2 điểm bởi GN⁺ 2025-10-09 | 1 bình luận | Chia sẻ qua WhatsApp
  • Cloudflare đã phát hiện một lỗi race condition hiếm gặp trong trình biên dịch Go chạy trên nền tảng arm64 khi giám sát lưu lượng ở quy mô lớn
  • Lỗi này biểu hiện dưới dạng dịch vụ bất ngờ rơi vào trạng thái panic hoặc phát sinh lỗi truy cập bộ nhớ trong quá trình stack unwinding
  • Trong quá trình truy vết nguyên nhân, họ xác nhận vấn đề xảy ra giữa asynchronous preemption của Go runtimehai lệnh điều chỉnh con trỏ ngăn xếp do trình biên dịch sinh ra
  • Bằng mã tái hiện tối thiểu, họ đã chứng minh đây là vấn đề của chính Go runtime, đồng thời chỉ ra sự tồn tại của một race window dài đúng một kích thước lệnh, nơi stack pointer bị thay đổi chưa hoàn tất
  • Vấn đề này đã được vá trong các phiên bản go1.23.12, go1.24.6, go1.25.0; cách làm mới tránh thao tác trên stack pointer theo kiểu không thể thay đổi ngay lập tức, từ đó chặn race condition tận gốc

Phân tích lỗi trình biên dịch Go ARM64 được Cloudflare phát hiện

Các trung tâm dữ liệu của Cloudflare xử lý 84 triệu yêu cầu HTTP mỗi giây tại hơn 330 thành phố trên toàn thế giới, và môi trường lưu lượng khổng lồ như vậy có đặc điểm là ngay cả những lỗi hiếm gặp cũng dễ lộ diện thường xuyên hơn. Bài viết này phân tích chi tiết vấn đề race condition phát sinh trong mã do trình biên dịch Go tạo ra trên nền tảng arm64, kèm theo ví dụ thực tế.

Điều tra hiện tượng panic bất thường

  • Trong mạng lưới Cloudflare, có các dịch vụ cấu hình vào kernel để xử lý lưu lượng cho các sản phẩm như Magic TransitMagic WAN
  • Trên các máy arm64, hệ thống giám sát phát hiện các thông báo fatal panic hiếm nhưng lặp đi lặp lại
  • Phân tích ban đầu cho thấy vi phạm tính toàn vẹn bị phát hiện trong quá trình stack unwinding (panic xảy ra thường xuyên trong mã cũ dùng mẫu panic/recover)
  • Tạm thời loại bỏ cấu trúc panic/recover đã làm giảm tần suất panic, nhưng về sau các fatal panic đáng ngờ lại xuất hiện thường xuyên hơn
  • Vì vậy, nhóm xác định cần phân tích nguyên nhân ở mức sâu hơn thay vì chỉ truy vết theo mẫu đơn giản

Tổng quan về Go runtime và cấu trúc dữ liệu của scheduler

  • Go sử dụng scheduler không gian người dùng nhẹ với mô hình M:N scheduling (ánh xạ nhiều goroutine lên một số ít kernel thread)
  • Các cấu trúc cốt lõi của scheduler xoay quanh g (goroutine), m (machine/kernel thread), p (processor)
  • Lỗi stack unwinding hoặc lỗi truy cập bộ nhớ có thể xảy ra khi stack pointer hoặc địa chỉ trả về thay đổi bất thường

Nguyên nhân cấu trúc của lỗi trong lúc stack unwinding

  • Phân tích nhiều backtrace cho thấy tất cả đều phát sinh trong quá trình stack unwinding của hàm (*unwinder).next
  • Một trường hợp coi stack là bất thường và kết thúc bằng lỗi chí mạng vì return address là null; trường hợp khác gây lỗi segmentation khi truy cập trường incgo của cấu trúc scheduler m trong stack frame
  • Vụ crash xảy ra ở vị trí cách khá xa điểm thực sự phát sinh lỗi, khiến việc lần theo nguyên nhân trở nên khó khăn

Mẫu quan sát được và mối liên hệ với thư viện Go Netlink

  • Khi xem xét stack trace, họ xác nhận các crash tập trung vào thời điểm preemption xảy ra trong hàm NetlinkSocket.Receive của thư viện Go Netlink
  • Từ đó, họ đặt ra hai giả thuyết
    • Có thể đây là lỗi bắt nguồn từ việc dùng unsafe.Pointer trong Go Netlink
    • Hoặc đây là lỗi trong chính asynchronous preemptionstack unwinding của Go runtime
  • Sau khi kiểm toán mã, họ không tìm thấy mẫu hỏng bộ nhớ trực tiếp nào, nên suy đoán trọng tâm của vấn đề nằm ở runtime và chiến lược vận hành stack

