Tương tranh trong Go - Phần 1: GoRoutines và WaitGroup
danh sách bài viết
Tương tranh trong Go - Phần 1: GoRoutines và WaitGroup
Viết bởi Mai Duy Dương
Bài viết dưới đây sẽ giúp bạn thấy sự hiệu quả và hữu ích của ngôn ngữ lập trình Golang trong việc viết các chương trình tương tranh (hay được biết đến rộng hơn với tên gọi tiếng Anh concurrency program).
Bạn cũng sẽ thấy được khi nào nên và không nên dùng concurrency và tại sao.
Lời mở đầu
Golang có một cách tiếp cận về concurrency rất khác so với đa số các ngôn ngữ lập trình, và được kết lại bởi lời tuyên bố của các tác giả của chính ngôn ngữ.
Không giao tiếp bằng cách chia sẻ bộ nhớ, chia sẻ bộ nhớ bằng giao tiếp.
Hay bạn có thể đọc nguyên văn:
Don't comminucate by sharing memory, share memory by communicating.
Trong Go, việc bắn ra một thứ gì đó để chạy nền là cực kỳ dễ dàng. Ví như, khi bạn có 2 hàm và muốn chạy nó cùng một thời điểm, bạn chỉ cần viết từ go trước khi gọi chúng. Và thình lình, chúng được chạy một cách tương tranh.
Nhưng khi bạn đã bắt chúng ra, làm sao để có thể nói chuyện với chúng?
Có một số cách để làm việc này. Bạn có thể dùng gói sync trong thư viện tiêu chuẩn của Go. Bạn có thể khóa chúng lại (mutex lock), không ai có thể chạm vào cho tới khi bạn hoàn thành. Hoặc bạn sau khi xong công việc bạn có thể nói với một thứ được gọi là nhóm đợi (WaitGroup), nói rằng bạn đã hoàn thành.
Hoặc bạn có thể áp dụng một cách thông thường và năng suất hơn trong go, đó là khi có các GoRoutine, chúng có thể nói chuyện với nhau qua các kênh (channel).
GoRoutine
Chúng ta sẽ xem GoRoutine là gì? Để giải thích một cách đơn giản, GoRoutines là một phần chương trình được chạy, có thể hình dung nó được chạy nền hoặc một cách tương tranh, và bạn có thể có nhiều GoRoutine chạy đồng thời.
Để tạo GoRoutine vô cùng đơn giản, chỉ cần viết go trước khi bạn gọi hàm.
Tạo GoRoutines
Để khởi động, chúng ta sẽ bắt đầu với đoạn code đơn giản sau.
Bạn tạo một đường dẫn mới và tạo 1 file main.go với nội dung.
package main

import "fmt"

func main() {
	fmt.Println("Hello, mom!")
}
Ở đây, bạn đã có 1 GoRoutine. Hàm main đã là một GoRoutine.
Và bạn có thể hình dung, GoRoutines là tập hợp các GoRoutine. Bạn có thể coi chúng là những thread rất nhẹ, không phải thread xây dựng sẵn trong phần cứng của CPU hay cũng có thể phân biệt với thread của hệ điều hành. Chúng tốn rất ít bộ nhớ, chạy rất nhanh và được quản lý bởi Go scheduler. Việc quản lý bao gồm cái gì chạy, khi nào và được chạy bao nhiêu thời gian, v.v.. Chúng làm tất cả mọi thứ kỳ diệu ở nền và sắp xếp, phân bổ mọi thứ cho bạn.
Để tiếp tục với ví dụ, tôi có đoạn code sau cho bạn
package main

import "fmt"

func printSomething(s string) {
	fmt.Println(s)
}

func main() {
	printSomething("This is the first thing to be printed!")
	printSomething("This is the second thing to be printed!")
}
Khi chạy chương trình với lệnh go run main.go, bạn sẽ thấy kết quả được in ra lần lượt:
This is the first thing to be printed!
This is the second thing to be printed!
Bạn muốn tạo một GoRoutine, việc làm đơn giản lúc này là thêm lệnh go trước khi gọi hàm
package main

import "fmt"

func printSomething(s string) {
	fmt.Println(s)
}

func main() {
	go printSomething("This is the first thing to be printed!")
	printSomething("This is the second thing to be printed!")
}
Lúc này, bạn đã bảo compiler rằng, bạn muốn thực thi đoạn code sau lệnh go trong GoRoutine. Từ đó sản sinh ra 1 GoRoutine, rồi đưa nó cho Go scheduler, và Go scheduler sẽ chăm lo cho GoRoutine của bạn.
Tuy nhiên, khi bạn chạy chương trình trên, màn hình chỉ in ra
This is the second thing to be printed!
Điều gì đã thực sự xảy ra? Bạn mong đợi chương trình sẽ sinh ra 1 GoRoutine, rồi nó sẽ gọi hàm printSomething và nó sẽ in văn bản ra màn hình. Tuy nhiên, chương trình đã thực hiện quá nhanh và đã không đủ thời gian cho GoRoutine trên để thực thi. Có thể GoRoutine đã chét hay output có thể đã được đưa vào một nơi mà bạn sẽ không bao giờ thấy được. Và dù sao đi nữa thì chương trình cũng không gặp lỗi.
Vậy làm sao để làm sửa nó? Tôi có thể chỉ cho bạn một cách
package main

import (
	"fmt"
	"time"
)

func printSomething(s string) {
	fmt.Println(s)
}

