Day 17

Phase 2: Channels Deep-Dive & hchan Architecture

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

Channels Deep-Dive: Buffered vs Unbuffered, The hchan Struct & State Matrix

ก้าวข้ามการใช้ Shared Memory & Explicit Locking สู่แก่นแท้ของ CSP (Communicating Sequential Processes): "Do not communicate by sharing memory; instead, share memory by communicating." ผ่าโครงสร้างระดับ Runtime Struct `hchan`, กลไก Zero-Copy Direct Stack Transfer, และตารางสถานะ Channel State Matrix ระดับป้องกันการแครชของ Core Banking Engine

Section 0: The "Why" & Core Vocabulary

ในโลก JVM / Spring Boot เมื่อเราต้องการส่งผ่านงานระหว่างสอง Thread แบบ Thread-Safe เรามักเลือกใช้ java.util.concurrent.ArrayBlockingQueue หรือ SynchronousQueue ซึ่งอยู่ภายใต้การควบคุมของ ReentrantLock, Condition (await/signal) และการจัดการ Heap Nodes ที่ซับซ้อน

The JVM BlockingQueue & Shared State Trap We Are Overcoming

1. Heavyweight OS Thread Blocking: เมื่อ Thread บน JVM บล็อกใน queue.take() ตัว OS Kernel จะต้องนำ Thread นั้นออกจาก CPU Run Queue ทำให้เกิด Context Switch ข้ามโหมด Kernel กินเวลา 1,000–2,000 นาโนวินาที
2. Memory Indirection & GC Pressure: ทุก Object ที่ถูกใส่เข้าไปใน Java Queue ต้องถูก Boxing หรือเก็บเป็น Object Reference บน Heap ทำให้เกิด Pointer Chasing และเพิ่มภาระ GC Sweep
3. Accidental Race Conditions: แม้ตัว Queue จะ Thread-safe แต่วิศวกร JVM มักเผลอส่ง Object Reference ที่ Mutable (สามารถถูกแก้ค่าข้างใน) เข้าไป ทำให้ Thread ทั้งสองแก้ข้อมูลตัวเดียวกัน เกิด Race Condition โดยไม่รู้ตัว!

The Go Solution: Channel ใน Go ถูกฝังลึกอยู่ใน Language Syntax และ Runtime โดยตรง เมื่อ Goroutine รอ Channel ระบบจะนำ Goroutine นั้นไปแขวนไว้ในคิว `sudog` ในระดับ User-Space โดยที่ OS Thread (M) ยังคงหยิบงานอื่นไปประมวลผลต่อได้ทันที ปราศจาก OS Context Switch!

คำศัพท์สถาปัตยกรรม (Go) นิยามและพฤติกรรมระดับ Runtime / Hardware แนวคิดเทียบเท่าใน Kotlin / JVM
Unbuffered Channel Channel ความจุ 0 (Rendezvous Point) ผู้ส่งและผู้รับต้องมาพบกันพร้อมกันพอดี มีการถ่ายโอนข้อมูลตรงจาก Stack สู่ Stack SynchronousQueue / Kotlin RendezvousChannel
Buffered Channel Channel ที่มี In-Memory Ring Buffer แบบ Circular Array ผู้ส่งจะไม่บล็อกตราบใดที่ Buffer ยังไม่เต็ม ArrayBlockingQueue(capacity) / Kotlin Channel(capacity)
hchan Struct Control Block ของ Channel บน Heap ประกอบด้วย Lock, Circular Buffer, Send/Recv Queue ของ Goroutines Internal state ของ AbstractQueue + ReentrantLock
sudog Struct ตัวแทนของ Goroutine ที่กำลังถูก Park (หลับ) อยู่ใน Waiting Queue ของ Channel AQS (AbstractQueuedSynchronizer) Node
Directional Channel การจำกัดสิทธิ์ใน Compile-Time ให้ฟังก์ชันทำได้เฉพาะรับ (<-chan) หรือส่ง (chan<-) เท่านั้น Kotlin ReceiveChannel<T> / SendChannel<T>

Inter-Thread Communication Cost: 1,000,000 Message Handoffs

Section 1: Go Syntax & Command Blueprint

Channel เป็นประเภทข้อมูลประเภท Reference Type (เบื้องหลังคือ Pointer ชี้ไปยัง hchan บน Heap) ดังนั้นการประกาศตัวแปรต้องสร้างผ่านคำสั่ง make() เสมอ หากไม่ใช้ make() จะได้ค่าเป็น nil channel