Asynchronous preemption và race condition

  • Tính năng asynchronous preemption được giới thiệu từ Go 1.14 gửi tín hiệu OS thread (SIGURG) tới các goroutine chạy quá lâu để cưỡng bức tạo scheduling point
  • Nếu preemption xảy ra giữa hai lệnh assembly điều chỉnh con trỏ stack frame, stack pointer sẽ bị giữ ở trạng thái trung gian
  • Khi stack bị unwinding để phục vụ garbage collection, panic handling hoặc tạo stack trace, hệ thống có thể đọc sai vị trí và diễn giải sai địa chỉ hàm hoặc dữ liệu

Tạo mã tái hiện tối thiểu

  • Bằng cách điều chỉnh kích thước cấp phát stack frame và viết hàm có thao tác điều chỉnh stack rõ ràng (big_stack) cùng mã liên tục gọi garbage collection, họ đã tái hiện được race condition
  • Trên thực tế, mã assembly điều chỉnh stack pointer bằng hai lệnh ADD, và nếu asynchronous preemption xảy ra ở giữa, quá trình stack unwinding sẽ dẫn tới crash
  • Lỗi này có thể được tái hiện chỉ bằng mã từ standard library thuần túy, qua đó chứng minh đây là một điểm yếu ở cấp độ số lần lệnh (cửa sổ dài 1 instruction) vốn có trong mã mà trình biên dịch Go tạo ra

Nguyên nhân của race window ở cấp trình biên dịch ARM64

  • Do kiến trúc ARM64 dùng lệnh có độ dài cố định và giới hạn giá trị tức thời, việc điều chỉnh stack pointer có thể cần từ hai lệnh trở lên
  • Trong biểu diễn trung gian nội bộ (IR) của Go, độ dài giá trị tức thời như vậy không được nhận biết, và chỉ đến giai đoạn chuyển sang mã máy thì lệnh tách mới được chèn vào
  • Vì vậy, việc hoàn trả stack frame (ADD RSP, RSP) dùng hai lệnh, tạo ra một cửa sổ đơn instruction dễ bị preemption
  • Unwinder tuyệt đối cần stack pointer chính xác, nên nếu bị dừng giữa chừng của chuỗi lệnh sẽ dẫn đến diễn giải sai giá trị và gây lỗi chí mạng
  • Luồng crash thực tế được cấu thành như sau:
    1. Asynchronous preemption xảy ra giữa hai lệnh ADD
    2. GC hoặc nguyên nhân khác kích hoạt routine stack unwinding
    3. Dò tìm vị trí stack pointer bất thường và diễn giải sai địa chỉ hàm
    4. Runtime crash

Sửa lỗi và cải tiến mang tính căn bản

  • Nhóm Cloudflare đã báo cáo lên kho mã chính thức của Go dựa trên mã tái hiện tối thiểu và phân tích chi tiết; vấn đề nhanh chóng được vá và phát hành
  • Từ các phiên bản go1.23.12, go1.24.6, go1.25.0 trở đi, hệ thống tính toàn bộ offset trước trong thanh ghi tạm, sau đó thay đổi stack pointer bằng một lệnh duy nhất, loại bỏ điểm yếu với preemption
  • Giờ đây stack pointer luôn được đảm bảo ở trạng thái hợp lệ, nên race condition bị chặn về mặt cấu trúc
LDP -8(RSP), (R29, R30)
MOVD $32, R27
MOVK $(1<<16), R27
ADD R27, RSP, RSP
RET

Kết luận và hàm ý

  • Lỗi này là một ví dụ cho thấy mã sinh ra bởi trình biên dịch trên một kiến trúc cụ thểcơ chế quản lý đồng thời (asynchronous preemption) có thể va chạm theo cách ngoài dự liệu
  • Đây là một trường hợp hấp dẫn ở chỗ một race condition cấp instruction cực hiếm chỉ lộ ra trong môi trường quy mô lớn đã được lần ra bằng dữ liệu thực tế và suy luận mang tính khoa học
  • Nếu bạn đang vận hành dịch vụ dựa trên kiến trúc ARM64 và môi trường Go mới, việc nâng cấp lên các phiên bản Go liên quan là rất quan trọng

