DEV Community

Cover image for Concurrency ใน Go ตอนที่ 1: Basic & Synchronization
Perajit
Perajit

Posted on

Concurrency ใน Go ตอนที่ 1: Basic & Synchronization

ตอนแรกของบทความ "Concurrency ใน Go" เราจะว่ากันด้วยพื้นฐานเกี่ยวกับ concurrency และ synchronization

Concurrency vs Parallelism

ก่อนอื่นมาทำความเข้าใจสองคำนี้กันก่อน

Concurrency

  • นิยามคือ ความสามารถในการจัดการงานหลายอย่างในช่วงเวลาเดียวกัน
  • การทำงานแต่ละงานไม่จำเป็นต้องเกิดขึ้นพร้อมกัน แค่สลับงานไปมาก็ถือว่า concurrency
  • ลักษณะสำคัญคือ ช่วงเวลาเริ่มต้นของแต่ละงานมีการ overlap

Parallelism

  • นิยามคือ ความสามารถในการประมวลผลงานหลายอย่างพร้อมกัน
  • ต้องอาศัย hardware


ภาพประกอบโดย Gemini

หมายเหตุ: concurrency สามารถเป็น parallelism หรือไม่ก็ได้ ขึ้นอยู่กับ hardware mapping

ต่อให้ไม่ได้รัน parallel แต่การรัน concurrent ก็สามารถทำให้ทำงานได้เร็วขึ้นได้ เพราะปกติในงานทั่วไปมักจะต้องรออะไรบางอย่างเสมอ เช่น รอดึงค่าจาก memory หรือเก็บค่าลง memory ในระหว่างที่รอนี้ถ้าเราสามารถสลับไปรันอย่างอื่นได้ก็จะทำให้ทำงานโดยรวมได้มีประสิทธิภาพมากขึ้น

Concurreny และ Parallelism ใน Go

Go เป็นภาษาที่มี concurreny แบบ built-in คือไม่จำเป็นต้องใช้ library ช่วย
และสามารถทำงานเป็น parallelism ได้ด้วย โดยการกำหนดค่า GOMAXPROCS > 1

ตัวอย่างการกำหนดค่าจากในโค้ด

runtime.GOMAXPROCS(4)
Enter fullscreen mode Exit fullscreen mode

หรือจะตั้งค่าผ่าน environment variable ก็ได้

export GOMAXPROCS=4
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

ผลลัพธ์ก็คือ เราจะเห็นแค่ข้อความเดียว...

Main Routine
Enter fullscreen mode Exit fullscreen mode

อ้าว?!

เรื่องของเรื่องก็คือ main() จบการทำงานไปก่อนที่ goroutine ใหม่จะทำงาน ดังนั้นเราต้องสั่งให้ Go รอ goroutine ใหม่ของเราก่อน

ซึ่งเราอาจจะสามารถสั่งให้ sleep รอก็ได้ เช่นรอไปเลย 100 ms แบบนี้

func main() {
  go fmt.Println("New Routine")
  time.Sleep(100 * time.Millisecond)
  fmt.Println("Main Routine")
}
Enter fullscreen mode Exit fullscreen mode

แต่อย่างที่รู้กันว่าไม่ควร เพราะจริงๆ เราบอกไม่ได้ว่าต้อง 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")
}
Enter fullscreen mode Exit fullscreen mode

หมายเหตุ: ต้องส่งค่า wg เป็น pointer เพื่อให้ทุกตัวลดตัวนับที่ตัวเดียวกัน ถ้าส่งแบบ value จะทำให้โปรแกรมค้างเพราะได้ wg เป็นค่า copy คนละตัวกัน

เราจะเห็นผลลัพธ์เป็น

New Routine
New Routine
Main Routine
Enter fullscreen mode Exit fullscreen mode

นั่นก็คือตอนนี้โปรแกรมของเราสามารถรัน 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()
}
Enter fullscreen mode Exit fullscreen mode

จากตัวอย่าง ถึงจะมีการเรียก 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)
}
Enter fullscreen mode Exit fullscreen mode

critical section ซึ่งในที่นี้ก็คือ counter++ จะถูกล็อกเอาไว้ให้รันได้ทีละ goroutine ทำให้อัพเดทค่าได้อย่างถูกต้อง ถ้าไม่มีการล็อกไว้ผลลัพธ์ของ counter อาจไม่ใช่ 1000 (ถ้าใช้เยอะจะทำให้ performance แย่ลงเพราะต้องรอ)

หมายเหตุ: ปกติแล้วใน concurrency เราไม่นิยมแชร์ตัวแปรกลางและแก้ไขค่าร่วมกัน แต่มักจะใช้การส่งค่าผ่าน channel ซึ่งจะกล่าวถึงในบทความถัดไป

Top comments (0)