บทเรียน 21 · Day 6 · Concurrency

Goroutine & WaitGroup: ทำหลายอย่างพร้อมกัน

~16 นาที · ต้องมี: บทเรียน 07 (pointer), บทเรียน 08 (defer)

go คำเดียวเปลี่ยนโปรแกรมของคุณจาก "ทำทีละอย่างตามลำดับ" เป็น "ทำหลายอย่างพร้อมกัน" — และเปลี่ยนกฎการอ่านโค้ดไปทั้งหมด บรรทัดถัดไปไม่ได้รอบรรทัดก่อนอีกต่อไป บทนี้เริ่มจากบั๊กที่ทุกคนเจอเป็นอย่างแรก แล้วค่อยไปเครื่องมือที่แก้มัน

1 · Concurrency ไม่ใช่ Parallelism

  Concurrency — จัดการหลายงานพร้อมกัน (โครงสร้างของโปรแกรม)

    บาริสต้า 1 คน:  ต้มกาแฟ A ─┐         ┌─ เสิร์ฟ A
                                ├ ระหว่างรอ ┤
                     รับออเดอร์ B ┘         └─ ต้มกาแฟ B

    คนเดียว แต่ไม่นั่งรออะไรเปล่า ๆ

  Parallelism — ทำหลายงานพร้อมกันจริง ๆ (การทำงานของเครื่อง)

    บาริสต้า 2 คน:  คนที่ 1 ─── ต้มกาแฟ A ───►
                     คนที่ 2 ─── ต้มกาแฟ B ───►

    สองมือทำงานพร้อมกันจริงในวินาทีเดียวกัน

พูดสั้น ๆ: concurrent = จัดของหลายอย่างให้ไม่ต้องนั่งรอเปล่า ๆ · parallel = มีมือหลายมือทำพร้อมกันจริง

Go ให้คุณเขียนแบบ concurrent — ส่วนจะได้ parallel จริงไหมขึ้นกับว่าเครื่องมีกี่ core (core = "มือ" ของ CPU · เครื่องสมัยนี้มักมี 4–16 มือ) โค้ดชุดเดียวกันรันบนเครื่อง 1 core ก็ยังถูกต้อง แค่ไม่เร็วขึ้น

2 · GMP Model — ใครเป็นคนจัดคิว

ถ้าสร้าง goroutine ได้เป็นแสนแต่ CPU มีไม่กี่ core มีใครสักคนต้องจัดคิว — คนนั้นคือ Go runtime (โปรแกรมเล็ก ๆ ที่ Go แถมติดมากับโปรแกรมของเราทุกตัว คอยคุมงานอยู่เบื้องหลัง) ที่ใช้โมเดลชื่อ GMP:

   G G G G  ──►  P1  ──►  M1  ──►  CPU Core 1
   G G G    ──►  P2  ──►  M2  ──►  CPU Core 2
   G G G G G ──► (รอคิว)

   G = Goroutine   ออเดอร์ที่ลูกค้าสั่ง — มีเป็นหมื่นได้
   M = Machine     พนักงานครัวตัวจริง (OS thread) — มีไม่กี่คน
   P = Processor   หัวหน้าครัวที่ถือคิวงาน — เท่ากับ GOMAXPROCS
ตัวคืออะไรราคาต่อตัวมีได้กี่ตัว
Gงานหนึ่งชิ้นที่คุณสั่งด้วย goเริ่มที่ ~2 KB โตได้หลักแสน–ล้าน
MOS thread จริง ๆ~1 MB (แพงกว่ามาก)เท่าที่จำเป็น
Pสิทธิ์ในการรันโค้ด Go—GOMAXPROCS (ปกติ = จำนวน core)

ทำไม goroutine ถึงเบา: มันไม่ใช่ thread (thread = "สายงาน" ที่ระบบปฏิบัติการจัดให้ — ของจริง ของแพง)
goroutine หลายพันตัวถูก multiplex ลงบน OS thread ไม่กี่ตัว (multiplex = เอาของหลายชิ้นมาสลับกันใช้ช่องทางเดียวกัน เหมือนช่องจ่ายเงิน 3 ช่องรับลูกค้าเป็นร้อยคนได้) — ไม่ใช่หนึ่ง goroutine ต่อหนึ่ง thread
ในภาษาที่ 1 thread = 1 งาน การสร้างหมื่น thread คือฆ่าเครื่อง ใน Go การสร้างหมื่น goroutine คือเรื่องปกติ
นี่คือเหตุผลหลักที่คนเลือก Go ทำงาน backend

