← Week 4 Day 18

Phase 2: Channel Multiplexing & Runtime selectgo

Quiz: 0/160 pts
Phase 2: Concurrency & Runtime Mastery

Channel Multiplexing with select, Non-Blocking I/O & runtime.selectgo

ก้าวข้ามการรอ Channel ตัวเดียว สู่การสลับฟังหลายเหตุการณ์พร้อมกันแบบ Atomic Multiplexer: เจาะลึกอัลกอริทึม `runtime.selectgo`, การสุ่มแบบ Pseudo-Random Selection เพื่อป้องกัน Starvation, การจัดเรียง Lock Order ตาม Memory Address เพื่อป้องกัน Deadlock, กับดัก Memory Leak ร้ายแรงของ time.After(), และเคล็ดลับขั้นสูง Nil Channel Deactivation Trick สำหรับระบบ Real-time Ingestion Engine

Section 0: The "Why" & Core Vocabulary

ในโลก JVM / Spring WebFlux / Project Reactor เมื่อเราต้องการรอผลลัพธ์จากหลาย Service หรือรอนานสุดไม่เกิน Timeout (เช่น รับข้อความจาก Kafka หรือรอคำตอบจาก Core Banking พร้อมตัด Timeout ใน 50ms) เรามักใช้โอเปอเรเตอร์กลุ่ม Reactive เช่น:

// ตัวอย่าง Reactive JVM (Kotlin / Project Reactor)
Mono.firstWithValue(paymentResponseMono, timeoutMono)
    .subscribeOn(Schedulers.boundedElastic())
    .timeout(Duration.ofMillis(50))

The Reactive Stream & CompletableFuture Trap We Are Overcoming

1. Object Allocations Everywhere: ทุกการเรียก Mono.firstWithValue หรือ CompletableFuture.anyOf จะสร้าง Subscription, Subscriber, และ Callback Nodes หลายสิบตัวลงบน Heap
2. Thread Context Hopping: การส่งต่องานระหว่าง Schedulers (Event Loop ↔ Worker Thread) ทำให้เกิด Thread Hopping และบ่อยครั้งเกิด Unhandled Cancellation Race Condition
3. Loss of Synchronous Readability: โค้ดกลายเป็น Callback Chain ลึก หรือกลายเป็น Reactive Monad ที่ Debug ด้วย Stack Trace แทบไม่ได้!

The Go Solution: Go ฝังคีย์เวิร์ด select ลงในระดับไวยากรณ์ภาษาโดยตรง เมื่อ Goroutine เข้าสู่คำสั่ง select ตัว Go Runtime จะคำนวณและประเมิน Channel ทุกตัวแบบ Atomic ใน User-Space หากไม่มี Channel ใดพร้อม Goroutine จะถูกสั่ง Park ไปแขวนบน Channel ทุกตัวในคราวเดียว โดยไม่มี OS Thread Hop เลยแม้แต่น้อย!

คำศัพท์สถาปัตยกรรม (Go) นิยามและพฤติกรรมระดับ Runtime / Hardware แนวคิดเทียบเท่าใน Kotlin / JVM
select Multiplexer Control block สำหรับรอ I/O บน Channel หลายตัวพร้อมกัน ประเมินผลแบบ Atomic และปลุก Goroutine เมื่อมีอย่างน้อย 1 case พร้อม CompletableFuture.anyOf() / Reactor Mono.firstWithValue()
default Case (Non-blocking) สาขาที่ทำงานทันทีหากไม่มี Channel ใดใน select พร้อมทำงาน ช่วยป้องกันไม่ให้ Goroutine ถูก Park (Zero Wait) queue.poll() / Non-blocking tryAcquire()
Pseudo-Random Selection กลไกสุ่มลำดับการตรวจสอบ Case หากมีหลาย Channel พร้อมส่ง/รับในเวลาเดียวกัน เพื่อป้องกัน Starvation (ลำเอียง) ไม่มีใน Java (JVM มักเช็กตามลำดับ Array แบบ Sequential)
runtime.scase Struct ใน Go Runtime เก็บข้อมูลของแต่ละ case ใน select (ชี้ไปยัง hchan, ตัวแปรข้อมูล, และชนิด operation) Internal Node ของ AQS หรือ Reactor Composite Subscription
Lock Ordering by Address การจัดเรียงลำดับการ Lock hchan.lock ตาม Memory Address จากน้อยไปมาก เพื่อป้องกัน Deadlock (ABBA Lock Trap) Ordered Multi-Locking (ต้องเขียนเองใน Java ผ่าน System.identityHashCode)

Multi-Event Multiplexing Latency & Memory Allocation (1,000,000 Decisions)

Section 1: Go Syntax & Command Blueprint

