Go được biết đến như một ngôn ngữ hỗ trợ việc chạy các chương trình đồng thời một cách dễ dàng. Nhưng cũng chính bằng việc xử lý đồng thời này dẫn đến việc chương trình có khả năng bị Data Race – Một trong những lỗi rất khó debug nếu bạn không may gặp phải trong code của mình
Trong bài viết này, chúng ta sẽ đi qua các ví dụ về các chương trình bị Data Race, và tìm ra điều kiện Race với công cụ race detector.
Data Race
Thay vì giải thích Data Race là gì, hãy cùng xem đoạn code ví dụ sau đây
func main() { fmt.Println(getNumber()) } func getNumber() int { var i int go func() { i = 5 }() return i }
Ở đây, chúng ta có thể thấy rằng hàm getNumber
đang cài đặt giá trị của i
sử dụng các goroutine riêng biệt. Chúng ta cũng trả về i
mà không cần biết rằng liệu goroutine đã hoàn thành hay chưa. Nên giờ đây có 2 khả năng có thể xảy ra:
- Giá trị của
i
được thiết lập bằng5
- Giá trị của
i
đang được trả về từ hàm
Giờ đây dựa trên thao tác nào được hoàn thành trước giá trị được in ra có thể là 0
hoặc 5
.
Đây là lý do tại sao nó được gọi là Data Race: gía trị trả về từ getNumber
dựa trên thao tác 1 và 2 nào kết thúc trước.
Data Race với thao tác đọc đang kết thúc trước
Data Race với thao tác ghi đang kết thúc trước
Như bạn có thể hình dung, thật kinh khủng khi test và sử dụng code mà trả về kết quả khác nhau khi bạn gọi nó, đây chính là lý do tại sao Data Race được đưa ra như là một vấn đề lớn.
Phát hiện Data Race
Code mà chúng ta đã đi qua là một trong những ví dụ đơn giản của Data Race trong thực tế. Trong ứng dụng lớn hơn, Data Race sẽ khó phát hiện hơn. May mắn cho chúng ta, Go có cách giúp chúng ta phát hiện ra Data Race mà chúng ta có thể sử dụng để ghim lại các điểm tiềm năng xảy ra Data Race.
Sử dụng nó đơn giản bằng cách thêm cờ -race
cho câu lệnh Go thông thường.
Ví dụ, hãy thử chạy chương trình sử dụng cờ -race
như sau
go run -race main.go
Kết quả
0 ================== WARNING: DATA RACE Write at 0x00c420086008 by goroutine 6: main.getNumber.func1() /Users/thuc/go/src/tmp/main.go:15 +0x3b Previous read at 0x00c420086008 by main goroutine: main.getNumber() /Users/thuc/go/src/tmp/main.go:17 +0x8e main.main() /Users/thuc/go/src/tmp/main.go:9 +0x33 Goroutine 6 (running) created at: main.getNumber() /Users/thuc/go/src/tmp/main.go:14 +0x7d main.main() /Users/thuc/go/src/tmp/main.go:9 +0x33 ================== Found 1 data race(s) exit status 66
Đầu tiên kết quả 0
được in ra (nên chúng ta biết rằng thao tác thứ 2 xảy ra trước). Các hàng tiếp theo hiển thị cho chúng ta thông tin về Data Race được tìm thấy trong code (Số hàng có thể không phù hợp với ví dụ code bên trên vì code thực tế sẽ được import và khai báo các package).
Chúng ta có thể thấy rằng thông tin về Data Race được chia thành 3 phần:
- Phần đầu tiên cho chúng ta biết rằng có thao tác ghi được thử bên trong một goroutine mà chúng ta tạo (Nơi chúng ta gán giá trị 5 cho
i
). - Phần tiếp theo cho chúng ta biết rằng một thao tác đọc đồng thời trong main goroutine, mà trong code của chúng ta, dấu vết thông qua câu lệnh trả về và câu lệnh in ra.
- Phần thứ 3 miêu tả rằng goroutine là nguyên nhân gây ra
Nên chỉ bằng việc thêm cờ -race
, sau đó câu lệnh go run
đã giải thích chính xác những gì xảy ra ở ví dụ bên trên.
Cờ -race
cũng có thể được thêm vào cho các câu lệnh go build
và go test
Nó là đơn giản để tìm ra điều kiện Race tiềm tàng trong Go, và tôi nghĩ rằng không có bất kỳ lý do nào để không thêm vào cờ -race
khi build một ứng dụng Go. Những lợi ích vượt xa chi phí và có thể đóng góp một ứng dụng mạnh mẽ hơn.
Sửa lỗi Data Race
Cuối cùng khi bạn tìm thấy vấn đề Data Race, bạn có thể yên tâm rằng Go cung cấp nhiều lựa chọn để sửa nó. Tất cả những giải pháp này giúp đảm bảo rằng việc truy cập đến biến trong các vấn đề được chặn nếu nó đang được ghi
Ngăn chặn sử dụng waitgroup
Cách đơn giản nhất để giải quyết vấn đề Data Race là ngăn chặn việc đọc cho tới khi tao tác ghi được hoàn thành.
func getNumber() int { var i int // Khởi tạo một biến waitgroup var wg sync.WaitGroup // `Add(1) biểu thị rằng có một task chúng ta cần đợi wg.Add(1) go func() { i = 5 // Gọi `wg.Done` biểu thị rằng chúng ta đã hoàn thành task wg.Done() }() // `wg.Wait` chặn cho tới khi `wg.Done` được gọi số lần tương tự // như số lượng task chúng ta có (trong trường hợp này, 1 lần) wg.Wait() return i }
Kết quả
5
Chặn bằng channel
Phương thức này về nguyên tắc giống như phương thức cuối cùng, ngoại trừ việc chúng ta sử dụng channel thay thế cho waitgroup.
func getNumber() int { var i int // Tạo một channel để đẩy một struct rỗng cho tời khi chúng ta hoàn thành done := make(chan struct{}) go func() { i = 5 // đẩy một struct rỗng cho tời khi chúng ta hoàn thành done <- struct{}{} }() // Câu lệnh này đợi cho tới khi có cái gì đó được đẩy vào trong channel `done` <-done return i }
Ngăn chặn bên trong hàm getNumber
, mặc dù đơn giản, sẽ gặp rắc rồi nếu chúng ta muốn gọi hàm liên tục. Phương pháp tiếp theo sẽ sử dụng một cách tiếp cận linh động đối với việc chặn.
Trả về một channel
Thay vì sử dụng channel để ngăn chặn hàm, chúng ta có thể trả về một channel thông qua việc đẩy kết quả, khi chúng ta có nó. Không giống như 2 phương thức trước, phương thực này không thực hiện bất kỳ việc chặn nào. Thay vào đó nó để lại việc quyết định chặn cho phía gọi code
// trả về một channel kiểu số nguyên thay cho một số nguyên func getNumberChan() <-chan int { // tạo channel c := make(chan int) go func() { // đẩy kết quả vào trong channel c <- 5 }() // trả về channel ngay lập tức return c }
Thì bạn có thể nhận được kết quả từ channel trong code phía gọi
func main() { // Code được đợi cho tới khi có cái gì đó được đẩy vào trong channel trả về // Trái ngược với phương thức trước, chúng ta chặn trong hàm main, thay vì // chính hàm của nó i := <-getNumberChan() fmt.Println(i) }
Cách tiếp cận này là linh động hơn bởi vì nó cho phép hàm cấp độ cao hơn để quyết định việc block và cơ chế chạy đồng thời, thay vì xử lý hàm getNumber
tuần tự.
Sử dụng mutex
Cho đến hiện tại, chúng ta đã quyết định rằng giá trị i
chỉ nên được đọc sau khi thao tác ghi hoàn thành. Hãy thử nghĩ về trường hợp, chúng ta không quan tâm đến thứ tự đọc và ghi, chúng ta chỉ yêu cầu rằng chúng không xảy ra đồng thời. Nếu nó giống như trường hợp của bạn, thì bạn nên sử dụng mutex.
// Đầu tiên tạo một struct chứa giá trị chúng ta muốn trả về // cùng với một mutex instance type SafeNumber struct { val int m sync.Mutex } func (i *SafeNumber) Get() int { // Phương thức `Lock` của mutex đợi nếu nó đã bị khoá // Nếu không, nó chặn các cuộc gọi khác cho tới khi phương thức `Unlock` được gọi i.m.Lock() // Defer `Unlock` cho tới khi phương thức này trả về defer i.m.Unlock() // Trả về giá trị return i.val } func (i *SafeNumber) Set(val int) { // Giống như phương thức `Get`, trừ khi chúng ta lock cho tới khi hoàn thành // writing to `i.val` i.m.Lock() defer i.m.Unlock() i.val = val } func getNumber() int { // tạo một instance của `SafeNumber` i := &SafeNumber{} // Sử dụng `Set` và `Get` thay vì gán bình thường và đọc // chúng ta bây giờ có thể đảm bảo rằng chỉ đọc nếu thao tác ghi hoàn thành hoặc vv... go func() { i.Set(5) }() return i.Get() }
Chúng ta có thể sử dụng getNumber
giống như các trường hợp khác. Nhìn lướt qua thì phương thức này có vể không hữu ích, chúng ta không đảm bảo được giá trị của i
là gì
Mutex với thao tác ghi khoá trước
Mutex với thao tác đọc khoá trước
Đây là lợi ích thực tế của mutex khi chúng ta có nhiều tao tác ghi cùng lúc, mà được lẫn lộn với thao tác đọc. Mặc dù bạn sẽ không cần trong hầu hết trường hợp, do các phương thức trước hoạt động đủ tốt, nó giúp nhận biết về chúng trong những trường hợp khác nhau.