下一个与OS平台相关的问题(这也是编写与平台无关的Java程序要面对的问题)是必须确定并发性和并行在该平台的定义。并发的多线程系统总会给人多个任务同时运行的感觉,其实这些任务是被分割为许多的块交错在一起执行的。在一个并行的系统中,两个任务实际上是同时(这里的同时是真正的同时,而不是快速交错执行所产生的并行假象)运行的,这就要求有多个CPU。如图1.1:
图1.1 Concurrency vs Parallelism
多线程其实并不能加快的程序速度。如果你的程序并不需要频繁的等待IO操作完成,那么多线程程序还会比单线程程序更慢些。但在多CPU系统下则反之。
Java线程系统非平台独立的主要原因就是要实现彻底的平行运行的线程,如果不使用OS提供的系统线程模型,是不可能的。对于JAVA而言,在理论上,允许由JVM来模仿整个线程系统,从而避免我在前一篇文章(驯服JAVA线程2)中,所提到的进入OS核心的时间消耗。但是,这样也排除了程序中的并行性,因为如果不使用任何操作系统级的线程(这样是为了保持平台独立性),OS会把JVM的实例当成一个单线程的程序来看待,也就只会分配单个CPU来执行它,从而导致就算运行在多CPU的机器上,而且只有一个JVM实例在单独运行,也不可能出现两个Java线程真正的并行运行(充分的利用两个CPU)。
所以,要真正的实现并行运行,只有存在两个JVM实例,分别运行不同的程序。做的再好一点就是让JVM把Java的线程映射到OS级的线程上去(一个Java线程就是一个系统的线程,让系统进行调配,充分发挥系统对资源的操控能力,这样就不存在只能在一个CPU上运行的问题了)。不幸的是,不同的操作系统实现的线程机制也不同,而且这些区别已经到了在编程时不能忽视的地步了。
由于平台不同而导致的问题下面,我将会通过比较Solaris和WindowsNT对线程机制实现不同之处,来说明前面提到的问题。
Java,在理论上,至少有10个线程优先等级划分(如果有两个或两个以上的线程都处在on ready状态下,那么拥有高优先级的线程将会先执行)。在Solaris里,支持231个优先等级,当然对于支持Java那10个的等级是没问题的。
在NT下,最多只有7优先级划分,却必须映射到Java的10个等级。这就会出现很多的可能性(可能Java里面的优先级1、2就等同于NT里的优先级1,优先级8、9、10则等于NT里的7级,还有很多的可能性)。因此,在NT下依靠优先级来调度线程时存在很多问题。
更不幸的还在后面呢!NT下的线程优先级竟然还不是固定的!这就更加复杂了!NT提供了一个名叫优先级助推(Priority Boosting)的机制。这个机制使程序员可以通过调用一个C语言的系统Call(Windows NT/2000/XP: You can disable the priority-boosting feature by calling the SetProcessPriorityBoost or SetThreadPriorityBoost function. To determine whether this feature has been disabled, call the GetProcessPriorityBoost or GetThreadPriorityBoost function.)来改变线程优先级,但Java不能这样做。当打开了Priority Boosting功能的时候,NT依据线程每次执行I/O相关的系统调用的大概时间来提高该线程的优先级。在实践中,这意味着一个线程的优先级可能高过你的想象,因为这个线程碰巧在一个繁忙的时刻进行了一次I/O操作。线程优先级助推机制的目的是为了防止一个后台进程(或线程)影响了前台的UI显示进程。其它的操作系统同样有着复杂的算法来降低后台进程的优先级。这个机制的一个严重的副作用就是使我们无法通过优先级来判断即将运行的就绪太线程。
在这种情况下,事态往往会变得更糟。
在Solaris中,也就意味着在所有的Unix系统中,或者说在所有当代的操作系统中,除了微软的以外,每个进程或者线程都有优先级。高优先级的进程时不会被低优先级的进程打断的,此外,一个进程的优先级可以由管理员限制和设定,以防止一个用户进程是打断OS核心进程或者服务。NT对此都无法支持。一个NT的进程就是一个内存的地址空间。它没有固定优先级,也不能被预先编排。而全部交由系统来调度,如果一个线程运行在一个不再内存中的进程下,这个进程将会被切换进内存。NT中进程的优先级被简化为几个分布在实际优先级范围内的优先级类,也就是说他们是不固定的,由系统核心干预调配。如1.2图:
图1.2 Windows NT优先级架构
上图中的列,代表线程的优先级,只有22级是有所有的程序所使用(其它的只能为NT自己使用)。行代表前面提过的优先级类。
一个运行在“Idle”级进程上的线程,只能使用1-6 和 15,这七个优先级别,当然具体的是那一级,还要取决于线程的设定。运行在“Normal”级进程里并且没有得到焦点的一个线程将可能会使用1,6 — 10或15的优先级。如果有焦点而且所在进程使还是“Normal”级,这样里面的线程将会运行在1,7 — 11或者15 级。这也就意味着一个拥有搞优先级但在“Idle”进程内的线程,有可能被一个低优先级但是运行在“Normal”级的线程抢先(Preempt),但这只限于后台进程。还应该注意到一个运行在“High”优先级类的进程只有6个优先级等级而其它优先级类都有7。
NT不对进程的优先级类进行任何限制。运行在任意进程上的任意线程,可以通过优先级助推机制完全控制整个系统, OS核心没有任何的防御。另一方面,Solaris完全支持进程优先级的机制,因为你可能需要设定你的屏幕保护程序的优先级,以防止它阻碍系统重要进程的运行J。因为在一台关键的服务器中,优先级低的进程就不应该也不能占用高优先级的线程执行。由此可见,微软的操作系统根本不适合做高可靠性服务器。
那么我们在编程的时候,怎样避免呢?对于NT的这种无限制的优先级设定和无法控制的优先级助推机制(对于Java程序),事实上就没有绝对安全的方法来使Java程序依赖优先级调度线程的执行。一个折衷的方法,就是在用setPriority( )函数设定线程优先级的时候,只使用Thread.MAX_PRIORITY, Thread.MIN_PRIORITY和Thread.NORM_PRIORITY这几个没有具体指明优先级的参数。这个限制至少可以避免把10级映射为7级的问题。另外,还建议可以通过os.name的系统属性来判定是否是NT,如果是就通过调用一个本地函数来关闭优先级助推机制,但是那对于运行在没有使用Sun的JVM plug-in的IE的Java程序,也是毫无作用的(微软的JVM使用了一个非标准的,本地实现)。最后,还是建议大家在编程的时候,把大多数的线程的优先级设置为NORM_PRIORITY,并且依靠线程调度机制(scheduling)。(我将会再后面的谈到这个问题)
协作!(Cooperate)一般来说,有两种线程模式:协作式和抢先式。
协作式多线程模型在一个协作式的系统中,一个线程包留对处理器的控制直到它自己决定放弃(也许它永远不会放弃控制权)。多个线程之间不得不相互合作否则可能只有一个线程能够执行,其它都处于饥饿状态。在大多数的协作式系统中,线程的调度一般由优先级决定。当当前的线程放弃控制权,等待的线程中优先级高的将会得到控制权(一个特例,就是Window 3.x系统,它也是协作式系统,但却没有很多的线程执行进度调节,得到焦点的窗体获得控制权)。
协作式系统相对于抢先式系统的一个主要优点就是它运行速度快、代价低。比如,一个上下文切换——控制权由一个线程转换到另一个线程——可以完全由用户模式子系统库完成,而不需进入系统核心态(在NT下,就相当于600个机械指令时间)。在协作式系统下,用户态上下文切换就相当于C语言调用,setjump / longjump。大量的协作式线程同时运行,也不会影响性能,因为一切由程序员编程掌握,更加不需要考虑同步的问题。程序员只要保证它的线程在没有完成目标以前不放弃对CPU的控制。但世界终归不是完美的,人生总充满了遗憾。协同式线程模型也有自己硬伤:
1. 在协作式线程模型下,用户编程非常麻烦(其实就是系统把复杂度转移到用户身上)。把很长的操作分割成许多小块,是一件需要很小心的事。
2. 协作式线程无法并行执行。
抢先式多线程模型另一种选择就是抢先式模型,这种模式就好象是有一个系统内部的时钟,由它来触发线程的切换。也就是说,系统可以任意的从线程中夺回对CPU的控制权,在把控制权分给其它的线程。两次切换之间的时间间隔就叫做时间切片(Time Slice)。
抢先式系统的效率不如协作式高,因为OS核心必须负责管理线程,但是这样使用户在编程的时候不用考虑那么多的其它问题,简化了用户的工作,而且使程序更加可靠,因为线程饥饿不再是一个问题。最关键的优势在于抢先式模型是并行的。通过前面的介绍,你可以知道协作式线程调度是由用户子程序完成,而不是OS,因此,你最多可以做到使你的程序具有并发性(如图1.1)。为了达到真正并行的目的,必须有操作系统介入。四个线程并行的运行在四个CPU上的效率要比四个线程并发的运行高的多。
一些操作系统,象Windows 3.1,只支持协作式模型;还有一些,象NT只支持抢先式模型(当然了你也可以通过使用用户模式的库调用在NT上模拟协作式模型。NT就有这么一个库叫“fiber”,但是遗憾的是fiber充满的了Bugs,并且没有彻底的整合到底层系统中去。)Solaris提供了世界上可能是最好的(当然也可能是最差的)线程模型,它既支持协作式,又支持抢先式。
从核心线程到用户进程的映射
最后一个要解决的问题就是核心线程到用户态进程的映射。NT使用的是一对一的映射模式,见图1.3。
图1.3 NT的线程模型
NT的用户线程就相当于系统核心线程。他们被OS直接映射到每一个处理器上,并且总是抢先式的。所有的线程操作和同步都是通过核心调用完成的。这是一个非常直接的模型,但是它既不灵活又低效。
图1.4表现的Solaris模型更加有趣。Solaris增加了一个叫轻量级进程(LWP — lightweight process)的概念。LWP是可以运行一个或多个线程的可调度单元。只在LWP这个程次上进行并行处理。一般的,LWP都存放在缓冲池中,并且按需分配给相应的处理器。如果一个LWP要执行某些时序性要求很高的任务时,它一定要绑定特定处理器,以阻止其它的LWPs使用它。
从用户的角度来看,这是一个既协作又抢先的线程模型。简单来说,一个进程至少有一个LWP供它所包含的所有线程共享使用。每个线程必须通过出让使用权(yield)来让其它线程执行(协作),但是单个LWP又可以为其它的进程的LWP抢先。这样在进程一级上就达到了并行的效果,同时在里面线程又处于协作的工作模式。
一个进程并不限死只有一个LWP,这个进程下的线程们可以共享整个LWP池。一个线程可以通过以下两种方法绑定到一个LWP上:
1. 通过编程显示的绑定一个或多个线程到一个指定的LWP。在这个情况下,同一个LWP下的线程必须协作式工作,但是这些线程又可以抢先其它LWP下的线程。这也就是说如果限制一个LWP只能绑定一个线程,那就变成了NT的抢先式线程系统了。
2. 通过用户态的调度器自动绑定。从编程的角度看,这是一个比较混乱的情况,因为你并不能假设环境究竟是协作式的,还是抢先式的。
Solaris线程系统给于用户最大的灵活性。你可以在速度极快的并发协作式系统和较慢但
确是并行的抢先式系统,或者这两者的折衷中选择。但是,Solaris的世界真的是那么完美吗?(我的一贯论调又出现了,呵呵!)这一切一切的灵活性对于一个Java程序员来说等于没有,因为你并不能决定JVM采用的线程模型。例如,早期的Solaris JVM采取严格的协作式机制。JVM相当于一个LWP,所有Java线程共享这唯一一个LWP。现在Solaris JVM又采用彻底的抢先式模型了,所有的线程独占各自的LWP。
那么我们这些可怜的程序员怎么办?我们在这个世上是如此的渺小,就连JVM采用那种模式的线程机制都无法确定。为了写出平台独立的代码,必须做出两个表面上矛盾的假设:
1. 一个线程可以被另一个线程在任何时候抢先。因此,你必须小心的使用synchronized关键字来保证非原子性的操作运行正确。
2. 一个线程永远不会被抢先除非它自己放弃控制权。因此,你必须偶然的执行一些放弃控制权的操作来给机会别的线程运行,适当的使用yield( )和sleep( )或者利用阻塞性的I/O调用。例如当你的线程每进行100次遍例或者相当长度的密集操作后,你应该主动的使你的线程休眠几百个毫秒,来给低优先级的线程以机会运行。注意!yield( )方法只会把控制权交给与你线程的优先级相当或者更高的线程。
图1.4 Solaris 线程模型
总结由于诸多的OS级因素导致了Java程序员在编写彻底平台独立的多线程程序时,麻烦频频(唉~~~~~~我又想发牢骚了,忍住!)。我们只能按最糟糕的情况打算,例如只能假设你的线程随时都会被抢先,所以必须适当的使用synchronized;又不得不假设你的线程永远不会被抢先,如果你不自己放弃,所以你又必须偶尔使用yield( )和sleep( )或阻塞的I/O操作来让出控制权。还有就是我一开始就介绍的:永远不要相信线程优先级,如果你想真正做到平台独立!
本文地址:http://com.8s8s.com/it/it17934.htm