第 14 章 MFC 多线程程序设计

线程(thread),是线程(thread of execution)的简单称呼。"Thread" 这个字的原意是「线」。中文字里头的「线程」也有「线」的意思,所以我采用「线程」、「线程」这样的中文名称。如果你曾经看过「多线」这个名词,其实就是本章所谓的「多线程」。

我曾经在第1章以三两个小节介绍 Win32 环境下的进程与线程观念,并且以程序直接调用 CreateThread 的形式,示范了几个 Win32 小例子。现在我要更进一步从操作系统的层面谈谈线程的学理基础,然后带引各位看看 MFC 对于「线程」支持了什么样的类。然后,实际写个MFC多线程程序。

从操作系统层面看线程

书籍推荐:如果要从操作系统层面来了解线程,Matt Pietrek 的 Windows 95 System Programming SECRETS(Windows 95 系统程序设计大奥秘/侯俊杰译/旗标出版)无疑是最佳知识来源。Matt 把操作系统核心模块(KERNEL32.DLL)中用来维护线程生存的数据结构都挖掘出来,非常详尽。这是对线程的最基础认识,直达其灵魂深处。

你已经知道,CreateThread 可以产生一个线程,而「线程」的本体就是 CreateThread 第3个参数所指定的一个函数(一般我们称之为「线程函数」)。这个函数将与目前的「执行事实」同时并行,成为另一个「执行事实」。线程函数的执行期,也就是该线程的生命期。

操作系统如何造成这种多任务并行的现象?线程对于操作系统的意义到底是什么?系统如何维护许多个线程?线程与其父亲大人(进程)的关系如何维持?CPU 只有一个,线程却有好几个,如何摆平优先权与排程问题?这些疑问都可以在下面各节中获得答案。

三个观念:模块、进程、线程

试着回答这个问题:进程(process)是什么?给你一分钟时间。

z z z z z...

你的回答可能是:『一个可执行文件执行起来,就是一个进程』。唔,也不能算错。但能不能够有更具体的答案?再问你一个问题:模块(module)是什么?可能你的回答还是:『一个可执行文件执行起来,就是一个模块』。这也不能够算错。但是你明明知道,模块不等于进程。KERNEL32 DLL 是一个模块,但不是一个进程;Scribble EXE 是一个模块,也是一个进程。

我们需要更具体的数据,更精准的答案。

如果我们能够知道操作系统如何看待模块和进程,就能够给出具体的答案了。一段可执行的程序(包括 EXE 和 DLL),其程序代码、数据、资源被加载到内存中,由系统建置一个数据结构来管理它,就是一个模块。这里所说的数据结构,名为 Module Database(MDB),其实就是PE格式中的PE表头,你可以从 WINNT.H 檔中找到一个IMAGE_NT_HEADER 结构,就是它。

好,解释了模块,那么进程是什么?这就比较抽象一点了。这样说,进程就是一大堆拥有权(ownership)的集合。进程拥有地址空间(由 memory context 决定)、动态配置而来的内存、文件、线程、一系列的模块。操作系统使用一个所谓的 Process Database(PDB)数据结构,来记录(管理)它所拥有的一切。

线程呢?线程是什么?进程主要表达「拥有权」的观念,线程则主要表达模块中的程序代码的「执行事实」。系统也是以一个特定的数据结构(Thread Database,TDB)记录线程的所有相关数据,包括线程局部储存空间(Thread Local Storage,TLS)、消息队列、handle 表格、地址空间(Memory Context)等等等。

最初,进程是以一个线程(称为主线程,primary thread)做为开始。如果需要,进程可以产生更多的线程(利用 CreateThread),让 CPU 在同一时间执行不同段落的代码。当然,我们都知道,在只有一颗 CPU 的情况下,不可能真正有多任务的情况发生,「多个线程同时工作」的幻觉主要是靠排程器来完成 -- 它以一个硬件定时器和一组复杂的游戏规则,在不同的线程之间做快速切换动作。以Windows 95 和Windows NT而言,在非特殊的情况下,每个线程被CPU照顾的时间(所谓的timeslice)是20个milliseconds。

如果你有一部多 CPU 计算机,又使用一套支持多 CPU 的操作系统(如 Windows NT),那么一个 CPU 就可以分配到一个线程,真正做到实实在在的多任务。这种操作系统特性称为symmetric multiprocessing(SMP)。Windows 95 没有SMP性质,所以即使在多CPU计算机上跑,也无法发挥其应有的高效能。

图 14-1 表现出一个进程(PDB)如何透过「MODREF 串列」连接到其所使用的所有模组。图 14-2 表现出一个模块数据结构(MDB)的细部内容,最后的 DataDirectory[16] 记录着16个特定节区(sections)的地址,这些sections 包括程序代码、数据、资源。图14-3 表现出一个线程数据结构(PDB)的细部内容。

当Windows加载器将程序加载内存中,KERNEL32 挖出一些内存,构造出一个PDB、一个TDB、一个以上的MDBs(视此程序使用到多少DLL 而定)。针对TDB,操作系统又要产生出memory context(就是在操作系统书籍中提到的那些所谓 page tables)、消息队列、handle 表格、环境数据结构(EDB)...。当这些系统内部数据结构都构造完毕,指令指位器(Instruction Pointer)移到程序的进入点,才开始程序的执行。

图14-1 进程(PDB)透过「MODREF串列」连接到其所使用的所有模块

线程优先权(Priority)

我想我们现在已经能够用很具体的形象去看所谓的进程、模块、线程了。「执行事实」发生在线程身上,而不在进程身上。也就是说,CPU 排程单位是线程而非进程。排程器据以排序的,是每个线程的优先权。

优先权的设定分为两个阶段。我已经在第1章介绍过。线程的「父亲大人」(进程)拥有所谓的优先权等级(priority class,图 1-7),可以在 CreateProcess 的参数中设定。线程基本上继承自其「父亲大人」的优先权等级,然后再加上 CreateThread 参数中的微调差额(-2~+2)。获得的结果(图 1-8)便是线程的所谓base priority,范围从0~31,数值愈高优先权愈高。::SetThreadPriority 是调整优先权的工具,它所指定的也是微调差额(-2~+2)。

图14-2 模块数据结构 MDB 的细部内容(数据整理自Windows 95 System Programming SECRETS, Matt Pietrek, IDG Books)

线程排程(Scheduling)

排程器挑选「下一个获得 CPU 时间的线程」的唯一依据就是:线程优先权。如果所有等待被执行的线程中,有一个是优先权16,其它所有线程都是优先权15(或更低),那么优先权16者便是下一个夺标者。如果线程A和B同为优先权16,排程器会挑选等待比较久的那个(假设为线程A)。当A的时间切片(timeslice)终了,如果B以外的其它线程的优先权仍维持在 15(以下),线程B就会获得执行权。

「如果B以外的其它线程的优先权仍维持在 15(以下)...」,唔,这听起来彷佛优先权会变动似的。的确是。为了避免朱门酒肉臭、路有冻死骨的不公平情况发生,排程器会弹性调整线程优先权,以强化系统的反应能力,并且避免任何一个线程一直未能接受 CPU 的润泽。一般的线程优先权是 7,如果它被切换到前景,排程系统可能暂时地把它调升到8 或9 或更高。对于那些有着输入消息等待被处理的线程,排程系统也会暂时调高其优先权。

对于那些优先权本来就高的线程,也并不是有永久的保障权利。别忘了 Windows 毕竟是个消息驱动系统,如果某个线程调用 ::GetMessage 而其消息队列却是空的,这个线程便被冻结,直到再有消息进来为止。冻结的意思就是不管你的优先权有多高,暂时退出排班行列。线程也可能被以 ::SuspendThread 强制冻结住(::ResumeThread 可以解除冻结)。

图14-3 线程数据结构( PDB)的细部内容(数据整理自Windows 95 System Programming SECRETS,Matt Pietrek,IDG Books)

会被冻结,表示这个线程「要去抓取消息,而线程所附带的消息队列中却没有消息」。如果一个线程完全和 UI 无关呢?是否它就没有消息队列?倒不是,但它的程序代码中没有消息循环倒是事实。是的,这种线程称为 worker thread。正因它不可能会被冻结,所以它绝对不受 Win16Mutex 或其它因素而影响其强制性多任务性质,及其优先权。

Thread Context

Context 一词,我不知道有没有什么好译名,姑且就用原文吧。它的直接意思是「前后关系、脉络;环境、背景」。所以我们可以说Thread Context 是构成线程的「背景」。

那是指什么呢?狭义来讲是指一组缓存器值(包括指令指位器 IP)。因为线程常常会被暂停,被要求把CPU拥有权让出来,所以它必须将暂停之前一刻的状态统统记录下来,以备将来还可以恢复。

你可以在WINNT.H中找到一个CONTEXT资料结构 , 它可以用来储存Thread Context 。::GetThreadContext 和::SetThreadContext 可 以 取 得 和 设 定 某个线程的context,因而改变该线程的状态。这已经是非常低阶的行为了。Matt Pietrek在其Windows 95 System Programming SECRETS 一书第10 章,写了一个Win32 API Spy程序,就充份运用了这两个函数。

我想我们在操作系统层面上的线程学理基础已经足够了,现在让我们看看比较实际一点的东西。

从程序设计层面看线程

书籍推荐:如果要从程序设计层面来了解线程,Jim Beveridge 和 Robert Wiener 合着的 Multithreading Applications in Win32 (Win32 多线程程序设计/侯俊杰译/碁峰出版)是很值得推荐的一份知识来源。这本书介绍线程的学理观念、程序方法、同步控制、资料一致性的保持、C runtime library 的多线程版本、C++ 的多线程程序方法、MFC 中的多线程程序方法、除错、进程通讯(IPC)、DLLs...,以及约 50 页的实际应用。

书籍推荐:Jeffrey Richter 的 Advanced Windows 在进程与线程的介绍上(第2章和第3章),也有非常好的表现。他的切入方式是详细而深入地叙述相关 Win32 API 的规格与用法。并举实例左证。

如何产生线程?我想各位都知道了,::CreateThread 可以办到。图 14-4 是与线程有关的 Win32 API。

与线程有关的Win32 API     功能
AttachThreadInput          将某个线程的输入导向另一个线程
CreateThread               产生一个线程
ExitThread                 结束一个线程
GetCurrentThread           取得目前线程的  handle
GetCurrentThreadId         取得目前线程的  ID
GetExitCodeThread          取得某一线程的结束代码(可用以决定线程是否已结束)
GetPriorityClass           取得某一进程的优先权等级
GetQueueStatus             传回某一线程的消息队列状态
GetThreadContext           取得某一线程的 context
GetThreadDesktop           取得某一线程的  desktop 对象
GetThreadPriority          取得某一线程的优先权
GetThreadSelectorEntry     除错器专用,传回指定之线程的某个selector 的LDT 记录项
ResumeThread               将某个冻结的线程恢复执行
SetPriorityClass           设定优先权等级
SetThreadPriority          设定线程的优先权
Sleep                      将某个线程暂时冻结。其它线程将获得执行权。
SuspendThread              冻结某个线程
TerminateThread            结束某个线程
TlsAlloc                   配置一个TLS(Thread Local Storage)
TlsFree                    释放一个TLS(Thread Local Storage)
TlsGetValue                取得某个TLS(Thread Local Storage)的内容
TlsSetValue                设定某个TLS(Thread Local Storage)的内容
WaitForInputIdle           等待,直到不再有输入消息进入某个线程中

图14-4 与线程有关的 Win32 API函数

注意,多线程并不能让程序执行得比较快(除非是在多 CPU 机器上,并且使用支持symmetric multiprocessing 的操作系统),只是能够让程序比较「有反应」。试想某个程序在某个选单项目被按下后要做一个小时的运算工作,如果这份工作在主线程中做,而且没有利用 PeekMessage 的技巧时时观看消息队列的内容并处理之,那么这一个小时内这个程序的使用者接口可以说是被冻结住了,将毫无反应。但如果沉重的运算工作是由另一个线程来负责,使用者接口将依然灵活,不受影响。

Worker Threads 和 UI Threads

从Windows 操作系统的角度来看,线程就是线程,并未再有什么分类。但从MFC的角度看,则把线程划分为和使用者接口无关的worker threads,以及和使用者接口(UI)有关的UI threads。

基本上,当我们以 ::CreateThread 产生一个线程,并指定一个线程函数,它就是一个 worker thread,除非在它的生命中接触到了输入消息—— 这时候它应该有一个消息循环,以抓取消息,于是该线程摇身一变而为 UI thread。

注意,线程本来就带有消息队列,请看图 14-3 的 TDB 结构。而如果线程程序代码中带有一个消息循环,就称为 UI thread。

错误观念

我记得曾经在微软的技术文件中,也曾经在微软的范例程序中,看到他们鼓励这样的作法:为程序中的每一个窗口产生一个线程,负责窗口行为。这种错误的示范尤其存在于 MDI 程序中。是的,早期我也沾沾自喜地为 MDI 程序的每一个子窗口设计一个线程。基本上这是错误的行为,要付出昂贵的代价。因为子窗口一切换,上述作法会导至线程也切换,而这却要花费大量的系统资源。比较好的作法是把所有UI(User Interface)动作都集中在主线程中,其它的「纯种运算工作」才考虑交给worker threads去做。

正确态度

什么是使用多线程的好时机呢?如果你的程序有许多事要忙,但是你还要随时保持注意某些外部事件(可能来自硬件或来自使用者),这时就适合使用多线程来帮忙。

以通讯程序为例。你可以让主线程负责使用者接口,并保持中枢的地位。而以一个分离的线程处理通讯端口。

MFC 多线程程序设计

我已经在第1章以一个小节介绍了 Win32 多线程程序的写法,并给了一个小范例MltiThrd。这一节,我要介绍 MFC 多线程程序的写法。

探索 CWinThread

就像CWinApp 对象代表一个程序本身一样,CWinThread 对象代表一个线程本身。这个 MFC 类我们曾经看过,第6章讲「MFC 程序的生死因果」时,讲到「CWinApp::Run——程序生命的活水源头」,曾经追踪过CWinApp::Run 的源头 CWinThread::Run(里面有一个消息循环)。可见程序的「执行事实」系发生在 CWinThread 对象身上,而 CWinThread 对象必须要(必然会)产生一个线程。

我希望「CWinThread 对象必须要(必然会)产生一个线程」这句话不会引起你的误会,以为程序在application object(CWinApp 对象)的构造函数必然有个动作最终调用到CreateThread 或 _beginthreadex。不,不是这样。想想看,当你的 Win32 程序执行起来,你的程序并没有调用 CreateProcess 为自己做出代表自己的那个进程,也没有调用CreateThread 为自己做出代表自己的主线程(primary thread)的那个线程。为你的程序产生第一个进程和线程,是系统加载器以及核心模块(KERNEL32)合作的结果。

所以,再次循着第6章一步步剖析的步骤,MFC 程序的第一个动作是 CWinApp::CWinApp(比 WinMain 还早),在那里没有「产生线程」的动作,而是已经开始在收集线程的相关信息了:

// in MFC 4.2 APPCORE.CPP
CWinApp::CWinApp(LPCTSTR lpszAppName)
{
    ...
    // initialize CWinThread state
    AFX_MODULE_STATE* pModuleState = _AFX_CMDTARGET_GETSTATE();
    AFX_MODULE_THREAD_STATE* pThreadState = pModuleState->m_thread;
    ASSERT(AfxGetThread() == NULL);
    pThreadState->m_pCurrentWinThread = this;
    ASSERT(AfxGetThread() == this);
    m_hThread = ::GetCurrentThread();
    m_nThreadID = ::GetCurrentThreadId();
    ...
}

虽然MFC程序只会有一个CWinApp对象,而CWinApp派生自CWinThread,但并不是说一个MFC程序只能有一个CWinThread 对象。每当你需要一个额外的线程,不应该在MFC程序中直接调用::CreateThread 或 _beginthreadex,应该先产生一个CWinThread 对象,再调用其成员函数 CreateThread 或全局函数 AfxBeginThread 将线程产生出来。当然,现在 你必然已经可以推测到,CWinThread::CreateThread或AfxBeginThread内部呼 叫了::CreateThread或_beginthreadex(事实上答案是_beginthreadex)。

这看起来颇有值得商议之处:为什么CWinThread 构造函数不帮我们调用 AfxBeginThread 呢?似乎CWinThread 为德不卒。

图 14-5 就是CWinThread 的相关原始代码。

图14-5 CwinThread的相关原始代码

产生线程,为什么不直接用::CreateThread 或_beginthreadex?为什 么要透过CWinThread对象 ?我想你可以轻易从MFC原始代码中看出,因为CWinThread::CreateThread 和AfxBeginThread 不只是::CreateThread 的一层包装,更做了一些application framework 所需的内部数据初始化工作,并确保使用正确的C runtime library 版本。原始代码中有:

#ifndef _MT
... // 做些设定工作,不产生线程,回返。
#else
... // 真正产生线程,回返。
#endif //!_MT)

的动作,只是被我删去未列出而已。

接下来我要把worker thread和UI thread 的产生步骤做个整理。它们都需要调用AfxBeginThread 以产生一个CWinThread 对象,但如果要产生一个 UI thread,你还必须先定义一个 CWinThread 派生类。

产生一个Worker Thread

Worker thread 不牵扯使用者接口。你应该为它准备一个线程函数,然后调用AfxBeginThread:

CWinThread* pThread = AfxBeginThread(ThreadFunc, &Param);
...
UINT ThreadFunc (LPVOID pParam)
{
    ...
}

AfxBeginThread 事实上一共可以接受六个参数,分别是:

CWinThread* AFXAPI AfxBeginThread(AFX_THREADPROC pfnThreadProc,
LPVOID pParam,
int nPriority = THREAD_PRIORITY_NORMAL,
UINT nStackSize = 0,
DWORD dwCreateFlags = 0,
LPSECURITY_ATTRIBUTES lpSecurityAttrs= NULL);

参数一pfnThreadProc 表示线程函数。参数二pParam 表示要传给线程函数的参数。参数三nPriority表示优先权的微调值,预设为 THREAD_PRIORITY_NORMAL,也就是没有微调。参数四 nStackSize 表示堆栈的大小,默认值 0 则表示堆栈最大容量为1MB。参数五 dwCreateFlags 如果为默认值 0,就表示线程产生后立刻开始执行;如果其值为 CREATE_SUSPENDED,就表示线程产生后先暂停执行。之后你可以使用CWinThread::ResumeThread 重新执行它。参数六 lpSecurityAttrs 代表新线程的安全防护属性。默认值 NULL 表示此一属性与其产生者(也是个线程)的属性相同。

在这里我们遭遇到一个困扰。线程函数是由系统调用的,也就是个 callback 函数,不容许有 this 指针参数。所以任何一般的 C++ 类成员函数都不能够拿来当做线程函数。它必须是个全局函数,或是个 C++ 类的 static 成员函数。其原因我已经在第6章的「Callback 函数」一节中描述过了,而采用全局函数或是C++ static 成员函数,其间的优劣因素我也已经在该节讨论过。

线程函数的类型AFX_THREADPROC定义于AFXWIN.H 之中:

// in AFXWIN.H
typedef UINT (AFX_CDECL *AFX_THREADPROC)(LPVOID);

所以你应该把本身的线程函数声明如下(其中的 pParam 是个指针,在实用上可以指向程序员自定的数据结构):

UINT ThreadFunc (LPVOID pParam);

否则,编译时会获得这样的错误消息:

error C2665: 'AfxBeginThread' : none of the 2 overloads can convert
parameter 1 from type 'void (unsigned long *)'

有时候我们会让不同的线程使用相同的线程函数,这时候你就得特别注意到线程函数使用全局变量或静态变量时,数据共享所引发的严重性(有好有坏)。至于放置在堆栈中的变量或对象,都不会有问题,因为每一个线程自有一个堆栈。

产生一个UI Thread

UI thread 可不能够光由一个线程函数来代表,因为它要处理消息,它需要一个消息循环。好得很,CWinThread::Run 里头就有一个消息循环。所以,我们应该先从CWinThread派生一个自己的类,再调用 AfxBeginThread 产生一个 CWinThread 对象:

class CMyThread : public CWinThread
{
    DECLARE_DYNCREATE(CMyThread)
    public:
    void BOOL InitInstance();
};
IMPLEMENT_DYNCREATE(CMyThread, CWinThread)
BOOL CMyThread::InitInstance()
{
    ...
}
CWinThread *pThread = AfxBeginThread(RUNTIME_CLASS(CMyThread));

我想你对RUNTIME_CLASS 宏已经不陌生了,第3章和第8章都有这个宏的原始代码展现以及意义解释。AfxBeginThread 是上一小节同名函数的一个 overloaded 函数,一共可以接受五个参数,分别是:

CWinThread* AFXAPI AfxBeginThread(CRuntimeClass* pThreadClass,
int nPriority = THREAD_PRIORITY_NORMAL,
UINT nStackSize = 0,
DWORD dwCreateFlags = 0,
LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL);

最后四个参数的意义和默认值比上一节同名函数相同,但是少接受一个 LPVOID pParam 参数。

你可以在AFXWIN.H 中找到CWinThread的定义:

class CWinThread : public CCmdTarget
{
    DECLARE_DYNAMIC(CWinThread)
    BOOL CreateThread(DWORD dwCreateFlags = 0, UINT nStackSize = 0,
    LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL);
    ...
    int GetThreadPriority();
    BOOL SetThreadPriority(int nPriority);
    DWORD SuspendThread();
    DWORD ResumeThread();
    BOOL PostThreadMessage(UINT message, WPARAM wParam, LPARAM lParam);
    ...
};

其中有许多成员函数和图14-4中的Win32 API函数有关。在CWinThread的成员函数中,有五个函数只是非常单纯的 Win32 API 的包装而已,它们被定义于AFXWIN2.INL文件中:

// in AFXWIN2.INL
// CWinThread
_AFXWIN_INLINE BOOL CWinThread::SetThreadPriority(int nPriority)
{ ASSERT(m_hThread!=NULL);return ::SetThreadPriority(m_hThread, nPriority); }
_AFXWIN_INLINE int CWinThread::GetThreadPriority()
{ ASSERT(m_hThread != NULL); return ::GetThreadPriority(m_hThread); }
_AFXWIN_INLINE DWORD CWinThread::ResumeThread()
{ ASSERT(m_hThread != NULL); return ::ResumeThread(m_hThread); }
_AFXWIN_INLINE DWORD CWinThread::SuspendThread()
{ ASSERT(m_hThread != NULL); return ::SuspendThread(m_hThread); }
_AFXWIN_INLINE BOOL CWinThread::PostThreadMessage(UINT message, WPARAM wParam, LPARAM lParam)
{ ASSERT(m_hThread != NULL); return ::PostThreadMessage(m_nThreadID, message, wParam, lParam); }

线程的结束

既然worker thread 的生命就是线程函数本身,函数一旦 return,线程也就结束了,自然得很。或者线程函数也可以调用 AfxEndThread,结束一个线程。

UI 线程因为有消息循环的关系,必须在消息队列中放一个 WM_QUIT,才能结束线程。放置的方式和一般Win32程序一样,调用::PostQuitMessage 即可办到。亦或者,在线程的任何一个函数中调用AfxEndThread,也可以结束线程。

AfxEndThread 其实也是个外包装,其内部调用 _endthreadex,这个动作才真正把线程结束掉。

别忘了,不论worker thread或UI thread,都需要一个CWinThread 对象,当线程结束,记得把该对象释放掉(利用 delete)。

线程与同步控制

看起来线程的诞生与结束,以及对它的优先权设定、冻结、重新启动,都很容易。但是我必须警告你,多线程程序的设计成功关键并不在此。如果你的每一个线程都非常独立,彼此没有干联,也就罢了。但如果许多个线程互有关联呢?有经验的人说多线程程序设计有多复杂多困难,他们说的并不是线程本身,而是指线程与线程之间的同步控制。

原因在于,没有人能够预期线程的被执行。在一个合作型多任务系统中(例如 Windows 3.x),操作系统必须得到程序的允许才能够改变线程。但是在强制性多任务系统中(如Win95或WinNT),控制权被排程器强制移转,也因此两个线程之间的执行次序变得不可预期。这不可预期性造成了所谓的 race conditions。

假设你正在一个文件服务器中编辑一串电话号代码。文件打开来内容如下:

Charley     572-7993
Graffie     573-3976
Dennis      571-4219

现在你打算为Sue加上一笔新数据。正当你输入 Sue电话号代码的时候,另一个人也打开文件并输入另一笔有关于 Jason 的数据。最后你们两人也都做了存文件动作。谁的数据会留下来?答案是比较晚存盘的那个人,而前一个人的输入会被覆盖掉。这两个人面临的就是 race condition。

再举一个例子。你的程序产生两个线程,A和B。线程B的任务是设定全局变量X。线程A则要去读取X。假设线程B先完成其工作,设定了X,然后线程A才执行,读取X,这是一种好的情况,如图 14-6a。但如果线程A先执行起来并读取全局变量X,它会读到一个不适当的值,因为线程B还没有完成其工作并设定适当的X。如图14-6b。这也是race condition。

另一种线程所造成的可能问题是:死结(deadlock)。图14-7可以说明这种情况。

图14-6a race condition(good)

图14-6b race condition(bad)

图14-7 死结(deadlock)

要解决这些问题,必须有办法协调各个线程的执行次序,让某个线程等待某个线程。Windows 系统提供四种同步化机制,帮助程序进行这种工作:

1. Critical Section(关键局部)

2. Semaphore(号志)

3. Event(事件)

4. Mutex(Mutual Exclusive,互斥器)

MFC 也提供了四个对应的类:

MFC 多线程程序实例

我将在此示范如何把第1章最后的一个 Win32 多线程程序 MltiThrd 改装为 MFC 程序。我只示范主架构(与 CWinThread、AfxBeginThread、ThreadFunc 有关的部份),程序绘图部份留给您做练习。

首先我利用MFC AppWizard 产生一个Mltithrd 项目,放在书附盘片的 Mltithrd.14 子目录中,并接受 MFC AppWizard 的所有预设选项。

接下来我在resource.h 中加上一些定义,做为线程函数的参数,以便在绘图时能够把代表各线程的各个长方形涂上不同的颜色:

#define HIGHEST_THREAD     0x00
#define ABOVE_AVE_THREAD   0x3F
#define NORMAL_THREAD      0x7F
#define BELOW_AVE_THREAD   0xBF
#define LOWEST_THREAD      0xFF

然后我在Mltithrd.cpp 中加上一些全局变量(你也可以把它们放在 CMltithrdApp 之中。我只是为了图个方便):

然后在 CMltithrdApp::InitInstance 函数最后面加上一些代码:

这样一来我就完成了五个 worker threads 的产生,并且将其优先权做了 -2~+2 范围之间的微调。

接下来我应该设计线程函数。就如我在第1章已经说过,这个函数的五个线程可以使用同一个线程函数。本例中是设计为全局函数好呢?还是 static 成员函数好?如果是后者,应该成为哪一个类的成员函数好?

为了「要在线程函数做窗口绘图动作」的考虑,我把线程函数设计为 CMltithrdView的一个static 成员函数,并遵循应有的函数类型:

好,到此为止,编译链接,获得的程序将在执行后产生五个线程,并全部冻结。以 Process Viewer(Visual C++ 5.0 所附工具)观察之,证明它的确有六个线程(包括一个主线程以及我们所产生的另五个线程):

接下来,留给你的作业是:

1. 利用资源编辑器为程序加上各选单项目,如图 1-9。

2. 设计上述选单项目的命令处理例程。

3. 在线程函数ThreadFunc 内加上计算与绘图能力。并判断使用者选择何种延迟方式,做出适当反应。