Tương tranh trong Go - Phần 2: Race Contition, Mutex và Channel
danh sách bài viết
Tương tranh trong Go - Phần 2: Race Contition, Mutex và Channel
Viết bởi Mai Duy Dương
phần trước, tôi đã chia sẻ cho các bạn thấy cách tạo GoRoutine và cách sử dụng sync.WaitGroup.
Với việc lập trình tương tranh trong Golang, việc tạo ra GoRoutine và chạy nó ở nền là vô cùng dễ dàng. Việc biết được vấn đề về tranh đoạt điều khiển (hay tiếng Anh là race condition) là vô cùng quan trọng. Tôi cũng sẽ chia sẻ cho bạn về mutex hay mutual exclusion (tiếng Việt là loại trừ lẫn nhau). Và cuối cùng sẽ về channel - phương pháp xử lý với tương tranh ưa thích trong go.
Giới thiệu về race condition
Tranh đoạt điều khiển xảy ra khi nhiều GoRoutine cố gắng để truy cập vào cùng dữ liệu. Điều này có thể sẽ khó có thể phát hiện khi đọc code. Trong Go, chúng ta có thể kiểm tra chúng khi chạy chương trình hoặc khi chạy test với go test.
Chúng ta có đoạn code sau
package main

import (
	"fmt"
	"sync"
)

var msg string
var wg sync.WaitGroup

func updateMessage(s string) {
	defer wg.Done()

	msg = s
}

func main() {
	msg = "Hello, mom!"

	wg.Add(2)
	go updateMessage("Hello, dad!")
	go updateMessage("Hello, sister!")
	wg.Wait()

	fmt.Println(msg)
}
Khi chúng ta chạy chương trình, màn hình terminal in ra kết quả
Hello, dad!
Có thể bạn sẽ mong đợi nó in ra Hello, sister! bởi nó được gọi sau, nhưng bằng một cách nào đó, updateMessage("Hello, sister") đã hoàn thành trước. Bạn cũng sẽ không thể nào biết được kết quả cho lần in ra tiếp theo .
Tiếp theo, để chạy chương trình, bạn thêm -race vào lệnh go run
go run -race main.go
Và khi đó, Go sẽ đưa cho bạn cảnh báo về việc tranh đoạt điều khiển.
==================
WARNING: DATA RACE
Write at 0x0000005ab830 by goroutine 8:
  main.updateMessage()
      /home/duong/go-concurrency-1/code/main.go:14 +0x6f
  main.main.func2()
      /home/duong/go-concurrency-1/code/main.go:22 +0x37

Previous write at 0x0000005ab830 by goroutine 7:
  main.updateMessage()
      /home/duong/go-concurrency-1/code/main.go:14 +0x6f
  main.main.func1()
      /home/duong/go-concurrency-1/code/main.go:21 +0x37

GoRoutine 8 (running) created at:
  main.main()
      /home/duong/go-concurrency-1/code/main.go:22 +0x90

goroutine 7 (finished) created at:
  main.main()
      /home/duong/go-concurrency-1/code/main.go:21 +0x84
==================
Hello, sister!
Found 1 data race(s)
exit status 66  
Mutex
Hiện tại, chương trình của tôi đang có một chút vấn đề. Hai lần gọi updatemessage đang xảy ra cùng một thời điểm. Chúng đang truy cập và muốn thay đổi dữ liệu của biến msg, diễn ra đồng thời và ta không biết lần gọi nào sẽ hoàn thành trước.
Chúng ta sẽ phải giải quyết vấn đề này, và may thay đối với Go, việc này là vô cùng dễ dàng.
Chúng ta sẽ sử dụng sync.Mutex trong code hiện tại
package main

import (
	"fmt"
	"sync"
)

var msg string
var wg sync.WaitGroup

func updateMessage(m *sync.Mutex, s string) {
	defer wg.Done()

	m.Lock()
	msg = s
	m.Unlock()
}

