ข้ามไปยังเนื้อหา

io และ bufio

Package io นิยาม interface ขนาดเล็กแต่เป็นรากฐานสองตัว:

type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}

Interface ทั้งสองนี้คือภาษากลางของ I/O ใน Go มี type จำนวนมากที่ implement ทั้งสอง: ไฟล์, network connection, HTTP request และ response body, in-memory buffer และอื่นๆ function ที่รับ io.Reader หรือ io.Writer จะทำงานกับทุก type เหล่านั้นโดยไม่ต้องแก้ไขโค้ด

strings.NewReader(s) ห่อ string เป็น io.Reader มีประโยชน์มากใน test และตัวอย่าง — สามารถทดสอบ function ที่อ่านจาก stream ได้โดยไม่ต้องแตะ filesystem

r := strings.NewReader("hello, world")
data, _ := io.ReadAll(r)
fmt.Println(string(data)) // hello, world

bytes.Buffer implement ทั้ง io.Reader และ io.Writer เขียนลงไปแล้วอ่านกลับออกมา:

var buf bytes.Buffer
fmt.Fprintln(&buf, "first line")
fmt.Fprintln(&buf, "second line")
content, _ := io.ReadAll(&buf)
fmt.Print(string(content))

fmt.Fprintln รับ io.Writer ใดก็ได้ ดังนั้นจึงเปลี่ยน output ที่ format แล้วไปยัง buffer, ไฟล์ หรือ network connection ได้โดยไม่ต้องเปลี่ยนโค้ด

bufio.Scanner ห่อ io.Reader ใดก็ได้และให้การอ่านทีละบรรทัด (หรือทีละ token) อย่างสะดวก function split แบบ default คือ bufio.ScanLines

scanner := bufio.NewScanner(r)
for scanner.Scan() {
fmt.Println(scanner.Text()) // one line at a time
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}

scanner.Scan() คืน false เมื่อ input หมดหรือเกิด error ควรตรวจสอบ scanner.Err() หลังลูปเสมอ — การคืน false เพียงอย่างเดียวไม่ได้บอกว่าเป็น EOF หรือ error

Package io ยังมี helper ที่มีประโยชน์:

io.ReadAll(r) // read until EOF, return []byte
io.Copy(dst, src) // copy from Reader to Writer
io.MultiReader(r1, r2) // chain readers sequentially
io.Discard // Writer that discards all bytes (like /dev/null)
io.LimitReader(r, n) // read at most n bytes
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"strings"
)
// printLines accepts any io.Reader — works with files, buffers, or strings
func printLines(r io.Reader) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fmt.Println(">", scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Println("scan error:", err)
}
}
func main() {
// strings.NewReader implements io.Reader
r := strings.NewReader("first line\nsecond line\nthird line")
printLines(r)
// bytes.Buffer implements both io.Reader and io.Writer
var buf bytes.Buffer
fmt.Fprintln(&buf, "written to buffer")
fmt.Fprintln(&buf, "another line")
content, _ := io.ReadAll(&buf)
fmt.Print(string(content))
// io.MultiReader chains two readers sequentially
r1 := strings.NewReader("hello ")
r2 := strings.NewReader("world\n")
combined := io.MultiReader(r1, r2)
fmt.Print("MultiReader output: ")
io.Copy(os.Stdout, combined)
// bufio.Scanner over in-memory CSV
csv := "alice,30\nbob,25\ncharlie,35"
scanner := bufio.NewScanner(strings.NewReader(csv))
for scanner.Scan() {
parts := strings.Split(scanner.Text(), ",")
fmt.Printf("name=%s age=%s\n", parts[0], parts[1])
}
}

