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 โตได้ | หลักแสน–ล้าน |
| M | OS 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 และไม่มี pathsync/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
ทำในเครื่อง (Playground จำกัดเวลารัน) รัน go vet ทุกครั้งก่อนรันจริง
- เขียน
download(id int)ที่แค่time.Sleepสุ่ม 100–500ms แล้วพิมพ์ไฟล์ N เสร็จ - เรียกแบบธรรมดา 5 ครั้งใน
for— จับเวลารวม ด้วยtime.Since(start) - เปลี่ยนเป็น
go download(i)— รันแล้วจะเห็นอะไร? (คำตอบอยู่ในหัวข้อ 3 ของบทนี้) - ใส่
sync.WaitGroupให้ถูก แล้วจับเวลาอีกที — เทียบกับข้อ 2 - เขียนใหม่ด้วย
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 ทุกประการ - ท้าทาย 1: จงใจ
wg.Add(6)ทั้งที่มี 5 งาน → จดคำในวงเล็บเหลี่ยม ของบรรทัดgoroutine 1 [...] - ท้าทาย 2: ย้าย
wg.Add(1)เข้าไปข้างในgo func(){...}แล้วรัน 10 ครั้งติด — ผลตรงกันทุกครั้งไหม? - ท้าทาย 3: เปลี่ยน
downloadให้รับwg sync.WaitGroup(ไม่มีดาว) แล้วสั่งgo vet ./...ก่อน รัน — vet บอกว่าอะไร?
ถ้าแต่ละไฟล์ใช้ ~300ms: แบบธรรมดา ≈ 1.5 วินาที (บวกกัน) ·
แบบ goroutine ≈ 0.5 วินาที (เท่ากับตัวที่ช้าที่สุด)
ถ้าได้ตัวเลขใกล้เคียงนี้แปลว่าถูกแล้ว
— ถ้าแบบ goroutine ได้ เกือบ 0 แปลว่าลืม Wait()
💬 ตอนนี้คุณสร้าง goroutine ได้แล้ว แต่มันยังคุยกันไม่ได้
ทุกตัวต่างคนต่างทำ แล้วเงียบไป
บทถัดไปคือ channel — ท่อที่ทำให้ goroutine ส่งของหากันได้
ติดตรงไหนถามได้เลย โดยเฉพาะท้าทาย 2 ถ้าผลออกมาไม่เหมือนกันสักครั้ง
อ้างอิง
- A Tour of Go — Concurrency — แก้โค้ดแล้วกดรันได้ในหน้าเว็บ
- Concurrency is not Parallelism — Rob Pike · ต้นทางของหัวข้อ 1
- pkg.go.dev — sync.WaitGroup · WaitGroup.Go (Go 1.25+)
- Effective Go — Goroutines
- Go Spec — Go statements