Giới thiệu
Data race (hai goroutine truy cập cùng một vùng nhớ mà không đồng bộ) nằm trong số những loại lỗi phổ biến nhất và khó gỡ nhất của các hệ thống đồng thời. Một data race xảy ra khi hai goroutine truy cập đồng thời cùng một biến và ít nhất một trong các truy cập đó là ghi. Xem Mô hình bộ nhớ của Go để biết chi tiết.
Dưới đây là một ví dụ về data race có thể dẫn tới sập chương trình và hỏng bộ nhớ:
func main() {
c := make(chan bool)
m := make(map[string]string)
go func() {
m["1"] = "a" // First conflicting access.
c <- true
}()
m["2"] = "b" // Second conflicting access.
<-c
for k, v := range m {
fmt.Println(k, v)
}
}
Cách dùng
Để hỗ trợ chẩn đoán những lỗi như vậy, Go có sẵn một race detector (công cụ phát hiện data race) tích hợp. Để dùng nó, thêm cờ -race vào lệnh go:
$ go test -race mypkg // to test the package
$ go run -race mysrc.go // to run the source file
$ go build -race mycmd // to build the command
$ go install -race mypkg // to install the package
Định dạng báo cáo
Khi race detector phát hiện một data race trong chương trình, nó in ra một báo cáo. Báo cáo chứa stack trace của các truy cập xung đột, cũng như stack tại nơi các goroutine liên quan được tạo ra. Đây là một ví dụ:
WARNING: DATA RACE
Read by goroutine 185:
net.(*pollServer).AddFD()
src/net/fd_unix.go:89 +0x398
net.(*pollServer).WaitWrite()
src/net/fd_unix.go:247 +0x45
net.(*netFD).Write()
src/net/fd_unix.go:540 +0x4d4
net.(*conn).Write()
src/net/net.go:129 +0x101
net.func·060()
src/net/timeout_test.go:603 +0xaf
Previous write by goroutine 184:
net.setWriteDeadline()
src/net/sockopt_posix.go:135 +0xdf
net.setDeadline()
src/net/sockopt_posix.go:144 +0x9c
net.(*conn).SetDeadline()
src/net/net.go:161 +0xe3
net.func·061()
src/net/timeout_test.go:616 +0x3ed
Goroutine 185 (running) created at:
net.func·061()
src/net/timeout_test.go:609 +0x288
Goroutine 184 (running) created at:
net.TestProlongTimeout()
src/net/timeout_test.go:618 +0x298
testing.tRunner()
src/testing/testing.go:301 +0xe8
Tuỳ chọn
Biến môi trường GORACE đặt các tuỳ chọn cho race detector. Định dạng là:
GORACE="option1=val1 option2=val2"
Các tuỳ chọn gồm:
log_path(mặc địnhstderr): race detector ghi báo cáo của nó vào một tệp tên làlog_path.pid. Hai tên đặc biệtstdoutvàstderrkhiến báo cáo được ghi ra đầu ra chuẩn và đầu ra lỗi chuẩn, theo thứ tự đó.exitcode(mặc định66): trạng thái thoát dùng khi thoát sau lúc phát hiện một race.strip_path_prefix(mặc định""): cắt bỏ tiền tố này khỏi mọi đường dẫn tệp được báo cáo, để báo cáo ngắn gọn hơn.history_size(mặc định1): lịch sử truy cập bộ nhớ của mỗi goroutine là32K * 2**history_size elements. Tăng giá trị này có thể tránh được lỗi “failed to restore the stack” trong báo cáo, nhưng đánh đổi bằng việc dùng nhiều bộ nhớ hơn.halt_on_error(mặc định0): điều khiển việc chương trình có thoát sau khi báo cáo data race đầu tiên hay không.atexit_sleep_ms(mặc định1000): số mili giây ngủ trong goroutine chính trước khi thoát.
Ví dụ:
$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race
Loại trừ các test
Khi bạn build với cờ -race, lệnh go định nghĩa thêm build tag race. Bạn có thể dùng tag này để loại trừ một số mã nguồn và test khi chạy race detector. Vài ví dụ:
// +build !race
package foo
// The test contains a data race. See issue 123.
func TestFoo(t *testing.T) {
// ...
}
// The test fails under the race detector due to timeouts.
func TestBar(t *testing.T) {
// ...
}
// The test takes too long under the race detector.
func TestBaz(t *testing.T) {
// ...
}
Sử dụng thế nào
Để bắt đầu, hãy chạy các test của bạn với race detector (go test -race). Race detector chỉ tìm được những race xảy ra lúc runtime, nên nó không thể tìm ra race trong những nhánh mã không được thực thi. Nếu các test của bạn chưa phủ hết mã, bạn có thể tìm thêm được race bằng cách chạy một binary được build với -race dưới khối lượng công việc thực tế.
Những data race điển hình
Dưới đây là một số data race điển hình. Tất cả đều có thể được phát hiện bằng race detector.
Race trên biến đếm vòng lặp
func main() {
var wg sync.WaitGroup
wg.Add(5)
var i int
for i = 0; i < 5; i++ {
go func() {
fmt.Println(i) // Not the 'i' you are looking for.
wg.Done()
}()
}
wg.Wait()
}
Biến i trong hàm literal chính là biến mà vòng lặp đang dùng, nên việc đọc trong goroutine chạy đua với phép tăng của vòng lặp. (Chương trình này thường in ra 55555, chứ không phải 01234.) Có thể sửa chương trình bằng cách tạo một bản sao của biến:
func main() {
var wg sync.WaitGroup
wg.Add(5)
var i int
for i = 0; i < 5; i++ {
go func(j int) {
fmt.Println(j) // Good. Read local copy of the loop counter.
wg.Done()
}(i)
}
wg.Wait()
}
Biến bị chia sẻ ngoài ý muốn
// ParallelWrite writes data to file1 and file2, returns the errors.
func ParallelWrite(data []byte) chan error {
res := make(chan error, 2)
f1, err := os.Create("file1")
if err != nil {
res <- err
} else {
go func() {
// This err is shared with the main goroutine,
// so the write races with the write below.
_, err = f1.Write(data)
res <- err
f1.Close()
}()
}
f2, err := os.Create("file2") // The second conflicting write to err.
if err != nil {
res <- err
} else {
go func() {
_, err = f2.Write(data)
res <- err
f2.Close()
}()
}
return res
}
Cách sửa là khai báo biến mới bên trong các goroutine (chú ý việc dùng :=):
...
_, err := f1.Write(data)
...
_, err := f2.Write(data)
...
Biến toàn cục không được bảo vệ
Nếu đoạn mã sau được gọi từ nhiều goroutine, nó dẫn tới race trên map service. Việc đọc và ghi đồng thời trên cùng một map là không an toàn:
var service map[string]net.Addr
func RegisterService(name string, addr net.Addr) {
service[name] = addr
}
func LookupService(name string) net.Addr {
return service[name]
}
Để mã trở nên an toàn, hãy bảo vệ các truy cập bằng một mutex:
var (
service map[string]net.Addr
serviceMu sync.Mutex
)
func RegisterService(name string, addr net.Addr) {
serviceMu.Lock()
defer serviceMu.Unlock()
service[name] = addr
}
func LookupService(name string) net.Addr {
serviceMu.Lock()
defer serviceMu.Unlock()
return service[name]
}
Biến kiểu nguyên thuỷ không được bảo vệ
Data race cũng có thể xảy ra trên các biến thuộc kiểu dữ liệu nguyên thuỷ (bool, int, int64, v.v.), như trong ví dụ này:
type Watchdog struct{ last int64 }
func (w *Watchdog) KeepAlive() {
w.last = time.Now().UnixNano() // First conflicting access.
}
func (w *Watchdog) Start() {
go func() {
for {
time.Sleep(time.Second)
// Second conflicting access.
if w.last < time.Now().Add(-10*time.Second).UnixNano() {
fmt.Println("No keepalives for 10 seconds. Dying.")
os.Exit(1)
}
}
}()
}
Ngay cả những data race “vô hại” như vậy cũng có thể dẫn tới các vấn đề rất khó gỡ, gây ra bởi tính không nguyên tử của các truy cập bộ nhớ, sự can thiệp của các tối ưu hoá từ compiler, hoặc các vấn đề sắp xếp lại thứ tự khi truy cập bộ nhớ của bộ xử lý .
Cách sửa điển hình cho race này là dùng một channel hoặc một mutex. Để giữ được hành vi lock-free, bạn cũng có thể dùng package sync/atomic.
type Watchdog struct{ last int64 }
func (w *Watchdog) KeepAlive() {
atomic.StoreInt64(&w.last, time.Now().UnixNano())
}
func (w *Watchdog) Start() {
go func() {
for {
time.Sleep(time.Second)
if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
fmt.Println("No keepalives for 10 seconds. Dying.")
os.Exit(1)
}
}
}()
}
Thao tác gửi và đóng không được đồng bộ
Như ví dụ này minh hoạ, các thao tác gửi và đóng không được synchronization trên cùng một channel cũng có thể là một race condition:
c := make(chan struct{}) // or buffered channel
// The race detector cannot derive the happens before relation
// for the following send and close operations. These two operations
// are unsynchronized and happen concurrently.
go func() { c <- struct{}{} }()
close(c)
Theo mô hình bộ nhớ của Go, một thao tác gửi trên channel happens-before thời điểm thao tác nhận tương ứng từ channel đó hoàn tất. Để đồng bộ thao tác gửi và đóng, hãy dùng một thao tác nhận để bảo đảm việc gửi đã xong trước khi đóng:
c := make(chan struct{}) // or buffered channel
go func() { c <- struct{}{} }()
<-c
close(c)
Yêu cầu
Race detector yêu cầu cgo phải được bật, và trên các hệ thống không phải Darwin thì yêu cầu có sẵn một trình biên dịch C. Race detector hỗ trợ linux/amd64, linux/ppc64le, linux/arm64, linux/s390x, linux/loong64, freebsd/amd64, netbsd/amd64, darwin/amd64, darwin/arm64, và windows/amd64.
Trên Windows, runtime của race detector nhạy cảm với phiên bản trình biên dịch C đã cài; kể từ Go 1.21, việc build một chương trình với -race yêu cầu một trình biên dịch C có kèm phiên bản 8 trở lên của các thư viện runtime mingw-w64. Bạn có thể kiểm tra trình biên dịch C của mình bằng cách gọi nó với các đối số --print-file-name libsynchronization.a. Một trình biên dịch C mới và tuân thủ sẽ in ra đường dẫn đầy đủ tới thư viện này, còn các trình biên dịch C cũ hơn thì chỉ lặp lại chính đối số đó.
Chi phí lúc chạy
Chi phí của việc phát hiện race thay đổi tuỳ chương trình, nhưng với một chương trình điển hình, lượng bộ nhớ sử dụng có thể tăng 5-10 lần và thời gian thực thi tăng 2-20 lần.
Race detector hiện cấp phát thêm 8 byte cho mỗi câu lệnh defer và recover. Những phần cấp phát thêm đó không được thu hồi cho tới khi goroutine kết thúc. Điều này nghĩa là nếu bạn có một goroutine chạy lâu và định kỳ gọi defer và recover, lượng bộ nhớ chương trình dùng có thể tăng không giới hạn. Những phần cấp phát bộ nhớ này sẽ không xuất hiện trong đầu ra của runtime.ReadMemStats hay runtime/pprof.