Concurrent programming บน Golang

Punyapat Sessomboon
6 min readDec 11, 2019

--

สวัสดีครับ บทความนี้เราจะมาพูดถึงการเขียน Concurrent programming บน Golang กัน จริงๆ แล้วเกือบจะทุกภาษาก็สามารถเขียน Concurrent ได้หมดแต่ Golang เป็นหนึ่งในภาษาที่ถูกออกแบบมาให้เขียน concurrent ได้สะดวกมากๆ เมื่อเทียบกับภาษาอื่นๆ แทบจะเรียกว่าเป็น feature หลักของภาษา Golang เลยก็ว่าได้…

สำหรับใครที่ไม่รู้จัก Golang มาก่อนแนะนำให้ลองไปอ่านบทความนี้ก่อนครับ

https://hackernoon.com/smoke-your-server-using-goroutines-zau32au

สิ่งที่ควรรู้เกี่ยวกับ concurrent computing

  • Concurrency คือความสามารถของโปรแกรม ที่สามารถ run code หลายๆ ส่วนในโปรแกรมพร้อมกัน หรือ run code ชุดเดียวกันในเวลาเดียวกันได้มากกว่า 1 รอบ (เหมือนเราเปิดโปรแกรมหลายๆ ตัวพร้อมกัน)
  • ส่วนใหญ่ที่เห็นใช้ concurrent กันก็เพื่อ 2 เหตุผลหลักๆ คือ เพิ่มประสิทธิภาพ (ลดเวลาที่โปรแกรมทำงาน) และเพื่อ Usability เช่น เขียนเว็บ server จำเป็นต้อง serve request ได้หลายร้อย request พร้อมกัน หรือในระดับ OS ที่ต้องรองรับการเปิดโปรแกรมหลายๆ โปรแกรมได้พร้อมกัน เช่น แต่งรูปไปด้วย ฟังเพลงไปด้วย
  • เรามักจะได้ยินคำว่า thread บ่อยๆ เมื่อพูดถึงเรื่อง concurrency ซึ่ง thread ก็คือ code ที่ถูก run โดย CPU เหมือน code ธรรมดาทั่วไป แต่ถูก run ไปพร้อมๆ กับ code หลัก (main thread) หรือพร้อม thread อื่นๆ โดยเป็นอิสระและไม่จำเป็นต้องรอให้ code หลัก หรือ thread อื่นๆ ทำงานเสร็จก่อน (ดูตัวอย่าง Goroutines ข้างล่างแล้วจะเห็นภาพครับ)

เริ่มต้นกันเลย

สิ่งที่เราควรจะรู้จักเมื่อจะเริ่มต้นเขียน concurrent programming บน Golang คือ Goroutines และ channels ครับ

Goroutines

ถ้าอธิบายสั้นๆ Goroutines ก็คือ light weight thread ครับ แต่จริงๆ แล้วการทำงานข้างหลังไม่เหมือนกันเลย ใครสนใจอ่านรายละเอียดเพิ่มเติม สามารถตามไป อ่านได้ที่นี่ (TLDR: Goroutine เหมือนเป็น virtual thread ที่ Go Runtime จะมาจัดการอีกที) แต่เพื่อให้อ่านง่ายและเป็น term ที่คุ้นเคยกับคนที่เคยเขียน concurrency ในภาษาอื่นมาก่อน ผมจะใช้คำว่า thread แทนที่ Goroutines ไปเลยนะครับ

ตัวอย่างที่ 1

ตัวอย่างแรกเป็นการเขียนโปรแกรมให้ code 2 ส่วนทำงานพร้อมกัน โดย code หลักหรือ (main thread) จะ print คำว่า “Task 1” และ code ใน thread ที่สร้างขึ้นมาใหม่จะ print คำว่า “Task 2”

ผลลัพธ์

Task 1
Task 2
Task 1
Task 2
Task 2
Task 1
Task 1
Task 2
Task 1
Task 2
Task 1
Task 2
...

จะเห็นว่า code ทั้งสองส่วนทำงานไปพร้อมๆ กัน คือ print “Task 1” และ “Task 2” ไปพร้อมๆ กัน แบบไม่มี pattern ตายตัว และไม่ได้ต้องรอให้ใครคนใดคนหนึ่งทำงานเสร็จก่อน เน้นย้ำตรงคำว่าไม่มี pattern ตายตัวนิดนึงนะครับ อันนี้เป็นคุณสมบัติของ thread เลย คือเราไม่มีทางรู้เลยว่าแต่ละ thread จะถูกหยิบขึ้นมาทำงานเมื่อไหร่ เรารู้แค่ว่า thread ทั้งหมดจะถูกหยิบขึ้นมาทำงานสลับกันไป (เสมือนว่ามันกำลังทำงานพร้อมๆ กันอยู่)

คำสั่งที่เป็น highlight คือ

go func() { ... }()

คำสั่งนี้จะทำให้ code ที่อยู่ใน func ที่ตามหลังคำสั่ง go ไปทำงานในอีก thread (สร้าง Goroutines ใหม่) และเริ่มทำงานทันที แค่นี้เลยครับ การสร้าง thread (หรือ Goroutines) ในภาษา Golang ง่ายมากๆ

ตัวอย่างที่ 2

สมมติว่าโจทย์ของเราคือได้รับ request ให้ทำงานที่ต้องใช้เวลาในการประมวลผลนานมากๆ เราต้องการตอบ request แต่ละอันทันทีเลยว่าเราเริ่มทำงานของคุณแล้ว เดี๋ยวถ้าทำงานเสร็จแล้ว เราจะส่ง email ไปบอก แล้วพร้อมที่จะรับ request ใหม่ทันที หลักการทำงานคล้ายๆ web service ที่ต้องใช้เวลาในการประมวลผลนานกว่าปกติ และไม่ต้องการให้ user คนอื่นๆ ต้องรอจนงานปัจจุบันเสร็จ

Your request has been added to queue.
Your request has been added to queue.
Your request has been added to queue.
ผ่านไป 3 วินาที...Send email >> Name=Perth gender=male age=29
Send email >> Name=Noom gender=male age=25
Send email >> Name=Onny gender=female age=25

จะเห็นว่าโปรแกรมตอบ request แต่ละอันทันทีว่า “Your request has been added to queue.” และเริ่มรับ request ต่อไปทันที พอเวลาผ่านไป 3 วินาที งานที่สั่ง run ไปก่อนหน้านี้ทำงานเสร็จ เราก็จะเห็นข้อความว่า “Send email >> xxx” เป็นตัวอย่างแสดงให้ว่าเมื่องานเสร็จเราก็ส่ง email ไปบอก user ที่เป็นเจ้าของ request ว่างานเสร็จแล้ว

processText เป็นตัวอย่างของ function ที่ใช้เวลาในการ process นาน (3 วินาที) ถ้าเปลี่ยนจาก

go processText(text)

เป็น

processText(text)

แต่ละข้อความ (request) ก็ต้องรอประมาณ 3 วินาทีจนกว่าจะพร้อมรับ request ต่อไป ซึ่งเป็น Usability ที่ไม่ดีแน่นอน การแยกแต่ละ request ไปทำงานอีก thread ทำให้เราไม่ต้องรอให้แต่ละ request ทำงานจนเสร็จ และเราสามารถพร้อมรับ request ใหม่ได้เลย

Channels

หลังจากทำความรู้จักกับ Goroutines ไปแล้ว ต่อไปเราก็จะมาทำความรู้จักกับ Channels ครับ Channels เป็นช่องทางการติดต่อสื่อสารระหว่างแต่ละ Thread (Goroutines)

ตัวอย่างที่ 1

ตัวอย่างนี้เราจะสร้าง Goroutines ขึ้นมา 2 ตัวครับ Goroutines ตัวแรกทำหน้าที่บวกเลขในตัวแปร a กับ b Goroutines ตัวที่สองรอผลลัพธ์จาก Goroutines ตัวแรก แล้วเอามาคูณ 2 และ print ผลลัพธ์

ขั้นตอนแรกก็เริ่มจากการประกาศ channel ขึ้นมาก่อนครับ

