回炉重造之重读Windows核心编程-006-线程

  线程也是有两部分组成的:

  1. 线程的内核对象,操作系统用来管理线程和统计线程信息的地方。
  2. 线程堆栈,用于维护现场在执行代码的时候用到的所有函数参数和局部变量。

  进程是线程的容器,如果进程中有一个以上的线程,这些线程将共享进程的地址空间,操作空间中的数据,执行相同的代码,对相同的数据操作,甚至内核对象句柄(因为它是依托进程而不是线程存在的)。

  所以进程使用的系统资源比线程多的多,线程只需要一个内核对象和一个堆栈。既然线程比进程需要的开销少,因此始终都应该设法用增加线程来解决编程问题。当然这也不是一成不变的,应该懂得权衡利弊。



何时运行线程

  前面的章节已经提到过,当进程被初始化时,系统就要为进程创建一个主线程,用以和CC++运行期库的启动代码一起运行,然后进入入口点函数,然后运行至入口点函数返回并CC++运行期库的启动代码调用ExitProcess为止。

  每个计算机都有一个功能强大的资源,CPU。让它闲置起来是没有道理的,应该让它处于繁忙之中,执行各种各样的工作:

  • 打开Windows2K开始就自带的内容索引服务程序,它能创建一个低优先级的线程,以定期打开磁盘上的文件内容并为之做索引。这样可以大大加快查找文件的效率。
  • 还可以使用磁盘碎片整理软件,使用低优先级线程运行,在系统空项期整理文件碎片。
  • 自动编译源代码文件、实时查看错误和警告信息。
  • 电子表格应用程序能够在后台运算。
  • 文字处理程序能够执行重新分页、拼写、后台打印和语法检查。
  • 文件内容后台拷贝到其他介质中。
  • Web浏览器和其他的服务器通信。

  最重要的是,多线程可以简化用户界面。设计一个拥有多线程的应用程序,可以扩大应用程序的功能。每一个线程都被分配了一个cpu,因此如果计算机有多个CPU,就可以让所有cpu都处于繁忙状态。


不能创建线程的情况

  多线程并发至少有一个问题:代码的重入,数据访问的冲突。

  一般来说一个应用程序有一个用户界面线程用于创建所有窗口,以及一个GetMessage循环。其他的线程都是工作线程,比用户界面线程优先级要低。


编写第一个线程函数

  主线程有对应的入口点,那么线程函数也有个入口函数,作为开始执行第一条代码的地方。和主线程一样,执行代码到结束就释放资源,线程内核对象的引用计数递减。

  • 和主线程不同,线程函数可以是任何名字。
  • 线程函数也可以传递参数。
  • 线程函数必须返回一个值,作为退出代码。
  • 线程函数应该尽可能地使用函数参数和局部变量。如果访问静态和全局的数据,就会有同步的问题。

CreateThread函数 

  调用下面的WindowsAPI就可以创建一个线程:

  

   上面的函数被调用的时候,系统创建一个内核对象,用来管理与线程相关的数据结构,和进程是类似的。系统从进程的地址空间中分配内存,给线程的堆栈使用。新线程的运行环境与创建线程的环境相同。这样使得同一个进程的多个线程之间的通信时相对方便的。

  注意:CreateThread函数是Windows用来创建线程的函数。可是如果你在写CC++代码的话,就不应该使用它了,而是使用VisualC++的运行时函数_beginthreadex。如果不是VisualC++编译器,你的编译器供应商会提供CreateThread的替代函数。反正不能用CreateThread。

  psa参数是指向SECURITY_ATTRIBUTES结果的指针,一般使用默认值。具体的用法请看第三章。

  cbStack参数用于设置线程堆栈大小,可以用链接程序的/STACK:[reserve][.commit]开关控制这个值。reserve参数用来设定系统为线程堆栈保留的地址控件量,默认1MB;commit参数用于设定“应该承诺用于堆栈保留区的物理存储器的容量”,默认值是1页。这个参数即便传递了值,函数仍然会检查链接器已经设置的值,哪个大用哪个。如果把0传进来,就使用Stack开关中设置的值。无论如何,这个值应该有个上限,否则如果存在递归则有可能消耗完所有的资源。

  pfnStartAddrpvParam分别是入口地址和参数。

  fdwCreate参数可以是0或者CREATE_SUSPEND。前者表示线程创建后立即调度,后者表示创建线程后先暂停运行。然而后者并不常用。

  pdwThreadID参数用来存放线程ID。

  


