11.4.3 进程和线程的实现

本节将用更多细节来讲述Windows如何创建一个进程。因为Win32是最具文档化的接口,因此我们将从这里开始讲述。我们迅速进入内核来理解创建一个新进程的本地API调用是如何实现的。这里有很多细节我们都将略过,比如在创建一个路径的时候,WOW16和WOW64有怎样专用的代码,以及系统如何提供特定应用的修补来修正应用程序中的小的不兼容性和延迟错误。我们主要集中在创建进程时执行的主代码路径,以及看一看我们已经介绍的知识之间还欠缺的一些细节。

当用一个进程调用Win32 CreateProcess系统调用的时候,则创建一个新的进程。这种调用使用kernel32.dll中的一个(用户态)进程来分几步创建新进程,其中会使用多次系统调用和执行其他的一些操作。

1)把可执行的文件名从一个Win32路径名转化为一个NT路径名。如果这个可执行文件仅有一个名字,而没有一个目录名,那么就在默认的目录里面查找(包括,但不限于,那些在PATH环境变量中的)。

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

2)绑定这个创建过程的参数,并且把它们和可执行程序的完全路径名传递给本地API NtCreateUserProcess。(这个API被增加到Window Vista使得创建进程的细节可以在内核态里处理,从而让进程可以在可信的边界内使用。之前介绍的那些API仍然是存在的,只是不再被Win32的CreateProcess调用使用。)

3)在内核态里运行,NtCreateUserProcess执行参数,然后打开这个进程的映像,创建一个内存区对象(section object),它能够用来把程序映射到新进程的虚拟地址空间。

4)进程管理器分配和初始化进程对象。(对于内核和执行层,这个内核数据结构就表示一个进程。)

5)内存管理器通过分配和创建页目录及虚拟地址描述符来为新进程创建地址空间。虚拟地址描述符描述内核态部分,包括特定进程的区域,例如自映射的页目录入口可以为每一个进程在内核态使用内核虚拟地址来访问它整个页表中的物理页面。

6)一个句柄表为新的进程所创建。所有来自于调用者并允许被继承的句柄都被复制到这个句柄表中。

7)共享的用户页被映射,并且内存管理器初始化一个工作集的数据结构,这个数据结构是在物理内存缺少的时候用来决定哪些页可以从一个进程里面移出。可执行映像中由内存区对象表示的部分会被映射到新进程的用户态地址空间。

8)执行体创建和初始化用户态的进程环境块(PEB),这个PEB为用户态和内核用来维护进程范围的状态信息,例如用户态的堆指针和可加载库列表(DLL)。

9)虚拟内存是分配在(ID表)新进程里面的,并且用于传递参数,包括环境变量和命令行。

10)一个进程ID从特殊的句柄表(ID表)分配,这个句柄表是为了有效地定位进程和线程局部唯一的ID。

11)一个线程对象被分配和初始化。在分配线程环境块(TEB)的同时,也分配一个用户态栈。包含了线程的为CPU寄存器保持的初始值(包括指令和栈指针)的CONTEXT记录也被初始化了。

12)进程对象被放入进程全局列表中。进程和线程对象的句柄被分配到调用者的句柄表中。ID表会为初始线程分配一个ID。

13)NtCreateUserProcess向用户态返回新建的进程,其中包括处于就绪并被挂起的单一线程。

14)如果NT API失败,Win32代码会查看进程是否属于另一子系统,如WOW64。或者程序可能设置为在调试状态下运行。以上特殊情况由用户态的CreateProcess代码处理。

15)如果NtCreateUserProcess成功,还有一些操作要完成。Win32进程必须向Win32子系统进程csrss.exe注册。Kernel32.dll向csrss.exe发送信息——新的进程及其句柄和线程句柄,从而进程可以自我复制了。进程和线程加入子系统列表中,从而它们拥有了所有Win32的进程和线程的完整列表。子系统此时就显示一个的带沙漏光标表明系统正运行,但光标还能使用。当进程首次调用GUI函数,通常是创建新窗口,光标将消失(如果没有调用到来,2秒后就会超时)。