🗣 ศัพท์ที่เพิ่งเรียนไป — เอาไว้คุยกับคนอื่น

สองหัวข้อแรกมีศัพท์เยอะที่สุดในบท นี่คือคำที่ dev ใช้เรียกสิ่งที่คุณเพิ่งเข้าใจไป

concurrency
“จัดหลายงานให้ไม่นั่งรอเปล่า” — เรื่องของวิธีเขียนโค้ด · ถามว่า “ตรงนี้ทำเป็น concurrent ได้ไหม” จะสื่อกว่า “ทำให้เร็วขึ้นได้ไหม”
parallelism
“ทำพร้อมกันจริงในวินาทีเดียวกัน” — เรื่องของเครื่องว่ามีกี่ core · ประโยคคลาสสิกคือ “concurrency is not parallelism”
goroutine
งานหนึ่งชิ้นที่สั่งด้วยคำว่า go · พูดว่า “ตรงนี้ยิง goroutine กี่ตัว” ได้เลย
OS thread
“สายงาน” ตัวจริงที่ระบบปฏิบัติการแจกให้ · แต่ละตัวจอง stack ไว้ราว 1–8 MB แล้วแต่ OS (จองเป็น virtual ไม่ได้ใช้ RAM จริงทั้งก้อน) เทียบกับ goroutine ที่เริ่มแค่ ~2 KB · ที่ต้องจำคือ goroutine ไม่เท่ากับ thread
scheduler
“คนจัดคิว” ที่ตัดสินว่า goroutine ตัวไหนได้รันตอนไหน · GMP คือแผนผังของมัน
GOMAXPROCS
“ให้รันโค้ด Go พร้อมกันได้กี่ช่อง” ปกติเท่ากับจำนวน core · ถ้าโปรแกรมช้าผิดปกติบน container ให้ถามว่า “GOMAXPROCS ตั้งเป็นเท่าไหร่”
ตั้งแต่ Go 1.25 runtime อ่าน CPU limit ของ container ให้เองแล้ว (cgroup บน Linux) ค่า default จึงเป็น min(จำนวน core, limit ของ container) และปรับตามได้ระหว่างรัน — ปัญหาคลาสสิกที่ GOMAXPROCS เท่ากับ core ของเครื่อง host ทั้งที่ container ถูกจำกัดไว้ 1 CPU จะเจอกับโปรเจกต์ที่ go.mod ยังประกาศเวอร์ชันเก่ากว่า 1.25 เป็นหลัก

3 · go — คำเดียวที่เปลี่ยนทุกอย่าง

doSomething()      // รอจนเสร็จ แล้วค่อยไปบรรทัดถัดไป

go doSomething()   // สั่งแล้วไปต่อทันที ไม่รอ
package main

import "fmt"

func sayHello() {
    fmt.Println("Hello from Goroutine")
}

func main() {
    go sayHello()
    fmt.Println("main จบแล้ว")
}

พิมพ์อะไรออกมาบ้าง และเรียงยังไง?

ผมรันแล้วได้บรรทัดเดียว:

main จบแล้ว

Hello from Goroutine ไม่โผล่เลย

กฎที่ต้องจำให้ขึ้นใจ: เมื่อ main จบ โปรแกรมจบทันที Go runtime ไม่รอ goroutine ที่ค้างอยู่แม้แต่ตัวเดียว — ไม่มี error ไม่มี warning ไม่มีอะไรบอกว่างานหายไป
goroutine ถูกสร้างจริง แต่ยังไม่ทันได้คิว main ก็ถึงบรรทัดสุดท้ายแล้ว

🏪 เทียบให้เห็นภาพ — เจ้าของร้านปิดร้านกลับบ้าน

