CodeGym /Các khóa học /C# SELF /Khóa: lock và lớp

Khóa: lock và lớp Monitor

C# SELF
Mức độ , Bài học
Có sẵn

1. Giới thiệu

Xem một tình huống quen thuộc khi làm việc với thread. Giả sử chúng ta có một bộ đếm chung trong một ứng dụng rất đơn giản.

int counter = 0;

void IncrementCounter()
{
    for (int i = 0; i < 100_000; i++)
    {
        counter++; // Không phải là atomic!
    }
}

// Khởi chạy hai thread:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Console.WriteLine($"Counter: {counter}");

Chạy đoạn code này vài lần. Hầu như không bao giờ thấy 200_000! Tại sao? Hai thread liên tục can thiệp lẫn nhau, đôi khi cả hai cùng đọc biến, tăng nó — và ghi cùng một kết quả. Kết quả là một phần các phép tăng "bị mất".

Đó chính là race condition. Nếu không tuân thủ "trật tự", các thread như đánh nhau vì dữ liệu.

Critical section: là gì?

Critical section — là đoạn mã mà chỉ một thread được chạy cùng lúc. Quay về ẩn dụ nhà bếp: giống vòi nước chung — nếu hai người cùng rửa mặt ở một lavabo thì sẽ bừa bộn. Hãy quy ước vào nhà tắm từng người một!

Trong ví dụ của chúng ta, critical section là dòng counter++.

2. Từ khóa lock

Trong C# có cách ngắn gọn và an toàn để tạo critical section — từ khóa lock. Nó che giấu việc xử lý primitives đồng bộ và đảm bảo chỉ một thread được vào block được bảo vệ tại một thời điểm.

Cách dùng lock

Cú pháp:

lock (lockerObject)
{
    // Mã mà chỉ một thread được thực thi cùng lúc
}

lockerObject — là bất kỳ object tồn tại suốt vòng đời chương trình. Thường làm như sau:

private static object locker = new object();

Lưu ý: không bao giờ dùng string, số hoặc các object mà người khác có thể truy cập tình cờ! Chỉ dùng những object private mà bạn chắc chắn không dùng ở nơi khác.

Sửa ví dụ của chúng ta

private static object locker = new object();
int counter = 0;

void IncrementCounter()
{
    for (int i = 0; i < 100_000; i++)
    {
        lock (locker)
        {
            counter++; // Bây giờ là atomic!
        }
    }
}

Giờ hai hoặc mười thread sẽ lần lượt vào đoạn mã này. Kết quả đầu ra — hoàn hảo 200_000. Mèo con hài lòng!

3. lock hoạt động như thế nào bên trong? Lớp Monitor

Bên trong, từ khóa lock làm việc với lớp System.Threading.Monitor. Nó như một thư ký, chỉ cho vào với thẻ đặc biệt.

Cú pháp tương đương với lock (nhưng "thô" hơn):

Monitor.Enter(locker);
try
{
    // Critical section
}
finally
{
    Monitor.Exit(locker);
}

Khác biệt chính — bạn phải tự đảm bảo gọi Monitor.Exit. Thường dùng try...finally cho việc này. Nếu quên Exit(), thread sẽ ở "bên trong" mãi, và các thread khác sẽ chờ mãi — chương trình treo như Windows cũ khi cập nhật.

Bảng: lock vs. manual Monitor

Cách An toàn lỗi Dễ viết Độ linh hoạt
lock(obj)
Không
Monitor
Chỉ khi dùng try/finally Không

Trong 99% trường hợp dùng lock. Manual Monitor chỉ cần khi cần tối đa độ linh hoạt: ví dụ muốn làm phương thức lock có timeout.

4. Đối số cho lock: được và không nên dùng?

Lỗi thường gặp của người mới: dùng string hoặc object "hiển thị" để lock. Ví dụ:

lock ("mylock") { /*...*/ } // Rất tệ!

Vấn đề là string bị intern (duy nhất trong app), có thể xung đột với thư viện khác và cuối cùng chương trình sẽ deadlock. Luôn dùng object private:

private readonly object myLock = new object();

lock (myLock)
{
    // chỉ code của bạn biết về myLock
}

5. lock: ví dụ với output console

Luyện tập thôi! Tạo mini-app nơi hai thread in dòng, nhưng truy cập Console cũng được sync — để text không bị lẫn lộn.

