Skip to main content

Command Palette

Search for a command to run...

Race Condition, Critical Section là gì? Cách sử dụng CRITICAL_SECTION trong lập trình đa luồng (Windows API)

Updated
5 min read

Trong lập trình đa luồng, các luồng thuộc cùng môt tiến trình chia sẻ vùng nhớ Heap, biến toàn cục, Files. Do đó, sẽ xảy ra vấn đề tranh chấp nguồn tài nguyên giữa các luồng.

1. Race condition

Race condition xảy ra khi kết quả của một chương trình phụ thuộc vào thứ tự thực thi không thể đoán trước của các luồng (Timing).

Giả sử 2 luồng cùng truy cập vào biến bất kỳ, sẽ có 3 trường hợp xảy ra như sau

- Hai luồng cùng đọc đồng thời (R-R): trường hợp này an toàn, không có nguy cơ gây ra lỗi dữ liệu.

- Một luồng đọc, một luồng ghi (R-W, W-R): trường hợp này phân thành hai trường hợp nhỏ hơn

  • Đọc trước ghi sau (R-W): nguy cơ luồng đọc dữ liệu không lấy được dữ liệu mới nhất. Giả sử luồng đọc lấy được giá trị X = 10, ngay sau đó luồng ghi cập nhật X = 20. Luồng đọc vẫn tiếp tục xử lý với giá trị X = 10 mà không biết giá trị đó đã hết hạn hoặc đã cũ.

  • Ghi trước đọc sau (W-R): Luồng ghi đang cập nhật dữ liệu nhưng chưa hoàn chỉnh, thì luồng đọc lấy dữ liệu. Dữ liệu này không phải giá trị cũ cũng không phải giá trị mới (dữ liệu bẩn).

- Hai luồng ghi đồng thời (W-W): Khi hai luồng cùng ghi vào một giá trị, nguy cơ xảy ra tình trạng giá trị của một luồng bị ghi đè bởi luồng kia. Ví dụ biến toàn cục X = 10, hai luồng cùng tăng X lên 1. Mỗi phép tăng gồm ba bước: đọc X, cộng 1, rồi cập nhật giá trị cho X. Nếu hai luồng cùng đọc được X = 10 trước khi bất kỳ luồng nào kịp ghi kết quả, khi đó cả hai đều ghi giá trị 11. Kết quả cuối cùng X = 11 thay vì 12 như mong đợi. Một ví dụ khác, nếu như hai luồng cùng ghi vào một cấu trúc dữ liệu lớn, nếu không được đồng bộ có nguy cơ xảy ra tình trạng dữ liệu sau khi được ghi có một nửa mang giá trị của luồng thứ nhất và một nửa mang giá trị của luồng thứ 2.

Để đồng bộ các luồng truy cập dữ liệu đọc/ghi một cách an toàn, chúng ta có một vài cơ chế phổ biến đó là Critical Section, Mutex và Semaphore.

2. Critical Section (Vùng găng)

Critical Section là đoạn code mà tại đó có nguy cơ xảy ra race condition. Hệ điều hành Windows cung cấp cho chúng ta một đối tượng CRITICAL_SECTION chạy chủ yế ở user-mode để bảo vệ vùng găng ở trên, đảm bảo tại mỗi thời điểm chỉ có một luồng được phép truy cập vào tài nguyên chung.

Khi vùng găng đang được lock bởi một luồng khác thì luồng muốn vào sẽ chuyển sang chế độ Sleep đề chờ cho tới khi vùng găng được unlock.

Chúng ta cùng xem xét một ví dụ sau trên windows:

#include <Windows.h>
#include <stdio.h>

int x = 0;

// Hàm xử lý thread 1
DWORD WINAPI Worker_T1(LPVOID param) {
    for (size_t i = 0; i < 500000; i++)
    {
        x++;
    }
    return 0;
}

// Hàm xử lý thread 2
DWORD WINAPI Worker_T2(LPVOID param) {
    for (size_t i = 0; i < 500000; i++)
    {
        x++;
    }
    return 0;
}

int main() {
    // Khởi tạo thread t1
    HANDLE t1ThreadHandle = CreateThread(0, 0, Worker_T1, NULL, 0, NULL);
    if(!t1ThreadHandle) {
        printf("T1 thread create failed\n");
        return 1;
    }

    // Khởi tạo thread t2
    HANDLE t2ThreadHandle = CreateThread(0, 0, Worker_T2, NULL, 0, NULL);
    if(!t2ThreadHandle) {
        printf("T2 thread create failed\n");
        return 1;
    }

    // Main thread chờ cho 2 luồng thực hiện xong
    WaitForSingleObject(t1ThreadHandle, INFINITE);
    WaitForSingleObject(t2ThreadHandle, INFINITE);

    // Đóng các handle
    CloseHandle(t1ThreadHandle);
    CloseHandle(t2ThreadHandle);

    printf("Final x = %d\n", x);

    return 0;
}

Kết quả: chúng ta có thể thấy mỗi lần chạy chương trình lai cho ra một kết quả khác nhau. Không ra được kết quả như mong đợi (1.000.000).

Để không xảy ra vấn đề này chúng ta sử dụng đối tượng CRITICAL_SECTION do hệ điều hành windows cung cấp để bảo vệ vùng găng như sau:

#include <Windows.h>
#include <stdio.h>

int x = 0;

// Khai báo đối tượng CRITICAL_SECTION để đồng bộ
CRITICAL_SECTION g_cs;

// Hàm xử lý thread 1
DWORD WINAPI Worker_T1(LPVOID param) {
    for (size_t i = 0; i < 500000; i++) {
        // Khóa quyền truy cập biến x
        EnterCriticalSection(&g_cs);
        x++;
        LeaveCriticalSection(&g_cs);
    }
    return 0;
}

// Hàm xử lý thread 2
DWORD WINAPI Worker_T2(LPVOID param) {
    for (size_t i = 0; i < 500000; i++) {
        // Chờ cho đến khi được quyền truy cập vào biến x
        EnterCriticalSection(&g_cs);
        x++;
        LeaveCriticalSection(&g_cs);
    }
    return 0;
}

int main() {

    // Khởi tạo đối tượng CRITICAL_SECTION
    InitializeCriticalSection(&g_cs);

    // Khởi tạo thread t1
    HANDLE t1ThreadHandle = CreateThread(0, 0, Worker_T1, NULL, 0, NULL);
    if(!t1ThreadHandle) {
        printf("T1 thread create failed\n");
        return 1;
    }

    // Khởi tạo thread t2
    HANDLE t2ThreadHandle = CreateThread(0, 0, Worker_T2, NULL, 0, NULL);
    if(!t2ThreadHandle) {
        printf("T2 thread create failed\n");
        return 1;
    }

    // Main thread chờ cho 2 luồng thực hiện xong
    WaitForSingleObject(t1ThreadHandle, INFINITE);
    WaitForSingleObject(t2ThreadHandle, INFINITE);

    // Đóng các handle của luồng
    CloseHandle(t1ThreadHandle);
    CloseHandle(t2ThreadHandle);

    // Xóa đối tượng CRITICAL_SECTION
    DeleteCriticalSection(&g_cs);

    printf("Final x = %d\n", x);

    return 0;
}

Kết quả đã không xảy ra tình trạng sai lệch giá trị của biến x

Ở bài tiếp theo, chúng ta sẽ tìm hiểu về Mutex và cách sử dụng để đồng bộ hóa trong lập trình đa luồng.

The End.

More from this blog

U40 Học Code - Lập trình & Hệ điều hành

11 posts