นึกถึงร้านเล็ก ๆ ร้านหนึ่ง — เจ้าของร้าน = main · ลูกน้อง = goroutine
· เรียกฟังก์ชันธรรมดา = เจ้าของลงมือทำเอง เสร็จแล้วค่อยทำอย่างอื่นต่อ
· go doSomething() = สั่งลูกน้องไปทำ แล้วเจ้าของไปทำอย่างอื่นทันที ไม่ยืนรอ
ปัญหาคือ พอเจ้าของทำงานของตัวเองครบ เขาก็ปิดไฟ ล็อกประตู กลับบ้าน ลูกน้องที่ทำค้างอยู่หลังร้านต้องหยุดกลางคัน งานหายหมด

ที่ไม่เหมือนกัน: ลูกน้องจริงคงตะโกนว่า “เดี๋ยวสิ ยังไม่เสร็จ” แต่ goroutine หายไปเงียบ ๆ ไม่มี error ไม่มี warning · และภาพ “ทำค้างอยู่หลังร้าน” ก็ยังหลอกอยู่นิดหนึ่ง — ของจริงหลายครั้ง ลูกน้องยังไม่ทันได้เริ่มงานเลยด้วยซ้ำ scheduler ยังไม่ทันจัดคิวให้ main ก็จบไปแล้ว (อย่างโค้ดข้างบนที่ไม่มีอะไรพิมพ์ออกมาเลยสักตัว ไม่ใช่พิมพ์ไปได้ครึ่งเดียว) · และลูกน้องใน Go ถูกมาก สร้างหมื่นตัวยังไหว ไม่เหมือนจ้างคนจริง

🗣 ศัพท์ที่เพิ่งเรียนไป
main goroutine
“เจ้าของร้าน” — goroutine ตัวที่รัน func main() · ประโยคที่จะได้ยินบ่อยคือ “main goroutine จบแล้วตัวอื่นตายหมด” ซึ่งคือกฎที่เพิ่งเจอไป
blocking / non-blocking
blocking = ค้างรอตรงนั้นจนเสร็จ (ฟังก์ชันธรรมดา) · go f() ทำให้ บรรทัดที่เรียก ไม่ค้าง แต่ตัวงานข้างในยัง block ได้เหมือนเดิม · ส่วนคำว่า non-blocking ที่ dev ใช้กันจะหมายถึง ตัว operation ที่ไม่ยอมค้างเลย เช่น non-blocking I/O หรือ select ที่มี default — ไม่ได้แปลว่า “บรรทัดนี้มี go” · ถาม senior ว่า “บรรทัดนี้ blocking ไหม” คือคำถามที่ตรงจุดที่สุดเวลาโค้ด concurrent มีปัญหา
goroutine leak
goroutine ที่ block ค้างอยู่ตลอดไป ไม่มีวันจบ — เช่น รอรับจาก channel ที่ไม่มีใครส่งให้ หรือยิง HTTP ที่ไม่ตั้ง timeout — เลยกินหน่วยความจำสะสมขึ้นเรื่อย ๆ · จุดสังเกตคือ “ไม่มีทางออก” ไม่ใช่ “ไม่มีใครรอ” — go sendLog() ที่ทำงานเสร็จแล้วจบไปเอง ไม่ใช่ leak แม้ไม่มีใครรอมัน ส่วนตัวที่มีคน Wait() อยู่แต่ข้างในค้างที่ channel คือ leak · คำนี้จะได้ยินบ่อยมากเวลารีวิวโค้ด backend

4 · ทางแก้ที่ผิด: time.Sleep

❌ เดาเวลาเอา

func main() {
    go sayHello()

    time.Sleep(time.Second)
}

ได้ผลจริง แต่: ช้าไป 1 วิเสมอ · งานที่ใช้ 1.1 วิยังหายอยู่ดี · บนเครื่อง CI (เครื่องกลางที่รันเทสต์ให้อัตโนมัติทุกครั้งที่ push โค้ด) ที่ช้ากว่าจะพังเป็นบางครั้ง
คือการเดา ไม่ใช่การรอ

✅ รอจนกว่าจะเสร็จจริง

func main() {
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        sayHello()
    }()

    wg.Wait()
}

เสร็จเมื่อไหร่ไปต่อเมื่อนั้น — ไม่เร็วไป ไม่ช้าไป

