【Win32 多线程程序设计基础第四章笔记】
🌹 作者: 云小逸
🤟 个人主页: 云小逸的主页
🤟 motto: 要敢于一个人默默的面对自己,强大自己才是核心。不要等到什么都没有了,才下定决心去做。种一颗树,最好的时间是十年前,其次就是现在!学会自己和解,与过去和解,努力爱自己。希望春天来之前,我们一起面朝大海,春暖花开!
🥇 专栏:
- WTL学习
- 动态规划
- C 语言
- C++
- Java 语言
- Linux 编程
- 算法
- 待续…
文章目录
- 📚 前言
- 一、同步控制的核心目标
- 1. 典型问题示例:链表插入冲突
- 2. 同步控制的作用
- 二、同步(Synchronous)与异步(Asynchronous)的区别
- 简单总结
- 三、同步机制的作用类比
- 1. 类比1:办公室管理者
- 2. 类比2:红绿灯系统
- 四、同步机制的使用原则
- 1. 按需选择类型
- 2. 支持组合使用
- 五、Critical Sections(关键区域/临界区域)
- 1. 定义与核心作用
- (1)本质
- (2)核心目标
- 2. 关键特性
- (1)非核心对象,无Handle
- (2)基于“红绿灯”式排他控制
- 3. 核心API与使用流程
- (1)核心API详解
- (2)标准使用流程
- 4. 实际应用示例:用临界区保护链表操作
- (1)链表结构设计
- (2)关键操作的同步保护
- (3)示例的核心优势
- 5. 重要特性:可重入性
- 示例代码
- 规则
- 6. 使用注意事项
- (1)最小化锁定时间:避免长时间占用资源
- (2)避免“Dangling Critical Sections”(悬垂临界区)
- (3)明确共享资源范围
- 7. 与“线程当老板”方案的对比优势
- 六、死锁(Deadlock)
- 1. 定义与本质
- 2. 典型案例:SwapLists函数交换链表
- (1)案例背景
- (2)函数代码(原文示例)
- (3)死锁产生的关键步骤
- (4)最终结果
- 3. 核心产生条件(基于案例提炼)
- 4. 危害与处理原则
- 5. 基础预防思路(文档提及)
- 七、哲学家进餐问题(The Dining Philosophers)
- 1. 问题的核心设定
- (1)角色与资源
- (2)哲学家的状态
- 2. 死锁的产生过程(文档核心逻辑)
- 3. DINING程序:可视化演示死锁行为
- (1)程序的核心选项
- (2)状态图例(文档明确说明)
- (3)死锁的可视化表现
- 4. 临界区的局限:为何无法解决该问题?
- (1)临界区的初步尝试与缺点
- (2)临界区的核心局限:非核心对象,无法批量等待
- 5. 解决方向:引入Mutex(互斥器)的必要性
- 八、互斥器(Mutexes)
- 1. Mutex与Critical Section的核心区别
- 补充:函数对应关系表(文档原文整理)
- 2. Mutex的核心API与使用流程
- (1)产生Mutex:`CreateMutex`
- 关键说明:
- (2)打开已存在的Mutex:`OpenMutex`或`CreateMutex`
- 适用场景:
- (3)锁住Mutex(获取拥有权):`Wait`系列函数
- ① Mutex的激发状态规则(文档核心)
- ② 等待流程示例(文档描述)
- ③ 关键API参数
- (4)释放Mutex(释放拥有权):`ReleaseMutex`
- 关键注意点:
- (5)销毁Mutex:`CloseHandle`
- 注意:
- 3. Mutex的特殊特性:处理“被舍弃的Mutex”
- (1)被舍弃的场景
- (2)应对建议
- 4. Mutex的实际应用:两个经典案例
- (1)案例1:哲学家进餐问题(解决死锁)
- 场景回顾:
- (2)案例2:修正SwapLists函数(解决循环等待)
- 问题回顾:
- 5. Mutex的适用场景总结
- 九、信号量(Semaphores)
- 1. 核心定位与作用
- (1)本质:有限资源的“计数器”
- (2)生活化实例:租车店的敞篷车(文档核心例子)
- 2. 信号量与Mutex的核心区别(文档重点对比)
- 3. 核心API与使用流程
- (1)创建信号量:`CreateSemaphore`
- 关键参数解析:
- 返回值与错误处理:
- (2)获取锁定:通过`Wait`系列函数
- 核心逻辑:
- 关键特性:
- (3)释放锁定:`ReleaseSemaphore`
- 关键规则:
- 示例(基于租车场景):
- 4. 信号量初值(`lInitialCount`)的意义
- 5. 典型应用:生产者-消费者问题
- 6. 适用场景总结
- 十、事件(Event Objects)与互锁变量(Interlocked Variables)
- 一、事件(Event Objects):完全可控的核心同步对象
- 1. 核心定位:状态可控的“信号开关”
- 2. 创建Event:`CreateEvent`函数与关键参数
- 核心参数解析(决定Event行为的关键):
- 返回值与错误处理:
- 3. Event的核心操作函数
- 4. EVENTTST程序:可视化演示Event行为
- (1)AutoReset Event(`bManualReset=FALSE`)
- (2)ManualReset Event(`bManualReset=TRUE`)
- 程序额外启示:OS的线程公平性
- 5. Event的关键局限性:信号遗失
- 示例(文档描述的死锁场景):
- 二、互锁变量(Interlocked Variables):轻量的32位变量原子操作
- 1. 核心定位:解决“简单变量的race condition”
- 2. 核心函数:3个原子操作函数
- (1)`InterlockedIncrement`:原子加1
- (2)`InterlockedDecrement`:原子减1
- (3)`InterlockedExchange`:原子赋值并返回旧值
- 3. 适用场景与优势
- (1)核心适用场景
- (2)与其他同步机制的区别
- 4. 关键注意事项
- 三、总结:两种机制的选型建议
- 📣 结语
📚 前言
本章是Win32多线程程序设计的核心章节——同步控制。对于零基础的你来说,多线程就像多个工人同时装修一间房子,若没有统一的规则,工人可能互相干扰(比如同时抢用一把锤子、同时修改一面墙的设计),最终导致装修混乱。而“同步控制”就是给工人制定的规则,目的是让多线程在共享资源(如内存、数据结构、文件)时有序执行,避免“互相干扰”的问题。接下来,我们会按照“为什么需要同步→同步与异步的区别→同步机制的类比→具体同步工具(临界区、死锁、互斥器等)”的顺序,一步步带你理解Win32多线程的同步技术,所有例子和比喻都会保留,确保你能轻松跟上。
一、同步控制的核心目标
多线程程序的最大挑战,是让多个线程“同心协力”地访问共享资源。当多个线程同时操作同一资源(比如一块内存、一个链表、一个文件)时,若没有协调,会出现“多个线程同时修改同一数据”的情况,最终导致数据错误或程序异常。
1. 典型问题示例:链表插入冲突
正如第2章提到的“链表插入”问题:线程A正在修改链表节点时,被操作系统强制切换到线程B;线程B成功插入新节点后,线程A恢复执行,会覆盖线程B的操作,最终导致链表断裂(数据结构损坏)。
2. 同步控制的作用
同步控制的核心就是通过“规则”阻止这种冲突,让线程按顺序访问共享资源。简单说,就是给共享资源加“保护锁”,确保同一时间只有一个线程能操作它,就像让工人“排队使用工具”,避免争抢。
二、同步(Synchronous)与异步(Asynchronous)的区别
文档通过Win32 API的两个函数,清晰区分了这两个概念,就像生活中两种不同的办事方式:
类型 | 核心逻辑 | Win32 API示例 | 特点 |
---|---|---|---|
同步 | 调用方需等待被调用方完成,才能继续执行,类似“排队办事”——你必须等前面的人办完,才能轮到你。 | SendMessage() | 1. 调用后阻塞,直到窗口函数执行完毕; 2. 确保操作“有结果后再推进”。 |
异步 | 调用方发送请求后直接继续执行,无需等待被调用方完成,类似“发消息留便条”——你把消息留下,不用等对方回复,先去做其他事。 | PostMessage() | 1. 仅将消息放入对方消息队列,立即返回; 2. 无法立即获取操作结果。 |
简单总结
- 同步是“我等你做完”,强调“顺序性”;
- 异步是“我先做我的”,强调“并行性”。
三、同步机制的作用类比
为了让你更好理解同步机制的角色,文档用了两个生活化例子:
1. 类比1:办公室管理者
若让一个线程当“管理者”,所有线程都要排队等它的指令才能操作资源。这种方式会导致效率极低——队伍过长时,大部分线程都在等待,完全违背多线程“高效计算”的需求,就像一个公司只有一个管理者审批所有事务,员工全在排队,工作进度严重滞后。
2. 类比2:红绿灯系统
同步机制更像红绿灯,为线程分配“通行权”(绿灯)或“等待权”(红灯)。它的核心原则是:确保每个线程都有机会获得绿灯,既避免多个线程同时抢资源(闯红灯),又不剥夺任何线程的执行机会(不会一直红灯),兼顾了秩序和效率。
四、同步机制的使用原则
Win32提供了多种同步机制,没有“万能方案”,需遵循以下原则使用:
1. 按需选择类型
Win32提供临界区、互斥器、信号量等多种同步机制,需根据问题场景选择。比如“同一进程内简单的资源保护”用临界区,“跨进程同步”用互斥器(后续章节会详细讲解每种机制的适用场景)。
2. 支持组合使用
基础同步机制可像“建筑积木”一样组合,设计更复杂的协调逻辑。例如用“信号量+事件对象”,能实现“多线程按批次处理任务”的需求——信号量控制每批线程数量,事件对象通知批次开始。
五、Critical Sections(关键区域/临界区域)
在Win32多线程同步机制中,Critical Sections(简称“临界区”) 是最基础、易用的同步工具,核心作用是确保“共享资源同一时间仅被一个线程访问”,避免竞争条件和数据破坏。
1. 定义与核心作用
(1)本质
临界区并非“区域”本身,而是保护共享资源的代码块的同步规则——它限定“处理共享资源的代码段”同一时间只能有一个线程进入执行。
文档明确:“所谓critical sections意指一小块‘用来处理一份被共享之资源’的程序代码”,这里的“共享资源”是广义的,包括一块内存、一个链表、一个文件等具有“使用排他性”的资源(同一时间只能被一个线程操作)。
(2)核心目标
解决多线程对共享资源的冲突访问问题。比如第1章中“链表插入被破坏”的场景:若两个线程同时执行AddHead
插入节点,可能因线程切换导致链表断裂;而用临界区包裹插入代码后,可强制线程“排队”执行,避免冲突。
2. 关键特性
(1)非核心对象,无Handle
临界区不属于Win32核心对象(如线程、互斥器),因此没有“句柄(Handle)”,仅存在于当前进程的内存空间中。这意味着:
- 无需通过
Create
类API创建,只需在程序中定义CRITICAL_SECTION
类型变量并初始化; - 仅能用于同一进程内的线程同步,无法跨进程使用(跨进程需用后续的Mutex)。
(2)基于“红绿灯”式排他控制
每个共享资源对应一个CRITICAL_SECTION
变量,该变量如同“红绿灯”:
- 当一个线程调用
EnterCriticalSection
进入临界区时,“红灯亮起”,其他线程尝试进入时会被阻塞; - 当线程调用
LeaveCriticalSection
离开临界区时,“绿灯亮起”,阻塞的线程中会有一个被允许进入。
3. 核心API与使用流程
文档明确了临界区的4个核心操作API,以及标准使用流程:
(1)核心API详解
API函数 | 功能 | 参数说明 | 返回值 |
---|---|---|---|
InitializeCriticalSection | 初始化临界区变量 | lpCriticalSection :指向待初始化的CRITICAL_SECTION 变量 | void (无返回值) |
DeleteCriticalSection | 销毁临界区变量(释放相关资源) | lpCriticalSection :指向待销毁的CRITICAL_SECTION 变量 | void |
EnterCriticalSection | 进入临界区(获取资源访问权) | lpCriticalSection :指向目标临界区变量 | void |
LeaveCriticalSection | 离开临界区(释放资源访问权) | lpCriticalSection :指向目标临界区变量 | void |
(2)标准使用流程
以文档中的全局临界区为例,流程如下(可直接复制使用):
// 1. 定义全局/共享的CRITICAL_SECTION变量(供所有线程访问)
CRITICAL_SECTION gCriticalSection;
void UseCriticalSection() {// 2. 初始化临界区(通常在程序启动时执行)InitializeCriticalSection(&gCriticalSection);// 3. 访问共享资源:进入临界区EnterCriticalSection(&gCriticalSection);/* 处理共享资源的代码(如修改全局变量、操作链表等) */// 4. 结束访问:离开临界区LeaveCriticalSection(&gCriticalSection);// 5. 程序退出前,销毁临界区DeleteCriticalSection(&gCriticalSection);
}
注意:DeleteCriticalSection
仅用于“销毁临界区变量的内部状态”,并非像C++delete
那样释放内存,需与变量的内存管理(如malloc
/free
)区分开。
4. 实际应用示例:用临界区保护链表操作
文档以“链表”为例,展示临界区如何解决共享数据结构的同步问题,核心逻辑如下:
(1)链表结构设计
将CRITICAL_SECTION
变量嵌入链表结构体List
中,确保每个链表实例独立受保护(而非用全局临界区锁所有链表,提升效率):
typedef struct _Node {struct _Node *next;int data;
} Node;
typedef struct _List {Node *head; // 链表头指针CRITICAL_SECTION critical_sec; // 该链表专属的临界区变量
} List;
(2)关键操作的同步保护
对链表的Create
、Delete
、AddHead
、Insert
、Next
等操作,均用临界区包裹“访问链表的代码段”:
-
创建链表:初始化链表时同时初始化临界区
List *CreateList() {List *pList = (List *)malloc(sizeof(List));pList->head = NULL;InitializeCriticalSection(&pList->critical_sec); // 初始化临界区return pList; }
-
插入节点:进入临界区后执行插入逻辑,避免多线程同时修改链表指针
void AddHead(List *pList, Node *node) {EnterCriticalSection(&pList->critical_sec); // 进入临界区node->next = pList->head; // 修改链表指针(共享操作)pList->head = node;LeaveCriticalSection(&pList->critical_sec); // 离开临界区 }
-
读取节点:即使是
Next
(读取node->next
)也需保护——文档强调“return node->next
会被编译为多个机器指令,非原子操作”,可能因线程切换导致读取错误:Node *Next(List *pList, Node *node) {Node* next;EnterCriticalSection(&pList->critical_sec);next = node->next; // 读取操作需同步LeaveCriticalSection(&pList->critical_sec);return next; }
(3)示例的核心优势
将临界区与链表实例绑定,而非用全局临界区,避免了“多个链表只能串行访问”的效率问题——每个链表的临界区独立,不同链表的操作可并行执行。
5. 重要特性:可重入性
文档特别指出,临界区支持可重入性:即同一个线程可以多次调用EnterCriticalSection
进入同一个临界区,无需担心阻塞。
示例代码
例如Insert
函数调用AddHead
时,两者都需进入同一个临界区:
void Insert(List *pList, Node *afterNode, Node *newNode) {EnterCriticalSection(&pList->critical_sec); // 第1次进入if (afterNode == NULL) {AddHead(pList, newNode); // AddHead中会第2次进入同一临界区(无阻塞)} else {newNode->next = afterNode->next;afterNode->next = newNode;}LeaveCriticalSection(&pList->critical_sec); // 第1次离开
}
规则
进入次数必须与离开次数相等(如进入5次需离开5次),否则临界区会永久锁定,其他线程无法进入。
6. 使用注意事项
文档明确了多个关键禁忌和风险,需重点关注:
(1)最小化锁定时间:避免长时间占用资源
- 核心原则:“不要长时间锁住一份资源”——临界区锁定期间,其他线程会被阻塞,若锁定时间过长(如几秒甚至分钟),会导致程序响应缓慢甚至“假死”。
- 具体禁忌:绝对不要在临界区内调用
Sleep()
或Wait...()
类API(如WaitForSingleObject
),这类函数会让线程长时间阻塞,导致临界区无法释放。
(2)避免“Dangling Critical Sections”(悬垂临界区)
临界区的最大缺点是“无法检测进入线程的状态”:若线程进入临界区后未调用LeaveCriticalSection
就结束(如崩溃、调用ExitThread
),会导致临界区“悬垂”,且系统无法自动清理:
- Windows NT:该临界区会被永久锁定,后续所有尝试进入的线程都会阻塞;
- Windows 95:后续线程会被允许进入,但此时共享资源可能处于不一致状态(如链表指针未修改完成),导致程序异常。
- 解决方案:若需检测线程状态并自动释放同步资源,需使用后续章节的
Mutex
(互斥器),它是核心对象,支持检测“线程遗弃”状态。
(3)明确共享资源范围
需为每个独立的共享资源分配专属的CRITICAL_SECTION
变量,而非用一个全局临界区保护所有资源——否则会导致“无关操作排队”,降低程序效率(如文档中链表示例的设计思路)。
7. 与“线程当老板”方案的对比优势
文档开头提到“让某个线程当老板”的方案(一个线程指挥所有线程排队)存在“队伍长、效率低”的缺点,而临界区的优势在于:
- 轻量高效:无需额外线程调度,仅通过进程内变量实现同步,开销远低于“老板线程”方案;
- 精准控制:仅锁定“处理共享资源的代码段”,而非整个线程的执行,减少不必要的阻塞;
- 可扩展性:多个临界区可独立工作,不同共享资源的操作可并行,提升程序整体吞吐量。
六、死锁(Deadlock)
死锁是临界区等同步机制使用不当引发的严重问题,文档通过“链表交换”案例清晰阐述了其产生原因、危害及预防思路。
1. 定义与本质
文档明确:死锁是多线程因“互相持有对方所需资源、且均不释放已持资源”而陷入的“永久阻塞”状态,又称“死亡拥抱(The Deadly Embrace)”。
其本质是:当多个线程同时请求多个共享资源时,若线程间“持有资源的顺序不一致”,会形成“我等你释放、你等我释放”的循环等待,最终所有线程都无法继续执行。
2. 典型案例:SwapLists函数交换链表
文档以“用临界区保护链表,并交换两个链表内容”的场景为例,还原死锁的产生过程:
(1)案例背景
- 每个链表(
List
)拥有独立的临界区(critical_sec
),确保链表操作的排他性; - 函数
SwapLists
需交换两个链表的头指针(head
),因此需要先后获取两个链表的临界区(先锁list1
,再锁list2
)。
(2)函数代码(原文示例)
void SwapLists(List *list1, List *list2)
{List *tmp_list;// 第一步:获取第一个链表的临界区EnterCriticalSection(&list1->critical_sec);// 第二步:获取第二个链表的临界区EnterCriticalSection(&list2->critical_sec);// 交换链表头指针(操作共享资源)tmp_list = list1->head;list1->head = list2->head;list2->head = tmp_list;// 释放临界区LeaveCriticalSection(&list1->critical_sec);LeaveCriticalSection(&list2->critical_sec);
}
(3)死锁产生的关键步骤
文档设定“线程A”和“线程B”同时调用SwapLists
,但请求链表的顺序相反,且中途发生线程切换(context switch),具体流程如下:
步骤 | 线程A(调用SwapLists(home, work) ) | 线程B(调用SwapLists(work, home) ) | 关键触发点 |
---|---|---|---|
1 | 调用EnterCriticalSection(&home->critical_sec) ,成功获取home 链表的临界区 | 未执行(待调度) | - |
2 | 发生context switch(操作系统切换线程) | 开始执行 | 线程切换是死锁的“导火索” |
3 | 被阻塞(等待CPU调度) | 调用EnterCriticalSection(&work->critical_sec) ,成功获取work 链表的临界区 | 线程B持有work 临界区 |
4 | 被阻塞 | 继续调用EnterCriticalSection(&home->critical_sec) ,尝试获取home 临界区 | 此时home 临界区已被线程A持有,线程B阻塞 |
5 | 恢复执行 | 持续阻塞(等待home 临界区) | - |
6 | 调用EnterCriticalSection(&work->critical_sec) ,尝试获取work 临界区 | 持续阻塞 | 此时work 临界区已被线程B持有,线程A阻塞 |
(4)最终结果
- 线程A:持有
home
链表的临界区,等待work
链表的临界区; - 线程B:持有
work
链表的临界区,等待home
链表的临界区; - 两者均不释放已持有的临界区,也无法获取对方的临界区,陷入永久阻塞,即死锁。
3. 核心产生条件(基于案例提炼)
文档虽未直接列出死锁的经典“四个条件”,但案例中已隐含所有关键前提,可总结为:
- 多资源需求:线程需同时请求两个或更多共享资源(案例中为两个链表的临界区);
- 持有并等待:线程持有一个资源后,不释放,继续等待其他资源(案例中线程A持有
home
后等work
,线程B持有work
后等home
); - 资源不可剥夺:资源(临界区)一旦被线程持有,其他线程无法强制夺取(Win32临界区无“剥夺”机制);
- 循环等待:线程间形成“你等我、我等你”的循环依赖(案例中线程A↔线程B的临界区等待)。
4. 危害与处理原则
- 危害:死锁会导致相关线程永久阻塞,若涉及主线程或核心功能线程,会使程序“假死”(无响应),且用户无任何错误提示(文档提及“操作系统不会当掉,用户不会获得错误信息”);
- 处理原则:文档明确“侦测死锁的算法过于复杂”,因此预防死锁比侦测、解决死锁更实用——对大部分程序,应在设计阶段避免死锁产生,而非依赖后续“解锁”。
5. 基础预防思路(文档提及)
文档指出预防死锁的核心思路是强制资源请求的“原子性”,即“all-or-nothing(要不统统获得,要不统统没有)”:
- 若线程需要多个资源,需确保“一次性获取所有所需资源”;若有任何一个资源无法获取,则释放已尝试获取的资源,不持有任何资源等待;
- 针对案例中的
SwapLists
,可修改为:先尝试同时获取list1
和list2
的临界区,若任一获取失败,则释放已获取的临界区,重新尝试(需结合后续章节的WaitForMultipleObjects
等API实现“批量等待资源”); - 延伸:另一种简单预防方式是统一资源请求顺序(如约定“始终按链表地址从小到大请求临界区”),避免线程间请求顺序相反(案例中线程A和B的请求顺序相反是死锁直接原因)。
七、哲学家进餐问题(The Dining Philosophers)
“哲学家进餐问题”是文档中用来具象化死锁原理的经典案例,它与前文“SwapLists函数死锁”本质一致(均因“多资源循环等待”引发),但通过“哲学家-筷子”的生活化场景更易理解。
1. 问题的核心设定
文档明确了“哲学家进餐问题”的基础规则,是理解死锁的前提:
(1)角色与资源
- 角色:多位哲学家围绕餐桌就坐(数量与“筷子”相等);
- 资源:每两位哲学家之间放一支筷子(即筷子总数=哲学家总数),筷子是“共享资源”——哲学家吃饭必须同时持有左右两支筷子,且持有后不会中途放下(“不愿意在吃完之前放下筷子”)。
(2)哲学家的状态
仅三种状态:思考(不占用筷子)、等待(持有部分筷子,等待另一支)、吃饭(持有两支筷子)。
2. 死锁的产生过程(文档核心逻辑)
文档通过“哲学家拿筷子的顺序”揭示死锁的必然性,核心在于“循环等待”:
- 默认拿筷顺序:若每位哲学家都遵循“先拿左手边筷子,再拿右手边筷子”的规则;
- 死锁触发:当所有哲学家几乎同时拿起左手边的筷子(此时每位哲学家均持有1支筷子),后续尝试拿右手边筷子时,会发现右手边的筷子已被相邻的哲学家持有——此时形成“循环等待”:
- 哲学家A持有左筷,等右筷(被哲学家B持有);
- 哲学家B持有左筷,等右筷(被哲学家C持有);
- ……
- 最后一位哲学家持有左筷,等右筷(被哲学家A持有);
- 结果:所有哲学家均持有1支筷子、等待另一支,且无人愿意放下已持有的筷子,陷入永久阻塞(死锁),与前文“SwapLists函数死锁”的“循环等待”逻辑完全一致。
3. DINING程序:可视化演示死锁行为
文档提到“书附盘片中的DINING程序”,其核心功能是直观展示死锁的产生与预防选择,程序行为与文档描述高度绑定:
(1)程序的核心选项
程序启动时会提供两个关键选择,决定哲学家的行为模式:
- 选择“是否使用WaitForMultipleObjects”:若选“否”(允许死锁),哲学家会“一次获取一支筷子”,持有1支时进入等待状态;若选“是”(避免死锁),哲学家会“要么一次获取两支筷子,要么什么都不获取”(避免部分持有资源)。
- 选择“是否启用East philosophers”:若选“是”,会加速死锁的发生(文档译注提及“deadlock much faster”)。
(2)状态图例(文档明确说明)
程序通过颜色和图标区分哲学家状态,便于观察:
- 绿色:休息(思考状态,不占用筷子);
- 红色:等待(持有1支筷子,等待另一支);
- 红色+两支筷子:吃饭(持有两支筷子,正常执行)。
(3)死锁的可视化表现
当程序允许死锁时,最终会出现“所有哲学家均为红色(持有1支筷子)”的状态——此时无哲学家能吃饭,完全对应前文描述的“循环等待”死锁场景。
4. 临界区的局限:为何无法解决该问题?
文档明确指出,前文的“临界区(Critical Sections)”无法解决哲学家进餐问题,核心原因与“资源等待的特性”和“临界区的本质”相关:
(1)临界区的初步尝试与缺点
文档提到一种朴素的解决思路:“使用一个临界区,允许一次只能够有一位哲学家拿起或放下筷子”——即通过临界区强制哲学家“排队拿放筷子”,避免同时拿筷导致的冲突。
但这种方法存在严重缺陷:过度限制并行性,即使哲学家之间无资源竞争(如不相邻的哲学家),也需排队等待,导致“非必要的等待”,违背多线程“提升效率”的初衷。
(2)临界区的核心局限:非核心对象,无法批量等待
文档强调,解决该问题的理想思路是“让哲学家一次尝试获取两支筷子(要么都拿到,要么都不拿)”,这需要用到上一章的WaitForMultipleObjects()
函数(等待多个资源均可用时再执行)。
但WaitForMultipleObjects()
仅支持“核心对象”(如后续章节的Mutex),而临界区不是核心对象(无Handle,仅存在于进程内存中)——因此无法通过该函数“批量等待多个临界区”(对应FAQ17“我能够等待一个以上的critical sections吗?”的隐含答案:不能)。
这也解释了为何前文“SwapLists函数死锁”无法用临界区完美解决:临界区无法支持“多资源的原子性获取”,只能按顺序获取,进而埋下循环等待的隐患。
5. 解决方向:引入Mutex(互斥器)的必要性
文档通过该问题自然引出“临界区的不足”,并指向新的同步机制——Mutex(互斥器):
- 核心逻辑:Mutex是Win32核心对象(有Handle),支持通过
WaitForMultipleObjects()
批量等待多个Mutex(对应“同时获取两支筷子”的需求); - 解决死锁的关键:若用“每支筷子对应一个Mutex”,哲学家可通过
WaitForMultipleObjects()
同时等待左右两支筷子的Mutex——仅当两支都可用时才获取,否则不持有任何一个,从根本上避免“部分持有资源”和“循环等待”(对应FAQ16“我如何避免死锁?”的核心答案:通过“all-or-nothing”原则,要么获取所有所需资源,要么不获取)。
这也成为后续章节讲解“Mutex(互斥器)”的重要铺垫,体现了文档“从问题到解决方案”的递进逻辑。
八、互斥器(Mutexes)
互斥器(Mutex,全称Mutual Exclusion)是Win32中支持互斥访问、具备跨进程能力且弹性更强的同步机制,核心作用与临界区一致——确保同一时间仅一个线程访问共享资源,但在速度、跨进程支持、等待控制等方面有显著差异。
1. Mutex与Critical Section的核心区别
文档明确两者“功能类似但弹性不同”,核心差异体现在4个维度:
对比维度 | 互斥器(Mutex) | 临界区(Critical Section) |
---|---|---|
速度性能 | 慢(锁住未被拥有的Mutex比临界区慢约100倍) 原因:需进入操作系统内核态(kernel mode)操作,而临界区在用户态(user mode/Win95的ring3)即可完成 | 快(用户态操作,无需内核介入) |
跨进程能力 | 支持(需通过CreateMutex 指定全局唯一名称,其他进程可通过名称访问) | 不支持(仅存在于当前进程内存空间,无Handle) |
等待超时控制 | 支持(可通过WaitForSingleObject 等函数指定等待时间,超时返回WAIT_TIMEOUT ) | 不支持(一旦进入等待,只能等资源释放,无法主动超时退出) |
核心对象属性 | 是Win32核心对象,有Handle,需通过CloseHandle 管理引用计数 | 非核心对象,无Handle,仅通过CRITICAL_SECTION 变量管理 |
补充:函数对应关系表(文档原文整理)
为便于对比使用,文档给出两者的API映射关系,明确“替换临界区时需改用哪些Mutex函数”:
临界区(Critical Section)函数 | 互斥器(Mutex)相关函数 |
---|---|
InitializeCriticalSection | CreateMutex (创建/初始化) |
- | OpenMutex (打开已存在的Mutex,跨进程用) |
EnterCriticalSection | WaitForSingleObject /WaitForMultipleObjects /MsgWaitForMultipleObjects (获取拥有权) |
LeaveCriticalSection | ReleaseMutex (释放拥有权) |
DeleteCriticalSection | CloseHandle (减少引用计数,计数为0时销毁) |
2. Mutex的核心API与使用流程
Mutex作为核心对象,需通过标准“创建/打开→获取拥有权→操作资源→释放→销毁”流程使用:
(1)产生Mutex:CreateMutex
用于创建新Mutex或获取已存在Mutex的Handle,是使用Mutex的起点。
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, // 安全属性(Win95无效,NULL为默认)BOOL bInitialOwner, // 初始拥有者:TRUE=创建线程拥有,FALSE=无初始拥有者LPCTSTR lpName // 名称(跨进程用,不含反斜线,需全局唯一)
);
关键说明:
- 参数
lpName
:跨进程同步的核心——若指定名称,其他进程可通过该名称打开此Mutex;若为NULL
,则仅当前进程内线程可用。名称需唯一(如“Company.AppName.Mutex”),避免与其他程序冲突。 - 返回值与错误处理:
- 成功:返回Mutex的Handle;
- 若
lpName
已存在:返回该Mutex的Handle,但GetLastError()
会传回ERROR_ALREADY_EXISTS
(非错误,仅表示未创建新对象); - 失败:返回
NULL
,需通过GetLastError()
获取具体原因。
- 示例(文档原文):创建一个无安全属性、无初始拥有者、名为“Demonstration Mutex”的Mutex:
HANDLE hMutex; void CreateAndDeleteMutex() {hMutex = CreateMutex(NULL, FALSE, "Demonstration Mutex");/* 操作共享资源 */CloseHandle(hMutex); // 释放引用,计数为0时销毁 }
(2)打开已存在的Mutex:OpenMutex
或CreateMutex
当Mutex已被其他进程创建(如服务器进程创建,客户端进程需访问),可通过两种方式获取Handle:
OpenMutex
:专门用于打开已存在的命名Mutex(推荐,语义更清晰),需指定Mutex名称和访问权限;CreateMutex
:若指定的lpName
已存在,会直接返回该Mutex的Handle(而非创建新对象),与OpenMutex
效果一致,但需通过GetLastError()
判断是否为新创建。
适用场景:
客户端-服务器架构中,服务器创建Mutex保护共享资源,客户端无需创建,仅需打开即可同步(如多个客户端访问服务器的共享配置文件)。
(3)锁住Mutex(获取拥有权):Wait
系列函数
Mutex的“锁住”本质是获取其拥有权,需通过WaitForSingleObject
、WaitForMultipleObjects
等函数实现,核心逻辑与“激发状态”绑定:
① Mutex的激发状态规则(文档核心)
- 激发状态:无线程拥有Mutex时,Mutex处于“激发状态”,此时
Wait
函数会立即返回,且返回后Mutex会立刻转为非激发状态(防止其他线程同时获取); - 非激发状态:有线程拥有Mutex时,Mutex处于“非激发状态”,其他线程调用
Wait
会阻塞,直到拥有者释放Mutex。
② 等待流程示例(文档描述)
- Mutex无拥有者(激发状态);
- 线程A调用
WaitForSingleObject(hMutex, INFINITE)
,Wait函数检测到激发状态,返回WAIT_OBJECT_0
,同时Mutex转为非激发状态; - 线程B调用
WaitForSingleObject(hMutex, INFINITE)
,因Mutex非激发,线程B阻塞; - 线程A调用
ReleaseMutex(hMutex)
,Mutex转回激发状态; - 线程B的Wait函数检测到激发,返回
WAIT_OBJECT_0
,Mutex再次转为非激发状态。
③ 关键API参数
以WaitForMultipleObjects
为例(解决多资源等待,如哲学家问题):
DWORD WaitForMultipleObjects(DWORD nCount, // 等待的Handle数量(如2支筷子→2)CONST HANDLE *lpHandles,// Handle数组(如存放两支筷子的Mutex Handle)BOOL bWaitAll, // TRUE=所有Handle激发才返回(关键,避免部分拥有)DWORD dwMilliseconds // 等待时间(INFINITE=无限等待)
);
- 文档中“哲学家问题”即通过
bWaitAll=TRUE
实现“要么同时获取两支筷子,要么都不获取”,从根本上避免死锁。
(4)释放Mutex(释放拥有权):ReleaseMutex
仅拥有Mutex的线程可释放,函数原型:
BOOL ReleaseMutex(HANDLE hMutex); // 参数:需释放的Mutex Handle
关键注意点:
- 拥有权归属:Mutex的拥有权属于“最后调用
Wait
且未释放的线程”,而非“创建Mutex的线程”——即使线程B创建Mutex,线程A调用Wait
后也会成为拥有者; - 释放次数匹配:若一个线程多次调用
Wait
获取同一Mutex(可重入),需调用相同次数的ReleaseMutex
才能完全释放(与临界区的Enter
/Leave
次数匹配规则一致)。
(5)销毁Mutex:CloseHandle
Mutex作为核心对象,通过引用计数管理生命周期:
- 每次调用
CreateMutex
或OpenMutex
,引用计数+1; - 每次调用
CloseHandle
,引用计数-1; - 当引用计数降至0时,Mutex被操作系统自动销毁(与线程是否结束无关,除非线程是最后一个持有Handle的)。
注意:
即使线程拥有Mutex但未释放就结束,只要其他线程仍持有该Mutex的Handle,Mutex也不会销毁(仅拥有权被释放)。
3. Mutex的特殊特性:处理“被舍弃的Mutex”
文档强调,这是Mutex独有的、区别于临界区的核心特性——解决“线程持有Mutex未释放就结束”的问题:
(1)被舍弃的场景
线程A持有Mutex(未释放),因异常(如崩溃)或调用ExitThread
结束,此时Mutex不会被销毁,而是触发特殊处理:
- Mutex被标记为“未被拥有”(但非激发状态);
- 下一个等待该Mutex的线程(如线程B)调用
Wait
时,会返回WAIT_ABANDONED_0
(单Mutex)或WAIT_ABANDONED_0 ~ WAIT_ABANDONED_0+nCount-1
(多Mutex),通知“前一个拥有者未释放就结束”。
(2)应对建议
- 通知的意义:告知当前线程“共享资源可能处于不一致状态”(前一个线程可能未完成数据修改);
- 处理方式:需检查共享资源的完整性(如链表是否断裂、文件是否完整),避免直接使用损坏的数据。
4. Mutex的实际应用:两个经典案例
文档通过“哲学家进餐问题”和“修正SwapLists函数”,展示Mutex如何解决之前临界区无法处理的死锁问题:
(1)案例1:哲学家进餐问题(解决死锁)
场景回顾:
哲学家需同时持有左右两支筷子(共享资源),用临界区时因“部分持有”导致死锁;改用Mutex后:
- 资源映射:每支筷子对应一个Mutex(
gChopStick[i] = CreateMutex(NULL, FALSE, NULL)
); - 等待逻辑:哲学家调用
WaitForMultipleObjects(2, myChopsticks, TRUE, INFINITE)
,bWaitAll=TRUE
确保“要么同时获取两支筷子,要么都不获取”; - 效果:无线程会“持有一支筷子等待另一支”,彻底避免循环等待,死锁不再发生。
(2)案例2:修正SwapLists函数(解决循环等待)
问题回顾:
用临界区时,线程A锁home
等work
,线程B锁work
等home
,形成死锁;改用Mutex后:
- 结构修改:
List
结构体中用HANDLE hMutex
代替CRITICAL_SECTION
; - 同步逻辑:通过
WaitForMultipleObjects
同时等待两个链表的Mutex,bWaitAll=TRUE
确保“要么同时锁住两个链表,要么都不锁”; - 修正代码(文档列表4-2核心):
void SwapLists(struct List *list1, struct List *list2) {struct List *tmp_list;HANDLE arrhandles[2] = {list1->hMutex, list2->hMutex};// 同时等待两个Mutex,均激发才返回WaitForMultipleObjects(2, arrhandles, TRUE, INFINITE);// 交换链表头(安全操作)tmp_list = list1->head;list1->head = list2->head;list2->head = tmp_list;// 释放MutexReleaseMutex(arrhandles[0]);ReleaseMutex(arrhandles[1]); }
- 效果:线程A和B均需同时获取两个Mutex才能执行交换,不会出现“部分持有”,循环等待被打破。
5. Mutex的适用场景总结
结合文档内容,Mutex适合以下场景:
- 跨进程同步:需多个进程共享资源(如多个程序访问同一配置文件);
- 需等待超时:避免线程永久阻塞(如等待Mutex 5秒后超时退出,提升程序容错性);
- 处理线程异常结束:通过
WAIT_ABANDONED_0
感知前线程未释放资源,保护数据完整性; - 多资源同步:需同时获取多个共享资源(如哲学家、SwapLists),通过
WaitForMultipleObjects
避免死锁。
而临界区更适合“同一进程内、对速度要求高、仅需简单互斥”的场景(如进程内多个线程操作全局数组)。
九、信号量(Semaphores)
信号量(Semaphores)是Win32中用于解决“多个线程共享有限数量资源” 的经典同步机制,核心作用是通过“资源计数”确保线程不会超出资源总量访问共享资源。
1. 核心定位与作用
(1)本质:有限资源的“计数器”
信号量的核心是维护一个“可用资源数量”的计数器,通过“计数增减”控制线程对资源的访问:
- 当线程需要访问资源时,计数器减1(锁定资源);
- 当线程释放资源时,计数器加1(释放资源);
- 当计数器为0时,后续线程需阻塞等待,直到有资源被释放(计数器>0)。
文档强调:信号量是解决Producer/Consumer(生产者-消费者)问题的关键——例如“环状缓冲区”(生产者写入数据、消费者读取数据),缓冲区大小有限,需通过信号量控制“可读数据量”,避免消费者读取空缓冲区或生产者写满缓冲区。
(2)生活化实例:租车店的敞篷车(文档核心例子)
文档用“租车店租敞篷车”的场景,直观解释信号量的作用:
- 资源:租车店有3辆敞篷车(有限资源,数量=3);
- 线程:4位客户(对应4个线程)同时要租敞篷车,由租车代理人(线程操作)处理;
- 问题:若无同步机制,可能出现“同一辆车被租给2个客户”(资源重复分配);
- 信号量解决方案:
- 用信号量维护“可用敞篷车数量”,初始值=3(
lInitialCount=3
),最大值=3(lMaximumCount=3
); - 代理人处理租车时,调用
Wait
函数——信号量现值减1(可用车数-1); - 客户还车时,调用
ReleaseSemaphore
——信号量现值加1(可用车数+1); - 当4个客户同时租车时,前3个客户成功(现值从3→0),第4个客户阻塞,直到有客户还车(现值>0)。
- 用信号量维护“可用敞篷车数量”,初始值=3(
这个例子的核心是:信号量通过“不可分割的计数操作”,避免“有限资源被超额分配”,且无需为每个资源单独创建同步对象(如Mutex),减少系统开销。
2. 信号量与Mutex的核心区别(文档重点对比)
文档明确:Mutex是信号量的“退化形式”(当信号量的lMaximumCount=1
时,即为“二元信号量”,类似Mutex),但Win32中两者无法互换,核心差异体现在4个维度:
对比维度 | 信号量(Semaphore) | 互斥器(Mutex) |
---|---|---|
资源数量支持 | 支持“有限多个资源”(如3辆敞篷车),最大值由lMaximumCount 指定 | 仅支持“1个资源”(独占访问),本质是“二元信号量” |
拥有权归属 | 无“拥有权”概念——任何线程均可调用ReleaseSemaphore 释放,无需是“获取锁定的线程” | 有“拥有权”——仅“最后调用Wait且未释放的线程”可释放(ReleaseMutex ) |
“线程异常结束”处理 | 无“Wait Abandoned”状态——若线程获取锁定后未释放就结束,信号量计数器不会自动恢复,可能导致资源泄漏 | 有“Wait Abandoned”状态——线程未释放就结束,下一个Wait的线程会收到通知,Mutex自动转为可获取状态 |
典型适用场景 | 有限资源共享(如连接池、缓冲区、租车店车辆) | 独占资源访问(如单一链表、全局变量) |
3. 核心API与使用流程
文档详细说明信号量的3个核心操作:创建→获取锁定→释放锁定,需严格遵循“计数增减”逻辑:
(1)创建信号量:CreateSemaphore
用于创建新信号量或获取已存在信号量的Handle,是使用信号量的起点。
HANDLE CreateSemaphore(LPSECURITY_ATTRIBUTES lpAttributes, // 安全属性(Win95无效,NULL=默认)LONG lInitialCount, // 信号量初值(≥0且≤lMaximumCount)LONG lMaximumCount, // 信号量最大值(最大可用资源数)LPCTSTR lpName // 名称(跨进程用,NULL=仅当前进程可用)
);
关键参数解析:
lInitialCount
(初值):初始可用资源数。例如租车店初始有3辆敞篷车→lInitialCount=3
;若需先初始化资源(如环状缓冲区),可设为0,初始化后再通过ReleaseSemaphore
增加。lMaximumCount
(最大值):资源总量上限,不可突破。例如租车店最多3辆敞篷车→lMaximumCount=3
,即使调用ReleaseSemaphore
也无法让现值超过3。lpName
(名称):支持跨进程同步——若指定全局唯一名称(如“Company.CarRental.Semaphore”),其他进程可通过该名称打开信号量;若为NULL
,仅当前进程内线程可用。
返回值与错误处理:
- 成功:返回信号量Handle;
- 若
lpName
已存在:返回该信号量的Handle,GetLastError()
传回ERROR_ALREADY_EXISTS
(非错误,仅表示未创建新对象); - 失败:返回
NULL
,需通过GetLastError()
获取原因。
(2)获取锁定:通过Wait
系列函数
线程需通过WaitForSingleObject
、WaitForMultipleObjects
等函数“申请资源”,本质是将信号量现值减1:
核心逻辑:
- 若信号量现值>0:
Wait
函数立即返回WAIT_OBJECT_0
,同时现值减1(资源被占用); - 若信号量现值=0:
Wait
函数阻塞,直到其他线程释放信号量(现值>0)。
关键特性:
- 无“拥有权”限制:同一线程可多次调用
Wait
函数,每次调用都会让现值减1(只要现值够)——例如线程A调用2次Wait
,若初始现值=3,调用后现值=1,无需担心阻塞(与Mutex不同,Mutex拥有者多次Wait不会阻塞,但不改变计数)。
(3)释放锁定:ReleaseSemaphore
线程释放资源时,需调用此函数将信号量现值增加指定数值,函数原型:
BOOL ReleaseSemaphore(HANDLE hSemaphore, // 需释放的信号量HandleLONG lReleaseCount, // 现值增加量(必须>0,不可为负或0)LPLONG lpPreviousCount // 输出参数:返回释放前的现值(可选,NULL=不获取)
);
关键规则:
- 现值不超限:增加后的现值绝对不会超过
CreateSemaphore
指定的lMaximumCount
——例如最大值=3,当前现值=2,lReleaseCount=2
,则最终现值=3(而非4)。 - 无拥有权限制:任何线程均可调用,无需是“获取锁定的线程”——例如线程A获取锁定(现值减1),线程B可调用
ReleaseSemaphore
释放(现值加1),这与Mutex的“仅拥有者释放”完全不同。
示例(基于租车场景):
// 1. 创建信号量:3辆敞篷车(初值3,最大值3)
HANDLE hCarSemaphore = CreateSemaphore(NULL, 3, 3, "CarRental.Convertible.Semaphore");
// 2. 线程A(租车):获取锁定,现值3→2
WaitForSingleObject(hCarSemaphore, INFINITE);
// 3. 线程B(还车):释放锁定,现值2→3
ReleaseSemaphore(hCarSemaphore, 1, NULL);
// 4. 销毁信号量
CloseHandle(hCarSemaphore);
4. 信号量初值(lInitialCount
)的意义
文档特别解释:初值的设定与“资源初始化”强相关,核心目的是确保资源准备就绪后再开放访问:
- 若初值设为0:初始时信号量现值=0,所有调用
Wait
的线程都会阻塞——适合“先初始化资源,再开放访问”的场景,例如环状缓冲区:- 创建信号量(初值0,最大值=缓冲区大小);
- 生产者线程初始化缓冲区(如分配内存、清空数据);
- 调用
ReleaseSemaphore
将现值设为缓冲区大小(或初始可用数据量),允许消费者线程访问。
- 若初值设为最大值:初始时资源全部可用,适合“资源已就绪”的场景(如租车店一开始就有3辆敞篷车)。
5. 典型应用:生产者-消费者问题
文档明确信号量是解决“生产者-消费者”问题的关键,以“环状缓冲区”为例:
- 生产者(Producer):向缓冲区写入数据,每次写成功后调用
ReleaseSemaphore
(现值+1,代表可用数据量增加); - 消费者(Consumer):从缓冲区读取数据,读取前调用
WaitForSingleObject
(现值-1,代表可用数据量减少),若现值=0则阻塞,直到生产者写入数据; - 信号量作用:通过“可用数据量计数”,避免消费者读取空缓冲区、或生产者写入满缓冲区(需配合另一个信号量控制“空闲空间”,文档未展开,但核心逻辑一致)。
6. 适用场景总结
结合文档内容,信号量适合以下场景:
- 有限资源共享:如数据库连接池(10个连接)、硬件设备(2个打印机)、租车店车辆等;
- 生产者-消费者模型:如环状缓冲区、消息队列,需控制“可用数据/空闲空间”数量;
- 跨进程同步:需多个进程共享有限资源(如多个程序访问同一批临时文件),通过命名信号量实现。
而Mutex更适合“独占资源访问”(如单一数据结构、全局变量),两者需根据资源数量和访问需求选择,不可互换。
十、事件(Event Objects)与互锁变量(Interlocked Variables)
这两部分是Win32同步机制的重要补充——Event Objects解决“线程间状态通知”问题,Interlocked Variables解决“32位变量原子操作”问题。
一、事件(Event Objects):完全可控的核心同步对象
Event Objects是Win32中最具弹性的同步机制,核心特点是“状态完全由程序控制”,不受Wait...()
函数副作用影响(区别于Mutex、Semaphore),仅用于“通知线程某个条件成立”(如I/O完成、资源就绪)。
1. 核心定位:状态可控的“信号开关”
Event的本质是一个“二元状态开关”,只有两种状态:
- 激发状态(Signaled):等待该Event的线程会被唤醒;
- 非激发状态(Non-Signaled):等待该Event的线程会阻塞。
关键优势:状态变化完全由程序主动控制——Mutex/Semaphore的状态会被Wait
函数自动改变(如Mutex的Wait
会让其变非激发),而Event的状态仅通过SetEvent
/ResetEvent
/PulseEvent
修改,Wait
函数只“检测状态”不“改变状态”,这是其“弹性”的核心来源。
2. 创建Event:CreateEvent
函数与关键参数
通过CreateEvent
创建Event对象,函数原型及参数决定了Event的核心行为:
HANDLE CreateEvent(LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全属性(Win95无效,NULL=默认)BOOL bManualReset, // 重置方式:自动/手动BOOL bInitialState, // 初始状态:激发(TRUE)/非激发(FALSE)LPCTSTR lpName // 名称(跨进程用,NULL=仅当前进程)
);
核心参数解析(决定Event行为的关键):
参数名 | 取值与意义 |
---|---|
bManualReset | - FALSE(AutoReset Event,自动重置):Event被激发后,唤醒一个等待线程,随后自动转为非激发状态(无需程序干预); - TRUE(ManualReset Event,手动重置):Event被激发后,唤醒所有等待线程,保持激发状态,需调用 ResetEvent 手动转为非激发状态。 |
bInitialState | - TRUE:Event创建后初始为激发状态; - FALSE:初始为非激发状态(默认常用)。 |
返回值与错误处理:
- 成功:返回Event的Handle;
- 若
lpName
已存在:返回已有Event的Handle,GetLastError()
传回ERROR_ALREADY_EXISTS
(非错误,仅表示未创建新对象); - 失败:返回
NULL
,需通过GetLastError()
获取原因。
3. Event的核心操作函数
文档明确了3个控制Event状态的函数,需结合“AutoReset/ManualReset”类型区分行为:
函数名 | 功能 | 对AutoReset/ManualReset的差异 |
---|---|---|
SetEvent | 将Event设为激发状态 | - AutoReset:激发后,等待线程被唤醒一个,自动变非激发; - ManualReset:激发后,所有等待线程被唤醒,保持激发直到 ResetEvent 。 |
ResetEvent | 将Event设为非激发状态 | - AutoReset:无效(因AutoReset会自动变非激发,手动调用无意义); - ManualReset:有效(必须调用此函数才能让激发状态的Event变非激发)。 |
PulseEvent | 短暂将Event设为激发,随后自动重置为非激发(“脉冲式”信号) | - AutoReset:激发→唤醒1个线程→自动变非激发; - ManualReset:激发→唤醒所有线程→自动变非激发。 |
4. EVENTTST程序:可视化演示Event行为
文档提到的EVENTTST
程序是理解Event的关键实例,核心逻辑是“3个线程等待1个Event,用户通过按钮控制Event状态”,不同类型的Event表现差异显著:
(1)AutoReset Event(bManualReset=FALSE
)
- 操作【ResetEvent】:无效果(AutoReset本身会自动非激发,手动重置无用);
- 操作【SetEvent】:Event激发→唤醒1个等待线程→自动变非激发;
- 操作【PulseEvent】:Event短暂激发→唤醒1个等待线程→自动变非激发;
- 特点:每次仅唤醒1个线程,避免线程“争抢”。
(2)ManualReset Event(bManualReset=TRUE
)
- 操作【ResetEvent】:Event从激发→非激发(仅手动操作有效);
- 操作【SetEvent】:Event激发→唤醒所有等待线程→保持激发(需手动Reset才变非激发);
- 操作【PulseEvent】:Event激发→唤醒所有等待线程→自动变非激发;
- 特点:适合“通知所有线程某个条件成立”(如“任务开始”“资源就绪”)。
程序额外启示:OS的线程公平性
文档强调:OS会保证等待中的线程“轮番被唤醒”,避免某个线程长期得不到CPU(“饥饿(Starvation)”)——这是所有Win32同步机制的共同特性,确保线程调度的公平性。
5. Event的关键局限性:信号遗失
文档明确Event的致命缺点:无等待线程时,激发信号会遗失——Event不保存“激发历史”,仅当线程正在等待时,激发信号才有效;若先激发Event(如PulseEvent
),再让线程等待,信号会丢失,可能导致死锁。
示例(文档描述的死锁场景):
- 线程A(接收者):检查队列→发生
context switch
; - 线程B(发送者):对Event调用
PulseEvent
(此时无线程等待,信号遗失); - 线程A恢复执行:调用
WaitForSingleObject
等待Event→因信号已遗失,永久阻塞→死锁。
这也是Semaphore能解决此问题的原因:Semaphore通过“计数”保存资源状态(即使无等待线程,计数仍会累积),而Event无计数,仅“即时响应”。
二、互锁变量(Interlocked Variables):轻量的32位变量原子操作
Interlocked Variables是Win32中最轻量的同步机制,无“等待”功能,仅用于保证“32位变量的原子操作”,避免因“非原子的加减/赋值”导致的race condition(如多线程同时修改计数器)。
1. 核心定位:解决“简单变量的race condition”
普通32位变量的“加减/赋值”操作会被编译为多个机器指令(如“读变量→修改→写回”),若线程在中间切换,会导致数据错误(如线程A读计数器=5,线程B也读=5,都加1后写回=6,实际应=7)。
Interlocked Variables通过硬件支持的原子操作,确保“读-改-写”过程不可分割,无需Critical Section/Mutex等重型同步,效率极高。
2. 核心函数:3个原子操作函数
文档介绍了3个核心函数,均针对“32位long变量”(地址需对齐为long word):
(1)InterlockedIncrement
:原子加1
LONG InterlockedIncrement(LPLONG lpTarget); // lpTarget:32位变量地址
- 功能:将
lpTarget
指向的变量原子加1; - 返回值:加1后的变量值(与0比较的结果:>0返回正值,=0返回0,<0返回负值);
- 用途:引用计数增加(如OLE对象的
AddRef()
函数)。
(2)InterlockedDecrement
:原子减1
LONG InterlockedDecrement(LPLONG lpTarget);
- 功能:将
lpTarget
指向的变量原子减1; - 返回值:减1后的变量值(与0比较,同上);
- 用途:引用计数减少(如OLE对象的
Release()
函数)——当返回0时,说明对象无引用,可销毁。
(3)InterlockedExchange
:原子赋值并返回旧值
LONG InterlockedExchange(LPLONG lpTarget, LONG lValue);
- 功能:将
lValue
原子赋值给lpTarget
指向的变量,同时返回变量的旧值; - 用途:原子更新标志位(如“将状态从‘空闲’设为‘忙碌’,并检查旧状态是否为空闲”)。
3. 适用场景与优势
(1)核心适用场景
- 引用计数(Reference Counting):如系统核心对“核心对象Handle”的计数、OLE对象的
AddRef()
/Release()
——需确保计数增减的原子性,避免对象提前销毁或内存泄漏; - 简单标志位更新:如“线程状态(运行/停止)”“资源是否可用”等32位变量的原子修改。
(2)与其他同步机制的区别
对比维度 | Interlocked Variables | Critical Section/Mutex |
---|---|---|
功能 | 仅32位变量的原子操作,无等待机制 | 保护代码块,支持等待(阻塞线程) |
开销 | 极轻(硬件原子操作,无内核/用户态切换) | 较重(Critical Section用户态切换,Mutex内核态切换) |
适用场景 | 简单变量(计数器、标志位) | 复杂代码块、共享资源(链表、文件) |
4. 关键注意事项
- 变量类型限制:仅支持32位
long
变量(地址需对齐为long word,否则可能出错); - 无等待功能:若需“等待变量达到某个值”(如等待计数器=0),需结合Event/Semaphore等机制,Interlocked函数本身无法阻塞线程;
- 硬件依赖:原子操作由CPU硬件支持(如Pentium的
LOCK
指令),确保多CPU(SMP)环境下仍有效。
三、总结:两种机制的选型建议
机制 | 核心用途 | 选型依据 |
---|---|---|
Event Objects | 线程间状态通知(如I/O完成、任务开始/结束) | 需“灵活控制信号”“通知1个/所有线程”,且能接受“信号遗失”风险(或搭配其他机制规避);跨进程同步也适用。 |
Interlocked Variables | 32位变量原子操作(计数器、标志位) | 仅需简单变量的加减/赋值同步,追求轻量高效,无需等待功能。 |
📣 结语
感谢你耐心看完Win32多线程同步控制的核心内容!本章从“为什么需要同步”到“具体同步工具的使用”,一步步带你理解了临界区、死锁、互斥器、信号量、事件和互锁变量的核心逻辑,所有例子和比喻都保留了文档的原貌,希望能帮零基础的你打好基础。
如果你觉得我写的不错,记得给我点赞,收藏 和 关注哦(。・ω・。)
让我们一起加油,向美好的未来奔去。让我们从一无所知的新手逐渐成为专家。为自己点赞吧!