Day 12: Standard Library I/O Mastery
Quiz: 0/16 | Score: 0 pts

Day 12: Standard Library I/O Mastery

Dissecting io.Reader, io.Writer, bufio, Zero-Allocation Streaming, and OS Syscall Minimization

Section 0: The "Why" & Core Vocabulary

ในโลก JVM / Spring Boot เมื่อเราต้องอ่านไฟล์ธุรกรรมของธนาคารขนาด 20GB หรืออ่าน Event log จากเครือข่าย เรามักเรียกใช้เมธอดอย่าง Files.readAllLines() หรือห่อหุ้ม Stream หลายชั้น:

// โค้ดสไตล์ Java/Kotlin ที่สร้างขยะมหาศาลบน Heap
val reader = BufferedReader(InputStreamReader(FileInputStream(file), StandardCharsets.UTF_8))
val lines = reader.readLines() // พังทันที OOM: โหลดข้อมูล 20GB ขึ้น RAM กลายเป็น String Objects พันล้านตัว!
The JVM Trap: Object Allocation Explosion & Context Switching Overhead

ใน JVM การแปลงข้อมูลดิบ (Raw Bytes) จาก Network Socket หรือ Disk ให้กลายเป็น java.lang.String ทุกๆ บรรทัด จะสร้าง Object Header ขนาด 16 ไบต์ + Reference Pointer 8 ไบต์ + Char Array ใน Heap สำหรับทุกๆ ค่า! ส่งผลให้ Garbage Collector ต้องทำงานหนัก เกิด Stop-The-World (STW) Pauses ที่ทำลาย SLA ความเร็วระดับ Sub-millisecond ของระบบธนาคาร

นอกจากนี้ การอ่านข้อมูลทีละไบต์ผ่าน InputStream.read() โดยไม่ทำ Buffering จะสั่งยิงคำสั่ง OS System Call (read syscall) สลับบริบทระหว่าง User Space และ Kernel Space ถี่เกินไป ทำให้ CPU สูญเสียประสิทธิภาพจาก Context Switching มหาศาล

Go ออกแบบระบบ I/O บนปรัชญา "Caller Allocates, Callee Populates": ผู้เรียก (Caller) จอง Slice ของ Byte เพียงชิ้นเดียว แล้วส่ง Pointer ของ Slice นั้นเข้าไปให้ Kernel หรือ Driver นำข้อมูลมาหยอดลงใน Buffer เดิมซ้ำๆ ทำให้การประมวลผลไฟล์ขนาด 100GB ใช้ Memory ในการทำงานคงที่เพียงแค่ไม่กี่ Kilobytes (O(1) Memory Footprint)!

คำศัพท์ทางเทคนิค (Go) นิยามและพฤติกรรมระดับ Runtime / Hardware แนวคิดเทียบเท่าใน Kotlin / JVM
io.Reader Single-method interface: Read(p []byte) (n int, err error) ดึงข้อมูลจาก Data Source มาใส่ใน Buffer p java.io.InputStream (แต่ Go ไม่คืน byte เดี่ยว แต่บังคับให้อ่านใส่ Slice เสมอ)
io.Writer Single-method interface: Write(p []byte) (n int, err error) ส่งข้อมูลจาก Slice p ออกไปยังปลายทาง java.io.OutputStream
io.EOF Sentinel Error มาตรฐาน (End Of File) บ่งบอกว่า Data Stream สิ้นสุดลงแล้ว ไม่ใช่ Error ร้ายแรงแต่เป็นสัญญาณสิ้นสุดข้อมูล คืนค่า -1 ใน InputStream.read() หรือ EOFException
bufio.Reader Struct ห่อหุ้ม io.Reader โดยสร้าง In-Memory Buffer (ค่าตั้งต้น 4,096 Bytes) ลดความถี่ของ OS System Calls java.io.BufferedReader
bufio.Scanner High-level Tokenizer สำหรับแยกอ่านข้อมูลเป็นท่อนๆ (เช่น ทีละบรรทัด หรือทีละคำ) โดย Reuse Internal Buffer java.util.Scanner (แต่ Go เร็วกว่าและใช้ Memory น้อยกว่ามาก)
io.Copy ฟังก์ชันเชื่อมต่อ Reader ตรงไปยัง Writer แบบ Direct Streaming พร้อมกลไก Zero-Copy (เช่น splice / sendfile syscalls) InputStream.transferTo(OutputStream) ใน Java 9+