⚠️ สไลด์หน้า time.Sleep() คอมไพล์ไม่ผ่าน — มี import "fmt" อย่างเดียว แต่เรียก time.Sleep ต้องเพิ่ม "time" เข้าไปด้วย (ถ้าลอกจากสไลด์ตรง ๆ จะเจอ undefined: time)

5 · sync.WaitGroup — ตัวนับที่รอจนถึงศูนย์

  counter = 0

  wg.Add(2)   ──►  counter = 2
                        │
  goroutine A จบ        │
  wg.Done()   ──►  counter = 1     wg.Wait() ยังค้างอยู่ตรงนี้
                        │
  goroutine B จบ        │
  wg.Done()   ──►  counter = 0  ──►  wg.Wait() ปล่อยผ่าน ไปบรรทัดถัดไป
🚌 เทียบให้เห็นภาพ — ครูนับหัวเด็กก่อนรถออก

ครูพานักเรียนไปทัศนศึกษา ถึงเวลากลับ ครูไม่ได้เดาว่า “น่าจะครบแล้ว” แล้วสั่งรถออก แต่ทำสามอย่าง:
· ก่อนปล่อยเด็กลงจากรถ ครูจดว่าพามา 2 คน → wg.Add(2)
· เด็กกลับขึ้นรถ แล้วขานชื่อ ครูขีดออกทีละคน → wg.Done()
· ครูยืนขวางประตูรถ ไม่ให้ออกจนกว่าจะขีดครบ → wg.Wait()
สังเกตว่าครูต้องจดจำนวนก่อนปล่อยเด็กลง — นี่คือเหตุผลที่ wg.Add() ต้องอยู่ก่อน go เสมอ (หัวข้อ 7.3 จะให้เห็นตอนพังจริง)

ที่ไม่เหมือนกัน: ครูรู้ว่า “เด็กคนไหน” ยังไม่กลับ แต่ WaitGroup รู้แค่ตัวเลข · แล้วมันก็ไม่สนใจว่างานสำเร็จหรือพัง ขอแค่ขานชื่อครบจำนวนก็ปล่อยรถออก

methodทำอะไรเรียกที่ไหน
wg.Add(n)เพิ่มตัวนับ nก่อน go เสมอ
wg.Done()ลดตัวนับ 1ใน goroutine · ใส่ defer ไว้บรรทัดแรก
wg.Wait()ค้างจนตัวนับเป็น 0ใน goroutine ที่ต้องการรอ (ปกติคือ main)
var wg sync.WaitGroup

wg.Add(2)

go func() {
    defer wg.Done()   // บรรทัดแรกเสมอ — panic ก็ยังลดตัวนับ
    doA()
}()

go func() {
    defer wg.Done()
    doB()
}()

wg.Wait()
fmt.Println("All done")

ทำไมต้อง defer wg.Done() ไม่ใช่ wg.Done() ท้ายฟังก์ชัน: ถ้าโค้ดตรงกลาง return ก่อน หรือ panic (panic = โปรแกรมเจอเรื่องที่ไปต่อไม่ไหว เลยเลิกทำฟังก์ชันนั้นแล้วไล่ขึ้นไปเรื่อย ๆ ถ้าไม่มีใคร recover ก็ crash ทั้งโปรแกรม) wg.Done() ที่บรรทัดสุดท้ายจะไม่ถูกเรียก → ตัวนับไม่ถึงศูนย์ → โปรแกรมค้างตลอดกาล
เคส “ค้าง” นี้เกิดตอนมี recover ดักไว้ข้างบน (เช่นใน handler/middleware ของเว็บที่กัน panic ไม่ให้ล้มทั้งเซิร์ฟเวอร์) — ผมลองรันดูแล้ว ถ้าไม่มี recover เลย โปรแกรมจะ crash ทันที ไม่ได้ค้าง ซึ่งก็ยังต้องแก้อยู่ดี · defer wg.Done() ปิดทั้งสองทางในบรรทัดเดียว
เทียบกับเรื่องรถทัศนศึกษา: เด็กเดินกลับมาถึงรถแล้ว แต่ลืมขานชื่อ ครูก็ไม่ยอมให้รถออก
defer ทำงานทุกทางออก (บทเรียน 08)

