Data Race Patterns trong Go

Uber đã sử dụng Golang (gọi tắt là Go) làm ngôn ngữ lập trình chính để phát triển các dịch vụ microservices. Monorepo Go của chúng họ bao gồm khoảng 50 triệu code (và vẫn đang phát triển) và chứa khoảng 2.100 service Go độc lập (và đang tăng theo từng ngày).
Go biến concurrency của mình thành tính năng first-class; các lệnh gọi hàm với tiền tố go sẽ chạy lệnh bất đồng bộ.Các lệnh gọi hàm bất đồng bộ này trong Go được gọi là goroutines. Các nhà phát triển đã che giấu độ trễ (ví dụ: lệnh gọi IO hoặc gọi RPC tới các service khác) bằng cách gọi goroutines. Hai hoặc nhiều goroutine có thể giao tiếp dữ liệu thông qua truyền message (channel) hoặc shared memory. Shared memory là phương thức trao đổi dữ liệu được sử dụng phổ biến nhất trong Go.
Goroutine được coi là “lightweight” và bởi vì chúng rất dễ tạo, các lập trình viên Go sử dụng goroutines một cách tự do. Kết quả là, chúng tôi nhận thấy rằng các chương trình được viết bằng Go thường có khả năng xử lý đồng thời cao hơn đáng kể so với các chương trình được viết bằng các ngôn ngữ khác. Ví dụ: bằng cách quét hàng trăm nghìn instance đang chạy trong tdata center của chúng tôi, chúng tôi phát hiện ra rằng Go microservices có khả năng xử lý đồng thời cao hơn ~8 lần so với Java microservices . Concurrency càng cao cũng đồng nghĩa là có khả năng xảy ra nhiều lỗi concurrency hơn. Data race là một concurrency bug xảy ra khi hai hoặc nhiều goroutine truy cập vào cùng một dữ liệu, ít nhất một trong số chúng ở trạng thái ghi và không có thứ tự nào giữa chúng.Data races là lỗi nguy hiểm và phải tránh bằng mọi giá.
Chúng tôi đã phát triển một hệ thống để phát hiện các data race tại Uber bằng kỹ thuật dynamic data race detection. Hệ thống này, trong khoảng thời gian sáu tháng, đã phát hiện khoảng 2.000 ata races trong codebase Go của chúng tôi, trong đó các lập trình viên của chúng tôi đã sửa ~ 1.100 data races.
Trong blog này, chúng tôi sẽ hiển thị các data race patterns khác nhau mà chúng tôi đã tìm thấy trong các chương trình Go của mình. Nghiên cứu này được thực hiện bằng cách phân tích hơn 1.100 data race do 210 lập trình viên trong khoảng thời gian sáu tháng
Data Race Patterns in Go
Chúng tôi đã điều tra khoản ~ 1.100 data races được fix bởi các lập trình viên của chúng tôi và chia chúng thành các danh mục khác nhau. Nghiên cứu của chúng tôi về các data race này cho thấy một số patterns phổ biến và một số lý do phức tạp gây ra các patterns trong Go:
Lựa chọn thiết kế của Go để capture các variables bằng cách tham chiếu trong goroutines là bắt nguồn của data races
Các hàm lồng nhau (hay còn gọi là closures), Go capture các variable bằng cách tham chiếu đến các biến đó. Lập trình viên không cần chỉ định rõ ràng biến nào được tham chiếu trong cú pháp closure.
Cách sử dụng này khác với Java và C++. Java lambdas chỉ capture variable theo giá trị và họ đã lựa chọn thiết kế đó một cách có chủ ý để tránh các concurrency bugs [1, 2]. C++ yêu cầu các nhà phát triển chỉ định rõ ràng việc capture theo giá trị hoặc theo tham chiếu
Các nhà phát triển thường không biết rằng một biến được sử dụng bên trong một closure là một free variable và được capture bằng tham chiếu, đặc biệt khi closure lớn. Thông thường, các nhà phát triển Go sử dụng các closure làm goroutine. Do capture-by-referenc và goroutine concurrency, các chương trình Go cuối cùng có khả năng có quyền truy cập không có thứ tự vào các free vataible trừ khi thực hiện đồng bộ hóa rõ ràng. Chúng tôi chứng minh điều này bằng ba ví dụ sau:
Example 1: Data race do capture biến iterate của vòng lặp
Code trong Hình 1A hiển thị việc loop qua các biến jobs trong slice và xử lý từng phần từ job thông qua hàm ProcessJob.

Trong đoạn mã này, nhà phát triển đã gói hàm ProcessJob vào một anonymous goroutine được khởi chạy mỗi lần cho mỗi mục. Tuy nhiên, biến chỉ mục của vòng lặp job bị capture theo tham chiếu bên trong goroutine. Khi goroutine được khởi chạy cho vòng lặp đầu tiên truy cập vào biến job, vòng lặp for trong goroutine cha sẽ tiến hành duyệt qua slice và cập nhật vào cùng một biến job để trỏ đến phần tử thứ hai trong slice, gây ra xung đột dữ liệu. Loại xung đột dữ liệu này xảy ra đối với các kiểu giá trị và kiểu tham chiếu; các slice, mảng và map; cũng như các truy cập chỉ đọc và ghi trong thân vòng lặp. Go khuyến nghị một phương pháp viết mã để ẩn và giấu biến chỉ mục vòng lặp trong thân vòng lặp, nhưng không may là các nhà phát triển không phải lúc nào cũng tuân theo.
for _ , job := range jobs:
go func() {
ProcessJob(job)
}(job)Ví dụ 2: Data race do capture các biến idiomatic error

Go hỗ trợ việc trả về nhiều giá trị từ hàm. Thường thì sẽ trả về giá trị trả về thực tế cùng với một đối tượng lỗi để chỉ ra có lỗi hay không, như thể hiện trong Hình 1B. Giá trị trả về thực tế được coi là có ý nghĩa chỉ khi giá trị lỗi là nil. Thường thì người ta gán đối tượng lỗi được trả về cho một biến được đặt tên là err, sau đó kiểm tra xem err có giá trị nil hay không. Tuy nhiên, vì nhiều hàm trả về lỗi có thể được gọi bên trong một thân hàm, sẽ có nhiều lần gán giá trị cho biến err, theo sau là kiểm tra giá trị nil cho mỗi lần. Khi nhà phát triển kết hợp mỗi lệnh này với một goroutine, biến err sẽ được capture theo tham chiếu trong closure. Kết quả là, các truy cập (cả đọc và ghi) đến err trong các goroutine chạy đồng thời với các thao tác đọc và ghi sau đó vào cùng biến err trong các dòng lệnh trong hàm chứa các go routine (hoặc nhiều trường hợp của goroutine), điều này gây ra data race về mặt dữ liệu.