Section 1: Go Syntax & Command Blueprint

ใน Standard Library แพ็กเกจ io ถูกออกแบบมาให้เรียบง่ายที่สุด แต่มีกฎทางไวยากรณ์และสัญญาระดับ Contract ที่เข้มงวดมาก:

1.1 The Golden Contract of io.Reader

// นิยามใน src/io/io.go
type Reader interface {
    Read(p []byte) (n int, err error)
}
The io.Reader Contract: กฎเหล็กที่วิศวกรทุกคนต้องจำขึ้นใจ
  • n คือจำนวนไบต์ที่อ่านได้จริง (ตั้งแต่ 0 <= n <= len(p))
  • หากมีข้อมูลบางส่วนอ่านเข้ามาได้ (เช่น ได้ 10 ไบต์) แต่ถึงจุดสิ้นสุดสตรีมพอดี Read อาจคืนค่า n = 10, err = io.EOF หรือ n = 10, err = nil ก็ได้!
  • กฎเหล็ก: ต้องประมวลผลข้อมูลจำนวน n ไบต์แรกเสมอ ก่อน ที่จะตรวจสอบ err หากตรวจสอบ err != nil แล้ว return ทันที ข้อมูล n ไบต์สุดท้ายนั้นจะตกหล่น (Data Loss)!

1.2 การใช้งาน io.Reader แบบ Low-Level (Zero-Heap Loop)

package main

import (
    "errors"
    "fmt"
    "io"
    "os"
)

func StreamLedgerFile(filePath string) error {
    file, err := os.Open(filePath) // os.File implements io.Reader
    if err != nil {
        return fmt.Errorf("failed to open ledger: %w", err)
    }
    defer file.Close() // ปิด File Descriptor เสมอเมื่อจบฟังก์ชัน

    // จอง Buffer ขนาด 4KB บน Stack/Heap เพียงครั้งเดียวตลอดการอ่านไฟล์ 50GB!
    buf := make([]byte, 4096)

    for {
        n, err := file.Read(buf) // Callee เอาข้อมูลมาหยอดใส่ buf[0:n]
        if n > 0 {
            // ประมวลผลเฉพาะไบต์ที่อ่านได้จริง buf[:n] เท่านั้น
            chunk := buf[:n]
            processTransactionBytes(chunk)
        }

        if err != nil {
            if errors.Is(err, io.EOF) {
                // จบไฟล์อย่างสมบูรณ์แบบ
                break
            }
            // เกิด I/O Error จริง (เช่น Disk Fault, Bad Sector)
            return fmt.Errorf("read error: %w", err)
        }
    }
    return nil
}

func processTransactionBytes(b []byte) {
    // ประมวลผลไบต์ดิบ
}

1.3 การใช้งาน bufio.Scanner สำหรับประมวลผลทีละบรรทัด

หากต้องการอ่านไฟล์ธุรกรรมแบบ Line-by-Line (เช่น CSV หรือ JSON Lines) การใช้ bufio.Scanner คือทางเลือกที่เป็น Idiomatic Go ที่สุด:

package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func ScanRaceBibs(r io.Reader) error {
    // ห่อหุ้ม Reader ด้วย bufio.Scanner
    scanner := bufio.NewScanner(r)

    // วนลูปอ่านทีละ Token (ค่าเริ่มต้นคือแบ่งด้วย Newline: '\n')
    for scanner.Scan() {
        // scanner.Bytes() คืน Slice ชี้ตรงไปยัง Internal Buffer (Zero-Copy!)
        // ระวัง: ค่าใน Bytes() จะเปลี่ยนไปในรอบ Scan() ถัดไป
        lineBytes := scanner.Bytes()
        
        // หรือดึงเป็น string (มีการ allocate string บน Heap)
        line := scanner.Text()
        fmt.Println("Scanned BIB record:", line)
    }

    // ตรวจสอบ Error หลังจบลูปเสมอ (Scanner จะกลืน io.EOF ไว้ แต่จะเก็บ I/O Error อื่นไว้ที่ .Err())
    if err := scanner.Err(); err != nil {
        return fmt.Errorf("scanner error: %w", err)
    }
    return nil
}

