线程同步
- 同一进程或者同一线程可以生成许多不同的子线程来完成规定的任务,但是多个线程同时运行的情况下可能需要对某个资源进行读写访问,比如以下这个情况:创建两个线程对同一资源进行访问,最后打印出这个资源,运气好一点的情况下得到的值应该为 2,运气不好的情况下就会变为 1
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
DWORD WINAPI ThreadCommunication();
unsigned __stdcall ThreadFun1(void* pvParam);
unsigned __stdcall ThreadFun2(void* pvParam);
// 共享资源
int Statistics = 0;
int main(int argc, char *argv[])
{
ThreadCommunication();
return 0;
}
DWORD WINAPI ThreadCommunication()
{
// 创建线程
HANDLE myThread1 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun1, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread1);
HANDLE myThread2 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun2, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread2);
// 等待线程退出
HANDLE Threads[2] = { 0 }; Threads[0] = myThread1; Threads[1] = myThread2;
WaitForMultipleObjects(2, Threads, TRUE, INFINITE);
// 关闭线程句柄
CloseHandle(myThread1); CloseHandle(myThread2);
// 最后打印计数
cout << "Statistics: " << Statistics << endl;
return TRUE;
}
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 做一些事情,完成之后计数加一
Statistics++;
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 做一些事情,完成之后计数加一
Statistics++;
return TRUE;
}
- 这个就是运气不好的情况,原因就是多线程运行时对同一资源的访问是竞争性的,线程 1 访问资源的时候线程 2 可能被堵塞。那么怎么让线程进行对同一资源有顺序的访问呢,微软并没有提供这样的方法可以让第一个线程先运行或者当第二个线程先运行(Windows系统并不是实时操作系统,对线程按照算法公平的原则进行调度,是随机的),但是微软提供了一些方法可以让线程同步运行,保证资源的按顺序访问
- 线程同步逻辑图
0x01 原子访问
- 原子访问是解决线程冲突的第一种方法,原子访问顾名思义就是按原子的方式访问资源并且保证访问资源不冲突。那么上面的代码可以改为这样:
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 做一些事情,完成之后计数加一
InterlockedExchangeAdd(&Statistics, 1);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 做一些事情,完成之后计数加一
InterlockedExchangeAdd(&Statistics, 1);
return TRUE;
}
- InterlockedExchangeAdd 的第一个参数可以传入一个 long 型变量,第二个参数传入想要加的值(只可以是整数,需要减的话传负数即可),该函数是以原子方式操作,所以解决了访问冲突的问题;如果嫌麻烦可以使用 InterlockedIncrement 函数直接将变量加一
- 但这些 Interlock 函数存在一个缺点,只能加减固定的整数,所以不够灵活(总不能在使用函数之前花时间做运算吧),所以出现了升级版函数 InterlockedExchange,这个函数可以直接把变量以原子方式改为我们想要的值,当然了这个是 32 位的,如果需要 64 位的可以使用 InterlockedExchange64 这个函数
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 做一些事情, 之后将 Statistics 的值变为 1
InterlockedExchange(&Statistics, 1);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 做一些事情, 之后将 Statistics 的值变为 2
InterlockedExchange(&Statistics, 2);
return TRUE;
}
- 除了以原子方式加减或改变一个变量,微软还给出了比较方便的比较函数
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 做一些事情
InterlockedExchange(&Statistics, 1);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 做一些事情
InterlockedCompareExchange(&Statistics, Statistics + 1, 1);
return TRUE;
}
- 上面代码的意义是如果线程 1 先运行并且将 Statistics 的值设置为 1 的话,线程 2 就将现有的 Statistics 的值加 1
0x02 关键段和旋转锁
- 纵观单用原子的方式解决线程同步确实有很大的局限性,第一:对资源变量只能以整数的方式进行操作;第二:只能更改变量,不能做到更改其他资源,比如 IO 读写等。所以出现了解决线程同步的第二种方法关键段和旋转锁
- 关键段顾名思义就是在这个代码段中,代码访问的所有资源都是按原子方式进行的,防止线程之间的访问冲突,实现关键段的方式也很简单:
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
DWORD WINAPI ThreadCommunication();
unsigned __stdcall ThreadFun1(void* pvParam);
unsigned __stdcall ThreadFun2(void* pvParam);
// 共享资源
int Statistics = 0;
// 创建供关键段使用的结构体
CRITICAL_SECTION Critical;
int main(int argc, char *argv[])
{
ThreadCommunication();
return 0;
}
DWORD WINAPI ThreadCommunication()
{
// 初始化结构体
InitializeCriticalSection(&Critical);
// 创建线程
HANDLE myThread1 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun1, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread1);
HANDLE myThread2 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun2, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread2);
// 等待线程退出
HANDLE Threads[2] = { 0 }; Threads[0] = myThread1; Threads[1] = myThread2;
WaitForMultipleObjects(2, Threads, TRUE, INFINITE);
// 关闭线程句柄
CloseHandle(myThread1); CloseHandle(myThread2);
// 清除关键段结构体
DeleteCriticalSection(&Critical);
// 最后打印计数
cout << "Statistics: " << Statistics << endl;
return TRUE;
}
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 关键段开始
EnterCriticalSection(&Critical);
// 做一些事情
for (size_t i = 0; i < 1000; i++)
{
Statistics++;
}
// 关键段结束
LeaveCriticalSection(&Critical);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 关键段开始
EnterCriticalSection(&Critical);
// 做一些事情
for (size_t i = 0; i < 1000; i++)
{
Statistics++;
}
// 关键段结束
LeaveCriticalSection(&Critical);
return TRUE;
}
- 需要注意的是在将结构体传入 EnterCriticalSection 或者 LeaveCriticalSection 函数之前必须使用 InitializeCriticalSection 函数对 CRITICAL_SECTION 结构体进行初始化。共享变量访问结束之后记得使用 DeleteCriticalSection 函数清除 CRITICAL_SECTION 结构体
注:任何线程都可以使用 LeaveCriticalSection 和 EnterCriticalSection 函数开辟关键段来防止对共享资源的访问冲突。但这里有一个问题就是如果一个线程抢到了对某一个共享资源的 CPU 访问时间,那么其它线程在想访问这一个共享资源是就会被阻塞,也就是会一直等待,所以为了节省宝贵的 CPU 时间,微软给出了 TryEnterCriticalSection 函数来解决以上问题,如果共享资源被别的线程访问,那么 TryEnterCriticalSection 函数就会直接返回 FALSE 而不是等待,假如共享资源并没有被其他线程访问,那么 TryEnterCriticalSection 函数就会返回 TRUE 并且更新 CRITICAL_SECTION 的成员变量,当然之后需要调用 LeaveCriticalSection 函数来释放CRIRICAL_SECTION 结构体变量
- 旋转锁是基于原子访问的技术,简单来说就是利用更为复杂的原子访问实现旋转锁:
long res = FALSE;
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 使用旋转锁
while (InterlockedExchange(&res, TRUE) == TRUE)
Sleep(0);
// 做一些事情
InterlockedExchange(&res, FALSE);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 使用旋转锁
while (InterlockedExchange(&res, TRUE) == TRUE)
Sleep(0);
// 做一些事情
InterlockedExchange(&res, FALSE);
return TRUE;
}
- 如果线程 1 先运行,那么 InterlockedExchange 会将 res 的值变为 TRUE,并且返回值为 TRUE,之后线程 1 就可以做它的工作了和访问共享的资源了;如果这时线程 2 运行,使用 InterlockedExchange 修改 res 为 TRUE 就会返回 FALSE(因为 res 已经为 TRUE)
注:关键段的使用需要注意死锁这个问题
0x03 读写锁和条件变量
- 读写锁类似于关键段,也是在保护对共享资源的访问,具体实现如下
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
DWORD WINAPI ThreadCommunication();
unsigned __stdcall ThreadFun1(void* pvParam);
unsigned __stdcall ThreadFun2(void* pvParam);
// 共享资源
int Statistics = 0;
// 读写锁结构体
SRWLOCK Srwlock;
int main(int argc, char *argv[])
{
ThreadCommunication();
return 0;
}
DWORD WINAPI ThreadCommunication()
{
// 使用 InitalizeSRWLock 函数初始化结构体
InitializeSRWLock(&Srwlock);
// 创建线程
HANDLE myThread1 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun1, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread1);
HANDLE myThread2 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun2, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread2);
// 等待线程退出
HANDLE Threads[2] = { 0 }; Threads[0] = myThread1; Threads[1] = myThread2;
WaitForMultipleObjects(2, Threads, TRUE, INFINITE);
// 关闭线程句柄
CloseHandle(myThread1); CloseHandle(myThread2);
// 最后打印计数
cout << "Statistics: " << Statistics << endl;
return TRUE;
}
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
// 写入这线程
AcquireSRWLockExclusive(&Srwlock);
// 做一些事情
Statistics++;
ReleaseSRWLockExclusive(&Srwlock);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
// 读取者线程
AcquireSRWLockShared(&Srwlock);
// 做一些事情
Statistics++;
ReleaseSRWLockShared(&Srwlock);
return TRUE;
}
- 与关键段不同的是,读写锁并没有提供函数来取消等待,而关键段的 TryEnterCriticalSection 函数则可以;但是读写锁也有便于控制线程访问资源的好处,比如控制一些线程只读共享资源,另一些线程更新共享资源,增加了程序的可伸缩性;权衡利弊还是优先使用关键段
- 对于线程同步的最后一种方式就是条件变量了,想象这样一种场景:某一个线程的功能是载入用于输入的一个文件,如果用户已经输入了这个文件的路径,那么载入它;如果用户还没有输入,那么线程进入等待状态,直到用户进行了输入。那么就利用条件变量实现它吧
#include <Windows.h>
#include <iostream>
#include <process.h>
using namespace std;
DWORD WINAPI ThreadCommunication();
unsigned __stdcall ThreadFun1(void* pvParam);
unsigned __stdcall ThreadFun2(void* pvParam);
// 读写锁结构体
SRWLOCK Srwlock;
int main(int argc, char *argv[])
{
ThreadCommunication();
return 0;
}
DWORD WINAPI ThreadCommunication()
{
// 使用 InitalizeSRWLock 函数初始化结构体
InitializeSRWLock(&Srwlock);
// 创建线程
HANDLE myThread1 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun1, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread1);
HANDLE myThread2 = (HANDLE)_beginthreadex(NULL, NULL, &ThreadFun2, NULL, CREATE_SUSPENDED, NULL); ResumeThread(myThread2);
// 等待线程退出
HANDLE Threads[2] = { 0 }; Threads[0] = myThread1; Threads[1] = myThread2;
WaitForMultipleObjects(2, Threads, TRUE, INFINITE);
// 关闭线程句柄
CloseHandle(myThread1); CloseHandle(myThread2);
return TRUE;
}
// 模拟判断用户是否输入
BOOL IsFit = FALSE;
// 条件变量结构体
CONDITION_VARIABLE Condition;
// 线程一
unsigned __stdcall ThreadFun1(void* pvParam)
{
AcquireSRWLockExclusive(&Srwlock);
// 模拟用户输入
Sleep(300);
// 输入完成之后将 IsFit 变为 TRUE,并且唤醒等待线程
IsFit = TRUE;
WakeConditionVariable(&Condition);
ReleaseSRWLockExclusive(&Srwlock);
return TRUE;
}
// 线程二
unsigned __stdcall ThreadFun2(void* pvParam)
{
AcquireSRWLockExclusive(&Srwlock);
// 如果用户还没有输入则进入等待状态,等待用户输入
if (IsFit == FALSE)
{
cout << "[-] 等待用户输入: " << endl;
SleepConditionVariableSRW(&Condition, &Srwlock, INFINITE, 0);
}
cout << "[*] 用户输入完毕 " << endl;
// 根据用户输入载入文件...
ReleaseSRWLockExclusive(&Srwlock);
return TRUE;
}
- 如果线程二执行前,用户已经输入并且变量 IsFit 的值已经变为 TRUE,那么没有问题,按照流程载入文件即可;如果线程二执行前用户还没有输入,那么该线程就会进入等待状态直到用于输入完成之后才开始往下执行载入文件的操作
用户下的线程用户研究总结到此完毕,如有错误欢迎指正
参考资料:Windows 核心编程