c := make(chan int)

การประกาศ channel ต้องบอกด้วยว่า channel นี้จะใช้สำหรับการรับส่งข้อมูลประเภทไหน ในตัวอย่างนี้จะสร้าง channel เพื่อส่งข้อมูลประเภท int ครับ

จากนั้นเราก็สร้าง Goroutines 2 ตัว จาก function add และ multiply และส่งตัวแปร channel ไปกับทั้ง 2 function ด้วยเพื่อให้ใช้ติดต่อสื่อสารกัน

กรณีของ function add เราจะส่งตัวแปร a และ b ไปเพื่อให้เอาค่าไปบวกกัน แต่ function multiply เราจะส่งแค่ตัวแปร channels ไปครับ เพราะ function multiply ควรจะได้ผลลัพธ์การบวกจาก function add มาทาง channel ก็เลยไม่จำเป็นต้องรู้ค่าตัวแปร a และ b

ผลลัพธ์

Result is 30

ผลลัพธ์ก็ตรงไปตรงมาครับ (10 + 5) x 2 = 30

***ถ้าใครสังเกตดีๆ code ชุดนี้มีจุดที่น่าสนใจอยู่ครับ จากที่เกริ่นไปก่อนหน้านี้ว่า เราไม่มีทางรู้เลยว่าแต่ละ thread (Goroutines) จะถูกหยิบมา run เมื่อไหร่ ในกรณีนี้มีความเป็นไปได้ที่ thread ของ function multiply จะถูกหยิบขึ้นมา run ก่อน ซึ่ง multiply ก็จะได้ผลการบวกที่ยังไม่เสร็จมา แต่ channels จะป้องกันไม่ให้เกิด bug นี้ครับ เป็น concept เรื่อง blocking ซึ่งเราจะมาพูดถึงในตัวอย่างต่อไปครับ

ตัวอย่างที่ 2

นอกจาก channel จะทำหน้าที่เป็นช่องทางการรับส่งข้อมูลระหว่าง Goroutines แล้ว channel ยังมีความสามารถในการทำ thread blocking ด้วย ซึ่งช่วยให้เราสามารถกำหนดการทำงานของ thread ได้อย่างมีประสิทธิภาพมากขึ้นโดยไม่จำเป็นต้องพึ่ง locking object เหมือนในหลายๆ ภาษา

<- c    // read from channel
c <- 1 // write to channel

การทำงานของ blocking ก็ตรงไปตรงมาเลยครับ บรรทัดที่ส่งข้อมูลเข้า channel จะโดนบล็อคจนกว่าจะมีคนมาอ่านข้อมูลจาก channel ไป และบรรทัดที่อ่านข้อมูลจาก channel ก็จะโดนบล็อคจนกว่าจะมีคนส่งข้อมูลเข้า channel

การโดน block ก็เหมือนกับโปรแกรมหยุดทำงาน และค้างอยู่ที่บรรทัดนั้นๆ ไม่ทำงานบรรทัดต่อไป คล้ายๆ ตอนเรากด Debug โปรแกรมแล้วไม่กด Step over/Step into.

Goroutines #1 has started, waiting for Goroutines #2 to start
Goroutines #2 has started, do some work and notify Goroutines #1
ผ่านไป 2 วินาที...Goroutines #2 has finished
Goroutines #1 received a notification from Goroutines #2

การทำงานของโปรแกรมก็คล้ายๆ กับตัวอย่างก่อนหน้าเลย คือสร้าง channel ขึ้นมา 1 ตัว สร้าง function ขึ้นมา 2 functions และส่ง channel ไปให้แต่ละ function ด้วย แต่รอบนี้เราลองสมมติให้ Goroutines #2 ใช้เวลาทำงานนานขึ้น เพื่อแสดงให้เห็นว่า Goroutines #1 จะต้องรอให้ Goroutines #2 ทำงานเสร็จก่อนจริงๆ ซึ่งก็เป็นไปตามนั้นครับ

ตัวอย่างที่ 3