🗣 ศัพท์ที่เพิ่งเรียนไป
sync.WaitGroup
“ครูที่ยืนนับหัว” — ตัวนับที่รอจนงานครบ · เรียกชื่อให้ตรงว่า “ใช้ sync.WaitGroup รอ” เพราะ Go ยังมีตัวรออย่างอื่นอีก (เช่น golang.org/x/sync/errgroup ที่รอและเก็บ error ด้วย — ตัวนี้ต้อง go get เพิ่มเอง ไม่ได้ติดมากับ Go และไม่มี path sync/errgroup ใน standard library)
counter
ตัวเลขข้างในที่ Add เพิ่ม แล้ว Done ลด · ไล่บั๊กให้ถามว่า “ตอนนี้ counter เท่าไหร่”
synchronization
“ทำให้หลายงานตรงจังหวะกัน” — ที่มาของชื่อ package sync · ถาม AI ว่า “ต้อง synchronize goroutine พวกนี้ยังไง” จะได้คำตอบตรงกว่าถามว่า “ทำยังไงให้รอกัน”

6 · ส่ง WaitGroup ข้ามฟังก์ชัน — ต้องเป็น pointer

func worker(id int, wg *sync.WaitGroup) {   // ← ดาว
    defer wg.Done()
    fmt.Println("Worker", id)
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go worker(1, &wg)          // ← แอมเปอร์แซนด์
    wg.Wait()
}

ลืมดาวเมื่อไหร่ Go จะคัดลอก WaitGroup ให้ทันที นี่คือกฎ pass-by-value ของ Go: ส่งอะไรเข้าฟังก์ชัน ฟังก์ชันได้ “สำเนา” เสมอ (บทเรียน 07)
ผลคือ Done() ไปลดตัวนับของสำเนา ส่วน Wait() เฝ้าตัวจริงที่ไม่มีวันถึงศูนย์

📋 เทียบให้เห็นภาพ — ใบเช็กชื่อตัวจริง กับ ใบถ่ายเอกสาร

กลับไปที่รถทัศนศึกษา ครูมีใบเช็กชื่ออยู่ใบเดียว
· ส่งใบตัวจริงไปให้เด็กเซ็น = ส่ง &wg — ครูมองใบเดิมก็เห็นว่าใครเซ็นแล้ว
· ถ่ายสำเนาแจกคนละใบ = ส่ง wg เฉย ๆ (ลืมดาว) — เด็กเซ็นในสำเนาตัวเองครบทุกคน แต่ใบตัวจริงที่ครูถืออยู่ยังว่างเปล่า ครูก็ยืนรอที่ประตูรถตลอดกาล

ที่ไม่เหมือนกัน: กระดาษถ่ายสำเนายังดูออกด้วยตา แต่ในโค้ดต่างกันแค่ดาวตัวเดียว ตาเปล่าพลาดง่ายมาก — เลยต้องพึ่ง go vet ตามข้างล่างนี้

✅ ข่าวดี — go vet จับให้ก่อนรัน

ผมลองลืมดาวดู แล้วสั่ง go vet:

worker passes lock by value: sync.WaitGroup contains sync.noCopy
call of worker copies lock value: sync.WaitGroup contains sync.noCopy

ตั้งแต่วันนี้ไป go vet ./... ควรอยู่ในนิสัยเหมือน go test — บั๊ก concurrency ส่วนใหญ่ไม่พังทันที แต่ vet เห็น

🗣 ศัพท์ที่เพิ่งเรียนไป
pass-by-value
“ส่งไปเป็นสำเนาเสมอ” — กฎของ Go ที่ทำให้ลืมดาวแล้วพัง · ไล่บั๊กกับ senior ให้พูดว่า “ตรงนี้โดน copy เพราะ pass-by-value รึเปล่า”
pointer
“ที่อยู่ของตัวจริง” — เขียน *sync.WaitGroup ที่ตัวรับ ส่ง &wg ตอนเรียก · พูดว่า “ตัวนี้ต้องรับเป็น pointer นะ”
copy a lock / noCopy
คำที่ go vet ใช้ด่าเรา · sync.WaitGroup มีชิ้นส่วนชื่อ noCopy แปะไว้บอกว่า ห้ามคัดลอกของชิ้นนี้ · เจอ “passes lock by value” ให้แปลในใจว่า “ลืมดาว”
static analysis
เครื่องมือที่อ่านโค้ดเฉย ๆ โดยไม่ต้องรัน แล้วชี้จุดที่น่าจะผิด — go vet คือตัวหนึ่ง · พูดว่า “vet ผ่านหรือยัง” ได้เลยเวลาส่งงานให้รีวิว

