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.