线程是进程中的一个执行单位(每个进程都必须有一个主线程),一个进程可以有多个线程,而一个线程只存在于一个进程中。在数据关系上,进程与线程是一对多的关系。线程不拥有系统资源,线程所使用的资源全部由进程向系统申请,线程拥有的是CPU的时间片。
在单处理器上(或单核处理器上),同一个进程中的不同线程交替得到CPU的时间片。在多处理器上(或多核处理器上),不同的线程可以同时运行在不同的CPU上,这样可以提高程序运行的效率。除此之外,在有些方面必须使用多线程。比如,如果在扫描磁盘并同时在程序界面上同步显示当前扫描的位置时,必须使用多线程。因为在程序界面上显示和磁盘的扫描工作在同一个线程中,而且界面也在不停进行重新显示,这样就会导致软件看起来像是卡死一样。在这种情况下,分为两个线程就可以解决该问题,界面的显示由主线程完成,而扫描磁盘的工作由另外一个线程完成,两个线程协同工作,这样就可以达到实时显示当前扫描状态的效果了。
首先了解一下线程的创建。线程的创建使用CreateThread()函数,该函数的原型如下:
- HANDLE CreateThread(
- LPSECURITY_ATTRIBUTES lpThreadAttributes, // SD
- DWORD dwStackSize, // initial stack size
- LPTHREAD_START_ROUTINE lpStartAddress, // thread function
- LPVOID lpParameter, // thread argument
- DWORD dwCreationFlags, // creation option
- LPDWORD lpThreadId // thread identifier
- );
参数说明如下。
lpThreadAttributes:指明创建线程的安全属性,为指向 SECURITY_ATTRIBUTES 结构的指针,该参数一般设置为 NULL。
dwStackSize:指定线程使用缺省的堆栈大小,如果为 NULL,则与进程主线程栈相同。
lpStartAddress:指定线程函数,线程即从该函数的入口处开始运行,函数返回时就意味着线程终止运行,该函数属于一个回调函数。线程函数的定义形式如下:
- DWORD WINAPI ThreadProc(
- LPVOID lpParameter // thread data
- );
线程函数的返回值为DWORD类型,线程函数只有一个参数,该参数在CreateThread()函数中给出。该函数的函数名称可以任意给定。很多时候并不能保证执行了CreateThread()函数后线程就会立即启动,线程的启动需要等待CPU的调度,CPU将时间片给该线程时,该线程才会执行,当然这个时间短到可以忽略它。
lpParameter:该参数表示传递给线程函数的一个参数,可以是指向任意数据类型的指针。这里是一个指针,可以方便的将多个参数通过结构体等一次性传到线程函数中。
dwCreationFlags:该参数指明创建线程后的线程状态,在创建线程后可以让线程立刻执行(这里的立即执行的意思是不会受人为的去让它处于等待状态),也可以让线程处于暂停状态。如果需要立刻执行,该参数设置为 0;如果要让线程处于暂停状态,那么该参数设置为 CREATE_SUSPENDED,待需要线程执行时调用ResumeThread()函数让线程的状态调整为等待运行的状态,然后由 CPU 分配时间片后去执行。
lpThreadId:该参数用于返回新创建线程的线程 ID。
如果线程创建成功,该函数返回线程的句柄,否则返回NULL。创建新线程后,该线程就开始启动执行了。但如果在dwCreationFlags中使用了CREATE_SUSPENDED参数,那么线程并不马上执行,而是先挂起,等到调用ResumeThread后才开始启动线程。线程的句柄需要通过CloseHandle()进行关闭,以便释放资源。
写一个简单的多线程的例子,代码如下:
- #include <windows.h>
- #include <stdio.h>
- DWORD WINAPI ThreadProc(LPVOID lpParam)
- {
- printf("ThreadProc \r\n");
- return 0;
- }
- int main()
- {
- HANDLE hThread = CreateThread(NULL,0,ThreadProc,NULL,0,NULL);
- printf("main \r\n");
- CloseHandle(hThread);
- return 0;
- }
代码在主线程中打印一行“main”,在创建的新线程中会打印一行“ThreadProc”。编译运行,查看其运行结果,如图1所示。
图1 多线程程序输出结果
从图1中看出,程序的输出跟预期的结果并不相同。程序的问题出在了哪里呢?每个线程都有属于自己的CPU时间片,当主线程创建新线程后,主线程的CPU时间片并未结束,它会向下继续执行。由于主线程的代码非常少,因此主线程在CPU分配的时间片中就执行完成并退出了。由于主线程的结束,意味着进程也就结束并退出了。因此,在代码中创建的线程虽然被创建了,但是根本就没有执行的机会。那么在这么短的代码中,如何保证新创建的线程在主线程结束前就能得到执行呢?或者说,主线程的运行需要等待新线程的完成才得以执行。这里需要使用WaitForSingleObject()函数,该函数的原型如下:
- DWORD WaitForSingleObject(
- HANDLE hHandle, // handle to object
- DWORD dwMilliseconds // time-out interval
- );
参数说明如下。
hHandle:该参数为要等待的对象句柄。
dwMilliseconds:该参数指定等待超时的毫秒数,如果设为 0,则立即返回,如果设为 INFINITE,则表示一直等待线程函数的返回。INFINITE 是系统定义的一个宏,其定义如下。
- #define INFINITE 0xFFFFFFFF
如果该函数失败,则返回WAIT_FAILED;如果等待的对象编程激发状态,则返回WAIT_ OBJECT_0;如果等待对象变成激发状态之前,等待时间结束了,将返回WAIT_TIMEOUT。
修改上面的代码,在CreateThread()函数后面加入如下代码:
- WaitForSingleObject(hThread, INFINITE);
添加WaitForSingleObject()函数以后,主线程会等待新创建的线程结束再继续向下执行主线程后续的代码。这样在控制台上的输出如图2所示。
图2 主线程等待子线程的执行
WaitForSingleObject()只能等待一个线程,可是在程序中往往要创建多个线程来执行,那么如果需要等待若干个线程的完成状态的话,WaitForSingleObject()函数就无能为力了。不过,系统除了提供WaitForSingleObject()函数外,还提供了另外一个可以等待多个线程的完成状态的函数WaitForMultipleObjects(),该函数的定义如下:
- DWORD WaitForMultipleObjects(
- DWORD nCount, // number of handles in array
- CONST HANDLE *lpHandles, // object-handle array
- BOOL fWaitAll, // wait option
- DWORD dwMilliseconds // time-out interval
- );
该函数的参数比WaitForSingleObject()函数多2个参数,下面介绍这些参数。
nCount:该参数用于指明想要让函数等待的线程的数量。该参数的取值范围在 1 到 MAXIMUM_WAIT _OBJECTS 之间。
lpHandles:该参数是指向等待线程句柄的数组指针。
fWaitAll:该参数表示是否等待全部线程的状态完成,如果设置为 TRUE,则等待全部。
dwMilliseconds:该参数与 WaitForSingleObject()函数中的 dwMilliseconds 用法相同。
WaitForSingleObject()和WaitForMultipleObjects()两个函数除了可以等待线程外,还可以等待用于多线程同步和互斥的内核对象。
在使用多线程的时候常常需要考虑和注意的问题很多。比如多线程同时对一个共享资源进行操作,通过线程需要按照一定的顺序执行等。看一个简单的多线程例子:
- int g_Num_One = 0;
- DWORD WINAPI ThreadProc(LPVOID lpParam)
- {
- int nTmp = 0;
- for ( int i = 0; i < 10; i ++ )
- {
- nTmp = g_Num_One;
- nTmp ++;
- // Sleep(1)的作用是让出 CPU
- // 使其他线程被调度运行
- Sleep(1);
- g_Num_One = nTmp;
- }
- return 0;
- }
每个线程都有一个CPU时间片,当自己的时间片运行完成后,CPU会停止该线程的运行,并切换到其他线程去运行。当多线程同时操作一个共享资源时,这样的切换会带来隐形的问题。这里的代码比较短,在一个CPU时间片内肯定会完成,无法体现出因线程切换而产生的错误。为了达到能够因线程切换导致的错误,在代码中加入了Sleep(1),使得线程主动让出CPU,让CPU进行线程切换。在代码中,线程处理的共享资源是全局变量g_Num_One变量。主函数创建线程的代码如下:
- int main()
- {
- HANDLE hThread[10] = { 0 };
- int i;
- for ( i = 0; i < 10; i ++ )
- {
- hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
- }
- WaitForMultipleObjects(10, hThread, TRUE, INFINITE);
- for ( i = 0; i < 10; i ++ )
- {
- CloseHandle(hThread[i]);
- }
- printf("g_Num_One = %d \r\n", g_Num_One);
- return 0;
- }
在主函数中,通过CreateThread()创建了10个线程,每个线程都让g_Num_One自增10次,每次的增量为1。那么10个线程会使得g_Num_One的结果变成100。编译运行上面的代码,查看输出结果,如图3所示。
图3 多线程操作共享资源的错误结果
这个结果和预测的结果并不相同。为什么会产生这种不同呢?这里进行一次模拟分析。为了方便分析,把线程的数量缩小为两个线程,分别是A线程和B线程。
① g_Num_One的初始值为0。
② 当A线程中执行nTmp = g_Num_One和nTmp++后(此时nTmp的值为1),因为Sleep(1)的原因发生了线程切换,此时g_Num_One的初始值仍然为0。
③ 当B线程中执行nTmp = g_Num_One和nTmp++后(此时nTmp的值也为1),因为Sleep(1)的原因又发生了线程切换。
④ A线程执行g_Num_One = nTmp,此时g_Num_One的值为1,接着执行下一次循环中的nTmp = g_Num_One和nTmp++的操作,又进行切换。
⑤ B线程执行g_Num_One = nTmp,此时g_Num_One的值为1。
到第⑤步时,不继续往下分析了,已经可以看出原因。g_Num_One的值是最后一次nTmp进行赋值后的值(线程中的局部变量属于线程内私有的,虽然是同一个线程函数,但是nTmp在每个线程中是私有的)。
解决该问题,这里使用的是临界区。临界区对象是一个CRITICAL_SECTION的数据结构,Windows操作系统使用该数据结构对关键代码进行保护,以确保多线程下的共享资源。在同一时间内,Windows只允许一个线程进入临界区。
临界区的函数有4个,分别是初始化临界区对象(InitializeCriticalSection())、进入临界区(EnterCriticalSection())、离开临界区(LeaveCriticalSection())和删除临界区对象(DeleteCriticalSection())。临界区很好的保护了共享资源,临界区在现实生活中有很多类似的例子。比如,在进行体检的时候,一个体检室内只有一个体检医生,体检医生会叫一个患者进去体检,这时其他人是不能进入的,当这个患者离开后,下一个患者才可以进入。这里体检医生就是一个共享的资源,而每个体检的患者是多个不同的线程。临界区就是以类似的方式保护了共享资源不被破坏的。下面依次来看一下这四个函数关于临界区的函数的定义,分别如下:
- VOID InitializeCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
- VOID EnterCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
- VOID LeaveCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
- VOID DeleteCriticalSection(
- LPCRITICAL_SECTION lpCriticalSection // critical section
- );
这4个API函数的参数都是指向CRITICAL_SECTION结构体的指针。修改上面有问题的代码,修改后的代码如下:
- #include <windows.h>
- #include <stdio.h>
- int g_Num_One = 0;
- CRITICAL_SECTION g_cs;
- DWORD WINAPI ThreadProc(LPVOID lpParam)
- {
- int nTmp = 0;
- for ( int i = 0; i < 10; i ++ )
- {
- // 进入临界区
- EnterCriticalSection(&g_cs);
- nTmp = g_Num_One;
- nTmp ++;
- Sleep(1);
- g_Num_One = nTmp;
- // 离开临界区
- LeaveCriticalSection(&g_cs);
- }
- return 0;
- }
- int main()
- {
- InitializeCriticalSection(&g_cs);
- HANDLE hThread[10] = { 0 };
- int i;
- for ( i = 0; i < 10; i ++ )
- {
- hThread[i] = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
- }
- WaitForMultipleObjects(10, hThread, TRUE, INFINITE);
- printf("g_Num_One = %d \r\n", g_Num_One);
- for ( i = 0; i < 10; i ++ )
- {
- CloseHandle(hThread[i]);
- }
- DeleteCriticalSection(&g_cs);
- return 0;
- }
编译以上代码并运行,输出结果为想要的正确结果,即g_Num_One的值为100。除了使用临界区以外,对于线程的同步与互斥还有其他方法,这里就不一一进行介绍了。在开发多线程程序时,要注意多线程的同步与互斥问题。临界区对象只能用于多线程的互斥。