7 · สามวิธีที่ทำ WaitGroup พังได้

7.1 Add มากกว่า Done → ค้างตลอดกาล

wg.Add(3)                  // บอกว่ามี 3 งาน

for i := 0; i < 2; i++ {     // แต่สร้างแค่ 2
    go func() {
        defer wg.Done()
        fmt.Println("working")
    }()
}

wg.Wait()

ผมรันแล้วได้:

working
working
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.WaitGroup.Wait]:

deadlock แปลว่า ทุกคนในโปรแกรมรอกันหมดจนไม่มีใครขยับได้ — เคสนี้คือครูรอเด็กคนที่ 3 ที่ไม่มีตัวตน · Go ตรวจเจอเองแล้วหยุดโปรแกรม พร้อมพิมพ์รายการ goroutine ที่ค้างอยู่ (เรียกว่า goroutine dump — คือ stack trace ของทุก goroutine พิมพ์ออกมาพร้อมกัน ส่วน stack trace เฉย ๆ หมายถึงของ goroutine ตัวเดียว)

⚠️ ระวัง — Go ไม่ได้จับให้ทุกครั้ง ตัวตรวจนี้ยิงเฉพาะตอนที่ ทุก goroutine หลับพร้อมกันเท่านั้น · ถ้ายังมีตัวอื่นทำงานอยู่ (เช่นเซิร์ฟเวอร์ที่คอยรับ request) การค้างที่ Wait() จะเงียบสนิท ไม่มี fatal error ให้เห็นเลย
ผมลองใส่เซิร์ฟเวอร์ไว้อีก goroutine แล้วรันโค้ดข้างบนซ้ำ ได้แค่ working สองบรรทัดแล้วค้างนิ่งไปเลย ไม่มี error ตามมา — “ไม่มี error” ไม่ได้แปลว่า “ไม่ค้าง”

อ่านบรรทัดในวงเล็บเหลี่ยมให้เป็น — [sync.WaitGroup.Wait] บอกเลยว่า goroutine ที่ 1 ค้างอยู่ที่ Wait() แปลว่าตัวนับไม่ถึงศูนย์
ถ้าเห็น [chan receive] หรือ [chan send] แทน แปลว่าค้างที่ channel (บทเรียน 22) คำในวงเล็บนี้คือเบาะแสแรกเสมอเวลา deadlock

7.2 Done มากกว่า Add → panic

wg.Add(1)
wg.Done()      // counter: 1 → 0
wg.Done()      // counter: 0 → -1
panic: sync: negative WaitGroup counter

ข้อดีคือพังเสียงดัง — ต่างจาก 7.1 ที่ค้างเงียบ ๆ เจอแบบนี้แก้ง่ายกว่ามาก

7.3 Add ช้าไป → ข้อมูลหายเงียบ ๆ

done := 0
for i := 0; i < 100; i++ {
    go func() {
        wg.Add(1)      // ❌ Add อยู่ข้างใน goroutine
        defer wg.Done()
        done++
    }()
}
wg.Wait()
fmt.Println("ทำเสร็จจริง =", done, "จาก 100")

โปรแกรมนี้พังแบบไหน — ค้าง, panic, หรืออย่างอื่น?

ไม่ค้าง ไม่ panic — มันจบปกติ แต่ตัวเลขไม่ครบ ผมรัน 3 ครั้ง:

ทำเสร็จจริง = 94 จาก 100
ทำเสร็จจริง = 96 จาก 100
ทำเสร็จจริง = 90 จาก 100

ทำไม: มีสองอย่างพังพร้อมกัน