终止线程的运行  

  • 线程函数返回(最好的办法)如果线程可以返回,就可以确保下列事项的实现:
  1. 在现场函数中创建的所有C++对象都能通过它们的析构函数销毁。
  2. 操作系统正确地释放线程堆栈使用的内存。
  3. 系统将线程的退出代码(在线程的内核对象中维护)设置为线程函数的返回值。
  4. 系统递减线程内核对象的引用计数。
  • 调用ExitThread函数,线程自行撤销(最好不用)。如果使用,C++资源将不会被回收。实在要用也是用VisualC++提供的_endthreadex,或者你的编译器供应商提供的替代函数。
  • 使用TerminateThread函数(避免使用)。它能撤销线程,线程的内核对象的引用计数也会递减。
  1. TerminateThread函数是异步运行的,想知道线程终止运行,就要调用WaitForSingleObject或者类似的函数。(设计良好的应用程序从来不使用这个函数,因为被终止运行的线程收不到它被撤销的通知,线程不能被正确地清除,并不能防止自己被撤销)
  2. 使用这个函数,系统是不回收这个线程的堆栈资源的。
  3. 线程终止运行的话,DLL通常接受清楚通知。而使用这个函数,DLL就不接受通知了,这就阻挡了适当的清除。
  • 包含线程的进程终止运行(避免使用)
  1. 就像对剩余的每个线程调用TerminateThread一样。显然这意味着正确的应用程序清除没有发生:C++对象的析构函数没有被调用, 数据没有转至磁盘等。

线程如果终止

  • 线程拥有的用户对象全被释放。
  • 线程的退出代码从STILL_ACTIVE传递给ExitThread或者TerminateThread
  • 线程的内核对象变为已通知。
  • 如果是最后一个线程,系统也将进程视为已经终止运行。
  • 线程内核对象的引用计数递减1。

一旦线程不再运行,系统中就没有别的线程能够处理这个线程的句柄,而别的线程可以调用GetExitCodeThread来检查hThread标识的线程是否时间终止运行。如果是这样,就确定它的退出代码:

  

   如果尚未终止运行,函数就返回STILL_ACTIVE标识符(定义为0x103),放进pdwExitCode。如果成功就返回TRUE


