Windows Internals 笔记——线程

时间:2024-05-29 23:02:50

1.进程有两个组成部分,一个进程内核对象和一个地址空间。线程也有两个组成部分:

  • 一个是线程的内核对象,操作系统用它管理线程。系统还用内核对象来存放线程统计信息的地方。
  • 一个线程栈,用于维护线程执行时所需的所有函数参数和局部变量。

2.线程要在其进程的地址空间内执行代码和处理数据,假如一个进程上下文中有两个以上的线程运行,这些线程将共享一个地址空间。这些线程可以执行同样的代码,可以处理相同的数据。此外,这些线程还共享内核对象句柄,因为句柄表是针对每一个进程的。

3.为一个进程创建一个虚拟的地址空间需要大量系统资源,系统会发生大量的记录活动,这需要用到大量内存,而且由于.exe和.dll文件要加载到一个地址空间,所有还需要用到文件资源。而线程只有一个内核对象和一个栈,几乎不涉及记录活动,所有不需要占用多少内存。

4.线程函数终止返回时,用于线程栈的内存也会被释放,线程内核对象的使用计数会递减,变为0时销毁。

5.系统从进程的地址空间中分配内存给线程栈使用,新线程在与负责创建的那个线程在相同的进程上下文中运行。因此,新线程可以访问进程内核对象的所有句柄、进程中的所有内存以及同一个进程中其他所有线程的栈。

6.调用CreateThread时,预定的地址空间容量设定了栈空间的上限,这样才能捕获代码中的无穷递归bug。否则系统会将进程的所有地址空间分配殆尽,并为线程栈调拨大量物理存储。

7.线程可以通过以下4种方法来终止运行。

  • 线程函数返回(强烈推荐)
  • 线程通过调用ExitThread函数“杀死”自己(避免使用这种方法)
  • 同一个进程或另一个进程中的线程调用TerminateThread函数(避免使用)
  • 包含线程的进程终止运行(避免使用)

8.让线程函数返回,可以确保以下正确的应用程序清理工作都得以执行。

  • 线程函数中创建的所有C++对象都通过其析构函数被正确销毁。
  • 操作系统正确释放线程栈使用的内存。
  • 操作系统把线程的退出吗(在线程的内核对象中维护)设为线程函数的返回值。
  • 系统递减少线程的内核对象的使用计数。

9.ExitThread函数将终止线程的运行,并导致操作系统清理该线程使用的所有操作系统资源。但是C/C++资源(如C++类对象)不会被销毁。

10.ExitThread是杀死主调线程,而TerminateThread能杀死任何线程。TerminateThread是异步的,返回时并不能保证线程以及被终止。而且使用这个函数时,除非拥有此线程的进程终止运行,否则系统不会销毁这个线程的堆栈。

11.动态链接库DLL通常会在线程终止运行时收到通知,但是如果调用TerminateThread杀死线程,则DLL不会收到这个通知。

12.线程终止运行时,会发生下面这些事情

  • 线程拥有的所有用户对象句柄会被释放。在Windows中,大多数对象都是由包含了“创建这些对象的线程”的进程拥有。但一个线程有两个用户对象:窗口和挂钩。一个线程终止运行时,系统会自动销毁由线程创建或安装的任何窗口,并卸载由线程创建或安装的任何挂钩,其他对象只有在拥有线程的进程终止时才会被销毁。
  • 线程的退出码从STILL_ACTIVE变成传给iExitThread或TerminateThread的代码。
  • 线程内核对象的状态变为触发状态。
  • 如果线程时进程中的最后一个活动线程,系统认为进程也终止了。
  • 线程内核对象的使用计数递减1。

13.线程终止运行时,其关联的线程对象不会自动释放,除非对这个对象的所有未结束的引用都被关闭了。

14.一旦线程不再运行,系统中就没有别的线程再用该线程的句柄了,但是其他线程可以调用GetExitCodeThread来检查线程是否终止运行,以及其退出代码。

