1. Ví dụ Deadlock trên C#
Deadlock — là tình huống khi mỗi luồng trong một nhóm chiếm giữ một resource và chờ resource khác được giải phóng, mà resource đó đang bị chiếm bởi luồng khác trong cùng nhóm — và không ai bao giờ nhận được thứ họ chờ.
Ta xem ví dụ thực tế. Giả sử có hai object làm khóa và hai luồng. Mỗi luồng khóa một object rồi cố gắng lấy khóa thứ hai.
using System;
using System.Threading;
class Program
{
static readonly object lockerA = new object();
static readonly object lockerB = new object();
static void Main()
{
Thread thread1 = new Thread(Thread1Work);
Thread thread2 = new Thread(Thread2Work);
thread1.Start();
thread2.Start();
thread1.Join();
thread2.Join();
Console.WriteLine("Cả hai luồng đã kết thúc (nếu không xảy ra Deadlock)");
}
static void Thread1Work()
{
lock (lockerA)
{
Console.WriteLine("Luồng 1: đã chiếm lockerA");
Thread.Sleep(100); // Cho luồng 2 có cơ hội chiếm lockerB
lock (lockerB)
{
Console.WriteLine("Luồng 1: đã chiếm lockerB");
}
}
}
static void Thread2Work()
{
lock (lockerB)
{
Console.WriteLine("Luồng 2: đã chiếm lockerB");
Thread.Sleep(100); // Cho luồng 1 có cơ hội chiếm lockerA
lock (lockerA)
{
Console.WriteLine("Luồng 2: đã chiếm lockerA");
}
}
}
}
Chạy mã này vài lần — rất có khả năng bạn sẽ gặp ứng dụng “treo”. Deadlock đã xảy ra! Mỗi luồng giữ một lock và chờ lock của luồng kia.
Minh hoạ Deadlock
Đây là sơ đồ nhìn như thế nào:
sequenceDiagram
participant Luồng_1
participant Luồng_2
participant lockerA
participant lockerB
Luồng_1->>lockerA: chiếm lockerA
Luồng_2->>lockerB: chiếm lockerB
Luồng_1->>lockerB: chờ lockerB được giải phóng
Luồng_2->>lockerA: chờ lockerA được giải phóng
Kết quả: Luồng 1 giữ lockerA và chờ lockerB, Luồng 2 giữ lockerB và chờ lockerA. Không ai chịu nhường — chương trình treo.
2. Làm sao phát hiện Deadlock?
Tại sao nó xảy ra?
Để hiểu nguyên nhân, nhìn vào vài thành phần:
- Khóa nhiều cái cùng lúc: Khi một luồng chiếm nhiều lock cùng lúc.
- Thứ tự khóa không nhất quán: Nếu các luồng khóa theo thứ tự khác nhau, deadlock có thể xảy ra.
- Có khả năng chờ: Luồng chờ resource đang bị chiếm bởi luồng khác.
- Không có ép buộc chiếm: Một luồng không thể “cướp” lock từ luồng khác trong C# — chỉ có thể chờ.
Bẫy cổ điển: nếu bạn có N luồng và N resource, và các luồng khóa theo thứ tự khác nhau — bạn đang đứng trước nguy cơ deadlock.
Dấu hiệu điển hình của Deadlock
Deadlock thường trông như một “treo” bình thường từ bên ngoài. Các luồng còn sống nhưng chờ nhau vô hạn.
Dấu hiệu thường gặp:
- Ứng dụng đột ngột không phản hồi (đôi khi chỉ khi tải cao).
- Debugger cho thấy các luồng dừng ở lock, Monitor.Enter, WaitOne, EnterReadLock hoặc các thao tác đồng bộ tương tự.
- Công cụ hệ thống (Task Manager hoặc Rider/Visual Studio) hiển thị 0% CPU — “mọi thứ đứng im”.
- Log không có lỗi, nhưng không có hoạt động nào.
Nếu dùng JetBrains Rider hoặc Visual Studio, kiểm tra debugger để xem luồng dừng ở đâu (Stack Trace). Nếu thấy nhiều luồng đang đứng chờ nhau trên lock/Mutex.WaitOne — chúc mừng (hoặc xin chia buồn): đó là deadlock!
Tình huống cổ điển gây Deadlock
Thứ tự khóa không đồng bộ. Như ví dụ ở trên: Luồng 1 khóa lockerA rồi lockerB; Luồng 2 ngược lại. Bẫy sẵn sàng.
Khóa lồng nhau. Khi trong một block lock bạn lại mở một lock khác trên object khác.
Resource giao nhau trong nhiều method. Khó theo dõi hơn nếu lock rải rác trong nhiều method/class và kết hợp khác nhau. Khi số lock > 2, kịch bản phức tạp hơn.
3. Ngăn ngừa Deadlock
Tin tốt: có thể phòng deadlock!
Tin xấu: cần cẩn thận khi thiết kế code đa luồng.
Vài lời khuyên “thực tế” (ghi nhớ nhé — phỏng vấn sẽ thích điều này!):
Luôn chiếm lock theo cùng thứ tự
Nếu có nhiều object có thể bị lock cùng nhau — xác định một thứ tự chung (ví dụ theo tên hoặc theo index) và luôn tuân theo.
Ví dụ — chiếm lock đúng thứ tự
static void SafeLock(object objA, object objB)
{
// So sánh hashcode — để các luồng luôn khóa object theo cùng một thứ tự
object first = objA.GetHashCode() < objB.GetHashCode() ? objA : objB;
object second = objA.GetHashCode() < objB.GetHashCode() ? objB : objA;
lock (first)
{
lock (second)
{
// critical section
}
}
}
Ở đây — cả hai luồng sẽ vào trước lock(objA) rồi lock(objB) nếu objA “nhỏ hơn” objB.
Sử dụng timeout
Nếu luồng không thể lấy lock trong thời gian hợp lý — nó nên ném exception hoặc thoát gọn. Dùng Monitor.TryEnter rất hợp lý.
Ví dụ với Monitor.TryEnter
bool lockTakenA = false;
bool lockTakenB = false;
try
{
Monitor.TryEnter(lockerA, 500, ref lockTakenA);
if (!lockTakenA)
{
// Không lấy được lock trong 500 ms — bỏ cuộc để tránh deadlock
return;
}
Monitor.TryEnter(lockerB, 500, ref lockTakenB);
if (!lockTakenB)
{
return;
}
// Critical section...
}
finally
{
if (lockTakenB)
Monitor.Exit(lockerB);
if (lockTakenA)
Monitor.Exit(lockerA);
}
Nếu không lấy được lock — đừng đứng yên chờ!
Giảm tối thiểu phần khóa
Trong critical section chỉ giữ phần code tối thiểu cần thiết.
Đừng trộn lẫn nhiều cơ chế đồng bộ
Nếu cùng lúc dùng lock, Mutex, Semaphore, ReaderWriterLockSlim — nguy cơ rối và deadlock tăng lên.
4. Deadlock với Mutex, Semaphore và ReaderWriterLockSlim
Deadlock xảy ra không chỉ với lock hoặc Monitor, mà cả với các cơ chế đồng bộ khác.
Deadlock với Mutex
static Mutex mutexA = new Mutex();
static Mutex mutexB = new Mutex();
void Work1()
{
mutexA.WaitOne();
Thread.Sleep(100);
mutexB.WaitOne();
// Critical section
mutexB.ReleaseMutex();
mutexA.ReleaseMutex();
}
Nếu có luồng thứ hai khóa theo thứ tự ngược lại — bạn sẽ có deadlock.
Deadlock với ReaderWriterLockSlim
ReaderWriterLockSlim, dù linh hoạt, không tự động bảo vệ khỏi deadlock nếu một luồng đã có một kiểu lock rồi cố lấy kiểu khác.
Ví dụ, nếu luồng giữ read-lock (EnterReadLock) rồi cố lấy write-lock (EnterWriteLock) — deadlock rất dễ xảy ra, vì write-lock không thể được cấp khi vẫn còn read-lock.
5. Tránh Deadlock trong ứng dụng thực tế
Xem ví dụ mô phỏng ứng dụng cửa hàng online, nơi chạy đồng thời:
- Luồng cập nhật tồn kho (writers)
- Luồng kiểm tra tồn kho (readers)
- Admin (luồng đặc biệt) vừa đọc vừa ghi
Tệ:
lock (stockLock)
{
// cập nhật kho
lock (userLock)
{
// thao tác với người dùng
}
}
// ... và ở chỗ khác lại ngược lại!
lock (userLock)
{
lock (stockLock) { }
}
Ổn:
Thỏa thuận chung — luôn cố gắng khóa stockLock trước, rồi userLock trong mọi luồng.
6. Làm sao tránh Deadlock ở công ty và phỏng vấn
Trong dự án thực tế:
- Khi thiết kế — vẽ sơ đồ luồng và lock (giấy/whiteboard đều ổn).
- Chia sẻ quy ước thứ tự khóa với toàn team.
- Dùng công cụ phân tích tĩnh (Rider/Visual Studio, ReSharper) — có thể tìm deadlock tiềm ẩn.
- Đọc tài liệu trên Microsoft Docs về Deadlock.
Trong phỏng vấn:
- Trình bày bạn hiểu vấn đề và các triệu chứng kinh điển.
- Nhắc các cách bảo vệ: thứ tự cố định, timeout (TryEnter), giảm tối thiểu critical sections.
- Cho ví dụ với TryEnter và giải thích vì sao quan trọng phải giải phóng tất cả resource trong finally.
7. Lỗi thường gặp và deadlock bất ngờ
Lỗi #1: lock không rõ ràng trong thư viện bên thứ ba.
Kịch bản kinh điển — logging trong critical section. Luồng kẹt, bạn còn không biết nguyên nhân vì là code ngoài.
Lỗi #2: khóa lại cùng một resource.
Đây là reentrant deadlock: luồng cố vào lại lock đã bị chiếm nhưng mechanism không hỗ trợ reentrancy.
Lỗi #3: dùng async trong critical section.
Đặc biệt nguy hiểm với await: luồng có thể “thả” control tại thời điểm không hợp lý và làm hệ thống kẹt.
GO TO FULL VERSION