func main() {
	msg = "Hello, mom!"

	mutex := sync.Mutex{}

	wg.Add(2)
	go updateMessage(&mutex, "Hello, dad!")
	go updateMessage(&mutex, "Hello, sister!")
	wg.Wait()

	fmt.Println(msg)
}
Như bạn có thể thấy, đầu tiên tôi khởi tạo biến mutex := sync.Mutex{}. Sau đó, bên trong hàm updateMessage, tôi thêm pointer để truyền vào biến mutex trên, cùng với thêm Lock()Unlock() trước và sau khi tôi truy cập và thay đổi dữ liệu của biến msg.
Sau đó, chúng ta chạy lại lện go run -race main.go và lần này, mặc dù chúng ta vẫn không biết GoRoutine nào sẽ hoàn thành trước, có thể là dad hoặc sister.
Nhưng điều quan trọng ở đây là tôi đã truy cập được dữ liệu một cách an toàn. Điều này được mọi người gọi là an toàn luồng (cái tên Tiếng Việt tôi chưa kiếm được ở đâu nên bịa ra, còn người ta hay gọi là thread safe).
Hello, dad!  
Bây giờ, bạn có thể thấy màn hình in ra mà không hiện WARNING: DATA RACE như ở trên.
Go channel
Trước khi giới thiệu cho bạn về Go channel, tôi sẽ giới thiệu cho bạn một vấn đề của khoa học máy tính, có tên tiếng Việt là vấn đề Nhà sản xuất-Người tiêu thụ (Producer/Consumer Problem).
Ngắn gọn dễ hiểu, bài toán này được định nghĩa như sau: có 1 Nhà sản xuất và 1 Người tiêu thụ, và có 1 cái Kho. Nhà sản xuất tạo ra món hàng, cho vào trong kho và Người tiêu thụ lấy món hàng đó ra và tiêu thụ.
Các vấn đề cần phải xem xét ở đây có thể kể đến như sau:
  • Nếu cái Kho đầy thì Nhà sản xuất không được sản xuất thêm.
  • Nếu cái Kho trống thì Người tiêu thụ không được tiêu thụ.
  • Trong 1 thời điểm, chỉ 1 người được đụng vào cái kho.
Để mô tả bài toán trên, tôi có 1 ví dụ sau. Ta có 2 hai bạn Na và An rủ nhau vào một vườn chuối. An thèm ăn chuối, vì vậy mới bắt đầu nhờ Na hái chuối. Na hái vào một cái giỏ, An lấy chuối trong giỏ, cái giỏ có thể dựng nhiều nhất 9 quả chuối. Mỗi quả chuối có trọng lượng khác nhau và cần thời gian khác nhau để tiêu thụ. Còn với việc hái xuống và bỏ vào giỏ, đối với tất cả các kích thước, chỉ cần 1 giây. Sau đây là đoạn code minh họa cho ví dụ trên.
package main

import (
	"fmt"
	"math/rand"
	"time"
)

// Tạo ra channel với max size là 9
var backet = make(chan int, 9)

func pick() {
	for {
		// Bắt đầu tính thời gian và nhặt chuối
		pickTime := 1000 * time.Millisecond
		time.Sleep(pickTime)

		// Tính cân nặng và bỏ chuối vào giỏ
		w := rand.Intn(5) + 1
		backet <- w // Đây là cú pháp để gửi tín hiệu vào channel

		fmt.Printf("Na HÁI chuối với trọng luợng %vkg trong vòng: %v\n", w, pickTime)
	}
}

func eat() {
	for {
		// An lấy ra quả chuối
		w := <- backet // Đây là cú pháp để lấy tín hiệu từ channel

		// Tính thời gian ăn và An bắt đầu ăn
		eatTime := time.Millisecond * time.Duration(w * 300)
		time.Sleep(eatTime)
		
		fmt.Printf("An ĂN  chuối với trọng luợng %vkg trong vòng: %v\n", w, eatTime)
	}
}

func main() {
	rand.Seed(time.Now().UnixNano())
	
	go pick()
	eat()
}
Như ở trên, bạn có thể thấy cú pháp cho việc tạo ra channel, gửi và lấy dữ liệu từ channel. Cuối cùng chúng ta chạy code, màn hình in ra.
Na HÁI chuối với trọng luợng 3kg trong vòng: 1s
An ĂN  chuối với trọng luợng 3kg trong vòng: 900ms
Na HÁI chuối với trọng luợng 5kg trong vòng: 1s
Na HÁI chuối với trọng luợng 4kg trong vòng: 1s
An ĂN  chuối với trọng luợng 5kg trong vòng: 1.5s
Na HÁI chuối với trọng luợng 1kg trong vòng: 1s
An ĂN  chuối với trọng luợng 4kg trong vòng: 1.2s
Na HÁI chuối với trọng luợng 1kg trong vòng: 1s
An ĂN  chuối với trọng luợng 1kg trong vòng: 300ms
An ĂN  chuối với trọng luợng 1kg trong vòng: 300ms
Na HÁI chuối với trọng luợng 3kg trong vòng: 1s
An ĂN  chuối với trọng luợng 3kg trong vòng: 900ms
Na HÁI chuối với trọng luợng 5kg trong vòng: 1s
Na HÁI chuối với trọng luợng 5kg trong vòng: 1s
An ĂN  chuối với trọng luợng 5kg trong vòng: 1.5s
Na HÁI chuối với trọng luợng 4kg trong vòng: 1s
Na HÁI chuối với trọng luợng 3kg trong vòng: 1s
An ĂN  chuối với trọng luợng 5kg trong vòng: 1.5s
Na HÁI chuối với trọng luợng 3kg trong vòng: 1s
An ĂN  chuối với trọng luợng 4kg trong vòng: 1.2s
...
Có vẻ chương trình chạy rất tốt. Đến đây, tôi đã giới thiệu qua cho bạn về bài toán producer/consumer.