Menu

Mutex trong Golang: sync.Mutex, RWMutex, atomic và sync.Once

Cách bảo vệ dữ liệu dùng chung giữa các goroutine bằng sync.Mutex và sync.RWMutex, khi nào sync/atomic là đủ, cách sync.Once chạy phần khởi tạo đúng một lần, và những lỗi khóa gây ra deadlock.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Bảo vệ dữ liệu dùng chung

sync.Mutex chỉ cho phép mỗi lần một goroutine đi vào phần code nằm giữa Lock và Unlock. Hãy đặt mutex ngay cạnh dữ liệu mà nó bảo vệ, thường là trong cùng một struct:

Chương trình này luôn in ra hits: 10000. Nếu không có khóa, 100 goroutine cùng ghi vào một map thường sẽ làm sập chương trình với fatal error: concurrent map writes. Lỗi đó đến từ một phép kiểm tra "cố gắng hết sức" của runtime, không thể recover, và việc chương trình không sập cũng không chứng minh code đúng.

Những chi tiết quan trọng:

  • Giá trị zero của sync.Mutex là chưa khóa và dùng được ngay. Không cần hàm khởi tạo.
  • Phương thức dùng receiver con trỏ (*Counter). Receiver giá trị sẽ khóa một bản sao của mutex, và như vậy chẳng bảo vệ được gì.
  • defer c.mu.Unlock() ngay sau Lock giúp mọi đường return, kể cả khi panic, đều nhả khóa.
  • Mọi lần truy cập đều phải qua khóa, kể cả đọc. Một lần đọc không khóa trong lúc goroutine khác đang ghi vẫn là data race.

Giữ vùng găng thật nhỏ

defer nhả khóa ở cuối hàm. Như vậy là đúng với những phương thức ngắn như ở trên. Trong một hàm dài hơn, hãy nhả khóa ngay khi không còn đụng đến dữ liệu dùng chung, để các goroutine khác không phải chờ những việc không cần khóa:

func (s *Store) Save(key string) error {
	s.mu.Lock()
	data := s.items[key] // copy what you need
	s.mu.Unlock()

	return writeToDisk(key, data) // slow I/O, outside the lock
}

Giữ khóa trong lúc gọi mạng, đọc ghi đĩa hay gửi vào channel là nguyên nhân phổ biến nhất khiến chương trình đồng thời chạy chậm, và gửi vào channel khi đang giữ khóa là nguyên nhân thường gặp của deadlock.

RWMutex cho dữ liệu đọc nhiều

sync.RWMutex có hai chế độ. RLock/RUnlock lấy khóa đọc dùng chung, nhiều goroutine có thể giữ cùng lúc. Lock/Unlock lấy khóa ghi độc quyền, khóa này chờ đến khi mọi goroutine đọc đã rời đi.

RWMutex đáng dùng khi phần lớn là đọc và mỗi lần đọc làm việc thực sự trong lúc giữ khóa. Với vùng găng rất nhỏ như một lần tra cứu map, một Mutex thường nhanh không kém, vì khóa đọc cũng có chi phí quản lý riêng. Hãy benchmark trước khi chọn.

Bạn không thể nâng khóa đọc thành khóa ghi. Gọi Lock trong khi vẫn giữ RLock trong cùng một goroutine sẽ gây deadlock. Hãy nhả khóa đọc trước, rồi lấy khóa ghi và kiểm tra lại điều kiện, vì một goroutine ghi khác có thể đã thay đổi dữ liệu trong khoảng giữa.

sync/atomic cho một giá trị đơn lẻ

Với một bộ đếm hay một cờ, sync/atomic đơn giản và rẻ hơn mutex. Nên dùng các kiểu bọc có kiểu (Go 1.19):

Atomic chỉ bảo vệ từng giá trị một. Ngay khi hai giá trị phải thay đổi cùng nhau (số dư và số giao dịch, một map và kích thước của nó), hãy dùng mutex. Hai thao tác atomic riêng rẽ có thể bị goroutine khác chen vào giữa.

sync.Once

sync.Once chạy một hàm đúng một lần, dù có bao nhiêu goroutine gọi nó cùng lúc. Mọi goroutine gọi Do đều chờ đến khi lần gọi đầu tiên chạy xong. Đây là cách chuẩn để khởi tạo trễ một thứ gì đó:

