1. Các điều kiện cổ điển của Coffman
Năm 1971 nhà khoa học máy tính Edward G. Coffman Jr. mô tả bốn điều kiện, nếu thiếu thì deadlock không thể xảy ra. Những quy tắc này từ lâu đã trở thành “bảng chữ cái” khi phỏng vấn và học lập trình đa luồng. Nên biết không chỉ thuộc lòng mà còn hiểu vì sao:
- Mutual Exclusion (Loại trừ lẫn nhau): ít nhất một resource chỉ có thể bị nắm giữ bởi một thread tại một thời điểm.
- Hold and Wait (Giữ và chờ): thread đã nắm một resource có thể chờ các resource khác.
- No Preemption (Không thể tước đoạt): resource không thể bị tước khỏi thread một cách cưỡng bức — chỉ chính thread đó mới giải phóng được.
- Circular Wait (Chờ vòng): tồn tại một chuỗi các thread, mỗi thread chờ resource mà thread tiếp theo đang giữ.
Nếu thỏa cả bốn điều kiện thì deadlock có thể xảy ra. Nếu phá vỡ ít nhất một — khả năng deadlock biến mất.
Quyết định nhanh
flowchart TD
A[Cần khóa nhiều resource cùng lúc?] -- Không --> B[Standard lock]
A -- Có --> C[Có thể luôn lấy theo cùng một thứ tự?]
C -- Có --> D[Luôn lấy theo cùng thứ tự]
C -- Không --> E[Tránh nested lock]
D --> F[Giảm thiểu thời gian giữ lock]
E --> F
B --> F
F --> G{Vẫn lo ngại?}
G -- Có --> H[Thêm timeout và retry]
G -- Không --> I[Ngủ ngon nhé!]
2. Các chiến lược chính để ngăn Deadlock
Deadlock là một phần không thể tránh khỏi trong thế giới đa luồng, nhiệm vụ của chúng ta là học cách sống chung với nó. Có nhiều chiến lược: từ đơn giản đến tinh tế.
Luôn khóa resources theo cùng một thứ tự
Ý tưởng đơn giản: nếu tất cả các thread luôn nắm resource theo cùng một thứ tự thì Circular Wait sẽ không thể xuất hiện. Kỹ thuật này thường gọi là ordered locking — “khóa có thứ tự”.
Ví dụ
Mã nguy hiểm:
// Thread 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// Thread 2
lock (resB)
{
lock (resA)
{
// ...
}
}
Ở đây có thể xảy ra deadlock: thread 1 đã giữ resA và chờ resB, thread 2 ngược lại. Cả hai đều treo và không bao giờ giải phóng.
Mã đúng:
// Luôn: trước resA, sau đó resB (không bao giờ ngược lại)
lock (resA)
{
lock (resB)
{
// ...
}
}
Bây giờ thứ tự là đồng nhất, deadlock không thể xảy ra. Không quan trọng có bao nhiêu resource hay tên chúng là gì — quan trọng là mọi thread đều lấy lock theo cùng một thứ tự.
Lỗi phổ biến: nếu trong code có bất kỳ chỗ nào vi phạm thứ tự thì rủi ro deadlock quay trở lại. Thận trọng nhé!
Tránh khóa nhiều resource cùng lúc
Đây là cách đơn giản nhất: nếu có thể không lấy lock trên hai hay nhiều resource cùng lúc — thì đừng! Càng nhiều nested locks thì nguy cơ bị kẹp càng lớn. Thay vào đó có thể copy dữ liệu cần thiết sang biến tạm, thả lock rồi mới thao tác với resource khác.
Dùng timeout khi lấy lock
Nếu thực sự cần lấy nhiều lock, timeout sẽ cứu cánh. Ví dụ thay vì dùng lock thông thường thì dùng các method cho phép “thử” lấy lock, nếu không được — rollback một cách gọn gàng, như Monitor.TryEnter.
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// Thử lấy cả hai lock tối đa 2 giây
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // không được, thoát
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return;
// Critical section
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
Nếu không lấy được lock thứ hai trong 2 giây thì ta thả lock thứ nhất và thoát. Kết quả là deadlock không xảy ra, chỉ có vài thread phải rollback thử nghiệm.
Giảm thiểu lượng mã nằm trong lock
Phần code càng nhỏ nằm trong lock thì thời gian thread khác phải chờ càng ngắn. Không nên làm tính toán nặng, gọi mạng hay I/O trong critical section. Lấy lock — sửa tài nguyên chung nhanh — thả. Các công việc khác làm ngoài lock.
Tách trạng thái — tránh shared state
Đôi khi tốt hơn là tránh dùng state chung:
- Dùng immutable objects.
- Làm việc với bản sao hoặc biến local thay vì global.
- Truyền dữ liệu bằng message (Actor Model, message queue).
- Dùng collection thread-safe: ConcurrentDictionary, ConcurrentQueue v.v. từ System.Collections.Concurrent.
3. Giải quyết Deadlock nếu nó đã xảy ra
Thủ công kill và restart (không tối ưu)
Cách đơn giản nhất là kết thúc tất cả process bị khóa. Nhưng cách này có nguy cơ mất dữ liệu. Đó là phương án cuối cùng, nên tránh.
Phát hiện Deadlock khi chương trình chạy
Một số hệ thống có thể phát hiện deadlock tự động. Nếu một thread không thể lấy lock quá lâu, nó có thể log sự kiện, báo monitoring và khởi tạo phục hồi.
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("Có vẻ chúng ta rơi vào deadlock hoặc ai đó giữ lock quá lâu!");
// Có thể khởi tạo phục hồi, xuất dump hoặc thông báo người dùng
}
Trong hệ phân tán người ta dùng các detector riêng, phân tích wait-for graph và ép unlock các transaction bị kẹt.
Rollback tự động và thử lại
Thói quen tốt là “bỏ cuộc” rồi thử lại: nếu không lấy được tất cả lock trong thời gian hợp lý, rollback, chờ một lúc (thường ngẫu nhiên để giảm xung đột) rồi thử lại.
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// Critical section
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// Không được — đợi và thử lại
Thread.Sleep(500 + new Random().Next(500));
}
4. Best practices và khuyến nghị
- Phân tích thứ tự lấy lock. Gom các điểm vào và kiểm tra thứ tự.
- Nếu được, dùng collection từ System.Collections.Concurrent.
- Dùng tool phân tích mã. IDE như Rider hoặc Visual Studio giúp tìm nested locks.
- Log các lần cố gắng lấy lock lâu.
- Chạy stress-test cho các component đa luồng.
- Tài liệu hoá quy ước về thứ tự khóa. Comment cứu bạn khỏi vi phạm ordered locking.
5. Ví dụ thực tế
Ví dụ: Cơ sở dữ liệu
Trong DBMS deadlock là khách quen: hai transaction cập nhật cùng bảng nhưng theo thứ tự khác (A→B và B→A). Hầu hết DBMS biết detect tình huống này và “kill” một trong các transaction.
Ví dụ: Phương thức bất đồng bộ
Trong .NET, nếu trộn async và locking có thể tạo deadlock: await làm block thread đang giữ lock, trong khi thread khác chờ cùng resource. Cần lên kế hoạch ranh giới của critical section và tránh await bên trong chúng.
6. Lỗi thường gặp khi làm việc với lock
Lỗi #1: khoá lên string.
lock (myString) — ý tưởng tệ. Trong .NET string được intern, nên bạn thực tế khoá bảng string toàn cục.
Lỗi #2: thứ tự lấy object khác nhau.
Nếu ở các chỗ khác nhau trong chương trình lock được lấy theo thứ tự khác nhau, khả năng deadlock tăng vọt.
Lỗi #3: giữ lock quá lâu.
Giữ lock lâu hơn cần thiết, hoặc gọi method ngoài trong critical section — là cách chắc chắn để bị treo và deadlock.
GO TO FULL VERSION