// Banking Ingestion Pipeline with Directional Channels & Range Close Pattern
package main

import (
    "fmt"
    "time"
)

type Transaction struct {
    TxID   string
    Amount int64 // หน่วยสตางค์ (Cents)
}

// 1. Send-Only Channel (chan<-): ผู้ส่งทำได้แค่ยัดข้อมูลเข้าและปิด Channel
func ingestTransactions(out chan<- Transaction) {
    txs := []Transaction{
        {"TX-1001", 50000},
        {"TX-1002", 125000},
        {"TX-1003", 9900},
    }

    for _, tx := range txs {
        out <- tx // ส่งข้อมูลเข้า Channel (หาก Buffer เต็ม จะบล็อกตรงนี้)
        fmt.Printf("[Ingest] Pushed %s\n", tx.TxID)
    }

    // 2. กฎเหล็ก: ผู้ส่ง (Producer) เป็นผู้ปิด Channel เสมอเมื่อส่งข้อมูลครบแล้ว!
    close(out)
}

// 3. Receive-Only Channel (<-chan): ผู้ประมวลผลทำได้แค่อ่านข้อมูลออก
func processSettlement(in <-chan Transaction, done chan<- bool) {
    // 4. for range บน Channel จะวนลูปอ่านเรื่อยๆ จนกว่า Channel จะถูก close()
    for tx := range in {
        fmt.Printf("[Settlement] Cleared %s for %d cents\n", tx.TxID, tx.Amount)
        time.Sleep(5 * time.Millisecond)
    }

    // แจ้ง Main Goroutine ว่าทำงานเสร็จสิ้นแล้ว
    done <- true
}

func main() {
    // สร้าง Buffered Channel ขนาด 10 รายการ ป้องกัน Ingestion Block ทันที
    txStream := make(chan Transaction, 10)
    done := make(chan bool)

    go ingestTransactions(txStream)
    go processSettlement(txStream, done)

    // รอรับสัญญาณเสร็จสิ้น (Unbuffered Channel Rendezvous)
    <-done
    fmt.Println("[Ledger] Daily settlement pipeline finished cleanly.")
}

รูปแบบไวยากรณ์ Channel ที่สำคัญใน Go:

ch := make(chan int)              // Unbuffered channel (Synchronous / Size 0)
ch := make(chan int, 100)        // Buffered channel (Circular queue ขนาด 100)
ch <- data                         // Send operator: ส่งค่า data เข้า channel
val := <-ch                        // Receive operator: รับค่าออกจาก channel
val, ok := <-ch                    // Comma-ok idiom: ok=true แปลว่าได้ข้อมูลจริง, ok=false แปลว่า channel ปิดแล้ว
close(ch)                          // ปิด channel แจ้งผู้รับว่าไม่มีข้อมูลใหม่อีก
  • len(ch): คืนค่าจำนวนข้อมูลที่ค้างอยู่ใน Buffer ปัจจุบัน
  • cap(ch): คืนค่าขนาดความจุสูงสุดของ Buffer (หากเป็น unbuffered จะคืนค่า 0)

Section 2: Memory Architecture & The `hchan` Anatomy

เมื่อคุณสั่ง make(chan T, cap) ตัว Go Runtime จะจัดสรร Struct บน Heap ชื่อว่า hchan (จากไฟล์ src/runtime/chan.go):

// src/runtime/chan.go (แกนกลางของ Channel ทุกตัวใน Go)
type hchan struct {
    qcount   uint           // 8 Bytes: จำนวนสมาชิกที่ค้างอยู่ใน buffer ปัจจุบัน
    dataqsiz uint           // 8 Bytes: ขนาดความจุ buffer สูงสุด (circular queue capacity)
    buf      unsafe.Pointer // 8 Bytes: Pointer ชี้ไปยัง Circular Array ใน RAM
    elemsize uint16         // 2 Bytes: ขนาดของแต่ละ element (ไบต์)
    closed   uint32         // 4 Bytes: สถานะปิด channel (0 = Open, 1 = Closed)
    elemtype *_type         // 8 Bytes: Type metadata ของข้อมูลที่ส่งผ่าน
    sendx    uint           // 8 Bytes: Buffer index สำหรับการส่ง (Send Index)
    recvx    uint           // 8 Bytes: Buffer index สำหรับการรับ (Receive Index)
    recvq    waitq          // 16 Bytes: คิวของ Goroutines ที่รอรับข้อมูล (Linked list ของ sudog)
    sendq    waitq          // 16 Bytes: คิวของ Goroutines ที่รอส่งข้อมูล (Linked list ของ sudog)
    lock     mutex          // 8 Bytes: Low-level spinlock ปกป้องฟิลด์ทั้งหมดใน hchan
}

