- Go 1.22 sẽ đổi biến vòng lặp
for từ phạm vi của toàn bộ vòng lặp sang phạm vi theo từng lần lặp, nhằm giảm một lỗi điển hình trong Go: closure vô tình capture cùng một biến
- Với ngữ nghĩa cũ, ngay cả khi không có goroutine, các hàm được thực thi sau vòng lặp vẫn tham chiếu cùng một
v hoặc i, nên có thể chỉ thấy giá trị cuối cùng hoặc khiến test vượt qua sai
- Bộ phân tích
loopclosure của go vet và gopls chỉ bắt các trường hợp chắc chắn nên có thể bỏ sót; các checker mạnh tay hơn lại có thể tạo cảnh báo sai, dẫn đến việc thêm không cần thiết các dòng x:= x
- Ngữ nghĩa mới chỉ áp dụng cho các module khai báo
go 1.22 trở lên trong go.mod; trong Go 1.21 có thể chạy bản xem trước bằng GOEXPERIMENT=loopvar
- Từ đầu tháng 5/2023, Google đã ép bật chế độ này cho mọi bản build trong toolchain Go nội bộ; trong 4 tháng không có báo cáo sự cố production nào, nhưng các test được viết sai đã lộ ra
Cạm bẫy capture biến trong vòng lặp for cũ
- Biến vòng lặp
for cũ của Go có phạm vi toàn bộ vòng lặp, nên đoạn mã tham chiếu biến đó sau khi một lần lặp kết thúc có thể nhìn thấy giá trị khác với ý định
- Khi duyệt
values := []string{"a", "b", "c"} và tạo ba goroutine, mỗi goroutine in cùng biến v, chứ không phải v riêng cho từng lần lặp
- Ngay cả khi không có đồng thời, vấn đề tương tự vẫn xảy ra
- Nếu trong vòng lặp lưu
func() { fmt.Println(i) } vào một slice rồi chạy sau, mỗi hàm sẽ tham chiếu cùng một i, thay vì giá trị riêng của từng lần lặp
Sự cố production và giới hạn của bộ phân tích
- Những lỗi như vậy đã dẫn tới sự cố production ở nhiều công ty; issue công khai của Let’s Encrypt là một trong số đó
- Trong trường hợp Let’s Encrypt, khi duyệt map,
k đã được sao chép bằng kCopy := k, nhưng modelToAuthzPB(&v) dùng con trỏ tới các field của v trong quá trình tạo kết quả, nên v cũng cần được sao chép riêng
- Việc capture biến trải qua nhiều hàm nên rất khó nhận ra vấn đề
- Các công cụ phân tích tĩnh khó xác định liệu một biến có còn sống sau lần lặp hay không, nên phải đánh đổi giữa cảnh báo sai và bỏ sót
- Bộ phân tích
loopclosure của go vet và gopls chỉ báo cáo các vấn đề chắc chắn, chấp nhận bỏ sót
- Các checker mạnh tay hơn có thể chỉ nhầm cả mã đúng thành mã sai
- Khi xem các commit thêm dòng
x := x trong mã Go mã nguồn mở, có thể thấy ngoài các bản sửa lỗi thật còn lẫn rất nhiều thay đổi không cần thiết
- Có tình huống lập trình viên thêm mã không cần thiết chỉ để làm hài lòng checker
- Trong hai diff như
informer := informer và a := a, chỉ một diff là sửa lỗi, diff còn lại là thay đổi không cần thiết; nhưng nếu không biết thông tin về kiểu và hàm thì rất khó phân biệt
Ngữ nghĩa vòng lặp mới trong Go 1.22
- Trong Go 1.22, biến vòng lặp
for dự kiến sẽ có phạm vi riêng theo từng lần lặp
- Các ví dụ ở trên sẽ không còn là chương trình Go có bug nữa, đồng thời nhu cầu về các công cụ kiểm tra không chính xác và các vấn đề production do lỗi này gây ra cũng sẽ giảm
- Để tương thích ngược, ngữ nghĩa mới chỉ áp dụng cho các package trong module khai báo
go 1.22 trở lên trong go.mod
- Có thể chuyển đổi dần dần thay vì thay đổi toàn bộ codebase cùng lúc
- Cũng có thể điều khiển ở cấp file bằng dòng
//go:build
- Mã hiện có vẫn giữ nguyên ngữ nghĩa như hiện tại
- Thay đổi chỉ áp dụng cho mã mới hoặc mã đã được cập nhật
- Lập trình viên có thể kiểm soát thời điểm ngữ nghĩa thay đổi trong một package cụ thể
Cơ chế an toàn trong các phiên bản Go trước
- Theo công việc về forward compatibility của Go, Go 1.21 không biên dịch mã khai báo
go 1.22 trở lên
- Các bản phát hành điểm Go 1.20.8 và Go 1.19.13 cũng có xử lý đặc biệt mang lại hiệu ứng tương tự
- Sau khi Go 1.22 được phát hành, mã được viết dựa trên ngữ nghĩa mới sẽ không bị biên dịch theo ngữ nghĩa cũ, trừ khi dùng phiên bản Go đã hết hỗ trợ rất cũ
Chạy bản xem trước trong Go 1.21
- Go 1.21 bao gồm bản xem trước của thay đổi phạm vi vòng lặp
- Khi biên dịch với
GOEXPERIMENT=loopvar, dòng go trong go.mod sẽ bị bỏ qua và ngữ nghĩa mới được áp dụng cho mọi vòng lặp
- Để kiểm tra package và toàn bộ dependency có vượt qua test với ngữ nghĩa vòng lặp mới hay không, chạy như sau
GOEXPERIMENT=loopvar go test
- Trên Go Playground, có thể thêm chú thích
// GOEXPERIMENT=loopvar ở đầu chương trình để thử ngữ nghĩa mới
- Toolchain Go nội bộ của Google đã được patch để ép bật chế độ này trong mọi bản build từ đầu tháng 5/2023; trong 4 tháng sau đó không có báo cáo vấn đề nào trong mã production
Các bug test bị ngữ nghĩa mới phơi bày
- Ngữ nghĩa vòng lặp mới không gây vấn đề cho mã production, nhưng đã làm lộ ra các test vốn vượt qua sai
- Trong ví dụ về subtest dùng
t.Parallel, Go 1.21 chặn từng subtest cho đến khi toàn bộ vòng lặp kết thúc, rồi mới chạy song song
- Khi vòng lặp kết thúc,
v luôn là 6, nên mọi subtest đều kiểm tra 6 có phải số chẵn hay không và vượt qua
- Vì các test case thực tế có
1, test lẽ ra phải thất bại
- Trong Go 1.21, độ chính xác của bộ phân tích
loopclosure đã được cải thiện, nên có thể nhận diện và báo cáo vấn đề này
- Ví dụ báo cáo trên Go Playground: chương trình ví dụ
- Nếu
go vet báo cáo vấn đề như vậy trong test, việc sửa chúng sẽ giúp chuẩn bị cho Go 1.22
- Công cụ và ví dụ để tìm vòng lặp gây lỗi test cụ thể khi áp dụng ngữ nghĩa mới được tổng hợp trong FAQ
Đọc thêm
1 bình luận
Ý kiến trên Hacker News
Có thể còn những ví dụ sớm hơn nhiều, nhưng cảnh báo lâu đời nhất về hành vi này mà tôi tìm được sau 60 giây tìm kiếm là FAQ comp.lang.lisp đăng từ năm 1992, hơn 30 năm trước
Tài liệu giải thích rằng
DOTIMES,DOLIST,DOdùng phép gán chứ không phải binding khi cập nhật biến lặp, nên nếulambdacapturennhư trong ví dụ, cả 10 closure đều được tạo trên giá trị của cùng một biếnNNếu capture bằng tham chiếu thì thực ra đây là hành vi có thể dự đoán được
Dù vậy, một khi đã học được cách nó hoạt động thì không còn là vấn đề nữa; nếu cần, có thể chọn form rồi macro-expand để kiểm tra cách triển khai
Nhóm ngôn ngữ C# cũng gặp cùng vấn đề sau khi đưa closure nhẹ vào C# 4.0, và chuyện này nhanh chóng lộ ra là một cái bẫy
Người dùng hầu như luôn dùng sai biến lặp, và đến C# 5.0 họ đã đưa vào một thay đổi phá vỡ tương thích
Eric Lippert đã viết một bài giải thích rất rõ “vì sao” từ góc nhìn đó: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
Bài công bố C# 5 gốc khá khó tìm, hy vọng nó chưa biến mất trong các đợt di chuyển blog trên nhiều domain của Microsoft sau năm 2012
Nghĩ tới chuyện chỉ riêng thay đổi kiểu chuỗi khi chuyển từ Python 2 sang 3 đã gây náo loạn thế nào, có lẽ thay đổi này sẽ không vào trước Python 4.0
Và rồi sẽ có ai đó chê Python tệ vì không sửa những thứ như thế này, rồi lại mắng Python vì script viết từ năm 2003 của họ không chạy nữa
Tôi nghĩ điều đó đã đóng vai trò lớn trong việc vượt qua rào cản “mặc định là từ chối trước” mà một đề xuất thay đổi ngôn ngữ vốn phải có
Một điểm thuyết phục lớn khác là kết quả quét các codebase mã nguồn mở để xem cán cân giữa số bug được sửa và số bug mới phát sinh
Vì truyền theo giá trị, nó capture trạng thái của biến tại thời điểm gọi, giúp giảm sự mơ hồ trong mã
Nếu cố capture biến theo cách kỳ lạ, chẳng hạn các collection dùng để tích lũy nhằm biến mảng thành map sẽ hành xử khác với các biến đã khai báo
Go có vẻ đang cố cân bằng bằng cách chỉ áp dụng hành vi này cho bộ đếm vòng lặp, nhưng một số biến vẫn sẽ hành xử kỳ lạ
Đặc biệt tôi tò mò chuyện gì sẽ xảy ra trong trường hợp định nghĩa nhiều biến lặp để scan trực tiếp input
for(let)https://eli.thegreenplace.net/2019/go-internals-capturing-lo... có vẻ giải thích vấn đề này chi tiết hơn
i := ingày xưa hoạt động hoàn toàn khác với những gì tôi từng nghĩBan đầu tôi nghĩ vì
imới được truyền vào goroutine nên escape analysis sẽ đánh dấu nó là thoát ra ngoài phạm vi lexical, do đó nó được cấp phát trên heap; mỗi vòng lặp sẽ có một lần cấp phát heap, khiến mỗi goroutine tham chiếu tới một vị trí bộ nhớ riêngThực tế là trình biên dịch Go có heuristic để chọn capture theo tham chiếu hay capture theo giá trị, và có một điều kiện là các giá trị không được cập nhật sau khi khởi tạo sẽ được capture theo giá trị
imới nằm trong phạm vi thânfor, và vì chính vòng lặp không cập nhật nó nên nó được xem là giá trị không được cập nhật sau khi khởi tạo; vì vậy mã được sinh ra capture nó theo giá trị mà không cần cấp phát heapTôi biết cách sau tốt hơn, nhưng muốn nghe từ ai đó hiểu sâu về Go rằng vì sao cách trước không đồng thời xảy ra
Liệu thay đổi này có làm hỏng các chương trình đang phụ thuộc vào hành vi hiện tại không?
go 1.22trở lên tronggo.modỞ cấp độ từng file, cũng có thể quyết định bằng dòng
//go:buildCam kết đó nói rằng các chương trình được viết theo đặc tả Go 1 phải tiếp tục biên dịch và chạy đúng mà không cần thay đổi trong suốt vòng đời của đặc tả; một ngày nào đó có thể có đặc tả Go 2, nhưng cho đến trước lúc đó, các chương trình Go đang chạy hôm nay vẫn phải tiếp tục chạy trong các bản phát hành điểm như Go 1.1, Go 1.2
Họ cho rằng do thiết kế này, số người vô tình tạo ra lỗi sẽ lớn hơn rất nhiều so với số người bị ảnh hưởng bởi bản sửa
Theo nhớ thì trong codebase của Google hoặc mã trên GitHub, gần như không có trường hợp nào thay đổi này phá vỡ hành vi mong đợi
Chỉ sau khi xác nhận số codebase bị ảnh hưởng ít đến mức nào, và tạo cơ chế buộc phải chủ động sửa mã nếu muốn dùng hành vi mới thông qua khai báo phiên bản trong
go.mod, họ mới quyết định phá vỡ tương thích ngượchttps://twitter.com/go100and1/status/1690412229135601664
https://twitter.com/go100and1/status/1690587305806057472
https://twitter.com/go100and1/status/1690589791686119424
https://twitter.com/go100and1/status/1690591234715492352
https://twitter.com/go100and1/status/1690593184857145344
https://twitter.com/go100and1/status/1691456732151889920
Phần lớn hoàn toàn không được nhắc đến trong tài liệu đề xuất
Python cũng từng gặp vấn đề này, nhưng không phải gần đây
Không chắc là Python đã thay đổi, hay là tôi mới bắt đầu nhận ra vấn đề
Việc nó vẫn có thể là vấn đề trong Python thì chỉ riêng đoạn mã này cũng đủ cho thấy:
funcs = [(lambda: x) for x in range(3)];funcs[0]()in ra2Python ngày trước còn tệ hơn, khi còn chia sẻ cả phạm vi bên ngoài list comprehension
Khi dùng lambda trong list comprehension hoặc vòng lặp, nó không capture giá trị hiện tại của
xmà capture tham chiếu tới biếnxĐến lúc gọi
funcs[0](),xđã được đặt thành giá trị cuối cùng củarange, tức2Nếu muốn hành vi mong muốn, hãy truyền
xlàm đối số mặc định của lambda:funcs = [(lambda x=x: x) for x in range(3)]Tôi mới dùng Go một chút và hiểu vấn đề phổ biến mà thay đổi này giải quyết, nhưng các ví dụ tinh vi hơn như trường hợp letsencrypt hay
"range c.informerMap"so với"range alarms"thì chưa hiểu rõTrong
for k, v := range someMap,vcó phải là kiểu giá trị của map, và có một binding duy nhất cho toàn bộ vòng lặp nên được sao chép ở mỗi lần lặp không? Nếu vậy thì vấn đề được giải thích, nhưng tôi đã tưởngvsẽ là một tham chiếu trỏ vào bên trong mapLướt nhanh phần “For statements with range clause” trong đặc tả cũng không tìm được câu trả lời; chắc vì tôi hầu như không đụng tới Go nên đã xem nhầm chỗ: https://go.dev/ref/spec#For_statements
Chỉnh sửa: câu trả lời nằm trong bảng định dạng code block. Có vẻ tôi đã bỏ qua nó như một banner. Thật bất ngờ khi
vlà giá trị được sao chép, chứ không phải tham chiếuNó hỗ trợ con trỏ tới slot của array, nhưng
for rangethì sao chép thay vì đưa cho bạn con trỏ trỏ tới từng slotvlàintĐó là giá trị, không phải con trỏ tới
intNếu tò mò thì có thể xem: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Về cơ bản, do tự động dereference, compiler đang biến
go a.Monitor(b)thành(&a).Monitor(b)Tôi tò mò đoạn “Kết quả của công việc về tương thích tiến, Go 1.21 sẽ không cố biên dịch mã khai báo
go 1.22trở lên. Chúng tôi cũng đã đưa xử lý đặc biệt có hiệu ứng tương tự vào các bản phát hành điểm Go 1.20.8 và Go 1.19.13, nên khi Go 1.22 được phát hành, mã được viết dựa trên ngữ nghĩa mới sẽ tuyệt đối không bị biên dịch theo ngữ nghĩa cũ, trừ khi dùng một phiên bản Go không còn được hỗ trợ và rất cũ” hoạt động như thế nàoNếu một package nào đó ghim ở 1.22 và tôi biên dịch bằng 1.18, nó có biên dịch được không, hay sẽ báo lỗi rằng cần compiler 1.22?
Vì trong Go 1.21 họ đã đổi định dạng số phiên bản trong file
go.mod, nên nếu cố build bằng Go 1.18 thì sẽ gặp lỗi kiểugo.mod:3: invalid go version '1.21.0': must match format 1.23Tuy nhiên điều này chỉ xảy ra khi tạo module bằng
go mod init; nếu tự viếtgo 1.21tronggo.modthì nó sẽ build mà không phàn nànĐây là một tính năng khá hay, nhưng cũng là một hành vi gây bất ngờ, và việc phải kết nối tới máy chủ do Google kiểm soát để tải binary khiến tôi hơi do dự
Cùng với module proxy, đây là một trong những tính năng gây cảm xúc lẫn lộn nhất của Go; có lẽ tôi sẽ yên tâm hơn nhiều nếu Go được quản lý bởi một quỹ mà Google chỉ có cổ phần
Chỉnh sửa: nghĩ lại thì đây là chuyện khi module hiện tại khai báo, chứ không phải khi dependency khai báo phiên bản khác, nên khác với câu hỏi ban đầu
Vì vậy việc dùng Go 1.18 trở nên chủ động nguy hiểm
Trong Go 1.19 thì có lẽ sẽ có lỗi biên dịch
Dù sao Go cũng không backport bản sửa lỗi bảo mật cho các bản phát hành cũ và thư viện chuẩn cũ, nên tôi cho rằng bản thân việc dùng những phiên bản đó đã nguy hiểm rồi
Nhưng ngay cả khi biên dịch bằng Go 1.22, mã của bạn vẫn sẽ có ngữ nghĩa Go 1.18
Go ở một khía cạnh nào đó là một ngôn ngữ rất kỳ lạ
Nó vừa là một ngôn ngữ có lập trường rất mạnh, đồng thời lại trông như một ngôn ngữ quá thiếu lập trường
Tôi không chắc sự khác nhau giữa đoạn mã duyệt
c.informerMapvà đoạn mã duyệtalarmslà gì, nhưng đoán rằng biến vòng lặp ở một bên có thể là con trỏ còn bên kia là giá trịVì lời gọi phương thức dùng pointer receiver, có phải trong trường hợp là giá trị thì compiler tự động chèn tham chiếu tới receiver không?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Khác biệt là ở một bên,
informerlà interface nên lời gọi phương thức được phân giải ngay thànhinformer.Run, do đó không có vấn đềỞ bên kia,
alà structAlarmvà được copy theo giá trị, còn phương thứcMonitornhận pointer receiverVì vậy compiler về cơ bản biến
go a.Monitor(b)thànhgo (&a).Monitor(b), tạo ra tham chiếu tới biến vòng lặp và gây ra vấn đềTôi đoán ở đoạn thứ hai, vì cuối cùng
achỉ còn mang giá trị của phần tử cuối cùng trongalarms, nên vấn đề gốc được mô tả trong bài xảy raKiến thức nội bộ của tôi chỉ đến đó, nhưng slice có backing array trên heap nên con trỏ hoặc tham chiếu có dính dáng ở một mức nào đó
Đọc xong thấy nhẹ nhõm hẳn
Một trong những khiếm khuyết lớn nhất của Go đang được sửa
Nếu viết kiểu
foo, err := getFoo(); if err != nil ...rồi sau đóbar, err := getBar(); fmt.Println(bar), bạn sẽ bỏ sót việc kiểm tra lỗi củagetBarDo quy tắc phạm vi, mẫu
if foo, err := getFoo(); err != niltrở nên khó kham nổi chỉ cần lồng sâu thêm một chútNó cũng đưa vào trạng thái sai. Khi
getFootrả về lỗi thì nên trả về gì? Bạn sẽ phải băn khoăn giữa việc đổi API sang trả về con trỏ để trảnil, hay để một đối tượng được tạo một phần ở trạng thái không hợp lệ