线程的其他性质

  SP和IP分别是参数和线程函数的入口!

  

   如果调用CreateThread创建了一个内核对象,下面的事情就会发生:

  1. 这个对象的引用计数是2。
  2. 线程的内核对象的其他属性也被初始化,引用计数被设置为1,退出代码设置为STILL_ACTIVE(0x103),该对象的已通知设置为未通知状态。

  一旦内核对象创建完成:

  1. 系统就从进程的地址空间中,给线程堆栈分配内存。
  2. 系统先后把参数和入口地址写入堆栈。

  每个线程都有自己的一套CPU寄存器,成为线程的上下文,用以反映线程上次运行时寄存器的状态。这些寄存器保存在CONTEXT结构里,这个结构本身则保存在线程的内核对象中。

  ESP和EIP是线程上下文中两个最重要的寄存器。由于线程总是在进程的上下文中运行的,因此这两个寄存器是用于标识线程所在的进程的地址空间中的。

  在线程被初始化的时候,context结构的堆栈指针寄存器暂存于线程堆栈上用来放置pfnStartAddr的地址,由一个未文档化的函数BaseThreadStart(在Kernel32.dll模块)调用:

  

  当线程完全初始化后,系统就要查看CREATE_SUSPEND标识是否被传递到CreateThread。如果没有传递,系统就把线程的暂停计数递减为0,线程就可以调度在一个进程中,然后加上上次保存的上下文加载到寄存器,线程就开始执行代码。

  根据上图,我们可以知道BaseThreadStart函数才是真正开始执行线程函数的地方,这个函数有两个参数,BaseThreadStart认为是被另一个函数调用的因为它可以访问两个参数但实际情况并非如此。正是因为可以访问这些参数,是因为操作系统显式地将值写入了堆栈(参数传递给函数的地方)。不过也有CPU使用寄存器传递参数,这样的话系统会允许线程在执行BaseThreadStart之前对寄存器初始化。

   如果新线程开始执行BaseThreadStart函数,将会发生下列情况:

  • 在线程函数中建立一个结构化异常处理(SEH)帧,线程在发生任何异常的时候都会得到系统的默认处理。
  • 系统调用线程函数,并将你传递给CreateThreadpvParam参数传递给它。
  • 当线程返回时,BaseThreadStart调用ExitThread,并将返回值传给它。线程内核对象的引用计数递减,线程停止执行。
  • 如果线程产生了一个没有处理的异常条件,则由BaseThreadStart函数建立的SEH帧处理它。这样最终的结果是停止进程的运行,而不只是线程。

   BaseThreadStart函数中,要么调用ExitThread,要么调用ExitProcess。这意味着线程不能退出这个函数,而是把返回地址推进堆栈,这样线程就知道在哪里返回。但是BaseThreadStart函数没有返回值,这样如果它不撤销线程,而是试图返回,那么就会访问到一个随机的内存地址,从而引发违规。

  当进程的主线程被初始化的时候,它的指令指针被设置为另一个未文档化的函数BaseProcessStart,结构并不陌生:

  

VOID BaseProcessStart(PROCESS_START_ROUTINE pfnStartAdr){
    __try{
        ExitThread(pfnStartAddr());
    }
    __except(UnhandleExceptionFilter(GetExceptionInformation())){
        ExitProcess(GetExceptionCode());
    }
}

  两个函数之间唯一的差别就是,BaseProcessStart没有使用pvParam参数。当BaseProcessStart开始执行时,它调用CC++运行时库的启动代码,接着初始化对应的入口点函数并调用它们。当入口点函数返回时,CC++运行时库的启动代码就调用ExitProcess。因此,对于CC++应用函数来说,主线程从不返回BaseProcessStart函数。


  VisualC++有6个CC++运行时库:

  • LibC.lib,单线程应用程序的静态链接库(创建程序时默认使用)
  • LibCD.lib,单线程应用程序的静态链接库的调试版
  • LibCMt.lib,多线程应用程序的静态链接库的发行版
  • LibCMtD.lib,多线程应用程序的静态链接库的调试版。
  • MSVCRt.lib,动态链接MSVCRt.lib的发行版的输入库。
  • MSVCRtD.lib,动态链接MSVCRtD.lib的调试版的输入库,同时支持单线程和多线程。

  无论实现什么类型的编程项目,必须知道哪个库将链接到你的项目。在CC++选项卡的Code Generation显示的类别中,可以选定一项。

  为什么将一个库用于单线程应用程序,而将另一个库用于多线程应用程序?究其原因,还是因为标准C运行时库问世的比较早,远远早于线程在应用程序的应用。标准C运行时库的发明者并没有考虑过多线程的问题。假定有下面的代码:

 1 BOOL fFailure = (system("NOTEPAD.EXE README.TXT")==-1);
 2 if(fFailure){
 3    switch(error){
 4     case E2BIG: // Argument list of environment to big
 5       break;
 6     case ENOENT: // Command interpreter cannot be found
 7       break;
 8     case ENOEXEC: // Command interpreter has bad format
 9       break;
10     case ENOMEM: // Insufficient memory to run command 
11       break;
12   }  
13 }

  假设某个线程在第一行执行后和第二行执行前,线程中断了运行,又假设中断运行是为了让同一个进程中的另一个线程开始执行,而这个新线程将执行另一个负责设置全局变量errno的/C运行时函数。这样当CPU重新分配给第一个线程的时候,errno的值将不在能够反映上面代码中出错的错误代码。

  为了解决这个问题,就要每个线程都有自己的errno,而不影响其他线程的errno,以及_doserrno,strtok,_wcstok,strerror,_strerror,tmpnam,tmpfile,asctime,_wasctime,gmtime,_ecvt,fcvt等。

  可是系统并不知道你的程序使用CC++编写的,更不知道你调用函数的线程本就是不安全的。问题在于你必须正确地进行所有的操作。若要创建一个新线程,绝对不要使用操作系统的CreateThread,必须使用CC++运行时库函数__beginthreadex:

