前言
- 熟练掌握Windows下的多线程编程,能够让我们编写出更规范多线程代码,避免不要的异常。Windows下的多线程编程非常复杂,但是了解一些常用的特性,已经能够满足我们普通多线程对性能及其他要求。
进程与线程
1. 进程的概念
-
进程就是正在运行的程序。主要包括两部分:
-
一个是操作系统用来管理进程的内核对象。内核对象也是系统用来存放关于进程的统计信息的地方。
-
另一个是地址空间,它包含所有可执行模块或 DLL 模块的代码和数据。它还包含动态内存。
-
2. 线程的概念
-
线程就是描述进程的一条执行路径,进程内代码的一条执行路径。一个进程至少有一个主线程,且可以有多个线程。线程共享进程的所有资源。线程主要包括两部分:
-
一个是线程的内核对象,操作系统用它来对线程实施管理。内核对象也是系统用来存放线程统计信息的地方。
-
另一个是线程堆栈,它用于维护线程在执行代码时需要的所有函数参数和局部变量。
-
3. 进程与线程的优劣
- 进程使用更多的系统资源,因为每个进程需要独立的地址空间。而线程只有一个内核对象及一个堆栈。如果有空间资源和运行效率上的考虑,则优先使用多线程。正因为每个地址有自已独立的进程空间,所以每个进程都是独立互不影响的。而一个进程中所有线程是共用进程的地址空间的,这样一个线程出问题可能影响到所有线程。像多标签浏览器容易一个见面假死导致整个浏览无法使用。所以像360浏览器等每个标签页都是一个进程,这样一个标签页面出问题并不会影响到其他标签页面。
4. 一个进程可以创建多少线程
- 32位windows中,0~4G线性内存空间。0~2G为应用程序内存空间(处于其中每个进程都有独立的内存空间),2G~4G为系统内核空间(内核进程完全共享)。那么进程的最大可用内存就是2G,每个线程栈的默认大小是1MB,理论上最多创建2048个线程,实际进程中还有一些其他地方占用内存,所以一般情况下可创建的线程总数为2000个左右。当然,如果想创建更多线程,可以缩小线程的栈大小。
与线程有关的函数
1. 线程的创建与终止
- 线程创建API
HANDLE CreateThread (
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
-
lpThreadAttributes,描述线程安全的结构体,默认传NULL.
-
dwStackSize,堆栈大小,默认1MB.
-
lpStartAddress,线程函数入口地址。
-
lpParameter,线程函数参数。
-
dwCreationFlags,线程创建时的状态,0表示线程创建之后立即运行。CREATE_SUSPENDED表示线程创建完挂起,直到调用ResumeThread才运行。
-
lpThreadId,指向1个变量接受线程ID,可为NULL。
-
线程终止API
void ExitThread(DWORD dwExitCode);
- 函数将强制终止线程的运行,并导致损伤系统清除该线程所使用的所有操作系统资源。但是C++对象可能由于析构函数没有正常调用导致资源不能得到正确释放。附加的退出码,可以用GetExitCodeThread()函数可以获取。不建议使用此线程终止函数,因为可能导致资源没有正确的释放,一般都让线程正常退出。另外,即便要强制终止线程,也要使用_endThreadEx(不使用_endThread),因为它兼顾了多线程资源安全。
BOOL TerminateThread(HANDLE hThread, DWORD dwExitCode);
- 该函数也是强制退出线程的,只不过此函数是异步的,即它告诉系统去终止指定线程,但是不能保证函数返回时线程已经被终止了。因此调用者必须使用WaitForSingleObject函数来确定线程是否终止。因此此函数调用后终止的线程堆栈资源不会得到释放。一般不建议使用此函数。
2. 线程安全
- 对线程安全没有一个比较具体的说明,简单来说线程函数的操作是安全的。这里的操作对象主要为:变量、函数、类对象。
线程安全变量
-
这里的变量指非自定义类型的全局变量/静态变量,或者通过线程参数传入的变量。
-
所有线程只读取该变量,那么该变量肯定线程安全的。
-
有1个线程写操作该变量,其他线程读取该变量。这时就需要考虑volatile。当一段线程代码多次读取变量的值时,编译器默认会优化代码只第1次会从内存上读取值,其他时候直接是从寄存器上读取的。这样如果其他线程更新了变量的值,读取的线程可能依然是从寄存器上读取的。这个时候就需要告诉编译器该变量不要优化,永远是从内存上读取。效率可能低一点,但是保证线程中变量的安全更重要。
-
有多个线程同时写操作该变量,那么就必须考虑临界区读写锁等方法。
-
线程安全函数
- 多线程出现之前就已经有C/C++运行时库,所以C/C++运行时库不一定是线程安全的。例如GetLastError()获取的就是一个全局的变量值,针对多线程可能就会出错。针对这个问题,MS提供了C/C++多线程运行时库,并且需要配合相应的多线程创建函数。
_beginthreadex
- 不建议使用_beginthread,因为它是早期不成熟的函数,因为它创建完成线程之后立即结束了句柄,导致不能有效控制线程。C/C++运行时库函数_beginthreadex是对操作系统函数CreateThread的封装,并且这里使用了线程局存储(TLS)来保证每个线程都有自已的单独的一些共用变量,例如像GetLastError()使用的变量。这样每个线程就能够保证所有的API函数都是线程安全的。
AfxBeginThread
- 如果当前代码环境是基于MFC库的,那么多线程创建函数必须使用MFC库函数AfxBeginThread。这是因为MFC库是对C/C++运行库的再封装,同样会面临MFC库本身存在的一些线程不安全变量的操作。AfxBeginThread其实是对_beginthreadex函数的再封装,在调用_beginthreadex之前完成一些安全载入MFC DLL库的的操作。这样基于MFC的库函数的调用才是安全的。
线程安全类
- 除了C/C++运行时库、MFC库因为已经有处理线程安全外,其他第三方库,甚至包括STL都不是线程安全的。这些自定义的类库,都需要自已去考虑线程安全。 这里可以利用锁、同步及异步等内核对象来解决,当然也可以使用TLS来解决。
3. 线程的暂停与恢复
-
在线程内核对象的内部有一个值,用于指明线程的暂停计数。当调用CreateThread函数时,就创建了线程的内核对象,并且内核对象里的暂停计数被初始化为 1,这样操作系统就不会再分配时间片给线程。当创建的线程指定CREATE_SUSPENED标志时,那么线程就处于暂停状,这个时候可以给线程进行一些优先级设置等其他初始化。当初始化完成之后,可以调用ResumeThread来恢复。单个线程可以暂时多次,如果暂停了3次,则需要ResumeThread恢复3次才能重新让线程获得时间片。
-
除了创建线程指定CREATE_SUSPENED来暂停线程外,还可以调用SuspendThread来暂时线程。调用SuspendThread时,因为不知道当前线程正在做什么,如果是正在进行内存分配或者正在一个锁操作当中,可能导致其他线程锁死之类的。所以使用SuspendThread时一定要加强措施来避免可能出现的问题。
用户模式与内核模式
- 运行 Windows 的计算机中的处理器有两个不同模式:“用户模式”和“内核模式”。根据处理器上运行的代码的类型,处理器在两个模式之间切换。应用程序在用户模式下运行,核心操作系统组件在内核模式下运行。多个驱动程序在内核模式下运行,但某些驱动程序在用户模式下运行。
1. 用户模式
-
当启动用户模式的应用程序时,Windows 会为该应用程序创建“进程”。进程为应用程序提供专用的“虚拟地址空间”和专用的“句柄表格”。由于应用程序的虚拟地址空间为专用空间,一个应用程序无法更改属于其他应用程序的数据。每个应用程序都孤立运行,如果一个应用程序损坏,则损坏会限制到该应用程序。其他应用程序和操作系统不会受该损坏的影响。
-
用户模式应用程序的虚拟地址空间除了为专用空间以外,还会受到限制。在用户模式下运行的处理器无法访问为该操作系统保留的虚拟地址。限制用户模式应用程序的虚拟地址空间可防止应用程序更改并且可能损坏关键的操作系统数据。
2. 内核模式
- 实现操作系统的一些底层服务,比如线程调度,多处理器的同步,中断/异常处理等。
3. 内核对象
- 顾名思义,内核对象即内核创建的对象。由于内核对象的数据结构只能被内核访问,所以应用程序无法在内存中找到这些数据内容。因为要用内核来创建对象,所以必从用户模式切换到内核模式,而从用户模式切换到内核模式是需要耗费几百个时钟 周期的。建和操作若干类型的内核对象,比如存取符号对象、事件对象、文件对象、文件映射对象、I / O完成端口对象、作业对象、信箱对象、互斥对象、管道对象、进程对象、信标对象、线程对象和等待计时器对象等。内核对象是跨进程的,所以跨进程可以使用内核对象进行通信。
时间片和原子操作
1. 时间片
-
早期CPU是单核单线程,所以不可能做到真正的多线程。时间片即是操作将CPU运行的时间划分成长短基本一致的时间区,即是时间片。多线程主要是通过操作系统不停地切换时间给不同的线程,来让线程快速交替运行,因为时间相隔很短,用户看起来像是几个线程同时在运行。当然现在CPU有多核多线程,可以做到真正的多线程了。可以使用SetThreadAffinityMask来指定线程运行在不同CPU上。
-
当1个线程有大量计算量,容易导致CPU使用很高,而其他进程线程得不到时间片。这个时候调用sleep(0),相当告诉操作系统重新来分配时间片,这个时候同优先级的线程就可能分配得时间片,减缓计算线程大量占用时间片。
2. 原子操作
-
线程同步问题在很大程度上与原子访问有关,所谓原子访问,是指线程在访问资源时能够确保所有其他线程都不在同一时间内访问相同的资源。
-
例如:
int g_nVal = 0;
DWORD WINAPI ThreadFun1(PLOVE pParam)
{
g_nVal++;
return 0;
}
DWORD WINAPI ThreadFun2(PLOVE pParam)
{
g_nVal++;
return 0;
}
-
因为g_nVal++是先从内存上取值放寄存器上再来进行计算,因为线程调度的不可控性,导致可能两个线程先后都是从内存上取到的0,这样自加后的结果都是1。这与我们实际想要的结果2并不一致。为了避免这种情况,就需要原子操作InterlockedExchangeAdd(g_nVal, 1)来达到效果。互锁函数操作一个内存地址时,会防止另一个CPU访问内一个内存地址。
-
InterlockedExchanged/InterlockedExchangePointer,前者是交换一个值,后者是交换一组值。其作用是原子交换指定的值,并返回原来的值。因此它可以有如下的应用。
void Fun()
{
while (InterlockedExchange(&g_bVal, TRUE) == TRUE)
Sleep(0);
// do something
InterlockedExchange(&g_bVal, FALSE);
}
- 上面的代码能够达到一个锁的效果。原子操作不用切换到内核模式,所以速度比较快。但是上面的代码依然需要不停地循环来达到等待的效果。临界区与原子操作一样,都可以直接在用户模式下操作,并且临界区则是直接等待完全不用给当前线程分配CPU时间片。所以效率上还是临界区更优一点。
线程池
- 当线程频繁创建时,大量线程的创建销毁会占用大量的资源,导致效率低下。这个时候就可以考虑使用线程池。线程池的主要原理,即创建的线程暂时不销毁,加入空闲线程列表。当需要创建新线程时,优先去空闲线程列表中查询是否有空闲线程,有就直接用,如果没有再创建新的线程。这样就能够达到减少线程的频繁创建与销毁。
协程
- 像Python、Lua都提供了协程,尤其是Lua,因为它没有多线程,所以非常依赖协程,Lua也是将协程发挥得比较好的脚本语言。像其他语言也都有第三方实现的协程库可用。Windows多线程是由内核提供的,所以创建多线程需要切换到内核模式,因为从用户模式切换到内核模式分花费几百个时钟周期。而一种直接由用户模式提供的轻量级类多线程,其实就是协程(Coroutine)。具体来讲就是函数A调用协程函数B,然后B执行到第5行中断返回函数A继续执行其他函数C,然后下次再次调用到B时,这个时候是从B函数的第5行开始执行的。看起来就是先执行协程函数B,执行了一部分,中断去执行C,执行完C接着从上次的位置执行B。看起来是简陋的多线程,其实是利用同步达到异步的效果。C++的主要实现原理,是通过保存函数的寄存器上下文以及堆栈,下次执行协程函数时,首先恢复寄存器上下文以及堆栈,然后跳转到上次执行的函数。如果有大规模的并发,不希望频繁调用多线程,可以考虑使用协程。
线程的分类
1. 有消息循环线程
- MFC中有用户界面线程,从CWinThread派生出一个新的类作为UI线程类CUIThread,然后调用AfxBeginthread(RUNTIME_CLASS(CUIThread));启动线程。UI线程可以直接创建模态对话框,而不用担心消息循环的问题,因为UI线程默认自带消息循环。
- MFC非用户界面线程,不能创建模态对话框,但是可以创建非模态对话框或普通窗口,但是必须自己写消息循环。
MSG msg;
while(GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
2. 无消息循环线程
- MFC中的工作者线程
- 其他没有加消息循环的普通线程。
线程间的通信
1. 共享内存变量
-
因为线程是共享进程内存的,所以通过全局/静态变量来进行通信效率最最高的。参数需要考虑是否加volitile。
-
通过传递的参数,如引用和指针。参数需要考虑是否加volitile。
2. 消息通知
-
如果是子线程向主线程通信,因为主线程有消息循环,所以子线程可以通过发送消息来向主线程通信。通过消息通信能够避免使用全局变量带来的耦合性。
-
SendMessage必须等待消息函数处理完成才返回,PostMessage则直接将消息放入消息队列立即返回。所以SendMessage的消息参数可以是临时变量,而PostMessage的消息参数必须保证足够的生存周期。
-
如果子线程有自定义的消息循环,也可以通过PostThreadMessage来指定线程通信。
while(true)
{
if(GetMessage(&msg,0,0,0)) //get msgfrom message queue
{
switch(msg.message)
{
case MY_MSG:
// Todo:
break;
}
}
};
3. 其他方式
- 所有跨进程的通信方式,当然可以用于跨线程了。
线程之间的状态
1. 异步
- 即多个线程彼此独立,不受外部线程的影响。线程本身就是实现异步的一种方式。
2. 同步
- 即多个线程彼此依赖,线程A的计算结果是线程B的计算的前提,也就是说在开始线程B的计算之前必须等待线程A的计算完。
3. 互斥
- 即多个线程在操作同一个资源时,一个线程必须等另一个线程结束了才能继续操作。互斥与同步不同之处是,互斥没有先后关系。同一个资源,可以指全局变量,也可以指一个文件对象或是其他的内核对象。因为内核对象是跨进程的,所以更是跨线程的。
等待函数
1. 概念
-
WaitForSingleObject函数是等待内核对象从无信号状态到有信号状态或是超时即返回。也即无信号状态时等待,有信号或超时立即返回。
-
WaitForMulitpleObjects函数是等待多个内核对象从无信号状态到有信号状态或是超时即返回(可以指明是所有对象或是任一对象)。
-
Windows拥有几种内核对象可以处于已通知状态和未通知状态:进程、线程、作业、文件、控制台输入/输出/错误流、事件、等待定时器、信号量、互斥对象。
2. 等待函数与内核对象之间的关系
对象 | 无信号状态 | 有信号状态 | 成功等待副作用 |
---|---|---|---|
进程 | 进程活动时 | 进程终止时 | 无 |
线程 | 线程活动时 | 线程终止时 | 无 |
文件 | I/O请求正在处理时 | I/O请求结束时 | 无 |
控制台输入 | 不存在任何输入 | 存在输入时 | 无 |
文件修改通知 | 没有任何文件修改通知 | 文件系统发现修改时 | 重置通知 |
自动重置事件 | ResetEvent, PulseEvent或等待成功 | 当调用SetEvent或PulseEvnet时 | 重置事件 |
人工重置事件 | ResetEvent,或PulseEvent | 当调用SetEvent或PulseEvnet时 | 无 |
自动重置定时器 | CancelWaitableTimer或等待成功 | 当时间到时(SetWaitableTimer) | 重置定时器 |
人工重置定时器 | CancelWaitableTimer | 当时间到时(SetWaitableTimer) | 无 |
信号量 | 等待成功 | 当资源数量>0时(ReleaseSemaphore) | 数量减1 |
互斥量 | 等待成功 | 当未被线程拥有时(ReleaseMutex) | 获取线程所有权 |
-
线程和进程创建及运行时都是无信号状态,当结束运行时变为有信号状态。
-
自动重置的事件(FALSE)对象,当等待成功的时候,会被修改为无信号状态。
-
信号量对象,当调用ReleaseSemaphore(数量加1),处于有信号状态,WaitForSingleObject会被触发并且立即将信号数量减1.
用户模式与内核模式的优缺点
1. 用户模式
-
优点:线程同步机制速度快
-
缺点:容易陷入死锁状态多个进程之间的线程同步会出现问题。(比如竞争资源、死锁)
2. 内核模式
-
优点:支持多个进程之间的线程同步,防止死锁
-
缺点:线程同步机制速度慢,线程必须从用户模式转为内核模式。这个转换需要很大的代价:往返一次需要占用x86平台上的大约1000个CPU周期。
线程间的状态处理
1. 线程的异步
- 线程本身就是异步的。
2. 线程的同步
- 线程的同步主要是通过事件(Event)内核对象、信号量(Semaphore)内核对象和互斥量(Mutex)内核对象。因为都是内核对象,所以不仅可以跨线程操作,还可以跨进程同步。
事件(Event)内核对象
-
事件分两种类型:人工重置事件和自动重置事件,前者在触发WaitForSingleObject之后需要手动调用ResetEvent将事件设置为无信号;而后者在触发WaitForSingleObject之后自动将事件设置为无信号状态。
-
常用函数:
CreateEvent,创建事件对象。
OpenEvent,打开已经创建的事件对象,可以跨进程打开。
SetEvent,将事件对象设置为有信号状态。
ResetEvent,将事件对象设置为无信号状态。
PulseEvent,将事件对象设置为有信号状态,然后又设置为无信号状态,此函数不常用。
- 示例:
HANDELg_hEvent;
int Main()
{
g_hEvent =CreateEvent(NULL, TRUE, FALSE, NULL);
_beginthreadex(NULL,0, ThreadFun1, 0);
_beginthreadex(NULL,0, ThreadFun2, 0);
SetEvnet(g_hEvent);//
}
DWORD WINAPIThreadFun1(PVOID pParam)
{
WaitForSingleObject(g_hEvent);
// Todo...
SetEvent(g_hEvnet);
return 0;
}
DWORD WINAPIThreadFun2(PVOID pParam)
{
WaitForSingleObject(g_hEvent);
// Todo...
SetEvent(g_hEvnet);
return 0;
}
- 注意:如果上面创建的是人工重置事件,则两个线程函数都将执行。如果是自动重置事件,则只能执行一个线程,且不能保证哪一个线程先执行。如果要保证一个线程先执行,可以添加事件对象用来确保指定线程已经执行,不能通过代码的先后顺序确保线程已经执行。
信号量(Semaphore)内核对象
-
信号量的使用规则:
-
当前信号量资源数大于0,则标记为有信号状态。
-
当前信号量资源数为0,则标记为无信号状态。
-
信号量资源数不能为负,且最大不能超过指定数量。
-
-
常用函数:
CreateSemaphore,创建信号量对象。
OpenSemaphore,打开指定信号量对象,可以跨进程。
ReleaseSemaphoer,资源计算加1。
- 示例:
HANDELg_hSema[2];
int Main()
{
g_hSema[0] =CreateSemaphore(NULL, 1, 1, NULL);
g_hSema[1] =CreateSemaphore(NULL, 0, 1, NULL);
_beginthreadex(NULL,0, ThreadFun1, 0);
_beginthreadex(NULL,0, ThreadFun2, 0);
}
DWORD WINAPIThreadFun1(PVOID pParam)
{
WaitForSingleObject(g_hSema[0]);
// Todo...
ReleaseSemaphoer(g_hSema[1]);
return 0;
}
DWORD WINAPIThreadFun2(PVOID pParam)
{
WaitForSingleObject(g_hSema[1]);
// Todo...
ReleaseSemaphoer(g_hSema[0]);
return 0;
}
- 这样就能够保证ThreadFun1执行完了,再执行ThreadFun2,然后再执行ThreadFun1,并且保证每个线程函数只能被调用一次.
互斥量(Mutex)内核对象
-
互斥量内核对象确保线程拥有单个资源的互斥访问权。在行为特性上,互斥量与临界区的一样。只不过,互斥量是内核对象,使用时需要从用户模式切换到内核模式,比较耗时。但正因为是内核对象,所以互斥量能够跨进程,并且能够设置超时时间,这是它比临界区灵活的地方。
-
互斥量和临界区一样,拥有一个线程拥有权的概念,即当前互斥量和当前临界区的释放只能由当前线程释放,其他线程释放无效。因为互斥量是内核对象,如果线程已经终止,但是其所属的互斥量依然没有释放,内核管理器会自动释放。临界区没有这个功能,因为临界区不是内核对象,所以临界区如果没有正确释放会导致死锁。
-
常用函数:
CreateMutex,创建互斥量对象。
OpenMutex,打开指定互斥量对象,可以跨进程。
ReleaseMutex,释放互斥量,对象被标记为有信号状态,触发WaitForSingleObject。
- 示例:
HANDELg_hMutex;
int Main()
{
g_hMutex =CreateMutex(NULL,FALSE);
_beginthreadex(NULL,0, ThreadFun1, 0);
_beginthreadex(NULL,0, ThreadFun2, 0);
}
DWORD WINAPIThreadFun1(PVOID pParam)
{
WaitForSingleObject(g_hMutex);
// Todo...
ReleaseMutex(g_hMutex);
return 0;
}
DWORD WINAPIThreadFun2(PVOID pParam)
{
WaitForSingleObject(g_hMutex);
// Todo...
ReleaseMutex(g_hMutex);
return 0;
}
- 两个函数谁先调用,谁即获取线程所有权。如果想指定线程先运行,需要判断指定线程已经执行之后再创建新线程,不能依靠线程的代码创建先后顺序。
3. 线程的互斥
-
像互斥量对象同样可以达到互斥的效果,只是互斥量功能更丰富,并且如果是简单的资源互斥,使用临界区的效率更优。
-
临界区(Critical Section)是一段供线程独占式访问的代码,也就是说若有一线程正在访问该代码段,其它线程想要访问,只能等待当前线程离开该代码段方可进入,这样保证了线程安全。他工作于用户级(相对于内核级),在Window系统中CRITICAL_SECTION实现临界区相关机制。
-
常用函数:
InitializeCriticalSection 初始化临界区
EnterCriticalSection 进入临界区
LeaveCriticalSection 离开临界区
DeleteCriticalSection 释放临界区资源
- 因为临界区拥有线程所有权这个概念,即进入临界区的线程才有权释放临界区。因为必须当前线程进入和释放,更多的时候,临界区是在一个函数里使用,为了确保不会由于中间退出函数导致没有释放,我们可以用下列方式来确保释放。
class Mutex {
public:
Mutex() {InitializeCriticalSection(section); }
~Mutex() { DeleteCriticalSection(section);}
void Enter() {EnterCriticalSection(section); }
void Leave() {LeaveCriticalSection(section); }
struct Lock;
protected:
Mutex(const Mutex&);
Mutex& operator=(const Mutex&);
CRITICAL_SECTION section;
};
structMutex::Lock {
Mutex& s;
Lock(Mutex& s) : s(s) { s.Enter(); }
~Lock() { s.Leave(); }
};
DWORD WINAPIThreadFun(PVOID pParam)
{
Mutex::Locklock(mutex);
// Todo...
return 0;
}
注意
-
注意所有内核对象在结束时都需要调用closeHandle()。
-
跨线程调用MFC对象函数都是不安全的。因为MFC对象的一些函数都与TLS有关联, 所以有些调用会出错。如UpdateData(),最好通过句柄发消息来完成相应的功能。