16)如果进程受限,如低权限的Internet Explorer,令牌会被改变,限制新进程访问对象。

17)如果应用程序被设置成需要与当前Windows版本加垫层(shim)地兼容运行,则特定的垫层将运行(垫层通常封装库调用以稍微修改它们的行为,例如返回一个假的版本号或者延迟内存的释放)。

18)最后,调用NtResumeThread挂起线程,并把这个结构返回给包含所创建的进程和线程的ID、句柄的调用者。

调度

Windows内核没有任何中央调度线程。所以,当一个线程不能够再执行时,线程将进入内核态,调度线程再决定转向的下一个线程。在下面这些情况下,当前正在执行的线程会执行调度程序代码:

1.当前执行的线程发生了信号量、互斥、事件、I/O等类型的阻塞。

2.线程向一个对象发信号(如发一个信号或者是唤醒一个事件)时。

3.配额过期。

第一种情况,线程已经在内核态运行并开始对调度器或输入输出对象执行操作了。它将不能继续执行,所以线程会请求调度程序代码寻找装载下一个线程的CONTEXT记录去恢复其执行。

第二种情况,线程也是在内核中运行。但是,在向一些对象发出信号后,它肯定还能够继续执行,因为发信号对象从来没有受到阻塞。然而,线程必须请求调度程序,来观测它的执行结果是否释放了一个具有更高调度优先级的正准备运行的线程。如果是这样,因为Windows完全是可抢占式的,所以就会发生一个线程切换(例如,线程切换可以发生在任何时候,不仅仅是在当前线程结束时)。但是,在多处理器的情况下,处于就绪状态的线程会在另一个CPU上被调度,那么,即使原来线程拥有较低的调度优先级,也能在当前的CPU上继续执行。

第三种情况,内核态发生中断,这时线程执行调度程序代码找到下一个运行的线程。由于取决于其他等待的线程,可能会选择同样的线程,这样线程就会获得新的配额,可以继续执行。否则发生线程切换。

在另外两种情况下,调度程序也会被调度:

1)一个输入输出操作完成时。

2)等待时间结束时。

在第一种情况下,线程可能处于等待输入输出时被释放然后执行。如果不保证最小执行时间,必须检查是否可以事先对运行的线程进行抢占。调度程序不会在中断处理程序中运行(因为那使中断关闭保持太久)。相反,中断处理发生后,DPC会排队等待一会儿。第二种情况下,线程已经对一个信号量进行了down操作或者因一些其他对象而被阻塞,但是定时器已经过期。对于中断处理程序来说,有必要让DPC再一次排队等待,以防止它在定时器中断处理程序时运行。

如果一个线程在这个时刻已到就绪,则调度程序将会被唤醒并且如果新的可运行线程有较高的优先级,那么和情形1的情况类似,当前的线程会被抢占。

现在让我们来看看具体的调度算法。Win32 API提供两个API来影响线程调度。首先,有一个叫SetPriorityClass的用来设定被调用进程中所有线程的优先级。其等级可以是:实时、高、高于标准、标准、低于标准和空闲的。优先级决定进程的先后顺序。(在Vista系统中,进程优先级等级也可以被一个进程用来临时地把它自己标记为后台运行(background)状态,即它不应该被任何其他的活动进程所干扰。)注意优先级是对进程而言的,但是实际上会在每个线程被创建的时候通过设置每个线程开始运行的基本优先级可以影响进程中每条线程的实际优先级。

第二个就是SetThreadPriority。它根据进程的优先级类来设定进程中每个线程的相对优先级(可能地,但是不必然地,调用线程)。可划分如下等级:紧要的、最高的、高于标准的、标准的、低于标准的、最低的和休眠的。时间紧急的线程得到最高的非即时的调度优先,而空闲的线程不管其优先级类别都得到最低的优先级。其他优先级的值依据优先级的等级来定,依次为(+2,+1,0,-1,-2)。进程优先级等级和相对线程优先级的使用使得能够更容易地确定应用程序的优先级。

