一、同步问题概述
如果多个线程同时对同一个变量(内存区域)进行读写,就会由于线程切换(cpu时间片分配)导致结果与预期不相符,如两个线程A和B同时执行变量自增运算,由于A从内存取数据到cpu后线程切换到了B,B取完数据,cpu运算完然后将结果写回内存后线程才切换到了A,A继续从中断的地方执行,即cpu运算和写回内存,这使得A和B写回内存的结果都是相同的,即都是变量自增一次的结果,而不是进行了两次自增运算的结果,这就是同步问题
如图:
#include <iostream>
#include<Windows.h>
unsigned int g_val = 0;
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 1000000; i++)
{
g_val++;
//InterlockedIncrement(&g_val);
}
std::cout << "值:" << g_val <<"线程id:" << GetCurrentThreadId()<<std::endl;
return 0;
}
int main()
{
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
system("pause");
}
运行结果:
二、用户态下解决方案
1.原子(互锁)操作
原子操作是指不会被线程调度打断的操作,在操作中不会发生线程切换,windows提供的原子操作api有如下:
以自增运算为例:
LONG __cdecl InterlockedIncrement(
__inout LONG volatile* Addend //要进行自增运算的变量的指针
);
演示:
#include <iostream>
#include<Windows.h>
unsigned int g_val = 0;
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 1000000; i++)
{
InterlockedIncrement(&g_val);
}
std::cout << "值:" << g_val <<"线程id:" << GetCurrentThreadId()<<std::endl;
return 0;
}
int main()
{
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
system("pause");
}
运行结果:
多次运行可以看到第一个线程的值总是在变化,这是因为线程在执行这些原子操作时也会不断切换,所以第一个线程的值总是不固定的,但是总是大于1000000,并且最终变量值为2000000,相当于执行了两遍for循环
2.临界区
如果我们的操作不是自增运算,而是如下:
#include <iostream>
#include<Windows.h>
#include<vector>
std::vector<int> vec;
RTL_CRITICAL_SECTION lock;
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 100000; i++)
{
vec.push_back(i); //末尾添加元素i
}
std::cout<<"数量:"<<vec.size()<<"线程id:" << GetCurrentThreadId()<<std::endl;
return 0;
}
int main()
{
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
system("pause");
}
编译后会发现无法运行,而且中断位置不固定,像这样
原子操作api只用于简单运算,当操作较为复杂的时候我们可以采用临界区,临界区相当于一把锁,把代码锁起来,只能让当前线程访问,其他线程会阻塞,相关api:
进入临界区(上锁):
void WINAPI EnterCriticalSection(
__inout LPCRITICAL_SECTION lpCriticalSection
);
参数是一个结构体指针,指向如下结构体
typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
//
// The following three fields control entering and exiting the critical
// section for the resource
//
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread; // from the thread's ClientId->UniqueThread
HANDLE LockSemaphore;
ULONG_PTR SpinCount; // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;
该结构体成员不需要我们填写,由api自己完成
出临界区(开锁):
void WINAPI LeaveCriticalSection(
__inout LPCRITICAL_SECTION lpCriticalSection
);
临界区初始化:
void WINAPI InitializeCriticalSection(
__out LPCRITICAL_SECTION lpCriticalSection
);
临界区反初始化
void WINAPI DeleteCriticalSection(
__inout LPCRITICAL_SECTION lpCriticalSection
);
示例:
#include <iostream>
#include<Windows.h>
#include<vector>
std::vector<int> vec;
RTL_CRITICAL_SECTION lock;
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 1000; i++)
{
EnterCriticalSection(&lock); //临界区开始
vec.push_back(i); //末尾添加元素i
LeaveCriticalSection(&lock); //临界区结束
}
std::cout<<"数量:"<<vec.size()<<"线程id:" << GetCurrentThreadId()<<std::endl;
return 0;
}
int main()
{
InitializeCriticalSection(&lock); //初始化临界区
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
system("pause");
DeleteCriticalSection(&lock); //释放临界区
}
三、内核同步对象
由Windows内核提供,0环和3环都可以使用
1.互斥体
相当于内核版的临界区,可以跨进程使用
创建或打开互斥体:
HANDLE WINAPI CreateMutex(
__in_opt LPSECURITY_ATTRIBUTES lpMutexAttributes, //安全属性
__in BOOL bInitialOwner, //是否对当前进程上锁
__in_opt LPCTSTR lpName //对象名称,如果要跨进程使用可以填
);
函数成功返回互斥对象句柄
注意如果第二个参数为TRUE并且调用者创建了互斥锁,则调用线程获得该互斥体的初始所有权
补充:虽然每个进程都有自己独立的资源和空间,但有些时候我们只需要程序在系统上只保存一份进程实例,这就是进程互斥问题,CreateMutex函数的第三个参数可以指定互斥对象的名称,程序每次运行时通过判断系统是否存在同名互斥对象来判断程序是否重复运行,若存在同名互斥对象,GetLastError()函数返回ERROR_ALREADY_EXISTS
给互斥体上锁(也可用于等待线程或进程结束):
DWORD WINAPI WaitForSingleObject(
__in HANDLE hHandle, //互斥对象句柄
__in DWORD dwMilliseconds //超时时间
);
如果互斥体未锁上,则该函数将上锁,否则将处于阻塞状态,第二个参数表示超时时间,如果过了这个时间,锁仍然未开,则函数将退出,但是如果这个时间内锁开了,函数也会退出,可以根据返回值来判断是哪种情况退出:
WAIT_OBJECT_0 | 锁开了 |
WAIT_TIMEOUT | 锁未开,超时了 |
WAIT_ABANDONED | 判断使用互斥体的线程是否结束 |
开锁(释放线程对互斥对象的控制权):
BOOL WINAPI ReleaseMutex(
__in HANDLE hMutex //句柄
);
示例:
#include <iostream>
#include<Windows.h>
#include<vector>
HANDLE mutex = NULL;
int g_val = 0;
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 1000000; i++)
{
WaitForSingleObject(mutex, INFINITE); //加锁,一直等待
g_val++;
ReleaseMutex(mutex); //开锁
}
std::cout<<"结果:"<<g_val<<"线程id:" << GetCurrentThreadId()<<std::endl;
return 0;
}
int main()
{
mutex = CreateMutex(NULL, FALSE,TEXT("test"));
if (mutex)
{
if (GetLastError() == ERROR_ALREADY_EXISTS)
{
std::cout << "进程已存在!" << std::endl;
}
}
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
system("pause");
}
临界区和互斥体的区别:
对于互斥体来说,当上锁的线程结束了并且没有开锁,锁会自动开,这种开锁称为线程遗弃,而对于临界区,当上锁的线程结束了并且没有开锁,锁不会自动开,其他线程依然处于阻塞状态
2.事件
创建事件内核对象:
HANDLE WINAPI CreateEvent(
__in_opt LPSECURITY_ATTRIBUTES lpEventAttributes,
__in BOOL bManualReset,
__in BOOL bInitialState,
__in_opt LPCTSTR lpName
);
第二个参数标识是否自动上锁,还是需要自己调api上锁
第三个参数表示初始状态时要不要上锁
上锁/等待:
DWORD WINAPI WaitForSingleObject(
__in HANDLE hHandle,
__in DWORD dwMilliseconds
);
开锁(设置事件对象为已触发):
BOOL WINAPI SetEvent(
__in HANDLE hEvent //事件句柄
);
上锁(设置事件对象为未触发):
BOOL WINAPI ResetEvent(
__in HANDLE hEvent
);
示例:
#include <iostream>
#include<Windows.h>
#include<vector>
HANDLE hevent = NULL;
int g_val = 0;
HANDLE hd1 = NULL, hd2 = NULL;
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 100000; i++)
{
WaitForSingleObject(hevent, INFINITE);
g_val++;
SetEvent(hevent);
}
std::cout<<"结果:"<<g_val<<"线程id:" << GetCurrentThreadId()<<std::endl;
return 0;
}
int main()
{
hevent = CreateEvent(NULL, FALSE, TRUE, NULL);
hd1=CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
hd2=CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
WaitForSingleObject(hd1, INFINITE); //暂停等待线程运行
WaitForSingleObject(hd2, INFINITE);
CloseHandle(hevent); //关闭句柄
}
3.信号量
信号量与前面的临界区,互斥体等等的都不同,临界区,互斥体是独占cpu资源,而信号量则是用于限制同时运行的线程的个数,信号量内核对象中有一个最大资源计数和一个当前资源计数,最大资源计数表示信号量可以控制的最大资源数目,当前资源计数表示信号量当前可用资源的数量
创建信号量:
HANDLE WINAPI CreateSemaphore(
__in_opt LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
__in LONG lInitialCount, //初始信号个数
__in LONG lMaximumCount, //最大个数
__in_opt LPCTSTR lpName
);
增加信号个数:
BOOL WINAPI ReleaseSemaphore(
__in HANDLE hSemaphore,
__in LONG lReleaseCount, //增加个数
__out_opt LPLONG lpPreviousCount //传出参数,原来的个数
);
如果当前资源计数大于0,那么信号量处于触发状态,如果当前资源计数等于0,那么信号量处于未触发状态。当一个线程访问资源时,当前资源计数会减一,但是当前资源计数一定大于等于0并且小于最大资源计数
使用:
#include <iostream>
#include<Windows.h>
#include<vector>
HANDLE sem = NULL;
int g_val = 0;
HANDLE hd[4] = {};
DWORD threadfun(LPVOID lpParameter)
{
for (int i = 0; i < 100000000000000; i++)
{
WaitForSingleObject(sem, INFINITE);
std::cout << "线程" << GetCurrentThreadId() << "正在执行" << std::endl;
g_val++;
Sleep(10000);
std::cout << "线程" << GetCurrentThreadId() << "执行完毕" << std::endl;
ReleaseSemaphore(sem, 1, NULL); //资源计数+1,让线程可以切换
}
return 0;
}
int main()
{
sem = CreateSemaphore(NULL, 0, 5, NULL);
for (int i = 0; i < 4; i++)
{
hd[i] = CreateThread(NULL, 0, threadfun, NULL, 0, NULL);
}
ReleaseSemaphore(sem, 1, NULL);
WaitForMultipleObjects(4, hd, TRUE, INFINITE);
}
运行结果:
补充:
对线程同步来说,每个内核对象都有两种状态,要么处于触发/有信号(signaled)状态,要么处于未触发/无信号(nonsignaled)状态,每种对象都有相应规则在这两个状态中切换,如进程内核对象创建时总是处于未触发状态,进程终止时进程内核对象会被操作系统设置为触发状态。对于线程来说,如果线程正在等待的对象处于未触发状态,这时线程是不可调度的,当对象处于触发状态时,线程才会变为可调度的,上面所说的WaitForSingleObject函数就是一个等待函数,会让一个线程自愿进入等待状态,直到指定的内核对象被触发为止或者超时。
所以说,这里说的锁实际上就是线程在等待对象被触发而被”锁住",而开锁就是让对象被触发
等待多个对象可以调用如下api:
DWORD WINAPI WaitForMultipleObjects(
__in DWORD nCount, //句柄个数
__in const HANDLE* lpHandles, //数组首地址
__in BOOL bWaitAll, //true表示等待所有线程都变为已触发,false表示只要有一个线程变为已触发就结束
__in DWORD dwMilliseconds
);