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
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:
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):
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`:
Section 3: Visual Workflow & Direct Stack-to-Stack Transfer
ความลับขั้นสูงสุดที่ทำให้ Unbuffered Channel ของ Go มี Latency ต่ำในระดับนาโนวินาที คือเทคนิค Direct Stack Copying ข้ามผ่าน Goroutines:
สรุปกระบวนการส่งข้อมูล 3 ลำดับขั้นของ Channel Send:
- Direct Copy to Waiting Receiver: หากมี Goroutine หลับรออยู่ใน
recvqให้ลอกข้อมูลลง Stack ของตัวรับโดยตรง แล้วสั่งปลุกตัวรับทันที (Zero buffer copy) - Write to Ring Buffer: หากไม่มีตัวรับรออยู่ แต่ Buffer ยังไม่เต็ม ให้คัดลอกข้อมูลลง
hchan.buf[sendx]แล้วขยับsendx = (sendx + 1) % dataqsiz - 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
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 กลับทันที