Nếu bạn từng viết Go, chắc hẳn đã từng gặp tình huống kỳ lạ khi sử dụng biến của vòng lặp for
(loop variable). Mặc dù code trông có vẻ đúng, nhưng kết quả lại không như mong đợi. Đây là một cái bẫy phổ biến mà nhiều lập trình viên Go từng rơi vào – biến vòng lặp bị “capture” sai cách trong closure.
Trong bài viết này, chúng ta sẽ tìm hiểu:
- Tại sao vấn đề này xảy ra?
- Ví dụ minh họa lỗi thường gặp
- Cách khắc phục
- Những cải tiến mới từ Go 1.22 giúp giải quyết triệt để vấn đề
- Hướng đến tương lai: cách debug để tìm hiểu nguồn ngọn vấn đề
- Tổng kết: chuyên mục dành riêng cho anh em chỉ muốn TL;DR
Vấn đề là gì?
Hãy bắt đầu với một ví dụ đơn giản:
func main() {
a := []int{1, 2, 3}
for i := range a {
go func() {
fmt.Println(a[i])
}()
}
time.Sleep(1 * time.Second)
}
Kết quả mà chúng ta mong đợi (không quan tâm thứ tự):
1
2
3
Nhưng thực tế là:
3
3
3
Tại sao lại như vậy?
Nếu chúng ta thử in ra địa chỉ của biến i
func main() {
a := []int{1, 2, 3}
for i := range a {
fmt.Println(&i)
}
}
>>> Console:
0xc00001e0d8
0xc00001e0d8
0xc00001e0d8
Có thể thấy địa chỉ của biến i không hề thay đổi sau mỗi lần for. Điều này dẫn đến giả thuyết rằng tất cả vòng lặp in ra 3 là bởi vì đều in ra giá trị a[2].
Thật đúng là 1 tính năng vô cùng ảo diệu của Go. Nếu bạn sử dụng GoLand (hàng JetBrain dành riêng cho tín đồ của Go), sẽ thấy 1 dòng kẻ vàng ngay dưới i
Ah ha, xem ra “tính năng này” cũng hay gặp phải trong cộng đồng đến mức IDE phải thêm cảnh báo.
Vén màn
Vấn đề của vòng for
Khi dùng for i := range a
, biến i
được tái sử dụng qua mỗi vòng lặp. Điều đó có nghĩa là địa chỉ của biến i
không thay đổi, chỉ giá trị của nó bị cập nhật.
Vì vậy, ta có thể đoán được ví dụ đầu tiên đã xảy ra quá trình sau:
- Biến
i
khởi đầu với giá trị 0 - Lần lặp thứ nhất: goroutine thứ 1 sẽ print ra giá trị a[0]
- Lần lặp thứ hai: goroutine thứ 2 sẽ print ra giá trị a[1]
- Tuy nhiên, vì địa chỉ của biến i không thay đổi, dẫn đến goroutine thứ 1 cũng sẽ print ra giá trị a[1]
- Lần lặp thứ ba: goroutine thứ 1 và 2 sẽ print ra giá trị a[2] thay vì a[1]
Vì vậy, khi các hàm func()
được gọi sau vòng lặp, tất cả đều in ra giá trị cuối cùng của i
, chính là 3
.
Tính năng của closure
Trong closure, biến mà được khai báo ở ngoài closure sẽ tham chiếu đến biến bên ngoài (outer variable) chứ không phải là bản sao của biến bên ngoài.
Ví dụ:
func main() {
i := 3
fmt.Println("Outer", &i)
{
fmt.Println("Inner", &i)
}
}
>>> Console:
Outer 0xc00001e0d8
Inner 0xc00001e0d8
Biến Outer và inner i
đều có chung địa chỉ 0xc00001e0d8
. Giờ chúng ta quay lại ví dụ goroutine
func main() {
a := []int{1, 2, 3}
for i := range a {
fmt.Println("Outer", &i)
go func() {
fmt.Println("Inner", &i)
}()
}
time.Sleep(1 * time.Second)
}
>>> Console:
Outer 0xc0000ac008
Outer 0xc0000ac008
Outer 0xc0000ac008
Inner 0xc0000ac008
Inner 0xc0000ac008
Inner 0xc0000ac008
Tất cả đều cùng trỏ đến chung 1 địa chỉ.
Nói tóm lại, lỗi này xảy ra do 2 yếu tố sau đồng thời xảy ra:
- biến
i
của vòng for được tái sử dụng - biến
i
bên trong closure tham chiếu đến biếni
của vòng for
Do vậy, để khắc phục lỗi, chúng ta chỉ cần làm mất 1 trong 2 yếu tố là được ;). Trong bài blog này đề cập đến 3 cách. Trong đó, cách 1 và 2 làm ảnh hưởng yếu tố thứ 2, còn cách 3 sẽ giải quyết yếu tố thứ 1.
Tuy nhiên, trước khi sang cách khắc phục lỗi, chúng ta cùng tìm hiểu thêm các ví dụ minh hoạ khác của lỗi này
Các ví dụ minh hoạ khác
Đến đây, 1 số bạn đọc có thể nghĩ tính năng này một phần là do range
. Thực nghiệm sẽ phủ nhận giả thuyết đó với ví dụ sau chỉ có vòng for
:
func main() {
a := []int{1, 2, 3}
for i := 0; i < len(a); i++ {
go func() {
fmt.Println(a[i])
}()
}
time.Sleep(1 * time.Second)
}
>>> Console:
panic: runtime error: index out of range [3] with length 3
Ái chà. Vì sau khi vòng lặp kết thúc, biến i đã tăng lên giá trị là 3. Và thế là các goroutine thi nhau truy cập vào index 3 của mảng, gây ra lỗi index out of range
ngay.
Kiểm chứng luôn? Nếu chúng ta thêm sleep vào sau mỗi goroutine, code sẽ chạy ngon ơ. Có thể thấy vì goroutine đã chạy trước khi sang lần lặp tiếp theo, kết quả đầu ra đúng như đã dự liệu.
func main() {
a := []int{1, 2, 3}
for i := 0; i < len(a); i++ {
go func() {
fmt.Println(a[i])
}()
time.Sleep(1 * time.Second)
}
time.Sleep(1 * time.Second)
}
>>> Console:
1
2
3
Xét một ví dụ khác được truyền cảm hứng từ wiki CommonMistake từ go.dev, không phải lúc nào goroutine cũng là yếu tố dẫn đến “tính năng” này
func main() {
a := []*int{}
for i := 0; i < 3; i++ {
a = append(a, &i)
}
fmt.Println(*a[0], *a[1], *a[2]) // expected: 0 1 2
}
>>> Console:
3 3 3
Vì mảng a
lưu trữ địa chỉ của biến i
, cho nên khi kết thúc vòng lặp, i có giá trị là 3, dẫn đến console 1 loạt dãy số 3.
Ngoài ra, lỗi này cũng hay xảy ra đối với khi gọi phương thức của struct
type DummyInt int
func (di *DummyInt) View() {
fmt.Println(*di)
}
func main() {
a := []DummyInt{1, 2, 3}
for _, di := range a {
go di.View()
}
time.Sleep(1 * time.Second)
}
>>> Console:
3
3
3
Ở đây, biến di
đã gọi phương thức View()
với chế độ pointer receiver
. Điều này dẫn đến cả 3 goroutine đều gọi đến phần thử thứ 3 của mảng a
.
Và nếu ta truy cập qua index, chương trình sẽ chạy như chúng ta mong đợi
for i := range a {
go a[i].View()
}
>>> Console:
3
1
2
Tương tự, nếu chúng ta đổi phương thức func (di *DummyInt) View()
sang func (di DummyInt) View()
, chương trình sẽ chạy như chúng ta mong đợi
type DummyInt int
func (di DummyInt) View() {
fmt.Println(di)
}
func main() {
a := []DummyInt{1, 2, 3}
for _, di := range a {
go di.View()
}
time.Sleep(1 * time.Second)
}
>>> Console:
1
3
2
Cách khắc phục
Cách 1: Tạo biến mới trong vòng lặp
for i := range a {
i := i
go func() {
fmt.Println(a[i])
}()
}
Giờ thì mỗi closure “capture” một biến riêng biệt, nên kết quả đúng:
1
3
2
Cách 2: Truyền tham số cho inner function
for i := range a {
go func(i int) {
fmt.Println(a[i])
}(i)
}
Hai cách vừa nêu đã giải quyết lỗi bằng cách xử lý yếu tố thứ 2 (1 trong 2 yếu tố gây nên lỗi loopvar)
- biến
i
bên trong closure tham chiếu đến biếni
của vòng for
Chúng ta cùng điểm qua các dự án open source trong cộng động đã xử lý lỗi này như thế nào:
- Kubernetes sử dụng cách 1 để sửa lỗi trong hàm test
- Flyte sử dụng cách 1 để sửa lỗi trong hàm
BootStrapSchedulesFromSnapShot
- skipper cũng sử dụng cách 1 để sửa các lỗi liên quan đến loopvar
- Gitlab cũng đã từng tích hợp tool exportloopref vào quy trình phát triển để tự động phát hiện các lỗi liên quan đến loopvar. Giờ đây, họ đã dừng tích hợp tool này sau khi đã sử dụng Go ≥ 1.22
1 case study nổi tiếng đến từ Let’s Encrypt. Người viết code đã biết được sự tồn tại của lỗi này và sử dụng cách 1 để work around, tuy nhiên sau cùng code lại dùng v
thay vì kCopy
để truyền vào hàm modelToAuthzPB
func authzModelMapToPB(m map[string]authzModel) (*sapb.Authorizations, error) {
...
for k, v := range m {
// Make a copy of k because it will be reassigned with each loop.
kCopy := k
authzPB, err := modelToAuthzPB(&v)
...
}
...
}
Đối với lỗi này, chúng ta có thể sửa theo 2 cách
// cách 1
authzPB, err := modelToAuthzPB(m[k])
// chú ý: không thực sự cần dùng tới kCopy, bởi vì đoạn code này ko dùng goroutine, tức là code chạy lần lượt
// -> luôn truyền đúng giá trị m[k] vào modelToAuthzPB trong mỗi vòng lặp
// cách 2: cách của Let's Encrypt
authzPB, err := modelToAuthzPB(v)
// -> cách này dẫn phải đổi phương thức modelToAuthzPB từ nhận pointer sang nhận value
// <https://github.com/letsencrypt/boulder/pull/4690/files#diff-e927ce9f2271d03ae5e746d3fe38532d7b4259783b3dc1a7364645c02ef45bcfL606-L608>
Vụ việc này năm 2020 này đã ảnh hưởng tới hơn 3 triệu cert.
Of currently-valid certificates, 3,048,289 certificates are affected (out of approximately 116 million overall active certificates).
Vì vậy, đội ngũ cũng đã tích hợp thêm công cụ gosec nhằm tìm ra và sửa hàng loạt các lỗi loopvar tương tự ở issue Anchor all referenced loop variables #4991
Cách 3: Nâng cấp Go thành phiên bản ≥ 1.22
Khi vấn đề do thư viện hoặc ngôn ngữ thì chúng ta nâng cấp phiên bản thôi nhỉ 😃?
Ở phần tiếp theo, chúng ta sẽ thấy được Go 1.22 đã giải quyết yếu tố thứ 1 mà bài blog đã nhắc ở trên:
- biến
i
của vòng for được tái sử dụng
Cập nhật từ Go 1.22: Đánh đổi hiệu năng với sự rõ ràng
Release note của Go 1.22 đã thông báo về sự thay đổi scope của biến vòng lặp.
In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs
Điều này có nghĩa rằng mỗi vòng lặp giờ đây sẽ tạo bản sao riêng biệt của biến vòng lặp. Thay vì là “per-loop scope”, các biến vòng lặp sẽ có “per-iteration scope”.
Ví dụ code ban đầu giờ sẽ in ra đúng kết quả như mong đợi:
func main() {
a := []int{1, 2, 3}
for i := range a {
go func() {
fmt.Println(a[i])
}()
}
time.Sleep(1 * time.Second)
}
// Go 1.22+ behavior
>>> Console:
1
2
3
Ở đây, mỗi lần lặp sẽ tính là 1 scope cho biến i
, cho nên khi sang lần lặp mới, biến i
sẽ là biến i
mới. Đây chính là “per-iteration scope”.
Giờ chúng ta cùng đào sâu xem đội ngũ Go Dev đã fix thế nào nhé.
- Quay trở lại vào năm 2023, bác Russ Cox đã đề xuất giải pháp trên thông qua issue spec: less error-prone loop variable scoping #60078
- Thực tế, vấn đề này đã được thảo luận từ 2022 ở Discussion redefining for loop variable semantics #56010.
- Chi tiết của đề xuất được nêu ở Proposal: Less Error-Prone Loop Variable Scoping.
Và sau một hồi lục lọi vào các commit ở repo golang/tools, chúng ta có thể thấy:
Đối với range
, ở dòng 2149 go/ssa/builder.go, biến mới sẽ được tạo bên trong vòng lặp đối với Go 1.22 trở lên.
if s.Tok == token.DEFINE && afterGo122 {
// go1.22: If iteration variables are defined (:=), this occurs inside the loop.
createVars()
}
Còn đối với các phiên bản thấp hơn, biến mới sẽ được tạo ở ngoài vòng lặp (thấy được ở dòng 2113)
if s.Tok == token.DEFINE && !afterGo122 {
// pre-go1.22: If iteration variables are defined (:=), this
// occurs once outside the loop.
createVars()
}
Còn đối với vòng for
, ở dòng 1908 go/ssa/builder.go, biến next
sẽ được tạo mới ở mỗi lần vòng lặp, và load giá trị cũ vào
next := emitLocal(fn, typ, v.Pos(), v.Name())
emitStore(fn, next, load, token.NoPos)
Hiện nay, Go Spec cũng đã tài liệu hoá lại hành vi của for ở 2 phiên bản khác nhau
Each iteration has its own separate declared variable (or variables) [Go 1.22]. The variable used by the first iteration is declared by the init statement. …… Prior to [Go 1.22], iterations share one set of variables instead of having their own separate variables
Cách debug
Nếu chúng ta gặp lỗi trên hoặc lỗi tương tự lần đầu tiên, làm thế nào để debug và nhanh chóng tìm ra được nguyên nhân?
Cách cơ bản nhất chính là đặt ra các giả thuyết và kiểm chứng chúng. Ví dụ như ở đầu blog này, chúng ta đã in ra địa chỉ của biến i
để thấy được địa chỉ của biến i
không hề thay đổi sau mỗi vòng lặp.
Đối với các bạn có kinh nghiệm về Assembly hay thậm chí Reverse, có thể sử dụng go build -gcflags="-l -S" main.go
để phân tích vấn đề ở mức độ sâu hơn.
Bên cạnh đó, Go cũng đã cung cấp 2 công cụ vô cùng đắc lực để phát hiện lỗi này khi có sự xuất hiện của goroutine
- go vet
- go run -race
>>> Console: go vet
./main.go:13:18: loop variable i captured by func literal
>>> Console: go run -race main.go
3
3
==================
WARNING: DATA RACE
Read at 0x00c000020118 by goroutine 7:
main.main.func1()
main.go:13 +0x4c
Previous write at 0x00c000020118 by main goroutine:
main.main()
main.go:11 +0xb3
Goroutine 7 (running) created at:
main.main()
main.go:12 +0x92
==================
3
Found 1 data race(s)
exit status 66
Tổng kết
- Đối với Go ≤ 1.21,
for
không tạo bản sao biến vòng lặp, mà giữ tham chiếu.- Điều này tăng khả năng code dính lỗi logic khi dùng closure trong vòng lặp
for
. - → dùng các kĩ thuật để tạo ra bản sao của biến trong vòng lặp.
- Điều này tăng khả năng code dính lỗi logic khi dùng closure trong vòng lặp
- Go ≥ 1.22 đã cải thiện điều này bằng cách tự động tạo bản sao của biến vòng lặp trong mỗi lần lặp. (từ “per loop scope” sang “per iteration scope”)
Tài liệu tham khảo
- Các lỗi sai điển hình: https://go.dev/wiki/CommonMistakes
- Bài blog giới thiệu về sửa lỗi loopvar ở Go 1.22: https://go.dev/blog/loopvar-preview
- Wiki về cập nhật mới của Loopvar: https://go.dev/wiki/LoopvarExperiment
- FAQ về closure và loopvar: https://go.dev/doc/faq#closures_and_goroutines
- Thảo luận định nghĩa lại biến vòng lặp: https://github.com/golang/go/discussions/56010
- Cách phân tích vấn đề ở mức độ assembly: https://eli.thegreenplace.net/2019/go-internals-capturing-loop-variables-in-closures/