ตอนแรกของบทความ "Concurrency ใน Go" เราจะว่ากันด้วยพื้นฐานเกี่ยวกับ concurrency และ synchronization
Concurrency vs Parallelism
ก่อนอื่นมาทำความเข้าใจสองคำนี้กันก่อน
Concurrency
- นิยามคือ ความสามารถในการจัดการงานหลายอย่างในช่วงเวลาเดียวกัน
- การทำงานแต่ละงานไม่จำเป็นต้องเกิดขึ้นพร้อมกัน แค่สลับงานไปมาก็ถือว่า concurrency
- ลักษณะสำคัญคือ ช่วงเวลาเริ่มต้นของแต่ละงานมีการ overlap
Parallelism
- นิยามคือ ความสามารถในการประมวลผลงานหลายอย่างพร้อมกัน
- ต้องอาศัย hardware
หมายเหตุ: concurrency สามารถเป็น parallelism หรือไม่ก็ได้ ขึ้นอยู่กับ hardware mapping
ต่อให้ไม่ได้รัน parallel แต่การรัน concurrent ก็สามารถทำให้ทำงานได้เร็วขึ้นได้ เพราะปกติในงานทั่วไปมักจะต้องรออะไรบางอย่างเสมอ เช่น รอดึงค่าจาก memory หรือเก็บค่าลง memory ในระหว่างที่รอนี้ถ้าเราสามารถสลับไปรันอย่างอื่นได้ก็จะทำให้ทำงานโดยรวมได้มีประสิทธิภาพมากขึ้น
Concurreny และ Parallelism ใน Go
Go เป็นภาษาที่มี concurreny แบบ built-in คือไม่จำเป็นต้องใช้ library ช่วย
และสามารถทำงานเป็น parallelism ได้ด้วย โดยการกำหนดค่า GOMAXPROCS > 1
ตัวอย่างการกำหนดค่าจากในโค้ด
runtime.GOMAXPROCS(4)
หรือจะตั้งค่าผ่าน environment variable ก็ได้
export GOMAXPROCS=4
GOMAXPROCS คือการกำหนดจำนวน OS threads ที่สามารถประมวลผล Go code ได้พร้อมกันในช่วงเวลาหนึ่ง
ใน Go เวอร์ชั่น 1.5 เป็นต้นไป ค่าเริ่มต้นของ GOMAXPROCS เท่ากับจำนวน Logical CPUs ของเครื่องที่ใช้งาน ซึ่งเป็นค่าที่ดึงประสิทธิภาพของ hardware ออกมาได้สูงสุดอยู่แล้ว ไม่จำเป็นต้องแก้ไข ถ้าตั้งค่าสูงเกินไปจะทำให้เกิด context switching สูงขึ้น ทำให้ทำงานช้าลง
Goroutines
ปกติแล้ว concurrency ในภาษาอื่นจะใช้ Thread แต่สำหรับใน Go เราจะใช้สิ่งที่เรียกว่า Goroutine ซึ่งอาจจะเรียกว่าเป็น Lightweight Thread
แล้วมันต่างกันยังไง?
อย่างที่บอกว่า concurrency อาศัยการสลับงานไปมา หรือเรียกว่าการจัดตารางงาน (scheduling) และสำหรับ thread ตัวที่ทำหน้าที่จัดตารางงานก็คือ OS
แต่ใน Go เนี่ย goroutine จะรันอยู่ภายใน thread อีกที (สามารถมีได้ถึงหลายพันหรือหลายหมื่นภายใน thread เดียว และใช้ memory น้อยกว่า thread มาก) และจะอาศัย Go Runtime Scheduler ทำหน้าที่จัดตารางงาน
เมื่อเราสั่งรัน main() Go จะสร้าง goroutine ตัวแรกสุดที่เรียกว่า main routine ขึ้นมารัน ถ้าเราต้องการให้โปรแกรมทำงานแบบ concurrency เราก็สามารถรันงานที่ต้องการในอีก routine ได้ง่ายๆ แค่เขียน keyword go ข้างหน้าตอนเรียกฟังก์ชั่นเท่านั้นเอง
มาลองกันดูหน่อย
func main() {
go fmt.Println("New Routine")
fmt.Println("Main Routine")
}
ผลลัพธ์ก็คือ เราจะเห็นแค่ข้อความเดียว...
Main Routine
อ้าว?!
เรื่องของเรื่องก็คือ main() จบการทำงานไปก่อนที่ goroutine ใหม่จะทำงาน ดังนั้นเราต้องสั่งให้ Go รอ goroutine ใหม่ของเราก่อน
ซึ่งเราอาจจะสามารถสั่งให้ sleep รอก็ได้ เช่นรอไปเลย 100 ms แบบนี้
func main() {
go fmt.Println("New Routine")
time.Sleep(100 * time.Millisecond)
fmt.Println("Main Routine")
}
แต่อย่างที่รู้กันว่าไม่ควร เพราะจริงๆ เราบอกไม่ได้ว่าต้อง sleep นานแค่ไหน วิธีรออย่างถูกต้องก็คือรอจนกว่ามันจะเสร็จนั่นแหละ
Synchronization และ Sync Package
synchronization คือกลไกที่ใช้ควบคุมลำดับและการเข้าถึงทรัพยากรที่ใช้ร่วมกัน เพื่อป้องกันปัญหาที่เกิดจากการทำหลายงานชนกัน โดยเฉพาะปัญหา race condition
สำหรับใน Go เราจะใช้แพ็กเกจ sync สำหรับทำ synchronization ซึ่งมีตัวช่วยหลายอย่าง แต่เราจะหยิบมาพูดถึง 3 ตัว คือ sync.WaitGroup, sync.Once และ sync.Mutex
sync.WaitGroup
หนึ่งในวิธีที่จะบังคับให้ Go รอจนกว่างานใน routine ย่อยจะเสร็จได้ก็คือการใช้ sync.WaitGroup
หลักการการทำงานคือ
- WaitGroup จะเก็บตัวนับ ซึ่งก็คือตัวเลขจำนวนงานที่ต้องรอ
- ใช้ method
Add()เพื่อเพิ่มตัวนับขึ้น (ใส่ตัวเลขจำนวนงานเท่าไหร่ก็ได้ ไม่จำเป็นต้องเพิ่มทีละ 1) - ใช้ method
Done()บอกมันเมื่อเสร็จแต่ละงาน เพื่อให้เลขลดลง - ใช้ method
Wait()เพื่อสั่งให้รอจนตัวนับเป็น 0 ซึ่งหมายความว่าไม่มีอะไรให้รอแล้ว
ตัวอย่างการใช้งาน
func foo(wg *sync.WaitGroup) {
fmt.Println("New Routine")
wg.Done() // บอกว่ารันจบแล้วงานนึง
}
func main() {
var wg sync.WaitGroup
wg.Add(2) // บอกว่ามีเพิ่ม 2 งาน
go foo(&wg)
go foo(&wg)
wg.Wait() // บอกให้รอจนกว่างานทั้งหมดจะเสร็จ
fmt.Println("Main Routine")
}
หมายเหตุ: ต้องส่งค่า
wgเป็น pointer เพื่อให้ทุกตัวลดตัวนับที่ตัวเดียวกัน ถ้าส่งแบบ value จะทำให้โปรแกรมค้างเพราะได้wgเป็นค่า copy คนละตัวกัน
เราจะเห็นผลลัพธ์เป็น
New Routine
New Routine
Main Routine
นั่นก็คือตอนนี้โปรแกรมของเราสามารถรัน goroutine ย่อยๆ ได้โดยไม่จบการทำงานไปก่อน
sync.Once
เป็นเครื่องมือที่ทำให้ฟังก์ชั่นที่ระบุถูกรันแค่ครั้งเดียวเท่านั้น ไม่ว่าจะมี goroutine เรียกใช้งานกี่ครั้งก็ตาม ใช้สำหรับการทำ initialization ที่ต้องทำแค่เพียงครั้งเดียว เช่น DB connection, setup state หรือทำ singleton pattern
func setup() {
fmt.Println("Setup")
}
func main() {
var once sync.Once
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
once.Do(setup)
}()
}
wg.Wait()
}
จากตัวอย่าง ถึงจะมีการเรียก once.Do(setup) จากหลาย goroutine (5 ตัว) แต่ setup() จะถูกเรียกทำงานแค่ครั้งเดียว
sync.Mutex
Mutex มาจาก Mutual Exclusion เป็นกลไกที่ใช้ป้องกันไม่ให้ goroutine หลายตัวเข้าถึง critical section ได้ในช่วงเวลาเดียวกัน
หลักการทำงานคือ
- ใช้
Lock()เพื่อล็อกไม่ให้ goroutine อื่นเข้ามาใช้งาน - ใช้
Unlock()เพื่อปลดล็อกให้ goroutine อื่นเข้ามาใช้งานต่อได้
func main() {
var mu sync.Mutex
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter)
}
critical section ซึ่งในที่นี้ก็คือ counter++ จะถูกล็อกเอาไว้ให้รันได้ทีละ goroutine ทำให้อัพเดทค่าได้อย่างถูกต้อง ถ้าไม่มีการล็อกไว้ผลลัพธ์ของ counter อาจไม่ใช่ 1000 (ถ้าใช้เยอะจะทำให้ performance แย่ลงเพราะต้องรอ)
หมายเหตุ: ปกติแล้วใน concurrency เราไม่นิยมแชร์ตัวแปรกลางและแก้ไขค่าร่วมกัน แต่มักจะใช้การส่งค่าผ่าน channel ซึ่งจะกล่าวถึงในบทความถัดไป

Top comments (0)