云计算百科
云计算领域专业知识百科平台

Windows学习笔记-20(线程同步-临界区Critical Section)

1. 临界区概述

临界区(Critical Section)是 Windows 提供的一种轻量级线程同步对象,用于保护共享资源在同一进程内的多个线程之间互斥访问。它确保同一时刻只有一个线程可以进入临界区代码段,从而避免数据竞争和不一致性。

核心特点

  • 用户态同步:临界区对象在用户态实现同步,除非发生竞争(即多个线程尝试同时进入),否则不会陷入内核,因此速度非常快。

  • 仅限单进程内使用:临界区不能跨进程同步(如需跨进程,应使用互斥体)。

  • 可递归(可选):默认情况下,同一线程不能多次进入同一个临界区(会导致死锁),但可以通过初始化时指定标志支持递归。

  • 无超时机制:EnterCriticalSection 会一直等待直到获得所有权,无法设置超时(但可用 TryEnterCriticalSection 实现非阻塞尝试)。

2. 临界区与互斥体的对比

特性临界区互斥体
类型 用户态对象 内核对象
速度 非常快(无内核切换) 较慢(需进入内核)
跨进程 不支持 支持(命名互斥体)
递归 默认不支持,可启用 支持
超时等待 不支持(可用 TryEnter) 支持(WaitForSingleObject)
遗弃检测 支持(WAIT_ABANDONED)
适用范围 单进程内高性能同步 跨进程、需超时/遗弃检测的场景

选择依据:如果只在单进程内同步,且追求最高性能,临界区是首选。若需要跨进程、超时或检测所有者终止,则应使用互斥体。

3. 核心 API 函数

3.1 初始化临界区 —— InitializeCriticalSection

void InitializeCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // 指向 CRITICAL_SECTION 结构的指针
);

  • 在使用临界区之前必须调用此函数进行初始化。

  • CRITICAL_SECTION 是一个不透明的结构体,由系统内部使用,应用程序只需分配内存(通常作为全局变量或成员变量),无需关心其内部细节。

扩展:InitializeCriticalSectionAndSpinCount 允许设置自旋计数,优化多核 CPU 上的性能(在获取不到锁时先自旋等待一小段时间,避免立即进入内核态)。

3.2 进入临界区 —— EnterCriticalSection

void EnterCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);

  • 调用线程尝试获得临界区的所有权。

  • 如果临界区当前未被占用,线程立即获得所有权并进入。

  • 如果已被其他线程占用,调用线程将阻塞(进入等待状态)直到占用者调用 LeaveCriticalSection。

  • 注意:默认情况下,同一线程多次调用 EnterCriticalSection 会导致死锁(除非初始化时启用了递归)。

3.3 尝试进入临界区 —— TryEnterCriticalSection

BOOL TryEnterCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);

  • 非阻塞版本:尝试进入临界区,如果成功则返回 TRUE 并拥有所有权;如果当前被其他线程占用,立即返回 FALSE,不会阻塞。

  • 适用于需要“尝试执行,否则做其他事情”的场景。

3.4 离开临界区 —— LeaveCriticalSection

void LeaveCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);

  • 释放临界区的所有权,允许其他等待的线程进入。

  • 调用线程必须已经拥有该临界区,否则行为未定义(可能导致死锁或其他线程无法获得)。

3.5 删除临界区 —— DeleteCriticalSection

void DeleteCriticalSection(
LPCRITICAL_SECTION lpCriticalSection
);

  • 当不再需要临界区时调用,释放系统资源。

  • 调用前必须确保没有线程正在使用该临界区(即所有线程都已离开)。

  • 删除后不能再次使用该临界区,除非重新初始化。

4. 使用步骤

  • 定义 CRITICAL_SECTION 变量(通常作为全局或静态变量,或作为类成员)。

  • 初始化:调用 InitializeCriticalSection。

  • 进入临界区:在访问共享资源前调用 EnterCriticalSection。

  • 访问共享资源:执行需要保护的代码。

  • 离开临界区:访问完毕后调用 LeaveCriticalSection。

  • 删除临界区:程序结束或不再需要时调用 DeleteCriticalSection。

  • 5. 示例1:多线程保护共享计数器

    // CriticalSectionExample.cpp
    #include <windows.h>
    #include <stdio.h>

    CRITICAL_SECTION g_cs;
    int g_counter = 0;
    const int ITERATIONS = 1000000;

    DWORD WINAPI ThreadProc(LPVOID lpParam) {
    for (int i = 0; i < ITERATIONS; i++) {
    EnterCriticalSection(&g_cs);
    g_counter++; // 临界区:保护共享变量
    LeaveCriticalSection(&g_cs);
    }
    return 0;
    }

    int main() {
    InitializeCriticalSection(&g_cs);

    HANDLE hThreads[4];
    for (int i = 0; i < 4; i++) {
    hThreads[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
    }

    WaitForMultipleObjects(4, hThreads, TRUE, INFINITE);

    printf("Expected counter: %d\\n", 4 * ITERATIONS);
    printf("Actual counter: %d\\n", g_counter);

    for (int i = 0; i < 4; i++) CloseHandle(hThreads[i]);
    DeleteCriticalSection(&g_cs);
    return 0;
    }

    WaitForMultipleObjects 是 Windows API 中一个强大的等待函数,它允许一个线程同时监视多个内核对象(如事件、互斥体、信号量、进程、线程等)的状态。线程可以阻塞,直到以下任一条件满足:

    • 指定的所有对象都变为有信号状态(等待全部,bWaitAll = TRUE)。

    • 指定的任一对象变为有信号状态(等待任一,bWaitAll = FALSE)。

    该函数常用于需要同时响应多个事件或资源的场景,例如等待多个线程完成、等待多个事件中的任意一个发生等。

    DWORD WaitForMultipleObjects(
    DWORD nCount, // 对象句柄数量
    const HANDLE *lpHandles, // 指向句柄数组的指针
    BOOL bWaitAll, // 等待类型:TRUE=全部,FALSE=任意一个
    DWORD dwMilliseconds // 超时时间(毫秒)
    );

    参数详解

    • nCount:要等待的对象句柄数量,取值范围为 1 到 MAXIMUM_WAIT_OBJECTS(通常为 64)。不能为 0。

    • lpHandles:指向 HANDLE 类型数组的指针,数组包含要等待的内核对象句柄。这些句柄可以属于不同类型(如事件、互斥体、进程等),但必须具有 SYNCHRONIZE 访问权限。

    • bWaitAll:

      • TRUE:等待所有对象都变为有信号状态。函数仅在全部对象同时有信号时才返回。

      • FALSE:等待任何一个对象变为有信号状态。函数在任一对象有信号时立即返回。

    • dwMilliseconds:超时时间,以毫秒为单位。

      • 0:立即检查对象状态并返回,不阻塞。

      • INFINITE:无限期等待,直到条件满足。

      • 其他值:最多等待指定的毫秒数,超时后返回 WAIT_TIMEOUT。

    返回值

    返回值指示函数返回的原因:

    • WAIT_OBJECT_0 到 WAIT_OBJECT_0 + nCount – 1:

      • 如果 bWaitAll = FALSE,返回值为 WAIT_OBJECT_0 + i,表示数组中第 i 个对象变为有信号。

      • 如果 bWaitAll = TRUE,返回值为 WAIT_OBJECT_0,表示所有对象都已变为有信号(具体是哪个对象先变的不重要)。

    • WAIT_ABANDONED_0 到 WAIT_ABANDONED_0 + nCount – 1:

      • 仅适用于等待互斥体对象。表示等待成功,但其中至少有一个互斥体被“遗弃”(即拥有该互斥体的线程在未释放的情况下终止)。此时线程获得了互斥体的所有权,但共享资源可能处于不一致状态。

      • 如果 bWaitAll = FALSE,返回值为 WAIT_ABANDONED_0 + i,表示第 i 个互斥体被遗弃。

      • 如果 bWaitAll = TRUE,返回 WAIT_ABANDONED_0,表示至少有一个互斥体被遗弃(所有对象都已有信号,但其中包含被遗弃的互斥体)。

    • WAIT_TIMEOUT:超时时间到达,且条件未满足。

    • WAIT_FAILED:函数调用失败,可通过 GetLastError 获取错误代码。常见错误如 nCount 超出限制、句柄无效等。

    6. 示例2:保护复杂数据结构(双向链表)

    // CriticalSectionList.cpp
    #include <windows.h>
    #include <stdio.h>
    #include <stdlib.h>

    typedef struct _Node {
    int data;
    struct _Node* next;
    struct _Node* prev;
    } Node;

    Node* g_pHead = NULL;
    CRITICAL_SECTION g_csList;

    // 插入节点(需要同步)
    void InsertNode(int value) {
    Node* pNew = (Node*)malloc(sizeof(Node));
    pNew->data = value;

    EnterCriticalSection(&g_csList);
    // 插入到链表头部
    pNew->next = g_pHead;
    pNew->prev = NULL;
    if (g_pHead != NULL) {
    g_pHead->prev = pNew;
    }
    g_pHead = pNew;
    LeaveCriticalSection(&g_csList);
    }

    // 删除第一个节点(需要同步)
    int RemoveNode() {
    EnterCriticalSection(&g_csList);
    if (g_pHead == NULL) {
    LeaveCriticalSection(&g_csList);
    return -1; // 空链表
    }
    Node* pDel = g_pHead;
    int value = pDel->data;
    g_pHead = pDel->next;
    if (g_pHead != NULL) {
    g_pHead->prev = NULL;
    }
    LeaveCriticalSection(&g_csList);

    free(pDel);
    return value;
    }

    DWORD WINAPI Producer(LPVOID) {
    for (int i = 0; i < 100; i++) {
    InsertNode(i);
    }
    return 0;
    }

    DWORD WINAPI Consumer(LPVOID) {
    for (int i = 0; i < 100; i++) {
    int val = RemoveNode();
    if (val != -1) {
    printf("Consumed: %d\\n", val);
    }
    }
    return 0;
    }

    int main() {
    InitializeCriticalSection(&g_csList);

    HANDLE hThreads[2];
    hThreads[0] = CreateThread(NULL, 0, Producer, NULL, 0, NULL);
    hThreads[1] = CreateThread(NULL, 0, Consumer, NULL, 0, NULL);

    WaitForMultipleObjects(2, hThreads, TRUE, INFINITE);

    CloseHandle(hThreads[0]);
    CloseHandle(hThreads[1]);
    DeleteCriticalSection(&g_csList);
    return 0;
    }

    说明:生产者和消费者线程并发操作链表,临界区确保链表操作的原子性。

    7. 示例3:使用 TryEnterCriticalSection 避免死锁

    当需要同时保护多个资源时,使用 TryEnterCriticalSection 可以优雅地处理获取不到锁的情况,避免死锁。

    // TryEnterExample.cpp
    #include <windows.h>
    #include <stdio.h>

    CRITICAL_SECTION g_cs1, g_cs2;

    DWORD WINAPI Thread1(LPVOID) {
    EnterCriticalSection(&g_cs1);
    printf("Thread1 got cs1\\n");
    Sleep(10); // 模拟一些工作

    // 尝试获取 cs2,若失败则释放 cs1 并重试
    while (!TryEnterCriticalSection(&g_cs2)) {
    printf("Thread1 cannot get cs2, releasing cs1 and retry…\\n");
    LeaveCriticalSection(&g_cs1);
    Sleep(5); // 让出 CPU
    EnterCriticalSection(&g_cs1); // 重新获取 cs1
    printf("Thread1 re-acquired cs1\\n");
    }
    printf("Thread1 got cs2\\n");

    // 同时拥有两个临界区,进行工作
    LeaveCriticalSection(&g_cs2);
    LeaveCriticalSection(&g_cs1);
    return 0;
    }

    DWORD WINAPI Thread2(LPVOID) {
    EnterCriticalSection(&g_cs2);
    printf("Thread2 got cs2\\n");
    Sleep(10);
    EnterCriticalSection(&g_cs1); // 如果 Thread1 持有 cs1,这里会死锁?但 Thread1 使用了 TryEnter,可能避免
    printf("Thread2 got cs1\\n");
    LeaveCriticalSection(&g_cs1);
    LeaveCriticalSection(&g_cs2);
    return 0;
    }

    int main() {
    InitializeCriticalSection(&g_cs1);
    InitializeCriticalSection(&g_cs2);

    HANDLE hThreads[2];
    hThreads[0] = CreateThread(NULL, 0, Thread1, NULL, 0, NULL);
    hThreads[1] = CreateThread(NULL, 0, Thread2, NULL, 0, NULL);

    WaitForMultipleObjects(2, hThreads, TRUE, INFINITE);

    DeleteCriticalSection(&g_cs1);
    DeleteCriticalSection(&g_cs2);
    return 0;
    }

    说明:Thread1 使用 TryEnterCriticalSection 尝试获取第二个锁,如果失败则释放第一个锁并重试,打破了可能发生的死锁循环。

    8. 注意事项与最佳实践

  • 初始化与删除必须配对:忘记 DeleteCriticalSection 会导致资源泄漏。

  • 临界区对象的生命周期:在进入临界区之前确保已经初始化,在离开后确保没有线程再使用它。

  • 避免长时间持有:临界区内的代码应尽量简短,减少其他线程的等待时间。

  • 小心异常:在 C++ 中,如果临界区内抛出异常且未捕获,会导致 LeaveCriticalSection 不被调用,从而死锁。可使用 RAII 包装类(如 std::lock_guard 或自定义类)自动管理。

  • 死锁预防:如果需要多个临界区,尽量按相同顺序获取;或使用 TryEnterCriticalSection 处理获取失败的情况。

  • 性能调优:对于高频访问且多线程竞争激烈的场景,可考虑使用 InitializeCriticalSectionAndSpinCount 设置自旋计数,避免频繁进入内核。

  • 不要跨进程使用:临界区对象只能在同一进程内有效,不能通过共享内存等方式用于进程间同步。

  • 补充知识:

    1. 什么是用户态和内核态?

    在操作系统中,CPU 特权级别通常分为用户态(User Mode)和内核态(Kernel Mode)(有的架构还有更多级别)。操作系统内核运行在内核态,拥有最高的权限,可以访问所有硬件和系统资源;而普通应用程序运行在用户态,权限受限,必须通过系统调用请求内核服务。

    • 用户态:应用程序代码执行的环境,不能直接访问硬件或内核数据结构。

    • 内核态:操作系统内核代码执行的环境,可以执行特权指令,管理系统资源。

    系统调用是从用户态进入内核态的唯一合法途径(除硬件中断外)。每次系统调用都伴随着上下文切换和模式切换,有一定开销。

    2. 同步对象的内核态与用户态分类

    Windows 提供的同步对象可以分为两类:

    • 内核对象同步:如互斥体(Mutex)、事件(Event)、信号量(Semaphore)、等待计时器(Waitable Timer)等。这些对象由内核管理,等待操作(如 WaitForSingleObject)会导致线程进入内核态等待,直到条件满足。

    • 用户态同步:如临界区(Critical Section)、轻量级读写锁(SRWLock)、条件变量(Condition Variable,配合 SRWLock)等。这些对象主要在用户态操作,只有在发生竞争(等待)时才可能短暂进入内核。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Windows学习笔记-20(线程同步-临界区Critical Section)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!