Trang chủHướng dẫnVòng lặp và Closure: Ác mộng một thời của các tín đồ Golang
Chuyên gia
Fix Bug
Go

Vòng lặp và Closure: Ác mộng một thời của các tín đồ Golang

CyStack blog 9 phút để đọc
CyStack blog09/05/2025
Locker Avatar

Dương Trần

🧠 Techno-Functional Project Manager | 🛡️ Security Engineer | 🔧 Backend Whisperer. Tech enthusiast on a lifelong quest to break, build, and secure cool stuff. Known in the team as the go-to rubber duck 🦆.
Locker logo social
Reading Time: 9 minutes

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
💡 Chú ý: cái bẫy loopvar này chỉ có tác dụng với Go 1.21 trở về trước. Từ Go 1.22 trở đi, anh em có thể kê cao gối mà ngủ và code.

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

vòng lặp và closure

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.

💡 Chú ý: Biến i thật ra đang giữ tham chiếu đến cùng một biến, chứ không phải bản sao của nó tại mỗi vòng lặp!!!

Vì vậy, ta có thể đoán được ví dụ đầu tiên đã xảy ra quá trình sau:

  1. Biến i khởi đầu với giá trị 0
  2. Lần lặp thứ nhất: goroutine thứ 1 sẽ print ra giá trị a[0]
  3. Lần lặp thứ hai: goroutine thứ 2 sẽ print ra giá trị a[1]
    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]
  4. 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:

  1. biến i của vòng for được tái sử dụng
  2. biến i bên trong closure tham chiếu đến biến i 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)

  1. biến i bên trong closure tham chiếu đến biến i 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:

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:

  1. 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é.

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.
  • 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

0 Bình luận

Đăng nhập để thảo luận

CyStack blog

Mẹo, tin tức, hướng dẫn và các best practice độc quyền của CyStack

Đăng ký nhận bản tin của chúng tôi

Hãy trở thành người nhận được các nội dung hữu ích của CyStack sớm nhất

Xem chính sách của chúng tôi Chính sách bảo mật.