Khi nhiều luồng (thread) cùng đọc và ghi một dữ liệu tại cùng thời điểm, các cập nhật có thể bị mất hoặc bị thấy khi mới làm được một nửa. Câu lệnh lock khiến một khối code trở nên loại trừ lẫn nhau: khi một luồng đang ở bên trong, mọi luồng khác gặp lock trên cùng đối tượng phải chờ đến lượt.
Vấn đề: race condition
Bốn task, mỗi task cộng 100.000 vào một bộ đếm dùng chung. Kết quả đúng phải là 400.000:
Ví dụ đầu ra:
Expected 400000, got 245609
Tổng thường bị thiếu, và thiếu một lượng khác nhau mỗi lần chạy (trên máy một nhân đôi khi nó lại đúng, chính điều đó khiến loại bug này khó bắt). count++ trông như một thao tác nhưng thực ra là ba: đọc count, cộng một, ghi lại. Hai luồng có thể cùng đọc được 500, cùng tính ra 501 và cùng ghi 501, thế là mất một lần tăng.
Cách sửa: lock
Bọc thao tác đọc, sửa, ghi trong một lock trên một đối tượng dùng chung:
Đầu ra:
Expected 400000, got 400000
Giờ chỉ một luồng được ở trong khối tại mỗi thời điểm, nên mỗi lần tăng hoàn tất trước khi lần sau đọc giá trị. Khóa còn đảm bảo tính hiển thị: luồng vào khóa sẽ thấy mọi lần ghi mà luồng giữ khóa trước đã thực hiện trước khi rời đi.
Sự bảo vệ chỉ có tác dụng khi mọi truy cập vào count đều đi qua cùng một khóa. Chỉ cần một count++ không khóa ở đâu đó trong chương trình là race condition quay lại.
lock được biên dịch thành gì
lock là cách viết tắt cho lớp Monitor, kèm try/finally để khóa được nhả kể cả khi khối lệnh ném ngoại lệ:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor còn có TryEnter(obj, timeout), bỏ cuộc sau một khoảng thời gian thay vì chờ mãi, và Wait/Pulse để báo hiệu giữa các luồng. Một luồng đang giữ khóa có thể vào lại chính khóa đó (khóa có tính reentrant), nên một phương thức có khóa có thể gọi một phương thức có khóa khác trên cùng đối tượng.
Chọn đối tượng khóa
Đối tượng trong lock (...) chỉ là một tín hiệu mà các luồng cùng thống nhất. Các quy tắc:
- Dùng một field private, chỉ đọc, kiểu
object.private readonly object sync = new object();. Private nghĩa là không code bên ngoài nào lấy được cùng khóa;readonlynghĩa là tín hiệu không thể bị thay thế trong khi một luồng đang giữ nó. - Đừng bao giờ
lock (this). Bất kỳ ai có tham chiếu đến đối tượng của bạn cũng khóa được trên nó, và code của họ sẽ chặn code của bạn hoặc gây deadlock. - Đừng bao giờ khóa trên chuỗi hoặc
typeof(...). Chuỗi literal được intern (mọi"orders"trong tiến trình là cùng một đối tượng), và đối tượngTypeđược dùng chung trong toàn ứng dụng, nên những đoạn code không liên quan có thể tranh nhau cùng một khóa. - Đừng bao giờ khóa trên kiểu giá trị.
lock (count)trên mộtintkhông biên dịch được (lỗi CS0185), còn tự boxing thì mỗi lần lại tạo một đối tượng mới, nên chẳng khóa được gì. - Dùng một khóa cho mỗi nhóm dữ liệu cần giữ nhất quán, một field khóa static cho dữ liệu tĩnh, một field instance cho dữ liệu riêng của từng instance.
Bảo vệ thao tác nhiều bước
lock cần thiết nhất ở nơi một phép kiểm tra và một hành động phải diễn ra cùng nhau. Một tài khoản thread safe cho thấy cả mẫu kiểm tra rồi hành động lẫn ảnh chụp nhất quán của hai field:
Đầu ra:
10 left after 33 withdrawals
Không có khóa, hai luồng có thể cùng thấy số dư 40, cùng vượt qua phép kiểm tra và cùng rút 30, để lại tài khoản âm 20. Phương thức Summary cũng khóa, nên nó không bao giờ báo balance sau một lần rút cùng với số withdrawals trước lần rút đó.
Dictionary<TKey, TValue> và List<T> cũng không thread safe: các lần ghi đồng thời có thể làm hỏng mảng bên trong của chúng, chứ không chỉ làm mất cập nhật. Hãy bảo vệ chúng bằng khóa hoặc dùng ConcurrentDictionary<TKey, TValue> và các kiểu khác trong System.Collections.Concurrent.
Interlocked cho giá trị đơn lẻ
Khi trạng thái dùng chung là một số nguyên hoặc một tham chiếu và việc cập nhật chỉ có một bước, lớp Interlocked thực hiện nó một cách nguyên tử mà không cần khóa:
Đầu ra:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add và Exchange tương ứng với các thao tác nguyên tử của bộ xử lý (một lệnh duy nhất trên x64). CompareExchange(ref location, newValue, expected) chỉ ghi nếu vị trí đó vẫn đang giữ expected, và trả về giá trị nó tìm thấy, nhờ vậy bạn có thể xây mọi phép cập nhật thành một vòng lặp thử lại. Mọi thứ chạm đến hai biến cùng lúc vẫn cần lock.
Deadlock
Deadlock xảy ra khi hai luồng, mỗi luồng giữ một khóa mà luồng kia cần:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
Nếu luồng 1 lấy accountA trong khi luồng 2 lấy accountB, thì sau đó mỗi luồng chờ luồng kia mãi mãi. Không có ngoại lệ, không có timeout; chương trình treo. Một giao dịch chuyển tiền giữa hai tài khoản khóa "from" rồi đến "to" sẽ gây ra đúng tình huống này khi hai giao dịch ngược chiều chạy cùng lúc.
Các cách phòng vệ chuẩn:
- Khóa theo một thứ tự toàn cục cố định. Với giao dịch chuyển tiền, khóa tài khoản có ID nhỏ hơn trước, bất kể tiền đi theo chiều nào.
- Giữ khóa thật ngắn và làm các việc chậm (I/O, ghi log, gọi mạng) bên ngoài khóa.
- Không gọi code không rõ nguồn gốc khi đang giữ khóa: sự kiện, callback và phương thức virtual có thể tự lấy khóa riêng.
- Dùng
Monitor.TryEntervới timeout ở nơi chương trình bị treo còn tệ hơn một lần thất bại.
Không có await trong lock: dùng SemaphoreSlim
await không được phép trong khối lock (lỗi biên dịch CS1996). Sau một await, phương thức có thể tiếp tục trên một luồng khác, mà khóa Monitor phải được nhả bởi chính luồng đã lấy nó. Với code bất đồng bộ, SemaphoreSlim với số lượng 1 hoạt động như một khóa tương thích với async:
Đầu ra:
5 saved, one at a time
Luôn ghép WaitAsync với Release trong một finally, nếu không một ngoại lệ sẽ khiến cổng đóng vĩnh viễn. Một SemaphoreSlim(3, 3) cho ba bên gọi vào cùng lúc, đó là cách giới hạn số request đồng thời tới một API có giới hạn tốc độ.
System.Threading.Lock (.NET 9)
.NET 9 cùng C# 13 bổ sung kiểu System.Threading.Lock chuyên dụng. Khi đối tượng trong câu lệnh lock là một Lock, trình biên dịch dùng API EnterScope nhanh hơn của nó thay cho Monitor:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
Quy tắc chọn đối tượng vẫn như cũ. Trên các phiên bản trước, private readonly object là lựa chọn đúng.
Lỗi thường gặp
- Khóa một số lần truy cập nhưng không phải tất cả. Mọi lần đọc và ghi dữ liệu dùng chung phải lấy cùng một khóa.
lock (this),lock (typeof(X)),lock ("name"). Code bên ngoài có thể lấy cùng khóa đó.- Làm I/O chậm bên trong khóa. Mọi luồng khác đều phải chờ; hãy giữ khối lệnh thật ngắn.
- Lấy hai khóa theo thứ tự khác nhau ở những chỗ khác nhau. Deadlock kinh điển.
- Dùng
lockbao quanhawait. Không biên dịch được; hãy dùngSemaphoreSlim. - Mỗi lần gọi một khóa mới.
lock (new object())không bảo vệ gì cả; đối tượng phải được dùng chung.
Câu hỏi thường gặp
lock trong C# dùng để làm gì?
lock (obj) { ... } chỉ cho một luồng chạy khối lệnh tại mỗi thời điểm với một đối tượng khóa nhất định. Luồng thứ hai gặp lock trên cùng đối tượng sẽ chờ đến khi luồng đầu rời khỏi khối. Khóa vẫn được nhả ra kể cả khi khối lệnh ném ngoại lệ, vì lock được biên dịch thành Monitor.Enter và Monitor.Exit bên trong một try/finally.
Nên khóa trên đối tượng nào trong C#?
Một field private dành riêng: private readonly object _sync = new object();. Nó phải là kiểu tham chiếu, được mọi luồng chạm vào dữ liệu cần bảo vệ dùng chung, và không truy cập được từ bên ngoài lớp. Đừng bao giờ khóa trên this, một Type (typeof(MyClass)) hay một chuỗi, vì code khác có thể khóa trên cùng đối tượng và chặn bạn hoặc gây deadlock.
Khi nào nên dùng Interlocked thay cho lock?
Khi trạng thái dùng chung là một con số hoặc một tham chiếu và thao tác chỉ có một bước: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange hoặc CompareExchange. Đây là các thao tác nguyên tử ở mức phần cứng và nhanh hơn lock. Với mọi thứ chạm đến nhiều field hoặc một collection, hãy dùng lock.
Có dùng được await bên trong lock trong C# không?
Không, trình biên dịch từ chối await bên trong khối lock (lỗi CS1996), vì code sau await có thể tiếp tục trên một luồng khác với luồng đang giữ khóa. Hãy dùng SemaphoreSlim với số lượng 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
Deadlock xảy ra với lock như thế nào?
Luồng 1 giữ khóa A và chờ khóa B, trong khi luồng 2 giữ khóa B và chờ khóa A. Không luồng nào đi tiếp được, và chương trình treo mà không có ngoại lệ nào. Hãy phòng tránh bằng cách luôn lấy nhiều khóa theo cùng một thứ tự toàn cục, giữ khóa trong thời gian ngắn nhất có thể, và không bao giờ gọi code không rõ nguồn gốc khi đang giữ khóa.