← Week 4 Day 19

Phase 2: Channel Ownership & Streaming Pipelines

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

Channel Ownership, Directionality & High-Throughput Pipelines (Fan-In / Fan-Out)

เปลี่ยนผ่านจากการส่งข้อมูลแบบ Ad-hoc สู่สถาปัตยกรรมสตรีมมิ่งความเร็วสูงระดับอุตสาหกรรม: ทำความเข้าใจกฎเหล็ก Channel Ownership Model เพื่อตัดปัญหา Panic และ Deadlock อย่างถาวร, การใช้ Directional Channel Types (chan<- และ <-chan) เป็น Compile-Time Safety Guard, การออกแบบ Fan-Out / Fan-In Multiplexing Pipeline รองรับการขนานงานคำนวณธุรกรรมธนาคารและสัญญาณเซนเซอร์ RFID ระดับ Sub-millisecond Latency

Section 0: The "Why" & Core Vocabulary

ในโลก JVM / Spring Boot / Project Reactor เมื่อเราต้องการสร้าง Pipeline ประมวลผลแบบขนาน (เช่น รับข้อความธุรกรรมทางการเงิน ตรวจจับการทุจริต และบันทึกบัญชีแยกประเภท) สถาปัตยกรรมแบบดั้งเดิมมักใช้ ThreadPoolExecutor ร่วมกับ BlockingQueue หรือ Reactive Stream (Flux.parallel().runOn(...)):

// ตัวอย่าง Java Reactive Parallel Pipeline
Flux.fromIterable(transactions)
    .parallel(16) // แยก 16 worker threads (Fan-out)
    .runOn(Schedulers.parallel())
    .map(tx -> fraudRuleEngine.validate(tx))
    .sequential() // รวมผลลัพธ์กลับมาเส้นเดียว (Fan-in)

The JVM Thread-Pool & Reactive Queue Trap We Are Leaving Behind

1. Ownership Ambiguity: ใน Java เมื่อ Thread หนึ่งส่ง Object เข้าสู่ Queue ใครคือเจ้าของที่แท้จริง? บ่อยครั้งที่ Worker เผลอแก้ไขฟิลด์ใน Object ขณะที่ Producer ยังถือ Reference อยู่ จนเกิด Mutable State Race Condition
2. Heavyweight Queue Nodes & Object Boxing: ทุก Item ที่ส่งผ่าน LinkedBlockingQueue ต้องจอง Heap Node ใหม่ 1 ชิ้น (กิน 24–32 ไบต์) เพิ่มความถี่ให้ JVM Major GC สั่งหยุดระบบชั่วขณะ
3. Unhandled Shutdown Poisoning: การปิดระบบ (Shutdown) ของ ThreadPool ใน Java มักทิ้งค้างงานค้างท่อ (Orphaned Tasks) หรือทำให้เกิด RejectedExecutionException ระหว่างที่ Service พยายามดับเครื่อง

The Go Solution: Go กำหนดให้ Channel มีความชัดเจนในระดับ "Channel Ownership" ผู้สร้างเป็นผู้เดียวที่มีสิทธิ์ปิด Channel ผู้รับมีสิทธิ์แค่อ่าน และคอมไพเลอร์บังคับทิศทางด้วย Directional Channel (chan<- และ <-chan) ส่งตรงข้อมูลผ่าน Ring Buffer หรือ Direct Memory Transfer โดยไม่มี Object Wrapper แม้แต่ไบต์เดียว!

คำศัพท์สถาปัตยกรรม (Go) นิยามและพฤติกรรมระดับ Runtime / Hardware แนวคิดเทียบเท่าใน Kotlin / JVM
Channel Ownership กฎเกณฑ์สถาปัตยกรรมที่ระบุว่า Goroutine ที่สร้าง Channel จะเป็นเจ้าของแต่เพียงผู้เดียว: มีหน้าที่เขียน (Send), มอบสิทธิ์อ่าน (Provide Read-only View) และสั่งปิด (Close) Resource Ownership Pattern / Producer lifecycle management
Send-Only Channel (chan<- T) Type Constraint ในระดับ Compile-Time บังคับให้ฟังก์ชันทำได้เฉพาะส่งข้อมูลเข้าและปิด Channel เท่านั้น หากเผลออ่านจะคอมไพล์ไม่ผ่าน Kotlin SendChannel<T>
Receive-Only Channel (<-chan T) Type Constraint บังคับให้ฟังก์ชันทำได้เฉพาะอ่านข้อมูลออกเท่านั้น ห้ามส่งข้อมูลเข้า และห้ามเรียก close() เด็ดขาด Kotlin ReceiveChannel<T> / Java Publisher<T>
Fan-Out Pattern การเปิด Worker Goroutines หลายตัวพร้อมกัน เพื่อร่วมกันแย่งหยิบงาน (Competing Consumers) ออกจาก Channel เดียวเพื่อกระจายโหลดซีพียู ThreadPool Executor Worker Threads / Flux.parallel()
Fan-In Pattern การรวบรวมผลลัพธ์จากหลาย Channels ที่ Worker ประมวลผลเสร็จแล้ว กลับมารวมเป็น Output Channel เดียว (Multiplexing) Flux.merge() / CompletableFuture.allOf() collector
Backpressure via Buffer การใช้ขนาดความจุของ Buffered Channel (cap) เป็นตัวหน่วงจังหวะ หาก Consumer ประมวลผลไม่ทัน Buffer จะเต็มและสั่งให้ Producer หยุดรอ (Park) โดยอัตโนมัติ Reactive Streams Backpressure (request(n))

