VC++讲义 第11章 线程间的同步

第11章线程间的同步

在 DOS时代,由于DOS并不是一个多任务的环境,所以要想实现多任务显得很勉强。随后有了Windows 3.X,虽然此操作系统有了多任务的支持但是严格的说,对多进程的支持并不够,这主要表现在进程间通信方面提供的支持非常少。一些传统的IPC方式都没有提供。后来在WinNT上完全实现了多进程/多线程支持,当然现在的Windows9X/2K都完全提供了这方面的支持。本章将介绍进程和线程的概念,及线程间的同步、多线程编程。

11.1 进程和线程的概念

使用32位Windows操作系统时,它能够同时运行几个程序,这种能力称为多任务处理,处理支持多任务,Win32操作系统还支持进程中的多线程处理。

在Win32操作系统中,采用的是抢先式多任务,这意味程序对CPU的占用时间是由系统决定的,系统为每个程序分配一定的CPU时间片,当程序的运行时间超过分配的时间片的时间后,系统就会中断该程序并把CPU控制权转交给别的程序。术语多任务其实就可以理解为系统可以同时运行对个进程。

进程(Process)就是一个运行的程序,它有独立的虚拟内存、代码、文件句柄和其他系统资源(如进程创建的文件、管道、同步对象等)组成。当启动一个进程时,操作系统会为此进程建立一个4GB的地址空间,进程是操作系统分配内存地址空间的单位。

线程(Thread),是操作系统分配处理器时间的最基本单元。所以一个进程必须包含一个线程,我们称之为主线程。如果需要,进程可以产生更多的线程,让CPU在同一时间执行不同段落的代码。进程中的线程是并行执行的,每个线程占用CPU的时间由系统来划分,系统不停地在各个线程之间切换。一个进程的所有线程共享它的虚拟地址空间、全局变量和操作系统资源。

简单的说,进程就是程序的一次执行,线程可以理解为进程中的执行的一段程序片段。在一个多任务环境中,下面的概念可以帮助我们理解两者间的差别:

●进程间是独立的,这表现在内存空间,上下文环境;线程运行在进程空间内。

●一般来讲(不使用特殊技术)进程是无法突破进程边界存取其他进程内的存储空间;

而线程由于处于进程空间内,所以同一进程所产生的线程共享同一内存空间。

●同一进程中的两段代码不能够同时执行,除非引入线程。

●线程是属于进程的,当进程退出时,该进程下的所有线程都会被强制退出并清除。

●线程占用的资源要少于进程所占用的资源。

●进程和线程都可以有优先级。

●在线程系统中进程也是一个线程。可以将进程理解为一个程序的主线程。

对于一个进程来说,当应用程序有几个任务要执行时,建立多个线程是很有用的,之所以有线程这个概念,就是因为以线程为调度对象比以进程为调度对象的执行效率会更高,原因有二:

1.由于创建新进程必须加载代码,而线程要执行的代码已经被映射到进程的地址空间,

所以创建、执行线程的速度比进程更快。

2.一个进程的所有线程共享进程的地址空间和全局变量,所以简化了线程之间的通讯。

虽然在进程中进行费时的工作不会导致系统的挂起,但会导致进程本身的挂起。所以,如果进程既要进行长期的工作,又要响应用户的输入,那么它可以启动一个线程来专门负责费时的工作,而进程(即主线程)仍然可以与用户进行交互。

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导致进程的所有线程终止;

●调用T erminateThread终止一个线程;

●调用T erminateProcess终止一个进程时,导致其所有线程的终止。

11.2.3 实例:通过创建多线程来编写网络聊天程序

这里,我们通过一个例子来熟悉线程的创建,同时也可以复习一下上一章讲过的套接字

的调用。本小节讲的实例,读者大概不会陌生,很类似于OICQ聊天一样的东西。完整例程

请参见光盘中的例子代码EX11_00,具体操作步骤如下:

●步骤1:新建一个MFC对话框应用程序(在MFC Appwizard step1对话框中选中Dialog

based单选按钮),工程名为EX11_00或用户自定义。

●步骤2:编辑对话框资源。

将对话框上的原有控件全部删去,将该对话框ID 改为IDD_DLG_CHAT,名称改为“聊天

程序”。

在对话框上添加两个编辑框,一个负责接收数据,另一个负责发送数据,将它们的ID

分别设为IDC_EDIT_RECV和IDC_EDIT_SEND,负责接收数据的编辑框设为Multiline属性;

再添加一个IP地址控件负责给出发送的地址,使用默认的ID号IDC_IPADDRESS1;

最后再添加一个按钮用来发送数据,ID号设为IDC_BTN_SEND,在按钮属性对话框的Styles页面上选中Default button复选框,使它成为默认的按钮,其用意是,当用户按下回车键时实际就相当于发送数据,而不必每次都单击发送按钮。对话框编辑结果如图11-00所示。

VC++讲义 第11章  线程间的同步

图11-00 编辑好的对话框资源

步骤3:在应用程序类CEX11_00App的InitInstance函数中初始化套接字库。

在上一章编写Win32控制台程序时讲过,要想在Windows环境中使用套接字,必须首先初始化套接字库来加载网络环境,函数InitInstance中代码如下:

BOOL CEX11_00App::InitInstance()

{

AfxEnableControlContainer();

//初始化套接字库

if (!AfxSocketInit())

{

AfxMessageBox("Init socket error");

return FALSE;

}

……

}

其中,函数AfxSocketInit用来初始化套接字库,注意,在MFC的应用程序中要想使用套接字必须调用AfxSocketInit函数来初始化套接字库,并且该函数的调用通常放在应用程序类的InitInstance函数中。相应的,必须在工程EX11_00的StdAfx.h文件中包含头文件#include ,打开工作台的FileView页面,展开Header Files文件夹就可以找到StdAfx.h文件,在该文件的尾部包含afxsock.h头文件。

头文件afxsock.h中包含CAsyncSocket和CSocket类的定义,这两个类是MFC的套接字类,其中CSocket类由CAsyncSocket类继承来的,如果应用程序中使用了这两个类或任何由这两个类继承来的子类就必须添加afxsock.h头文件;另外,如果在应用程序中调用了

AfxSocketInit函数,也要添加afxsock.h头文件。

步骤4:在对话框类CEX11_00Dlg中定义一个成员变量m_sock,并加入一个成员函数InitSocket(),对m_sock进行初始化。

在工作台的ClassView页面通过鼠标右键单击CEX11_00Dlg类,选择Add Member Variable菜单项在类CEX11_00Dlg的头文件中添加SOCKET类型的成员变量m_sock。

在工作台的ClassView页面通过鼠标右键单击CEX11_00Dlg类,选择Add Member Function菜单项在类CEX11_00Dlg中添加返回值为BOOL类型的成员函数InitSocket()。类CEX11_00Dlg的头文件中的定义如下:

class CEX11_00Dlg : public CDialog

{

// Construction

public:

BOOL InitSocket();

SOCKET m_sock;

CEX11_00Dlg(CWnd* pParent = NULL);

……

};

在添加的成员函数InitSocket中对变量m_sock进行初始化,即建立一个套接字,将该套接字与本地地址相连。在函数InitSocket中编辑如下代码:

BOOL CEX11_00Dlg::InitSocket()

{

//创建套接字

m_sock=socket(AF_INET,SOCK_DGRAM,0);

//判断套接字是否创建成功

if (m_sock==INVALID_SOCKET)

{

MessageBox("create socket error");

return FALSE;

}

//获取本机地址

SOCKADDR_IN addrSock;

addrSock.sin_addr.S_un.S_addr=htonl(INADDR_ANY);

addrSock.sin_family=AF_INET;

addrSock.sin_port=htons(4500);

//将本地地址和套接字相连并判断绑定是否成功

if (SOCKET_ERROR==bind(m_sock,(SOCKADDR*)&addrSock,

sizeof(addrSock)))

{

MessageBox("bind socket error");

return FALSE;

}

return TRUE;

}

该段代码首先调用socket函数创建一个面向非连接服务的套接字,该函数如创建成功

返回套接字的句柄,若失败,则返回INVALID_SOCKET,因此它下面的if语句用来判断创建套接字是否成功。然后通过INADDR_ANY由计算机自动获取本地地址,与创建的套接字进行绑定,当然也通过if语句来判断绑定是否成功。

步骤5:编写主线程函数,负责发送数据。

我们把发送按钮的消息响应函数作为主线程函数,打开对话框,双击上面的发送按钮,会弹出如图11-01所示的对话框,单击ok,将该按钮的单击消息响应函数OnBtnSend添加到类CEX11_00Dlg中。

VC++讲义 第11章  线程间的同步

图11-01 添加命令按钮消息响应函数

在OnBtnSend函数中获取用户想要发送数据的对方的地址,然后将用户输入的文本发送出去,函数代码如下:

void CEX11_00Dlg::OnBtnSend()

{

//定义字符型变量strSend用来保存用户将要发送的数据

CString strSend;

GetDlgItemText(IDC_EDIT_SEND,strSend);

SOCKADDR_IN addrSend;

//定义DWORD型的变量dwIP用来保存用户在IP地址控件中输入的对方的IP

DWORD dwIP;

((CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1))->GetAddress(dwIP);

//取得对方IP地址之后发送数据

addrSend.sin_addr.S_un.S_addr=htonl(dwIP);

addrSend.sin_family=AF_INET;

addrSend.sin_port=htons(4500);

sendto(m_sock,strSend,strlen(strSend)+1,0,

(SOCKADDR*)&addrSend,sizeof(addrSend));

//数据发送之后,将发送数据的编辑框清空

SetDlgItemText(IDC_EDIT_SEND,"");

}

代码中,调用函数GetDlgItemText用来获取ID号为IDC_EDIT_SEND的编辑框控件中的文本内容(即用户输入的要发送的字符串),并将该文本内容存放到变量strSend中,该函数原形如下:

int GetDlgItemText( int nID, CString& rString ) const;

该函数是CWnd类的成员函数,用来获取对话框上指定控件的标题或文本内容。第一个参数nID表示将要获取标题或文本所在的控件的ID值。第二个参数rString,为CString 类型的变量,用来存放取得的控件的标题或文本。该函数将得到的标题或文本拷贝到参数rString指定的字符串中,并返回字符串的长度。

函数GetDlgItem,读者大概不会陌生,在第七章我们讲过,根据函数中参数给出的ID 值来获取对应的控件窗口。代码中,我们用该函数取得IP地址控件窗口,为的是调用它的成员函数GetAddress来获得用户输入的对方的IP地址。GetAddress函数原形如下:int GetAddress( DWORD& dwAddress );

该函数是CIPAddressCtrl类的成员函数,用来获取指定的IP地址控件中显示的IP地址,参数dwAddress为DWORD类型的引用,调用GetAddress函数之后获取的IP地址就存放在这个参数中,因此,之前我们定义了一个DWORD类型的变量dwIP就是用来存放用户输入的IP地址。

最后,调用sendto函数将数据发送之后,把发送数据编辑框控件中的字符清空,以便用户继续输入其他字符。

●步骤6:在对话框类的头文件中定义一个结构体RecvParam,用于向创建的线程的入口

函数传递参数。

之前,在讲创建线程的函数CreateThread时,我们讲过,此函数的第四个参数是表示传递给线程入口函数的32位的参数,但是只能传递一个参数,然而,我们这里需要两个参数:一个是套接字,一个是对话框窗口句柄,因此,只好定义一个结构体,将这两个参数作为结构体的两个数据成员一起传给线程。

在类CEX11_00Dlg的头文件中定义结构体如下:

struct RecvParam

{

HWND hwnd;

SOCKET sock;

};

定义该结构体的位置在CEX11_00Dlg类声明的前面,即class CEX11_00Dlg: public CDialog代码的前边,不要放在类声明的里边。因为该结构体不属于对话框类的成员。

●步骤7:在对话框的OnInitDialog()函数中调用InitSocket函数并创建一个线程。

在对话框CEX11_00Dlg类的OnInitDialog函数中调用InitSocket函数是为了当对话框一启动时就创建套接字并将套接字与本机地址绑定。OnInitDialog函数代码如清单11-00所示:

清单11-00 CEX11_00Dlg::OnInitDialog()函数代码———————————————————————————————————————

1 BOOL CEX11_00Dlg::OnInitDialog()

2 {

3 CDialog::OnInitDialog();

4 ……

5 InitSock();

6((CIPAddressCtrl*)GetDlgItem(IDC_IPADDRESS1))->SetAddress(192,168,123,110);

7RecvParam *pRecvParam=new RecvParam;

8pRecvParam->hwnd=m_hWnd;

9pRecvParam->sock=m_sock;

10CreateThread(NULL,0,RecvProc,(LPVOID)pRecvParam,0,NULL);

11 ……

12 } ———————————————————————————————————————第6行代码调用SetAddress函数来置IP地址控件的初始地址,本例中设置的是笔者的

主机地址。

第7行代码定义了一个指向结构RecvParam的指针变量pRecvParam,8、9行代码给结构中的两个数据成员分别赋值,第10行代码将变量pRecvParam作为参数传递给创建的线程。

●步骤8:在对话框类的实现文件(.cpp)中创建线程的入口函数过程,在该函数中负责

接收数据,代码如下:

//创建线程的入口函数

DWORD WINAPI RecvProc(

LPVOID lpParameter // thread data

)

{

//lpParameter变量保存的是传来的结构体参数

HWND hwnd=((RecvParam*)lpParameter)->hwnd;

SOCKET sock=((RecvParam*)lpParameter)->sock;

//定义addrRecv用于保存发送数据方(即对方)的主机信息

SOCKADDR_IN addrRecv;

int len=sizeof(addrRecv);

//变量buff用于保存接收的数据,经格式化后存放到szchar中

char buff[1024];

char szchar[2048];

while(1)

{

if (SOCKET_ERROR==recvfrom(sock,buff,1024,0,(SOCKADDR*)&addrRecv,&len))

break;

else

{

sprintf(szchar,"%s:%s",inet_ntoa(addrRecv.sin_addr),buff);

PostMessage(hwnd,WM_RECVDATA,0,(LPARAM)szchar);

}

}

return 1;

}

其中,参数lpParameter里边保存的就是由创建线程的函数传递过来的结构体参数,这里,通过定义hwnd和sock两个变量将传递过来的值取出以备使用。

接收数据以后,就应该处理该数据将它显示出来,本例中采用的方法是通过调用PostMessage函数向对话框窗口发送一条消息,并将收到的数据作为此消息的参数,然后在该消息的消息处理函数中来显示收到的数据。

●步骤9:添加WM_RECVDATA消息的消息处理函数并编写代码。

手动添加消息处理函数的过程在前面章节中有讲过,读者应该不会陌生。首先,在类CEX11_00Dlg的头文件(.h)中,在定义结构体RecvParam的前面给消息WM_RECVDATA定义一个ID号,如下:

#define WM_RECVDATA WM_USER+1

然后,在类的头文件(.h)中,在类声明的部分,在宏DECLARE_MESSAGE_MAP之前添加消息响应函数。如下:

class CEX11_00Dlg : public CDialog

{

// Construction

……

afx_msg void OnRecvdata(WPARAM wParam,LPARAM lParam);

DECLARE_MESSAGE_MAP()

};

然后在类的实现文件中,在宏BEGIN_MESSAGE_MAP()和END_MESSAGE_MAP()之间添加消息映射。如下:

ON_MESSAGE(WM_RECVDATA,OnRecvdata)

最后,在类的实现文件中的末尾部分定义消息处理函数OnRecvdata的函数体,并在函数中编写如清单11-01所示代码:

清单11-01 CEX11_00Dlg::OnRecvdata函数代码———————————————————————————————————————

1void CEX11_00Dlg::OnRecvdata(WPARAM wParam,LPARAM lParam)

2{

3CString strRecv;

4strRecv=(char *)lParam;

5CString strTemp;

6GetDlgItemText(IDC_EDIT_RECV,strTemp);

7strRecv+="\r\n";

8strRecv+=strTemp;

9SetDlgItemText(IDC_EDIT_RECV,strRecv);

10} ———————————————————————————————————————3~4行代码,定义字符型变量strRecv,然后用来存放由参数lParam传来的收到的数据,第6行调用的GetDlgItemText函数是取出接收编辑框中原有的内容,放到strTemp变量中,然后将接收的数据strRecv在尾部添加回车换行符之后,再连上strTemp中的文本,最后第9行将内容显示出来。

VC++讲义 第11章  线程间的同步

图11-02 聊天程序运行结果

这样做的方式,读者大概理解了,并不是直接将用户接收的数据显示在编辑框中之前接收的数据的前面,那样实现起来比较难,我们是把接收的数据和编辑框中原有的数据连成一个字符串显示在编辑框中,这样实现起来很简单,而且给用户的错觉是,编辑框中原有的内容向下移动了,刚刚收到的数据显示在最上面,运行结果如图11-02所示。

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 派生的类提供)。

由于用户界面线程需要有自己的窗口界面和消息循环,因此,它的创建要比一个工作者线程的创建复杂的多,不是一个函数就可以解决的。用户界面线程的创建过程一般遵循如下步骤:

1.从CWinThread中派生新类

2.重载CWinThread的InitInstance函数

3.使用AfxBeginThread函数创建并启动线程对象

下面我们将通过一个实例来讲述如何创建一个用户界面线程。完整利程请参见光盘中例

子代码EX11_01,以下是具体操作步骤:

●步骤1:新建一个MFC单文档应用程序,工程名为EX11_01或用户自定义。

●步骤2:添加新的菜单项“用户界面线程”用于创建和启动线程。

打开工作台的ResourceView页面,修改MFC应用程序向导给我们自动生成的菜单资源,在原菜单基础上再添加一个弹出式菜单“线程”,在它下面添加菜单项“用户界面线程”,设置其ID为ID_THREAD_GUI。

●步骤3:从CWinThread中派生新类

派生新类的方法很简单,在以前章节中也有介绍过,可以使用 ClassWizard工具选择Add Class|New,也可以通过打开工作台ClassView页面,鼠标右键单击最顶层类集,在弹出的快捷方式菜单中选择New Class菜单项来启动New Class对话框,在基类列表框中选择CWindThread类,然后在Name编辑框中输入派生类名,如MyThread。

打开我们新产生的MyThread类的头文件(.h),有如下类的定义:

////////////////////////////////////////////////////////////////////////// // MyThread thread

class MyThread : public CWinThread

{

DECLARE_DYNCREATE(MyThread)

protected:

MyThread(); // protected constructor used by dynamic creation

……

DECLARE_MESSAGE_MAP()

};

代码中使用了DECLARE_DYNCREATE宏,使用该宏表明MyThread类具有动态创建的能力;使用DECLARE_MESSAGE_MAP宏表明具有消息映射,可以处理命令消息。再打开MyThread类的实现文件(.cpp),下面列出其中一部分代码;

/////////////////////////////////////////////////////////////////////////// // MyThread

IMPLEMENT_DYNCREATE(MyThread, CWinThread)

MyThread::MyThread()

{

}

……

BEGIN_MESSAGE_MAP(MyThread, CWinThread)

//{{AFX_MSG_MAP(MyThread)

// NOTE - the ClassWizard will add and remove mapping macros here.

//}}AFX_MSG_MAP

END_MESSAGE_MAP()

代码中使用了IMPLEMENT_DYNCREATE宏是和头文件中DECLARE_DYNCREATE宏相对应的,保证新建的类具有动态创建的能力;而BEGIN_MESSAGE_MAP和END_MESSAGE_MAP宏是和头文件中DECLARE_MESSAGE_MAP宏相对应的,使类具有消息映射机制。

●步骤4:重载CWinThread的虚函数

父类CWinThread有一些可以重载的虚函数,其中InitInstance函数必须要重载,该函数用于初始化实例;ExitInstance函数一般情况下也应该重载,该函数用于清除实例;而

Run函数,如果没有特殊需要,一般不去重载,该函数负责将消息分发出去。

重载InitInstance函数举例如清单11-02所示:

清单11-02 MyThread::InitInstance函数代码———————————————————————————————————————

1 BOOL MyThread::InitInstance()

2 {

3 // TODO: perform and per-thread initialization here

4CFrameWnd* pFrameWnd= new CFrameWnd();

5pFrameWnd->CreateEx(0,AfxRegisterWndClass( CS_HREDRAW|CS_VREDRAW) , 6"用户界面线程示例",

7WS_OVERLAPPEDWINDOW|WS_VISIBLE,

8CRect(100,100,400,300),

9NULL,

100);

11m_pMainWnd=pFrameWnd;

12pFrameWnd->ShowWindow(SW_SHOW);

13pFrameWnd->UpdateWindow();

14 return TRUE;

15 } ———————————————————————————————————————代码中,第4行首先构造了一个框架窗口对象,然后在第5~10行调用其成员函数CreateEx来产生窗口,最后将窗口显示出来。如果用户界面线程正常执行时,就会产生一个标题为"用户界面线程示例"的窗口

步骤5:使用AfxBeginThread函数创建并启动线程对象

在CEX11_01View类中,添加新建菜单项的COMMAND命令消息处理函数OnThreadGui,在该消息处理函数中通过调用AfxBeginThread函数来启动用户界面线程,代码如下:void CEX11_01View::OnThreadGui()

{

// TODO: Add your command handler code here

AfxBeginThread(RUNTIME_CLASS(MyThread));

}

代码中又用到了AfxBeginThread函数,但是创建工作者线程和创建用户界面线程的AfxBeginThread函数的形式是有一定区别的,在这段代码里,用的是创建用户界面线程的AfxBeginThread函数,原形如下:

CWinThread* AFXAPI AfxBeginThread(

CRuntimeClass* pThreadClass,

int nPriority,

UINT nStackSize,

DWORD dwCreateFlags,

LPSECURITY_ATTRIBUTES lpSecurityAttrs

);

第一个参数pThreadClass,表示从CWinThread派生的RUNTIME_CLASS类。

第二个参数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。

因为我们在视图类CEX11_01View中用到了新建的线程类MyThread,因此,我们在最后不要忘了在CEX11_01View类中包含MyThread类的头文件,如下:

#include "MyThread.h"

运行程序结果如图11-03所示。

VC++讲义 第11章  线程间的同步

图11-03 用户界面线程示例运行结果

新建的用户界面线程窗口显示在屏幕上,但需要读者了解的是,这个线程窗口不属于CEX11_01程序的主框架窗口,而是和主框架窗口(也可以说是主线程窗口)并列的,二者的父窗口都是系统的桌面。查看桌面底部的Windows系统任务栏就会发现除了CEX11_01应用程序窗口,也有单独的用户界面线程窗口。

此时,这两个窗口可以并行工作,互不影响,用户可以单独关闭用户界面线程窗口,就相当于正常退出了用户界面线程,不会对主线程造成影响。反过来,如果用户关闭主线程窗口,则用户界面线程窗口也会被迫关闭,这种情况属于用户界面线程非正常退出,会造成内存泄漏。

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前向其他线程询问一下:有谁在用吗?如果没被使用则可以立即对该变量进行操作,否则等其他线程使用完后再使用,而且在自己得到该变量的控制权后,也要告知其他线程此时不能使用这一变量,直到自己使用完并释放为止。

属于不同进程的线程在同时访问同一内存区域或共享资源时,也会存在同样的问题。因此,在多线程应用程序中,常常需要采取一些措施来同步线程的执行。需要同步的情况包括以下几个方面:

1.当两个或多个线程需要访问每次只能被一个线程访问的共享资源时。例如,当一个线程在写文件时,要求阻止另一个线程读该文件

2.当多个线程的执行有先后顺序,它们之间需要协调运行时。例如,如果B线程需要等待A线程完成到某一程度时才能运行,那么B线程就应该暂时挂起以减少对 CPU的占用时间,让A线程处于运行状态,一旦当A线程运行到那一时刻时,才发信号给B线程,让B

线程转到运行状态。

3.在Windows 95环境下编写多线程应用程序还需要考虑重入问题。我们知道,Windows NT是真正的32位操作系统,它解决了系统重入问题。而Windows 95由于继承了Windows 3.x 的部分16位代码,没能够解决重入问题。这意味着在Windows 95中两个线程不能同时执行某个系统功能,否则有可能造成程序错误,甚至会造成系统崩溃。应用程序应该尽量避免发生两个以上的线程同时调用同一个Windows API函数的情况

那么,如何实现同步呢?Windows给我们提供了多种同步对象供我们使用,并且可以替我们管理同步对象的加锁和解锁。我们需要做的就是对每个需要同步使用的资源产生一个同步对象,在使用该资源前申请加锁,在使用完成后解锁。

下面就会介绍几种同步对象的用法及一组重要的等待函数。

11.4.2 等待函数

Win32 API提供了一组能使线程阻塞其自身执行的等待函数。这些函数只有在作为其参数的一个或多个同步对象(见下小节)产生信号时才会返回。在超过规定的等待时间后,不管有无信号,函数也都会返回。在等待函数未返回时,线程处于等待状态,此时线程只消耗很少的CPU时间。等待函数分三类:

1.等待单个对象

这类函数包括: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函数还有进行排队功能,保证先提出等待请求的线程先获得对象的使用权。

2.等待多个对象

这类函数包括: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,则在测试了同步对象的状态之后马上返回。

3.可以发出提示的函数

这类函数包括:MsgWaitForMultipleObjectsEx、SignalObjectAndWait、WaitForMultipleObjectsEx、WaitForSingleObjectEx,这些函数主要用于重叠(Overlapped)的I/O(异步I/O)。

11.4.3 同步对象

同步对象用来协调多线程的执行,它可以被多个线程共享。前面讲过的线程的等待函数就是用同步对象的句柄作为参数,同步对象应该是所有要使用的线程都能访问到的。同步对象的状态要么是有信号的,要么是无信号的。同步对象主要有四种:关键代码段(Critical_section),互斥对象(Mutex),事件对象(Event),信标对象(Semaphores)。

在Win32中,同步对象是用来协调多线程执行的一种机制,而MFC则将它们封装成几个同步类。表11-00列出了用于线程同步的同步对象,Win32为其提供的主要函数,以及在MFC 中封装的类。

VC++讲义 第11章  线程间的同步

VC++讲义 第11章  线程间的同步

11.4.3.1 关键代码段

关键代码段也叫临界区,关键代码段是一种最简单的同步对象,它只可以被单个进程内的线程使用。它的作用是保证只有一个线程可以申请到该对象。因此,可以让所有的线程都共享同一个关键代码段对象,哪个线程拥有这个关键代码段对象,它就可以访问受保护的共享资源,其他线程只有等到第一个线程释放了关键代码段对象,才有机会获得该对象,以便访问同一数据。

关键代码段类似于商场中的一个试衣间,同一时间只能有一个人进去,而另一个人若想进去,只有等到上一个人出来,将试衣间空出来才可以。关键代码段是一种保证在某一时刻只有一个线程能够访问数据的简便方法。与其他三种同步对象相比,关键代码段为互相排斥同步提供更快、更有效的机制。

下面将介绍关键代码段对象的几个相关的函数:

1.VOID InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

该函数用于产生关键代码段。参数lpCriticalSection为指针变量,指向一个已声明的关键代码段的一个实例。

2.VOID EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection );

该函数用于进入关键代码段,相当于申请加锁,如果该关键代码段正被其他线程使用则该函数会等待到其他线程释放。参数lpCriticalSection意义同上。

3.BOOL TryEnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection );

该函数用于进入关键代码段,相当于申请加锁,和EnterCriticalSection不同的是,如果该关键代码段正被其他线程使用,则该函数会立即返回FALSE,而不会等待。参数lpCriticalSection意义同上。

4.VOID LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection );

该函数用于退出关键代码段,相当于申请解锁。参数lpCriticalSection意义同上。该函数和EnterCriticalSection函数必须成对出现,如果线程在没有获得关键代码段的使用权的情况下调用LeaveCriticalSection函数,会导致其他线程调用EnterCriticalSection 时无限等待。

5.VOID DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection );

该函数用于删除关键代码段。参数lpCriticalSection意义同上。

关键代码段的使用一般遵循如下过程:首先,用关键代码段数据类型CRITICAL_SECTION 定义一个关键代码段对象;然后,调用InitializeCriticalSection函数创建及初始化该对象,初始化时把对象设置为NOT_SINGALED,表示允许线程使用资源;如果一段程序代码需要对某个资源进行同步保护,则这是一段关键段代码。在进入该关键段代码前调用EnterCriticalSection函数,这样,其他线程都不能执行该段代码,若它们试图执行就会被阻塞;完成关键段代码的执行之后,调用LeaveCriticalSection函数,其他的线程就可以继续执行该段代码。如果该函数不被调用,则其他线程将无限期的等待。

下面以一个实例来说明关键代码段的使用方法,该实例存放于光盘的例子代码EX11_02中,是一个Win32控制台程序,鉴于读者对创建Win32控制台程序及给工程添加C++源文件的过程已经熟悉,因此,只给出源文件(.cpp)中的代码,该代码列于清单11-03中。

清单11-03 工程EX11_02源文件(关键代码段示例)———————————————————————————————————————

1 #include

2 #include

3 //线程1的入口函数声明

4 DWORD WINAPI FunProc1(

5 LPVOID lpParameter

6 );

7 //线程2的入口函数声明

8 DWORD WINAPI FunProc2(

9 LPVOID lpParameter

10 );

11 //定义一个关键代码段对象

12 CRITICAL_SECTION cs;

13 //全局变量i

14 int i;

15 //主线程

16 void main()

17 {

18 //创建两个线程

19 CreateThread(NULL,0,FunProc1,NULL,0,NULL);

20 CreateThread(NULL,0,FunProc2,NULL,0,NULL);

21 //创建及初始化关键代码段对象

22 InitializeCriticalSection(&cs);

23 i=3;

24 cout<<"main thread is running"<

25 Sleep(200);

26 //删除关键代码段对象

27 DeleteCriticalSection(&cs);

28 }

29 //线程1的入口函数

30 DWORD WINAPI FunProc1(

31 LPVOID lpParameter // thread data

32 )

33 {

34 //线程1进入关键代码段

35 EnterCriticalSection(&cs);

36 i++;

37 int c=i+2;

38 cout<

39 cout<<"thread1 is running"<

40 //线程1离开关键代码段

41 LeaveCriticalSection(&cs);

42 return 1;

43 }

44 //线程2的入口函数

45 DWORD WINAPI FunProc2(

46 LPVOID lpParameter // thread data

47 )

48 {

49 //线程2进入关键代码段

50 EnterCriticalSection(&cs);

51 i++;

52 int c=i+2;

53 cout<

54 cout<<"thread2 is running"<

55 return 1;

56 }

该段代码在19、20行创建了两个线程,首先线程1进入关键代码段,运行33~43行之间的代码,将全局变量i的值加1(i结果为4),再将局部变量c的值赋为i+2(c结果为6)进行输出,然后离开关键代码段;之后,线程2进入关键代码段,运行48~56行之间的代码,同样对变量i和c进行赋值和输出。由于线程1先离开了关键代码段,所以线程2得以进入,二者在不同的时间访问了全局变量i,运行结果如图11-04所示。

VC++讲义 第11章  线程间的同步

图11-04 关键代码段实例运行结果

现在我们把清单11-03中,线程1的入口函数中的第41行代码,LeaveCriticalSection(&cs);代码注释掉,也就是说让线程1不离开关键代码段,那么线程2就会被阻塞,永远等待下去,只要线程1不离开,线程2就进不去,也无法访问全局变量

相关文档
  • 线程之间的同步机制

  • 操作系统线程的同步

  • 多线程同步方法及比较

  • 多线程同步

  • 线程同步机制

  • 同步多线程

相关推荐: