内核线程与用户线程

来源:互联网 发布:热血江湖怪物数据 2017 编辑:程序博客网 时间:2024/04/30 01:44

 

在传统的操作系统中,每个进程有一个自己的地址空间及一个单一的控制流程。事实上,这几乎就是传统操作系统中进程的定义。不过,现实中有非常多情况下需要在同一个地址空间中完成并行的任务,比如Web服务器程式,虽然使用多进程方式编程也能非常好地实现服务器,但进程间的数据共享由于需要跨越地址空间而显得十分不方便,同时进程间转换的开销也不可小视。其实这些问题的本质在于两个概念:  

1. 资源的分组 

2. 指令的执行流程 

所谓资源分组,是指操作系统以什么为最小单位给用户程式分配资源及对这些资源进跟踪。这里提到的资源,指的是打开文件、同步对象、管道等,及进程最重要的标志:地址空间。在现代操作系统中,进程就是所谓的资源分配的最小单位。一个进程拥有自己独立的地址空间、内核对象表(记录打开文件、同步对象等等)、进程句柄等。 

而所谓指令流程,实际上指的是操作系统调度占用CPU的实体,我们称之为“线程”(thread ofexecution)。每个线程拥有自己的用户栈、核心栈、程式计数器等,线程和传统的进程类似,也有运行、挂起、就绪等状态,在状态间的转换也类似。但和传统进程所不同的是,线程没有独立的地址空间,所有属于同一个进程的线程共享同一个线性地址空间。由此可知,在线程模型下,操作系统进行资源分配是以进程为单位,而当操作系统进行任务调度时,则以线程为单位进行。当然这并不是说进程和线程间没有直接关系。恰恰相反,进程和线程间的关系非常密切。线程要占用CPU执行预定任务,没有资源是不可能的完成任务的;同时,只有资源而没有指令流的进程也是没有意义的。所以结果是,一个进程至少包含一个线程(称为主线程或初始线程),而一个线程只属于一个进程。 

线程模型的好处

线程的出现,使得在同一个进程环境下进行多道程式设计成为可能。由于同一个进程所属的线程间共享同一地址空间,所以线程间能能过直接传递指针来传递数据,而这在传统的进程模型下是不可能实现的。不单如此,线程间还能共享内核对象,使得许多任务得以简化。比如,同步对象的使用。在传统操作系统中,要将一个同步对象的句柄传递给另一个进程,有两条路能走:通过父子进程间的继承关系(如Unix中的fork系统调用)或是通过对象命名,然后再在另一个进程中以同样的名字打开。这两条路哪一条都不是非常方便。问题的关键在于,同步对象的句柄值只是每个进程对象表中的索引,在另一个进程中是无效的。但在线程模型下,这个问题就迎刃而解了。因为(同一进程中的)线程间共享同一张内核对象表,所以同一个同步对象的句柄对各线程来说都是有效的,传递时只要直接传句柄值就行了。另一个比较实际的例子是字处理软件。假设目前正在编辑一篇重要文章,为了减少由于断电而造成的损失,软件被设定为每隔1分钟自动存盘一次。如果在传统操作系统下,由于一个进程只有一个执行流,每当2分钟的间隔到达后,进程转向响应定时器软中断(在Unix下为进程收到信号,并执行信号处理过程),这样所有的处理用户输入的代码被挂起,直至磁盘读写完成,信号处理程式返回为止。如果非常不幸地,文章非常长,或用户在软盘或网络驱动器上工作,每次保存文章所花时间为50秒(如果是61秒用户就幸运了,但没人想要这样的幸运),那么用户几乎没有时间去编辑文章,这样的软件特性显然毫无用处。其实,在用户等待磁盘操作完成的时候,虽然进程对用户的输入无响应,但CPU确实是空闲的(假定没有忙碌的后台进程),理论上CPU应该能响应用户输入。这样,我们就回到了多任务系统的设计初衷:提高CPU利用率。
我们先来讨论两个不使用线程模型的解决方案:多进程编程和使用异步系统调用。如果使用多进程方式,则由主进程新建一个工作进程,将需要保存的数据传递给工作进程以进行保存操作。如果需要保存的数据量非常大,内存间的数据复制是个可观的开销。当然,在较新的操作系统如SystemV中,由于采用COW(Copy OnWrite)技术,这个性能损失能略过。另一个改进办法是使用共享内存,在一些不使用fork方式新建进程的操作系统上这是个好办法。若使用异步系统调用,则需要编写一系列信号处理程式。主程式在运行时跟踪并记录当前状态,在信号出现时转到信号处理程式,处理完成后根据处理前的状态继续运行。这种方案采用的是有限状态自动机的思想,能避免多进程操作时的同步及数据传递问题,但他使得程式变得相当复杂。这两种办法都是可行的,但前者通讯开销比较大,后者如果运行在多CPU主机上,则无法充分利用CPU资源。若使用线程模型,则没有上述两个问题。字处理进程能采用两个线程,前后界面线程和后台工作线程。界面线程负责响应用户输入,工作线程平进处于挂起状态,并且由主线程定时把他唤醒进行数据保存工作。这样,用户能在几乎无察觉的情况下定时保存文件。 

