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

Concurrency: แบบฉบับ Go

ภาษาส่วนใหญ่เพิ่ม concurrency เข้าไปบน shared-memory threads ภายหลัง แต่ Go ถูกออกแบบตั้งแต่ต้นรอบแนวคิดที่แตกต่างออกไปเรียกว่า Communicating Sequential Processes (CSP) ซึ่ง Tony Hoare เป็นผู้นิยามในปี 1978 คำพูดของ Go ที่สรุปได้ชัดที่สุดคือ:

อย่าสื่อสารโดยการแชร์หน่วยความจำ ให้แชร์หน่วยความจำโดยการสื่อสารแทน

ในทางปฏิบัติหมายความว่า:

  • Goroutines คือ function ที่ทำงานอิสระ ใช้ทรัพยากรน้อยมาก — stack เริ่มต้นเพียงไม่กี่ KB ที่ขยายตัวได้ตามต้องการ — จึงสามารถมีได้หลายแสนตัวที่รันพร้อมกัน
  • Channels คือท่อส่งข้อมูลแบบ typed goroutines ส่งค่าเข้า channel และรับค่าจาก channel อีกตัว ทั้งการส่งข้อมูลและการ synchronize เกิดขึ้นในคำสั่งเดียว
  • Scheduler จัดสรร goroutines ลงบน OS threads (GOMAXPROCS threads ตามค่าเริ่มต้น หนึ่งตัวต่อ CPU core) คุณไม่ต้องจัดการ threads โดยตรง

การออกแบบนี้ทำให้โปรแกรม concurrent เข้าใจง่ายขึ้น เมื่อข้อมูลไหลผ่าน channel ความเป็นเจ้าของ (ownership) ชัดเจน: sender เคยถือมันอยู่ ตอนนี้ receiver ถือแทน ไม่ต้องใช้ mutex สำหรับการส่งต่อนั้นเลย

flowchart LR
  subgraph SharedMemory["Shared-memory style (most languages)"]
    T1["Thread A"] -.Lock/Unlock.-> Mu["mutex"]
    T2["Thread B"] -.Lock/Unlock.-> Mu
    Mu --> Data["shared variable"]
  end
  subgraph CSP["Go's CSP style"]
    G1["Goroutine A"] -->|send| Ch["channel"]
    Ch -->|receive| G2["Goroutine B"]
  end
Shared memory ใต้ lock เทียบกับ CSP: channel ย้ายตัวข้อมูลไปเลย ทำให้ไม่มีอะไรเหลือให้ต้อง lock
บทเรียนหัวข้อ
หน้านี้ภาพรวมโมเดลและตัวอย่างแรกที่รันได้
Goroutinesgo f(), scheduler, sync.WaitGroup
Channelsสร้าง, ส่ง, รับ, unbuffered vs buffered, close, range
Selectรวม channels หลายตัว, default, timeouts
Sync Primitivessync.Mutex, sync.Once, sync/atomic
Contextcontext.Context, การยกเลิก, การส่งต่อ
PatternsWorker pool, fan-out/fan-in, pipeline

snippet ด้านล่างสร้าง goroutines 3 ตัวแบบขนาน แต่ละตัวเขียนผลลัพธ์ลงใน slice ที่จัดสรรไว้ล่วงหน้า indexed ตาม id ของตัวเอง ทำให้ผลลัพธ์เรียงลำดับเสมอ sync.WaitGroup บล็อก main จนกว่าทั้งสามตัวจะเสร็จสิ้น — บท Goroutines สอน WaitGroup เองแบบเจาะลึก (รวมถึงบัคคลาสสิกเรื่องตำแหน่ง wg.Add) หน้านี้แค่ต้องการมันเพื่อให้ main มีชีวิตอยู่นานพอที่จะเห็น output เท่านั้น

package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
results := make([]string, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results[id] = fmt.Sprintf("goroutine %d says hello", id)
}(i)
}
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
fmt.Println("main exits")
}
สิ่งที่ได้ประโยชน์ต้นทุน
goroutine vs OS thread2-8 KB stack vs 1-8 MBscheduler overhead
CSP (channel) vs shared memoryชัดเจนว่า data flow ไปไหนchannel overhead สูงกว่า mutex
M:N schedulingใช้ OS thread น้อยscheduler complexity
  • goroutine = OS thread — goroutine เบากว่า thread มาก สร้างได้หลักแสนตัว
  • concurrent = parallel — concurrent คือจัดการหลาย task พร้อมกัน, parallel คือรันพร้อมกันจริงบน multi-core
  • channel เสมอดีกว่า mutex — mutex เหมาะกับ simple shared state, channel เหมาะกับ data flow

ทุกบทในโมดูลนี้จบด้วย section แบบเจาะลึกของตัวเอง นี่คือเวอร์ชันภาพรวมที่ควรเช็กตั้งแต่แรกเห็น ก่อนจะลงลึกในรายละเอียดของ snippet ที่ generate มา:

  • main (หรือ goroutine ที่เรียก) รอ goroutine ทุกตัวที่มันเริ่มไหม — ด้วย WaitGroup, channel, หรือ return channel ที่ caller drain — หรือมันอาจ return ทั้งที่ goroutine ยังรันอยู่?
  • ทุก goroutine มีทางออกไหม และมีอะไรป้องกัน state ที่ goroutine มากกว่าหนึ่งตัวแตะไหม?
  • ถ้า snippet ถูกนำเสนอว่า deterministic ลำดับที่ print ออกมาถูกบังคับจริงไหม (sort แล้ว หรืออ่านกลับผ่าน indexed slice) หรือสองรอบที่รันอาจ print ลำดับต่างกันได้จริง ๆ?

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

func main() {
for i := 0; i < 3; i++ {
go func(id int) {
fmt.Println("goroutine", id, "says hello")
}(i)
}
fmt.Println("main exits")
}
เฉลย

ไม่มีอะไรตรงนี้รอให้ทั้งสาม goroutine รันก่อนที่ main จะ return เลย Go runtime ไม่ได้เก็บ process ให้มีชีวิตอยู่เพื่อ background goroutine — ทันทีที่ main return process จะ exit ไม่ว่า goroutine ไหนจะถูก schedule ไปแล้วหรือยังก็ตาม output จริงที่จับได้จากสองรอบที่รันโปรแกรมเดียวกันเป๊ะนี้:

main exits
main exits
goroutine 0 says hello

ทั้งสองเป็นผลลัพธ์ที่ถูกต้องของ binary เดียวกัน — บางครั้งไม่มี goroutine ไหนได้รันเลย บางครั้งหนึ่งหรือสองตัวได้รัน ขึ้นอยู่กับ scheduling ล้วน ๆ วิธีแก้คือ sync.WaitGroup ที่ตัวอย่างที่รันได้ของหน้านี้เองใช้: main ต้องไม่ return จนกว่าจะยืนยันแล้วว่า goroutine ทุกตัวที่มันเริ่มเสร็จสิ้นแล้ว

คำพูด Go 'share memory by communicating' หมายความว่าอะไรในทางปฏิบัติ?
GOMAXPROCS ควบคุมอะไร?
ทำไม wg.Add(1) ถึงต้องเรียกก่อน go func()?
โปรแกรมนี้ print อะไรได้บ้าง?
func main() {
    for i := 0; i < 3; i++ {
        go func(id int) {
            fmt.Println("goroutine", id, "says hello")
        }(i)
    }
    fmt.Println("main exits")
}
ข้อความไหนเกี่ยวกับ goroutine กับ OS thread ที่ถูกต้อง?