Làm dev đau lưng

Giới thiệu về Goroutines

Sau một thời gian dài làm việc với Golang mà không chia sẻ về Goroutines thì thật thiếu sót. Lần này mình sẽ đi qua một vài khái niệm đơn giản trước, giúp bạn làm quen với Goroutines.

Goroutines

Bắt đầu với chương trình đơn giản, ta có một danh sách words và muốn in chúng ra màn hình:

words := []string{
	"one",
	"two",
	"three",
}
for i, w := range words {
	fmt.Println(i, w)
}

// output
one
two
three

Chương trình đang chạy tuần tự, nghĩa là phải in one xong thì mới đến lượt two và sau đó là three. Hmm, giờ làm sao in ra mà không phải đợi nhỉ?

-> Lúc này ta đến với khái niệm Goroutines, chỉ đơn giản thêm từ khóa go vào trước fmt.Println thôi:

words := []string{
	"one",
	"two",
	"three",
}
for _, w := range words {
	go fmt.Println(w)
}

// output
two
one

Oh, chương trình đã chạy xong nhưng three đâu rồi? Thử chạy lại một vài lần thì kết quả hoàn toàn khác nhau. It’s flaky!

Vậy giờ sửa lỗi này như nào? Thử đợi một giây xem nào:

words := []string{
	"one",
	"two",
	"three",
}
for _, w := range words {
	go fmt.Println(w)
}
time.Sleep(time.Second)

// output
one
three
two

Bây giờ kết quả đã đủ các giá trị nhưng nó không hề in theo thứ tự, vì sao?

-> Bởi vì Goroutines thực thi các lệnh độc lập và đồng thời, vì vậy mà không thể bảo đảm thứ tự.

WaitGroups

Tuy nhiên trong thực tế, khi một function thực thi, chúng ta không thế nào biết được phải đợi bao lâu. Ví dụ:

printAny := func(value any) {
	time.Sleep(2 * time.Second) // assume we do a heavy processing which takes 2 seconds
	fmt.Println(value)
}
words := []string{
	"one",
	"two",
	"three",
}
for _, w := range words {
	go printAny(w)
}
time.Sleep(time.Second) 

// output

Lúc này chẳng có output nào cả, vì printAny mất hơn 2s mỗi khi thực thi trong khi ta chỉ đợi có 1s. Vậy làm sao để giải quyết vấn đề này?

-> Đến lúc giới thiệu WaitGroups rồi:

var wg sync.WaitGroup

printAny := func(value any) {
	time.Sleep(2 * time.Second) // assume we do a heavy processing which takes 2 seconds
	fmt.Println(value)
	wg.Done() // make this function complete by decreasing the WaitGroup by one

}
words := []string{
	"one",
	"two",
	"three",
}

wg.Add(len(words)) // define the number of goroutines that will run
for _, w := range words {
	go printAny(w)
}
wg.Wait() // wait for all goroutines complete

// output
two
three
one

Kết quả đúng như mong đợi, WaitGroups giúp ta chờ các goroutines hoàn tất bằng cách:

  • Đầu tiên ta phải khai báo số lượng goroutines sẽ chạy với wg.Add, hay gọi là counter đi.
  • Khi một chức năng thực thi xong, giảm counter lại bằng cách gọi wg.Done.
  • Cuối cùng, wg.Wait giúp khóa chương trình cho đến khi tất cả chức năng thực thi xong (chờ counter về 0).

Race Condition and Mutex

Tuy nhiên, như bao chương trình xử lý đồng thời khác, ta sẽ đối mặt với vấn đề race condition. Cùng tạo một function đơn giản updateName để reproduce nào.

var wg sync.WaitGroup

name := "Qui"
updateName := func(newName string) {
	fmt.Printf("Start updating from '%s' to '%s'\n", name, newName)
	name = newName
	wg.Done()
}

wg.Add(3)
go updateName("Khanh Qui")
go updateName("Khanh Qui Vo")
go updateName("Steve")
wg.Wait()
fmt.Printf("Final result: '%s'\n", name)

// output
Start updating from 'Qui' to 'Steve'
Start updating from 'Qui' to 'Khanh Qui Vo'
Start updating from 'Qui' to 'Khanh Qui'
Final result: 'Khanh Qui'

Xem nào, tên đã được cập nhật thành công. Nhưng khoan đã, tại sao updateName luôn Start updating from 'Qui', tức là nó luôn access giá trị cũ mỗi khi đổi tên.

-> Vấn đề này gọi là race condition vì nhiều goroutines access cùng một giá trị và cùng một thời điểm.

Điều này cực kì nguy hiểm, nếu không cẩn thận nó có thể passed test dễ dàng. Một khi viết code ta nên expect chương trình:

  • updateName cập nhật được tên.
  • updateName phải access giá trị mới nhất rồi mới thực hiện update.

Hmm, cùng chỉnh sửa một xíu để sửa lỗi này nào:

var wg sync.WaitGroup

name := "Qui"
updateName := func(newName string) {
	fmt.Printf("Start updating from '%s' to '%s'\n", name, newName)
	name = newName
	wg.Done()
}

wg.Add(1)
go updateName("Khanh Qui")
wg.Wait()

wg.Add(1)
go updateName("Khanh Qui Vo")
wg.Wait()

wg.Add(1)
go updateName("Steve")
wg.Wait()
fmt.Printf("Final result: '%s'\n", name)

// output
Start updating from 'Qui' to 'Khanh Qui'
Start updating from 'Khanh Qui' to 'Khanh Qui Vo'
Start updating from 'Khanh Qui Vo' to 'Steve'
Final result: 'Steve'

Và giờ chương trình chạy đúng như ta mong đợi, nhưng goroutines phải đợi các thèng khác hoàn thành trước. Nói cách khác, nó không khác gì một chương trình chạy tuần tự bình thường cả, chỉ là nhìn phức tạp hơn thôi 😂

-> Đây là lúc giới thiệu Mutex - a mutual exclusion lock

var wg sync.WaitGroup
var lock sync.Mutex

name := "Qui"
updateName := func(newName string) {
	lock.Lock()
	fmt.Printf("Start updating from '%s' to '%s'\n", name, newName)
	name = newName
	lock.Unlock()
	wg.Done()
}

wg.Add(3)
go updateName("Khanh Qui")
go updateName("Khanh Qui Vo")
go updateName("Steve")
wg.Wait()
fmt.Printf("Final result: '%s'\n", name)

// output
Start updating from 'Qui' to 'Steve'
Start updating from 'Steve' to 'Khanh Qui Vo'
Start updating from 'Khanh Qui Vo' to 'Khanh Qui'
Final result: 'Khanh Qui'

Yah đây là cái ta thật sự muốn. Chương trình chạy song song, và mỗi khi access một tài nguyên chung, nó phải đợi Unlock thì mới được phép access. Cái này còn gọi là thread-safe.

Đến đây bài cũng dài, hẹn anh chị em dịp sau nhe, cảm ơn mọi người đã đọc đến đây.