func main() {
	go printSomething("This is the first thing to be printed!")
	time.Sleep(1 * time.Second)

	printSomething("This is the second thing to be printed!")
}
Bằng cách thêm vào time.Sleep(1 * time.Second) chúng ta đã có thể đợi GoRoutine trên được thực thi và in ra cho màn hình 2 dòng:
This is the first thing to be printed!
This is the second thing to be printed!
Tuy giải được vấn đề, đây là một cách rất tệ và bạn sẽ không muốn đưa đoạn code này vào trong bất kỳ sản phẩm nào của công ty. Rất có thể nó sẽ giúp bạn bị đuổi việc và không còn được ăn bám ở công ty nữa.
Vì thế, ở phần này, tôi sẽ giới thiệu với bạn WaitGroup.
Và trước khi giới thiệu về Waitgroup, chúng ta sẽ bàn luận về giải pháp trên. Tại sao nó lại tệ.
Ở trong trường hợp này, bạn có thể thấy, time.Sleep(1 * time.Second) khiến bạn đợi quá lâu cho một việc đơn giản như printSomething(). Hoặc đối với một số công việc tốn thời gian mà bạn không biết sẽ mất bao lâu để hoàn thành, việc gọi time.Sleep(1 * time.Second) có thể kết thúc trước khi công việc mà GoRoutine của bạn còn đang xử lý. Điều đó dẫn đến kết quả sai lầm khi tính toán.
Giới thiệu về WaitGroup
// A WaitGroup waits for a collection of GoRoutines to finish.
// The main GoRoutine calls Add to set the number of
// GoRoutines to wait for. Then each of the GoRoutines
// runs and calls Done when finished. At the same time,
// Wait can be used to block until all GoRoutines have finished.
//
// A WaitGroup must not be copied after first use.
//
// In the terminology of the Go memory model, a call to Done
// “synchronizes before” the return of any Wait call that it unblocks.
type WaitGroup struct {
...
Tác giả của Go đã định nghĩa rằng, một WaitGroup đợi một tập hợp các GoRoutine kết thúc.
Ta có main GoRoutine sẽ gọi hàm Add để đặt số GoRoutine mình muốn đợi. Sau đó, mỗi GoRoutine sẽ chạy và gọi hàm Done khi kết thúc. Và khi ấy, hàm Wait sẽ được dùng để chặn (hay block) cho đến khi tất cả các GoRoutine hoàn thành. Lưu ý, WaitGroup không được copy sau lần dùng đầu tiên.
Và trước khi bắt đầu, tôi muốn thay đổi một chút cho đoạn code ở trên
package main

import (
	"fmt"
	"time"
)

func printSomething(s string) {
	fmt.Println(s)
}

func main() {
	fruits := []string{
		"Apple",
		"Blackberry",
		"Cucumber",
		"Lemon",
		"Passionfruit",
		"Satsuma",
		"Solanum quitoense",
		"Ugli fruit",
		"Yuzu",
	}

	for i, v := range fruits {
		go printSomething(fmt.Sprintf("%v %v", i, v))
	}

	time.Sleep(1 * time.Second)

	printSomething("This is the second thing to be printed!")
}
Sau khi chạy chương trình, 9 dòng đầu, các loại hoa quả sẽ không in theo một thứ tự nhất định, và chúng ta sẽ không cần quan tâm tới chuyện này.
8 Yuzu
4 Passionfruit
0 Apple
7 Ugli fruit
2 Cucumber
5 Satsuma
1 Blackberry
3 Lemon
6 Solanum quitoense
This is the second thing to be printed!
Tiếp theo tôi xóa bỏ hàm time.Sleep và thay vào đó sử dụng sync.WaitGroup
package main

import (
	"fmt"
	"sync"
)

func printSomething(wg *sync.WaitGroup, s string) {
	defer wg.Done()

	fmt.Println(s)
}

func main() {
	wg := sync.WaitGroup{}
	wg.Add(9)

	fruits := []string{
		"Apple",
		"Blackberry",
		"Cucumber",
		"Lemon",
		"Passionfruit",
		"Satsuma",
		"Solanum quitoense",
		"Ugli fruit",
		"Yuzu",
	}

	for i, v := range fruits {
		go printSomething(&wg, fmt.Sprintf("%v %v", i, v))
	}

	wg.Wait()

	printSomething(&wg, "This is the second thing to be printed!")
}
Bạn có thể nhận thấy việc khởi tạo sync.WaitGroup, và sau đó gọi wg.Add(), ở nơi mà time.Sleep hạ lạc, tôi đã thay thế nó bằng wg.Wait() và trong hàm printSomething, một pointer đến WaitGroup đã được thêm vào để khi hoàn thành có thể gọi wg.Done().
Sau khi chạy chương trình, output được in ra, tuy nhiên chúng ta gặp 1 lỗi panic: sync: negative WaitGroup counter.
8 Yuzu
6 Solanum quitoense
7 Ugli fruit
0 Apple
1 Blackberry
3 Lemon
4 Passionfruit
5 Satsuma
2 Cucumber
This is the second thing to be printed!
panic: sync: negative WaitGroup counter

goroutine 1 [running]:
sync.(*WaitGroup).Add(0xc00007cde8?, 0x28?)
...
Có vẻ như, chúng ta đã gọi Done nhiều hơn số lượng mà chúng ta Add. Để fix lỗi này, đơn giản ta có thể thêm Add trước lần print cuối cùng.
wg.Add(1)
printSomething(&wg, "This is the second thing to be printed!")
Khi đó kết quả in ra sẽ là
8 Yuzu
0 Apple
1 Blackberry
2 Cucumber
3 Lemon
4 Passionfruit
5 Satsuma
6 Solanum quitoense
7 Ugli fruit
This is the second thing to be printed!
Ở đây, ta có thể thấy chương trình hiện nay đã chạy đúng như mong đợi.
phần tiếp theo, tôi sẽ giới thiệu về tranh đoạt điều khiển (hay tiếng Anh là race condition), mutex và channels trong Go đến với các bạn.