1 điểm bởi GN⁺ 2023-09-20 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 vetgopls 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

  • 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 saibỏ sót
    • Bộ phân tích loopclosure của go vetgopls 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 := informera := 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
    • Chương trình ví dụ: ví dụ Go Playground
    • Chú thích này chỉ có hiệu lực trên Go Playground
  • 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

 
GN⁺ 2023-09-20
Ý 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, DO dùng phép gán chứ không phải binding khi cập nhật biến lặp, nên nếu lambda capture n như trong ví dụ, cả 10 closure đều được tạo trên giá trị của cùng một biến N

    • D cũng có cùng vấn đề: https://issues.dlang.org/show_bug.cgi?id=2043
      Nếu capture bằng tham chiếu thì thực ra đây là hành vi có thể dự đoán được
    • Chuẩn không nêu rõ những vòng lặp như vậy thay đổi giá trị hay rebind, nên nếu capture biến thì phải giả định rằng nó không rebind
      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

    • Python cũng đã nhận nhiều yêu cầu tính năng tương tự trong nhiều năm, nhưng câu trả lời luôn là “lợi ích lớn không nhiều mà lại phá vỡ mã hiện có”: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      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
    • jaredpar của nhóm C# đã để lại bình luận đầu tiên trong thảo luận GitHub về đề xuất Go này: https://github.com/golang/go/discussions/56010
      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
    • Java cũng từng có vấn đề này với anonymous class, và thường giải quyết bằng cách đưa vào function object
      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
    • JavaScript cũng từng có cùng vấn đề và đã đưa vào vòng lặp for(let)
    • Đúng kiểu Go: không học từ các ngôn ngữ đi trước, bỏ qua hành vi này, rồi sau đó lại tìm cách sửa
  • 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ều thú vị là lý do trick i := i ngà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ì i mớ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êng
      Thự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ị
      i mới nằm trong phạm vi thân for, 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 heap
      Tô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?

    • Để đảm bảo tương thích ngược với mã hiện có, ngữ nghĩa mới chỉ được áp dụng cho các package trong module khai báo go 1.22 trở lên trong go.mod
      Ở cấp độ từng file, cũng có thể quyết định bằng dòng //go:build
    • Không rõ vì sao bị downvote, nhưng thực tế đây đúng là một thay đổi phá vỡ cam kết tương thích Go 1
      Cam 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
    • Trong quá trình chuẩn bị Go 1.21, họ đã phân tích một kho ngữ liệu mã Go rất lớn để xem những gì sẽ bị ảnh hưởng, và nói rằng con số đó cực kỳ nhỏ
      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
    • Đề xuất ban đầu đã trình bày khá chi tiết nội dung khảo sát các trường hợp sử dụng hiện có của cú pháp này
      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ược
    • Khá nhiều
      https://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 ra 2

    • Đó là hành vi đúng
      Python ngày trước còn tệ hơn, khi còn chia sẻ cả phạm vi bên ngoài list comprehension
    • Hành vi này là do late binding của closure trong Python
      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 x mà capture tham chiếu tới biến x
      Đến lúc gọi funcs[0](), x đã được đặt thành giá trị cuối cùng của range, tức 2
      Nếu muốn hành vi mong muốn, hãy truyền x là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, v có 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ưởng v sẽ là một tham chiếu trỏ vào bên trong map
    Lướ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 vgiá trị được sao chép, chứ không phải tham chiếu

    • Go không hỗ trợ con trỏ tới key hoặc value của map
      Nó hỗ trợ con trỏ tới slot của array, nhưng for range thì sao chép thay vì đưa cho bạn con trỏ trỏ tới từng slot
    • Nếu có một map từ string sang integer, kiểu của vint
      Đó là giá trị, không phải con trỏ tới int
    • Tôi đã tìm được nguồn gốc của các đoạn mã này
      Nế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.22 trở 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ào
    Nế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?

    • Họ đã dùng một cách hơi tinh quái
      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ểu go.mod:3: invalid go version '1.21.0': must match format 1.23
      Tuy nhiên điều này chỉ xảy ra khi tạo module bằng go mod init; nếu tự viết go 1.21 trong go.mod thì nó sẽ build mà không phàn nàn
    • Điều thú vị là trong Go 1.21, nếu module khai báo một phiên bản Go cao hơn, hành vi mặc định là tải về toolchain mới hơn và dùng nó thay thế: https://go.dev/blog/toolchain
      Đâ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
    • Theo tôi hiểu, trong Go 1.18 thì dù module 1.22 được đưa vào làm dependency, nó vẫn sẽ biên dịch, và nếu dựa vào tính năng này thì có thể tạo ra logic sai
      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
    • Nó nên gây lỗi biên dịch
      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.informerMap và đoạn mã duyệt alarms là 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?

    • Tôi đã tìm được bản gốc chứa đoạn mã này bằng tìm kiếm mã trên GitHub
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      Khác biệt là ở một bên, informer là interface nên lời gọi phương thức được phân giải ngay thành informer.Run, do đó không có vấn đề
      Ở bên kia, a là struct Alarm và được copy theo giá trị, còn phương thức Monitor nhận pointer receiver
      Vì vậy compiler về cơ bản biến go a.Monitor(b) thành go (&a).Monitor(b), tạo ra tham chiếu tới biến vòng lặp và gây ra vấn đề
    • Khi duyệt map trong Go thì giá trị luôn được copy, nên đoạn mã đầu tiên có vẻ hoạt động như mong đợi
      Tôi đoán ở đoạn thứ hai, vì cuối cùng a chỉ còn mang giá trị của phần tử cuối cùng trong alarms, nên vấn đề gốc được mô tả trong bài xảy ra
    • Nhìn tên thì cái ở trên là map, còn cái ở dưới là slice
      Kiế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 đó
    • Chắc chắn là có thứ gì đó kiểu compiler biết việc bắt lấy giá trị
  • Đọ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

    • Không, khiếm khuyết lớn nhất là xử lý lỗi
      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ủa getBar
      Do quy tắc phạm vi, mẫu if foo, err := getFoo(); err != nil trở nên khó kham nổi chỉ cần lồng sâu thêm một chút
      Nó cũng đưa vào trạng thái sai. Khi getFoo trả 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ệ
    • Tiếp theo nên sửa kiểm tra nil của interface