1.4 การเขียนข้อมูลด้วย io.Writer และ bufio.Writer

io.Writer มีเมธอดเดียวคือ Write(p []byte) (n int, err error) แต่หากเขียนไฟล์บ่อยๆ ทีละ 10–20 ไบต์ ระบบจะยิง System Call รัวๆ เราจึงต้องหุ้มด้วย bufio.NewWriter และ ต้องสั่ง Flush() เสมอ:

func WriteAuditLog(w io.Writer, records []string) error {
    // สร้าง Buffer ขนาด 4096 ไบต์ในหน่วยความจำ
    bufWriter := bufio.NewWriter(w)

    for _, rec := range records {
        _, err := bufWriter.WriteString(rec + "\n")
        if err != nil {
            return fmt.Errorf("failed to write record: %w", err)
        }
    }

    // กฎเหล็ก: ข้อมูลที่ค้างใน RAM Buffer จะยังไม่ลง Disk หรือ Socket จนกว่าจะ Flush!
    if err := bufWriter.Flush(); err != nil {
        return fmt.Errorf("failed to flush buffer: %w", err)
    }
    return nil
}

Section 2: Memory Architecture & Full Struct Definitions

เพื่อความเข้าใจระดับ Bare-Metal เราจะผ่า Struct ภายในของแพ็กเกจ bufio จาก Go Source Code (src/bufio/bufio.go) ว่าตัวแปรเก็บอะไรไว้ใน RAM และจัดเรียง Memory อย่างไร:

2.1 โครงสร้าง bufio.Reader (Total: 48 Bytes + Buffer Allocation)

// src/bufio/bufio.go
type Reader struct {
    buf          []byte        // 24 Bytes (Pointer 8B + Len 8B + Cap 8B)
    rd           io.Reader     // 16 Bytes (iface: tab 8B + data 8B)
    r, w         int           // 16 Bytes (r int 8B, w int 8B บน 64-bit)
    err          error         // 16 Bytes (iface: tab 8B + data 8B)
    lastRuneSize int           //  8 Bytes (ขนาด Rune ล่าสุดสำหรับ UnreadRune)
    lastByte     int           //  8 Bytes (ไบต์ล่าสุดสำหรับ UnreadByte)
}
+---------------------------------------------------------------------------------------+ | bufio.Reader Struct Memory Breakdown (64-bit) | +---------------------------------------------------------------------------------------+ | Byte Offset | Field Name | Type | Size (Bytes) | Description | +-------------+------------------------+---------------+--------------+-----------------+ | 0x00 - 0x17 | buf | []byte | 24 Bytes | Slice Header | | 0x18 - 0x27 | rd | io.Reader | 16 Bytes | Source iface | | 0x28 - 0x2F | r | int | 8 Bytes | Read index | | 0x30 - 0x37 | w | int | 8 Bytes | Write index | | 0x38 - 0x47 | err | error | 16 Bytes | Pending error | | 0x48 - 0x4F | lastRuneSize | int | 8 Bytes | Rune size | | 0x50 - 0x57 | lastByte | int | 8 Bytes | Last byte value | +---------------------------------------------------------------------------------------+ Total Struct Header Footprint = 88 Bytes (อยู่ใน Memory แนบสนิท ไม่กระจายตัวเหมือน JVM Object)

2.2 Ring-Like Sliding Window Buffer ภายใน bufio.Reader

bufio.Reader บริหารพื้นที่ใน buf ด้วย Pointer Index 2 ตัวคือ r (Read Marker) และ w (Write Marker):

