Trong bài viết này, mình sẽ đề cập đến một số trường hợp và sự cố phổ biến mà bạn có thể gặp phải khi sử dụng goroutines và cách để giải quyết chúng.
1. Giới thiệu
Đầu tiên, goroutine là gì? Bản chất Golang là concurrent. Để đạt được tính concurrent, Go sử dụng các goroutines - các hàm hoặc phương thức chạy đồng thời với các hàm hoặc phương thức khác. Ngay cả hàm main của Golang cũng là một goroutine.
Goroutines có thể được xem như các lightweight thread, nhưng không giống như các thread, chúng không được quản lý bởi hệ điều hành mà bởi runtime của Golang.
Việc một ứng dụng Go có hàng trăm, thậm chí hàng nghìn goroutines chạy đồng thời là điều rất bình thường.
(Đọc thêm về goroutines tại đây)
Hãy bắt đầu với một ví dụ nhanh và tạo một tệp hello.go:
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Hello")
}
func main() {
go hello()
time.Sleep(1 * time.Second)
fmt.Println("main function")
}
và output sẽ là:
$ go run hello.go
Hello
main function
Tuyệt vời, goroutine của chúng ta đã thực thi thành công.
Tuy nhiên, khi bạn bắt đầu thêm nhiều chức năng hơn vào các goroutines, bạn cũng có thể gặp phải một trong những trường hợp phổ biến dưới đây.
2. Vấn đề Waiting…
Hãy bắt đầu với một cái đơn giản:
Như bạn có thể nhận thấy, việc sử dụng time.Sleep rất phổ biến khi thể hiện chức năng cơ bản của goroutines.
Vậy tại sao sleep lại cần thiết ở đây? Hãy kiểm tra nó ngay lập tức mà không cần hàm time.Sleep.
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Hello")
}
func main() {
go hello()
// time.Sleep(1 * time.Second) // now it's commented out
fmt.Println("main function")
}
$ go run hello.go
main function
Rất tiếc, hiện tại output của goroutine bị thiếu. Tại sao lại như vậy?
Bởi vì quá trình thực thi của chương trình bắt đầu bằng cách khởi tạo main package và sau đó call hàm main
. Khi lệnh gọi hàm đó trả về, chương trình sẽ thoát. Nó không đợi các goroutines khác (non-main
) hoàn thành.
Có nghĩa là khi hàm main kết thúc, nó sẽ thực thi, nó sẽ không đợi các goroutines khác kết thúc.
Vì vậy, bây giờ chúng ta đã hiểu sự cần thiết của việc đợi các goroutines khác kết thúc, có cách nào hiệu quả và hiệu quả hơn để đợi goroutines kết thúc, thay vì đoán xem sẽ mất bao lâu để kết thúc goroutines?
Có chứ! nó được gọi là WaitGroups.
WaitGroups cho phép chúng ta chặn cho đến khi tất cả các goroutines trong WaitGroups đó hoàn tất quá trình thực thi của chúng.
Một ví dụ về triển khai WaitGroup:
package main
import (
"fmt"
"sync"
)
func hello(wgrp *sync.WaitGroup) {
fmt.Println("Hello")
wgrp.Done() /////// notifies the waitgroup that it finished
}
func main() {
var wg sync.WaitGroup
wg.Add(1) /////// adds an entry to the waitgroup counter
go hello(&wg)
wg.Wait() ////// blocks execution until the goroutine finishes
fmt.Println("main function")
}
Chạy code
$ time go run hello.go
Hello
main function
real 0m0.230s
user 0m0.240s
sys 0m0.099s
Tốt hơn và nhanh hơn, vì chúng ta không phải đợi một khoảng thời gian cố định.
3. Deadlocks
Có thể bạn đã từng gặp lỗi đáng sợ này trước đây
fatal error: all goroutines are asleep - deadlock!
Deadlock xảy ra khi một nhóm goroutines đang đợi nhau và không ai trong số đó có thể tiến hành.
Hãy nhớ rằng, main package cũng là goroutine.
package main
import (
"fmt"
"sync"
)
func hello(wgrp *sync.WaitGroup) {
fmt.Println("Hello")
wgrp.Done() /////// removing the wgrp.Done will cause a deadlock
}
func main() {
var wg sync.WaitGroup
wg.Add(2) ///// 2 as the value will cause a deadlock
go hello(&wg)
wg.Wait() ////// blocks execution until the goroutine finishes
fmt.Println("main function")
}
- wgrp.Done() đánh dấu việc thực thi chương trình goroutine đã kết thúc. Nếu bỏ qua điều này cũng sẽ gây ra deadlock.
- wg.Add() nhận số lượng goroutines mà chúng ta nên chờ đợi.
Những giá trị khả thi bao gồm:
0 và goroutine sẽ không thực thi
1 sẽ hoạt động như mong đợi
2 trở lên sẽ dẫn đến deadlock
Trong cả hai trường hợp, chúng ta sẽ gặp phải deadlock vì hàm main đợi quy trình khác hoàn thành quá trình thực thi của nó:
Trường hợp 1: Goroutine sẽ không bao giờ đánh dấu việc thực thi nó trên WorkGroup là xong.
Trường hợp 2: wg.Add sẽ tiếp tục chờ đợi nhiều goroutines hơn dự kiến để chạy.
Một trường hợp khác mà bạn sẽ gặp deadlock là khi không có goroutines nào khác để nhận những gì người gửi gửi, vì điều này không thể xảy ra trong cùng một goroutine:
package main
import "fmt"
func main() {
c := make(chan string)
c <- "hello"
fmt.Println(<-c)
}
thay vào đó, hãy làm điều này:
package main
import (
"fmt"
)
func main() {
c := make(chan string)
go func() {
get := <-c
fmt.Println("get value:", get)
}()
fmt.Println("push to channel c")
c <- "hello" // send the value and wait until it's received.
}
4. Kết quả bất ngờ
Thêm một vòng lặp for vào hỗn hợp:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
players := []string{"James Harden", "Kyrie Irving", "Kevin Durant"}
wg.Add(len(players))
for _, player := range players {
go func() {
fmt.Printf("printing player %s\n", player)
wg.Done()
}()
}
wg.Wait()
}
$ go run hello.go
printing player Kevin Durant
printing player Kevin Durant
printing player Kevin Durant
Huh? Có phải nó sẽ in các tên khác nhau mỗi lần lặp lại không?
Uhm.. đúng là như vậy, nhưng các goroutines được tạo bên trong vòng lặp for sẽ không nhất thiết phải thực thi tuần tự.
Mỗi quy trình bắt đầu ngẫu nhiên.
Cách giải quyết khá đơn giản, chúng ta sẽ chỉ chuyển mục hiện tại của lần lặp:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
players := []string{"James Harden", "Kyrie Irving", "Kevin Durant"}
wg.Add(len(players))
for _, player := range players {
go func(baller string) { // add the current iterated player
fmt.Printf("printing player %s\n", baller)
wg.Done()
}(player) // add the current iterated player
}
wg.Wait()
}
$ go run hello.go
printing player James Harden
printing player Kevin Durant
printing player Kyrie Irving
5. Race conditions và chia sẻ data giữa các goroutine
Giờ đây, nó trở nên phức tạp và thú vị hơn một chút:
Hãy tưởng tượng rằng bạn có một ứng dụng ngân hàng, nơi khách hàng có thể gửi và rút tiền.
Miễn là ứng dụng là single thread và đồng bộ, sẽ không có bất kỳ vấn đề gì, nhưng điều gì sẽ xảy ra nếu ứng dụng của bạn tạo ra hàng trăm hoặc hàng nghìn goroutines?
Hãy xem xét tình huống này:
Một khách hàng có số dư là 100 đô la và gửi 50 đô la vào tài khoản của mình.
Một goroutines xem các giao dịch, đọc số dư hiện tại là 100 đô la và thêm 50 đô la vào số dư.
Nhưng khoan đã, đồng thời cũng có một khoản phí 80 đô la được áp dụng cho tài khoản của khách hàng để thanh toán hóa đơn của anh ta tại quán bar địa phương.
Goroutine thứ hai sẽ đọc số dư hiện tại khi đó là 100 đô la, trừ đi 80 đô la trong tài khoản và cập nhật số dư tài khoản.
Sau đó, khách hàng sẽ kiểm tra số dư tài khoản của mình và thấy rằng đó chỉ là 20 đô la thay vì 70 đô la, vì goroutine thứ hai đã ghi đè giá trị số dư khi xử lý giao dịch đó.
Để giải quyết vấn đề này, chúng ta có thể sử dụng Mutex.
Mutex? Mutex (loại trừ lẫn nhau) là một phương pháp được sử dụng như một locking mechanism để đảm bảo rằng chỉ có một Goroutine đang truy cập vào phần code quan trọng tại bất kỳ thời điểm nào.
Thêm Mutexes tại đây. Nó sẽ trông như thế này:
package main
import (
"fmt"
"sync"
)
var (
mutex sync.Mutex
balance int
)
func init() {
balance = 100
}
func deposit(val int, wg *sync.WaitGroup) {
mutex.Lock() // lock
balance += val
mutex.Unlock() // unlock
wg.Done()
}
func withdraw(val int, wg *sync.WaitGroup) {
mutex.Lock() // lock
balance -= val
mutex.Unlock() // unlock
wg.Done()
}
func main() {
var wg sync.WaitGroup
wg.Add(3)
go deposit(20, &wg)
go withdraw(80, &wg)
go deposit(40, &wg)
wg.Wait()
fmt.Printf("Balance is: %d\n", balance)
}
$ go run mutex.go
Balance is: 80
Hãy chú ý đến các lệnh mutex.Lock() command và mutex.Unlock() command để làm cho nó xảy ra.
Chúng ta vẫn sử dụng workgroup theo cách tương tự như đã giải thích trước đó.
Có một cách khác để giải quyết nó, lần này là sử dụng các channel.
Channel là các đường ống kết nối các tuyến concurrent goroutines. Bạn có thể gửi các giá trị vào các channel từ một goroutine và nhận các giá trị đó vào một goroutine khác.
Hãy nhớ rằng, hàm main cũng là một goroutine.
(Xem thêm các channel tại đây)
Trong ví dụ này, chúng ta sử dụng buffered channel.
Buffered channel này được sử dụng để đảm bảo rằng chỉ một goroutine có thể truy cập vào phần code quan trọng, là phần điều chỉnh số dư.
package main
import (
"fmt"
"sync"
)
var (
balance int
)
func init() {
balance = 100
}
func deposit(val int, wg *sync.WaitGroup, ch chan bool) {
ch <- true
balance += val
<-ch
wg.Done()
}
func withdraw(val int, wg *sync.WaitGroup, ch chan bool) {
ch <- true
balance -= val
<-ch
wg.Done()
}
func main() {
var wg sync.WaitGroup
ch := make(chan bool, 1) // create the channel
wg.Add(3)
go deposit(20, &wg, ch)
go withdraw(80, &wg, ch)
go deposit(40, &wg, ch)
wg.Wait()
fmt.Printf("Balance is: %d\n", balance)
}
$ go run buf.go
Balance is: 80
Chúng ta đã tạo một buffered channel với dung lượng là1
, vì chúng ta muốn sửa đổi số dư chỉ một lần cho mỗi operation và nó được chuyển cho các goroutines gửi/rút tiền.
Vậy chúng ta nên chọn cái nào?
Nói chung, sử dụng các channel khi các Goroutines cần giao tiếp với nhau và tắt tiếng khi chỉ một Goroutine truy cập vào phần code quan trọng.
Trong trường hợp này, cách tốt nhất là sử dụng Mutex.
Mình hy vọng bạn thấy bài viết này hữu ích.
Bài viết được lược dịch từ Reshef Sharvit.
Chau Le
Cực thích đồ ngọt như sữa, kem. Thích phiêu lưu mạo hiểm nhưng vì Covid mà vô tình trở thành người chia sẻ nội dung lập trình :))
follow me :
Bài viết liên quan
Design Pattern là gì? 23 Classic Design Pattern với Golang
Aug 21, 2024 • 3 min read
Concurrency pattern trong Go - Phần 2
Apr 11, 2024 • 7 min read
Concurrency pattern trong Golang - Phần 1
Mar 25, 2024 • 8 min read
So sánh Golang và NodeJS chi tiết
Oct 14, 2023 • 22 min read
Buffered Channel là gì? Thường xuyên hỏi trong phỏng vấn Golang Dev
Jun 14, 2023 • 6 min read
Golang Channel là gì? Các ứng dụng Channel trong Golang
Jun 14, 2023 • 12 min read