Win32多线程编程(2) — 线程控制

时间:2022-06-15 13:58:20

Win32线程控制只有是围绕线程这一内核对象的创建、挂起、恢复、终结以及通信等操作,这些操作都依赖于Win32操作系统提供的一组API和具体编译器的C运行时库函数。本篇围绕这些操作接口介绍在Windows系统下的多线程编程要点,后续将进一步涉及多线程通信的同步互斥等议题。

 

1.线程的创建(CreateThread

每个线程必须拥有一个进入点函数,线程从这个进入点开始运行。主线程的进入点是main/WinMain函数,如果想在进程中创建一个辅助线程,则必须为该辅助线程指定一个进入点函数,这个函数称为线程函数

线程函数的定义如下:

typedef DWORD (WINAPI* ThreadProc)(LPVOID lpParam); // 线程函数名称ThreadProc可以是任意的

WINAPI是一个宏名,在 windef.h文件中有如下的声明。

#define WINAPI __stdcall

__stdcall 是Windows标准 C/C++函数的调用方法。从底层上说,使用这种调用方法参数的进栈顺序和标准 C调用(__cdecl 方法)是一样的,都是从右到左,但是__stdcall 采用自动清栈的方式,而__cdecl 采用的是手工清栈方式。

Windows 规定,凡是由它来负责调用的函数都必须定义为__stdcall 类型。

ThreadProc是一个回调函数,即由Windows系统来负责调用的函数,所以此函数应定义为__stdcall类型。注意,如果没有显式说明的话,函数的调用方法是__cdecl。

Windows创建新线程的API是CreateThread,由该函数创建的线程将在调用者的虚拟地址空间内执行。函数原型如下:

// The CreateThread function creates a thread to execute within the virtual address space of the calling process.

// To create a thread that runs in the virtual address space of another process, use the CreateRemoteThread function.

HANDLE CreateThread(

LPSECURITY_ATTRIBUTES lpThreadAttributes, // SD

SIZE_T dwStackSize,                       // initial stack size

LPTHREAD_START_ROUTINE lpStartAddress,    // thread function

LPVOID lpParameter,                       // thread argument

DWORD dwCreationFlags,                    // creation option

LPDWORD lpThreadId                        // thread identifier

);

参数一为线程的安全属性,一般设为NULL,表示使用默认安全属性。

参数二为线程堆栈大小,一般设为NULL,表示使用默认堆栈大小,对应VC的/STACK:链接器选项。VC6默认的堆栈大小为1M,可通过“Project SettingsàLinkàStack allocations”设置堆栈大小;VC2005中,可在“项目属性à配置属性à链接器à系统”中设置堆栈大小。

参数三为线程函数的地址,传递函数指针或函数名ThreadProc。

参数四为传递给线程函数的参数,即ThreadProc的参数。其为LPVOID类型,对复杂的参数采用结构体或类按址传递。

参数五为线程创建参数,例如线程创建后是否立即启动的开关选项。

参数六为内核给新创建的线程分配的线程ID号,为输出参数。

用户编写多线程程序时,一般关注参数三和参数四足矣,其他可采用默认参数。

此函数执行成功后,将返回新建线程的线程句柄。lpStartAddress参数指定了线程函数的地址,新建线程将从此地址开始执行,直到 return 语句返回,线程运行结束,把控制权交给操作系统。

线程内核对象(kthread

线程内核对象就是一个包含了线程状态信息的数据结构。每一次对 CreateThread 函数的成功调用,系统都会在内部为新的线程分配一个内核对象。系统提供的管理线程的函数其实就是依靠访问线程内核对象来实现管理的。在WinDbg中,可通过lkd> dt nt!_kthread查看线程内核对象数据结构,这里涉及到线程上下文(Context)、使用计数(Usage Count)和暂停计数(Suspend Count)等重要概念。

(1)线程上下文

线程的上下文本质上是一组处理器的寄存器,有正在执行程序中的指针及堆栈指针。上下文及其转换的过程根据处理器的结构不同会有所不同,参考《线程的数据结构》。在WinDbg中,可以通过lkd> dt nt!_context命令来观察上下文的数据结构。<WINNT.H>中定义了_CONTEXT结构。

大约每经 20ms,Windows 查看一次当前存在的所有线程内核对象。在这些对象中,只有一少部分是可调度的(没有处于暂停状态),Windows 选择其中的一个内核对象,将它的CONTEXT(上下文)装入 CPU的寄存器,这一过程称为上下文切换。

用户可调用GetThreadContext查看当前线程的用户模式的上下文信息;调用SetThreadContext改变线程上下文,待下次调度进CPU时生效。其中,ContextFlags参数通过异或掩码指定欲查看的寄存器。_KTHREAD::ContextSwitches为线程已切换的次数。

(2)使用计数

Usage Count成员记录了线程内核对象的使用计数,这个计数说明了此内核对象被打开的次数。线程内核对象的存在与 Usage Count 的值息息相关,当这个值是0的时候,系统就认为已经没有任何进程在引用此内核对象了,于是线程内核对象就要从内存中撤销。

只要线程没有结束运行,Usage  Count 的值就至少为 1。在创建一个新的线程时,CreateThread 函数返回了线程内核对象的句柄,相当于打开一次新创建的内核对象,这也会促使 Usage Count 的值加1。所以创建一个新的线程后,初始状态下 Usage Count 的值是2。之后,只要有进程打开此内核对象,就会使Usage Count的值加1。比如当有一个进程调用OpenThread函数打开这个线程内核对象后,Usage Count 的值会再次加 1。

// The OpenThread function opens an existing thread object.

HANDLE OpenThread(

DWORD dwDesiredAccess,  // access right

BOOL bInheritHandle,    // handle inheritance option

DWORD dwThreadId        // thread identifier

);

由于对这个函数的调用会使 Usage Count 的值加1,所以在使用完它们返回的句柄后一定要调用 CloseHandle 函数进行关闭。关闭内核对象句柄的操作就会使Usage Count 的值减 1。

还有一些函数仅仅返回内核对象的伪句柄,并不会创建新的句柄,当然也就不会影响Usage Count 的值。如果对这些伪句柄调用 CloseHandle 函数,那么CloseHandle 就会忽略对自己的调用并返回 FALSE。对进程和线程来说,这些函数有:

// The GetCurrentProcess function retrieves a pseudo handle for the current process.

HANDLE GetCurrentProcess(VOID);

// The GetCurrentThread function retrieves a pseudo handle for the current thread.

HANDLE GetCurrentThread(VOID);

前面提到,新创建的线程在初始状态下 Usage  Count 的值是 2。此时如果立即调用CloseHandle 函数来关闭CreateThread返回的句柄的话,Usage Count 的值将减为 1,但新创建的线程是不会被终止的。待线程函数返回,系统会使 Usage Count 的值由1减为0,线程的生命周期到此为止,系统将撤销此线程内核对象,释放其所占内存。

如果不关闭句柄的话,Usage Count 的值将永远不会是 0,系统将永远不会撤销它占用的内存,这就会造成内存泄漏(当然,线程所在的进程结束后,该进程占用的所有资源都要释放)。

(3)暂停计数

暂停计数参考下面的多线程状态控制。

(4)主辅线程的执行同步

一般主线程应该后于辅助线程退出,如果主线程先退出,辅助线程尚在执行,将会出现意想不到的结果,因此主线程必须对辅助线程具有完全的控制权。

一个可执行对象有两种状态,未受信(nonsignaled)和受信(signaled)状态。线程内核对象只有当线程过程运行结束时才达到受信状态。可调用WaitForSingleObject/WaitForMultipleObjects函数在线程内核对象(HANDLE)上等待,以便主辅线程同步。

 

2.线程的状态控制(SuspendThread/ResumeThreadSleep

线程在创建后和终止前之间的状态,用户可感知或控制的状态主要有运行和暂停(中断)两种,对应的操作为挂起(Suspend)和恢复(Resume)。

线程内核对象中的Suspend Count(_KTHREAD::SuspendCount)用于指明线程的暂停计数。

当调用CreateProcess(创建进程的主线程)或CreateThread函数时,线程的内核对象被创建了,它的暂停计数被初始化为1(即出于暂停状态),这可以阻止新创建的线程被立即调度进CPU中。因为线程初始化需要时间,当线程完全初始化好了之后,CreateProcess或CreateThread检查dwCreationFlags参数是否传递了CREATE_SUSPEND标志,如果传递了,这些函数就返回,同时新线程处于暂停状态。如果尚未传递该标志,那么线程的暂停计数将被递减为 0。当线程的暂停计数是 0的时候,该线程就进入可调度状态。

创建线程的时候,若指定CREATE_SUSPEND标志,则用户有机会在线程执行任何代码之前改变线程的运行环境(如后面讨论的优先级)。然后,需要调用ResumeThread函数,减少线程的暂停计数至0,使线程恢复运行,进入可调度状态。

// The ResumeThread function decrements a thread's suspend count. When the suspend count is decremented to zero, the execution of the thread is resumed.

DWORD ResumeThread(

HANDLE hThread   // handle to thread

);

后续可调用SuspendThread函数来暂停一个线程的运行,因为该API传递的是句柄,故该函数可跨进程调用,即在一个线程中暂停另一个线程。与ResumeThread相反,SuspendThread函数的调用会增加线程的暂停计数。

// The SuspendThread function suspends the specified thread.

DWORD SuspendThread(

HANDLE hThread   // handle to thread

);

只有当线程的暂停计数是 0的时候,线程才能进入可调度状态。因此,必须注意SuspendThread/ResumeThread函数调用次数的匹配,以便正确控制。

SuspendThread/ResumeThread函数对于线程状态的控制具有很强的针对性,另外一种简单的替代方案是调用Sleep函数,让调用线程睡一会儿,给其他的线程一个调度机会,从而提供一种线程切换缓冲机制。

// The Sleep function suspends the execution of the current thread for the specified interval.

// To enter an alertable wait state, use the SleepEx function.

VOID Sleep(

DWORD dwMilliseconds   // sleep time

);

Sleep函数在线程的while(1) 死循环中非常实用,因为一个线程如果while(1)轮回,则CPU使用率一般会飙升,置系统与卡死状态。特殊地,Sleep(0)表示调用线程主动放弃时间片的剩余部分,它强制系统调度其他线程。但是,系统有可能重新调度刚刚调用了Sleep的那个线程。 由于该调用本身占用时钟周期,也会让当前线程放弃时间片,交出控制权,从而使别的线程有机会执行。当然,可直接调用SwitchToThread()执行线程切换。

3.线程的优先级控制(GetThreadPriority/SetThreadPriority

线程的状态转换、优先级及调度方案,请参考《线程的调度》。

对于一个线程,我们可以调用GetThreadPriority函数来获取其优先级。

// The GetThreadPriority function retrieves the priority value for the specified thread. This value, together with the priority class of the thread's process, determines the thread's base-priority level.

int GetThreadPriority(

HANDLE hThread   // handle to thread

);

系统可以动态地调整线程的优先级,当系统希望这个线程处理窗口消息、I/O调用或是系统发现3-4秒内这个线程一直迫切地需要一个时间片时,它会将这个线程的优先级调整为15并可以运行双倍的时间片。对线程优先级的动态调整是通过调用SetThreadPriority函数实现的。

// The SetThreadPriority function sets the priority value for the specified thread. This value, together with the priority class of the thread's process, determines the thread's base priority level.

BOOL SetThreadPriority(

HANDLE hThread, // handle to the thread

int nPriority   // thread priority level

);

    对于一般的应用程序开发,按照常规的优先级配置即可,很少涉及优先级的操作。

4.线程的停止(ExitThread/TerminateThread

线程过程的结束主要有两种情形,即自然终止和人为中止。

自然终止,主要是指线程函数自然返回的情况,是寿终正寝的圆寂。人为中止是通过暴力手段将尚未完成使命的线程谋杀,是死于非命的夭折。

线程自然终止时,会发生下列事件:

l  在线程函数中创建的所有 C++对象将通过它们各自的析构函数被正确地销毁。

l  该线程使用的堆栈将被释放。

l  系统将线程内核对象中 Exit Code(退出代码)的值由 STILL_ACTIVE 设置为线程函数的返回值。

l  系统将递减线程内核对象中 Usage Code(使用计数)的值。

线程结束后的退出代码可以被其他线程用GetExitCodeThread函数检测到,所以可以当做自定义的返回值来表示线程的执行结果。

人为中止主要有两种手段:

(1)调用ExitThread(CRT中的exit)结束当前线程(调用线程);

// The ExitThread function ends a thread.

VOID ExitThread(

DWORD dwExitCode   // exit code for this thread

);

ExitThread 函数会中止当前线程的运行,促使系统释放掉所有此线程使用的资源。但是,线程函数中申请的C++资源,典型的如C++类(class)却不能得到正确地清除。这是因为C++对象的析构函数是通过atexit挂接到线程中,在exit退出时(main/WinMain或线程过程返回之后)调用。若线程过程被终止,则CRT越过atexit,从而使C++资源不能正确释放。所以,结束线程最好的方法还是让线程自然返回。

(2)调用TerminateThread进行跨线程中止。

// The TerminateThread function terminates a thread.

BOOL TerminateThread(

HANDLE hThread,    // handle to thread

DWORD dwExitCode   // exit code

);

因为该API传递的是句柄,故该函数可跨线程调用,即在一个线程中中止另一个线程。

这是一个被强烈建议避免使用的函数,因为一旦执行这个函数,程序无法预测目标线程会在何处被中止,其结果就是目标线程可能根本没有机会来做清除工作,如线程中打开的文件和申请的内存都不会被释放。另外,使用 TerminateThread 函数中止线程的时候,系统不会释放线程使用的堆栈。所以建议读者在编程的时候尽量让线程自己退出,如果主线程要求某个线程结束,可以通过各种方法通知线程,线程收到通知后自行退出。只有在迫不得已的情况下,才使用TerminateThread 函数终止线程。

无论是自然终止还是人为中止,它们都将使使用计数减1。此时,我们通过调用GetExitCodeThread函数来获取线程函数的返回值。最后,必须调用CloseHandle关闭线程句柄,使使用计数再减1。至此,该线程内核对象结束生命的旅程。

总之,始终应该让线程正常退出,即使它的线程函数自然返回。通知线程退出的方法很多,如设置全局变量、使用事件对象等,这涉及到下一节线程间的通信问题。

5.用CRT_beginthreadex代替操作系统的CreateThread

(1)为IDE选择正确的RTL(Run Time Library

在VC6中,“Project Settings à C/C++ à Category(Code Generation)à Use run-time library”共有六个选项供选择:

/ML:Single-Threaded*

/MLd:Debug Single-Threaded

/MT:Multithreaded

/MTd:Debug Multithreaded

/MD:Multithreaded DLL

/MDd:Debug Multithreaded DLL

其中/ML[d]和/MT[d]主要针对常规多线程应用程序的RTL版本选择,/MD[d]主要针对多线程LIB或DLL工程中的RTL版本选择。

VC6常规工程的默认选项为/ML[d],MFC工程的默认选项为/MD[d]。对于需要多线程支持的工程中,如果不设定/MT或/MD选项,则可能出现“error LNK2001: unresolved external symbol __beginthreadex”或“error C2065: '_beginthreadex' : undeclared identifier”。

在VC2005中,“项目属性 à 配置属性 à C/C++  à 代码生成”中提供了四种选择,

多线程(/MT

多线程调试(/MTd

多线程DLL(/MD

多线程调试DLL(/MDd

VC2005工程默认选项为/MD[d]。

不同的链接选项,链接器将选择不同的链接库进行链接。参考《C Run-Time Libraries (CRT)》和《/MD, /ML, /MT, /LD   (Use Run-Time Library)》。

(2)用C运行时库的_beginthreadex代替操作系统的CreateThread来创建线程

在实际的开发过程中,一般不直接使用Windows系统提供的CreateThread函数创建线程,而是使用 C/C++运行期函数_beginthread(ex)/_endthread(ex)。

使用_beginthread无法创建带有安全属性的新线程,无法创建暂停的线程,也无法获得线程ID。_endthread的情况类似,它不带参数,这意味这线程的退出代码必须硬编码为0。一般不调用_beginthread/_endthread,而是调用其扩展版本_beginthreadex/_endthreadex。

事实上,C/C++运行期库提供CreateThread加强版的_beginthreadex,是为了多线程同步的需要。在早期的单线程C运行库中有许多的全局变量,如errno、strerror等,它们可以用来表示线程当前的一些状态。

但是在多线程程序设计中,每个线程必须有惟一的状态,否则这些变量记录的信息就不会准确了。比如,全局变量errno 用于表示调用运行期函数失败后的错误代码。如果所有线程共享一个errno 的话,在一个线程产生的错误代码就会影响到另一个线程。为了解决这个问题,每个线程都需要有自己的errno 变量。

要想使运行期为每个线程都设置状态变量,必须在创建线程的时候调用运行期提供的_beginthreadex,让运行期设置了相关变量后再去调用Windows系统提供的 CreateThread函数。

_beginthreadex的参数与CreateThread函数对应,函数的参数和数据类型都是C Run-time Library中的类型,在使用时候需要强制类型转换。

// /Microsoft Visual Studio/VC98/CRT/SRC/THREADEX.C

unsigned long __cdecl _beginthreadex(

void *security,

unsigned stacksize,

unsigned (__stdcall* initialcode)(void*),

void * argument,

unsigned createflag,

unsigned *thrdaddr)

{

_ptiddata ptd;                  /* pointer to per-thread data */

// ……

ptd->_initaddr = (void *)initialcode;

ptd->_initarg = argument;

ptd->_thandle = (unsigned long)(-1L);

// ……

CreateThread(security,

stacksize,

_threadstartex,

(LPVOID)ptd,  // pointer to per-thread data(_ptiddata ptd)

createflag,

thrdaddr));

// ……

}

线程启动函数为_threadstartex,其参数为线程局部存储(TLS)结构_ptiddata ptd,其中包含了线程函数ptd->_initaddr和线程参数ptd->_initarg。关于线程局部存储(TLS),参考后续议题。实际上_ptiddata结构中定义了_terrno、_token、_errmsg等单线程全局变量。

struct _tiddata

{

unsigned long   _tid;           /* thread ID */

unsigned long   _thandle;       /* thread handle */

int     _terrno;                /* errno value */

unsigned long   _holdrand;      /* rand() seed value */

char *      _token;             /* ptr to strtok() token */

char *      _errmsg;            /* ptr to strerror()/_strerror() buff */

void *      _initaddr;          /* initial user thread address */

void *      _initarg;           /* initial user thread argument */

}

typedef struct _tiddata * _ptiddata;

// _threadstartex() - New thread begins here

static unsigned long WINAPI _threadstartex(void *ptd)

{

// ……

// Call fp initialization, if necessary

if ( _FPmtinit != NULL )

(*_FPmtinit)();

// ……

_endthreadex(((unsigned (WINAPI *)(void*))(((_ptiddata)ptd)->_initaddr))(((_ptiddata)ptd)->_initarg));

}

线程启动函数_threadstartex中,进行相关的初始化(_FPmtinit)后,开始调用ptd->_initaddr(ptd->_initarg)执行真正的线程过程。传递线程过程返回码调用_endthreadex函数。

// _endthreadex() - Terminate the calling thread

void __cdecl _endthreadex(unsigned retcode)

_endthreadex函数释放线程局部存储(TLS)数据_ptiddata ptd,调用ExitThread结束当前线程的运行。故这里有了不调用ExitThread的另一个理由:即它会阻止线程的_ptiddata内存的释放。故建议使用_endthreadex替代ExitThread调用,当然,这也是不应该提倡的做法。

参考《_beginthreadex和CreateThread》。