Khởi động một goroutine
Đặt go trước một lời gọi hàm (function) và lời gọi đó sẽ chạy đồng thời. Câu lệnh trả về ngay lập tức; nơi gọi không chờ.
Bốn goroutine chạy shout cùng lúc. Thứ tự chúng chạy xong là không xác định, nhưng kết quả luôn theo đúng thứ tự ban đầu, vì mỗi goroutine ghi vào chỉ số của riêng nó và main chỉ đọc slice sau khi wg.Wait() trả về.
Ví dụ đó đã chứa ba thứ mà hầu như chương trình goroutine nào cũng cần: một cách để bắt đầu công việc (go), một cách để chờ nó (sync.WaitGroup), và một cách lấy kết quả về mà không để hai goroutine cùng chạm vào một vùng nhớ (mỗi goroutine một phần tử slice).
Main không chờ
Khi main trả về, chương trình thoát. Các goroutine còn đang chạy bị dừng ngay tại chỗ. Không có gì chờ chúng cả.
Chương trình này thường chỉ in ra from main. Đôi khi goroutine được lập lịch kịp và bạn thấy cả hai dòng. Chính chữ "thường" đó là vấn đề: code chạy được trên máy bạn nhưng lỗi trên một máy chủ đang tải nặng.
Một time.Sleep ở cuối main khiến bản demo in ra cả hai dòng, và đó là cách sửa sai. Nó đoán xem công việc mất bao lâu. Hãy chờ chính công việc đó, bằng một WaitGroup (trang WaitGroup trình bày chi tiết) hoặc bằng một channel.
Lấy kết quả về
Câu lệnh go bỏ đi giá trị trả về của hàm. x := go f() không biên dịch được. Có hai cách chuẩn để trả dữ liệu về.
Mỗi goroutine một ô, như trong ví dụ đầu tiên. Cấp sẵn kích thước cho một slice, đưa mỗi goroutine chỉ số của nó, rồi đọc sau Wait. Thứ tự được giữ nguyên và không cần khóa, vì không có hai goroutine nào ghi vào cùng một phần tử.
Một channel. Mỗi goroutine gửi kết quả của mình; bên nhận gom chúng lại. Kết quả đến theo thứ tự chạy xong, không theo thứ tự bắt đầu.
Việc nhận đúng len(nums) giá trị cũng đóng vai trò chờ: main không thể đi qua vòng lặp cho đến khi mọi goroutine đã gửi. Thứ tự đến thay đổi giữa các lần chạy, nên chương trình sắp xếp trước khi in bất cứ thứ gì phụ thuộc thứ tự. Trang channel trình bày channel có buffer, việc đóng channel và range trên một channel.
Biến vòng lặp và closure (thay đổi từ Go 1.22)
Từ Go 1.22, mỗi lần lặp của vòng lặp for nhận một bản sao mới của các biến vòng lặp. Một closure được khởi động trong goroutine sẽ giữ giá trị của đúng lần lặp đó, nên code này đúng:
for i, w := range words {
go func() {
results[i] = shout(w) // Go 1.22+: i and w belong to this iteration
}()
}
Trước Go 1.22, mọi lần lặp dùng chung một i và một w, và các goroutine thường đều thấy giá trị cuối cùng. Code cũ né vấn đề này bằng cách truyền giá trị làm đối số, go func(i int, w string) { ... }(i, w), hoặc bằng cách che biến, i := i. Cả hai đều vô hại từ Go 1.22 trở đi, và bạn vẫn sẽ gặp chúng trong code có sẵn. Hành vi mới áp dụng khi go.mod của module ghi go 1.22 trở lên.
Goroutine rất rẻ
Một goroutine bắt đầu với stack nhỏ (vài kilobyte) mà runtime tự nới rộng và thu hẹp khi cần. Bộ lập lịch của Go chạy goroutine trên một nhóm thread của hệ điều hành, tối đa GOMAXPROCS thread thực thi code Go cùng lúc, và mặc định GOMAXPROCS bằng số CPU. Khi bị chặn ở một channel, một mutex, một lần sleep hay I/O mạng, goroutine được tạm gác lại và thread được giải phóng cho goroutine khác.
Vì vậy, khởi động một goroutine cho mỗi tác vụ vẫn ổn, kể cả với số lượng lớn:
Một trăm nghìn goroutine chạy xong trong chưa đầy một giây. Tổng luôn là 4999950000, vì atomic.Int64 làm cho mỗi phép cộng không thể bị chia cắt. Tuy vậy, rẻ không có nghĩa là miễn phí: mỗi goroutine còn đang bị chặn vẫn giữ stack của nó và mọi thứ nó tham chiếu tới.
| Thread của hệ điều hành | Goroutine | |
|---|---|---|
| Do ai tạo | kernel | Go runtime |
| Stack ban đầu | cố định, thường từ 1 MB trở lên | vài KB, lớn lên khi cần |
| Chuyển đổi | context switch của kernel | bộ lập lịch của Go, trong user space |
| Định danh | có thread ID | không có ID đọc được, có chủ ý |
| Số lượng thường gặp | hàng trăm | từ hàng nghìn đến hàng triệu |
Data race
Hai goroutine truy cập cùng một biến vào cùng một lúc, trong đó ít nhất một bên ghi, là một data race. Kết quả không thể đoán trước, không chỉ "lệch một chút": các lần cập nhật bị mất, và một race trên giá trị string, slice, map hay interface có thể làm chương trình crash hoặc hỏng bộ nhớ.
Trên máy nhiều nhân, chương trình này in ra một số khác nhau nhỏ hơn 10000 ở hầu hết các lần chạy, vì hai goroutine đọc cùng một giá trị cũ và cả hai đều ghi lại giá trị đó cộng một. Trên máy một nhân, nó có thể in ra 10000, và điều đó còn tệ hơn: bug qua được bài test của bạn rồi lộ ra trên production.
Go có sẵn bộ phát hiện race. Chạy chương trình hoặc test với -race:
go run -race main.go
go test -race ./...
==================
WARNING: DATA RACE
Read at 0x00c000090038 by goroutine 8:
main.main.func1()
/tmp/race/main.go:16 +0x94
Previous write at 0x00c000090038 by goroutine 6:
main.main.func1()
/tmp/race/main.go:16 +0xa4
...
Found 2 data race(s)
exit status 66
Nó chỉ ra đúng dòng (counter++) và cả hai goroutine. Nó chỉ báo những race thật sự xảy ra trong lần chạy, nên hãy chạy nó trên các bài test đi qua những nhánh code đồng thời. Nó làm chương trình chậm đi vài lần, nên dùng cho test và staging, không dùng trên production.
Các cách sửa, từ đơn giản nhất đến tổng quát nhất:
- Đừng chia sẻ. Cho mỗi goroutine dữ liệu riêng và gộp lại ở cuối (mẫu mỗi goroutine một ô).
- Dùng
sync/atomiccho một bộ đếm hoặc một cờ đơn lẻ:var n atomic.Int64; n.Add(1). - Dùng
sync.Mutexbao quanh những thứ lớn hơn, như một map hay một struct có nhiều trường. Trang mutex cũng trình bàyRWMutexvàsync.Once. - Gửi dữ liệu qua channel để tại mỗi thời điểm chỉ một goroutine sở hữu nó.
Panic trong goroutine làm sập cả chương trình
Nếu một goroutine panic và không có gì recover bên trong chính goroutine đó, toàn bộ chương trình sẽ crash, kể cả main và mọi goroutine khác. Một recover trong main không giúp được gì, vì recover chỉ bắt panic trong goroutine của chính nó.
Recover theo cách này hợp lý ở lớp ngoài cùng của một máy chủ chạy lâu dài, nơi một request lỗi không được phép kéo sập phần còn lại. Trong code thông thường, panic thường có nghĩa là có bug, và crash một cách rõ ràng là kết quả đúng.
Rò rỉ goroutine
Một goroutine bị chặn mãi mãi sẽ không bao giờ thoát và không bao giờ giải phóng bộ nhớ. Nguyên nhân kinh điển là một lần gửi mà không ai sẽ nhận:
func firstResult(urls []string) string {
ch := make(chan string) // unbuffered
for _, u := range urls {
go func() { ch <- fetch(u) }()
}
return <-ch // takes the first result; the other senders block forever
}
Mỗi lần gọi làm rò rỉ len(urls) - 1 goroutine. Trong một máy chủ xử lý request này hàng nghìn lần, bộ nhớ tăng dần cho đến khi tiến trình chết. Có hai cách sửa: làm channel đủ lớn để mọi bên gửi đều chạy xong (make(chan string, len(urls))), hoặc cho các goroutine một cách để bỏ cuộc, thường là một context.Context cùng một select trên ctx.Done(). Bạn có thể theo dõi rò rỉ bằng runtime.NumGoroutine() trong test.
Giới hạn số goroutine chạy cùng lúc
"Mỗi phần tử một goroutine" ổn với 10.000 phép tính nhẹ. Nó không ổn với 10.000 HTTP request gửi đến cùng một máy chủ hay 10.000 tệp đang mở. Hãy giới hạn mức đồng thời bằng một channel có buffer dùng làm semaphore:
Channel có buffer chứa tối đa 3 token, nên tại mọi thời điểm có nhiều nhất 3 goroutine đã đi qua dòng sem <-. Mức cao nhất không bao giờ vượt quá 3, và với mười hai tác vụ đều có sleep thì trên thực tế nó chạm đến 3. Một nhóm cố định các worker goroutine đọc từ channel công việc là dạng phổ biến còn lại; trang WaitGroup xây dựng một nhóm như vậy.
Ngoài thư viện chuẩn, golang.org/x/sync/errgroup gộp WaitGroup, lỗi đầu tiên, việc hủy qua context và giới hạn mức đồng thời (g.SetLimit(n)) vào một kiểu. Đây là lựa chọn quen thuộc trong code production cần cả bốn thứ đó.
Lỗi thường gặp
- Quên chờ.
maintrả về và công việc lặng lẽ không bao giờ diễn ra. Mỗi câu lệnhgocần một cách tương ứng để biết nó đã xong. - Gọi
wg.Addbên trong goroutine.Waitcó thể chạy trướcAdd, thấy bộ đếm bằng 0 và trả về sớm. Hãy gọiAddtrước câu lệnhgo. - Chia sẻ biến mà không đồng bộ. Map là trường hợp hay gặp: ghi đồng thời vào map thường bị runtime phát hiện và làm chương trình crash với
fatal error: concurrent map writes, lỗi màrecoverkhông bắt được. - Giả định một thứ tự. Goroutine chạy theo bất kỳ thứ tự nào bộ lập lịch chọn. Nếu kết quả phải có thứ tự, hãy gom lại rồi sắp xếp, hoặc ghi vào các ô theo chỉ số.
- Dùng
time.Sleepđể đồng bộ. Nó làm test chậm mà vẫn chập chờn. Hãy chờ sự kiện, đừng chờ một phỏng đoán. - Khởi động goroutine mà không có cách dừng nó. Bất cứ thứ gì chạy vòng lặp hoặc chờ I/O nên nhận một
context.Contextđể nơi gọi có thể hủy nó.
Câu hỏi thường gặp
Goroutine trong Go là gì?
Goroutine là một lời gọi hàm chạy đồng thời với phần còn lại của chương trình. Bạn khởi động một goroutine bằng cách đặt go trước lời gọi: go work(). Goroutine do Go runtime quản lý chứ không phải hệ điều hành, và runtime phân bổ rất nhiều goroutine lên một số ít thread của hệ điều hành, nên khởi động hàng nghìn goroutine là chuyện bình thường.
Làm sao chờ các goroutine chạy xong trong Go?
Dùng sync.WaitGroup: gọi wg.Add(1) trước mỗi câu lệnh go, defer wg.Done() ở đầu goroutine, và wg.Wait() ở chỗ bạn cần tất cả đã xong. Nếu các goroutine tạo ra giá trị, việc nhận đúng một giá trị từ channel cho mỗi goroutine cũng có tác dụng chờ.
Goroutine và thread khác nhau thế nào?
Một thread của hệ điều hành có stack cố định (thường từ 1 MB trở lên) và do kernel lập lịch. Goroutine bắt đầu với stack chỉ vài kilobyte, tự lớn lên khi cần, và bộ lập lịch của Go chuyển qua lại giữa các goroutine ngay trong user space. Runtime chạy goroutine trên tối đa GOMAXPROCS thread cùng lúc (mặc định bằng số CPU).
Làm sao lấy giá trị trả về từ một goroutine?
Câu lệnh go bỏ đi giá trị trả về của hàm. Hãy gửi kết quả qua một channel (results <- compute(x)) hoặc ghi nó vào ô riêng của goroutine trong một slice đã cấp sẵn kích thước (out[i] = compute(x)) rồi đọc sau wg.Wait().
Vì sao chương trình Go thoát trước khi goroutine kịp in ra gì?
Khi main trả về, chương trình kết thúc và mọi goroutine khác bị dừng mà không chạy nốt phần code còn lại. Không có gì tự động chờ goroutine cả. Hãy chặn main cho đến khi công việc xong bằng một WaitGroup hoặc một lần nhận từ channel. Thêm time.Sleep chỉ che giấu vấn đề.