复习
内核对象
内核对象本质是一个内核层的结构体,只能使用 Windows 提供的API操作结构体内容
内核对象的特性
操作内核对象需要使用句柄,每个进程都有一张句柄表保存自己的句柄
大多数内核对象在操作时都需要提供指定的安全描述符(安全属性)
内核对象的全局性,不同的进程可以通过 id 或名称打开同一个内核对象
内核对象的引用计数,每个内核对象都有引用计数,当引用计数为0,内核对象会被销毁,
CloseHandle
的作用是将引用计数减1进程:是内核对象,通常由一个可执行文件产生,最少由一块4GB的虚拟空间,一个进程内核对象,一个线程内核对象和需要用到的模块组成
线程:是内核对象,用于执行代码,线程间没有从属关系,但把一个进程的第一个线程称为主线程,主线程一旦退出,整个程序就会退出,线程最少由一个线程内核对象和一个线程的栈帧组成
线程的基本操作:
1 |
|
线程同步
线程同步问题
在多线程编程中,极易产生错误,原因是:
- 多个线程同时访问了共有的资源(如全局变量、句柄、堆空间等),造成资源在不同线程中修改时出现不一致,出现访问错误
- 多个线程对于资源的访问需要按照一定的先后顺序,但未按照预想的顺序来,导致程序出错
1 |
|
结果不是200000
由于单条 g_Number++
被翻译成了三条汇编指令,若不能保证三条指令连续执行,就会由于线程的切换产生问题,最终结果出错
1 | mov eax,dword ptr [g_Number (07AA138h)] |
原子操作
原子操作本质就是将C语言代码解释成单条汇编指令,一个线程对某个资源操作时保证没有其他线程能够对此资源进行访问
缺陷:只支持对最长8字节的整数类型执行算数运行
应用场景:在进行内联 hook 时可以解决线程安全问题
常见操作:
InterlockedIncrement | 给一个整型变量自增1 |
---|---|
InterlockedExchangeAdd | 为一个整型变量以原子方式加上一个数 |
InterlockedExchange | 将一个32位数以原子方式赋值给另一个数 |
InterlockedExchange64 | 将一个64位数以原子方式赋值给另一个数 |
InterlockedCompareExchange | 若两数相等,就将另一个数赋值,不相等则无效 |
以上操作全都是作为一个执行单元来做的,基本上都是对于变量的算数运算
1 | long g_Number = 0; |
临界区
临界区(关键段)是一个结构体,通过结构体内的一些字段判断执行当前代码的线程是否是对应的线程,若不是就阻塞
优点:可以保护一段代码,执行速度快
缺点:拥有该临界区的线程一旦崩塌,就会产生死锁
临界区具有线程所有权这个概念,必须进入临界区的线程,调用离开临界区,临界区才会被打开。假如加锁的线程崩溃了,其他线程就锁死了。
临界区结构体是一个不确定的结构体,使用前必须调用 InitializeCriticalSection
初始化,使用完后需调用 DeleteCriticalSection
销毁临界区
void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection) |
---|
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection) |
void EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection) |
void LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection) |
1 | int g_Number = 0; |
1 | //主函数中还要初始化和销毁临界区 |
等待函数
等待函数可以等待一切可等待的内核对象,可等待的内核对象有2个状态,激发态和非激发态
等待函数的作用是使一个线程进入到等待状态,直到指定的内核对象被触发为止
等待函数的副作用:改变被等待内核对象的信号状态(有信号 -> 无信号),基于此原理,才能实现后面的内核对象同步
函数原型:
1 | DWORD WINAPI WaitForSingleObject( |
1 |
|
互斥体
互斥体是一个内核对象
互斥体也具有线程所有权的概念,得到互斥体的线程,需要自己去释放互斥体。谁加锁,谁开锁。如果得到互斥体的线程崩溃了,互斥体会立即变为激发态。所有等待互斥体的线程中会立即有线程得到互斥体。不会造成死锁的问题
优点:拥有临界区的特性(线程拥有者),但不会产生死锁,且跨进程
缺点:慢
应用场景:用于防双开
互斥体的内容:
- 两个状态,激发态(有信号)和非激发态(无信号)
- 一个概念,线程拥有权,与临界区类似
- 等待函数等待互斥体的副作用,将互斥体的拥有者设为本线程,将互斥体的状态设为非激发态
函数 | 作用 | 备注 |
---|---|---|
CreateMutex | 创建互斥体 | 可以给互斥体起名字 |
OpenMutex | 打开互斥体,得到句柄 | 根据名字才能打开互斥体 |
ReleaseMutex | 释放互斥体 | 会使得互斥体处于激发态 |
CloseHandle | 关闭句柄 | 使用完后关闭 |
WaitForSignalObject | 等待互斥体处于激发态 | 等到激发态后,会使得互斥体再次处于非激发态 |
1 |
|
信号量
互斥:通常是多个进程访问同一个资源
同步:通常是多个线程按照指定顺序执行
信号量是一个内核对象
特点:可以进行多次上锁操作
缺点:慢
应用场景:控制同时执行的线程的最大个数
信号量通常不会单独使用,一般要结合互斥体或事件
只要信号数不为0,那么就处于激发态
函数 | 作用 | 备注 |
---|---|---|
CreateSemaphore | 创建信号量 | 可以给信号量起名字 可以指定最大信号数和当前信号数 |
OpenSemaphore | 打开信号量 | 根据名字才能打开信号量 |
ReleaseSemaphore | 释放信号量 | 会增加信号量的信号数,但是不会超过最大信号数 |
WaitForSignalObject | 等待信号量处于激发态 | 若处于激发态,则会减少1个信号数,信号数位0,将其置为非激发态 |
1 |
|
事件
事件是一个内核对象
事件,没有线程所有权的概念,任何线程都可以释放事件
特点:可手动操作,也可设置为自动操作
缺点:慢
函数 | 作用 | 备注 |
---|---|---|
CreateEvent | 创建事件 | 可以给事件起名字 可以设置两种模式:手工 自动 |
OpenEvent | 打开事件,得到句柄 | 根据名字才能打开事件 |
SetEvent | 释放事件 | 会使得事件处于激发态 |
ResetEvent | 重置事件 | 会使得事件处于非激发态,对手工模式的事件有效 |
WaitForSignalObject | 等待事件处于激发态 | 等到激发态后,对于自动模式的事件会使其再次处于非激发态 |
1 |
|
总结:
原子操作,只能保证对于基本算数操作是原子性的
临界区和互斥体从词语的含义上看,他们主要就是为了解决互斥问题
临界区的优点是快,互斥体的优点是能够跨进程访问,崩溃不死锁
事件从词语的含义上看,更适合做通知(产生了一个事件)。比较适合解决有先后顺序的多线程问题
事件和互斥体的最大区别,就是线程所有权。互斥体谁上锁,谁开锁。事件没有这个要求
信号量,由于存在信号数的问题,比较适合解决多线程的协调问题。典型问题,就是生产者消费者问题
实例
事件和信号量更适合解决有序的问题。因为他们不要求谁上锁,谁开锁
用代码实现一个读文件线程,一个写文件线程,实现先写后读,两个线程都结束之后,主线程才结束,这种没有过多线程同时访问的有顺序的问题,比较适合用事件来解决
1 |
|
多个信号数的信号量比较适合解决多个线程间有顺序需要协调的问题, 最为经典的就是生产者消费者问题
关键点有2个:1. 必须有一个队列,可以有数量限制,也可以没有数量限制。2. 每一个生产者是一个线程,每一个消费者是一个线程,队列满了,生产者需要等待,队列空了,消费者需要等待
整个问题是多线程并发时的协调问题
1 | //生产者先生产食物,并将消费者变为非激发态,使自己为激发态 |