读书笔记:对线程模型的批评
——感谢Ian.Sian投递本文——
多线程模型是主流的并发编程模型。在过去几十年来,多线程模型一直是开发并发程序的有力工具。然而,它的历史并非总那么美好。1997年,NASA 的“火星探路者”号在执行任务的途中遭遇了严重的时序异常(参见 “What really happend on Mars“,注目 follow-up 中的现身说法),无法发回探测数据。如果不是 NASA 远程刷新了程序,它的结局就只能是报废在火星上。这一切都是由程序中潜藏的一个优先级反转 bug 造成的。更早的例子还有80年代的一系列 Therac-25 型医用粒子加速器事故。在这些加速器释放出的过量辐射照射之下,数位病人死亡。事后调查显示,至少有一次发生事故的原因,是加速器的控制软件中,存在一个只能由特定操作序列引发的竞争条件 bug。你也许认为这些只是陈年往事,但是直到现在,即便是世界500强公司们高价买来的信息系统,也同样避免不了这些问题。这导致许多程序员认为线程是个潘多拉魔盒,对它采取能躲就躲的态度。然而近来计算机的发展使得躲猫猫的空间越来越小:随便从市场上淘一个CPU,它里面也有不止一个核心。未来的程序员只会有越来越多的机会接触到并发编程,而无法再独善其身了。
加州大学伯克利分校教授,爱德华 A. 李在2006年做了一次题为《线程的麻烦 (The Problem with Threads)》的学术报告。在报告中他提到:看上去,多线程只是对核心语言的小小扩展,甚至可以以第三方库的形式存在。但实质上,多线程程序和原有的核心语言编写的程序已经完全不同了。其原因在于,由于多线程程序可能以任意的次序交错执行,程序再也无法像顺序执行时那样产生确定的结果。多线程程序容易编写(因为写的是顺序程序),但是难分析,难调试,更容易出错。
在我的想法中,产生问题的根源,是多线程模型作为对并发问题的一个抽象,是很不完善的。抽象的实质是对问题的转换。我们可以把抽象应用于一个问题,把它转换成另一个(或许)更简单的问题来解决。解决了转换后的简单问题,就意味着解决了原有的困难问题。严格来说,一个抽象一定要保存原有问题的结构,同时去除无关细节。但是,由于我们生活的世界并没有什么东西是完全“严格”的,现实中使用的抽象有时会隐藏解决问题的关键细节,或者残留一些不该漏出来的东西。评价一个抽象的好坏,也就不止是看它能节省多少代码,和它的界面有多优美这么简单,同时还要看看在一个问题被抽象转换之后,留了下来的细节还能不能好好地解决它。
我们可以从这个意义上理解为什么线程模型是个很糟糕的抽象。一方面,对解决问题很关键的细节(如执行次序)被隐藏起来并受到了粗暴的对待。另一方面,线程模型极力兼容顺序程序的设计思想也使得如共享变量这样的,与线程不兼容的细节依然残留在程序员们的视线之内。我们无力控制程序的执行次序,而我们程序的正确性却依赖于对共享变量的有序变更。可以说,线程提供给我们的抽象简直是千疮百孔。我们还能用它干活,只是因为我们手里还有加锁机制,而它可以部分地堵上线程模型的漏洞。讽刺的是,引入加锁机制解决问题的同时,又带来了新的问题,所以我们编写多线程程序总会遇上死锁,活锁,优先级反转……等等。
同样作为并发编程问题的抽象,角色模型(Actor Model) 比线程模型好就好在,它的资源分享不像线程模型那样通过共享变量来进行。角色模型中的资源分享只能通过特定的机制(消息传递)来进行。你在角色模型里依然可能犯错误,如你可能制造死锁,也有可能造成优先级反转。但是没有共享变量就意味着没有了竞争条件,所以绝大部分资源也用不着上锁了。这样一来,原先至关重要的细节变得不那么重要,问题就这么解决了。
一般来说,在修复一个糟糕的抽象时,可以采取的策略分如下两类:
- 把造成问题的那部分抽象拿掉,直接露出底层的细节
- 换一个和底层兼容性更好的抽象模型
以 MapReduce 为例,它在解决分布式计算问题时,采取的是第一类策略。与现时流行的做法相反,MapReduce 并不试图制造计算是在单一场所完成的假象(流行话讲叫“云计算”),相反它需要程序员自己把问题拆分到集群中不同的机器上。同时,它却隐藏了大量其他细节。这种另类策略导致批评 MapReduce “太底层,不通用” 的声音不绝于耳, 然而这正是 MapReduce 聪明的地方。它放弃面面俱到,集中精力于高效地解决一小类问题(这类问题与排序问题有类似的结构),同时对其他的问题故意视而不见。它的流行证明了这一策略的成功。
角色模型,通信进程(Communicating Sequential Processes, CSP),以及函数式编程(FP)在应对并发编程问题时不约而同地选择了第二类策略。它们采用了与并发兼容性更好的抽象。角色模型与通信进程从线程模型的问题中抹去了共享变量,纯粹 FP 则抹掉了“变量”的可变性。CSP 还可以降低程序执行次序的不确定性(因为在CSP中执行次序默认是确定的,不确定性必须在程序设计时显式声明)。由于这些努力,这几种模型都避免了落入线程模型的麻烦中,得到了对并发问题的更优美的解法。我们可以说,这些模型提供的抽象比线程模型的都要好。很遗憾的是,它们尽管优美,但却乏人问津。角色模型与通信进程目前不被任何主流操作系统原生支持(微软在 Windows 7 附带的新并行运行时 ConcRT 中加入了基于角色模型的 Asynchronous Agents Library,使得状况稍微改观了一点)。FP 的年岁几乎和计算机语言的历史一样古老, 但它的市场份额直到现在也小得可怜。
也许一切都是因为线程模型表面上那迷惑人的简单性,以及墨菲定律的变体:布劳尔技术惯性定律(已经成功的技术在新的,更好的技术出现时也会赖着不走)。我们曾经接纳了一个有缺点的解决方案,而现在我们被捆绑在这个方案上了。我们为线程模型写了成百上千万行的代码,而现在这些代码的重量束缚住我们的手脚,使得我们无法前行。
解决线程模型带来的问题的正确做法,是推广新的,更完善的模型。既然解决问题的阻碍同时来自于新技术的低认知度和现有代码的拖累,很自然地有两个方面的工作要做。一、使得新技术更容易被多数程序员使用,二、想办法让现有的代码和新技术兼容。
在兼容老代码这一头,我们已经有了一些行动。微软在 Windows 7 中提供一个称为用户模式调度 (UMS) 的功能。UMS 可以将内核模式的线程转换为用户模式线程,而应用程序可以自己提供一个 UMS 调度器来调度它们。这意味着,我们现在有机会重载掉系统调度器的默认行为,而根据应用自身的特点给出更合理的调度安排来。这个功能可以用在构造更容易使用的并发模型上,这样开发的模型可以与老代码兼容(但 UMS 有一个让人迷惑的限制:只能用在64bit 的Windows 7 版本上)。
同样地,在推广新技术方面,现在也有了很多成果。除了角色模型外,事务性内存(这又是一种避免竞争条件,从而避免加锁的方法)正在研究中;CSP 已经有了数个实现(如由 Kent 大学开发,针对 Java 的 JCSP),同时还有针对 CSP 的模型检证工具;至于 FP,最近因为人们认为 Web 系统的建模可以在函数式编程范式中更好的表达,FP 正在唤起人们的注意。我们缺的只剩下新技术的成功应用范例(实际上,前面的技术并不是没有成功范例,我们缺的是经验能够大规模运用的范例 ),以及一支理解这些技术的程序员大军了。对于这后一条,我甚至想,既然多线程编程唯一”容易”的事情是写代码,何不做出一种工具来让程序员们可以用写顺序程序的思维来在这些新模型中编写程序呢?这样的工具会帮助程序员利用线性程序的思维来理解代码,但是同时又让人注意到自己的改动正在影响系统的哪一部分。如果新模型的代码变得好理解了,也许更多的人会使用它们。
(全文完)
(转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)
《读书笔记:对线程模型的批评》的相关评论
线程模型是一种机器模型,而不是一种问题模型。
我认为一味的封装不是一个好的解决方案,作为一个LabVIEW程序员,深受不能接触到程序底层而困扰中.确实LabVIEW的封装使多线程的开发容易了很多,但是使用多线程时提高的性能往往消耗在了各种各样的封装里.
说来说去还是资源管理的问题。。。
对于异步操作执行的顺序可以是确定的,但是执行的时间确是不定的。
今天才知道还有这么多概念,很有收获,多谢!
关于“角色模型”,在嵌入式里,量子编程(http://www.state-machine.com/)是一种类似的实现,可惜跟国内大多数嵌入式程序员思维方式不同、资料缺乏,没有流行起来。
个人理解, 线程模型最初是为了让两个没什么关系的事一起跑以提高效率, 现在人们只是远离了其初衷罢了 —— 让许多有密切关系的事在不同的线程上跑.
是翻译的文章还是Ian.Sian原创?
@鸟人
这是敝人写作一篇 Research Proposal 的副产品。原文是用英文写的,然后由我自己翻译成的中文。参考文献里的连接则几乎都是老美的东西(这也没法了)。
@hou
之所以有了机器模型,或言之 机器支持(CPU supports Thread),还是源自于问题模型的需要。
@Ian.sino
哈哈,牛人啊。 不错不错,学习了很多东西。 不过,偶的嗅觉还是不错的, 闻到了翻译的味道。还是
现有英文,再有中文。 BTW,你的research 是哪个方向啊? 如果贴上英文版,来个中英对照,就更好了!
在桌面环境下过度抽象不是好事,会对效率产生很大影响。如果用四核CPU跑抽象过的程序的效率和原生跑单核的程序一个效率,那么还不如直接写单线程程序。
建议写线程,进程论文的,先到富士康的iphone生产装配线学习一下什么是流水线,如何优化流水线,再写论文,会更靠谱一些。
想起一句话:进程是在CPU上并行;线程则在进程下并行
好文章,尤其喜欢文中总结的两个策略。
多线程其实是Shared Memory,Actor是Message passing。大部分人都用的shared memory模型进行并行编程,但是多线程的non-deterministic成了万恶之源。现在有很多人在做deterministic multiprocessing的研究,TM也只是解决了并行编程的部分问题。对并行编程来讲,性能、功耗、Debugging、Programmability都需要很多工作,任重而道远啊,没有十年的功夫怕是很难搞定。
erlang的那种消息传递的轻量用户线程很优雅,效率也够高。
这个写的真好
@不正直的人
erlang的效率一点也不高,比V8的javascript都要慢,对于计算密集型程序,单线程的C程序都要比跑在8核上的erlang程序快。
@lhb5883
哥坚决不玩LabView大半年了,现在已转Qt啦,哈哈
虽然做了快两年LV,但哥坚决不再继续LabView了,现在已转Qt啦,哈哈
OPENMP如何?
就象第一个回复说的,线程模型是一种机器模型,不是问题模型。
呈现给用户什么模型是库或者编译器之类应该做的事情。
印象中最早的pthread库不是基于内核线程的,都是库自身实现调度,早期solaris不是可以在一个内核lwp上调度多个用户线程么。现在是一对一了,从前我记得是1对N的。自己做一个调度库应该有很多gnu的实现了。
构建一个多线程的系统就像是构建一个小社会,每个线程就是一个人,而避免人之间的冲突还真是个麻烦事
很不错 啊
@huzhan
不好意思有急事出差,回答晚了。
感谢huzhan的回复,可以看出来您对此也有很深刻的了解。以下是我对这些问题的看法。
1 我认为,OpenMP 的问题范围是”并行”,而非更广义的”并发”。它是分配同样的任务到多个处理器,而并发要处理的情况更加通用。
2 我同意这个描述:线程模型是一个机器模型。它原本的考虑是将求解问题的机器与问题本身分离开来,但是实践证明这两样东西没法完全分开 (线程模型中的不确定性就是分开的结果)。因此,像CSP这样的建模方法试图在对问题建模的同时将并发考虑在内。这样的话并发就成了问题模型的一部分。关于它们是什么模型的争论意义不大,因为一旦开始编程,怎么分析线程就成了程序员面前的问题的一部分了。
3 UNIX支持用户模式的线程,用户模式线程也确实是构造CSP的常用底层,但是用户模式线程会遇上和 Windows中的 fiber 一样的问题:一个线程阻塞相当于进程阻塞。UMS 的专利文档中号称它解决了这个问题(大概就是内核把阻塞这个事件抛给UMS调度器让它处理,和Scheduler Activations 很像,甚至”Scheduler Activations”直接就出现在了文档里)。这里不好的一点就是我应该提起 UMS 的源头为 Scheduler Activations 的。至于为什么Windows 要为 Scheduler Activations 招魂,是因为多核系统可能是它的新机会。
@wooki
我说的不是Erlang的运行效率,而是说的消息传递效率,Erlang里发一个消息的开销很低。
从运行效率上来说不适合做计算密集型的应用。
另外如果使用HIPE,性能会有很大的提高。目前我对HIPE的了解不多,准备学习一下。
其实。。。个人觉得多进程模式简单易实现。。。IPC方法也相当多,UNIX下控制子进程还是很简单的。。。
你总有共享资源的时候,比如写文件。共享本质是跑不掉的。
http://drdobbs.com/high-performance-computing/200001985 不知楼主看否。你用何种编程模型取决于你要解决的问题。
现在遇到的编程问题是,你必须
1 确定那些资源共享
2 手工加锁确保exclusively access共享资源
#2 引起的问题在明处,而#1是个设计问题,在暗处。最头疼的是,你无法在语法上(C C#,C++和java)中声明某个变量或某个函数只能在某个特定的线程中访问或运行。这样,随着时间的变化,没共享的被共享了。这是我现实中遇到的问题。
现在再看到这篇文章,貌似actor和fp有发扬光大的趋势。。以及go的csp。。