(1) wg.Wait() ถูกเรียกตอนที่ goroutine หลายตัวยังไม่ทันได้เริ่มทำงาน จึงยังไม่ได้ Add ตัวนับตอนนั้นอาจเป็น 0 พอดี → Wait() ปล่อยผ่านทันที → main จบ → goroutine ที่เหลือตายกลางคัน

(2) done++ ถูก goroutine 100 ตัวเขียนทับกัน บนตัวแปรก้อนเดียว การบวกแบบนี้ไม่ปลอดภัย: สองตัวอาจอ่านค่าเดิมได้เท่ากันแล้วเขียนกลับเป็นเลขเดียวกัน นับหายไปอีกโดยไม่เกี่ยวกับ WaitGroup เลย อาการนี้เรียกว่า data race และบทเรียน 24 จะพิสูจน์ให้ดูด้วย go run -race

สรุปคืออย่าเพิ่งสรุปว่า "แก้ WaitGroup ให้ถูกแล้วตัวเลขจะครบ 100" บทนี้แก้ได้แค่สาเหตุที่ (1)

อันตรายที่สุดในสามข้อ — 7.1 ค้างให้เห็น 7.2 panic ให้เห็น แต่ 7.3 ทำงาน "สำเร็จ" ทุกครั้ง แค่ข้อมูลหายไปบางส่วนแบบไม่มีใครรู้
กฎ: wg.Add() ต้องอยู่ใน goroutine ที่เรียก wg.Wait() เสมอ — ไม่ใช่ในตัวที่ถูกสร้าง

8 · wg.Go() — ทางลัดใหม่ที่กันพลาดทั้งสามข้อ

แบบเดิม — 3 จุดที่พลาดได้

var wg sync.WaitGroup

wg.Add(1)          // ลืม / ผิดจำนวน

go func() {
    defer wg.Done()  // ลืม / ลืม defer
    sayHello()
}()

wg.Wait()

Go 1.25+ — จับคู่ให้อัตโนมัติ

var wg sync.WaitGroup

wg.Go(func() {
    sayHello()
})

wg.Wait()

// Add/Done/go อยู่ในคำสั่งเดียว
// ไม่มีทางลืม ไม่มีทาง Add ช้า

⚠️ wg.Go ใหม่มาก — เพิ่งมีใน Go 1.25 ผมทดสอบบน Go 1.26.5 ใช้ได้ปกติ แต่ลองย้อน go.mod ให้เป็น go 1.24 ดู: go build ยังผ่าน แต่ go vet ฟ้อง

sync.Go requires go1.25 or later (module is go1.24)

ถ้าโปรเจกต์ที่ทำงานยังไม่ถึง 1.25 ต้องใช้แบบเดิม — และสไลด์ก็สอนแบบเดิมไว้ด้วย ซึ่งถูกแล้ว เพราะโค้ดที่คุณจะไปเจอในโลกจริง ยังเป็นแบบ Add/Done เกือบทั้งหมด
ต้องอ่านออกทั้งสองแบบ · เขียนใหม่ให้ใช้ wg.Go

ทดสอบความเข้าใจ

1. func main() { go sayHello() } พิมพ์อะไร?

main จบ = โปรแกรมจบทันที runtime ไม่รอ goroutine ที่ค้างอยู่
ไม่ใช่ deadlock (ไม่มีใครรออะไร) ไม่ใช่ panic (ไม่มีอะไรผิดกฎ) — แค่เงียบหายไป ซึ่งอันตรายกว่าทั้งสองอย่าง

2. wg.Add(3) แต่สร้าง goroutine แค่ 2 ตัว เกิดอะไร?

ตัวนับลงถึง 1 แล้วหยุด — Wait() รอตลอดกาล Go ตรวจเจอว่าไม่มี goroutine ไหนทำอะไรได้อีก จึงหยุดโปรแกรม: fatal error: all goroutines are asleep - deadlock!
ดูในวงเล็บจะเห็น [sync.WaitGroup.Wait] ซึ่งชี้จุดให้เลย
(negative counter คือกรณีตรงข้าม: Done เกิน)

3. ทำไม wg.Add(1) ต้องอยู่ ก่อน go ไม่ใช่ข้างในนั้น?