15.对CreateThread函数的一个调用导致系统创建了一个线程内核对象。该对象最初的使用计数为2.其他属性也被初始化。一旦创建了内核对象,系统就分配内存,供线程的堆栈使用。此内存是从进程的地址空间内存分配的,因为线程没有自己的地址空间。然后系统将pvParam和pfnStartAddr参数写入线程栈的第一个和第二个值。

Windows Internals 笔记——线程

16.每个线程都有其自己的一组CPU寄存器,称为线程的上下文。上下文反映了当线程上一次执行时,线程的CPU寄存器的状态。线程的CPU寄存器全部保存再一个CONTEXT结构中。CONTEXT结构保存在线程内核对象中,

17.指令寄存器和栈指针寄存器时线程上下文中最重要的两个寄存器。记住,线程始终在进程的上下文中运行,所以,这两个地址标识的内存都位于线程所在进程的地址空间中。当线程内核对象被初始化的时候,CONTEXT结构的堆栈指针寄存器被设为pfnStartAddr在线程堆栈中的地址。而指令寄存器被设为RtlUserThreadStart函数的地址。

VOID RtlUserThreadStart(PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam)
{
__try {
ExitThread((pfnStartAddr)(pvParam));
}
__except (UnhandledExceptionFilter(GetExceptionInformation())) {
ExitProcess(GetExceptionCode());
}
// NOTE: we never get here
}

18.RtlUserThreadStart实际就是线程开始执行的地方。两个参数是由操作系统将值显示地写入线程堆栈,而不是从另一个函数调用的。新线程执行RtlUserThreadStart函数的时候,将会发生以下事情:

  • 围绕线程函数,会设置一个结构化异常处理帧。这样一来,线程执行期间所产生的任何异常都能得到系统的默认处理。
  • 系统调用线程函数,把传给CreateThread函数的pvParam参数传给它。
  • 线程函数返回时,RtlUserThreadStart调用ExitThread,将你的线程函数的返回值传给它。线程内核对象的使用计数递减,而后线程停止执行。
  • 如果线程产生一个未被处理的异常,RtlUserThreadStart函数所设置的SEH帧会处理这个异常。通常,这意味者系统会向用户显示一个消息框,而且当用户关闭此消息框时,RtlUserThreadStart会调用ExitProcess来终止整个进程,而不只是终止有问题的线程。

19. RtlUserThreadStart内,线程会调用ExitThread或者ExitProcess。这意味着线程永远不能退出此函数。

20.当RtlUserThreadStart调用你的线程函数时,它会将线程函数的返回地址压入堆栈,使线程函数知道在何处返回。但是,RtlUserThreadStart函数是不允许返回的。如果它没有在强行“杀死”线程的前提下尝试返回,几乎肯定会引起访问违规,因为线程堆栈上没有函数返回地址,RtlUserThreadStart将尝试返回某个随机的内存位置。

21.一个进程的主线程初始化时,其指令指针会被设为同一个未文档化的函数RtlUserThreadStart,当RtlUserThreadStart开始执行时,它会调用C/C++运行库的启动代码,后者初始化继而调用你的_tmain或_tWinMain函数。你的入口点函数返回时,C/C++运行时启动代码会调用ExitProcess。所以对于C/C++应用程序来说,主线程永远不会返回到RtlUserThreadStart函数。

22.VS附带了4个C/C++运行库用于本机代码的开发,还有两个库向,Microsoft.NET的托管环境。注意,所有这些库都支持对线程开发,不再有单独的一个C/C++库专门针对单线程开发。

Windows Internals 笔记——线程

23.由于标准C运行库是在1970年左右发明的,很久以后才在操作系统上出现线程的概念,所以多线程应用程序使用C运行库会有问题,如设置errno等全局变量。

24.创建新线程时,一定不要调用操作系统的CreateThread函数。相反,必须调用C/C++运行库函数_beginthreadex。

Windows Internals 笔记——线程

Windows Internals 笔记——线程

