应用程序被装载到内存之后就形成了进程,这是上一章重点讨论的话题。但是程序在内存中是如何执行的呢?这就涉及到了代码的执行单元——线程。本章就线程的创建、多线程处理展开介绍。
本章首先介绍创建线程的方法和线程内核对象,接着详细分析产生线程同步问题的根本原因,并提出一些解决办法。为了扩展多线程的应用和为读者提供更多的实际机会,本章还重点讨论了线程局部存储和CWinThread类的设计,这也是设计框架程序的一个前奏。
本书今后讨论的程序实例很多都是基于多线程的,在实际的应用过程中,大部分程序也都会涉及到多线程,所以读者应该深入掌握本章的内容。
CreateProcess函数创建了进程,同时也创建了进程的主线程。这也就是说,系统中的每个进程都至少有一个线程,这个线程从入口地址main处开始执行,直到return语句返回,主线程结束,该进程也就从内存中卸载了。
主线程在运行过程中还可以创建新的线程,即所谓的多线程。在同一进程中运行不同的线程的好处是这些线程可以共享进程的资源,如全局变量、句柄等。当然各个线程也可以有自己的私有堆栈用于保存私有数据。本节具体介绍线程的创建和线程内核对象对程序的影响。
线程描述了进程内代码的执行路径。进程中同时可以有多个线程在执行,为了使它们能够“同时”运行,操作系统为每个线程轮流分配CPU时间片。为了充分地利用CPU,提高软件产品的性能,一般情况下,应用程序使用主线程接受用户的输入,显示运行结果,而创建新的线程(称为辅助线程)来处理长时间的操作,比如读写文件、访问网络等。这样,即便是在程序忙于繁重的工作时也可以由专门的线程响应用户命令。
每个线程必须拥有一个进入点函数,线程从这个进入点开始运行。主线程的进入点是函数main,如果想在进程中创建一个辅助线程,则必须为该辅助线程指定一个进入点函数,这个函数称为线程函数。线程函数的定义如下:
DWORD WINAPI ThreadProc(LPVOID lpParam); // 线程函数名称ThreadProc可以是任意的
WINAPI是一个宏名,在windef.h文件中有如下的声明:
#define WINAPI __stdcall ;
__stdcall是新标准C/C++函数的调用方法。从底层上说,使用这种调用方法参数的进栈顺序和标准C调用(_cdecl方法)是一样的,都是从右到左,但是__stdcall采用自动清栈的方式,而_cdecl采用的是手工清栈方式。Windows规定,凡是由它来负责调用的函数都必须定义为__stdcall类型。ThreadProc是一个回调函数,即由Windows系统来负责调用的函数,所以此函数应定义为__stdcall类型。注意,如果没有显式说明的话,函数的调用方法是_cdecl。
可以看到这个函数有一个参数lpParam,它的值是由下面要讲述的CreateTread函数的第四个参数lpParameter指定的。
创建新线程的函数是CreateThread,由这个函数创建的线程将在调用者的虚拟地址空间内执行。函数的用法如下:
HANDLE CreateThread (
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程的安全属性
DWORD dwStackSize, // 指定线程堆栈的大小
LPTHREAD_START_ROUTINE lpStartAddress, // 线程函数的起始地址
LPVOID lpParameter, // 传递给线程函数的参数
DWORD dwCreationFlags, // 指定创线程建后是否立即启动
DWORD* lpThreadId // 用于取得内核给新生成的线程分配的线程ID 号
);
此函数执行成功后,将返回新建线程的线程句柄。lpStartAddress 参数指定了线程函数的地址,新建线程将从此地址开始执行,直到return语句返回,线程运行结束,把控制权交给操作系统。
下面是一个简单的例子(03ThreadDemo工程下)。在这个的例子中,主线程首先创建了一个辅助线程,打印出辅助线程的ID号,然后等待辅助线程运行结束;辅助线程仅打印出几行字符串,以模拟真正的工作。程序代码如下:
#include <stdio.h>
#include <windows.h>
// 线程函数
DWORD WINAPI ThreadProc(LPVOID lpParam)
{ int i = 0;
while(i < 20)
{ printf(" I am from a thread, count = %d /n", i++); }
return 0;
}
int main(int argc, char* argv[])
{ HANDLE hThread;
DWORD dwThreadId;
// 创建一个线程
hThread = ::CreateThread (
NULL, // 默认安全属性
NULL, // 默认堆栈大小
ThreadProc, // 线程入口地址(执行线程的函数)
NULL, // 传给函数的参数
0, // 指定线程立即运行
&dwThreadId); // 返回线程的ID号
printf(" Now another thread has been created. ID = %d /n", dwThreadId);
// 等待新线程运行结束
::WaitForSingleObject (hThread, INFINITE);
::CloseHandle (hThread);
return 0;
}
程序执行后,CreateThread函数会创建一个新的线程,此线程的入口地址为 ThreadProc。最后的输出结果如图3.1所示。
图3.1 新线程的运行结果
上面的例子使用CreateThread 函数创建了一个新线程:
CreateThread ( NULL, NULL, ThreadProc, NULL,0, NULL);
创建新线程后CreateThread函数返回,新线程从ThreadProc函数的第一行执行。主线程继续运行,打印出新线程的一些信息后,调用WaitForSingleObject函数等待新线程运行结束。
// 等待新线程运行结束
::WaitForSingleObject (
hThread, // hHandle 要等待的对象的句柄
INFINITE ); // dwMilliseconds 要等待的时间(以毫秒为单位)
WaitForSingleObject函数用于等待指定的对象(hHandle)变成受信状态。参数dwMilliseconds给出了以毫秒为单位的要等待的时间,其值指定为INFINITE表示要等待无限长的时间。当有下列一种情况发生时函数就会返回:
(1)要等待的对象变成受信(signaled)状态。
(2)参数dwMilliseconds指定的时间已过去。
一个可执行对象有两种状态,未受信(nonsignaled)和受信(signaled)状态。线程对象只有当线程运行结束时才达到受信状态,此时“WaitForSingleObject(hThread, INFINITE)”语句才会返回。
CreateThread函数的lpThreadAttributes和dwCreationFlags参数的作用在本节的例子中没有体现出来,下面详细说明一下。
lpThreadAttributes——一个指向SECURITY_ATTRIBUTES结构的指针,如果需要默认的安全属性,传递NULL就行了。如果希望此线程对象句柄可以被子进程继承的话,必须设定一个SECURITY_ATTRIBUTES结构,将它的bInheritHandle成员初始化为TRUE,如下面的代码所示:
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = NULL;
sa.bInheritHandle = TRUE ; // 使CreateThread返回的句柄可以被继承
// 句柄h可以被子进程继承
HANDLE h = ::CreateThread (&sa, ...... );
当创建新的线程时,如果传递NULL做为lpThreadAttributes参数的值,那么返回的句柄是不可继承的;如果定义一个SECURITY_ATTRIBUTES类型的变量sa,并像上面一样初始化sa变量的各成员,最后传递sa变量的地址做为lpThreadAttributes参数的值,那么CreateThread函数返回的句柄就是可继承的。
这里的继承是相对于子进程来说的。当创建子进程时,如果为CreateProcess函数的bInheritHandles参数传递TRUE,那么子进程就可以继承父进程的可继承句柄。
dwCreationFlags——创建标志。如果是0,表示线程被创建后立即开始运行;如果指定为CREATE_SUSPENDED标志,表示线程被创建以后处于挂起(暂停)状态,直到使用ResumeThread函数(见下一小节)显式地启动线程为止。
线程内核对象就是一个包含了线程状态信息的数据结构。每一次对CreateThread函数的成功调用,系统都会在内部为新的线程分配一个内核对象。系统提供的管理线程的函数其实就是依靠访问线程内核对象来实现管理的。下面列出了这个结构的基本成员:
线程内核对象(Thread Kernel Object)
CONTEXT (上下文,即寄存器的状态)
|
|||
Usage Count 使用计数(2) |
|||
Suspend Count 暂停次数(1) |
|||
Exit Code 退出代码(STILL_ACTIVE) |
|||
Signaled 是否受信(FALSE) |
|||
……………… |
创建线程内核对象的时候,系统要对它的各个成员进行初始化,上表中每一项括号里面的值就是该成员的初始值。本节主要讨论内核对象各成员的作用,以及系统如何管理这些成员。
每个线程都有它自己的一组CPU寄存器,称为线程的上下文。这组寄存器的值保存在一个CONTEXT结构里,反映了该线程上次运行时CPU寄存器的状态。
Usage Count成员记录了线程内核对象的使用计数,这个计数说明了此内核对象被打开的次数。线程内核对象的存在与Usage Count的值息息相关,当这个值是0的时候,系统就认为已经没有任何进程在引用此内核对象了,于是线程内核对象就要从内存中撤销。
只要线程没有结束运行,Usage Count的值就至少为1。在创建一个新的线程时,CreateThread函数返回线程内核对象的句柄,相当于打开一次新创建的内核对象,这也会促使Usage Count的值加1。所以创建一个新的线程后,初始状态下Usage Count的值是2。之后,只要有进程打开此内核对象,就会使Usage Count的值加1。比如当有一个进程调用OpenThread函数打开这个线程内核对象后,Usage Count的值会再次加1。
HANDLE OpenThread(
DWORD dwDesiredAccess, // 想要的访问权限,可以为THREAD_ALL_ACCESS等
BOOL bInheritHandle, // 指定此函数返回的句柄是否可以被子进程继承
DWORD dwThreadId // 目标线程ID号
); // 注意,OpenThread函数是Windows 2000及其以上产品的新特性,Windows 98并不支持它。
由于对这个函数的调用会使Usage Count的值加1,所以在使用完它们返回的句柄后一定要调用CloseHandle函数进行关闭。关闭内核对象句柄的操作就会使Usage Count的值减1。
还有一些函数仅仅返回内核对象的伪句柄,并不会创建新的句柄,当然也就不会影响Usage Count的值。如果对这些伪句柄调用CloseHandle函数,那么CloseHandle就会忽略对自己的调用并返回FALSE。对进程和线程来说,这些函数有:
HANDLE GetCurrentProcess (); // 返回当前进程句柄
HANDLE GetCurrentThread (); // 返回当前线程句柄
前面提到,新创建的线程在初始状态下Usage Count的值是2。此时如果立即调用CloseHandle函数来关闭CreateThread返回的句柄的话,Usage Count的值将减为1,但新创建的线程是不会被终止的。
在上一小节那个简单的例子中,Usage Count值的变化情况是这样的:调用CreateThread函数后,系统创建一个新的线程,返回其句柄,并将Usage Count的值初始化为2。线程函数一旦返回,线程的生命周期也就到此为止了,系统会使Usage Count的值由2减为1。接下来调用CloseHandle函数又会使Usage Count减1。这个时候系统检查到Usage Count的值已经为0,就会撤销此内核对象,释放它占用的内存。如果不关闭句柄的话,Usage Count的值将永远不会是0,系统将永远不会撤销它占用的内存,这就会造成内存泄漏(当然,线程所在的进程结束后,该进程占用的所有资源都要释放)。
线程内核对象中的Suspend Count用于指明线程的暂停计数。当调用CreateProcess(创建进程的主线程)或CreateThread函数时,线程的内核对象就被创建了,它暂停计数被初始化为1(即处于暂停状态),这可以阻止新创建的线程被调度到CPU中。因为线程的初始化需要时间,当线程完全初始化好了之后,CreateProcess或CreateThread检查是否传递了CREATE_SUSPENDED 标志。如果传递了这个标志,那么这些函数就返回,同时新线程处于暂停状态。如果尚未传递该标志,那么线程的暂停计数将被递减为0。当线程的暂停计数是0的时候,该线程就处于可调度状态。
创建线程的时候指定CREATE_SUSPENDED标志,就可以在线程有机会在执行任何代码之前改变线程的运行环境(如下面讨论的优先级等)。一旦达到了目的,必须使线程处于可调度状态。进行这项操作,可以使用ResumeThread函数。
DWORD ResumeThread (HANDLE hThread); // 唤醒一个挂起的线程
该函数减少线程的暂停计数,当计数值减到0的时候,线程被恢复运行。如果调用成功,ResumeThread函数返回线程的前一个暂停计数,否则返回0xFFFFFFFF(-1)。
单个线程可以被暂停若干次。如果一个线程被暂停了3次,它必须被唤醒3次才可以分配给一个CPU。暂停一个线程的运行可以用SuspendThread函数。
DWORD SuspendThread (HANDLE hThread); // 挂起一个线程
任何线程都可以调用该函数来暂停另一个线程的运行。和ResumeThread相反,SuspendThread函数会增加线程的暂停计数。
大约每经20ms,Windows查看一次当前存在的所有线程内核对象。在这些对象中,只有一少部分是可调度的(没有处于暂停状态),Windows选择其中的一个内核对象,将它的CONTEXT(上下文)装入CPU的寄存器,这一过程称为上下文转换。但是这样做的前提是,所有的线程具有相同的优先级。在现实环境中,线程被赋予许多不同的优先级,这会影响到调度程序将哪个线程取出来做为下一个要运行的线程。