go แค่สั่ง ไม่ได้แปลว่าเริ่มทำแล้ว — goroutine อาจยังไม่ได้คิวตอนที่ Wait() ถูกเรียก ตัวนับยังเป็น 0 → ผ่านฉลุย → main จบ → งานที่เหลือหายไปเงียบ ๆ
ผมรัน 100 goroutine แบบนี้ 3 ครั้ง ได้ 94 / 96 / 90 — ไม่เคยครบ และไม่เคย error

4. ส่ง WaitGroup เข้าฟังก์ชันแบบ func worker(wg sync.WaitGroup) ผลคือ?

Go ส่ง argument แบบ pass-by-value เสมอ (บทเรียน 07) → worker ได้ สำเนา → Done() ลดตัวนับของสำเนา ตัวจริงไม่ขยับ → Wait() ค้าง
ต้องเขียน wg *sync.WaitGroup และส่ง &wg
go vet จับให้ก่อนรัน: passes lock by value: sync.WaitGroup contains sync.noCopy

🛠️ ภารกิจ — ดาวน์โหลดพร้อมกัน 5 ไฟล์

ทำในเครื่อง (Playground จำกัดเวลารัน) รัน go vet ทุกครั้งก่อนรันจริง

  1. เขียน download(id int) ที่แค่ time.Sleep สุ่ม 100–500ms แล้วพิมพ์ ไฟล์ N เสร็จ
  2. เรียกแบบธรรมดา 5 ครั้งใน for — จับเวลารวม ด้วย time.Since(start)
  3. เปลี่ยนเป็น go download(i) — รันแล้วจะเห็นอะไร? (คำตอบอยู่ในหัวข้อ 3 ของบทนี้)
  4. ใส่ sync.WaitGroup ให้ถูก แล้วจับเวลาอีกที — เทียบกับข้อ 2
  5. เขียนใหม่ด้วย wg.Go() ให้ได้ผลเดียวกัน — เช็คก่อนด้วยคำสั่ง go version ถ้าได้ต่ำกว่า go1.25 ให้ข้ามข้อนี้ไปเลย (ของใหม่ยังไม่มีในเครื่องคุณ ไม่ใช่คุณเขียนผิด) ถ้าเครื่องเป็น 1.25 ขึ้นไปแต่ go vet ฟ้อง sync.Go requires go1.25 or later (module is go1.xx) ให้ไปแก้บรรทัด go ในไฟล์ go.mod ให้เป็น go 1.25 แล้วรันใหม่
    ถูกแล้วเมื่อผลลัพธ์และเวลารวมเท่ากับข้อ 4 ทุกประการ
  6. ท้าทาย 1: จงใจ wg.Add(6) ทั้งที่มี 5 งาน → จดคำในวงเล็บเหลี่ยม ของบรรทัด goroutine 1 [...]
  7. ท้าทาย 2: ย้าย wg.Add(1) เข้าไปข้างใน go func(){...} แล้วรัน 10 ครั้งติด — ผลตรงกันทุกครั้งไหม?
  8. ท้าทาย 3: เปลี่ยน download ให้รับ wg sync.WaitGroup (ไม่มีดาว) แล้วสั่ง go vet ./... ก่อน รัน — vet บอกว่าอะไร?

ถ้าแต่ละไฟล์ใช้ ~300ms: แบบธรรมดา ≈ 1.5 วินาที (บวกกัน) · แบบ goroutine ≈ 0.5 วินาที (เท่ากับตัวที่ช้าที่สุด)
ถ้าได้ตัวเลขใกล้เคียงนี้แปลว่าถูกแล้ว — ถ้าแบบ goroutine ได้ เกือบ 0 แปลว่าลืม Wait()

💬 ตอนนี้คุณสร้าง goroutine ได้แล้ว แต่มันยังคุยกันไม่ได้ ทุกตัวต่างคนต่างทำ แล้วเงียบไป บทถัดไปคือ channel — ท่อที่ทำให้ goroutine ส่งของหากันได้
ติดตรงไหนถามได้เลย โดยเฉพาะท้าทาย 2 ถ้าผลออกมาไม่เหมือนกันสักครั้ง

อ้างอิง