1. Vấn đề khi truy cập đồng thời
1.1. Tình trạng cạnh tranh dữ liệu (Data Race)
Trong lập trình Go, lập trình đồng thời là phương pháp quan trọng để khai thác hiệu năng của các bộ xử lý đa nhân. Tuy nhiên, khi nhiều goroutine cùng truy cập và thay đổi một cấu trúc dữ liệu như mảng, có thể xảy ra tình trạng cạnh tranh dữ liệu. Đây là một vấn đề nghiêm trọng, xảy ra khi một hoặc nhiều goroutine thực hiện thao tác ghi (write) trên cùng một phần tử của mảng trong khi có ít nhất một goroutine khác đang thực hiện thao tác đọc (read) trên phần tử đó. Do thứ tự thực thi của các goroutine không được đảm bảo, dữ liệu có thể bị ghi đè hoặc đọc sai, dẫn đến kết quả không chính xác.
package main
import (
"fmt"
"sync"
)
var mangSo [10]int
var wg sync.WaitGroup
// TangGiaTri tăng giá trị của một phần tử trong mảng.
func TangGiaTri(viTri int) {
defer wg.Done()
mangSo[viTri] += 1
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1)
go TangGiaTri(i)
}
wg.Wait()
fmt.Println("Kết quả mảng:", mangSo)
}
Mặc dù đoạn mã trên trông có vẻ sẽ tăng mỗi phần tử của mảng `mangSo` lên 1, nhưng do các goroutine chạy song song, kết quả thực tế có thể không như mong đợi. Một số phần tử có thể không được tăng giá trị, hoặc tăng nhiều hơn một lần, đây chính là biểu hiện của tình trạng cạnh tranh dữ liệu.
1.2. Đọc dữ liệu không nhất quán (Dirty Read)
Một vấn đề khác là đọc dữ liệu không nhất quán (Dirty Read). Điều này xảy ra khi một goroutine đọc một phần tử của mảng trong khi một goroutine khác đang thực hiện một chuỗi thao tác ghi phức tạp trên phần tử đó. Nếu goroutine đọc can thiệp vào quá trình ghi, nó có thể nhận được một giá trị trung gian, không hoàn chỉnh.
package main
import (
"fmt"
"sync"
)
var mangDuLieu [1]int
var wg sync.WaitGroup
// GhiDuLieu thực hiện một chuỗi thao tác ghi.
func GhiDuLieu() {
defer wg.Done()
mangDuLieu[0] = 10
mangDuLieu[0] = 20 // Ghi đè giá trị
}
// DocDuLieu đọc giá trị từ mảng.
func DocDuLieu() {
defer wg.Done()
fmt.Println("Giá trị đọc được:", mangDuLieu[0])
}
func main() {
wg.Add(2)
go GhiDuLieu()
go DocDuLieu()
wg.Wait()
}
Trong ví dụ này, hàm `GhiDuLieu` gán giá trị 10 rồi ngay lập tức gán 20 cho phần tử đầu tiên của mảng `mangDuLieu`. Tuy nhiên, goroutine thực thi `DocDuLieu` có thể đọc được giá trị 10 nếu nó được thực thi giữa hai thao tác gán này, dẫn đến việc đọc được dữ liệu "bẩn".
2. Các giải pháp
2.1. Sử dụng KhóaMutex (Mutex)
KhóaMutex là một công cụ hiệu quả để giải quyết các vấn đề về đồng thời. Bằng cách bao quanh các thao tác truy cập mảng trong một khối khóa, chúng ta đảm bảo rằng chỉ có một goroutine có thể truy cập mảng tại một thời điểm, từ đó loại bỏ hoàn toàn tình trạng cạnh tranh dữ liệu và đọc dữ liệu không nhất quán.
package main
import (
"fmt"
"sync"
)
var mangSo [10]int
var wg sync.WaitGroup
var khóaMutex sync.Mutex // Khai báo một KhóaMutex
func TangGiaTri(viTri int) {
defer wg.Done()
khóaMutex.Lock() // Đóng khóa trước khi truy cập
mangSo[viTri] += 1
khóaMutex.Unlock() // Mở khóa sau khi hoàn thành
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1)
go TangGiaTri(i)
}
wg.Wait()
fmt.Println("Kết quả mảng (an toàn):", mangSo)
}
Bằng cách sử dụng `khóaMutex.Lock()` và `khóaMutex.Unlock()`, chúng ta đảm bảo rằng chỉ một goroutine có thể thực thi khối mã bên trong cùng một lúc, đảm bảo tính toàn vẹn của dữ liệu.
2.2. Khóa đọc-ghi (RWMutex)
Khi có nhiều thao tác đọc và ít thao tác ghi, `RWMutex` (Read-Write Mutex) là một lựa chọn tốt hơn `Mutex` thông thường. `RWMutex` cho phép nhiều goroutine đọc đồng thời, nhưng sẽ khóa độc quyền khi có một goroutine thực hiện thao tác ghi.
package main
import (
"fmt"
"sync"
)
var mangThongTin [10]int
var wg sync.WaitGroup
var khóaDocGhi sync.RWMutex // Khai báo một Khóa đọc-ghi
// DocGiaTri đọc giá trị của một phần tử.
func DocGiaTri(viTri int) {
defer wg.Done()
khóaDocGhi.RLock() // Đóng khóa chỉ để đọc
fmt.Println("Đọc giá trị tại", viTri, ":", mangThongTin[viTri])
khóaDocGhi.RUnlock() // Mở khóa đọc
}
// GhiGiaTri ghi giá trị vào một phần tử.
func GhiGiaTri(viTri, giaTri int) {
defer wg.Done()
khóaDocGhi.Lock() // Đóng khóa để ghi (độc quyền)
mangThongTin[viTri] = giaTri
khóaDocGhi.Unlock() // Mở khóa ghi
}
func main() {
// Tạo nhiều goroutine đọc
for i := 0; i < 5; i++ {
wg.Add(1)
go DocGiaTri(i)
}
// Tạo một goroutine ghi
wg.Add(1)
go GhiGiaTri(0, 100)
wg.Wait()
}
Trong ví dụ này, `DocGiaTri` sử dụng `RLock()` và `RUnlock()` để cho phép nhiều goroutine đọc cùng lúc. Ngược lại, `GhiGiaTri` sử dụng `Lock()` và `Unlock()` để đảm bảo không có goroutine nào khác (dù là đọc hay ghi) có thể truy cập mảng trong khi nó đang thực hiện thao tác ghi.
2.3. Sử dụng Kênh (Channel)
Kênh là một tính năng cốt lõi của Go, có thể được sử dụng để giải quyết vấn đề truy cập đồng thời bằng cách chuyển các yêu cầu thao tác thành một hàng đợi tuần tự. Thay vì cho phép các goroutine truy cập trực tiếp, chúng ta gửi các hàm thực hiện thao tác vào một kênh, và một goroutine riêng biệt sẽ xử lý chúng một cách tuần tự.
package main
import (
"fmt"
"sync"
)
var mangKetQua [10]int
var wg sync.WaitGroup
var kênhHoạtĐộng = make(chan func()) // Kênh để nhận các hàm thao tác
// XửLýHoạtĐộng là goroutine duy nhất thực thi các thao tác từ kênh.
func XửLýHoạtĐộng() {
for op := range kênhHoạtĐộng {
op()
}
}
func TangPhanTu(viTri int) {
defer wg.Done()
kênhHoạtĐộng <- func() {
mangKetQua[viTri] += 1
}
}
func main() {
go XửLýHoạtĐộng() // Khởi động goroutine xử lý
for i := 0; i < 10; i++ {
wg.Add(1)
go TangPhanTu(i)
}
close(kênhHoạtĐộng) // Đóng kênh sau khi gửi hết các thao tác
wg.Wait()
fmt.Println("Kết quả mảng (qua kênh):", mangKetQua)
}
Trong đoạn mã này, các goroutine gửi một hàm ẩn danh thực hiện thao tác tăng giá trị vào kênh `kênhHoạtĐộng`. Goroutine `XửLýHoạtĐộng` duy nhất đọc các hàm này từ kênh và thực thi chúng một cách tuần tự, đảm bảo rằng không có hai thao tác nào trên mảng được thực hiện cùng lúc.