Win32 MultiThread Summary——《Win32多线程程序设计》
ZeroJiu 愚昧之巅V4

我们可以为我们的能力自豪,但对于我们的缺点——还有我们的无知和错误——我们必须诚实。

Andrew Hunt/David Thomas程序员修炼之道

多线程并不一定是最好的,合适才是最好的。

为什么多线程?

多线程主要的优点是价廉物美,启动快、退出快、与其他线程共享核心对象,很容易实现共产主义的伟大梦想。但是其又有不可预期、测试困难的缺点。

多线程往往会和多进程在一起进行比较,进程含有内存和资源:

  • 内存:可达到2GB,分为Code(程序的可执行部分,只读性质)、Data(全局变量和静态变量)和Stack(局部变量)
  • 资源:核心对象(file handles和线程)、User资源(如对话框和字符串)、GDI资源(如Device Context和brushes)

线程是进程的一个实体,是独立调度和分派的基本单位,不拥有系统资源(只拥有少许运行中必不可少的私有资源),线程可与同属一个进程的其它线程共享进程的全部资源。

总结来说,进程拥有资源,属于这个进程的若干个线程共享进程的资源。进程是资源分配和调度的单位,线程是CPU调度和分配的单位,资源是分配给进程的,线程只拥有很少资源,因而切换代价比进程切换低。但是进程的健壮性远比线程好,如果一个进程死亡,系统中的其他进程还是可以继续执行。

使用好多线程,就是要知道何时应该用多线程,何时不该用。如果应该用多线程,如何解决Race Condition问题?如何共享数据?如何提高效率?如何同步线程和数据?总结起来就是:

  • 有始有终,线程的创建和释放都要靠自己
  • 不抛弃不放弃,等一等线程,让它做完自己的工作
  • 文明有序,资源占用无冲突

但是有时候却不建议使用多线程:

  • 针对于慢速I/O设备,Overlapped I/O更能胜任
  • 程序的健壮性要求很高,值得付出比较多的额外负担,多进程可能更能胜任

操作线程

如何创建线程?

如果要写一个多线程程序,第一步就是创建一个线程,我们可以使用CreateThread API函数,也可以使用_beginthreadex C 函数,其实我大多数时候使用的是Boost库上面的boost::thread对象来创建线程对象。如果有兴趣可以看看Boost库,这里暂且不讨论Boost库thread。

如果使用上面两个函数,可以去msdn查看。使用上面两种函数创建线程,其线程函数都必须符合以下格式,当然函数名可以更换:

1
DWORD WINAPI ThreadFunc(LPVOID n);

使用CreateThread API函数或者_beginthreadex函数,可以传回两个值用以识别一个新的线程——返回值Handle(句柄)和输出参数lpThread(线程ID)。为了安全防护的缘故,不能根据一个线程的ID获得其handle。

如何释放线程?

线程和进程一样,都是核心对象。如何释放线程属于如何释放核心对象的问题。CloseHandle函数在这里起了十分重要的作用。CloseHandle函数的功能是将核心对象的引用计数减1。其不能直接用来释放核心对象,核心对象只有在其引用计数为0的时候会被操作系统自动销毁。

1
BOOL CloseHandle(HANDLE hObject);

如果你不调用该函数,即使线程在创建之后执行完毕,引用计数还是不为0,线程无法被销毁。如果一个进程没有在结束之前对它所打开的核心对象调用CloseHandle,操作系统会自动把那些对象的引用计数减一。虽然操作系统会做这个工作,但是他不知道核心对象实际的意义,也就不可能知道解构顺序是否重要。如果你在循环结构创建了核心对象而没有CloseHandle,好吧!你可能会有几十万个句柄没有关闭,你的系统会因此没有可用句柄,然后各种异常现象就出现了。记住当你完成你的工作,应该调用CloseHandle函数释放核心对象

在清理线程产生的核心对象时也要注意这个问题。不要依赖因线程结束而清理所有被这一线程产生的核心对象。面对一个打开的对象,区分其拥有者是进程或是线程是很重要的。这决定了系统何时做清理工作。程序员不能选择有进程或者线程拥有对象,一切都得视对象类型而定。如果被线程打开的核心对象被进程拥有,线程结束是无法清理这些核心对象的。

线程核心对象与线程

其实这两个是不同的概念。CreateThread函数返回的句柄其实是指向线程核心对象,而不是直接指向线程本身。在创建一个新的线程时,线程本身会开启线程核心对象,引用计数加1,CreateThread函数返回一个线程核心对象句柄,引用计数再加1,所以线程核心对象一开始引用计数就是2。

调用CloseHandle函数,该线程核心对象引用计数减一,线程执行完成之后,引用计数再减一为零,该核心对象被自动销毁。

不能依赖因线程的结束而清理所有被这一线程产生的核心对象。许多对象,例如文件,是被进程拥有的。

结束主线程

首先得了解哪个线程是主线程:程序启动后就执行的线程。主线程有两个特点:

  • 负责GUI主消息循环
  • 主线程结束时,强迫其他所有线程被迫结束,其他线程没有机会执行清理工作

第二个特点也就意味着,如果你不等待其他线程结束,它们没有机会执行完自己的操作,也没有机会做最后的cleanup操作。我遇到过由于没有等待,而出现程序奔溃的情况。反正很危险。

结束线程并获取其结束代码

这个没什么好说的,可以使用ExitThread函数退出线程,返回一个结束代码。GetExitCodeThread函数获取ExitThread函数或者return语句返回的结束代码。不过想通过GetExitCodeThread来等待线程结束是个很糟糕的注意——CPU被浪费了。下一节提及的WaitForSingleObject才是正道。

获取程序结束代码:

1
2
/* 如果线程尚未结束,返回TRUE,lpExitCode值为STILL_ACTIVE */
BOOL bResult = GetExitCodeThread(hThread, lpExitCode);

终止其他线程

终止其他线程可以使用TerminateThread()函数,也可以使用全局标记。

TerminateThread()函数的缺点是:

  • 1、线程没有机会在结束前清理自己,其堆栈也没有被释放掉,出现内存泄露;
  • 2、任何与此线程有附着关系的DLLs也没有机会获得线程解除附着通知;
  • 3、线程进入的Critical Section将永远处于锁定状态(Mutex会返回wait_abandoned状态)。
  • 4、线程正在处理的数据会处于不稳定状态。

TerminateThread()唯一可以预期的是:线程handle变成激发状态,并且传回dwExitCode所指定的结束代码。