type waitq struct {
    first *sudog // 8 Bytes: ชี้ไปหา Goroutine แรกที่หลับรออยู่
    last  *sudog // 8 Bytes: ชี้ไปหา Goroutine ท้ายสุดในคิว
}

type sudog struct {
    g           *g             // Goroutine ตัวจริงที่กำลังถูก Park
    elem        unsafe.Pointer // Pointer ชี้ไปยังตำแหน่งตัวแปรใน Stack เพื่อทำ Direct Transfer
    acquiretime int64
    releasetime int64
    ticket      uint32
    isSelect    bool
    next        *sudog         // Intrusive linked list
    prev        *sudog
    ...
}

Memory Layout: Circular Ring Buffer ภายใน `hchan`

เมื่อกำหนดความจุให้ Channel (Buffered) ตัว Runtime จะจัดสรรหน่วยความจำแบบแผ่นเดียวต่อเนื่อง (Contiguous memory) ต่อท้าย struct `hchan`:

+-----------------------------------------------------------------------------------------------+ | hchan Struct (Heap) | | lock | qcount=3 | dataqsiz=5 | sendx=3 | recvx=0 | closed=0 | recvq=empty | sendq=empty | +-----------------------------------------------------------------------------------------------+ | v (buf pointer ชี้ไปยัง Circular Memory Buffer) +-----------------------------------------------------------------------------------------------+ | Index 0 [Recv] | Index 1 | Index 2 | Index 3 [Send] | Index 4 (Free) | | "TX-1001" | "TX-1002" | "TX-1003" | (รอเขียนรอบถัดไป)| (ว่าง) | +-----------------------------------------------------------------------------------------------+ ^ ^ | | recvx sendx (จุดที่ผู้รับจะหยิบ) (จุดที่ผู้ส่งจะเติม) * เมื่อ sendx หรือ recvx วิ่งไปสุดขอบ (Index 4) จะวนลูป (Wrap-around) กลับมาที่ Index 0 เสมอ!

Section 3: Visual Workflow & Direct Stack-to-Stack Transfer

ความลับขั้นสูงสุดที่ทำให้ Unbuffered Channel ของ Go มี Latency ต่ำในระดับนาโนวินาที คือเทคนิค Direct Stack Copying ข้ามผ่าน Goroutines:

[Scenario: Goroutine 2 (G2) กำลังรอรับข้อมูล <-ch อยู่แล้ว แต่ยังไม่มีข้อมูล] 1. G2 ถูกถอดออกจาก CPU Core (gopark) และนำตัวแปร pointer &targetVar ไปฝากไว้ที่ sudog.elem ใน hchan.recvq 2. Goroutine 1 (G1) ทำการส่งข้อมูล ch <- txData +-----------------------------------------------+ | hchan: recvq -> [sudog: G2 (Pointer: 0x7ffd)] | +-----------------------------------------------+ | +----------------------------+----------------------------+ | | v v [G1 Stack Memory] [G2 Stack Memory] txData: {ID: "TX-99", Amt: 500} targetVar: (Uninitialized) | ^ | | +========= runtime.send() Direct Stack Memory Copy =======+ (คัดลอกข้าม Stack ตรงๆ ใน User Space! ไม่ผ่าน hchan.buf) 3. G1 สั่ง goready(G2): ปลุก G2 ให้กลับไปอยู่ใน Local Run Queue (LRQ) ของ Processor (P) 4. เมื่อ G2 ตื่นขึ้นมา ข้อมูลได้ถูกวางไว้ใน targetVar เรียบร้อยแล้วใน 0-Alloc!

สรุปกระบวนการส่งข้อมูล 3 ลำดับขั้นของ Channel Send:

  1. Direct Copy to Waiting Receiver: หากมี Goroutine หลับรออยู่ใน recvq ให้ลอกข้อมูลลง Stack ของตัวรับโดยตรง แล้วสั่งปลุกตัวรับทันที (Zero buffer copy)
  2. Write to Ring Buffer: หากไม่มีตัวรับรออยู่ แต่ Buffer ยังไม่เต็ม ให้คัดลอกข้อมูลลง hchan.buf[sendx] แล้วขยับ sendx = (sendx + 1) % dataqsiz
  3. Park Current Goroutine: หาก Buffer เต็ม (หรือเป็น unbuffered ที่ไม่มีคนรอ) ให้เอา Goroutine ตัวเองห่อเป็น sudog แขวนไว้ที่ sendq แล้วสั่ง gopark() สละ CPU ให้งานอื่นทันที