ไวยากรณ์ select ดูคล้ายคำสั่ง switch แต่ ทุก case จะต้องเป็นการกระทำบน Channel เท่านั้น (ส่งข้อมูลเข้า หรืออ่านข้อมูลออก):

// Banking Payment Gateway Gateway Router with Timeout & Non-Blocking Fallback
package main

import (
    "context"
    "fmt"
    "time"
)

type PaymentResult struct {
    TxID   string
    Status string
}

// MultiplexPaymentListener: ฟังผลลัพธ์จาก Primary Channel, Secondary Channel หรือ Timeout
func MultiplexPaymentListener(ctx context.Context, primary <-chan PaymentResult, secondary <-chan PaymentResult) {
    // 1. สร้าง Reusable Timer เพื่อป้องกัน Memory Leak จาก time.After()
    timeoutTimer := time.NewTimer(100 * time.Millisecond)
    defer timeoutTimer.Stop()

    for {
        select {
        case tx, ok := <-primary:
            if !ok {
                fmt.Println("[Listener] Primary gateway closed. Disabling branch...")
                primary = nil // 2. Nil Channel Trick: ปิดการทำงานของ case นี้ทันที!
                continue
            }
            fmt.Printf("[Primary Gateway] Settled: %s (%s)\n", tx.TxID, tx.Status)
            return

        case tx, ok := <-secondary:
            if !ok {
                fmt.Println("[Listener] Secondary gateway closed. Disabling branch...")
                secondary = nil // 2. Nil Channel Trick
                continue
            }
            fmt.Printf("[Secondary Gateway] Settled: %s (%s)\n", tx.TxID, tx.Status)
            return

        case <-timeoutTimer.C:
            fmt.Println("[Timeout] Settle decision exceeded 100ms threshold! Triggering Circuit Breaker.")
            return

        case <-ctx.Done():
            fmt.Println("[Cancellation] Upstream context cancelled. Aborting.")
            return

        default:
            // 3. Non-blocking branch: หากไม่มี channel ใดพร้อม จะกระโดดมาที่นี่ทันทีโดยไม่บล็อก
            // มักใช้ในการตรวจเช็กแบบ Polling หรือ Fire-and-Forget
            // ข้อควรระวัง: หากใส่ default ใน for loop จะกลายเป็น Busy-Wait กิน CPU 100% ทันที!
            time.Sleep(10 * time.Millisecond)
        }
    }
}

กฎเหล็ก 4 ประการของไวยากรณ์ `select` ใน Go:

  1. Pseudo-Random Execution: หากมี 2 cases หรือมากกว่าพร้อมส่ง/รับในเสี้ยววินาทีเดียวกัน Go จะ สุ่มเลือก (Uniform Random) ว่าจะรัน case ไหน ไม่ได้รันจากบนลงล่างแบบ switch ทั่วไป!
  2. Blocking without Default: หากไม่มี case ใดพร้อมเลย และไม่มีบล็อก default: Goroutine ตัวนั้นจะถูกจับหลับ (Park) ทันทีจนกว่าจะมี Channel ตัวใดตัวหนึ่งพร้อม
  3. Non-Blocking with Default: หากมี default: และไม่มี Channel ใดพร้อม Go จะข้ามการ Park และเข้าไปรันบล็อก default: ทันทีใน 0 นาโนวินาที
  4. Empty Select Deadlock: คำสั่ง select {} ที่ไม่มี case ใดๆ เลย จะทำให้ Goroutine นั้นบล็อกค้างไปตลอดกาล (Permanent Sleep)

Section 2: Memory Architecture & The `runtime.selectgo` Anatomy

เมื่อคอมไพเลอร์พบคำสั่ง select ที่มี Channel มากกว่า 1 ตัว มันจะแปลงคำสั่งให้กลายเป็นการเรียกฟังก์ชันภายใน Go Runtime ชื่อ runtime.selectgo() (จากไฟล์ src/runtime/select.go):

// src/runtime/select.go (โครงสร้างข้อมูลของแต่ละ Case ใน select)
type scase struct {
    c    *hchan         // 8 Bytes: Pointer ชี้ไปยัง Channel ตัวจริงบน Heap
    elem unsafe.Pointer // 8 Bytes: Pointer ชี้ไปยัง Buffer ใน Stack ของ Goroutine สำหรับรับ/ส่ง
    kind uint16         // 2 Bytes: ประเภท Case (caseNil, caseRecv, caseSend, caseDefault)
    _    [6]byte        // 6 Bytes: Compiler Padding ให้ครบ 8 Bytes Alignment
}