调度程序按照下列方式进行调度。系统有32个优先级,从0到31。依照图11-27的表格,进程优先级和相对线程优先级的组合形成32个绝对线程优先级。在表格的数字决定了线程的基本优先级(base priority)。除此之外,每条线程都有当前优先级(current priority),这个当前的优先级可能会高于(但是不低于)前面提到的基本优先级,关于这一点我们稍后将会讨论。

阅读 ‧ 电子书库
图 11-27 Win32优先级到Windows优先级的映射

为了使用这些优先级进行调度,系统维护一个包含32个线程列表的队列,分别对应图11-27中的0~31的不同等级。每个列表包含了就绪线程对应的优先级。基本的调度算法是从优先级队列中从31到0的从高优先级到低优先级的顺序查找。一旦一个非空的列表被找到,等待队首的线程就运行一个时间片。如果时间配额已用完,这个线程排到其优先级的队尾,而排在前面的线程就接下来运行。换句话说,当在最高的优先级有多条线程处于就绪状态,它们就按时间片轮转法来调度。如果没有就绪的线程,那么处理器空闲,并设置成低功耗状态来等待中断的发生。

值得注意的是,调度取决于线程而不是取决于线程所属的进程。因此调度程序并不是首先查看进程然后再是进程中的线程。它直接找到线程。调度程序并不考虑哪个线程属于哪个进程,除非进行线程切换时需要做地址空间的转换。

为了改进在具有大量处理器的多处理器情况下的调度算法的可伸缩性,调度管理器尽力不给全局的优先级表的数组加上一个全局的锁来实现同步访问控制。相反地,对于一个准备到CPU的线程来说,若是处理器已就位,则可以让它直接进行,而不必进行加锁操作。

对于每一个进程,调度管理器都维护了一个理想处理器(ideal processor)记录,它会在尽可能的时候让线程在这个理想处理器上运行。这改善了系统的性能,因为线程所用到的数据驻留在理想处理器的内存中。调度管理器可以感知多处理器的环境,并且每一个处理器有自己的内存,可以运行需要任意大小内存空间的程序——但是如果内存不在本地,则会花费较大的时间开销。这些系统被认为是NUMA(非统一内存地址)设备。调度管理器努力优化线程在这类计算机上的分配。当线程出现缺页错误时,内存管理器努力把属于理想处理器的NUMA节点的物理页面分配给线程。

队首的队列在图11-28中表示。这个图表明实际上有四类优先等级:实时级、用户级、零页和空闲级,即当它为-1时有效。这些值得我们深入讨论。优先级16~31属于实时级的一类,用来为构建满足实时性约束的系统。处于实时级的线程优先于任何动态分配级别的线程,但是不先于DPC和ISR。如果一个实时级的应用程序想要在系统上运行,它就要求设备驱动不能运行DPC和ISR更多的额外时间,因为这样可能导致这些实时线程错过它们的截止时间。

阅读 ‧ 电子书库
图 11-28 Windows Vista为线程支持32个优先级

用户态下不能运行实时级的线程。如果一个用户级线程在一个高优先级运行,比如说,键盘或者鼠标线程进入了一个死循环,键盘或者鼠标永远得不到运行从而系统被有效地挂起。把优先级设置为实时级的权限,需要启用进程令牌中相应的特权。通常用户没有这个特权。

应用程序的线程通常在优先级1~15上运行。通过设定进程和线程的优先级,一个应用程序可以决定哪些线程得到偏爱(获得更高优先级)。ZeroPage系统线程运行在优先级0并且把所有要释放的页转化为全部包含0的页。每一个实时的处理器都有一个独立的ZeroPage线程。