ตัวอย่างนี้เป็นการเขียน code ที่มี bug ครับ คือมีแค่ส่วนที่เป็นการอ่านข้อมูลจาก channel เพราะฉะนั้นโปรแกรมนี้จะไม่มีวันทำงานเสร็จ (เพราะรอให้มีข้อมูลเข้ามา channel แต่ไม่มี code ที่เป็นส่วนที่ส่งข้อมูลเข้า channel) ซึ่งนี่ก็เป็นตัวอย่างของสิ่งที่เรียกว่า dead lock ครับ คือ thread ที่ทำงานด้วยกันทุกตัวอยู่ในสถานะ sleep หมด หรือในตัวอย่างนี้ก็คือ thread ทั้งหมดในโปรแกรมอยู่ในโหมด sleep (waiting)

ตัวอย่างที่ 4

ตัวอย่างนี้เราจะใช้ channel ในการช่วยจำกัดจำนวนของ Goroutines ที่เราจะสร้างกันครับ บางครั้งเราจำเป็นต้องจำกัดจำนวน Goroutines หรือ thread ในโปรแกรมของเราด้วยสาเหตุต่างๆ เช่น

  • ถ้า host ของเรามี memory แค่ 512MB และสมมติว่าการทำงานใน 1 thread ต้องการ memory ประมาณ 10MB แปลว่าเราสร้าง Goroutines ได้แค่ประมาณ 50 Goroutines ถ้ามากกว่านั้น อาจจะเกิด error out of memory และโปรแกรมก็หยุดทำงานไป (ในความเป็นจริงโปรแกรมทั่วไปก็จะไม่ใช้ memory 100% ของเครื่อง host ครับ เราต้องเผื่อให้ OS และต้องเผื่อให้โปรแกรมอื่นๆ ในเครื่องด้วย)
  • กรณีที่เราต้องการลดเวลาในการประมวลผลโดยเพิ่มจำนวน thread มาช่วยทำงาน เมื่อเราเพิ่ม thread ไปถึงจุดหนึ่งเราจะพบว่าประสิทธิภาพของโปรแกรมแย่ลง เราเรียกว่ามันเลยจุด optimum point ไปแล้วครับ เพราะว่าการสร้าง thread หรือ Goroutines ก็มี overhead เมื่อเราสร้างมันมากเกินไปก็จะทำให้ CPU ต้องมาทำงาน overhead พวกนี้แทนที่จะได้ทำงานจริงๆ ซึ่งจุด optimum point นี้ก็ขึ้นอยู่กับ spec ของ hardware และลักษณะของงานด้วยครับ

ตอนที่เราสร้าง Goroutines เราสามารถกำหนดได้ด้วยว่าเราต้องการ channel ที่มีขนาด buffer เท่าไหร่ เช่น

c := make(chan bool, 3)

หมายความเราต้องการสร้าง channel ที่สามารถจุ boolean ได้ 3 ตัว คล้ายๆ กับ array[3] ซึ่งทำให้เราสามารถใส่ข้อมูลเข้าไปได้ก่อน 3 ตัว และการใส่ข้อมูลตัวที่ 4 ถึงจะทำให้ thread ถูก block ความสามารถตัวนี้เองที่ทำให้เราสามารถควบคุมจำนวน Goroutines ที่เราจะสร้างได้

for loop บน main thread จะสร้าง Goroutines ใหม่ไปเรื่อยๆ จนเมื่อถึงรอบที่ 4 พอสร้าง Goroutines เสร็จ main thread จะโดน block เพราะว่าไม่สามารถเขียนข้อมูลเข้า channel ได้แล้ว และเมื่อผ่านไป 2 วินาที Goroutines ที่สร้างก่อนหน้านี้ก็จะทำงานเสร็จและทยอยอ่านข้อมูลจาก channel ทำให้ channel มี slot ว่างและ main thread สามารถใส่ข้อมูลเข้าไปใหม่ได้จนครบ 3 slots และโดนบล็อคอีกครั้ง ตัวอย่างนี้จะมีค่าเท่ากับเราจำกัดจำนวนของ Goroutines ให้มีได้มากที่สุดแค่ 4 ตัว