Throughput & Context-Switch Overhead (1,000,000 Items Pipeline)

Section 1: Go Syntax & Command Blueprint

ในภาษา Go การสร้าง Pipeline ทางการเงินหรือระบบประมวลผลเซนเซอร์ประกอบด้วย 3 ส่วนหลัก: Producer (Generator), Workers (Transform/Fan-Out), และ Multiplexer (Fan-In/Consumer) โดยควบคุมทิศทางอย่างเข้มงวด:

// Banking Fraud Scoring Pipeline: Fan-Out / Fan-In with Explicit Ownership
package main

import (
    "fmt"
    "sync"
    "time"
)

type PaymentTx struct {
    TxID   string
    Amount int64 // สตางค์
    Score  int   // Fraud Risk Score
}

// 1. Stage 1: Generator/Producer (Owner of txStream)
// คืนค่า <-chan PaymentTx (Receive-Only) ให้ผู้บริโภค ป้องกันไม่ให้ใครแอบส่งข้อมูลเข้าหรือปิด Channel ผิดจังหวะ
func GenerateTransactions(txs []PaymentTx) <-chan PaymentTx {
    out := make(chan PaymentTx, 100) // Buffer 100 รายการสำหรับ Smooth Ingestion

    go func() {
        defer close(out) // กฎเหล็ก: ผู้สร้างคือผู้ปิด Channel เสมอเมื่อผลิตของหมด!
        for _, tx := range txs {
            out <- tx
        }
    }()

    return out
}

// 2. Stage 2: Fan-Out Worker
// รับ Receive-Only เข้ามาคำนวณ และส่งผลลัพธ์ออกทาง Receive-Only ใหม่ที่ตนเป็นเจ้าของ
func RunFraudScorer(in <-chan PaymentTx) <-chan PaymentTx {
    out := make(chan PaymentTx)

    go func() {
        defer close(out)
        for tx := range in {
            // จำลองการคำนวณโมเดลความเสี่ยงทางการเงิน
            if tx.Amount > 10000000 { // เกิน 100,000 บาท
                tx.Score = 95
            } else {
                tx.Score = 10
            }
            out <- tx
        }
    }()

    return out
}

// 3. Stage 3: Fan-In Multiplexer
// รวบรวมหลาย Channels (จาก Worker หลายตัว) ให้ไหลกลับมารวมเป็น Channel เดียว
func FanIn(channels ...<-chan PaymentTx) <-chan PaymentTx {
    out := make(chan PaymentTx)
    var wg sync.WaitGroup
    wg.Add(len(channels))

    // เปิด Goroutine แยกฟังแต่ละ Channel แล้วส่งข้อมูลเข้า out
    for _, ch := range channels {
        go func(c <-chan PaymentTx) {
            defer wg.Done()
            for tx := range c {
                out <- tx
            }
        }(ch)
    }

    // เปิด Goroutine เฝ้ารอให้ทุก worker ปิดช่องทาง จึงทำการปิด out
    go func() {
        wg.Wait()
        close(out) // ปิด Fan-In Channel เมื่อทุกเส้นย่อยจบงาน
    }()

    return out
}