每个线程都有一个基于进程优先级的基本优先级和一个线程自己的相对优先级。用于决定一个线程在32个列表中的哪一个列表进行排队的优先级取决于当前优先级,通常是得到和当前线程的基本优先级一样的优先级,但并不总是这样。在特定的情况下,非实时线程的当前优先级被内核一下子提到尽可能高的优先级(但是不会超过优先级15)。因为图11-28的排列以当前的优先级为基础,所以改变优先级可以影响调度。对于实时优先级的线程,没有任何的调整。

现在让我们看看一个线程在什么样的时机会得到提升。首先,当输入输出操作完成并且唤醒一个等待线程的时候,优先级一下子被提高,给它一个快速运行的机会,这样可以使更多的I/O可以得到处理。这里保证I/O设备处于忙碌的运行状态。提升的幅度依赖于输入输出设备,典型地磁盘片对应于1级,串行总线对应于2级,6级对应于键盘,8级对应于声卡。

其次,如果一个线程在等待信号量,互斥量同步或其他的事件,当这些条件满足线程被唤醒的时候,如果它是前台的进程(该进程控制键盘输入发送到的窗口)的话,这个线程就会得到两个优先级的提升,其他情况则只提升一个优先级。这倾向于把交互式的进程优先级提升到8级以上。最后,如果一个窗口输入就绪使得图形用户接口线程被唤醒,它的优先级同样会得到大幅提升。

提升不是永远的。优先级的提升是立刻发生作用的,并且会引起处理器的再次调度。但是如果一个线程用完它的时间分配量,它就会降低一个优先级而且排在新优先级队列的队尾。如果它两次用完一个完整的时间配额,它就会再降一个优先级,如此下去直到降到它的基本优先级,在基本优先级得到保持不会再降,直到它的优先级再次得到提升。

还有一种情况就是系统变动(fiddle)优先级。假设有二个线程正在一个生产者-消费者类型问题上一起协同工作。生产者的工作需要更多的资源,因此,它得到高的优先级,例如说12,而消费者得到的优先级为4。在特定的时刻,生产者已经把共享的缓冲区填满,信号量发生阻塞,如图11-29a所示。

阅读 ‧ 电子书库
图 11-29 优先级转置的示例

如图11-29b所示,在消费者得到调度再次运行之前,一个无关的线程在优先级8已就绪得到调度运行。只要这个线程想要运行,它将会一直运行,因为这个线程的优先级高于消费者的优先级,而比它优先级高的生产者由于阻塞也不能够运行。在这种情况下,直到优先级为8的线程运行完毕,生产者才有机会再次运行。

Windows通过一个称为大hack来解决此类问题的。系统记录一个已就绪的线程自从上次得到运行后距离当前的时间有多久。如果它超过一个特定的阈值,它就被提升到15级的优先级并得到两个时间配额的运转。这就可能解决生产者阻塞的情况。在两个时间配额用完之后,它的优先级一下子又回到原来的优先级而不是逐级别地缓慢下降到原来的优先级。或许较好的解决方法是把那些用完时间配额的线程的优先级不断地降低。毕竟,问题不是由饥饿的线程所引起的,而是由贪婪线程造成的。这一问题广为人知地称作优先级倒转(priority inversion)。

在优先为16条线程获得互斥量却长时间得不到调度的时候会发生一个类似的问题,致使更重要的系统线程由于等待互斥量而不能运行发生饥饿。这一问题在操作系统里通过在那些只需要短时间拥有互斥量的线程在很忙时禁用调度来解决。(在一个多处理器上,一个Spin锁应被使用。)

在离开调度的主题之前,关于时间配额值得再讨论一下。在Windows客户端系统上,默认值是20毫秒。在Windows服务器系统上,它是180毫秒。短的时间配额在交互性上会更好些,然而长的时间配额能减少切换提高效率。如果需要,时间配额可以手动地设置成默认值的2倍、4倍或6倍。

最后对调度算法来说,当新窗口变成前台窗口的时候,它的全部在窗口中注册的线程都会得到一个较长的时间配额。这一个变化给它们较多的处理器时间,从而为这些窗口刚刚转移到前台的应用程序带来了更好的用户体验。