线程的实现

由上文的定义,线程为进程中的一个或多个指令执行流,这个机制在现代操作系统的实现主要可分为两大类。即根据操作系统内核是否对线程可感知,分为内核线程和用户线程。实际上,上文所说的线程是操作系统调度的基本单位,实际上指的只是内核线程。所谓内核线程,其建立和销毁都是由操作系统负责、通过系统调用完成的。操作系统在调度时,参考各进程内的线程运行情况做出调度决定,如果一个进程中没有就绪态的线程,那么这个进程也不会被调度占用CPU。事实上在视窗系统2000中,操作系统进行调度时根本就不理采线程是属于哪个进程的,只是将所有的就绪线程统一排成若干个优先级队列,然后进行调度。在这个情况下,线程的确成了调度的最小单位,所以有时线程也被称为“轻量级进程”。 

和内核级线程相对应的,是用户级线程。这类实现多见于一些历史悠久的操作系统(如Unix系列),为了在操作系统中加入线程支持,采用了在用户空间增加运行库来实现线程。这些运行库被称为“线程包”。用户线程是不能被操作系统所感知的,也就是说操作系统还是一如既往地进行进程调度,就像根本没有线程相同。每当用户进程获得CPU控制权,线程运行库决定该从哪里开始运行,即运行哪一个用户线程。理所当然地,各用户线程之间是非抢占式,一个用户线程会一直运行直至他主动放弃CPU(线程退出、等待同步对象或执行阻塞式系统调用)或是整个进程被操作系统重新调度。用户级线程的好处在于他进行调度时不必陷入操作系统内核,免去了上下文转换的开销,因而能达到较高的性能。当然,实现用户级线程有一些比较复杂的问题需要解决。首先,需要处理阻塞式系统调用。如果没有采取适当的措施,只要某一个线程执行了一个阻塞式的系统调用(如Read),则整个进程就会被操作系统所挂起,直至操作完成,这就违背了线程设计的初衷,因为其他线程无法得到CPU的控制权。一个解决方案是使用异步系统调用进行替换。在一些操作系统如Unix中,系统支持异步调用并且提供查询系统调用状态的系统调用(Unix中为Select)。这样的话,能对原来的系统调用库进行改造,用以实现线程包。Select调用能查询当前系统调用(如Read)是否安全,即是否会发生阻塞。如果会发生阻塞,则线程包的运行库不会发出真正的系统调用,而是把当前线程挂起,转而执行另一个线程。然后,在下次运行库获得控制权的时候再次检查该调用是否安全,做出是否发出系统调用的决定。这个解决方案需求线程运行库(run-time)在用户线程每次进程系统调用的时候获取控制权,然后再决定是转发系统调用还是进行用户线程调度。也就说,他得改写原有的系统调用的用户库,插入用来和线程运行库交互的代码。这些插入的代码被称为外套(jacket)或是封装器(wrapper)。其次的问题发生在出现缺页异常的时候。当出现缺页异常时,操作系统会把通过把外部页读入内存或是别的什么方法,使发生异常的页变为可读。但问题在于,操作系统并不知道用户线程的存在,一旦发生缺页,操作系统会挂起整个进程。另一个问题就是线程间的非抢占式调度问题。在进程不会转换的情况下,用户线程如果不进行系统调用,那么线程运行库就会不获得控制权,该线程就会保持运行直至其自愿放弃CPU。这使得用户线程间不能轮流使用CPU。一个可能的解决方案就是由系统传递时钟中断到进程,即操作系统定时给线程运行库发送信号,使其得到控制权并做定时调度成为可能。但这样做的缺点是开销过大。更有的问题就是用户级线程无法利用多CPU系统的并行处理能力。 从实际程式设计模型的角度上来看,用户级线程也有他的弱点。目前设想有一个多线程的Web服务器程式,接收网络请求并提供服务。理所当然地,他会产生大量的系统调用,而且大部分都是阻塞式的。特别是在等待用户请求的时候,那些系统调用都不是在相对较短的时间内能解除的。那样的话,每次线程运行库获得控制权时所执行的大量Select大部分都是徒劳无功的。这就意味着,在一个系统调用较少的程式里,用户线程的性能能更小一些。不过,在计算密集的程式里,用多线程方式编程又有多大意义呢?这也成了那些反对用户级线程的人的主要理由之一。 