Mỗi dòng "runs once" xuất hiện đúng một lần. Nếu hàm truyền vào Do panic, Once vẫn coi như đã chạy xong và không bao giờ thử lại. sync.OnceValues làm điều tương tự cho những hàm trả về hai giá trị, thường là một giá trị và một lỗi.

Mutex, channel hay sync.Map

Tình huốngDùng
Một struct hoặc map mà nhiều goroutine cập nhật tại chỗsync.Mutex trong struct
Phần lớn là đọc, thỉnh thoảng ghi, mỗi lần đọc làm việc thực sựsync.RWMutex
Một bộ đếm hoặc một cờsync/atomic
Khởi tạo một lầnsync.Once, sync.OnceValue
Chuyển dữ liệu từ goroutine này sang goroutine khácchannel
Cache có khóa được ghi một lần và đọc nhiều lần, hoặc các goroutine làm việc trên những khóa tách biệtsync.Map

sync.Map không phải là thứ thay thế chung cho một map có khóa. Nó không có tham số kiểu, nên giá trị trả về là any, và nó chỉ nhanh hơn trong hai trường hợp ở bảng trên. Hãy bắt đầu với một mutex và một map bình thường.

Lỗi thường gặp

  • Sao chép mutex. Truyền theo giá trị một struct chứa sync.Mutex, hoặc dùng receiver giá trị, sẽ sao chép cả khóa. go vet báo passes lock by value hoặc copies lock value.
  • Khóa hai lần trong một goroutine. Mutex của Go không reentrant. Nếu Inc gọi Get và cả hai cùng lấy khóa, Inc sẽ bị chặn mãi mãi. Hãy để các phương thức public lấy khóa, còn các hàm hỗ trợ private mặc định là khóa đã được giữ.
  • Quên nhả khóa khi return sớm. Hãy dùng defer, trừ khi có lý do để không dùng.
  • Lấy khóa theo thứ tự khác nhau. Nếu một goroutine lấy khóa A rồi B, trong khi goroutine khác lấy B rồi A, cả hai có thể chờ nhau mãi mãi. Luôn lấy nhiều khóa theo cùng một thứ tự.
  • Để lộ dữ liệu được bảo vệ. Trả về map bên trong từ một phương thức cho phép nơi gọi đọc và ghi nó mà không qua khóa. Hãy trả về một bản sao (maps.Clone, Go 1.21) hoặc một giá trị đơn lẻ.
  • Chỉ bảo vệ các lần ghi. Đọc không khóa đồng thời với ghi có khóa vẫn là race. Hãy chạy test bằng go test -race.

Câu hỏi thường gặp

Mutex trong Go là gì?

sync.Mutex là một khóa chỉ cho phép mỗi lần một goroutine chạy phần code nằm giữa mu.Lock() và mu.Unlock(). Bạn dùng nó để bảo vệ dữ liệu mà nhiều goroutine cùng đọc và ghi, chẳng hạn một map hay một struct. Giá trị zero của nó là một mutex chưa khóa, dùng được ngay.

Khi nào nên dùng RWMutex thay cho Mutex?

Khi số lần đọc nhiều hơn hẳn số lần ghi và mỗi lần đọc giữ khóa trong một khoảng thời gian đáng kể. RLock cho phép bao nhiêu goroutine đọc cùng lúc cũng được, còn Lock chờ đến khi được truy cập độc quyền. Với vùng găng ngắn, một Mutex thường nhanh tương đương hoặc nhanh hơn, nên hãy đo trước khi chuyển.

Map trong Go có an toàn khi dùng đồng thời không?

Không. Đọc đồng thời thì không sao, nhưng một lần ghi diễn ra cùng lúc với bất kỳ lần đọc hay ghi nào khác là data race, và runtime thường phát hiện ra rồi làm sập chương trình với fatal error: concurrent map writes (hoặc concurrent map read and map write). Hãy bảo vệ map bằng sync.Mutex hoặc sync.RWMutex, hoặc dùng sync.Map cho những trường hợp riêng của nó.

sync.Mutex trong Go có reentrant không?

Không. Nếu goroutine đang giữ khóa gọi Lock thêm lần nữa, nó sẽ bị chặn mãi mãi vì chờ chính mình. Hãy tổ chức code sao cho các phương thức exported lấy khóa rồi gọi các hàm hỗ trợ unexported, vốn mặc định là khóa đã được giữ.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu