让我们与星光探索者一起,探索Windows吧!

线程的创建时机

之前的探索过程中,我们知道,每个进程至少都有一个线程。进程有两个组成部分:一个进程内核对象和一个地址空间。类似地,线程也有两个组成成分

  • 线程内核对象:操作系统用它管理线程

  • 线程栈:用于维护线程执行时所需的所有函数参数和局部变量

进程是有惰性的,进程从来不执行任何东西,他只是一个线程的容器。线程共享同一个地址空间,执行同样代码,处理相同数据,此外,这些线程还共享对象句柄。

因此,进程相对于线程使用的系统资源更多。原因在于为一个进程创建一个虚拟的地址空间需要大量系统资源,系统发生大量记录活动,占用大量内存。线程几乎不涉及记录活动,所以不需要占用多少内存。

每个计算机都有强大的资源CPU。让CPU闲着是没有任何道理的(假设不考虑省电和散热等问题)。为了让CPU保持“忙碌”,我们可以让他执行多个任务。下面有多个案例:

  • Windows Indexing Services(Windows索引服务)创建了一个低优先级的线程,此线程定期醒来,并对硬盘上的特定资源的文件内容进行索引,这极大改进了性能。

  • 只要暂停输入,Microsoft Visual Studio自动编译C#和Microsoft Visual Basic .NET源代码文件。(星光探索者都心动地想学C#了)

  • Web浏览器在后台与服务器进行通信,在当前网站结果出来前,用户可以调整浏览器窗口大小,转到其它网站

多线程简化了应用程序的用户界面设计。多线程应用程序有很多好处。当然并不是任何问题都可以分解成线程解决。

通常,应用程序有一个UI线程,此线程负责创建窗口,其他线程都是工作线程,用于工作,绝对不会创建窗口。例如网易的NIM_Duilib_Framework: 网易云信Windows应用界面开发框架(基于Duilib)就使用了类似的思想。并且UI线程优先级高于工作线程,这样一来,用户界面才能迅速响应用户操作。

线程的运行

每个线程都必须有一个入口点函数,这是线程执行的起点。线程的入口点函数如下:

1
2
3
4
5
DWORD WINAPI 线程入口点函数名(PVOID pvParam)
{
/* 执行代码 */
return 0;
}

最终线程函数结束运行并返回,此时线程栈内存也会释放,线程内核对象引用计数也会递减。线程内核对象的寿命大于等于线程本身寿命。

线程函数必须返回一个值,就像_tmain函数和_tWinMain函数一样。这是该线程的退出代码。

创建线程对象,可调用CreateThread 函数

1
2
3
4
5
6
7
8
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全描述符
SIZE_T dwStackSize, // 线程栈大小
LPTHREAD_START_ROUTINE lpStartAddress, // 线程入口函数地址
LPVOID lpParameter, // 线程入口函数的参数
DWORD dwCreationFlags, // 线程创建的标志,传0使其立即运行
LPDWORD lpThreadId // 线程ID,可传NULL
);

第一个参数仍然是熟悉的安全描述符,当然可以传NULL。第二个参数是线程栈大小,也就是一个线程可以使用的地址空间。每个线程都拥有自己的栈,如果传0将使用可执行程序的默认值,链接器设置的默认值为1MB。当然并不是dwStackSize传多少,栈大小就有多少,系统会将dwStackSize向上取整到页面大小。

Windows是一个抢占式的多线程系统(preeemptive multithreading system),这意味着新线程和调用CreateThread函数的线程可以同时执行。线程可以通过下列四种方式终止运行:

  • 线程函数返回(强烈推荐)

  • 调用ExitThread函数杀死自己,即主调线程(避免使用)

  • 调用TerminateThread函数杀掉自己或杀其他线程(避免使用)

  • 包含线程的进程终止运行(避免使用)

就类似进程终止的方式一样,线程函数返回可以正确执行相应的清理工作。第二种和第三种则可能不能正确的清理,尤其是第三种,因为第三种情况杀别的线程时,线程不会收到自己即将被杀死的通知。

对于调用TerminateThread方式杀线程,这个函数不是阻塞函数,因此并不能保证这个函数返回时,目标线程已被杀死。此外,如果线程是通过返回或ExitThread的方式终止运行,该线程的堆栈也会被销毁,如果用的是TerminateThread,那么除非拥有此线程的进程终止运行,否则系统不会销毁这个线程的堆栈。这样实现的目的是为了防止其他线程引用本线程堆栈上的数据时,引起访问违规。

对于包含线程的进程终止运行这种方式,这样会使进程中剩余的所有线程全部被强行杀死。这意味着正确的应用程序清理工作不会执行。因此,如果应用程序并发运行多个线程,需要在主线程返回之前,明确处理好每个线程的终止过程。

线程终止运行时,会发生:

  • 线程拥有的所有用户对象都被释放。系统会自动销毁由线程或安装的任何窗口,并卸载由线程创建或安装的任何挂钩,其他对象只有在拥有线程的进程终止时才会销毁

  • 线程的退出代码从STLL_ACTIVE(定义为0x103)变为线程入口函数返回值,TerminateThread或ExitThread调用的代码

  • 线程内核对象变为触发状态。如果线程是进程的最后一个活动线程,系统认为进程终止了

  • 线程内核对象引用计数递减1

虽然线程不再运行,但仍然可以调用GetExitCodeThread函数获得线程的返回值。

线程原理

下面是线程内核对象示意图:

一旦创建了线程内核对象,系统就分配内存,供线程的堆栈使用,此内存是从进程的地址空间内分配的,因为线程没有自己的地址空间,因此线程没有自己的地址空间.线程堆栈始终是从高位内存地址向低位地址构建的

指令指针(IP)及 RtlUserThreadStart 函数

因为新线程的指令指针被设置为 RtlUserThreadStart,所以这个函数实际就是线程开始执行的地方,观察 RltUserThreadStart 的原型,你会以为他接收了两个参数,但这就暗示着该函数是从另一个函数调用的,而实情并非如此.新线程只是在此处产生并且开始执行.之所以能访问这个两个参数,是由于操作系统将值显示地写入线程堆栈(参数通常就是这样传给函数的)

新线程执行 RtlUserThreadStart 函数时候,将发生以下事情

  • 围绕线程函数,会设置一个结构化异常处理(Structured Exception Handling,SEH)帧.这样一来线程执行期间所有产生的任何异常都能得到系统的默认处理.

  • 系统调用线程函数,把传给 CreateThread函数的 pvParam 参数传递给它.

  • 线程函数返回时,RtlUserThreadStart 调用 ExitThread函数,将你的线程函数的返回值传给它.线程内核对象的使用计数器递减,然后线程停止执行

  • 如果线程产生了一个为被处理的异常,RtlUserThreadStart 函数所设置的 SEH 帧会处理这个异常.通常,这意味着系统会向用户显示一个消息框,而且当用户关闭此消息框时,RtlUserThreadStart 会调用 ExitProcess来终止整个进程.而不只是终止有问题的线程

注意,在 RtlUserThreadStart 内,线程会调用 ExitThread 或者 ExitProcess.这意味着线程永远不能退出此函数;它始终在内部”消亡”

线程创建时,会先创建一个线程内核对象(分配在进程的地址空间上),存储上下文CONTEXT及一些统计信息,具体包括:

  • 寄存器SP:指向栈中线程函数指针的地址

  • 寄存器IP:指向装载的NTDLL.dll里RtlUserThreadStart函数地址

  • Usage Count:引用计数,初始化为2

  • Suspend Count:挂起计数,初始化为1。

  • ExitCode:退出代码,线程在运行时为STILL_ACTIVE(且初始化为该值)

  • Signaled:初始化为未触发状态

大约每隔20ms windows就会查看所有当前存在的线程内核对象。并在可调度的线程内核对象中选择一个,将其保存在CONTEXT结构的值载入cpu寄存器。这被称为上下文切换(context switch)。大约又过20ms(GetSystemTimeAdjustmnet函数第二个参数的返回值) windows将当前cpu寄存器存回内核对象,线程被挂起。Windows再次检查内核对象,并在可调度的内核对象中选择一个进行调度。此过程不断重复直到系统关闭。

