Quá trình phát hiện lỗi trong trình biên dịch ARM64 của Go
(blog.cloudflare.com)- 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 runtime và hai 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 Transit và Magic 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
incgocủa cấu trúc schedulermtrong 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.Receivecủ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 preemption và stack 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:
- Asynchronous preemption xảy ra giữa hai lệnh ADD
- GC hoặc nguyên nhân khác kích hoạt routine stack unwinding
- Dò tìm vị trí stack pointer bất thường và diễn giải sai địa chỉ hàm
- 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ể và 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
Ý kiến Hacker News
pushhoặcpopkí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.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 LDRaddđể đ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.sscanftrong 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.Unsafenà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.