signature สองบรรทัด Read(p []byte) (n int, err error) ซ่อนกฎสามข้อที่ทำให้โค้ดที่ AI เขียนพลาดบ่อยมาก:

  1. Read คืน n > 0 พร้อม err ที่ไม่ใช่ nil ในการเรียกครั้งเดียวกันได้ caller ต้อง process byte จำนวน n ก่อนที่จะเช็ก err — ถ้าทิ้งข้อมูลนั้นไปเพราะ err != nil จะทำให้ chunk สุดท้ายหายไปเงียบๆ
  2. “short read” เป็นเรื่องถูกกฎหมาย Read อาจเติมข้อมูลให้ p น้อยกว่า len(p) ได้แม้ว่าจะยังมีข้อมูลเหลือและ err == nil ห้ามสมมติว่าเรียก Read ครั้งเดียวจะเต็ม buffer เสมอ — ต้องวน loop หรือใช้ helper ที่วน loop ให้
  3. io.EOF ไม่ใช่ความล้มเหลว มันคือสัญญาณปกติว่าไม่มีข้อมูลเหลือแล้ว ให้ปฏิบัติกับ err == io.EOF ว่า “หยุด loop ได้ ไม่มี error ต้องรายงาน” — ห้าม log.Fatal มันหรือส่งมันขึ้นไปทั้งดุ้นเหมือนมีอะไรพัง
n, err := r.Read(buf)
if n > 0 {
process(buf[:n]) // always handle n bytes first
}
if err != nil {
if err == io.EOF {
return nil // clean end of stream, not an error
}
return err
}

เมื่อต้องการ byte ตามจำนวนที่แน่นอนหรือขั้นต่ำโดยไม่อยากเขียน loop เอง io.ReadFull(r, buf) อ่านจนกว่า buf จะเต็มหรือคืน io.ErrUnexpectedEOF ถ้า stream จบก่อน; io.ReadAtLeast(r, buf, min) คือแนวคิดเดียวกันแต่สำหรับความยาวขั้นต่ำแทนที่จะเต็ม buffer

bufio.Scanner จำกัดแต่ละ token (บรรทัด) ไว้ที่ bufio.MaxScanTokenSize — 64 KB — โดย default เมื่อบรรทัดยาวเกินนั้น Scan() จะคืน false และ Err() จะคืน bufio.ErrTooLong ซึ่งดูเหมือน EOF ที่สะอาดทุกประการถ้าไม่ได้เช็ก Err() หลัง loop — input ที่เหลือจะหายไปเงียบๆ

scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // raise the max to 1 MB
for scanner.Scan() {
process(scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Fatal(err) // catches ErrTooLong, not just I/O errors
}

scanner.Buffer(initial, max) กำหนดทั้ง buffer เริ่มต้นและเพดานที่มันขยายไปได้ เรียกก่อน Scan() ครั้งแรกถ้ามีโอกาสที่บรรทัดเดียวจะยาวเกิน 64 KB จริงๆ (บรรทัด JSON ยาวๆ, base64 blob)

bufio.Writer buffer การเขียนไว้ในหน่วยความจำและจะไปถึง io.Writer ข้างล่างก็ต่อเมื่อ buffer เต็ม หรือเมื่อเรียก Flush() เอง ไม่มีอะไรรับประกันว่าข้อมูลถึง disk หรือ network แล้วจนกว่า Flush() จะ return

error จาก Close() ก็สำคัญด้วยเหตุผลเดียวกัน: type ที่สร้างบน buffered writer (หรือไฟล์) อาจแสดง write failure ที่ล่าช้า — เช่น disk เต็ม — ได้เฉพาะตอนที่ flush ครั้งสุดท้ายเกิดขึ้นภายใน Close เท่านั้น การไม่สนใจ return value ของ defer f.Close() จะกลืน failure นั้นไปเงียบๆ

w := bufio.NewWriter(f)
fmt.Fprintln(w, "important data")
if err := w.Flush(); err != nil {
return fmt.Errorf("flush: %w", err) // the write may not have failed until now
}
if err := f.Close(); err != nil {
return fmt.Errorf("close: %w", err) // a writer's Close error is not decorative
}

io.Copy(dst, src) อ่านจาก src แล้วเขียนไปยัง dst จนกว่า src จะคืน io.EOF โดยอาศัย contract ของ Writer: Write ต้องคืน error ที่ไม่ใช่ nil ถ้าเขียนได้น้อยกว่า len(p) byte ดังนั้น io.Copy จึงเชื่อได้ว่า error ที่เป็น nil หมายความว่า chunk ทั้งหมดถูกเขียนสำเร็จ

helper เล็กๆ อีกสามตัวของ io ที่ครบชุดเครื่องมือ:

  • io.TeeReader(r, w) — คืน Reader ที่ mirror ทุก byte ที่มันอ่านไปยัง w เป็น side effect (checksum หรือ log stream ระหว่างที่ถูกอ่าน โดยไม่ต้อง buffer ทั้งหมดไว้ในหน่วยความจำก่อน)
  • io.Pipe() — คู่ io.Reader/io.Writer ในหน่วยความจำแบบ synchronous ไม่มี buffer; write จะ block จนกว่าจะมี read ที่ตรงกันมาดึงออกไป มีประโยชน์เมื่อจะ stream ข้อมูลจาก goroutine หนึ่งไปอีก goroutine โดยไม่ต้องมี buffer คั่นกลาง
  • io.NopCloser(r) — ห่อ io.Reader ด้วย Close ที่ไม่ทำอะไร (no-op) เพื่อให้ตอบสนอง io.ReadCloser นี่คือสิ่งที่ http.Request.Body ต้องการพอดีใน test หรือสิ่งที่ส่งกลับเมื่อไม่อยากให้ caller ปิด reader ที่ใช้ร่วมกัน
tee := io.TeeReader(resp.Body, hasher) // reading tee also feeds hasher
req.Body = io.NopCloser(bytes.NewReader(replayed)) // satisfy io.ReadCloser in a test
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
// shortReader returns at most 3 bytes per Read call, and delivers n>0
// together with io.EOF on the final call — a legal io.Reader.
type shortReader struct {
data []byte
}
func (r *shortReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p, r.data[:min(3, len(r.data))])
r.data = r.data[n:]
if len(r.data) == 0 {
return n, io.EOF // n > 0 AND err != nil in the same call
}
return n, nil
}
func main() {
// 1. short reads: n>0 with err!=nil on the last call
sr := &shortReader{data: []byte("hello!")}
buf := make([]byte, 10)
for {
n, err := sr.Read(buf)
fmt.Printf("Read: n=%d err=%v data=%q\n", n, err, buf[:n])
if err != nil {
break
}
}
// 2. io.ReadFull loops for you
sr2 := &shortReader{data: []byte("hello!")}
exact := make([]byte, 6)
n, err := io.ReadFull(sr2, exact)
fmt.Printf("ReadFull: n=%d err=%v data=%q\n", n, err, exact[:n])
// io.ReadFull when the stream has fewer bytes than requested
sr3 := &shortReader{data: []byte("hi")}
short := make([]byte, 6)
n, err = io.ReadFull(sr3, short)
fmt.Printf("ReadFull short: n=%d err=%v\n", n, err)
// 3. bufio.Scanner ErrTooLong when a token exceeds the buffer max
longLine := strings.Repeat("x", 20)
scanner := bufio.NewScanner(strings.NewReader(longLine + "\ntail\n"))
scanner.Buffer(make([]byte, 0, 16), 16) // small max on purpose
for scanner.Scan() {
fmt.Println("scanned:", scanner.Text())
}
fmt.Println("scanner.Err():", scanner.Err())
// 4. bufio.Writer needs Flush
var out bytes.Buffer
w := bufio.NewWriter(&out)
fmt.Fprint(w, "buffered, not flushed yet")
fmt.Println("before Flush, out.Len():", out.Len())
w.Flush()
fmt.Println("after Flush, out.Len():", out.Len())
// 5. io.TeeReader mirrors what it reads into a side buffer
var tee bytes.Buffer
tr := io.TeeReader(strings.NewReader("mirrored"), &tee)
got, _ := io.ReadAll(tr)
fmt.Printf("TeeReader read=%q sideBuffer=%q\n", got, tee.String())
// 6. io.NopCloser satisfies io.ReadCloser for a plain Reader
var rc io.ReadCloser = io.NopCloser(strings.NewReader("wrapped"))
data, _ := io.ReadAll(rc)
fmt.Println("NopCloser Close err:", rc.Close(), "data:", string(data))
}