此外,在线程内核对象中有一个值表示线程的挂起计数。调用CreateProcess或者 CreateThread时,系统将创建线程内核对象,并把挂起计数初始化为1。在线程初始化之后,CreateProcess 或者Createrhread 函数将查看是否有CREATE_SUSPENDED标志传入。如果有,函数会返回并让新的线程处于挂起状态。如果没有,函数会将线程的挂起计数递减为0。当线程的挂起计数为0时,线程就成为可调度的了,除非它还在等待某个事件发生(例如键盘输入)。

CONTEXT结构

CONTEXT结果保存着特定于处理器寄存器的数据,请参考WinNT.h头文件。CONTEXT结构分为几部分,CONTEXT_CONTROL包含CPU的控制寄存器,比如指令指针,栈指针,标志和函数返回地址。CONTEXT_INTEGER标识CPU整数寄存器,CONTEXT_FLOATING_POINT标识CPU浮点寄存器,CONTEXT_DEBUG_REGISTERS标识CPU调试寄存器,CONTEXT_SEGMENTS标识CPU段寄存器,CONTEXT_EXTENDED_REGISTERS标识CPU拓展寄存器。

我们可调用GetThreadContext 函数获得线程内核对象内部,获得当前CPU状态集合。在调用GetThreadContext之前,需要先初始化一个CONTEXT结构,并设置其ContextFlags属性,然后挂起目标线程,最后才获取。可调用SetThreadContext函数修改寄存器状态

线程的挂起和恢复,睡眠与切换

通过创建一个处于挂起状态的线程,我们可以在线程执行任何代码之前改变它的环境。改变了线程的环境之后,必须使其变为可调度的。这可以通过调用ResumeThread函数,传入调用CreateThread时所返回的线程句柄(或者传给CreateProcess的ppiProclnfo参数所指向的结构中的线程句柄)

一个线程可以被多次挂起。如果一个线程被挂起三次,则在它有资格让系统为它分配CPU之前必须恢复三次。除了在创建线程时使用 CREATE_SUSPENDED标志外,还可以通过调用SuspendThread来挂起线程

1
2
3
4
5
6
7
8
DWORD ResumeThread(
HANDLE hThread
);


DWORD SuspendThread(
HANDLE hThread
);