0 r w len(buf) (4096) +------------+-------------------+----------------------+ buf []byte | CONSUMED | AVAILABLE DATA | FREE SPACE | +------------+-------------------+----------------------+ |<-- Unread Data -->| 1. ข้อมูลถูกอ่านจาก Disk มาเติมลงใน Free Space (ขยับ w ไปข้างหน้า) 2. เมื่อ Caller สั่ง Read() ข้อมูลจะถูกอ่านจากตำแหน่ง r (ขยับ r ตามไป) 3. เมื่อ r วิ่งมาทัน w (r == w) แสดงว่า Buffer ว่างเปล่า ระบบจะ Reset r = 0, w = 0 เพื่อเติมรอบใหม่

2.3 โครงสร้าง bufio.Scanner และ Tokenizer Memory

// src/bufio/scan.go
type Scanner struct {
    r            io.Reader // 16 Bytes: Target Reader
    split        SplitFunc //  8 Bytes: Function Pointer สำหรับแบ่ง Token
    maxTokenSize int       //  8 Bytes: เพดานขนาด Token (Default 64KB)
    token        []byte    // 24 Bytes: Slice ชี้ผลลัพธ์ Token ล่าสุด
    buf          []byte    // 24 Bytes: Buffer ภายในเริ่มต้นที่ 4KB ขยายได้ถึง maxTokenSize
    start        int       //  8 Bytes: Index เริ่มต้น Token
    end          int       //  8 Bytes: Index สิ้นสุด Token
    err          error     // 16 Bytes: Error สถานะ
    empties      int       //  8 Bytes: ตรวจจับ Empty token loop
    scanCalled   bool      //  1 Byte  : สถานะเรียก Scan
    done         bool      //  1 Byte  : จบ Stream หรือยัง
    // + 6 Bytes Padding ให้ครบ 8 Bytes Boundary
}

Section 3: Visual Workflow & Assembly/RAM Mechanics

ทำไมการใช้ bufio หรือ io.Reader ถึงทำงานได้เร็วกว่า และลดภาระของ CPU ได้มหาศาล? มาดูลำดับขั้นตอนการทำงานจริงระดับ Kernel และ OS System Calls

3.1 เปรียบเทียบ: Unbuffered Read vs Buffered Read

[Unbuffered Read: อ่านไฟล์ขนาด 4096 ไบต์ ทีละ 1 ไบต์] Caller Loop 4,096 รอบ: --> สลับ Context: User Space ---[sys_enter]---> Kernel Space (read syscall) <-- สลับ Context: Kernel Space ---[sys_exit]----> User Space ** ผลลัพธ์: เกิด OS System Call ทั้งหมด 4,096 ครั้ง! CPU หมดเวลาไปกับ Context Switch ** ----------------------------------------------------------------------------------------- [Buffered Read (bufio): อ่านไฟล์ขนาด 4096 ไบต์] Caller ขอ 1 ไบต์: --> bufio ตรวจพบว่า Buffer ภายในว่างเปล่า --> ยิงคำสั่ง OS Syscall เพียง 1 ครั้ง ดึงข้อมูล 4,096 ไบต์มาเก็บใน RAM Buffer ของ User Space Caller ขอไบต์ที่ 2 ถึง 4096: --> bufio ดึงจาก User-Space RAM Buffer ให้ทันที (In-Memory Array Access) ไม่แตะต้อง OS Syscall เลย! ** ผลลัพธ์: เกิด OS System Call เพียง 1 ครั้งเท่านั้น (ลดลง 4,000 เท่าตัว!) **

3.2 Zero-Copy Pipeline ด้วย io.Copy

เมื่อสั่ง io.Copy(dstWriter, srcReader) ตัว Go Runtime จะตรวจสอบแบบ Dynamic Interface Assertion ว่า srcReader หรือ dstWriter รองรับฟังก์ชันระดับ OS หรือไม่:

Section 4: Edge Cases, Hazards & Idiomatic Patterns

Hazard #1: The Scanner Token Size Limit Trap (bufio.ErrTooLong)

ใน bufio.Scanner ค่าตั้งต้นของขนาด Token สูงสุด (Max Token Size) ถูก Hardcode ไว้ที่ 64 KB (`bufio.MaxScanTokenSize = 64 * 1024`)
หากคุณนำ bufio.Scanner ไปอ่าน JSON Lines ขนาดใหญ่ หรือ CSV Record ในระบบธนาคารที่มีฟิลด์ Signature/Payload ขนาดยาวเกิน 64KB ลูปจะ หยุดทำงานเงียบๆ และคืน Error bufio.ErrTooLong หากไม่ได้ตรวจสอบ scanner.Err() ข้อมูลธุรกรรมจะตกหล่นทันที!