1 bình luận

 
GN⁺ 2025-10-09
Ý kiến Hacker News
  • Đây đúng là một phát hiện rất ấn tượng; ngay khi nhìn vào mã assembly là có thể lần theo hướng gỡ lỗi. Thực ra cách này không nhất thiết chỉ làm được ở assembly, có thể làm ở giai đoạn IR, nhưng vì nhiều lý do nên đã không như vậy. Việc đọc được ARM assembly là một lợi thế lớn. Tôi cũng từng nghĩ đến cách thử push hoặc pop kích thước stack để giảm số dòng lệnh, nhưng không chắc GC chính xác kiểm tra điều gì nên không dám khẳng định. Muốn nghe thêm ý kiến khác.
    • Thông thường sẽ dùng pseudo-instruction của ARM là LDR Rd, =expr. Với các hằng số không thể tạo trực tiếp, nó đặt hằng số ở một vị trí tương đối theo PC rồi nạp vào thanh ghi dựa trên PC. Nhờ đó có thể biến thao tác “cộng hằng số vào SP” thành 2 lệnh thực thi, và cần tổng cộng 12 byte gồm 8 byte mã cùng 4 byte vùng dữ liệu (cho hằng số 17 bit). Tài liệu liên quan: giải thích về pseudo-instruction LDR
    • Đây là một trường hợp đặc biệt khi cộng giá trị tức thời vào RSP, nên khá bất ngờ khi assembler không xử lý riêng lỗi này. Nếu bản vá chỉ được áp dụng ở phía compiler thì vấn đề tương tự có thể vẫn còn ở những chỗ khác trong aarch64 assembly.
    • Biểu thức lạ có dấu đô la trong cú pháp ARM assembly này không phải là assembly chuẩn AArch64, và tôi nghĩ bài viết đáng lẽ cũng nên nhắc đến quy tắc “stack chỉ được di chuyển một lần”.
    • Các runtime như Java hay .NET đặt safepoint rõ ràng để tránh việc ngữ cảnh bị thay đổi giữa chừng trong tập lệnh.
    • Có vẻ cách sửa đúng là để compiler nạp hằng số vào thanh ghi theo hai bước rồi dùng một lệnh add để điều chỉnh SP một cách nguyên tử. Dù có thêm một lệnh, đổi lại sẽ đảm bảo tính nguyên tử. Hoặc cũng có thể tính toán bằng thanh ghi tạm rồi chuyển lại sau.
  • Với ai đang vội, chia sẻ luôn link commit sửa lỗi: liên kết commit của golang/go
    • Khi xem issue này, tôi tự hỏi liệu nhóm Go có dùng bot ngôn ngữ tự nhiên hay chỉ đơn giản kiểm tra từ khóa “backport” trong comment. Bình luận liên quan: github issue comment
  • Đây là một bài blog xuất sắc về mặt kỹ thuật; phần giải thích quá rõ ràng nên rất dễ hiểu, đến mức có cảm giác mình thông minh hơn hẳn. Dù đã rất lâu rồi mới đọc lại assembly kể từ x86 assembly, tôi vẫn theo dõi được dễ dàng. Và với một đội như thế này, tôi cũng có thêm niềm tin rằng họ luôn đủ năng lực và quy trình kiểm soát chất lượng để xử lý các vấn đề kiểu này. Tôi từng cân nhắc cả Ampere Altra để mở rộng server, nhưng cuối cùng do dư không gian nên dùng Epyc.
  • Tôi nghĩ nếu Go có một chế độ single-step mọi lệnh và tạo ngắt GC ở mỗi lệnh thì những lỗi kiểu này sẽ dễ tìm hơn nhiều.
  • Tôi tò mò ARM64 server đang được dùng ở đâu. Năm ngoái họ nói sẽ ra mắt server Gen 12 dựa trên AMD EPYC, nhưng không thấy nhắc ARM64; hiện tại ARM64 đang được dùng trong production.
    • Tôi không phải nhân viên Cloudflare, nhưng đọc blog của họ khá nhiều nên biết rằng, xét đến secure boot và các yếu tố khác, họ đã triển khai Ampere song song với AMD từ vài năm trước. Mục đích vận hành có vẻ là vì hiệu quả ở edge, dù cũng có thể có mục đích khác. Có thể xem thêm tại bài viết về thiết kế edge server, Ampere Altra vs AWS Graviton2đánh giá Qualcomm ARM.
    • Tôi nhớ từng nghe rằng Cloudflare host một phần năng lực tính toán non-edge trên public cloud, ví dụ như control plane, nên cũng có thể là như vậy.
  • Tôi cứ nghĩ dạo này Cloudflare chỉ dùng 100% Rust và x86 (EPYC), nên việc họ đang dùng cả Go và ARM khá thú vị.
  • Với tôi, các bài blog của Cloudflare lúc nào cũng là nội dung tuyệt vời, thể hiện đúng bản chất của kỹ thuật mà không cần đến mấy màn ảo thuật hạ tầng hay ML. Một ngày nào đó tôi muốn ứng tuyển vào đây. Compiler bug thật ra phổ biến hơn người ta nghĩ nhiều (ngày trước tôi từng tìm ra vài lỗi trong gcc mỗi năm), nhưng thường là những trường hợp hiếm chỉ lộ ra ở quy mô rất lớn như trong bài viết. Đa số người ta không bao giờ chạy tới quy mô đó.
    • Tôi tò mò vì sao hôm nay bạn chưa nộp đơn.
  • Cần nhấn mạnh rằng stack pointer luôn phải được điều chỉnh một cách nguyên tử.
    • Có vẻ những người viết phần preemption đã làm mã theo giả định của x86 (ở đó lệnh có thể chứa hằng số nên việc này diễn ra một cách nguyên tử), còn khi port sang ARM thì ở mức cao hơn nó bị tự động tách ra, từ đó sinh ra lỗi này. Không hẳn là lỗi của riêng ai, nhưng kết quả thì không tốt.
    • Đó cũng chính là suy nghĩ đầu tiên bật ra trong đầu tôi.
  • Tôi chưa hiểu rõ bằng cách nào machine thread lại có thể dừng giữa hai lệnh như vậy. Không rõ trên bare metal chuyện này có thể xảy ra hay không.
    • Go dùng interrupt để gửi thông báo GC.
    • signals.
  • Về câu “đó là một vấn đề rất thú vị”, đúng là việc giải quyết được một lỗi nền tảng như vậy hẳn rất đã, nhưng khi nó còn chưa được giải quyết thì chắc chắn chẳng vui vẻ gì. Kiểu bug này nuốt trọn mọi sự chú ý của bạn. Vì chẳng ai nghĩ vấn đề nằm ở standard library hay compiler, nên văn hóa chung là lập trình viên cứ tiếp tục nghi ngờ mã của chính mình. Tôi cũng từng tìm ra một lỗi trong standard library, và phía SDK luôn là thứ bị nghi ngờ sau cùng. Kết quả là mất hết thời gian ở những chỗ chẳng liên quan, mà đã là race condition như lần này thì còn khó tái hiện, cứ tưởng nó biến mất rồi lại đột ngột xuất hiện trở lại.
    • Bình luận này có thêm trải nghiệm tương tự của chính người viết, nhưng lại cố phản bác cảm giác vui thích của tác giả, nên thành ra làm giảm bớt sự đồng cảm. Mỗi người có thể thấy thú vị ở những điều khác nhau.
    • Có những người lại thấy vui với kiểu gỡ lỗi cực kỳ đặc biệt mà người khác sẽ cảm thấy khổ sở. Điều khiến người này nản lòng có thể lại là niềm vui với người khác.
    • Tôi đoán điều tác giả muốn nói có lẽ không phải là “funny” mà là “satisfying”. Tôi cũng từng bị dí deadline khi phải bắt một lỗi sscanf trong toolchain Ubuntu GCC ARM; lúc đó thì không vui chút nào, nhưng sau khi xác định chính xác vấn đề và viết xong regression test, cảm giác thực sự rất thỏa mãn.
    • Việc sửa được một khiếm khuyết sâu như vậy mang lại cảm giác giải thoát cực lớn. Tôi cũng nhiều lần thấy niềm vui lớn nhất của mình đến từ việc xử lý các lỗi ở compiler hoặc CPU.
    • Trong ngôn ngữ managed, nếu bị segfault dù không hề dùng kiểu Unsafe nào, tôi thường xem đó là dấu hiệu cho thấy khả năng cao vấn đề không nằm ở mã của mình.