output จริงจากโปรแกรมนี้บน Go 1.26.5:

Read: n=3 err=<nil> data="hel"
Read: n=3 err=EOF data="lo!"
ReadFull: n=6 err=<nil> data="hello!"
ReadFull short: n=2 err=unexpected EOF
scanner.Err(): bufio.Scanner: token too long
before Flush, out.Len(): 0
after Flush, out.Len(): 25
TeeReader read="mirrored" sideBuffer="mirrored"
NopCloser Close err: <nil> data: wrapped

สังเกต loop ของ scanner ด้านบน: scanned: ไม่ถูกพิมพ์เลย เพราะบรรทัดยาว 20 byte เกินเพดาน 16 byte ตั้งแต่ token แรก Scan() จึงคืน false ทันที และ Err() รายงาน bufio.Scanner: token too long — บรรทัด "tail" ไม่ถูกเห็นเลย

flowchart LR
  File["os.File / net.Conn"] --> BR["bufio.Reader"]
  BR --> Scanner["bufio.Scanner"]
  Scanner --> App["your code"]
  App2["your code"] --> BW["bufio.Writer"]
  BW -- "Flush()" --> Out["os.File / net.Conn"]
  Src["io.Reader"] -- "io.Copy" --> Dst["io.Writer"]
  Src -- "io.TeeReader" --> Side["side io.Writer"]
อ่านเป็นก้อนผ่าน bufio, เขียนต้อง Flush เอง, TeeReader แยกสำเนาออกไป

bytes.Buffer เก็บข้อมูลไว้ใน slice ภายใน; การ copy bytes.Buffer โดยค่า (assign, ส่งเป็น argument โดยค่า, หรือเก็บใน slice ที่จะ append ต่อ) จะ copy แค่ slice header โดยไม่มี mutex หรือ lock ให้ copy ตามไปด้วย — แต่ทั้งสอง copy จะ alias backing array เดียวกันในขณะที่แต่ละตัวติดตาม offset การอ่าน/เขียนของตัวเองแยกกัน ทำให้การเขียนและอ่านบน copy หนึ่งไปทำลายสิ่งที่อีก copy เห็น package doc พูดตรงๆ ไว้เลยว่า “The zero value for Buffer is an empty buffer ready to use… Do not copy a non-zero Buffer.”

ต่างจาก sync.Mutex ตรงที่ไม่มี check แบบ go vet copylocks สำหรับกรณีนี้ — bytes.Buffer ไม่ได้ embed marker noCopy ดังนั้น compiler และ go vet จะเงียบ ให้ส่ง *bytes.Buffer เสมอ ไม่ใช่ bytes.Buffer โดยค่า เมื่อมันมีข้อมูลอยู่แล้ว

flowchart LR
  subgraph R["io.Reader interface"]
    Src["Source"] --> Read["Read(p []byte)"] --> Buf["buffer p"] --> Consumer["consumer"]
  end
  subgraph C["Chaining readers"]
    F["os.File (raw)"] --> BRd["bufio.NewReader (buffered)"] --> GZ["gzip.NewReader (decompressed)"] --> JD["json.NewDecoder (decoded)"]
  end
  subgraph W["bufio.Writer batching"]
    Small["small writes (many syscalls)"] --> WB["buffer 4 KB"] --> Flush["one Flush (one syscall)"] --> Out["os.File"]
  end
