线程间的同步
在 DOS 时代,由于 DOS 并不是一个多任务的环境,所以要想实现多任务显得很勉强。随后有了 Windows 3.X ,虽然此操作系统有了多任务的支持但是严格的说,对多进程的支持并不够,这主要表现在进程间通信方面提供的支持非常少。一些传统的 IPC 方式都没有提供。后来在 WinNT 上完全实现了多进程 / 多线程支持,当然现在的 Windows9X/2K 都完全提供了这方面的支持。本章将介绍进程和线程的概念,及线程间的同步、多线程编程。
11.1 进程和线程的概念
使用 32 位 Windows 操作系统时,它能够同时运行几个程序,这种能力称为多任务处理,处理支持多任务, Win32 操作系统还支持进程中的多线程处理。
在 Win32 操作系统中,采用的是抢先式多任务,这意味程序对 CPU 的占用时间是由系统决定的,系统为每个程序分配一定的 CPU 时间片,当程序的运行时间超过分配的时间片的时间后,系统就会中断该程序并把 CPU 控制权转交给别的程序。术语多任务其实就可以理解为系统可以同时运行对个进程。
进程 ( Process ) 就是一个运行的程序,它有独立的虚拟内存、代码、文件句柄和其他系统资源( 如进程创建的文件、管道、同步对象等)组成 。当启动一个进程时,操作系统会为此进程建立一个 4GB 的地址空间,进程是操作系统分配内存地址空间的单位。
线程 ( Thread ), 是操作系统分配处理器时间的最基本单元。所以一个进程必须包含一个线程,我们称之为主线程。如果需要,进程可以产生更多的线程,让 CPU 在同一时间执行不同段落的代码。进程中的线程是并行执行的,每个线程占用 CPU 的时间由系统来划分,系统不停地在各个线程之间切换。一个进程的所有线程共享它的虚拟地址空间、全局变量和操作系统资源。
简单的说,进程就是程序的一次执行,线程可以理解为进程中的执行的一段程序片段。在一个多任务环境中,下面的概念可以帮助我们理解两者间的差别:
• 进程间是独立的,这表现在内存空间,上下文环境;线程运行在进程空间内。
• 一般来讲(不使用特殊技术)进程是无法突破进程边界存取其他进程内的存储空间;而线程由于处于进程空间内,所以同一进程所产生的线程共享同一内存空间。
• 同一进程中的两段代码不能够同时执行,除非引入线程。
• 线程是属于进程的,当进程退出时,该进程下的所有线程都会被强制退出并清除。
• 线程占用的资源要少于进程所占用的资源。
• 进程和线程都可以有优先级。
• 在线程系统中进程也是一个线程。可以将进程理解为一个程序的主线程。
对于一个进程来说,当应用程序有几个任务要执行时,建立多个线程是很有用的, 之所以有线程这个概念,就是因为以线程为调度对象比以进程为调度对象的执行效率会更高,原因有二:
• 由于创建新进程必须加载代码,而线程要执行的代码已经被映射到进程的地址空间,所以创建、执行线程的速度比进程更快。
• 一个进程的所有线程共享进程的地址空间和全局变量,所以简化了线程之间的通讯。
虽然在进程中进行费时的工作不会导致系统的挂起,但会导致进程本身的挂起。所以,如果进程既要进行长期的工作,又要响应用户的输入,那么它可以启动一个线程来专门负责费时的工作,而进程(即主线程)仍然可以与用户进行交互。
11.2 Win32 的 线程
实际上,进程只是个外壳,真正运行的是它里面的线程,每个进程都有个主线程,就是以 WinMain 函数或 main 函数开始的, WinMain 函数和 main 函数就是主线程的入口函数。每个线程都有个入口函数,当主线程返回时进程便退出。
11.2.1 线程的创建
可以使用 CreateThread 函数来创建线程, CreateThread 的原型如下:
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes ,
DWORD dwStackSize ,
LPTHREAD_START_ROUTINE lpStartAddress ,
LPVOID lpParameter ,
DWORD dwCreationFlags , // creation flags
LPDWORD lpThreadId
);
第一个参数 lpThreadAttributes , 表示创建线程的安全属性,该参数为指向 SECURITY_ATTRIBUTES 结构的指针,该参数可以忽略,通常为 NULL 。
第二个参数 dwStackSize , 指定线程栈的尺寸,该参数可以忽略,通常为 0 ,若设置该参数为 0 表示默认的尺寸与进程主线程栈的尺寸相同。
第三个参数 lpStartAddress ,指定线程开始运行的地址,该参数通常指的就是创建的线程的入口函数。该参数通过函数 DWORD WINAPI ThreadProc(LPVOID lpParameter ); 来定义,其中,这个函数中的参数 lpParameter 得到的数据就是由调用 CreateThread 函数中的第四个参数 lpParameter 传递过来的。
第四个参数 lpParameter , 表示传递给线程入口函数的 32 位的参数。
第五个参数 dwCreationFlags ,用来控制创建的线程的状态,该参数有两个取值:
• CREATE_SUSPEND 表示此线程创建后会呈挂起状态,直到调用 ResumeThread 函数来唤醒,继续执行该线程。
• 0 表示此线程创建后立即运行。
第六个参数 lpThreadId , 用来存放返回的线程 ID 。如果将该参数设为 NULL ,则表示不返回线程 ID 。
11.2.2 线程的终止
终止一个线程的方法不止一种,有以下几种:
• 调用了 ExitThread 函数;
• 线程函数返回:主线程返回导致 ExitProcess 被调用,其他线程返回导致 ExitThread 被调用;
• 调用 ExitProcess 导致进程的所有线程终止;
• 调用 TerminateThread 终止一个线程;
• 调用 TerminateProcess 终止一个进程时,导致其所有线程的终止。
11.3
MFC 的线程处理
在 Win32 API 的基础之上, MFC 提供了处理线程的类和函数。处理线程的类是 CWinThread ,函数是 AfxBeginThread 、 AfxEndThread 等。 CWinThread 是 MFC 线程类,它的成员变量 m_hThread 和 m_hThreadID 分别记录当前线程的句柄和线程 ID 。在 MFC 应用程序中,所有的线程都是 CWinThread 对象,用 AfxBeginThread 函数可以创建一个 CWinThread 对象。 我们以前使用过的 CWinApp 类就是从 CWinThread 类派生的。
Win32 API 中并不区分线程类型,它只需要知道线程的开始地址(即线程的入口函数)以便它开始执行线程,而在 MFC 中支持两种类型的线程:工作者线程和用户界面线程。
工作者线程常 用于完成不要求用户输入的任务,如耗时计算、后台打印之类的任务,因此,它不需要有界面。工作者线程也适用于等待一个事件的发生。例如,从一个应用程序种接收数据,而不必要求用户等待。
用户界面线程一般用于处理用户输入并对用户产生的事件和消息作出应答,用户界面线程必须产生新的用户界面,这是与工作者线程不同。同时用户界面线程内还必须有消息循环,因此,用户界面线程比工作者线程要复杂。
11.3.1 创建工作者线程
工作者线程实际上就是并行执行的一个函数。在进行某项非常耗时的工作时,如果直接调用函数,往往容易导致主线程被阻塞,应用程序不能立即响应用户输入,交互性变差,此时,就应该考虑创建一个工作者线程来处理此类工作了。
一个 MFC 线程,不管是工作者线程还是用户界面线程,都是调用 AfxBeginThread 函数来创建并初始化,只是 AfxBeginThread 被重载成两个版本,一个用于工作者线程,一个用于用户界面线程。
创建工作者线程比较简单,不必从 CWinThread 派生新的线程类,只需要提供一个控制函数 , 由线程启动后执行该函数,然后,使用 AfxBeginThread 创建 MFC 线程对象。
用于创建工作者线程的函数如下:
CWinThread* AFXAPI AfxBeginThread(
AFX_THREADPROC pfnThreadProc ,
LPVOID pParam ,
int nPriority ,
UINT nStackSize ,
DWORD dwCreateFlags ,
LPSECURITY_ATTRIBUTES lpSecurityAttrs
) ;
第一个参数 pfunThreadProc ,表示线程的入口函数地址,函数的原形应该如同: UINT MyControllingFunction( LPVOID pParam );
第二个参数 pParam ,表示传递给线程的参数。
第三个参数 nPriority ,表明线程的优先级 , 默认的优先级别 THREAD_PRIORITY_NORMAL , 如果为 0 ,则与创建该线程的线程相同。该 参数有以下几种级别,下面是从高到低排序的:
THREAD_PRIORITY_TIME_CRITICAL
THREAD_PRIORITY_HIGHEST
THREAD_PRIORITY_ABOVE_NORMAL
THREAD_PRIORITY_NORMAL
THREAD_PRIORITY_BELOW_NORMAL
THREAD_PRIORITY_LOWEST
HREAD_PRIORITY_IDLE
第四个参数 nStackSize ,表示线程的栈大小,如果为 0 表示使用系统默认值。
第五个参数 dwCreateFlags ,表示创建线程时的标记,若该参数为 CREATE_SUSPENDED 表示线程创建后呈挂起状态;如果为 0 ,表示该线程一建立就立即运行。
第六个参数 lpSecurityAttrs ,表示安全属性,该参数一般为 NULL 。
该函数调用成功的返回值是 CWinThread 类的指针,可以通过它实现对线程的控制。在线程函数返回时线程将被结束,在线程内部可以利用 void AfxEndThread( UINT nExitCode ); 结束线程, nExitCode 为退出码。
工作者线程一旦启动,就开始执行控制函数,线程结束,控制函数也就结束了。线程控制函数的原形如下:
UINT MyControllingFunction(LPVOID pParam);
其中的函数名并不是固定的那个函数名,而是用户自定义的函数名 , 可以为任何合法的命名,如下面我们自定义名为 MyThread 。以下是一个控制函数的例子:
UINT MyThread( LPVOID pParam )
{
// 接收一个窗口类指针,然后设置窗口标题
CWnd *pIndex=(CWnd*)pParam;
for(int i=0;i<100;i++)
{
char TMsz[100];
sprintf(TMsz," 工作者线程 : %d",i);
pIndex ->SetWindowText(TMsz);
Sleep(10);
}
return 0; // 返回并退出线程
// 或者调用 void AfxEndThread( UINT nExitCode ); 来退出
}
然后在其他地方调用 AfxBeginThread(MyThread,& m_Index , THREAD_PRIORITY_NORMAL , 0 , 0 , NULL ); 其中, m_Index 传递窗口类指针。
11.3.2 创建用户界面线程
用户界面线程通常用于处理用户的输入,响应用户产生的时间和消息。一旦使用 AppWizard 创建一个 MFC 应用程序,就已经创建了一个用户界面线程——主线程(由 CWinApp 派生的类提供)。
由于用户界面线程需要有自己的窗口界面和消息循环,因此,它的创建要比一个工作者线程的创建复杂的多,不是一个函数就可以解决的。用户界面线程的创建过程一般遵循如下步骤:
• 从 CWinThread 中派生新类
• 重载 CWinThread 的 InitInstance 函数
• 使用 AfxBeginThread 函数创建并启动线程对象
11.4 线程同步
在有若干个线程并行运行的环境里,不同线程之间的同步是至关重要的。“同步”这个词很容易让人误解,因为我们平时说两件事情同步,其意义是将两件事情同时来做;而这里的线程“同步”却恰恰相反,它的目的是避免多个线程同时进行某些操作,使多个线程之间协调工作。
同步可以保证在一个时间内只有一个线程对某个资源(如操作系统资源等共享资源)有控制权。共享资源包括全局变量、公共数据成员或者句柄等。同步还可以使得有关联交互作用的代码按一定的顺序执行。 本节将介绍为什么需要线程同步,以及如何使用同步对象来实现线程同步。
11.4.1 为什么要同步
我们知道,同一进程中的所有线程共享进程的虚拟地址空间,因此,很可能会发生多个线程同时访问同一个对象(包括全局变量、共享资源、 API 函数和 MFC 对象等),这种情况下容易导致程序的错误。例如,如果一个线程正在对一个大尺寸全局变量进行读操作,在未读完时,另一个线程又对该全局变量进行写操作,那么,第一个线程读取的变量值有可能是一种修改过程中的不稳定值。
举个例子,有下面这样一段代码:
int iIndex=0; // 变量 iIndex 为全局变量
DOWRD threadA(void* pD)
{
for(int i=0;i<100;i++)
{
int iCopy= iIndex ;
//Sleep(1000);
iCopy++;
//Sleep(1000);
iIndex =iCopy;
}
}
现在假设有两个线程 threadA1 和 threadA2 在同时运行,那么运行结束后 iIndex 的值会是多少,是 200 吗?不是的,如果我们将 Sleep(1000) 前的注释去掉后我们会很容易明白这个问题,因为在 iIndex 的值被正确修改前它可能已经被其他的线程修改了。这个例子是一个将机器代码操作放大的例子,因为在 CPU 内部也会经历数据读 / 写的过程,而在线程执行的过程中线程可能被中断而让其他线程执行。变量 iIndex 在被第一个线程修改后,写回内存前如果它又被第二个线程读取,然后才被第一个线程写回,那么第二个线程读取的其实是错误的数据,这种情况就称为脏读( dirty read )。这个例子同样可以推广到对文件、资源的使用上。
那么要如何才能避免这一问题呢,假设我们在使用 iCounter 前向其他线程询问一下:有谁在用吗?如果没被使用则可以立即对该变量进行操作,否则等其他线程使用完后再使用,而且在自己得到该变量的控制权后,也要告知其他线程此时不能使用这一变量,直到自己使用完并释放为止。
属于不同进程的线程在同时访问同一内存区域或共享资源时,也会存在同样的问题。因此,在多线程应用程序中,常常需要采取一些措施来同步线程的执行。需要同步的情况包括以下几个方面:
• 当两个或多个线程需要访问每次只能被一个线程访问的共享资源时。例如,当一个线程在写文件时,要求阻止另一个线程读该文件
• 当多个线程的执行有先后顺序,它们之间需要协调运行时。例如,如果 B 线程需要等待 A 线程完成到某一程度时才能运行,那么 B 线程就应该暂时挂起以减少对 CPU 的占用时间,让 A 线程处于运行状态,一旦当 A 线程运行到那一时刻时,才发信号给 B 线程,让 B 线程转到运行状态。
• 在 Windows 95 环境下编写多线程应用程序还需要考虑重入问题。我们知道, Windows NT 是真正的 32 位操作系统,它解决了系统重入问题。而 Windows 95 由于继承了 Windows 3.x 的部分 16 位代码,没能够解决重入问题。这意味着在 Windows 95 中两个线程不能同时执行某个系统功能,否则有可能造成程序错误,甚至会造成系统崩溃。应用程序应该尽量避免发生两个以上的线程同时调用同一个 Windows API 函数的情况
那么,如何实现同步呢? Windows 给我们 提供了多种同步对象供我们使用,并且可以替我们管理同步对象的加锁和解锁。我们需要做的就是对每个需要同步使用的资源产生一个同步对象,在使用该资源前申请加锁,在使用完成后解锁。
下面就会介绍几种同步对象的用法及一组重要的等待函数。
11.4.2 等待函数
Win32 API 提供了一组能使线程阻塞其自身执行的等待函数。这些函数只有在作为其参数的一个或多个同步对象 ( 见下小节 ) 产生信号时才会返回。在超过规定的等待时间后,不管有无信号,函数也都会返回。在等待函数未返回时,线程处于等待状态,此时线程只消耗很少的 CPU 时间。 等待函数分三类:
• 等待单个对象
这类函数包括: SignalObjectAndWait 、 WaitForSingleObject 、 WaitForSingleObjectEx ,其中 最常用的是 WaitForSingleObject ,该函数原形如下:
DWORD WaitForSingleObject(
HANDLE hHandle ,
DWORD dwMilliseconds
);
第一个参数 hHandle ,表示等待的同步对象的句柄。
第二个参数 dwMilliseconds ,表示等待的时间,以 ms 为单位。如果该参数为 0 ,那么函数就测试同步对象的状态并立即返回;如果为 INFINITE 表示无限期的等待。
该函数调用之后的返回值有如下几种:
• WAIT_ABANDONED 在等待的对象为互斥对象时,表明因互斥对象被中断而变为有信号状态,但互斥对象未释放。
• WAIT_OBJECT_0 指定的同步对象 得到使用权, 处于有信号的状态。
• WAIT_TIMEOUT 超过( dwMilliseconds )规定时间返回, 并且同步对象无信号。
• WAIT_FAILED 函数调用失败
在线程调用 WaitForSingleObject 后,如果一直无法得到控制权线程将被挂起,直到超过时间或是获得控制权。
在以下情况下等待函数将返回:
同步对象获得信号时返回;等待时间达到了返回:如果等待时间不限制 (Infinite) ,则只有同步对象获得信号才返回;如果等待时间为 0 ,则在测试了同步对象的状态之后马上返回。
讲到这里我们必须更深入的讲一下 WaitForSingleObject 函数中的对象( Object )的含义,这里的对象是一个具有信号状态的对象,对象有两种状态:有信号 / 无信号。而等待的含义就在于等待对象变为有信号的状态,对于互斥对象来讲如果正在被使用则为无信号状态,被释放后变为有信号状态。当等待成功后 WaitForSingleObject 函数会将互斥对象置为无信号状态,这样其他的线程就不能获得使用权而需要继续等待。 WaitForSingleObject 函数还有进行排队功能,保证先提出等待请求的线程先获得对象的使用权。
• 等待多个对象
这类函数包括: WaitForMultipleObjects 、 WaitForMultipleObjectsEx 、 MsgWaitForMultipleObjects 、 MsgWaitForMultipleObjectsEx 四种,其中最常用的函数是 WaitForMultipleObjects ,原形如下:
DWORD WaitForMultipleObjects(
DWORD nCount ,
CONST HANDLE * lpHandles ,
BOOL fWaitAll ,
DWORD dwMilliseconds // 超时设置,以 ms 为单位,如果为 INFINITE 表示无限期的等待
);
第一个参数 nCount ,表示 等待的对象数量。
第二个参数 lpHandles ,代表一个 对象 句柄数组 指针。
第三个参数 bWaitAll ,说明了等待类型,若该参数为 TRUE ,那么函数在所有对象都有信号后才返回;如果为 FALSE ,则只要有一个对象变成有信号状态,函数就返回。
第四个参数 dwMilliseconds ,表示等待的时间,以 ms 为单位。如果该参数为 0 ,那么函数就测试同步对象的状态并立即返回;如果为 INFINITE 表示无限期的等待。
该函数调用之后的返回值有如下几种:
• WAIT_OBJECT_0 到 (WAIT_OBJECT_0 + nCount – 1) :当 fWaitAll 为 TRUE 时表示所有对象变为有信号状态;当 fWaitAll 为 FALSE 时,返回值减去 WAIT_OBJECT_0 得到的就是变为有信号状态的对象在数组中的下标。
• WAIT_ABANDONED_0 到 (WAIT_ABANDONED_0 + nCount – 1) :当 fWaitAll 为 TRUE 时表示所有对象变为有信号状态;当 fWaitAll 为 FALSE 时,表示对象中有一个对象为互斥对象,该互斥对象因为被中断而成为有信号状态,使用返回值减去 WAIT_OBJECT_0 得到的就是变为有信号状态的对象在数组中的下标。
• WAIT_TIMEOUT :表示超过规定时间返回。
在以下情况下等待函数返回:
一个或全部同步对象获得信号时返回(在参数中指定是等待一个或多个同步对象);等待时间达到了返回:如果等待时间不限制 (Infinite) ,则只有同步对象获得信号才返回;如果等待时间为 0 ,则在测试了同步对象的状态之后马上返回。
• 可以发出提示的函数
这类函数包括: MsgWaitForMultipleObjectsEx 、 SignalObjectAndWait 、 WaitForMultipleObjectsEx 、 WaitForSingleObjectEx ,这些函数主要用于重叠 (Overlapped) 的 I/O (异步 I/O )。
11.4.3 同步对象
同步对象用来协调多线程的执行,它可以被多个线程共享。前面讲过的线程的等待函数就是用同步对象的句柄作为参数,同步对象应该是所有要使用的线程都能访问到的。同步对象的状态要么是有信号的,要么是无信号的。同步对象主要有四种: 关键代码段( Critical_section ),互斥对象( Mutex ),事件对象( Event ),信标对象( Semaphores )。
在 Win32 中,同步对象是用来协调多线程执行的一种机制,而 MFC 则将它们封装成几个同步类。表 11-00 列出了用于线程同步的同步对象, Win32 为其提供的主要函数,以及在 MFC 中封装的类。
表 11-00 线程同步对象列表
同步对象名 | Win32 主要的 API 函数 | MFC 类及主要成员函数 |
关键代码段 Critical_section | InitializeCriticalSection () DeleteCriticalSection () EnterCriticalSection () TryEnterCriticalSection () LeaveCriticalSection () | CCriticalSection Unlock( ) Lock( ) SetEvent( ) |
互斥对象 Mutex | CreateMutex () OpenMutex () ReleaseMutex () | CMutex |
信标对象 Semaphores | CreateSemaphore () OpenSemaphore () ReleaseSemaphore () | CSemaphore |
事件对象 Event | CreateEvent () OpenEvent () ResetEvent () SetEvent () | CEvent SetEvent PulseEvent ResetEvent Unlock |