private static object consoleLock = new object();

void PrintMessages(string name)
{
    for (int i = 0; i < 5; i++)
    {
        lock (consoleLock)
        {
            Console.WriteLine($"{name}: Thông báo {i + 1}");
            Thread.Sleep(50); // Mô phỏng xử lý
        }
    }
}

Thread t1 = new Thread(() => PrintMessages("Thread 1"));
Thread t2 = new Thread(() => PrintMessages("Thread 2"));

t1.Start();
t2.Start();

t1.Join();
t2.Join();

Kết quả: các dòng in lần lượt, không lẫn lộn. Cách này thường dùng cho logging, để khỏi đọc "rác" trong log.

6. Những chi tiết hữu ích

Quản lý lock thủ công: nâng cao với Monitor

Khi lock bình thường không đủ (ví dụ bạn muốn thử vào section mà không chờ vô hạn), dùng Monitor.TryEnter.

if (Monitor.TryEnter(locker, 100)) // chờ 100 ms
{
    try
    {
        // Critical section
    }
    finally
    {
        Monitor.Exit(locker);
    }
}
else
{
    Console.WriteLine("Không lấy được lock trong 100 milliseconds");
}

Tiện khi chương trình không muốn "treo" — ví dụ ta có thể báo người dùng hoặc làm việc hữu ích khác khi resource đang bị chiếm.

Minh họa: lock hoạt động thế nào (sơ đồ)

flowchart LR
    A[Thread 1: muốn vào critical section]
    B[Thread 2: muốn vào critical section]
    C[locker rảnh]
    D[Thread 1 thực thi mã trong lock]
    E[Thread 2 chờ]
    F[Thread 1 thoát lock]
    G[Thread 2 lấy quyền truy cập]
    
    A -- Kiểm tra locker --> C
    C -- locker rảnh --> D
    B -- Kiểm tra locker --> D
    D -- lock bị chiếm --> E
    D -- Hoàn thành --> F
    F -- Giải phóng locker --> G
    E -- locker giờ rảnh --> G

Lock và performance

Lock hoạt động đơn giản: chỉ một thread tại một thời điểm có thể chạy đoạn mã giữa dấu {}. Điều này tốt cho tính toàn vẹn dữ liệu, nhưng... càng nhiều thread "đứng chờ" thì càng chậm. Vì thế sync không phải là thuốc tiên: cố gắng giữ critical section càng nhỏ càng tốt.

Mẹo đời: nếu critical section chỉ tốn phần nhỏ của ms — ok. Nếu trong đó có tính toán nặng, IO, mạng hoặc file — tốt hơn tách ra khỏi block lock. Đọc/tính trước, rồi nhanh chóng cập nhật giá trị chung bên trong vùng bảo vệ.

Trong phỏng vấn và thực tế

Trong mọi ứng dụng nghiêm túc dùng thread, nhà tuyển dụng sẽ hỏi: "Làm sao khi hai thread truy cập cùng biến?" Hiển thị code có lock — hồ sơ của bạn sẽ không rơi vào thùng rác của HR.

Trên thực tế, đặc biệt ở hệ thống tải cao, người ta còn dùng cơ chế sync nâng cao — nhưng lockMonitor vẫn là tiêu chuẩn vàng cho các trường hợp đơn giản.

7. Đặc điểm khi dùng lock và lỗi thường gặp

Lỗi phổ biến nhất — "quên" dùng cùng một object làm lock. Ví dụ:

void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }

Nếu cả hai method quản lý cùng một biến, nhưng object ab khác nhau, bạn chỉ tạo ra một bảo vệ giả — các thread vẫn thao tác biến cùng lúc!

Kết luận: luôn dùng cùng một object để bảo vệ cùng dữ liệu.

Trường hợp khác — dùng lock quá "rộng". Ví dụ, lock (this) trong class bình thường nếu bạn không chắc người ngoài không dùng object này để lock. Điều này dễ dẫn tới deadlock và bug khó chịu.

Và cuối cùng: KHÔNG khóa các thao tác dài hoặc ngoại vi (IO, mạng, file) bên trong lock. Bạn có thể chặn truy cập thread khác lâu, làm giảm performance. Critical section = chỉ những gì thực sự không thể làm song song!

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Đồng bộ hóa đơn giản với `lock`
Đồng bộ hóa đơn giản với `lock`
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION