VC中利用多线程技术实现线程之间的通信 | ||||
---|---|---|---|---|
| ||||
http://www.sina.com.cn 2006年02月06日 13:31 天极yesky | ||||
作者: 刘涛 当前流行的Windows操作系统能同时运行几个程序(独立运行的程序又称之为进程),对于同一个程序,它又可以分成若干个独立的执行流,我们称之为线程,线程提供了多任务处理的能力。用进程和线程的观点来研究软件是当今普遍采用的方法,进程和线程的概念的出现,对提高软件的并行性有着重要的意义。现在的大型应用软件无一不是多线程多任务处理,单线程的软件是不可想象的。因此掌握多线程多任务设计方法对每个程序员都是必需要掌握的。本实例针对多线程技术在应用中经常遇到的问题,如线程间的通信、同步等,分别进行探讨,并利用多线程技术进行线程之间的通信,实现了数字的简单排序。
上述的二个函数分别用来获取和设置线程的优先级,这里的优先级,是相对于该线程所处的优先权层次而言的,处于同一优先权层次的线程,优先级高的线程先运行;处于不同优先权层次上的线程,谁的优先权层次高,谁先运行。至于优先级设置所需的常数,自己参考MSDN就可以了,要注意的是要想设置线程的优先级,这个线程在创建时必须具有THREAD_SET_INFORMATION访问权限。对于线程的优先权层次的设置,CwinThread类没有提供相应的函数,但是可以通过Win32 SDK函数GetPriorityClass()和SetPriorityClass()来实现。 (三)线程的悬挂和恢复 CWinThread类中包含了应用程序悬挂和恢复它所创建的线程的函数,其中SuspendThread()用来悬挂线程,暂停线程的执行;ResumeThread()用来恢复线程的执行。如果你对一个线程连续若干次执行SuspendThread(),则需要连续执行相应次的ResumeThread()来恢复线程的运行。 (四)结束线程 终止线程有三种途径,线程可以在自身内部调用AfxEndThread()来终止自身的运行;可以在线程的外部调用BOOL TerminateThread( HANDLE hThread, DWORD dwExitCode )来强行终止一个线程的运行,然后调用CloseHandle()函数释放线程所占用的堆栈;第三种方法是改变全局变量,使线程的执行函数返回,则该线程终止。下面以第三种方法为例,给出部分代码:
3、线程之间的通信 通常情况下,一个次级线程要为主线程完成某种特定类型的任务,这就隐含着表示在主线程和次级线程之间需要建立一个通信的通道。一般情况下,有下面的几种方法实现这种通信任务:使用全局变量(上一节的例子其实使用的就是这种方法)、使用事件对象、使用消息。这里我们主要介绍后两种方法。 (一) 利用用户定义的消息通信 在Windows程序设计中,应用程序的每一个线程都拥有自己的消息队列,甚至工作线程也不例外,这样一来,就使得线程之间利用消息来传递信息就变的非常简单。首先用户要定义一个用户消息,如下所示:#define WM_USERMSG WMUSER+100;在需要的时候,在一个线程中调用::PostMessage((HWND)param,WM_USERMSG,0,0)或CwinThread::PostThradMessage()来向另外一个线程发送这个消息,上述函数的四个参数分别是消息将要发送到的目的窗口的句柄、要发送的消息标志符、消息的参数WPARAM和LPARAM。下面的代码是对上节代码的修改,修改后的结果是在线程结束时显示一个对话框,提示线程结束:
上面的例子是工作者线程向用户界面线程发送消息,对于工作者线程,如果它的设计模式也是消息驱动的,那么调用者可以向它发送初始化、退出、执行某种特定的处理等消息,让它在后台完成。在控制函数中可以直接使用::GetMessage()这个SDK函数进行消息分检和处理,自己实现一个消息循环。GetMessage()函数在判断该线程的消息队列为空时,线程将系统分配给它的时间片让给其它线程,不无效的占用CPU的时间,如果消息队列不为空,就获取这个消息,判断这个消息的内容并进行相应的处理。 (二)用事件对象实现通信 在线程之间传递信号进行通信比较复杂的方法是使用事件对象,用MFC的Cevent类的对象来表示。事件对象处于两种状态之一:有信号和无信号,线程可以监视处于有信号状态的事件,以便在适当的时候执行对事件的操作。上述例子代码修改如下:
运行这个程序,当关闭程序时,才显示提示框,显示"Thread ended"。 |
前面我们讲过,各个线程可以访问进程中的公共变量,所以使用多线程的过程中需要注意的问题是如何防止两个或两个以上的线程同时访问同一个数据,以免破坏数据的完整性。保证各个线程可以在一起适当的协调工作称为线程之间的同步。前面一节介绍的事件对象实际上就是一种同步形式。Visual C++中使用同步类来解决操作系统的并行性而引起的数据不安全的问题,MFC支持的七个多线程的同步类可以分成两大类:同步对象(CsyncObject、Csemaphore、Cmutex、CcriticalSection和Cevent)和同步访问对象(CmultiLock和CsingleLock)。本节主要介绍临界区(critical section)、互斥(mutexe)、信号量(semaphore),这些同步对象使各个线程协调工作,程序运行起来更安全。
(一) 临界区
临界区是保证在某一个时间只有一个线程可以访问数据的方法。使用它的过程中,需要给各个线程提供一个共享的临界区对象,无论哪个线程占有临界区对象,都可以访问受到保护的数据,这时候其它的线程需要等待,直到该线程释放临界区对象为止,临界区被释放后,另外的线程可以强占这个临界区,以便访问共享的数据。临界区对应着一个CcriticalSection对象,当线程需要访问保护数据时,调用临界区对象的Lock()成员函数;当对保护数据的操作完成之后,调用临界区对象的Unlock()成员函数释放对临界区对象的拥有权,以使另一个线程可以夺取临界区对象并访问受保护的数据。同时启动两个线程,它们对应的函数分别为WriteThread()和ReadThread(),用以对公共数组组array[]操作,下面的代码说明了如何使用临界区对象:
#include "afxmt.h" int array[10],destarray[10]; CCriticalSection Section; UINT WriteThread(LPVOID param) { Section.Lock(); for(int x=0;x<10;x++) array[x]=x; Section.Unlock(); } UINT ReadThread(LPVOID param) { Section.Lock(); For(int x=0;x<10;x++) Destarray[x]=array[x]; Section.Unlock(); } |
上述代码运行的结果应该是Destarray数组中的元素分别为1-9,而不是杂乱无章的数,如果不使用同步,则不是这个结果,有兴趣的读者可以实验一下。
(二)互斥
互斥与临界区很相似,但是使用时相对复杂一些,它不仅可以在同一应用程序的线程间实现同步,还可以在不同的进程间实现同步,从而实现资源的安全共享。互斥与Cmutex类的对象相对应,使用互斥对象时,必须创建一个CSingleLock或CMultiLock对象,用于实际的访问控制,因为这里的例子只处理单个互斥,所以我们可以使用CSingleLock对象,该对象的Lock()函数用于占有互斥,Unlock()用于释放互斥。实现代码如下:
#include "afxmt.h" int array[10],destarray[10]; CMutex Section; UINT WriteThread(LPVOID param) { CsingleLock singlelock; singlelock (&Section); singlelock.Lock(); for(int x=0;x<10;x++) array[x]=x; singlelock.Unlock(); } UINT ReadThread(LPVOID param) { CsingleLock singlelock; singlelock (&Section); singlelock.Lock(); For(int x=0;x<10;x++) Destarray[x]=array[x]; singlelock.Unlock(); } |
(三)信号量
信号量的用法和互斥的用法很相似,不同的是它可以同一时刻允许多个线程访问同一个资源,创建一个信号量需要用Csemaphore类声明一个对象,一旦创建了一个信号量对象,就可以用它来对资源的访问技术。要实现计数处理,先创建一个CsingleLock或CmltiLock对象,然后用该对象的Lock()函数减少这个信号量的计数值,Unlock()反之。下面的代码分别启动三个线程,执行时同时显示二个消息框,然后10秒后第三个消息框才得以显示。
/ Csemaphore *semaphore; Semaphore=new Csemaphore(2,2); HWND hWnd=GetSafeHwnd(); AfxBeginThread(threadProc1,hWnd); AfxBeginThread(threadProc2,hWnd); AfxBeginThread(threadProc3,hWnd); UINT ThreadProc1(LPVOID param) { CsingleLock singelLock(semaphore); singleLock.Lock(); Sleep(10000); ::MessageBox((HWND)param,"Thread1 had access","Thread1",MB_OK); return 0; } UINT ThreadProc2(LPVOID param) { CSingleLock singelLock(semaphore); singleLock.Lock(); Sleep(10000); ::MessageBox((HWND)param,"Thread2 had access","Thread2",MB_OK); return 0; } UINT ThreadProc3(LPVOID param) { CsingleLock singelLock(semaphore); singleLock.Lock(); Sleep(10000); ::MessageBox((HWND)param,"Thread3 had access","Thread3",MB_OK); return 0; } |
二、 编程步骤
1、 启动Visual C++6.0,生成一个32位的控制台程序,将该程序命名为"sequence"
2、 输入要排续的数字,声明四个子线程;
3、 输入代码,编译运行程序。
三、 程序代码
// // sequence.cpp : Defines the entry point for the console application. /* 主要用到的WINAPI线程控制函数,有关详细说明请查看MSDN; 线程建立函数: HANDLE CreateThread( LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全属性结构指针,可为NULL; DWORD dwStackSize, // 线程栈大小,若为0表示使用默认值; LPTHREAD_START_ROUTINE lpStartAddress, // 指向线程函数的指针; LPVOID lpParameter, // 传递给线程函数的参数,可以保存一个指针值; DWORD dwCreationFlags, // 线程建立是的初始标记,运行或挂起; LPDWORD lpThreadId // 指向接收线程号的DWORD变量; ); 对临界资源控制的多线程控制的信号函数: HANDLE CreateEvent( LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全属性结构指针,可为NULL; BOOL bManualReset, // 手动清除信号标记,TRUE在WaitForSingleObject后必须手动//调用RetEvent清除信号。若为 FALSE则在WaitForSingleObject //后,系统自动清除事件信号; BOOL bInitialState, // 初始状态,TRUE有信号,FALSE无信号; LPCTSTR lpName // 信号量的名称,字符数不可多于MAX_PATH; //如果遇到同名的其他信号量函数就会失败,如果遇 //到同类信号同名也要注意变化; ); HANDLE CreateMutex( LPSECURITY_ATTRIBUTES lpMutexAttributes, // 安全属性结构指针,可为NULL BOOL bInitialOwner, // 当前建立互斥量是否占有该互斥量TRUE表示占有, //这样其他线程就不能获得此互斥量也就无法进入由 //该互斥量控制的临界区。FALSE表示不占有该互斥量 LPCTSTR lpName // 信号量的名称,字符数不可多于MAX_PATH如果 //遇到同名的其他信号量函数就会失败, //如果遇到同类信号同名也要注意变化; ); //初始化临界区信号,使用前必须先初始化 VOID InitializeCriticalSection( LPCRITICAL_SECTION lpCriticalSection // 临界区变量指针 ); //阻塞函数 //如果等待的信号量不可用,那么线程就会挂起,直到信号可用 //线程才会被唤醒,该函数会自动修改信号,如Event,线程被唤醒之后 //Event信号会变得无信号,Mutex、Semaphore等也会变。 DWORD WaitForSingleObject( HANDLE hHandle, // 等待对象的句柄 DWORD dwMilliseconds // 等待毫秒数,INFINITE表示无限等待 ); //如果要等待多个信号可以使用WaitForMutipleObject函数 */ #include "stdafx.h" #include "stdlib.h" #include "memory.h" HANDLE evtTerminate; //事件信号,标记是否所有子线程都执行完 /* 下面使用了三种控制方法,你可以注释其中两种,使用其中一种。 注意修改时要连带修改临界区PrintResult里的相应控制语句 */ HANDLE evtPrint; //事件信号,标记事件是否已发生 //CRITICAL_SECTION csPrint; //临界区 //HANDLE mtxPrint; //互斥信号,如有信号表明已经有线程进入临界区并拥有此信号 static long ThreadCompleted = 0; /*用来标记四个子线程中已完成线程的个数,当一个子线程完成时就对ThreadCompleted进行加一操作, 要使用InterlockedIncrement(long* lpAddend)和InterlockedDecrement(long* lpAddend)进行加减操作*/ //下面的结构是用于传送排序的数据给各个排序子线程 struct MySafeArray { long* data; int iLength; }; //打印每一个线程的排序结果 void PrintResult(long* Array, int iLength, const char* HeadStr = "sort"); //排序函数 unsigned long __stdcall BubbleSort(void* theArray); //冒泡排序 unsigned long __stdcall SelectSort(void* theArray); //选择排序 unsigned long __stdcall HeapSort(void* theArray); //堆排序 unsigned long __stdcall InsertSort(void* theArray); //插入排序 /*以上四个函数的声明必须适合作为一个线程函数的必要条件才可以使用CreateThread 建立一个线程。 (1)调用方法必须是__stdcall,即函数参数压栈顺序由右到左,而且由函数本身负责 栈的恢复, C和C++默认是__cdecl, 所以要显式声明是__stdcall (2)返回值必须是unsigned long (3)参数必须是一个32位值,如一个指针值或long类型 (4) 如果函数是类成员函数,必须声明为static函数,在CreateThread时函数指针有特殊的写法。如下(函数是类CThreadTest的成员函数中): static unsigned long _stdcall MyThreadFun(void* pParam); handleRet = CreateThread(NULL, 0, &CThreadTestDlg::MyThreadFun, NULL, 0, &ThreadID); 之所以要声明为static是由于,该函数必须要独立于对象实例来使用,即使没有声明实例也可以使用。*/ int QuickSort(long* Array, int iLow, int iHigh); //快速排序 int main(int argc, char* argv[]) { long data[] = {123,34,546,754,34,74,3,56}; int iDataLen = 8; //为了对各个子线程分别对原始数据进行排序和保存排序结果 //分别分配内存对data数组的数据进行复制 long *data1, *data2, *data3, *data4, *data5; MySafeArray StructData1, StructData2, StructData3, StructData4; data1 = new long[iDataLen]; memcpy(data1, data, iDataLen << 2); //把data中的数据复制到data1中 //内存复制 memcpy(目标内存指针, 源内存指针, 复制字节数), 因为long的长度 //为4字节,所以复制的字节数为iDataLen << 2, 即等于iDataLen*4 StructData1.data = data1; StructData1.iLength = iDataLen; data2 = new long[iDataLen]; memcpy(data2, data, iDataLen << 2); StructData2.data = data2; StructData2.iLength = iDataLen; data3 = new long[iDataLen]; memcpy(data3, data, iDataLen << 2); StructData3.data = data3; StructData3.iLength = iDataLen; data4 = new long[iDataLen]; memcpy(data4, data, iDataLen << 2); StructData4.data = data4; StructData4.iLength = iDataLen; data5 = new long[iDataLen]; memcpy(data5, data, iDataLen << 2); unsigned long TID1, TID2, TID3, TID4; //对信号量进行初始化 evtTerminate = CreateEvent(NULL, FALSE, FALSE, "Terminate"); evtPrint = CreateEvent(NULL, FALSE, TRUE, "PrintResult"); //分别建立各个子线程 CreateThread(NULL, 0, &BubbleSort, &StructData1, NULL, &TID1); CreateThread(NULL, 0, &SelectSort, &StructData2, NULL, &TID2); CreateThread(NULL, 0, &HeapSort, &StructData3, NULL, &TID3); CreateThread(NULL, 0, &InsertSort, &StructData4, NULL, &TID4); //在主线程中执行行快速排序,其他排序在子线程中执行 QuickSort(data5, 0, iDataLen - 1); PrintResult(data5, iDataLen, "Quick Sort"); WaitForSingleObject(evtTerminate, INFINITE); //等待所有的子线程结束 //所有的子线程结束后,主线程才可以结束 delete[] data1; delete[] data2; delete[] data3; delete[] data4; CloseHandle(evtPrint); return 0; } /* 冒泡排序思想(升序,降序同理,后面的算法一样都是升序):从头到尾对数据进行两两比较进行交换,小的放前大的放后。这样一次下来,最大的元素就会被交换的最后,然后下一次 循环就不用对最后一个元素进行比较交换了,所以呢每一次比较交换的次数都比上一次循环的次数少一,这样N次之后数据就变得升序排列了*/ unsigned long __stdcall BubbleSort(void* theArray) { long* Array = ((MySafeArray*)theArray)->data; int iLength = ((MySafeArray*)theArray)->iLength; int i, j=0; long swap; for (i = iLength-1; i > 0; i--) { for(j = 0; j < i; j++) { if(Array[j] > Array[j+1]) //前比后大,交换 { swap = Array[j]; Array[j] = Array[j+1]; Array[j+1] = swap; } } } PrintResult(Array, iLength, "Bubble Sort"); //向控制台打印排序结果 InterlockedIncrement(&ThreadCompleted); //返回前使线程完成数标记加1 if(ThreadCompleted == 4) SetEvent(evtTerminate); //检查是否其他线程都已执行完 //若都执行完则设置程序结束信号量 return 0; } /*选择排序思想:每一次都从无序的数据中找出最小的元素,然后和前面已经有序的元素序列的后一个元素进行交换,这样整个源序列就会分成两部分,前面一部分是已经排好序的有序序列,后面一部分是无序的,用于选出最小的元素。循环N次之后,前面的有序序列加长到跟源序列一样长,后面的无序部分长度变为0,排序就完成了。*/ unsigned long __stdcall SelectSort(void* theArray) { long* Array = ((MySafeArray*)theArray)->data; int iLength = ((MySafeArray*)theArray)->iLength; long lMin, lSwap; int i, j, iMinPos; for(i=0; i < iLength-1; i++) { lMin = Array[i]; iMinPos = i; for(j=i + 1; j <= iLength-1; j++) //从无序的元素中找出最小的元素 { if(Array[j] < lMin) { iMinPos = j; lMin = Array[j]; } } //把选出的元素交换拼接到有序序列的最后 lSwap = Array[i]; Array[i] = Array[iMinPos]; Array[iMinPos] = lSwap; } PrintResult(Array, iLength, "Select Sort"); //向控制台打印排序结果 InterlockedIncrement(&ThreadCompleted); //返回前使线程完成数标记加1 if(ThreadCompleted == 4) SetEvent(evtTerminate);//检查是否其他线程都已执行完 //若都执行完则设置程序结束信号量 return 0; } /*堆排序思想:堆:数据元素从1到N排列成一棵二叉树,而且这棵树的每一个子树的根都是该树中的元素的最小或最大的元素这样如果一个无序数据集合是一个堆那么,根元素就是最小或最大的元素堆排序就是不断对剩下的数据建堆,把最小或最大的元素析透出来。下面的算法,就是从最后一个元素开始,依据一个节点比父节点数值大的原则对所有元素进行调整,这样调整一次就形成一个堆,第一个元素就是最小的元素。然后再对剩下的无序数据再进行建堆,注意这时后面的无序数据元素的序数都要改变,如第一次建堆后,第二个元素就会变成堆的第一个元素。*/ unsigned long __stdcall HeapSort(void* theArray) { long* Array = ((MySafeArray*)theArray)->data; int iLength = ((MySafeArray*)theArray)->iLength; int i, j, p; long swap; for(i=0; i<iLength-1; i++) { for(j = iLength - 1; j>i; j--) //从最后倒数上去比较字节点和父节点 { p = (j - i - 1)/2 + i; //计算父节点数组下标 //注意到树节点序数跟数组下标不是等同的,因为建堆的元素个数逐个递减 if(Array[j] < Array[p]) //如果父节点数值大则交换父节点和字节点 { swap = Array[j]; Array[j] = Array[p]; Array[p] = swap; } } } PrintResult(Array, iLength, "Heap Sort"); //向控制台打印排序结果 InterlockedIncrement(&ThreadCompleted); //返回前使线程完成数标记加1 if(ThreadCompleted == 4) SetEvent(evtTerminate); //检查是否其他线程都已执行完 //若都执行完则设置程序结束信号量 return 0; } /*插入排序思想:把源数据序列看成两半,前面一半是有序的,后面一半是无序的,把无序的数据从头到尾逐个逐个的插入到前面的有序数据中,使得有序的数据的个数不断增大,同时无序的数据个数就越来越少,最后所有元素都会变得有序。*/ unsigned long __stdcall InsertSort(void* theArray) { long* Array = ((MySafeArray*)theArray)->data; int iLength = ((MySafeArray*)theArray)->iLength; int i=1, j=0; long temp; for(i=1; i<iLength; i++) { temp = Array[i]; //取出序列后面无序数据的第一个元素值 for(j=i; j>0; j--) //和前面的有序数据逐个进行比较找出合适的插入位置 { if(Array[j - 1] > temp) //如果该元素比插入值大则后移 Array[j] = Array[j - 1]; else //如果该元素比插入值小,那么该位置的后一位就是插入元素的位置 break; } Array[j] = temp; } PrintResult(Array, iLength, "Insert Sort"); //向控制台打印排序结果 InterlockedIncrement(&ThreadCompleted); //返回前使线程完成数标记加1 if(ThreadCompleted == 4) SetEvent(evtTerminate); //检查是否其他线程都已执行完 //若都执行完则设置程序结束信号量 return 0; } /*快速排序思想:快速排序是分治思想的一种应用,它先选取一个支点,然后把小于支点的元素交换到支点的前边,把大于支点的元素交换到支点的右边。然后再对支点左边部分和右 边部分进行同样的处理,这样若干次之后,数据就会变得有序。下面的实现使用了递归 建立两个游标:iLow,iHigh;iLow指向序列的第一个元素,iHigh指向最后一个先选第一个元素作为支点,并把它的值存贮在一个辅助变量里。那么第一个位置就变为空并可以放置其他的元素。 这样从iHigh指向的元素开始向前移动游标,iHigh查找比支点小的元素,如果找到,则把它放置到空置了的位置(现在是第一个位置),然后iHigh游标停止移动,这时iHigh指向的位置被空置,然后移动iLow游标寻找比支点大的元素放置到iHigh指向的空置的位置,如此往复直到iLow与iHigh相等。最后使用递归对左右两部分进行同样处理*/ int QuickSort(long* Array, int iLow, int iHigh) { if(iLow >= iHigh) return 1; //递归结束条件 long pivot = Array[iLow]; int iLowSaved = iLow, iHighSaved = iHigh; //保未改变的iLow,iHigh值保存起来 while (iLow < iHigh) { while (Array[iHigh] >= pivot && iHigh > iLow) //寻找比支点大的元素 iHigh -- ; Array[iLow] = Array[iHigh]; //把找到的元素放置到空置的位置 while (Array[iLow] < pivot && iLow < iHigh) //寻找比支点小的元素 iLow ++ ; Array[iHigh] = Array[iLow]; //把找到的元素放置到空置的位置 } Array[iLow] = pivot; //把支点值放置到支点位置,这时支点位置是空置的 //对左右部分分别进行递归处理 QuickSort(Array, iLowSaved, iHigh-1); QuickSort(Array, iLow+1, iHighSaved); return 0; } //每一个线程都要使用这个函数进行输出,而且只有一个显示器,产生多个线程 //竞争对控制台的使用权。 void PrintResult(long* Array, int iLength, const char* HeadStr) { WaitForSingleObject(evtPrint, INFINITE); //等待事件有信号 //EnterCriticalSection(&csPrint); //标记有线程进入临界区 //WaitForSingleObject(mtxPrint, INFINITE); //等待互斥量空置(没有线程拥有它) int i; printf("%s: ", HeadStr); for (i=0; i<iLength-1; i++) { printf("%d,", Array[i]); Sleep(100); //延时(可以去掉) /*只是使得多线程对临界区访问的问题比较容易看得到 如果你把临界控制的语句注释掉,输出就会变得很凌乱,各个排序的结果会 分插间隔着输出,如果不延时就不容易看到这种不对临界区控制的结果 */ } printf("%d\n", Array[i]); SetEvent(evtPrint); //把事件信号量恢复,变为有信号 } |
四、 小结
对复杂的应用程序来说,线程的应用给应用程序提供了高效、快速、安全的数据处理能力。本实例讲述了线程处理中经常遇到的问题,希望对读者朋友有一定的帮助,起到抛砖引玉的作用。