25.对于_beginthreadex函数,需要重点关注以下几点:

  • 每个线程都有自己的专用_tiddata内存块,它们是从C/C++运行库的堆上分配的。
  • 传给_beginthreadex的线程函数的地址保存在_tiddata内存块中。
  • _beginthreadex确实会在内部调用CreateThread,因为操作系统只知道用这种方式来创建一个新线程。
  • CreateThread函数被调用时,传给它的函数地址是_threadstartex(而非pfnStartAddr)。另外,参数地址是_tiddata结构的地址,而非pvParam。
  • 如果一切顺利,会返回线程的句柄,就像CreateThread那样。任何操作失败,会返回0。

26.关于threadstartex函数,以及其重点:

Windows Internals 笔记——线程

  • 新的线程首先执行RtlUserThreadStart,然后再跳转到_threadstartex。
  • threadstartex唯一的参数就是新线程的_tiddata内存块的地址。
  • TlsSetValue是一个操作系统函数,它将一个值与主调线程关联起来,这就是所谓的线程局部存储。threadstartex函数将_tiddata内存块与新建线程关联起来。
  • 在无参数的辅助函数_callthreadstartex中,有一个SEH帧,它将预期要执行的线程函数包围起来。这个帧处理着与运行库有关的许多事情,比如运行时错误(如抛出未被捕捉的C++异常),和C/C++运行库的signal函数。这一点相当重要,如果用CreateThread函数新建了一个线程,然后调用C/C++运行库的signal函数,那么signal函数不能正常工作。
  • 预期要执行的线程函数会被调用,并向其传递预期的参数。
  • 线程函数的返回值被认为时线程的退出代码。但是注意callthreadstartex不是简单的返回到_threadstartex,继而到RtlUserThreadStart,如果时那样的话,线程会终止运行,其退出代码也会被正确设置,但线程的_tiddata内存块不会被销毁。这会导致应用程序出现内存泄漏。为了防止这个问题,_threadstartex调用了_endthreadex,并向其传递退出代码。

27.对于_endthreadex函数,需要注意以下几点:

Windows Internals 笔记——线程

  • C运行库的_getptd_noexit函数在内部调用操作系统的TlsGetValue函数,后者获取主调线程的tiddata内存块地址。
  • 然后,_endthreadex将此数据块释放,并调用操作系统的ExitThread函数来实际地销毁线程。当然,它会传递并正确设置退出代码。

28.当一个线程调用一个需要_tiddata结构地C/C++运行库函数时:

  • 首先,C/C++运行库函数尝试取得线程数据块的地址(通过调用TlsGetValue)。
  • 如果NULL被作为_tiddata块的地址返回,表明主调线程没有与之关联的_tiddata块。在这个时候,C/C++运行库函数会为主调线程分配并初始化一个_tiddata块。
  • 然后,这个块会与线程关联(通过TlsSetValue),而且只要线程还在运行,这个块就会一直存在并与线程关联。
  • 现在C/C++运行库函数可以使用线程的_tiddata块,以后调用的任何C/C++运行库函数也都可以使用。

29.对于上述过程事实上,问题还是有的,假如线程使用了C/C++运行库的signal函数,则整个进程都会终止,因为结构化异常处理(SEH)帧没有就绪,从而导致内存泄漏。第二个问题是,假如线程不是通过调用_endthreadex来终止的,数据库就不能被销毁,从而导致内存泄漏。

30.当模块连接到C/C++运行库的DLL版本时,这个库会在线程终止时收到一个DLL_THREAD_DETACH通知,并会释放_tiddata块,可以防止_tiddata块的泄漏,但是还是尽量避免使用。

31._endthread函数是无参的,意味者线程的退出代码被硬编码为0,而且它在调用ExitThread前,会调用CloseHandle,向其传入新线程的句柄。

32.Windows提供了一些函数来方便线程引用它的进程内核对象或者它自己的线程内核对象:GetCurrentProcess() GetCurrentThread()。这两个函数都返回到主调线程的进程内核对象或线程内核对象的一个伪句柄。调用这两个函数,不会影响进程内核对象或线程内核对象的使用计数。调用CloseHandle会忽略此调用,并返回FALSE。注意,伪句柄是一个指向当前线程的句柄,即发出函数调用的那个线程。可以用DuplicateHandle转换成真正的句柄。