至于内核线程,由于他是操作系统的一部分,避免了用户级线程所遇见的大量复杂的实现性问题。上文提到过,内核线程有时也被称为轻量级进程,意即他的实现概念和早期的进程有些类似。内核线程的优势在于其实现的简洁性,一切工作在内核中完成,避免了针对用户库的大量修改。 

Microsoft 视窗系统2000中线程的实现

在视窗系统 2000中,线程的实现采用的是内核线程。 当视窗系统2000进程被创建时,操作系统为其建立一个默认的初始线程,线程从该可执行文件的入口点开始运行。之后,其他所有的线程都是由初始线程运行时动态调用Win32API创建的。创建线程的API–CreateThread有六个参数,分别是安全信息、线程堆栈大小、线程入口点、入口点参数、创建选项和一个指向用于接收线程ID的变量的指针。 

视窗系统2000之所以只为每个线程创建一个初始线程,是因为大部分的高级语言都没有用于多线程编程的语义,不能定义多个初始线程。所以所有的额外线程都被设计为需要动态创建。线程入口点其实指的是新线程开始运行的第一条指令的位置,理论上他能位于用户空间的所有一处(不考虑内存保护机制),在C语言的语义中即为一个指向函数的指针。另外,操作系统也设计该入口函数带一个参数,为32位值,没有定义其类型(其实也没有必要定义其类型,类型检查是编译器干的事情)。线程能创建时能选择其初始状态:运行或挂起。一个线程能被创建为挂起状态,然后再进行一些初始化工作,再调用ResumeThread把他激活。视窗系统 2000中线程的性能良好。内核线程的弱点之一就是在调度时要进行多次上下文转换,在用户态和内核态之间的多次转换会清空MMU的TLB(由于页表的转换)而产生非常多页表查寻操作;在i386的一些操作系统上,还会造成段的转换,开销非常大。 

这里得提一下视窗系统2000的内存策略。他把4GB线性内存地址空间分为高端的2GB和低端的2GB(在视窗系统 2000 Advanced Server及以上的系统中能设置为1GB+3GB),其中高端内存用于在各个进程间共享,存放系统内核;低端用于用户空间。在这种设计下,用户代码陷入内核只是造成特权级的改动,操作系统代码还是位于同一个内存空间,而内存页表无需改动,这样就大大降低了系统调用的开销。当然,这样设计也有缺点,他减少了用户程式可用的地址空间(这就是为什么ADServer要支持3GB内存的原因)。  

所以,由于视窗系统 2000中系统调用的开销较小,所以其内核线程能达到较最佳的性能。 其实视窗系统2000还提供了另一个线程的解决方案,即一个线程能拥有一个或多个纤程(Fiber)。这就是用户线程的一个实现。纤程间的调度都是在用户空间内完成的,实际上是由用户自己完成的。关于纤程的讨论已超出本文范围。综上所述,线程是现代操作系统中非常重要的特征之一,他在应用程式模型中起的作用也是非常巨大的。而综合比较线程的两种实现方式:用户线程和内核线程,其后者显然更具优势。事实上,比较成功的操作系统诸如视窗系统2000等,采用的正是内核线程。

本文来自:http://www.cnblogs.com/embeddedtech/archive/2012/08/19/2646897.html

0 0
原创粉丝点击