Section 4: Edge Cases, Hazards & The Channel State Matrix

นี่คือ "ตารางศักดิ์สิทธิ์ (The Holy Channel State Matrix)" ที่วิศวกร Go ระดับ Senior ทุกคนต้องจำให้แม่นยำ เพราะเป็นที่มาของ 90% ของบั๊กและโปรแกรมล่มใน Production:

การกระทำ (Operation) Nil Channel (var ch chan T) Open Channel (ch := make(...)) Closed Channel (close(ch))
Send (ch <- val) Block ถาวรตลอดกาล! (Deadlock) ส่งสำเร็จ (หรือบล็อกถ้า buffer เต็ม) PANIC ทันที! (send on closed channel)
Receive (<-ch) Block ถาวรตลอดกาล! (Deadlock) รับสำเร็จ (หรือบล็อกถ้า buffer ว่าง) คืนค่า Zero-value ทันที! (ไม่บล็อก)
Receive (val, ok := <-ch) Block ถาวรตลอดกาล! (Deadlock) คืนค่า (data, true) คืนค่า (Zero-value, false) ทันที
Close (close(ch)) PANIC ทันที! (close of nil channel) ปิดสำเร็จ (เปลี่ยนสถานะ closed=1) PANIC ทันที! (close of closed channel)

Hazard #1: The Producer-Closes Rule

ห้ามปิด Channel จากฝั่งผู้รับ (Receiver) เด็ดขาด และ ห้ามปิด Channel หากมีผู้ส่ง (Producers) หลายตัวขนานกัน เพราะหาก Producer ตัวอื่นยังพยายามส่งข้อมูลเข้า Channel ที่ถูกปิดไปแล้ว โปรแกรมจะเกิด Runtime Panic ทันที!
กฎเหล็ก: เฉพาะ Goroutine ที่เป็นเจ้าของหน้าที่ส่ง (Single Producer) เท่านั้นที่มีสิทธิ์เรียก close()

Hazard #2: Goroutine Leak via Blocked Channel

หากคุณเปิด Goroutine ขึ้นมาส่งข้อมูลเข้า Unbuffered Channel: go func() { ch <- result }() แต่ Main Goroutine ยกเลิกหรือเลิกรอรับข้อมูลแล้ว ตัว Goroutine ลูกจะ ถูกแขวนค้างอยู่ใน RAM ตลอดกาล (Goroutine Leak) และจะไม่ถูก Garbage Collector เก็บกวาดเด็ดขาด!
กฎเหล็ก: ใช้ Buffered Channel ขนาด 1 เสมอสำหรับงาน Fire-and-Forget หรือควบคุมผ่าน context.Context

Section 5: Knowledge Verification Quiz

16 Comprehensive Questions — Channels, hchan Internals & State Mechanics

160 Points Total

Section 6: Hands-on Engineering Labs

ฝึกฝนการเขียน Channel Coordination ที่ปลอดภัย ปราศจาก Data Race, Panic และ Deadlock ตามมาตรฐานระบบชำระเงิน

Lab 1: Directional Banking Ingestion Pipeline

โจทย์: เขียนฟังก์ชัน ProduceEvents(events []string, out chan<- string) วนลูปส่ง event แต่ละตัวเข้าสู่ out และเมื่อส่งครบทุกตัวแล้ว ต้องสั่ง close(out) เสมอ

Lab 2: Safe Channel Draining with Comma-Ok Idiom

โจทย์: เขียนฟังก์ชัน DrainAndSum(in <-chan int64) int64 โดยใช้ Infinite Loop ร่วมกับ val, ok := <-in หาก !ok (Channel ปิดแล้ว) ให้ทำการ break และคืนค่าผลรวมสะสมทั้งหมด

Lab 3: Non-Leaking Fire-and-Forget Dispatcher

โจทย์: เขียนฟังก์ชัน AsyncSettle(txID string) <-chan bool ที่สร้าง done := make(chan bool, 1) (Buffered ขนาด 1 เพื่อป้องกัน Goroutine Leak) แตก Goroutine ประมวลผลและส่ง true เข้า done แล้วคืนค่า done กลับทันที