1 unsigned long _begintheradex(
2     void *security,
3     unsigned stack_size,
4     unsigned (*start_addres)(void *),
5     void *arglist,
6     unsigned initflag,
7     unsigned *thrdaddr);

  注意,这个函数只存在与CC++运行时库的多线程版本中,如果链接到单线程的版本,链接就会得到一个“未转换的外部符号”错误消息。当然,从设计上讲,单线程库是不能在多线程应用程序中正确运行的。另外要注意,VisualStudio在创建新项目时默认使用的是单线程库。这并不是最安全的默认设置,对于多线程应用程序来说,应该显示地转换到多线程的版本。

  由于Microsoft为CC++运行时库提供了源代码,因此很容易确定CreateThread_beginthreadex两者之间的差别。_beginthreadex的源代码在threadex.c中。  

C:Program Files (x86)Microsoft Visual Studio 10.0VCcrtsrc	hreadex.c

  关于_beginthreadex有下面这些:

  • 每个线程都获得由CC++运行时库的堆栈分配的自己的tiddata内存结构(在mtdll.h文件中)。
  • 传递给_beginthreadex的线程函数保存在tiddata中,传递给这个函数的参数也保存在这个结构中。
  • _beginthreadex的实现来看确实是在内部调用了CreateThread,这是操作系统唯一知道的创建新线程的方法。
  • 当调用CreateThread时,它被告知通过调用_threadstartex而不是pfnStartAddr来执行新线程。
  • 传递给线程函数的参数是tiddata结构而不是pvParam的地址。
  • 如果一切顺利,返回线程句柄,否则返回NULL。

  这个结构和线程关联的方式是通过函数_threadstartex函数(和_beginthreadex在同样的文件中)。关于_threadstartex

  • tiddata的地址作为唯一的参数被传递给_threadstartex
  • TlsSetValue是一个操作系统函数,负责将一个值与调用线程关联起来。Tls的术语叫做线程本地存储
  • 一个SEH帧被放置在线程函数的周围,负责处理与运行时库的许多事情:
    • 运行时错误
    • CC++运行时库的signal函数。如果线程是用CreateThread创建的,又调用signal函数,那么函数就不能正确的运行。
  • 调用必要的线程函数,传递必要的参数。记住,函数和参数的地址由_beginthreadex保存在tiddata中。
  • 线程函数的返回值被认为是线程的退出代码。注意,_threadstaartex不是返回到BaseThreadStart。如果它这样做,线程就终止运行,退出代码被正确地设置,线程的tiddata块却不会被撤销。这将导致一个漏洞。若要防止这个漏洞,就要调用_endtheadex(同样位于Threadex.c文件)。

  关于_endtheadex

  • _getptd函数内部调用TlsGetValue函数,它负责检索调用线程的tiddata块的地址。
  • 该数据块被释放,操作系统调用EndThread以便真正地撤销线程。

       Microsoft的VisualC++开发小组认识到编程人员喜欢调用ExitThread,就实现了他们的愿望,还不会让应用程序占用内存。如果真的想强制撤销线程,可以调用_endthreaded(而不是ExitThread)以便释放tiddata块,然后退出。

  现在数据块和线程关联起来,每个线程都有各自的内存存放数据了。例如errno在多线程环境下实际上是一个宏了,

  显然,上面说到的那些操作都会影响多线程版本的CC++运行时库的性能,这也是为什么吗Microsoft公司除了多线程版本,还提供单线程版本的静态链接库的原因。

  CC++运行时库的动态链接版本编写成一种通用的版本,这样就可以被使用CC++运行时库函数的所有正在运用的应用程序和DLL共享。由于这个原因,运行时库质询在与多线程版本中。由于DLL中提供了CC++运行时库,exe和dll就包含更少的代码,编译出来的规模更小。同时如果Microsoft排除了CC++运行时库DLL中的错误,应用程序中的错误也会自动得到解决。


不应该使用的CC++运行时库函数

   另外两个函数:

unsigned long _beginthread(
    void (__cdecl *start_address)(void *),
    unsigned stack_size,
    void *arglist);
void _endthread(void);

  的确它们和_beginthreadex以及_endtheadex有相似的功能,但是参数更少,不如后者全面。使用_beginthread的话,就不能创建带有安全属性的线程、无法创建可以暂停的线程和无法获得线程的ID值。函数_endthead不仅如此,还有更大的问题,就是_beginthread创建的线程终止后,调用CloseHandle即将失败。


自己的ID的概念了解一下

  为了便于操作进程和进程中的线程,Windows提供了一些函数使得线程很容易引用它的进程内核对象和线程内核对象:

HANDLE GetCurrentProcess(); // 进程的伪句柄
HANDLE GetCurrentThread();  // 线程的伪句柄

  这两个函数并没有在句柄表中创建新句柄,也没有影响到引用计数,即使调用CloseHandle把伪句柄当作参数,CloseHandle也会忽略并返回FALSE。

  如果想要实句柄,就要先通过下面的函数拿到进程或者线程的ID:

DWORD GetCurrentProcessId();
DWORD GetCurrentThreadId();

  在调用:

HANDLE OpenProcess(
DWORD dwDesiredAccess, //渴望得到的访问权限(标志)
BOOL bInheritHandle, // 是否继承句柄
DWORD dwProcessId// 进程标示符
);
HANDLE WINAPI OpenThread(
    _In_ DWORD dwDesiredAccess,
    _In_ BOOL  bInheritHandle,
    _In_ DWORD dwThreadId 
); 

  当然,如果只有一个伪句柄,又想获得实句柄,可以使用DuplicateHandle函数转换。

BOOL DuplicateHandle(
   HANDLE hSourceProcess,
   HANDLE hSource,
   HANDLE hTargetProcess,
   PHANDLE phTarget,
   DWORD fdwAccess, 
   BOOL bInhberitHandle,
   DWORD fdwOption);

  书中的例子:

DWORD WINAPI ParentThread(PVOID pvParam){
    HANDLE hThreadParent;
    DuplicateHandle(
        GetCurrentProcess(),
        GetCurrentThread(),
        GetCurrentProcess(),
        &hThreadParent,
        0,
        FALSE,
        DUPLICATE_SAME_ACCESS); //递增引用计数
        CreateThread(NULL,0,ChildThread,(PVOID)hThreadParent,0,NULL);
}
DWORD WINAPI ChildThread(PVOID pvParam){
    HANDLE hThreadParent = (HANDLE)pvParam;
    FILETIME ftCreationTime,ftExitTime,ftKernelTime,ftUserTime;
        
GetThreadTimes(hThreadParent,&ftCreationTime,&ftExitTime,&ftKernelTime,&ftUserTime);
CloseHandle(hThreadParent); //递减引用计数
}

  这样就可以用过hThreadParent这个变量影响主线程了。

  注意DuplicateHandle会递增转换后的实句柄的引用计数,因此必须用CloseHandle递减。

原文地址:https://www.cnblogs.com/leoTsou/p/12362352.html