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)等。这些对象主要在用户态操作,只有在发生竞争(等待)时才可能短暂进入内核。
网硕互联帮助中心



评论前必须登录!
注册