0
0
0
0
2
2
2
2
4
4
4
4
6
6
6

Goroutine ในรอบเดียวกันจะ print เลขวินาทีปัจจุบันซึ่งก็จะมีเลขที่เหมือนกันแค่ 4 ชุดเท่านั้น ตามจำนวน Goroutines ที่ถูกจำกัดไว้

ตัวอย่างที่ 5

เราสามารถใช้คำสั่ง range คู่กับ channel เพื่อให้วน loop อ่านค่าจาก channel

Got '81' from channel
Got '87' from channel
Got '47' from channel
Got '59' from channel
Got '81' from channel
Got '18' from channel
Got '25' from channel
Got '40' from channel
Got '56' from channel
Got '0' from channel

โปรแกรมนี้จะไม่มีวันหยุดทำงานและ print ค่าตัวเลขที่ random มาได้ไปเรื่อยๆ โดยที่ main thread จะเป็นคนส่งข้อมูลเข้า channel และมีอีก thread ทำหน้าที่อ่านข้อมูลจาก channel ไปเรื่อยๆ และ print ค่าออกมา

for number := range channel { ... } // read from channel

ตัวอย่างที่ 6

เราสามารถใช้คำสั่ง select เพื่อรอข้อมูลจากหลายๆ channel พร้อมๆ กันและทำงานใน case ของ channel ที่มีข้อมูลเข้ามาเป็น channel แรกได้

ตัวอย่างนี้เราจะใช้ select เพื่อกำหนด timeout ของ Goroutines

timeout := time.Tick(3 * time.Second)

คำสั่งข้างบนนี้จะสร้าง channel ใหม่ขึ้นมาและจะรอ 3 วินาทีแล้วส่งข้อมูลเข้า channel ส่วน Goroutines อีกตัวจะ random sleep ตั้งแต่ 0–10 วินาที เพื่อจำลองการทำงานที่อาจจะใช้เวลานาน และ select จะทำหน้าที่รอข้อมูลจากทั้ง 2 channels ถ้า channel จาก Goroutines เสร็จก่อน ก็แสดงว่าโปรแกรมทำงานสำเร็จภายในเวลาที่กำหนด (3 วินาที) แต่ถ้า channel จาก time.Tick พร้อมก่อน ก็แสดงว่า Goroutines ใช้เวลาในการทำงานนานเกินกว่ากำหนด

WaitGroup

คำสั่งนี้จะใช้ในกรณีที่เราต้องการรอให้ Goroutines ทำงานเสร็จก่อน ถึงทำบางอย่างต่อไป เช่น เราใช้ Goroutine ยิง REST API ไปทั้งหมด 10 ครั้งในเวลาเดียวกัน เราต้องการรอให้ REST API request ทั้งหมดเสร็จก่อน ถึงจะแสดงผลลัพธ์

การใช้งานก็มีแค่ 3 คำสั่งคือ Add, Done, Wait โดยหลัการคือ คิดคล้ายๆ กับการบวกลบเลขครับ โดยเรามีเลขตั้งต้นคือ 0 แล้ว Add คือ บวกเพิ่ม 1, Done คือ ลบออก 1 และ Wait คือจะ block thread จนกว่าเลขนั้นจะกลายเป็น 0 ตัวอย่างเช่น

doWork for C
doWork for B
doWork for A
All done!

ในตัวอย่างจะเห็นเรา ก่อนที่เราจะสั่งให้ Goroutine เริ่มทำงาน เราจะสั่ง wg.Add(1) เราเริ่ม Goroutine 3 ตัว และ Add ไป 3 ครั้ง เท่ากับว่าตอนนี้เลขตั้งต้นของ wg มีค่าเท่ากับ 3 แล้ว พอไปถึงบัดทันที่ wg.Wait() , thread ปัจจุบัน (main thread) ก็จะโดน block ไม่ให้ทำงานต่อ จนกว่าจะมีการเรียก wg.Done() 3 ครั้ง ซึ่งแปลว่าเลขตั้งต้นของ wg ก็จะกลับมาเป็น 0 อีกครั้ง ซึ่งเป็นเหตุผลที่เราต้องส่ง wg ไปให้ Goroutine ด้วย