任何线程都可以调用这个函数挂起另一个线程(只要有线程的句柄)。显然,线程可以将自挂起,但是它无法自己恢复。与ResumeThread 一样,SuspendrThread 返回线程之前的挂起计数。一个线程最多可以挂起MAXIMUM SUSPEND_COUNT(WinNT.h中定义为127次。请注意,就内核模式下面执行情况而言,SuspendThread 是异步的,但在线程恢复之前,它是无法在用户模式下执行的。

实际开发中,应用程序在调用SuspendThread时必须小心,因为试图挂起一个线程时,我们不知道线程在做什么。例如,如果线程正在分配堆中的内存,线程将锁定堆。当其他线程要访问堆的时候,它们的执行将被中止,直到第一个线程恢复。只有在确切知道目标线程是哪个(或者它在做什么),而且采取完备措施避免出现因挂起线程而引起的问题或者死锁的时候,调用SuspendThread才是安全的。

其实,Windows中不存在挂起和恢复进程的概念,因为系统从来不会给进程调度CPU时间。在一个特殊情况下,即调试器处理WaitForDebugEvet返回的调试事件时,Windows将冻结被调试进程中的所有线程,直到调试器调用ContinueDebugEvent。Windows没有提供其他方式挂起进程中的所有线程,因为存在竞态条件问题。例如,在线程被挂起时,可能创建一个新的进程。系统必须想方设法挂起这个时间段中任何新的线程。

线程还可以告诉系统,在一段时间内自己不需要调度了。这可以通过调用Sleep 函数实现

1
2
3
void Sleep(
DWORD dwMilliseconds
);

调用Sleep函数,将使线程自愿放弃属于它的时间片中剩下的部分。

系统设置线程不可调度的时间只是“近似于”所设定的毫秒数。没错,如果告诉系统想睡眠100ms,那么线程将睡眠差不多这么长时间,但是可能会长达数秒甚至数分钟。Windows不是实时操作系统。我们的线程可能准时醒来,但是实际情况取决于系统中其他线程的运行情况。

可以调用Sleep并给dwMs参数传入INFINITE。这是在告诉系统,永远不要调度这个进程。这样做没有什么用处。让线程退出并将其栈和内核对象返还给系统,要好得多。

可以给Sleep传入0。这是在告诉系统,主调线程放弃了时间片的剩余部分,它强制系统调度其他线程。但是系统有可能重新调度刚刚调用了Sleep的那个线程。如果没有相同或者较高优先级的可调度线程时,就会发生这样的事情。

我们可以调用SwitchToThread 函数使主调线程自动放弃CPU时间。

调用这个函数时,系统查看是否存在正急需CPU时间的饥饿线程。如果没有,SwitchToThread 立即返回。如果存在,SwitchToThread将调度该线程(其优先级可能比SwitchToThread的主调线程低)。饥饿线程可以运行一个时间量,然后系统调度程序恢复正常运行。

通过这个函数,需要某个资源的线程可以强制一个可能拥有该资源的低优先级的线程放弃资源。如果在调用SwitchToThread时没有其他线程可以运行,则函数将返回FALSE;否则,函数将返回一个非零值。

线程的执行时间

我们可使用新的GetTickCount64 函数先后获取一个时间点,然后相减算出任务消耗的时间。但是在Windows,我们不可能知道线程能获得CPU时间,当线程失去CPU时间时,计时更困难了。因而,我们应该调用GetThreadTimes函数

1
2
3
4
5
6
7
BOOL GetThreadTimes(
HANDLE hThread,
LPFILETIME lpCreationTime, // 线程创建时间绝对值,单位100ns
LPFILETIME lpExitTime, // 线程退出时间,单位100ns
LPFILETIME lpKernelTime, // 线程执行内核模式操作系统代码用时间绝对值,100ns单位
LPFILETIME lpUserTime // 线程执行应用程序代码所用时间,100ns单位
);

要进行高精度的性能分析,我们可调用QueryPerformanceCounter函数QueryPerformanceFrequency函数

线程优先级

在调度程序给一个可调度线程分配CPU之前,CPU可以运行一个线程大约20ms。这是优先级都相同的情况,实际上,各个线程有很多不同的优先级,这将影响调度程序如何选择下一个要运行的线程。

每个线程被赋予0(最低)~31(最高)的优先级数。CPU首先查看优先级最高的线程,并以循环(round-robin)的方式进行调度。一个31优先级的结束,cpu会调度领一个优先级为31的线程。只要有优先级为31的线程,系统就不会给优先级0-30的线程分配CPU,称为饥饿(starvation)在多处理器系统上饥饿发生的可能性要小得多。

较高优先级的线程会抢占较低优先级线程的时间片,例如一个优先级的5线程正在执行,系统确定有一个更高优先级的线程准备运行,会立即暂停较低优先级的线程(即使他还有时间片没用完)并将cpu分配给较高优先级的线程,该线程将获得一个完整的时间片。

系统启动时会创建一个0优先级的页面清零线程(zero page thread)负责在系统空闲时将内地中所有闲置页面清零。进程运行,我们便可以通过调用SetPriorityClass,设置优先级类,来改变自己的优先级。可调用GetPriorityClass函数获得进程的优先级类。

Windows支持6个优先级类(priority class),优先级从小到大分别是idle, below normal, normal, above normal, high, real-time,当然normal最为常用,idle适用于系统什么都不做时运行的应用程序,例如屏幕保护程序。只有在绝对必要时才使用high优先级类,因为high优先级类要求此进程中的线程立即响应事件,执行实时任务,任务管理器就使用这个优先级,因此用户可以通过它结束失控的线程。使用real-time优先级时,进程甚至能与系统组件抢CPU时间片,这可能会导致用户操作无响应,用户误认为死机了。

选择了优先级后,我们转而关注自己应用程序的线程。线程使用的是相对进程优先级,从小到大分别是idle, lowest, below normal, normal, above normal, highest, time-critical。

通常来说,CreateThread总是创建相对线程优先级为normal的新线程,如果要创建其他优先级的线程,我们需要先CreateThread时传入CREATE_SUSPENDED标志,然后调用SetThreadPriority函数,最后调用ResumeThread唤醒线程。Windows没有提供返回线程优先级的函数,这是Microsoft故意的,它保留了任何时候改变调度算法的权利。

偶尔,系统也会提升一个线程的优先级,通常是为了响应某种I/O事件如窗口消息或磁盘读取。系统只提升优先级值在1-15(即动态优先级范围,dynamic priority range)的线程,系统不会把线程的优先级提升到实时范围(高于15),因为这样会影响操作系统。下列函数可以允许或禁止系统对线程或进程进行动态提升,查询是否启用进程或线程优先级提升等

SetThreadPriorityBoost function (processthreadsapi.h) - Win32 apps | Microsoft Learn

SetProcessPriorityBoost function (processthreadsapi.h) - Win32 apps | Microsoft Learn

GetThreadPriorityBoost function (processthreadsapi.h) - Win32 apps | Microsoft Learn

GetProcessPriorityBoost function (processthreadsapi.h) - Win32 apps | Microsoft Learn

C/C++运行库相关事项

Visual Studio附带了4个C/C++运行时库用于本机代码的开发,还有两个库面向Microsoft .NET的托管环境,这些库都支持多线程开发

库名称 描述
LibCMt.lib 库的静态链接发行版本
LibCMtD.lib 库的静态链接调试版本
MSVCRt.lib 导入库,用于动态链接MSVCR80.dll库的发行版本
MSVCMRt.lib 导入库,用于动态链接MSVCR80D.dll库的发行版本
MSVCMRt.lib 导入库,用于托管/本机代码混合
MSVCURt.lib 导入库,编译成100%纯MSIL代码

拓展资料:超线程CPU

超线程(hyper-threading)是Xeon,Pentium 4 和更新的CPU支持的一个技术。超线程其实就是同时多线程(simultaneous multi-theading),是一项允许一个CPU执行多个控制流的技术。它的原理很简单,就是把一颗CPU当成两颗来用,将一颗具有超线程功能的物理CPU变成两颗逻辑CPU,而逻辑CPU对操作系统来说,跟物理CPU并没有什么区别。因此,操作系统会把工作线程分派给这两颗(逻辑)CPU上去执行,让(多个或单个)应用程序的多个线程,能够同时在同一颗CPU上被执行。注意:两颗逻辑CPU共享单颗物理CPU的所有执行资源。因此,我们可以认为,超线程技术就是对CPU的虚拟化。

Hyper-threading 有时叫做 simultaneous multi-threading,它可以使我们的单核CPU执行多个控制流程。这个技术会涉及到备份一些CPU硬件的一些信息,比如程序计数器和寄存器文件等,而对于比如执行浮点运算的单元它只有一个备份,可以被共享。一个传统的处理器在线程之间切换大约需要20000时钟周期,而一个具有Hyperthreading技术的处理器只需要1个时钟周期,因此这大大减小了线程之间切换的成本。hyperthreading技术的关键点就是:当我们在处理器中执行代码时,很多时候处理器并不会使用到全部的计算能力,部分计算能力会处于空闲状态,而hyperthreading技术会更大程度地“压榨”处理器。举个例子,如果一个线程必须要等到一些数据加载到缓存中以后才能继续执行,此时CPU可以切换到另一个线程去执行,而不用去处于空闲状态,等待当前线程的IO执行完毕。

Hyper-threading 使操作系统认为处理器的核心数是实际核心数的2倍,因此如果有4个核心的处理器,操作系统会认为处理器有8个核心。这项技术通常会对程序有一个性能的提升,通常提升的范围大约在15%-30%之间,对于一些程序来说它的性能甚至会小于20%, 其实性能是否提升这完全取决于具体的程序。比如,这2个逻辑核心都需要用到处理器的同一个组件,那么一个线程必须要等待。因此,Hyper-threading只是一种“欺骗”手段,对于一些程序来说,它可以更有效地利用CPU的计算能力,但是它的性能远没有真正有2个核心的处理器性能好,因此它不能替代真正有2个核心的处理器。但是同样都是2核的处理器,一个有hyper-threading技术而另一个没有,那么有这项技术的处理器在大部分情况下都要比没有的好。