กฎเหล็ก 4 ประการของ Directional Channel ใน Go:

  1. Implicit Covariant Conversion: Bidirectional Channel (chan T) สามารถถูกแปลงเป็น Directional Channel (chan<- T หรือ <-chan T) ได้โดยอัตโนมัติเมื่อส่งเข้า Parameter ของฟังก์ชัน แต่ ไม่สามารถแปลงย้อนกลับได้!
  2. Receiver Cannot Close: คอมไพเลอร์ของ Go จะปฏิเสธการเรียกคำสั่ง close(ch) บนตัวแปรที่เป็น Receive-only (<-chan T) ทันทีตั้งแต่ Compile-Time ป้องกันความผิดพลาดของ Consumer ที่เผลอปิด Channel
  3. Producer Responsibility: ฟังก์ชันที่เป็นผู้สร้าง Channel ควรสร้าง Goroutine ขึ้นมาผลิตข้อมูลและสั่ง defer close() ภายในขอบเขตของตนเอง แล้วคืนค่าออกไปเป็น <-chan T
  4. Safe Loop Termination: คำสั่ง for item := range inChannel จะวนลูปอ่านข้อมูลและปลดบล็อกจบการทำงานโดยอัตโนมัติทันทีที่ inChannel ถูกสั่ง close() และดึงข้อมูลที่ค้างใน Buffer จนหมด

Section 2: Memory Architecture & Pipeline Dataflow

มาดูแผนผังหน่วยความจำระดับ Heap และ CPU Cores เมื่อข้อมูลธุรกรรมไหลผ่านสถาปัตยกรรม Fan-Out (กระจายงาน) และ Fan-In (รวมงาน):

[Stage 1: Producer] GenerateTransactions() ---> [txStream (hchan buffer: 100)] (Address: 0x1040) | +-------------------------+-------------------------+ | (Competing Consumers: แย่งอ่านข้อมูลจาก hchan.recvq เดียวกัน) | v v [Stage 2: Worker A (CPU Core 0)] [Stage 2: Worker B (CPU Core 1)] RunFraudScorer(txStream) RunFraudScorer(txStream) - Stack Frame G1: {TxID: "TX-1", Score: 95} - Stack Frame G2: {TxID: "TX-2", Score: 10} - Sends to: outA (0x2080) - Sends to: outB (0x30c0) | | +-------------------------+-------------------------+ | v [Stage 3: Fan-In Multiplexer (sync.WaitGroup)] FanIn(outA, outB) ---> [combinedStream (hchan)] (Address: 0x4100) | v [Stage 4: Consumer / Settlement Engine] for tx := range combinedStream { WriteLedgerJournal(tx) }

ทำไม Fan-Out ใน Go ถึงแทบไม่มี Memory Overhead เมื่อเทียบกับ JVM ThreadPool?

ใน JVM เมื่อคุณตั้งค่า ThreadPoolExecutor(16, 32, ...) ระบบปฏิบัติการจะต้องกันหน่วยความจำอย่างน้อย 16 MB ถึง 32 MB ทันทีสำหรับ Thread Stacks และการส่ง Object ผ่าน BlockingQueue ต้องจอง Heap Node ใหม่ทุกครั้ง

ในทางตรงกันข้าม Worker ใน Go แต่ละตัวคือ Goroutine ขนาดเริ่มต้นเพียง 2KB การเปิด Worker 50–100 ตัวเพื่อทำ Fan-Out ใช้ RAM รวมไม่ถึง 200KB! และเมื่อ Worker แย่งดึงข้อมูลจาก Channel ตัว Go Runtime จะประสานงานผ่าน Mutex ภายในของ hchan เพื่อส่งข้อมูลตรงข้าม Stack โดยไม่มี Object Allocation เลยแม้แต่น้อย

Section 3: Visual Workflow & Pipeline Teardown Mechanics

ในระบบประมวลผลระดับภารกิจวิกฤต (Mission-Critical Banking) สิ่งที่ท้าทายที่สุดไม่ใช่ตอนเปิด Pipeline แต่คือ "การดับเครื่องอย่างสมบูรณ์แบบ (Graceful Pipeline Teardown)" เพื่อไม่ให้มีข้อมูลค้างท่อและไม่เกิด Goroutine Leak:

[Step 1: Producer Finishes] --> ส่งข้อมูลตัวสุดท้ายเข้า inStream --> เรียกคำสั่ง close(inStream) --> inStream เปลี่ยนสถานะเป็น closed=1 [Step 2: Workers Drain & Terminate (Fan-Out)] --> Worker 1 และ Worker 2 ดึงข้อมูลที่เหลือจน Buffer ว่างเปล่า --> คำสั่ง for tx := range inStream พบว่า channel ปิดและว่าง -> หลุดออกจากลูปทันที! --> defer close(outA) และ defer close(outB) ทำงานตามลำดับ --> Goroutine ของ Worker ทั้งสองตัวสลายตัวออกจาก RAM คืนหน่วยความจำ 2KB ให้ระบบ [Step 3: Fan-In Coordinator Synchronization] --> Goroutine ผู้เฝ้า outA และ outB พบว่า channels ปลายทางปิดลง --> เรียก wg.Done() ครบ 2 ครั้ง --> wg.Wait() ปลดบล็อกใน Coordinator Goroutine --> เรียก close(combinedOut) เพื่อส่งสัญญาณปิดท่อใหญ่สุดท้าย [Step 4: Final Sink Clean Exit] --> Main Goroutine ที่รอ for tx := range combinedOut ดึงข้อมูลครบหมด --> จบลูปอย่างสง่างามโดยมีข้อมูลครบ 100% ปราศจาก Data Loss หรือ Goroutine ค้างในระบบ!

