第六章 线程基础
本章内容
6.1 何时创建线程
6.2 何时不应该创建线程
6.3 编写一个线程函数
6.4 CreateThread函数
6.5 终止运行线程
6.6 线程内幕
6.7 C/C++运行库注意事项
6.8 了解自己的身份
线程有两个组成部分:
1)一个是线程的内核对象,操作系统用他管理线程。系统还用内核对象来存放线程统计信息的地方。
2)一个线程栈,用于维护线程执行时所需的所有函数的参数和局部变量
进程只是一个线程容器,进程内的线程共享地址空间,进程的内核对象句柄。
进程需要维护地址空间,加载dll ,exe文件等。操作系统内部需要统计大量的信息。
而线程只需要一个内核对象和一个栈,开销低很多。(在VC++库上还会维护一个_tiddata的Thread Local Storage的数据结构)
6.1 何时创建线程
充分利用cpu资源并行执行任务。将UI和后台处理分割,UI只响应用户输入,处理交给后台的线程。这样能提升用户体验。
6.2 何时不应该创建线程
线程能解决一些问题但是会产生一些新的问题。也就是数据共享问题。(多线程同时访问数据该如何同步防止产生资源竞争)
在用户界面上应该用一个线程来创建各个层级的窗口(很少用多线程创建各自的窗口,Windows的Explorer资源管理器的每个窗口都是一个线程为了防止某个文件操作停止响应而导致完全无法操作管理文件)
通常应用程序由一个用户界面线程处理消息和创建各种窗口(高优先级),其他工作线程处理各种后台运算并且不会创建窗口。
6.3 编写第一个线程函数
每个线程都有一个入口函数
例如
DWORD WINAPI ThreadFunc(PVOID pvParam) {
DWORD dwResult = 0;
//...
return dwResult;
}
线程内核对象的寿命可能超过线程本身的寿命。
主线程函数入口必须为 : main, wmain, WinMain wWinMain, 而线程函数可以任意命名
主线程函数有多个参数,而普通线程函数只有一个参数。而且其意义可以由我们自行定义。
线程函数必须有一个返回值,他会成为该线程的退出代码。(主线程的退出代码成为进程的退出代码)
线程函数应该尽量使用函数参数和局部变量。使用静态变量和全局变量,多个线程可以同时访问这些变量,可能破坏变量中保存的内容。(涉及了资源竞争Race Condition)
6.4 CreateThread函数
WINBASEAPI
_Ret_maybenull_
HANDLE
WINAPI
CreateThread(
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
_In_ SIZE_T dwStackSize,
_In_ LPTHREAD_START_ROUTINE lpStartAddress,
_In_opt_ __drv_aliasesMem LPVOID lpParameter,
_In_ DWORD dwCreationFlags,
_Out_opt_ LPDWORD lpThreadId
);
该函数会创建一个线程内核对象,一个较小的数据结构,操作系统用该结构来管理线程。
系统从进程的地址空间中分配内存给线程栈使用。 新线程可以访问进程内核对象的所有句柄,进程中所有内存以及同一个进程中其他所有线程的栈。
作者推荐 在写C/C++代码的时候不要使用CreateThread来创建线程,而应该用C++库的_beginthreadex.
CreateThread提供操作系统级别的创建线程的操作,且仅限于工作者线程。不调用MFC和RTL的函数时,可以用CreateThread,其它情况不要轻易。在使用的过程中要考虑到进程的同步与互斥的关系(防止死锁)。
线程函数定义为:DWORD WINAPI _yourThreadFun(LPVOID pParameter)。
但它没有考虑:
(1)C Runtime中需要对多线程进行纪录和初始化,以保证C函数库工作正常(典型的例子是strtok函数)。
(2)MFC也需要知道新线程的创建,也需要做一些初始化工作(当然,如果没用MFC就没事了)。
同时_beginthreadex呢:
MS对C Runtime库的扩展SDK函数,首先针对C Runtime库做了一些初始化的工作,以保证C Runtime库工作正常。然后,调用CreateThread真正创建线程。 仅使用Runtime Library时,可以用_BegingThreadex。
在_beginthreadex的源码中(后面有贴)。可以看出最重要的一个步骤是调用 _calloc_crt来创建一个线程特有的数据结构和线程私有的数据结构(thread local storage)_tiddata, 比如多线程的strok等。
总结:如果你要在自己创建的线程函数中使用大量C/C++标准库的功能就必须使用_beginthreadex防止出错
如果只是单纯的使用windows api 不涉及任何标志C/C++库函数可以调用CreateThread
6.4.1 psa 参数
一个指向SECURITY_ATTRIBUTES结构的指针。默认的安全属性可以传入NULL。 若要自己从能继承此线程内核对象,则必须设定继承属性初始化成员bInheritHandle = TRUE
6.4.2 cbStackSize参数
指定线程可以为其线程栈使用多少地址空间。
CreateProcess创建主线程也会使用此值,保存在exe文件内部。 可以用/STACK:[reserve] [,commit]来控制该值。
默认是1MB(在Itanium芯片组上默认4MB) commit 指定最初应该为栈预留的地址空间区域调拨多少物理内存,默认是一个页。
预定的地址空间的容量设定了栈空间的上限,这样才能捕获代码中的无穷递归bug(默认x86 CPU的 ESS, ESP寄存器本身并不设定任何栈空间的上限,这是操作系统为了管理栈所做的限制)也防止线程耗尽进程的内存地址空间。
6.4.3 pfnStartAddr 和 pvParam参数
指定了线程函数的地址, 线程函数的参数与CreateThread的pvParam参数一致。通常用于传递一个初始值给线程函数。这样多个线程可以共用同一个线程函数。通过向其传递不同的初始值来指定不同的任务。
参考以下bug代码,在SecondThread中使用了第一个线程栈上的局部变量。该变量在引用时可能已经被销毁了从而导致内存错误。
DWORD WINAPI SecondThread(PVOID pvParam) {
// Do some length processing here...
// attempt to access the variable on FirstThread's stack.
// Note:: this may cause an access violation - it depends on timing!.
*((int *)pvParam) = 5;
return 0;
}
DWORD WINAPI FirstThread(PVOID pvParam) {
// Initialize a stack-based variable
int x = 0;
DWORD dwThreadID;
// Create a new thread.
HANDLE hThread = CreateThread(NULL, 0, SecondThread, (PVOID)&x,
0, &dwThreadID);
// we don't reference the new thread anymore.
// so close our handle to it.
CloseHandle(hThread);
// Our thread is done.
// Bug: our stack will be destroyed, but
// Secondthread might try to access it.
return 0;
}
该问题的解决方案是将x申明为一个静态变量,使编译器在应用程序的Section(而不是线程栈)中为x创建一个存储区域。 但是这又会使得当前线程不可重入。(也就是不能再创建同一个线程来执行当前函数)除非使用正确的线程同步技术。
6.4.4 dwCreateFlags
线程的创建标志, CREATE_SUSPENDED 创建完毕以后线程将被挂起。常用语配合作业来完成一些功能限制。
6.4.5 pdwThreadID
一个指向DWORD型的地址,返回创建线程的ThreadID
6.5 终止运行线程
线程可以通过以下4种方法来终止运行。
1)线程函数返回
2)线程通过调用ExitThread函数来“杀死”自己(要避免这种方法)
3)同一个进程或另一个进程的线程调用TerminateThread(要避免这种方法)
4)包含线程的进程终止运行(要避免该方法)
6.5.1 线程函数返回
让线程函数返回,可以确保以下正确的应用程序清理工作都得以执行。
1)线程函数中创建的所有C++对象都通过其析构函数被正确销毁。