人们普遍对并发 ― 多处理 ― 存有误解。本月的“服务器诊所”要介绍基本的并发概念,为了使您的业务在服务器机柜中安全地进行,您需要这些并发概念。
人们对多处理这个名称存在许多误解。多数理论课程和许多编程教科书都清楚地解释了并发概念,但并发是一个很难的主题,几乎我们所有人都需要进修。
并发指的是一次有不止一个“应用程序”在运行的情形。这里我引用“应用程序”是因为它的意义是依赖于上下文的。Linux 主机总是把一组或多或少同时执行的程序(网络协议守护程序、cron 管理程序、内核本身,常常还有更多)填到主机的进程表中。Linux 是一个多任务的操作系统。它就是为这样的任务而构建的。
在典型的单处理器主机上,任务实际上并不是同时执行的。内核中称为调度程序的部分将工作换进换出,从而让所有工作都获得一轮执行。在同一个时间间隔内,您的浏览器在下载东西,同时您在编辑程序源代码,同时还在播放音乐。并发常常与这种同时出现联系在一起。
并发的两个方面
请记住,从“用户视图”或“编程模型”的角度来看,并发就是关于调度对不可分资源的访问这样一件事情。然而,与之相对的是"后端"意义上的并发。寻求原始性能的人们把重点放在这个不同的方面。在他们看来,“多处理”的意思通常是把单个任务分为几个部分,使不同的中央处理器(central processing unit,CPU)能协同完成任务。其思想是按照外部时钟的步调在更短的时间内完成一项工作,即便要以更复杂的硬件和编程为代价也在所不惜。
并发的这两个方面都与调度,即将任务分配给 CPU 有关。两者都影响可用性。然而将这两个方面混淆起来却是人们常犯而又棘手的错误。初学编程的人似乎特别容易迷信最重要的并发方法之一 ― “多线程”。“多线程”常简称为“线程化”,对它的错误印象包括以下一些想法:
线程化使程序运行得更快。
线程化是唯一的并发构造,或者是唯一切实可行的并发构造。
N 路主机的速度大约是单处理主机的 N 倍。
只要对概念作一点点澄清就可以很快地纠正这些错误想法。
天真的开发者常常这样问道,“我的程序太慢了;我可以怎样通过线程化来加快程序的运行?”答案通常是,“办不到。”对现有的单任务应用程序作简单的转换以将它分为几个多任务的部分总是会导致需要更多的计算。一般来说,将这样一个程序“线程化”会使程序要用去更长时间。
这种错误的说法会长久存在,当然有其理由。许多程序都可以分解为几个部分,从而减轻瓶颈问题。将计算密集的工作 ― 比如,对航天飞机重返大气层的模拟 ― 分散给八个 CPU 而不是一个 CPU 去做,其完成速度将快得多。更常见的是重构一个程序,以避免输入/输出(input/output,I/O)“阻塞”。如果您的消费者级应用程序能在等待键盘输入、等待从磁盘调入的数据或等待通过网络传来的消息的时候做一些有用的工作,那看起来就像无代价地“提高了速度”似的。
线程化的局限性
但是,相信线程化会带来这些加速是要冒风险的。这些加速都依赖于更深层的分析;仅当存在未充分利用的资源可用的情况下,才有可能提高速度。此外,线程化并不是实现这些并发的唯一办法,并且常常也不是最好的办法。
学术文献研究了至少十二种很重要的投入到了实际应用的并发模型。除听说过线程化外,您多半也听说过多处理(从程序的意义上说)、协同例程和基于事件的编程,也可能听说过连续(continuation)、生成器和几个更神秘难懂的构造。比如,如果您有一种支持生成器但不支持线程的语言,那您可以用生成器来模拟线程(反之亦然),则从这个意义上说,上述所有方法就都有一个形式上大致对等的东西。
然而,编写合适的程序不同于抽象的对等。在您按进度努力交付可靠的应用程序时,并发模型之间存在确实的不同。举例来说,线程存在很多年来人们已经知道的脆弱性。线程是相当低级别的编程构造。要用线程安全地编写程序是很难的;需要操纵线程的程序很容易引起不一致的数据、死锁、不可伸缩的锁定以及倒置的优先级等问题。Java 最初打算只支持多线程,将它作为并发的核心概念,最近,由于线程化的性能问题,Java 放弃了这一打算。能调试线程的调试器开销巨大,这一点早已臭名昭著。
不过,也不全是坏消息。如果您花点时间清楚地理解一些基本概念,那么使用线程就象使用 XML 或 LDAP 或在其它任何专门领域那样可靠。更直接地说,对于许多情形是存在更安全的 ― 有时也是更快的!― 并发模型的。
在许许多多情形中,使一个应用程序成为多任务的最好办法是将它分解为多个相互合作的进程而不是线程。程序员通常都拒绝接受这一现实。其中一个原因是历史的:进程过去常常比线程“重”得多,并且在多数版本的 Windows 中现在仍然如此。然而,在现代的 Linux 中,不同进程之间的上下文切换所花的时间只比同一进程的线程之间相应的上下文切换多 15%。时间上的花费所带来的回报是理解得更深刻的并且更健壮的编程模型。许多程序员都可以编写安全的独立进程。但能编写安全的线程的程序员却相当少。
什么时候更适合采用多进程而不是多线程呢?举例来说,假如您有一个“控制面板”图形用户界面(graphical user interface,GUI),它监视几个大型计算的结果、检索和更新数据库记录、甚至可能会报告外部物理设备的状态。您可以将所有这些任务放到一个进程中,针对每项任务有一个独立的线程。在 Windows 中,这常常是优先采用的做法。
不过,就我的开发实践来说,我通常是把每项任务放到它自己的进程中,通过套接字、管道或不时共享的内存进行彼此间的通信。由于您可以使用所有您常用的命令行工具来实现独立进程的自动化,所以,上述做法极大简化了单元测试。一个进程的崩溃不会危及其它任何进程。其性能通常与采用多线程不相上下,而且有时还更好,这取决于具体的硬件和编程状况。
其它并发模型
这样一种多进程实现常常依赖于基于事件的编程。事件是一个不同的并发概念,对管理 I/O 和相关的多任务职责很有用。事件将异步的“外部动作”与编程上的回调(也称为信号、绑定等等)联系起来。思考一下前面所说的 GUI 控制面板;在 Unix 中编写这个程序时,一种高性能的做法是只在 select() 系统调用检测到到达的数据时才更新显示。使用 C 的程序员常常用 select 来标记基于事件的方法。
您可能会把“协同例程”或“生成器”看作是课堂以外的内容。然而,它们已被内置在诸如 Modula 和 Icon 之类语言的定义中,因为它们在使多任务编程变得非常强大的同时又让它仍然易于理解(从而也仍然是安全的)。如果您有复杂的性能要求,如果您的应用程序是以几百个子任务的方式进行了最佳建模,尤其是如果您的服务器房间放置着大量的多路主机,那您应该研习更广范围的并发模型。您将发现每种模型都有它最适合的情形。其中某个并发模型可能会适合您自己的需要。
同时,请意识到以下一点,即您或许要为您想在 Linux 中使用的任何模型寻求支持。下面的参考资料指向了一些用各种并发模型进行的实现和实验,还有其它参考资料。
多路难题
最后要注意的一点:不要假设您的多任务软件会在您的多处理(常常是“对称多处理”(symmetric multiprocessing,SMP))硬件上很好地运行。特别是对于 Linux 的各种较老的版本,要从 SMP 机器中获得有用的结果,常常需要用到很专业的知识。在用多个(最多 4 个,有时可以更多)处理器来处理不同的进程时,Linux 2.4 的缺省安装可以做得很好。然而,一个进程中的多个线程可能会在单个处理器上造成瓶颈,而其它处理器却处于空闲状态。其它并发方法有时也会造成类似的问题。
避免这些资源浪费有赖于您平台的具体状况。有了 Linux 2.4 和流行的多路硬件,您可以合理地期望用缺省的“内核线程”(请参阅参考资料中的 Linux 线程常见问题解答)来恰当地调度线程,调度线程就是将这些线程分配给不同的 CPU。请使用 top 和其它系统管理工具来验证调度是正确的,关于实际使用的线程调度方面的具体问题,请向您的 Linux 供应商或用户组询问。
在您的多数编程工作中,您可能是将程序自然地分解为不同的逻辑任务。清楚地理解基本的并发概念,您就可以应用它们来满足您自己的要求。请记住,并发既有面向前方的方面又有面向后方的方面:“用户视图”或“编程模型”控制您如何与应用程序交互的功能,而“后端”则管理把任务分配给硬件的工作。严格地区分您的功能和性能要求。最后,请记住,谈到并发并不是仅仅就是线程化而已。通过使用明显不是多线程的模型进行编程,您常常可以充分利用您的服务器。