// วิธีแก้: หากต้องอ่านบรรทัดที่ยาวเกิน 64KB ให้ขยาย Buffer ล่วงหน้าด้วย Buffer()
scanner := bufio.NewScanner(reader)
// อนุญาตให้บรรทัดยาวได้สูงสุดถึง 10 MB
const maxCapacity = 10 * 1024 * 1024
buf := make([]byte, 64*1024)
scanner.Buffer(buf, maxCapacity)
Hazard #2: The Slice Retention Leak in scanner.Bytes()

ฟังก์ชัน scanner.Bytes() ไม่ได้ Allocate หน่วยความจำใหม่ มันคืน Slice ที่ชี้ตรงไปยัง scanner.buf ภายใน! หากคุณเก็บ Slice นี้ไว้ใน Struct หรือส่งข้าม Goroutine โดยไม่ทำ copy() ข้อมูลจะถูกเขียนทับทันทีในรอบ Scan() ถัดไป กลายเป็น Data Corruption ข้าม Record

// หายนะ: ทุกรอบจะได้ค่าขยะทับกัน
records := make([][]byte, 0)
for scanner.Scan() {
    records = append(records, scanner.Bytes()) // พัง! ทุก element ชี้ไปที่ buffer เดียวกัน
}

// วิธีแก้ที่ถูกต้อง: Clone ไบต์ออกมาเป็นของตัวเอง
for scanner.Scan() {
    b := scanner.Bytes()
    cloned := make([]byte, len(b))
    copy(cloned, b)
    records = append(records, cloned)
}
Hazard #3: Forgetting to Flush bufio.Writer

หากโปรแกรมเขียนข้อมูลเสร็จสิ้นแล้วจบการทำงานโดยไม่เรียก bufWriter.Flush() ข้อมูลที่ตกค้างอยู่ใน Internal Memory Buffer (อาจมากถึง 4KB) จะหายสาบสูญไปทันที โดยไม่ถูกบันทึกลง Disk หรือ Socket!

Idiomatic Go Patterns ในงาน I/O:

Section 5: Knowledge Verification Quiz (16 Challenges)

ทดสอบความเข้าใจเชิงลึกระดับ Runtime & I/O Architecture ทุกข้อมีคะแนนสะสมและอธิบายเหตุผลอย่างละเอียด

Section 6: Hands-on Engineering Labs

เขียนโค้ด Go ตามหลัก Streaming Architecture ตรวจสอบความถูกต้องด้วย Verification Engine

Lab 1: Fixed-Buffer Stream Counter (Zero Allocation)

โจทย์: เขียนฟังก์ชัน CountBytes(r io.Reader) (int64, error) โดยสร้าง Buffer ชิ้นเดียวขนาด 1024 ไบต์ วน Loop อ่านข้อมูลด้วย r.Read(buf) นับจำนวนไบต์รวมทั้งหมดจนกว่าจะพบ io.EOF โดยต้องประมวลผล n ก่อนตรวจสอบ Error เสมอ

Lab 2: Race Timing RFID Log Scanner

โจทย์: เขียนฟังก์ชัน CountValidPasses(r io.Reader) (int, error) โดยใช้ bufio.NewScanner วนลูปอ่านข้อมูลทีละบรรทัด หากบรรทัดใดมีความยาวมากกว่า 0 ให้บวกตัวนับขึ้น 1 และตรวจสอบ scanner.Err() ก่อนคืนค่าผลลัพธ์

Lab 3: Buffered Ledger Audit Writer with Mandatory Flush

โจทย์: เขียนฟังก์ชัน WriteLedgerRecords(w io.Writer, entries []string) error โดยใช้ bufio.NewWriter(w) วนลูปเขียนแต่ละ entry ต่อท้ายด้วย \n และสั่ง Flush() เพื่อเทข้อมูลลง Disk ให้สมบูรณ์แบบ