设立全局标记的优点:保证目标线程在结束之前安全而一致的状态
设立全局标记的缺点:线程需要一个polling机制,时时检查标记值。(可以使用一个手动重置的event对象

等一等线程

等待一个线程的结束

使用WaitForSingleObject最显而易见的好处是你终于可以把以下代码精简成一句了。

1
2
3
4
5
6
7
8
9
for(;;)
{
int rc;
rc = GetExitCodeThread(hThread, &exitCode);
if(!rc && exitCode != STILL_ACTIVE)
break;
}
→ → → → → →
WaitForSingleObject(hThread, INFINITE);

其他好处就是:

  • busy loop浪费太多CPU时间
  • 可以设定等待时间

等待多个线程的结束

WaitForSingleObject函数不好同时判断多个线程的状态,WaitForMultipleObjects可以同时等待多个线程,可以设定是否等待所有线程执行结束还是只要一个线程执行完立马返回。

在GUI线程中等待

在GUI线程中总是要常常回到主消息循环,上述两个wait api函数会阻塞主消息循环。MsgWaitForMultipleObjects函数可以在对象呗激发或者消息到达时被唤醒而返回。

相关基本函数

1、等待一个核心对象被激发(线程结束)

WaitForSingleObject不仅仅能够等待一个线程结束,也能等待一个核心对象变成激发状态。

MSDN中提及:

If this handle(核心对象) is closed while the wait is still pending, the function’s behavior is undefined.

会不会奔溃呢?这是个问题。

1
2
3
4
5
6
7
/* 失败返回WAIT_FAILED
/* 成功:
/* WAIT_OBJECT_0: 等待目标变成激发状态
/* WAIT_TIMEOUT: 核心对象变成激发状态之前,等待时间终了。
/* WAIT_ABANDONED: 如果一个拥有mutex互斥器的线程结束之前没有释放mutex
/* dwMilliseconds可以为INFINITE,表示一直等待。也可以为0,表示立即检查handle的状态。
DWORD result = WaitForSingleObject(hThead, dwMilliseconds);

2、等待多个核心对象被激发(线程结束)

1
2
3
4
5
6
7
/* bWaitAll是TRUE,那么返回值将是WAIT_OBJECT_0 */
/* bWaitAll是FALSE,那么将返回值减去WAIT_OBJECT_0,就表示数组中那个handle被激发 */
DWORD result = WaitForMultipleObjects(
nCount, /* 最大容量是MAXIMUM_WAIT_OBJECTS(64)*/
lpHandles,
bWaitAll,
dwMilliseconds);

3、等待核心对象被激发或消息到达队列

在一个GUI程序中等待线程结束会导致进程卡死,常常回到主消息循环式很重要的。光使用GetMessage函数和WaitForMultipleObjects无法做到这一点。MsgWaitForMultipleObjects函数能够同时等待核心对象被激发或者消息到达队列。

这块代码详见Win32多线程程序设计P88

1
DWORD result = MsgWaitForMultipleObjects(nCount, pHandles, bWaitAll, dwMilliseconds, dwWakeMask);

线程同步

线程同步主要有Critical Sections、Mutex、Semaphores、Event,除了Critical Section是存在于进程内存空间内,其他都是核心对象

Critical Sections

Critical Section用来实现排他性占有,适用范围时单一进程的各个线程之间。临界区并非核心对象,一旦线程进入一个临界区,他就能够一再的重复进入该临界区。但是必须保证,每一个进入操作都有一个对应的离开操作

Critical Section的一个缺点就是,没有办法获知进入critical section中的那个线程是生是死。这样的话,如果线程挂了还没有离开临界区,系统没有办法将该临界区清楚。

同时,临界区由于context switching也会发生死锁(DeadLock)现象——每个线程都抓住了部分资源,而都在等待对方线程的资源。

1
2
3
4
5
CRITICAL_SECTION g_criticalSection;
InitializeCriticalSection(&g_criticalSection);
EnterCriticalSection(g_criticalSection);
LeaveCriticalSection(g_criticalSection);
DeleteCriticalSection(&g_criticalSection);

千万不要再一个critical section之中调用Sleep()或任何Wait() API函数。

Critical Sections注意事项:

  • 一旦线程进入一个Critical Section,再调用LeaveCriticalSection函数之前,就能一直重复的进入该Critical Section。
  • 千万不要在一个Critical section之中调用Sleep()或者任何Wait… API函数。
  • 如果进入Critical section的那个线程结束了或者当掉了,而没有调用LeaveCriticalSection函数,系统就没有办法将该Critical Section清除。

Critical Section的优点:

  • 相对于Mutex来说,其速度很快。锁住一个未被拥有的mutex要比锁住一个未被拥有的critical section,需要花费几乎100倍时间。(critical section不需要进入操作系统核心)

Critical Section的缺陷:

  • Critical Section不是核心对象,无法WaitForSingleObject,没有办法解决死锁问题(一个著名的死锁问题:哲学家进餐问题)
  • Critical Section不可跨进程
  • 无法指定等待结束的时间长度
  • 不能够同时有一个Critical section被等待
  • 无法侦测是否已被某个线程放弃

Mutex

Mutex可以在不同的线程之间实现排他性占有,甚至即使那些线程属于不同进程。Mutex和临界区类似,但是它牺牲速度以增加弹性。两者区别:

  • 锁住一个未被拥有的Mutex,比锁住一个未被拥有的critical section,需要花费几乎100倍的时间(临界区非核心对象,不需要进去操作系统核心,只要在用户态进行操作即可)。
  • Mutex可以跨进程,而临界区只能在同一进程使用
  • 等待一个Mutex,可以指定结束等待的时间长度。

Mutex的激发状态:当没有任何线程拥有该Mutex而且有一个线程正以Wait…()等待该Mutex,该mutex就会短暂的出现激发状态。

如果线程拥有一个mutex而在结束前没有调用ReleaseMutex函数,mutex不会被摧毁,取而代之的是,该mutex会被视为未拥有以及未被激发。而下一个等待的线程会被以WAIT_ABANDONED_0通知。

使用示例:

1
2
3
4
5
6
7
8
9
10
HANDLE hMutex ; /* global attributes */
hMutex = CreateMutex (
NULL, /* default event attributes */
false, /* default not initially owned */
NULL /* unnamed */);
DWORD dwWaitResult = WaitForSingleObject (hMutex , INFINITE );
if (dwWaitResult == WAIT_OBJECT_0 ) {
/* wait succeed, do what you want */
}
ReleaseMutex(hMutex );

示例解释:

1、HMutex在创建时为未被拥有未激发状态;
2、调用Wait…()函数,线程获得hMutex的拥有权,HMutex短暂变成激发状态,然后Wait…()函数返回,此时HMutex的状态是被拥有未激发
3、ReleaseMutex之后,HMutex的状态变为未被拥有未激发状态

Mutex注意事项:

  • Mutex的拥有权并非属于哪个产生它的哪个线程,而是那个最后对此mutex进行Wait…()操作并且尚未进行ReleaseMutex()操作的线程。
  • 如果线程拥有一个mutex而在结束前没有调用ReleaseMutex(),mutex不会被摧毁,取而代之,该mutex会被视为“未被拥有”以及“未被激发”,而下一个等待中的线程会被以WAIT_ABANDONED_0通知。
  • Wait…()函数在Mutex处于未被拥有未被激发状态时返回。
  • 将CreateMutex的第二个参数设为true,可以阻止race condition,否则调用CreateMutex的线程还未拥有Mutex,发生了context switch,就被别的线程拥有了。

Mutex优点

  • 核心对象,可以调用Wait…() API函数
  • 跨线程、跨进程、跨用户(将CreateMutex的第三个参数前加上"Global//")
  • 可以具名,可以被其他进程开启
  • 只能被拥有它的哪个线程释放

Mutex缺点

  • 等待代价比较大

Semaphores

Semaphore被用来追踪有限的资源。

和Mutex的对比

  • mutex是semaphore的退化,令semahpore的最大值为1,那就是一个mutex
  • semaphore没有拥有权的概念,也没有wait_abandoned状态,一个线程可以反复调用Wait…()函数以产生锁定,而拥有mutex的线程不论在调用多少次Wait…()函数也不会被阻塞。
  • 在许多系统中都有semaphore的概念,而mutex则不一定。
  • 调用ReleaseSemaphore()的那个线程并不一定是调用Wait…()的那个线程,任何线程都可以在任何时间调用ReleaseSemaphore,解除被任何线程锁定的Semaphore。

Semaphore优点:

  • 核心对象
  • 可以具名,可以被其他进程开启
  • 可以被任何一个线程释放

Semaphore缺点

Event

Event通常用于overlapped I/O,或者用来设计某些自定义的同步对象。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HANDLE hEvent ; /* global attributes */
hEvent = CreateEvent (
NULL, /* default event attributes */
true, /* mannual reset */
false, /* nonsignaled */
NULL /* unnamed */
);

SetEvent(hEvent);
PulseEvent(hEvent);
DWORD dwWaitResult = WaitForSingleObject (hEvent , INFINITE );
ResetEvent(hEvent);
if (dwWaitResult == WAIT_OBJECT_0 )
{
/* wait succeed, do what you want */
...
ResetEvent(hEvent );
}

示例解释:

1、CreateEvent默认为非激发状态、手动重置
2、SetEvent把hEvent设为激发状态
3、在手动重置情况下(bManualReset=true),PulseEvent把event对象设为激发状态,然而唤醒所有等待中的线程,然后恢复为非激发状态;
4、在自动重置情况下(bManualReset=false),PulseEvent把event对象设为激发状态,然而唤醒一个等待中的线程,然后恢复为非激发状态;
5、ResetEvent将hEvent设为未激发状态

Event注意事项:

  • CreateEvent函数的第二个参数bManualReset若为false,event会在变成激发状态(因而唤醒一个线程)之后,自动重置为非激发状态;
  • CreateEvent函数的第二个参数bManualReset若为true,event会在变成激发状态(因而唤醒一个线程)之后,不会自动重置为非激发状态,必须要手动ResetEvent;

Event优点:

  • 核心对象
  • 其状态完全由程序来控制,其状态不会因为Wait…()函数的调用而改变。
  • 适用于设计新的同步对象
  • 可以具名,可以被其他进程开启

Event缺点:

  • 要求苏醒的请求并不会被存储起来,可能会遗失掉。如果一个AutoReset event对象调用SetEvent或PulseEvent,而此时并没有线程在等待,这个event会被遗失。如Wait…()函数还没来得及调用就发生了Context Switch,这个时候SetEvent,这个要求苏醒的请求会被遗失,然后调用Wait…()函数线程卡死。

替代多线程

Overlapped I/O

Win32之中三个基本的I/O函数:CreateFile()、ReadFile()和WriteFile()。

  • 设置CreateFile()函数的dwFlagsAndAttributes参数为FILE_FLAG_OVERLAPPED,那么对文件的每一个操作都将是Overlapped。此时可以同时读写文件的许多部分,没有目前的文件位置的概念,每一次读写都要包含其文件位置。
  • 如果发出许多Overlapped请求,那么执行顺序无法保证。
  • Overlapped I/O不能使用C Runtime Library中的stdio.h函数,只能使用ReadFile()和WriteFile()来执行I/O。

Overlapped I/O函数使用OVERLAPPED结构来识别每一个目前正在进行的Overlapped操作,同时在程序和操作系统之间提供了一个共享区域,参数可以在该区域双向传递。

多进程

如果一个进程死亡,系统中的其他进程还是可以继续执行。多进程程序的健壮性远胜于多线程。因为如果多个线程在同一个进程中运行,那么一个误入歧途的线程就可能把整个进程给毁了。

另一个使用多重进程的理由是,当一个程序从一个作业平台被移植到另一个作业平台,譬如Unix(不支持线程,但进程的产生与结束的代价并不昂贵),Unix应用程序往往使用多个进程,如果移植成为多线程模式,可能需要大改。

多线程程序设计成功秘诀

  • 各线程的数据要分离开来,避免使用全局变量
  • 不要再线程之间共享GDI对象
  • 确定你知道你的线程状态,不要径自结束程序而不等待它们结束
  • 让主线程处理用户界面(UI)

附录一:Context Switching、Race Condition、Atomic Operations

注意几个核心概念:

  • Context Switching

线程中断时,CPU把当前线程的寄存器内容拷贝到堆栈中,再把它从堆栈中拷贝到一个CONTEXT结构中,来存储当前线程的状态。要切换不同的线程,操作系统应先切换隶属之进程的内存,然后恢复该线程放在Context结构中的寄存器值。

  • Race Condition

抢占性多任务系统,两个线程的执行次序不可预期,可能造成竟态条件。至于原子操作,是指一个操作能够不受中断地完成。由于一句代码能够扩展成很多句机器指令,在多线程的情况下,原子操作可以防止简单的一句代码被中断,产生竟态条件。

  • Atomic Operations

Atomic Operation可以更加广义一点,指一些列操作不受Context Switching导致的Race Conditions的影响,能够正确的执行的操作。

附录二:单线程和多线程版本C Runtime Library

在编译的时候使用/MD或/MDd选项,表示使用多线程版本的C runtime library。多线程版函数确保你的输出不会中断。

区别

  • 如errno之类的变量,在多线程版本里每个线程各拥有一个
  • 多线程版本中的数据结构以同步机制加以保护

VS2013中有如下Runtime Library

  • Multi-threaded (/MT): multithread, static version
  • Multi-threaded Debug (/MTd): multithread, static debug version
  • Multi-threaded DLL (/MD): multithread- and DLL-specific version
  • Multi-threaded Debug DLL (/MDd):multithread- and DLL-specific debug version

/MD和/MDd将是潮流所趋,/ML和/MLd方式请及时放弃,其问题主要如下:

  • 最终生成的二进制代码因链入庞大的运行时库实现而变得非常臃肿。
  • 当某项目以静态链接库的形式嵌入到多个项目,则可能造成运行时库的内存管理有多份,最终将导致致命的“Invalid Address specified to RtlValidateHeap”问题。

注意:MFC程序必须使用多线程版本的C Runtime Library,否则会在链接时获得”undefined function“的错误消息。

附录三:线程对象和线程的不同

线程的handle是指向线程核心对象,而不是指向线程本身。线程对象的默认计数为2,当你调用CloseHandle时,引用计数下降1,当线程结束时,引用计数再降1。只有当两件事情都发生时,这个对象才会被真正清除。

附录四:GDI对象和核心对象

包括:进程、线程、文件、事件、信号量、互斥器、管道

不同:

  • GDI对象有单一拥有者,核心对象有一个以上的拥有者。核心对象保持一个引用计数,以记录有多少handles对应到此对象。

程序员不能选择由进程或线程拥有对象,一切都得视对象类型而定,区分一个对象拥有者是进程或线程是很重要的,因为会决定系统什么时候做清除善后工作。由于引用计数的设计,对象有可能在产生该对象之进程结束之后,还继续幸存。

附录五:被激发的对象(Signaled Objects)

可被WaitForSingleObject使用的核心对象有两种状态:激发与未激发,WaitForSingleObject会在目标物变成激活的时候返回。

1、线程、进程

当线程正在执行时,线程对象出于未激发状态,当线程结束时,线程对象就被激发了。

进程类似。

2、Event

1
2
3
SetEvent()
PulseEvent()
ResetEvent()

3、Mutex

如果Mutex没有被任何线程拥有,他就是出于激发状态。一旦一个等待mutex的函数返回了,mutex就自动重置为未激发状态。

4、Semaphore

Semaphore有一个计数器,当计数器内容大于0时,Semaphore出于激发状态,当计数器内容等于0时,Semaphore处于未激发状态。

附录六:如何选择_beginthreadex() and CreateThread()

其实你只要记住这句:

如果你写一个多线程程序,而且没有使用MFC,那么你应该总是和多线程版本的C run-time Library链接,并且总是以beginthreadex()和endthreadex()取代CreateThread()和ExitThread()。

_beginthreadex()函数的设计是为了保证多线程情况下的安全,其相对于CreateThread()函数多了层外包,其必须为每一个由它开启和结束的线程做一些簿记工作。

但是为什么要增加一些簿记工作?Win32多线程编程貌似没有讲清楚。后面会深入学习。

至于什么时候使用单线程版本的Runtime Library和CreateProcess(),不建议考虑这个问题,弄混乱了就不好了。只要记住上面的那句话就行了。

使用_beginthreadex()函数需要注意以下:

  • _beginthreadex()函数传回的线程handle,必须被强制转换类型为HANDLE后才能使用。
  • 同时必须对着_beginthreadex()的传回值调用CloseHandle()函数。
  • 绝对不要在一个“以beginthreadex()启动的线程"中调用ExitThread(),必须使用endthreadex(),否则C Runtime Library就没有机会释放”为该线程而配置的资源“。

避免_beginthread()是出于:beginthread()传回的HANLDE也许可用,也许不可用。被beginthread()产生出来的线程所做的第一件事就是关闭自己的handle。这样做是为了隐藏Win32的实现细节,但其传回来的handle可能在当时是不可用的。

Powered by Hexo & Theme Keep
This site is deployed on
Unique Visitor Page View