// พารามิเตอร์หลักที่ถูกส่งเข้า runtime.selectgo
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (chosen int, ok bool) {
    // cas0: Array ของ scase ทั้งหมดใน select บล็อกนั้น
    // pollorder: Array ขนาดเท่าจำนวน cases เก็บ index ที่ถูก Shuffle แบบสุ่ม
    // lockorder: Array ขนาดเท่าจำนวน cases เก็บ index ที่จัดเรียงตาม Memory Address ของ hchan
    ...
}

The Dual-Array Architecture: Pollorder vs Lockorder

ทำไม Go ต้องใช้อาร์เรย์ 2 ชุดในการประมวลผลคำสั่ง select? มาดูโครงสร้างระดับ Memory Address:

สมมติมี 3 Channels: chA ที่ Address 0x2000 (Case 0) chB ที่ Address 0x1000 (Case 1) chC ที่ Address 0x3000 (Case 2) +-----------------------------------------------------------------------------------------------+ | 1. pollorder (ลำดับการสุ่มตรวจ): [ 1, 0, 2 ] | | - สุ่มลำดับด้วย Pseudo-Random Number Generator เพื่อให้ทุก Case มีโอกาสถูกเลือกเท่าเทียมกัน| | - ป้องกันปัญหา Starvation (เช่น Case แรกกินงานหมด Case ล่างไม่ได้รัน) | +-----------------------------------------------------------------------------------------------+ | 2. lockorder (ลำดับการ Lock): [ 1, 0, 2 ] -> จัดเรียงตาม Pointer Address: 0x1000 < 0x2000 < 0x3000| | - กฎเหล็ก: ลำดับการ Lock Mutex ของ hchan ทุกตัว จะต้อง Lock จาก Address น้อยไปหามากเสมอ! | | - ป้องกัน Deadlock แบบ ABBA: หาก Goroutine 1 Lock (A แล้ว B) ขณะที่ Goroutine 2 Lock (B แล้ว A)| +-----------------------------------------------------------------------------------------------+

Section 3: Visual Workflow & The selectgo Lifecycle

เมื่อ Goroutine ของคุณวิ่งมาถึงบล็อก select ตัว Runtime จะปฏิบัติตาม 4-Phase Execution Pipeline ที่แม่นยำระดับนาโนวินาที:

[Phase 1: Shuffle & Sort] --> คำนวณ pollorder (Random shuffle ด้วย fastrandn) --> คำนวณ lockorder (HeapSort ตาม hchan memory address) [Phase 2: Fast Lock & Check (Can we run immediately?)] --> ล็อก hchan ทุกตัวตาม lockorder --> วนลูปตรวจตาม pollorder: - มี Channel ใดพร้อมส่ง/รับทันทีหรือไม่? (Buffer ว่าง/เต็ม หรือมี sudog รอ) --> หากเจอ Case ที่พร้อม: - ทำการ Send / Receive ข้อมูลทันที - ปลดล็อก hchan ทุกตัวตาม lockorder - RETURN: กระโดดออกจาก select ไปรันโค้ดใน case นั้น! [Phase 3: Default Check (Non-blocking branch)] --> หากไม่มี Channel ใดพร้อมเลย แต่มี "default:" อยู่ในบล็อก: - ปลดล็อก hchan ทุกตัวทันที - RETURN: กระโดดไปรันโค้ดใน default! (ไม่เกิดการ Park) [Phase 4: Enqueue & Park (Going to Sleep)] --> ไม่มี Channel ใดพร้อม และไม่มี default: --> สร้าง struct sudog ตัวแทน Goroutine ปัจจุบัน --> นำ sudog ไปแขวนไว้ใน waitq (recvq หรือ sendq) ของ "ทุกๆ Channel" ใน select! --> ปลดล็อก hchan ทุกตัว --> เรียกคำสั่ง gopark() -> Goroutine สละ CPU และหลับรอสัญญาณ... [Wakeup: One Channel Becomes Ready] --> มี Goroutine อื่นส่งข้อมูลเข้า Channel ตัวใดตัวหนึ่งใน select --> Goroutine ปัจจุบันถูกปลุก (goready) --> เข้าล็อก hchan ทุกตัวตาม lockorder อีกครั้ง --> ดึง sudog ของตัวเองออกจาก waitq ของ Channel "ตัวอื่นๆ ทั้งหมด" ที่เหลือ! --> ปลดล็อกทั้งหมด และรันบล็อก case ที่สำเร็จ

Section 4: Edge Cases, Hazards & The Nil Channel Trick

Hazard #1: The `time.After()` Memory Leak in Hot Loops

