线程安全问题
每个线程都有自己独立的堆栈,局部变量是存储在栈中的,这就意味着每个线程都会有一份自己的局部变量,当线程仅仅访问自己的局部变量时就不存在线程安全问题。但是全局变量是存储在全局区的,多线程共享全局变量,当多个线程共用一个全局变量进行非读行为时就会存在线程安全问题
如下所示代码,我们创建了两个线程并写了一个线程函数,该函数的作用就是使用全局变量,模拟售卖物品。全局变量countNumber表示该物品的总量,其值是10。如果有多个地方(线程)去卖(使用)这个物品(全局变量)时,就会出现差错:
#include <windows.h>
int countNumber = 10;
DWORD WINAPI ThreadProc(LPVOID lpParameter) {
while (countNumber > 0) {
printf("Sell num: %d\n", countNumber);
// 售出-1
countNumber--;
printf("Count: %d\n", countNumber);
}
return 0;
}
int main(int argc, char* argv[])
{
HANDLE hThread;
hThread = CreateThread(NULL, NULL, ThreadProc, NULL, 0, NULL);
HANDLE hThread1;
hThread1 = CreateThread(NULL, NULL, ThreadProc, NULL, 0, NULL);
CloseHandle(hThread);
getchar();
return 0;
}
如图,我们运行了代码,发现会出现重复售卖,并且到最后总数竟变成了-1。
这是因为多线程在执行代码的时候是可以随时切换线程的,而不是等一个线程执行完毕以后再切换另一个线程执行。只有一个线程执行完毕以后再去执行另一个线程,这样才是线程安全。
为实现线程安全,有以下几种方法
临界区
一次只允许一个线程使用的资源叫做临界资源,而访问临界资源的程序,称之为临界区。通过临界区便可以很好的解决线程安全问题。
假设有一个全局变量令牌,线程只有获取了这个令牌,才能访问全局变量X。当线程1获取了这个令牌时,令牌的值修改为0,表该令牌已经被线程1所有,然后线程1会执行代码去访问全局变量X,最后归还令牌,令牌的值修改为1。在这个过程中,其他线程会根据令牌的值判断是否可以获取令牌进而判断是否可以访问全局变量X
线程锁
当临界资源是用户级资源时,可以使用线程锁实现临界区。通过线程锁我们可以解决线程安全问题,其步骤如下所示:
1.创建全局变量:CRITICAL_SECTION cs;
2.初始化全局变量:InitializeCriticalSection(&cs);
3.实现临界区:进入 → EnterCriticalSection(&cs); 离开 → LeaveCriticalSection(&cs);
我们就可以这样改写之前的售卖物品的代码,在使用全局变量开始前构建并进入临界区,使用完之后离开临界区:
#include <windows.h>
#include<iostream>
CRITICAL_SECTION cs; // 创建全局变量
int countNumber = 10;
DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
EnterCriticalSection(&cs); // 进入临界区,获取令牌
while(countNumber > 0)
{
printf("Thread: %d\n", *((int*)lpParameter));
printf("Sell num: %d\n", countNumber);
// 售出-1
countNumber--;
printf("Count: %d\n", countNumber);
}
LeaveCriticalSection(&cs); // 离开临界区,归还令牌
return 0;
}
int main(int argc, char* argv[])
{
InitializeCriticalSection(&cs); // 使用之前进行初始化
int a = 1;
HANDLE hThread;
hThread = CreateThread(NULL, NULL, ThreadProc, (LPVOID)&a, 0, NULL);
int b = 2;
HANDLE hThread1;
hThread1 = CreateThread(NULL, NULL, ThreadProc, (LPVOID)&b, 0, NULL);
CloseHandle(hThread);
CloseHandle(hThread1);
getchar();
return 0;
}
此时我们发现线程是安全的,只有线程1在执行
互斥体
我们在前文学习的线程锁只能控制用户级的内核资源,其只能控制同一个进程的多个线程共享临界资源。内核级资源可以跨进程共享,当有多个进程的线程同时去访问内核级资源时,线程锁显然不合适,这就需要互斥体了。
当进程创建一个已存在的同名的互斥体时,它并不会创建一个新的互斥体,而是会返回已存在的同名的互斥体句柄,这些进程共享同一个互斥体。
当进程创建互斥体失败时,会返回NULL
比如我们把令牌放到进程B的应用层,那么进程A就无法访问。为保证内核级临界资源的安全,我们需要一个能够放在内核中的令牌来控制,而这是互斥体。
接下来我们尝试使用互斥体:
我们创建两个如下代码的进程
#include <windows.h>
#include<iostream>
int main(int argc, char* argv[])
{
// 创建互斥体(令牌),起始为有信号的状态
HANDLE cm = CreateMutex(NULL, FALSE, "XYZ");
// 等待获取令牌,即互斥体有信号或该进程是线程拥有者
// 当我们设置互斥体起始为无信号时,运行本进程,再运行其他进程,其他进程由于互斥体无信号,所以无法访问临界资源。但本进程由于是该线程的拥有者,因此尽管互斥体无信号,它仍然以后获取临界资源
WaitForSingleObject(cm, INFINITE);
// 操作资源
// 内核资源为屏幕,printf打印输出到屏幕,占有内核资源
for (int i = 0; i < 5; i++) {
printf("Process: A Thread: B -- %d \n", i);
Sleep(1000);
}
// 释放互斥体(令牌)
ReleaseMutex(cm);
return 0;
}
运行程序以后,我们发现只有在其中一个进程执行完毕以后,另一个进程才会继续执行
互斥体和线程锁的区别
1.线程锁只能用于单个进程间的线程控制
2.互斥体可以设定等待超时,但线程锁不能
3.当线程意外结束,互斥体没有释放时,互斥体可以避免无限等待,另一个线程可以访问临界资源
4.互斥体效率没有线程锁高
事件
事件本身可以做为通知类型来使用,创建事件使用函数CreateEvent,其语法格式如下:
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // SD 安全属性,包含安全描述符
BOOL bManualReset, // 事件类型:FALSE表示互斥,TRUE表示通知
BOOL bInitialState, // 初始信号状态:TRUE为有信号,FALSE为无信号
LPCTSTR lpName // 事件昵称,只在本进程使用可以不命名
);
接下来我们通过代码观察事件有什么特性
#include <windows.h>
#include<iostream>
HANDLE e_event;
DWORD WINAPI ThreadProc(LPVOID lpParameter) {
// 等待事件
WaitForSingleObject(e_event, INFINITE);
printf("ThreadProc - running ...\n");
getchar();
return 0;
}
DWORD WINAPI ThreadProcB(LPVOID lpParameter) {
// 等待事件
WaitForSingleObject(e_event, INFINITE);
printf("ThreadProcB - running ...\n");
getchar();
return 0;
}
int main(int argc, char* argv[])
{
// 创建事件
e_event = CreateEvent(NULL, TRUE, FALSE, NULL);
// 创建2个线程
HANDLE hThread[2];
hThread[0] = CreateThread(NULL, NULL, ThreadProc, NULL, 0, NULL);
hThread[1] = CreateThread(NULL, NULL, ThreadProcB, NULL, 0, NULL);
// 设置事件为已通知,也就是设置为有信号
SetEvent(e_event);
// 等待两个线程执行结束,销毁内核对象
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
CloseHandle(hThread[0]);
CloseHandle(hThread[1]);
// 事件类型也是内核对象,所以也需要关闭句柄
CloseHandle(e_event);
return 0;
}
如下图所示,运行程序以后,两个线程都执行了,而如果是之前我们使用互斥体的话,线程A会先执行,执行结束以后线程B才会执行。
注意:我们在线每个程函数的最后都使用了getchar()用于阻止了线程执行结束,但是两个线程还是都执行了:
我们修改下创建事件类型为互斥,重新运行程序进行观察:
我们发现,这次只有一个线程运行了
接下来我们开始了解通知类型的作用,这实际上和WaitForSingleObject函数有关:
WaitForSingleObject会等待内核对象状态的修改,当事件对象为通知类型时该函数就不会去等待修改对象的状态,当事件对象为互斥类型时该函数就会等待去修改对象的状态。这就是前文程序运行两个结果的原因了
信号量
信号量是一个内核对象,创建信号量使用函数CreateSemaphore:
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, //安全属性
LONG lInitialCount, //当前资源计数:当前可用资源
LONG lMaximumCount, //最大资源数:信号量可以控制的最大资源
LPCTSTR lpName ); //信号量的名字,只在本进程使用时可以NULL
每个内核对象都有两种状态:未通知状态和已通知状态。以线程为例:当线程正在运行时,它是未通知状态,即无信号。当线程运行完毕时,它是已通知状态,即有信号。
信号量规则:
1.当前资源计数大于0时,信号量处于已通知状态
2.当前资源计数等于0,信号量处于未通知状态
3.lInitialCount >= 0
4.lInitialCount <= lMaximumCount
简单的说:信号量的lMaximumCount表示最大允许线程同时运行的数量,而lInitialCount表示当前允许添加新的线程同时运行的数量。当lInitialCount为0时,表示当前不允许有新的线程运行,当lInitialCount>0时,表示当前允许有新的线程运行。
当需要递增信号量的当前资源计数时,使用函数ReleaseSemaphore:
BOOL ReleaseSemaphore(
HANDLE hSemaphore, //信号量句柄
LONG lReleaseCount, //增加个数,必须大于0且不超过最大资源数量
LPLONG lpPreviousCount //返回当前资源数量的原始值,NULL表不需要传出
);
我们可以简单的把信号量理解为一个数,当这个数大于0时,表示可以添加新线程运行,但这个数等于0时表示不可以添加新线程运行。这种说法不恰当但符合信号量的使用
线程同步问题
线程互斥:线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。
线程同步: 线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。同步的前提是互斥,其次就是有序,互斥并不代表A线程访问临界资源后就一定是B线程再去访问,也有可能是A线程,这就是属于无序的状态,所以同步就是互斥加上有序。
线程同步经典的问题就是生产者和消费者的问题:生产者生产一个物品,将其放进容器里,然后消费者从容器中取物品进行消费,如此循环
互斥体解决同步问题
#include <iostream>
#include <windows.h>
// 容器
int container;
// 次数
int count = 10;
// 互斥体
HANDLE hMutex;
// 生产者
DWORD WINAPI ThreadProc(LPVOID lpParameter) {
for (int i = 0; i < count; i++) {
// 等待互斥体,获取令牌
WaitForSingleObject(hMutex, INFINITE);
// 获取当前进程ID
int threadId = GetCurrentThreadId();
// 生产存放进容器
container = 1;
printf("Thread: %d, Build: %d \n", threadId, container);
// 释放令牌
ReleaseMutex(hMutex);
}
return 0;
}
// 消费者
DWORD WINAPI ThreadProcB(LPVOID lpParameter) {
for (int i = 0; i < count; i++) {
// 等待互斥体,获取令牌
WaitForSingleObject(hMutex, INFINITE);
// 获取当前进程ID
int threadId = GetCurrentThreadId();
printf("Thread: %d, Consume: %d \n", threadId, container);
// 消费
container = 0;
// 释放令牌
ReleaseMutex(hMutex);
}
return 0;
}
int main(int argc, char* argv[])
{
// 创建互斥体
hMutex = CreateMutex(NULL, FALSE, NULL);
// 创建2个线程
HANDLE hThread[2];
hThread[0] = CreateThread(NULL, NULL, ThreadProc, NULL, 0, NULL);
hThread[1] = CreateThread(NULL, NULL, ThreadProcB, NULL, 0, NULL);
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
CloseHandle(hThread[0]);
CloseHandle(hThread[1]);
CloseHandle(hMutex);
return 0;
}
运行结果如下
我们发现生产和消费并不是有序进行的,甚至还出现了先消费后生产的情况。因此只依靠互斥体只能达到互斥的目的,而不能达到有序的目的。
接下来我们修改代码使得有序进行
此时便达成了线程的互斥和有序的关系。但这会引发一个问题:for循环执行了不止10次,过分的占用计算资源。
事件解决同步问题
为解决计算资源浪费的问题,我们通过事件去解决
#include <windows.h>
// 容器
int container = 0;
// 次数
int count = 10;
// 事件
HANDLE eventA;
HANDLE eventB;
// 生产者
DWORD WINAPI ThreadProc(LPVOID lpParameter) {
for (int i = 0; i < count; i++) {
// 等待事件,修改事件A状态
WaitForSingleObject(eventA, INFINITE);
// 获取当前进程ID
int threadId = GetCurrentThreadId();
// 生产存放进容器
container = 1;
printf("Thread: %d, Build: %d \n", threadId, container);
// 给eventB设置有信号
SetEvent(eventB);
}
return 0;
}
// 消费者
DWORD WINAPI ThreadProcB(LPVOID lpParameter) {
for (int i = 0; i < count; i++) {
// 等待事件,修改事件B状态
WaitForSingleObject(eventB, INFINITE);
// 获取当前进程ID
int threadId = GetCurrentThreadId();
printf("Thread: %d, Consume: %d \n", threadId, container);
// 消费
container = 0;
// 给eventA设置有信号
SetEvent(eventA);
}
return 0;
}
int main(int argc, char* argv[])
{
// 创建事件
// 线程同步的前提是互斥
// 顺序按照先生产后消费,所以事件A设置信号,事件B需要通过生产者线程来设置信号
eventA = CreateEvent(NULL, FALSE, TRUE, NULL);
eventB = CreateEvent(NULL, FALSE, FALSE, NULL);
// 创建2个线程
HANDLE hThread[2];
hThread[0] = CreateThread(NULL, NULL, ThreadProc, NULL, 0, NULL);
hThread[1] = CreateThread(NULL, NULL, ThreadProcB, NULL, 0, NULL);
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
CloseHandle(hThread[0]);
CloseHandle(hThread[1]);
// 事件类型也是内核对象,所以也需要关闭句柄
CloseHandle(eventA);
CloseHandle(eventB);
return 0;
}
运行结果如下图:
信号量解决同步问题
#include <iostream>
#include <Windows.h>
int g_Count = 0;
HANDLE g_Semaphore;
DWORD ThreadCallBack1(
LPVOID lpThreadParameter
)
{
//根据信号量的数值进行
WaitForSingleObjectEx(g_Semaphore, -1, TRUE);//获取信号量,信号量减一
for (size_t i = 0; i < 200000; i++)
{
g_Count++;
}
ReleaseSemaphore(g_Semaphore, 1, NULL);//释放信号量,信号量加一
return 0;
}
DWORD ThreadCallBack2(
LPVOID lpThreadParameter
)
{
WaitForSingleObjectEx(g_Semaphore, -1, TRUE);
for (size_t i = 0; i < 200000; i++)
{
g_Count++;
}
ReleaseSemaphore(g_Semaphore, 1, NULL);
return 0;
}
int main()
{
g_Semaphore = CreateSemaphore(NULL, 1, 2, L"RSemaphore");//设置信号量
HANDLE hThread1 = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)ThreadCallBack1, NULL, NULL, NULL);
HANDLE hThread2 = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)ThreadCallBack2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, INFINITE);
WaitForSingleObject(hThread2, INFINITE);
std::cout << g_Count << std::endl;
system("pause");
return 0;
}
原子操作解决同步问题
原子操作比较低能,其只能应用于简单的加减法的线程同步问题
原子操作依赖于CPU总线,其维持一种信号用于阻止其他线程访问同一个内存地址,从而实现解决线程同步问题
#include <iostream>
#include <Windows.h>
int g_Count = 0;
DWORD ThreadCallBack1(
LPVOID lpThreadParameter
)
{
for (size_t i = 0; i < 200000; i++)
{
//g_Count++;
InterlockedAdd((LONG volatile *)&g_Count, 1);//原子加法
}
return 0;
}
DWORD ThreadCallBack2(
LPVOID lpThreadParameter
)
{
for (size_t i = 0; i < 200000; i++)
{
//g_Count++;
InterlockedAdd((LONG volatile *)&g_Count, 1);
}
return 0;
}
int main()
{
HANDLE hThread1 = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)ThreadCallBack1, NULL, NULL, NULL);
HANDLE hThread2 = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)ThreadCallBack2, NULL, NULL, NULL);
WaitForSingleObject(hThread1, INFINITE);
WaitForSingleObject(hThread2, INFINITE);
std::cout << g_Count << std::endl;
system("pause");
return 0;
}