Data Race Detector

Giới thiệu

Data race là một trong những loại lỗi phổ biến nhất và khó gỡ lỗi nhất trong các hệ thống đồng thời. Một data race xảy ra khi hai goroutine truy cập cùng một biến đồng thời và ít nhất một trong các lần truy cập là thao tác ghi. Xem Mô hình bộ nhớ Go để biết thêm chi tiết.

Dưới đây là ví dụ về một data race có thể dẫn đến crash 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

Để giúp chẩn đoán các lỗi như vậy, Go bao gồm một bộ phát hiện data race tích hợp sẵn. Để sử 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 bộ phát hiện race tìm thấy một data race trong chương trình, nó in ra một báo cáo. Báo cáo chứa các stack trace cho những lần truy cập xung đột, cũng như các stack nơi các goroutine liên quan được tạo ra. Dưới đâ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

Tùy chọn

Biến môi trường GORACE đặt các tùy chọn cho bộ phát hiện race. Định dạng là:

GORACE="option1=val1 option2=val2"

Các tùy chọn gồm:

Ví dụ:

$ GORACE="log_path=/tmp/race/report strip_path_prefix=/my/go/sources/" go test -race

Loại trừ các bài kiểm thử

Khi bạn biên dịch 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ã và bài kiểm thử khi chạy bộ phát hiện race. Một số 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) {
	// ...
}

Cách sử dụng

Để bắt đầu, hãy chạy các bài kiểm thử của bạn bằng bộ phát hiện race (go test -race). Bộ phát hiện race chỉ tìm ra các race xảy ra tại thời điểm chạy, do đó nó không thể tìm thấy các race trong những đường dẫn mã không được thực thi. Nếu các bài kiểm thử của bạn có độ phủ chưa đầy đủ, bạn có thể tìm thêm các race bằng cách chạy một tệp nhị phân được biên dịch với -race dưới tải trọng thực tế.

Các 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 bộ phát hiện race.

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 là cùng biến được dùng bởi vòng lặp, do đó thao tác đọc trong goroutine bị race với phép tăng của vòng lặp. (Chương trình này thường in ra 55555, không phải 01234.) Chương trình có thể được sửa 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 các biến mới trong các goroutine (lưu ý cách 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ó sẽ dẫn đến race trên map service. Đọc và ghi đồng thời vào 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]
}

Để làm cho mã an toàn, hãy bảo vệ các lần truy cập bằng 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 thủy không được bảo vệ

Data race cũng có thể xảy ra trên các biến kiểu nguyên thủy (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 đến các vấn đề khó gỡ lỗi do tính không nguyên tử của các lần truy cập bộ nhớ, can thiệp từ các tối ưu hóa trình biên dịch, hoặc các vấn đề về sắp xếp lại thứ tự khi truy cập bộ nhớ bộ xử lý.

Cách sửa điển hình cho race này là dùng channel hoặc mutex. Để giữ nguyên hành vi không khóa, có thể dùng gói 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 đồng bộ

Như ví dụ này minh họa, các thao tác gửi và đóng không đồng bộ 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ớ Go, một thao tác gửi trên channel xảy ra trước khi thao tác nhận tương ứng từ channel đó hoàn thành. Để đồng bộ hóa các thao tác gửi và đóng, hãy dùng một thao tác nhận đảm bảo rằng thao tác gửi hoàn thành trước khi đóng:

c := make(chan struct{}) // or buffered channel

go func() { c <- struct{}{} }()
<-c
close(c)

Yêu cầu

Bộ phát hiện race yêu cầu cgo phải được bật, và trên các hệ thống không phải Darwin cần phải cài đặt trình biên dịch C. Bộ phát hiện race hỗ trợ linux/amd64, linux/ppc64le, linux/arm64, linux/s390x, linux/loong64, freebsd/amd64, netbsd/amd64, darwin/amd64, darwin/arm64windows/amd64.

Trên Windows, runtime của bộ phát hiện race nhạy cảm với phiên bản trình biên dịch C được cài đặt; từ Go 1.21, để biên dịch một chương trình với -race cần một trình biên dịch C tích hợp phiên bản 8 trở lên của 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 đối số --print-file-name libsynchronization.a. Một trình biên dịch C mới hơn tuân thủ sẽ in ra đường dẫn đầy đủ cho thư viện này, trong khi các trình biên dịch C cũ hơn sẽ chỉ lặp lại đối số.

Chi phí runtime

Chi phí phát hiện race thay đổi tùy chương trình, nhưng đối với một chương trình điển hình, mức sử dụng bộ nhớ có thể tăng 5-10 lần và thời gian thực thi tăng 2-20 lần.

Bộ phát hiện race hiện cấp phát thêm 8 byte cho mỗi câu lệnh deferrecover. Các cấp phát thêm đó không được thu hồi cho đến khi goroutine kết thúc. Điều này có nghĩa là nếu bạn có một goroutine chạy lâu dài liên tục phát ra các lời gọi deferrecover, mức sử dụng bộ nhớ của chương trình có thể tăng không giới hạn. Các 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.