หลายคนคุ้นเคยกับการเขียน Timeout ใน select แบบนี้: select { case <-ch: ... case <-time.After(5 * time.Second): ... } อันตรายระดับ Production: ทุกครั้งที่ลูปวนมารัน time.After() ตัว Runtime จะจัดสรร time.Timer ใหม่ลงบน Heap 1 ตัวเสมอ! หากมีข้อความวิ่งเข้า ch รวดเร็วตลอดเวลา ตัว Timer 5 วินาทีจะไม่ถูกปลุก และ จะไม่ถูก Garbage Collector เก็บกวาดจนกว่าจะครบ 5 วินาที ส่งผลให้ Timer สะสมหลักแสนตัวใน RAM เกิด Memory Leak และ CPU Spike จาก Timer Heap!
กฎเหล็ก: ในลูปแบบ Long-running ให้ใช้ timer := time.NewTimer() และเรียก timer.Reset() ภายนอกลูปเสมอ

Hazard #2: The Busy-Wait CPU 100% Trap

หากคุณใส่ default: ไว้ใน select ภายใต้ for {} loop ที่ไม่มีการหน่วงเวลาหรือไม่มีเงื่อนไขหยุดพัก: for { select { case v := <-ch: ... default: } } ตัว Goroutine จะวิ่งวนลูปเช็กด้วยความเร็วแสง ทำให้ Core ซีพียูวิ่งชนเพดาน 100% CPU Utilization ทันที แม้จะไม่มีข้อมูลส่งเข้ามาเลยก็ตาม!
กฎเหล็ก: ใช้ default: เฉพาะเมื่อต้องการ Non-blocking check แบบครั้งเดียว (Fire-and-Forget) หากต้องการรอข้อมูล ให้ตัด default: ออกเพื่อให้ Goroutine ยอม Park หลับรอ

The Senior Architect Idiom: The Nil Channel Deactivation Trick

จำกฎศักดิ์สิทธิ์จาก Day 17 ได้หรือไม่: "Send หรือ Receive บน Nil Channel จะบล็อกค้างตลอดกาล"
ในคำสั่ง select ข้อกำหนดของภาษา Go ระบุไว้ว่า: หาก Case ใดกำลังฟัง Nil Channel อยู่ Case นั้นจะถูกปิดการทำงาน (Disable) ไปโดยสมบูรณ์ และจะไม่มีวันถูกเลือกเด็ดขาด!

// ต้องการอ่านข้อมูลจาก ch1 และ ch2 จนกว่าทั้งคู่จะปิด โดยไม่ต้องหลุดลูปก่อนเวลา for ch1 != nil || ch2 != nil { select { case v, ok := <-ch1: if !ok { ch1 = nil // ปิดสวิตช์ case นี้ทันที! select จะไม่สนใจ ch1 อีกต่อไป continue } process(v) case v, ok := <-ch2: if !ok { ch2 = nil // ปิดสวิตช์ case นี้ทันที! continue } process(v) } } // เมื่อทั้ง ch1 และ ch2 กลายเป็น nil ลูป for จะสิ้นสุดลงอย่างสง่างามโดยไม่เกิด Busy-wait หรือ Panic!

Section 5: Knowledge Verification Quiz

16 Comprehensive Questions — selectgo Mechanics, Non-blocking I/O & Hazards

160 Points Total

Section 6: Hands-on Engineering Labs

ฝึกฝนการเขียน Concurrency Multiplexing ป้องกัน Deadlock, CPU Spikes และ Memory Leaks ตามมาตรฐานระบบประมวลผลความเร็วสูง

Lab 1: Non-Blocking High-Frequency Event Dropper

โจทย์: เขียนฟังก์ชัน TryPushEvent(ch chan<- int, eventID int) bool โดยใช้ select ร่วมกับ default หากส่งข้อมูลเข้า ch <- eventID ได้ทันทีให้คืนค่า true หาก Buffer เต็มและไม่สามารถส่งได้ทันที ให้เข้าบล็อก default: แล้วคืนค่า false (Zero Waiting)

Lab 2: Leak-Free Banking Transaction Timeout

โจทย์: เขียนฟังก์ชัน AwaitTransaction(resCh <-chan string, timeout time.Duration) (string, error) โดยสร้าง timer := time.NewTimer(timeout) และเรียก defer timer.Stop() เพื่อป้องกัน Timer Memory Leak หากได้รับผลลัพธ์ก่อน ให้คืนค่า (res, nil) หากหมดเวลาก่อน ให้คืนค่า ("", errors.New("timeout"))

Lab 3: Nil Channel Dynamic Stream Deactivator

โจทย์: เขียนฟังก์ชัน DrainTwoStreams(ch1 <-chan int, ch2 <-chan int) int วนลูปอ่านข้อมูลจากทั้งสอง Channel จนกว่าทั้งคู่จะปิด (เมื่อ channel ใดปิด ให้กำหนดตัวแปรนั้นเป็น nil) สะสมผลบวกของค่าทั้งหมดแล้วคืนค่าผลรวมออกมา