进程的延伸——线程(下)
目录
调度程序激活机制
弹出式线程
使单线程代码多线程化
进程的延伸——线程(中)
调度程序激活机制
调度程序激活工作的目标是模拟内核线程的功能,但是为线程包提供通常在用户空间中才能实现的更好的性能和更大的灵活性。
1.当使用调度程序激活机制时,内核给每个进程安排一定数量的虚拟处理器,并且让(用户空间)运
行时系统将线程分配到处理器上。这一机制也可以用在多处理器中,此时虚拟处理器可能成为真实的CPU。分配给一个进程的虚拟处理器的初始数量是一个,但是该进程可以申请更多的处理器并且在不用时退回。内核也可以取回已经分配出去的虚拟处理器,以便把它们分给需要更多处理器的进程。
2.使该机制工作的基本思路:当内核了解到一个线程被阻塞之后,内核通知该进程的运行时系统,并且在堆栈中以参数形式传递有问题的线程编号和所发生事件的一个描述。
内核通过在一个已知的起始地址启动运行时系统,从而发出了通知。这是对UNIX中信号的一种粗略模拟。这个机制称为上行调用(upcall)。(“上行”我理解为是“上面的运行时系统”)
激活后,就重新调度其线程,把当前线程标记为阻塞并从就绪表中取出另一个线程,设置其寄存器,然后再启动之。稍后,当内核知道原来的线程又可运行时,内核就又一次上行调用运行时系统,通知它这一事件。此时该运行时系统按照自己的判断,或者立即重启动被阻塞的线程,或者把它放入就绪表中稍后运行。
3.在某个用户线程运行的同时发生一个硬件中断时,被中断的CPU切换进内核态。如果被中断的进程对引起该中断的事件不感兴趣,比如,是另一个进程的I/O完成了,那么在中断处理程序结束之后,就
把被中断的线程恢复到中断之前的状态。
不过,如果该进程对中断感兴趣,比如,是该进程中的某个线程所需要的页面到达了,那么被中断的线程就不再启动,代之为挂起被中断的线程。而运行时系统则启动对应的虚拟CPU,此时被中断线程的状态保存在堆栈中。随后,运行时系统决定在该CPU上调度哪个线程:被中断的线程、新就绪的线程还是某个第三种选择。
调度程序激活机制的一个目标是作为上行调用的信赖基础,这是一种违反分层次系统内在结构的概
念。通常,n层提供n+1层可调用的特定服务,但是n层不能调用n+1层中的过程。上行调用并不遵守
这个基本原理(它是内核区调用上层的用户区的过程)。
弹出式线程
1.在分布式系统中经常使用线程。一个有意义的例子是如何处理到来的消息,例如服务请求。传统的方法是将进程或线程阻塞在一个 receive 系统调用上,等待消息到来。当消息到达时,该系统调用接收消息,并打开消息检查其内容,然后进行处理。
2.不过,也可能有另一种完全不同的处理方式,在该处理方式中,一个消息的到达导致系统创建一个处理该消息的线程,这种线程称为弹出式线程。
弹出式线程的关键好处是:由于这种线程相当新,没有历史——没有必须存储的寄存器、堆栈诸如此类的内容,每个线程从全新开始,每一个线程彼此之间都完全一样。这样,就有可能快速创建这类线程。对该新线程指定所要处理的消息。使用弹出式线程的结果是,消息到达与处理开始之间的时间非常短。
在使用弹出式线程之前,需要提前进行计划。例如,哪个进程中的线程先运行?如果系统支持在内
核上下文中运行线程,线程就有可能在那里运行。
在内核空间中运行弹出式线程通常比在用户空间中容易且快捷,而且内该空间中的弹出式线程可以很容易访问所有的表格和I/O设备,这些也许在中断处理时有用。
而另一方面,出错的内核线程会比出错的用户线程造成更大的损害。例如,如果某个线程运行时间太长,又没有办法抢占它,就可能造成进来的信息丢失。
使单线程代码多线程化
目标场景:许多已有的程序是为单线程进程编写的。把这些程序改写成多线程需要比直接写多线程程序更高的技巧。下面是一些其中易犯的错误。
代码层面,一个线程的代码通常包含多个过程,会有局部变量、全局变量和过程参数。局部变量和参数不会引起任何问题,但对线程而言是全局变量,并不是对整个程序也是全局的。其他线程在逻辑上和这些变量无关。
问题举例:
问题一:考虑由UNIX维护的errno变量。当进程(或其中的线程)进行系统调用失败时,错误码会放入errno。线程1执行系统调用access以确定是否允许它访问某个特定文件。发生错误时操作系统把返回值放到全局变量errno里。当控制权返回到线程1之后,并在线程1读取errno之前,调度程序确认线程1此刻已用完CPU时间,并决定切换到线程2。线程2执行一个open调用(不同的调用),结果失败,导致重写errno,于是给线程1的返回值会永远丢失。随后在线程1执行时。它将读取错误的返回值并导致错误操作。
解决方案:
1)全面禁止全局变量,不过这个想法不合适,因为它同许多已有的软件冲突。
2)为每个线程赋予其私有的全局变量。
每个线程有全局变量的私有副本,避免了冲突。在效果上,这个方案创建了新的作用域层,这些变量对一个线程的所有过程是可见的。在原先的作用域层里,全局变量在程序中处处可见。
实现:
方案一:多数程序设计语言具有表示局部变量和全局变量的方式,而没有中间的形式(即私有的全局变量)。有可能为全局变量分配一块内存,并将它转送给线程中的每个过程作为额外的参数。尽管这不是一个漂亮的方案,但却是一个可用的方案。
方案二:引入新的库过程,以便创建、设置和读取这些线程范围的全局变量。
步骤:首先调用create_global("bufptr")在堆上或在专门为调用线程所保留的特殊存储区上替一个名为bufptr的指针分配存储空间。无论该存储空间分配在何处,只有调用线程才可访问其全局变量。如果另一个线程创建了同名的全局变量,由于它在不同的存储单元上,所以不会与已有的那个变量产生冲突。
访问全局变量有两个调用:一个用于写入全局变量,另一个用于读取全局变量。
set_global("bufptr", &buf);
它把指针的值保存在先前通过调用create_global创建的存储单元中。
bufptr = read_global("bufptr");
这个调用返回一个存储在全局变量中的地址,这样就可以访问其中的数据。
问题二:有许多库过程并不是可重入的,它们不是被设计成这样工作方式的:对于任何给定的过程,当前面的调用尚没有结束之前,可以进行第二次调用。
像通过网络发送消息和内存分配过程就会存在这个问题(具体不展开,不然篇幅太长)。
解决方案:
1)重写整个库,这有可能引入一些微妙的错误,所以这么做是一件很复杂的事情。
2)为每个过程提供一个包装器,该包装器设置一个二进制位从而标志某个库处于使用中。在先前的调用还没有完成之前,任何试图使用该库的其他线程都会被阻塞。尽管这个方式可以工作,但是它会极大地降低系统潜在的并行性。
问题三:考虑信号的问题(这个问题在中篇“依旧不能解决的问题2”中提过)。例如,如果某个线程调用alarm,信号送往进行该调用的线程是有意义的。但是,当线程完全在用户空间实现时,内核根本不知道有线程存在,因此很难将信号发送给正确的线程。如果一个进程一次仅有一个警报信号等待处理,而其中的多个线程又独立地调用alarm,那么情况就更加复杂了。
问题四:由多线程引入的最后一个问题是堆栈的管理。在很多系统中,当一个进程的堆栈溢出时,内核只是自动为该进程提供更多的堆栈。当一个进程有多个线程时,就必须有多个堆栈。如果内核不了解所有的堆栈,就不能使它们自动增长,直到造成堆栈出错。事实上,内核有可能还没有意识到内存错误是和某个线程栈的增长有关系的。
总结
这些问题当然不是不可克服的,但是却说明了给已有的系统引入线程而不进行实质性的重新设计系统是根本不行的。至少可能需要重新定义系统调用的语义,并且不得不重写库。而且所有这些工作必须与在一个进程中有一个线程的原有程序向后兼容。