Go – Data Race

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:

  1. Giá trị của i được thiết lập bằng 5
  2. 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:

  1. 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).
  2. 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.
  3. 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.

You May Also Like

About the Author: Nguyen Dinh Thuc

Leave a Reply

Your email address will not be published.