อย่างลืมว่าต้องส่ง wg เป็น reference ไปนะครับ (คือเป็น pointer *) ถ้าไม่ใช้ reference มันจะกลายเป็นการ pass by value และจะทำให้ wg ทำงานไม่ถูก

สรุปแล้ว ก่อนที่ main thread จะสามารถ print “All done!” ได้ มันจะต้องรอให้ Goroutine ทั้ง 3 ตัวทำงานเสร็จก่อน

Tips เพิ่มเติม จริงๆ เราสามารถ Add มากกว่าทีละ 1 และถ้าต้องการลบมากกว่าทีละ 1 เราก็ใช้ Add แต่ใส่เลขติดลบได้ครับ

Mutex

mutex ย่อมาจาก Mutual execution เป็น concept ของการ share resource กันระหว่าง thread แต่จะต้องไม่ใช้งานพร้อมๆ กัน เพื่อไม่ให้เกิด conflict อย่างพวก Race condition

Golang มาพร้อมกับความสามารถในการทำ Mutex โดยใช้ sync.Mutex ซึ่งมี method 2 ตัวที่ชื่อตรงไปตรงมามากๆ คือ Lock และ Unlock

ตัวอย่างนี้ขอยืม code มาจาก tutorial ของ Golang เลยนะครับ

วิธีการใช้งานก็ง่ายๆ เลยครับ code ส่วนไหนที่เราต้องการจำกัดให้มีแค่ thread เดียวที่เข้ามาทำงานได้ เราก็ครอบมันด้วย Lock และ Unlock

mutex.Lock()// จะมีแค่ thread เดียวที่เข้ามา run code ส่วนนี้ได้mutex.Unlock()

ตัวอย่างนี้เราสร้างประเภทตัวแปรขึ้นมาใหม่ชื่อ SafeCounter และข้างในมี attribute 2 ตัวคือ map[string]int และ mux ตัวแปรประเภทใหม่นี้จะมี method อยู่ 2 ตัวชื่อว่า Inc และ Value

  • Inc จะทำหน้าที่เพิ่มค่าของ int ใน map ใต้ key ที่ user ส่งมา โดยป้องกันไม่ให้ 2 threads เข้ามาเพิ่มค่าของตัวแปรพร้อมกัน
  • Value ทำหน้าที่อ่านค่า int ใน key ที่ user ส่งมา โดยจะป้องกันไม่ให้มีการแก้ไขค่าระหว่างที่มี thread กำลังอ่านค่าของตัวแปร

***ในกรณีที่มี thread มากกว่า 2 threads พยายามแก้ค่าตัวแปรพร้อมๆ กัน หรือ thread หนึ่งพยายามอ่านค่า และอีก thread พยายามแก้ไขค่า (ถ้าทุก thread อ่านอย่างเดียวถือว่าโอเค) อาจจะทำให้เกิด Race condition และผลลัพธ์ที่คาดเดาไม่ได้ เช่น ค่าของตัวแปรไม่เพิ่มตามจำนวนที่สั่ง Inc หรือ ค่าที่อ่านได้ไม่ใช่ค่าล่าสุดเป็นต้น

สรุป

การเขียน Concurrent programming บน Golang สิ่งที่เราต้องทำความเข้าใจหลักๆ เลยคือ Goroutines และ Channels เท่านั้นเองครับ เราสามารถเอามันไปประยุกต์ใช้กับงานได้หลากหลายแบบเลย ซึ่งทั้ง Goroutines และ Channels ก็มีรายละเอียดยิบย่อยอีกประมาณหนึ่งถ้าสนใจก็ลองตามไปอ่านที่ Official document ของ Golang ได้เลยครับ แต่ถ้าเป็น task ที่ไม่ได้ซับซ้อนมาก ส่วนใหญ่แค่ concept ที่อธิบายในบทความนี้ก็เพียงพอแล้วครับ เจอกันใหม่บทความหน้าครับ

Happy coding!

Reference

--

--