pipeline ของ Reader, การต่อ reader เป็นสาย และการ batch ของ Writer
สิ่งที่ได้ประโยชน์ต้นทุน
bufio.Readerลด syscall (batching reads)เพิ่ม memory สำหรับ buffer
bufio.Writerลด syscall (batching writes)ต้อง Flush() ก่อน close
io.Reader chaincomposable, zero-copy pipelineerror handling ซับซ้อนเมื่อ chain ยาว
io.Copyefficient transfer ระหว่าง Reader→Writerไม่ inspect data ระหว่างทาง
  • unbuffered read เร็วพอ — ทุก Read() call โดยไม่มี buffer = 1 syscall — ช้ามากสำหรับ small reads จำนวนมาก
  • bufio.Writer flush อัตโนมัติ — ไม่ — ต้อง Flush() เองก่อน close หรือส่ง response ไม่งั้น data อาจหาย
  • io.Reader อ่าน exact n bytes เสมอ — Read() อาจ return น้อยกว่า n bytes — ต้องวน loop ถ้าต้องการ exact read
  • strings.NewReader กับ bytes.NewReader เหมือนกัน — strings.NewReader สำหรับ string literal, bytes.NewReader สำหรับ []byte ที่มีอยู่แล้ว

💡 ตัวอย่างจากของจริง

Go HTTP server: net/http ห่อ TCP connection ด้วย bufio.Reader โดยอัตโนมัติ — request parsing ไม่ต้อง syscall ทุก byte

AI assistant เขียนโค้ด io/bufio ที่ compile ผ่านและดูสมเหตุสมผลบ่อยกว่าโค้ดที่ถูกต้องจริงๆ ในกรณีขอบเยอะมาก เช็กสิ่งเหล่านี้ก่อนเชื่อว่า review ผ่าน:

  • loop ของ bufio.Scanner เช็ก scanner.Err() หลัง loop หรือถือว่า false หมายถึง EOF ที่สะอาดเสมอ?
  • ถ้า input มีโอกาสมีบรรทัดยาวเกิน 64 KB มีการเรียก scanner.Buffer(...) เพื่อขยายเพดานหรือไม่ หรือจะถูกตัดทิ้งเงียบๆ?
  • สิ่งที่เขียนผ่าน bufio.Writer ถูก Flush() ก่อน function จะ return หรือก่อนส่ง response หรือไม่?
  • return value ของ f.Close() ถูกเช็กหรือไม่เมื่อ f ถูกเขียนลงไป ไม่ใช่แค่ตอนอ่านอย่างเดียว?
  • Read ที่เขียนขึ้นเอง สมมติว่า caller จะได้ buffer เต็มเสมอหรือไม่ หรือ caller วน loop / ใช้ io.ReadFull อย่างถูกต้อง?
  • bytes.Buffer ถูก copy โดยค่า (ส่งเป็น argument, เก็บใน slice, return จาก function) หลังจากมันมีข้อมูลแล้วหรือไม่?

🐛 หา bug ในโค้ดนี้

func loadConfig(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
cfg := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
k, v, _ := strings.Cut(scanner.Text(), "=")
cfg[k] = v
}
return cfg, nil
}
เฉลย

loop ไม่เคยเช็ก scanner.Err() เลย ถ้าบรรทัดหนึ่งใน path ยาวเกินเพดาน default 64 KB — ค่ายาวๆ, ไฟล์ binary หลุดเข้ามา — Scan() จะคืน false, loop จบ, และ loadConfig จะคืน config ที่ไม่ครบพร้อม error เป็น nil เหมือนกับว่าไฟล์ถูกอ่านครบแล้ว แก้ได้โดยเช็ก error หลัง loop:

if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %s: %w", path, err)
}
return cfg, nil

ถ้าบรรทัดที่ยาวเกิน 64 KB เป็นเรื่องปกติที่คาดไว้ ให้เรียก scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize) ก่อน loop แทนที่จะแค่ตรวจจับ failure

io.ReadAll(r) คืนค่าอะไร?
หลังจาก bufio.Scanner.Scan() คืนค่า false ต้องตรวจสอบอะไร?
Type ใดที่ implement ทั้ง io.Reader และ io.Writer?
ทำไมควรรับ io.Reader ใน function แทนที่จะรับ *os.File?
โค้ดนี้พิมพ์อะไร?
sc := bufio.NewScanner(strings.NewReader(strings.Repeat("x", 100000)))
sc.Scan()
fmt.Println(sc.Err())
โค้ดนี้พิมพ์อะไร และทำไม?
var out bytes.Buffer
w := bufio.NewWriter(&out)
fmt.Fprint(w, "hi")
fmt.Println(out.Len())