【Win32 多线程程序设计基础第七章笔记】
🌹 作者: 云小逸
🤟 个人主页: 云小逸的主页
🤟 motto: 要敢于一个人默默的面对自己,强大自己才是核心。不要等到什么都没有了,才下定决心去做。种一颗树,最好的时间是十年前,其次就是现在!学会自己和解,与过去和解,努力爱自己。希望春天来之前,我们一起面朝大海,春暖花开!
🥇 专栏:
- WTL学习
- 动态规划
- C 语言
- C++
- Java 语言
- Linux 编程
- 算法
- 待续…
文章目录
- 📚 前言
- 一. 认识volatile关键字:阻止编译器“好心办坏事”
- 1. 编译器优化的“坑”:寄存器里的“过期数据”
- 2. volatile关键字的作用:标记“易变”变量
- 3. volatile的使用实例:从代码看差异
- 实例1:未加volatile的函数(有问题)
- 实例2:加了volatile的函数(正确)
- 4. volatile的进阶用法:与const结合
- 二. Referential Integrity:多线程中的“数据关联性”保护
- 1. 从数据库事务理解“参照完整性”
- 2. 多线程中的“参照完整性”问题
- 例子1:数组附加数据的隐患
- 例子2:删除形状的“指针悬空”问题
- 3. 解决方法:把锁定“提级”
- 三. The Readers/Writers Lock:平衡“读多写少”场景的效率
- 1. 排他锁定的“效率瓶颈”
- 2. 借鉴OpenFile的共享模式
- 3. Readers/Writers Lock的核心算法
- 读锁的获取与释放
- 写锁的获取与释放
- 4. 读写锁的Win32实现:数据结构与核心函数
- 步骤1:定义RWLock数据结构
- 步骤2:初始化读写锁(InitRWLock)
- 步骤3:获取与释放读锁
- 步骤4:获取与释放写锁
- 5. 范例程序:READWRIT的作用
- 四. 我需要锁定吗?:判断锁定的“黄金法则”
- 五. Lock Granularity:锁定粒度的“选择艺术”
- 1. 两种粒度的对比
- 2. 粒度选择建议
- 📣 结语
📚 前言
在Win32多线程程序中,“数据一致性”是绕不开的核心问题。想象一下:多个线程像一群人同时编辑同一份文档,有人改标题、有人删段落、有人加内容,若不加以约束,文档很快会变得混乱——这就是多线程共享数据时的真实场景。本章(《Win32多线程程序设计》第七章)的核心目标,就是教你如何避免这种“混乱”,通过volatile
关键字、Readers/Writers Lock(读写锁)等工具,确保多线程操作数据时的“正确性”和“效率”平衡。即使你是零基础,也能通过本章的比喻和实例,逐步理解多线程数据保护的逻辑。
一. 认识volatile关键字:阻止编译器“好心办坏事”
1. 编译器优化的“坑”:寄存器里的“过期数据”
编译器为了让程序跑得更快,会做一个“优化”:把频繁使用的变量拷贝到CPU的寄存器(相当于你手边的“通讯录”)里——从寄存器读数据比从内存读快得多。但这个“好心”在多线程中会出大问题:
假设线程A把变量count
(存着用户数量)拷贝到寄存器,线程B却修改了内存中count
的值(比如新增了一个用户)。此时线程A的寄存器里,count
还是旧值,就像你通讯录里的电话没更新,打过去找的还是“旧用户”——这就是“数据过期”问题。
单线程中不会有这个问题(编译器能跟踪变量的所有修改),但多线程中编译器无法知道其他线程在做什么,所以必须“阻止”它把共享变量放进寄存器。
2. volatile关键字的作用:标记“易变”变量
volatile
是C/C++的标准关键字(所有编译器都支持),它的核心作用是告诉编译器:这个变量可能被其他线程修改,不要把它的临时拷贝放在寄存器里,每次用都要从内存重新读。
- 适用场景:所有被多个线程共享的变量(比如全局变量、跨线程传递的指针指向的变量)。
- 注意:
volatile
不替代临界区、互斥器等同步工具,它只解决“编译器优化导致的读取过期”问题,不解决“多线程同时修改”的竞争问题。
3. volatile的使用实例:从代码看差异
我们用一个简单的WaitForKey
函数(等待变量pch
从0变成非0),看volatile
的影响:
实例1:未加volatile的函数(有问题)
// 未加volatile:编译器可能把*pch放进寄存器
void WaitForKey(char *pch) {while (*pch == 0); // 循环检查*pch是否为0
}
编译器优化后的汇编代码(关键部分):
; 把pch指向的地址放进eax寄存器
mov eax, DWORD PTR _pch$[esp-4]
; 把*pch的值放进al寄存器(只读一次!)
movsx al, BYTE PTR [eax]
$L84:
; 只检查al寄存器的值(不读内存了!)
test al, al
je SHORT $L84 ; 如果al是0,继续循环
问题:一旦*pch
被其他线程改成非0,线程A的循环还是会继续——因为它只检查寄存器里的旧值。
实例2:加了volatile的函数(正确)
// 加volatile:告诉编译器*pch可能被其他线程修改
void WaitForKey(volatile char *pch) {while (*pch == 0);
}
编译器优化后的汇编代码(关键部分):
; 把pch指向的地址放进eax寄存器(地址不变,只读一次)
mov eax, DWORD PTR _pch$[esp-4]
$L84:
; 每次循环都从内存读*pch的值(不依赖寄存器)
cmp BYTE PTR [eax], 0
je SHORT $L84 ; 如果内存中的值是0,继续循环
正确:每次循环都从内存重新读取*pch
,其他线程修改后能立刻感知。
4. volatile的进阶用法:与const结合
如果一个变量“不能被当前函数修改,但可能被其他线程修改”,可以用const volatile
修饰:
// 函数不能改value,但其他线程可能改
void PrintValue(const volatile int *value) {printf("%d\n", *value); // 每次读都从内存取最新值
}
二. Referential Integrity:多线程中的“数据关联性”保护
1. 从数据库事务理解“参照完整性”
在数据库中,“参照完整性”(Referential Integrity)指“一组改变必须全部完成,否则数据会不一致”。比如:
- 销售数据库里,“发票记录”和“采购项目记录”是关联的——不能只新增发票、不填采购项目,也不能只删采购项目、保留发票。
- 这种“要么全成、要么全不做”的操作叫“事务”(Transaction),数据库会暂存所有修改,直到事务完成才一次性写入,避免中间状态被读取。
2. 多线程中的“参照完整性”问题
多线程程序也有同样的问题:一个操作可能涉及多个数据的修改,若修改到一半被其他线程打断,数据就会“残缺”。
例子1:数组附加数据的隐患
要在数组末尾加一条数据,需要两步:
- 把数据写入数组的空闲位置;
- 把数组的“元素计数”加1。
如果线程A执行完第一步后,线程B读取数组:此时数组里有新数据,但计数没更新——线程B会以为“新数据不存在”,导致错误。
例子2:删除形状的“指针悬空”问题
假设绘图软件有两个线程:
- 线程1(UI线程):管理形状数组,提供
GetShapePointer
(获取形状指针)和DeleteShape
(删除形状)函数; - 线程2(绘图线程):用
GetShapePointer
拿到形状指针后绘图。
若线程2拿到指针后,线程1调用DeleteShape
释放了该形状的内存——线程2的指针就会指向“无效内存”(悬空指针),程序可能崩溃。
3. 解决方法:把锁定“提级”
要保证数据关联性,需要把“分散的小锁定”改成“集中的大锁定”——在修改一组关联数据前,先锁定整个数据结构,直到所有修改完成再释放。
比如之前的AddLineItems
函数(给链表加多个元素),原本每个AddHead
都单独加锁,现在把锁提到AddLineItems
层面:
// 改进前:每个AddHead单独加锁(有漏洞)
void AddLineItems(List *pList) {Node node;while (还有元素要加) {GetLineItem(&node);AddHead(pList, &node); // AddHead里有Enter/LeaveCriticalSection}
}// 改进后:整个循环加锁(无漏洞)
void AddLineItems(List *pList) {Node node;// 锁定整个链表,直到所有元素加完EnterCriticalSection(&pList->critical_sec);while (还有元素要加) {GetLineItem(&node);AddHead(pList, &node); // 不用再单独加锁}LeaveCriticalSection(&pList->critical_sec);
}
三. The Readers/Writers Lock:平衡“读多写少”场景的效率
1. 排他锁定的“效率瓶颈”
之前学的“临界区”“互斥器”都是“排他锁定”——不管是读还是写,同一时间只能有一个线程操作。但实际场景中,“读操作”往往比“写操作”多(比如多人读同一篇文章,很少人修改),此时排他锁定会让所有读线程排队,效率极低。
就像图书馆:如果不管借书(读)还是改书(写),都只允许一个人进,读者会怨声载道。合理的做法是:多个读者可以同时进,写者只能在没人读/写时进。
2. 借鉴OpenFile的共享模式
Win16的OpenFile
函数有三种共享模式,正好对应这种需求:
OF_SHARE_EXCLUSIVE
:排他模式(读写都阻止)——对应“写操作”;OF_SHARE_DENY_WRITE
:阻止写、允许读——对应“读操作”;OF_SHARE_DENY_READ
:阻止读、允许写——对应“写操作”。
Readers/Writers Lock(读写锁)就是实现这种逻辑的工具:
- 多个读线程可以同时持有“读锁”;
- 只有一个写线程可以持有“写锁”,且持有期间不允许任何读线程。
3. Readers/Writers Lock的核心算法
读写锁的实现基于“计数器+信号量”,核心逻辑(伪码)如下:
读锁的获取与释放
// 获取读锁
Lock(ReaderMutex); // 保护读计数器,避免多个线程同时改
ReadCount = ReadCount + 1;
if (ReadCount == 1) {Lock(DataSemaphore); // 第一个读者锁定数据,阻止写者
}
Unlock(ReaderMutex);// 释放读锁
Lock(ReaderMutex);
ReadCount = ReadCount - 1;
if (ReadCount == 0) {Unlock(DataSemaphore); // 最后一个读者释放数据,允许写者
}
Unlock(ReaderMutex);
写锁的获取与释放
// 获取写锁:直接锁定数据,阻止所有读/写
Lock(DataSemaphore);// 释放写锁:释放数据,允许读/写
Unlock(DataSemaphore);
4. 读写锁的Win32实现:数据结构与核心函数
步骤1:定义RWLock数据结构
需要三个关键成员:
hMutex
:保护ReadCount
的互斥器(避免多个线程同时修改计数器);hDataLock
:控制数据访问的信号量(初始值1,最大值1,类似互斥器);nReaderCount
:读线程计数器(记录当前有多少个读线程)。
typedef struct _RWLock {HANDLE hMutex; // 保护nReaderCount的互斥器HANDLE hDataLock; // 控制数据访问的信号量int nReaderCount; // 读线程计数器
} RWLock;
步骤2:初始化读写锁(InitRWLock)
BOOL InitRWLock(RWLock *pLock) {pLock->nReaderCount = 0;// 创建信号量:初始值1,最大值1(允许一个线程锁定)pLock->hDataLock = CreateSemaphore(NULL, 1, 1, NULL);if (pLock->hDataLock == NULL) return FALSE;// 创建互斥器:保护nReaderCountpLock->hMutex = CreateMutex(NULL, FALSE, NULL);if (pLock->hMutex == NULL) {CloseHandle(pLock->hDataLock); // 失败时清理资源return FALSE;}return TRUE;
}
步骤3:获取与释放读锁
// 获取读锁
BOOL AcquireReadLock(RWLock *pLock) {BOOL result = TRUE;// 先锁定互斥器,才能修改nReaderCountif (!MyWaitForSingleObject(pLock->hMutex)) return FALSE;// 第一个读线程需要锁定数据信号量if (++pLock->nReaderCount == 1) {result = MyWaitForSingleObject(pLock->hDataLock);}ReleaseMutex(pLock->hMutex); // 释放互斥器return result;
}// 释放读锁
BOOL ReleaseReadLock(RWLock *pLock) {int result;LONG lPrevCount;if (!MyWaitForSingleObject(pLock->hMutex)) return FALSE;// 最后一个读线程释放数据信号量if (--pLock->nReaderCount == 0) {result = ReleaseSemaphore(pLock->hDataLock, 1, &lPrevCount);}ReleaseMutex(pLock->hMutex);return result;
}
步骤4:获取与释放写锁
// 获取写锁:直接锁定数据信号量
BOOL AcquireWriteLock(RWLock *pLock) {return MyWaitForSingleObject(pLock->hDataLock);
}// 释放写锁:释放数据信号量
BOOL ReleaseWriteLock(RWLock *pLock) {int result;LONG lPrevCount;result = ReleaseSemaphore(pLock->hDataLock, 1, &lPrevCount);// 检查信号量是否原本被锁定(避免误释放)if (lPrevCount != 0) {FatalError("ReleaseWriteLock - 信号量未被锁定!");}return result;
}
5. 范例程序:READWRIT的作用
文档中的READWRIT
程序是读写锁的实战案例:
- 包含
LIST.C
(用读写锁保护的链表); - 启动4个线程:2个读线程、1个写线程、1个删除线程;
- 运行后可观察:多个读线程同时读,写线程只有在无读线程时才执行。
四. 我需要锁定吗?:判断锁定的“黄金法则”
很多新手会纠结“什么时候该加锁”,以下是文档给出的核心指导方针(零基础也能直接用):
- 不确定就加锁:多线程程序中,“过度锁定”顶多影响效率,“漏加锁”会导致数据错乱,调试难度极高;
- 多线程共享数据必加锁:只要多个线程会“使用”同一数据(读、写、比较、修改都算),就必须保护;
- 基础类型用Interlocked函数:如果是32位及以下的基础类型(如
int
、DWORD
),且只是简单的“加1/减1”,可以用InterlockedIncrement
/InterlockedDecrement
函数,不用加锁; - 复杂数据用数据库/专用工具:如果数据关联复杂(比如多表关联),可以用支持多线程的数据库(如SQL Server),它自带事务和锁定机制;
- 单线程专属数据不加锁:如果数据只在一个线程中创建、修改、销毁,其他线程完全不碰,就不用加锁。
五. Lock Granularity:锁定粒度的“选择艺术”
“锁定粒度”指“一次锁定的数据范围大小”,分两种极端:
- 粗粒度锁定(Coarse Granularity):一次锁定整个数据结构(比如锁定整个链表);
- 细粒度锁定(Fine Granularity):一次锁定数据结构的一小部分(比如锁定链表的一个节点)。
1. 两种粒度的对比
特性 | 粗粒度锁定(Coarse Granularity) | 细粒度锁定(Fine Granularity) |
---|---|---|
使用难度 | 简单(只需一个锁) | 复杂(多个锁,易出错) |
死锁风险 | 极低(锁少,依赖关系简单) | 极高(锁多,易形成循环依赖) |
效率瓶颈 | 易出现(所有线程等一个锁) | 较少(锁不冲突时可并行) |
锁定开销 | 低(锁的创建/销毁少) | 高(锁多,切换频繁) |
2. 粒度选择建议
- 新手从粗粒度开始:先锁定整个数据结构,保证程序正确运行;
- 逐步优化为细粒度:当发现某个锁成为效率瓶颈(比如读线程排队严重),再拆分成多个细粒度锁;
- 避免过度拆分:比如给10000个CAD形状各加一个锁,会导致代码复杂到无法维护;
- 提前预防死锁:用细粒度锁时,要规定锁的获取顺序(比如先锁节点A再锁节点B),避免循环等待。
📣 结语
本章从“编译器优化的小坑”(volatile)讲到“多线程数据关联的大坑”(参照完整性),再到“读多写少的效率方案”(读写锁),最后给出“锁定判断和粒度选择”的实战建议——核心逻辑只有一个:多线程数据保护的本质是“平衡正确性和效率”。
如果你是零基础,建议先从volatile
和“粗粒度锁定”练手,比如用临界区保护一个共享数组;熟练后再尝试读写锁,比如实现一个支持多用户读、单用户写的日志系统。
记得给本文点赞、收藏,后续还会更新Win32多线程的其他章节。跟着实例敲代码,你会发现多线程数据一致性没那么难!