前言
熟练掌握Windows下的多线程编程,能够让我们编写出更规范多线程代码,避免不要的异常。Windows下的多线程编程非常复杂,但是了解一些常用的特性,已经能够满足我们普通多线程对性能及其他要求。
进程与线程
1. 进程的概念
进程就是正在运行的程序。主要包括两部分:
• 一个是操作系统用来管理进程的内核对象。内核对象也是系统用来存放关于进程的统计信息的地方。
• 另一个是地址空间,它包含所有可执行模块或 D L L模块的代码和数据。它还包含动态内
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上。
sleep(0),当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++的主要实现原理,是通过保存函数的寄存器上下文以及堆栈,下次执行协程函数时,首先恢复寄存器上下文以及堆栈,然后跳转到上次执行的函数。如果有大规模的并发,不希望频繁调用多线程,可以考虑使用协程。