Section 4: Edge Cases, Hazards & Defensive Rules

Hazard #1: The Abandoned Consumer & Producer Goroutine Leak

สมมติว่า Consumer ยกเลิกการอ่านข้อมูลกลางคัน (เช่น พบ Error แถวที่ 5 หรือตัด Timeout) แล้วออกจากฟังก์ชันไป: หาก Producer ยังคงพยายามส่งข้อมูลเข้า Channel ที่ไม่มีใครอ่าน: out <- nextRecord // บล็อกค้างตลอดกาล! ตัว Producer Goroutine จะ ถูกแขวนค้างใน RAM ตลอดไป และจะไม่ถูก Garbage Collector เก็บกวาดเด็ดขาด กลายเป็น Goroutine Leak สะสมจนเซิร์ฟเวอร์ล่ม!
กฎเหล็ก: ส่งสัญญาณยกเลิกด้วย context.Context หรือ done <-chan struct{} เข้าไปใน Producer เสมอ และใช้ select ดักฟัง case <-ctx.Done(): return

Hazard #2: The Multiple Producers Close Panic

หากคุณเปิด Worker 5 ตัวเพื่อประมวลผล และให้ Worker ทุกตัวพยายามส่งผลลัพธ์เข้า resultCh เดียวกัน: ห้ามให้ Worker ตัวใดตัวหนึ่งสั่ง close(resultCh) เด็ดขาด! เพราะทันทีที่ Worker ตัวแรกสั่งปิด หาก Worker ตัวที่สองยังพยายามส่งข้อมูล resultCh <- data จะเกิด Runtime Panic: send on closed channel ทันที!
กฎเหล็ก: ปิด Channel ที่มี Producers หลายตัวผ่าน Fan-In Multiplexer ร่วมกับ sync.WaitGroup หรือใช้ sync.Once เท่านั้น

The Senior Architect Idiom: Explicit Cancellation via Done Channel

ในระบบ Ingestion ความเร็วสูง ทุก Stage ของ Pipeline ควรรับช่องทางสัญญาณ done <-chan struct{} เข้าไปด้วย เพื่อให้ Consumer ต้นทางสามารถสั่งปิดสวิตช์ท่อส่งทั้งหมดพร้อมกันใน 0 ไมโครวินาที:

func GeneratorWithCancel(done <-chan struct{}, items []int) <-chan int { out := make(chan int) go func() { defer close(out) for _, item := range items { select { case out <- item: case <-done: // หาก Consumer เลิกรอ สละสิทธิ์และคืน Goroutine ทันที! return } } }() return out }

Section 5: Knowledge Verification Quiz

16 Comprehensive Questions — Channel Ownership, Directionality & Pipelines

160 Points Total

Section 6: Hands-on Engineering Labs

ฝึกฝนการเขียน Concurrent Streaming Pipelines, Directional Guards และ Fan-Out/Fan-In Coordination ตามมาตรฐานวิศวกรรมธนาคาร

Lab 1: Directional Generator with Explicit Ownership

โจทย์: เขียนฟังก์ชัน EmitTransactions(txIDs []string) <-chan string โดยสร้าง Channel ภายใน, แตก Goroutine ส่งข้อมูลทุกตัวใน txIDs เข้า Channel, สั่ง close เมื่อส่งครบ และคืนค่า Channel กลับไปเป็นแบบ Receive-Only (<-chan string)

Lab 2: Concurrent Fan-Out Transaction Enricher

โจทย์: เขียนฟังก์ชัน EnrichWorker(in <-chan int) <-chan int โดยสร้าง Channel ขาออก, แตก Goroutine วนลูป for val := range in นำ val * 10 ส่งเข้า Channel ขาออก และสั่ง defer close Channel ขาออกเมื่อลูปสิ้นสุด

Lab 3: Thread-Safe Fan-In Multiplexer

โจทย์: เขียนฟังก์ชัน MergeStreams(ch1 <-chan string, ch2 <-chan string) <-chan string โดยสร้าง out := make(chan string) และใช้ sync.WaitGroup จัดการรอให้ทั้งสองสตรีมส่งข้อมูลเข้า out จนหมด ก่อนแตก Goroutine สั่ง wg.Wait() และ close(out)