├── README.md ├── build ├── future-of-code.epub ├── future-of-code.mobi └── future-of-code.pdf └── manuscript ├── Book.txt ├── Preview.txt ├── Sample.txt ├── images ├── originals │ ├── chapter1 │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ └── 6.jpg │ ├── chapter2 │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ ├── chapter3 │ │ ├── 1.jpg │ │ └── 2.jpg │ ├── chapter4 │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ ├── 12.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ ├── chapter5 │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ ├── 12.jpg │ │ ├── 13.jpg │ │ ├── 14.jpg │ │ ├── 15.jpg │ │ ├── 16.jpg │ │ ├── 17.jpg │ │ ├── 18.jpg │ │ ├── 19.jpg │ │ ├── 2.jpg │ │ ├── 20.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ └── chapter6 │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ ├── 12.jpg │ │ ├── 13.jpg │ │ ├── 14.jpg │ │ ├── 15.jpg │ │ ├── 16.jpg │ │ ├── 17.jpg │ │ ├── 18.jpg │ │ ├── 19.jpg │ │ ├── 2.jpg │ │ ├── 20.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg └── title_page.jpg ├── 中文版序.md ├── 内容提要.md ├── 前言.md ├── 第一章.md ├── 第三章.md ├── 第二章.md ├── 第五章.md ├── 第六章.md ├── 第四章.md └── 译者序.md /README.md: -------------------------------------------------------------------------------- 1 | 《代码的未来》 2 | ============== 3 | 4 | **作者:松本行弘** 5 | 6 | **译者:周自恒** 7 | 8 | # 目录 9 | 10 | * [内容提要](manuscript/内容提要.md) 11 | * [译者序](manuscript/译者序.md) 12 | * [中文版序](manuscript/中文版序.md) 13 | * [前言](manuscript/前言.md) 14 | * [第一章](manuscript/第一章.md) 15 | * [第二章](manuscript/第二章.md) 16 | * [第三章](manuscript/第三章.md) 17 | * [第四章](manuscript/第四章.md) 18 | * [第五章](manuscript/第五章.md) 19 | * [第六章](manuscript/第六章.md) 20 | 21 | # 下载 22 | 23 | http://pan.baidu.com/s/1bnmuTCb 24 | 25 | 提取密码: lkqf 26 | 27 | build date: 2014/2/8 28 | -------------------------------------------------------------------------------- /build/future-of-code.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/build/future-of-code.epub -------------------------------------------------------------------------------- /build/future-of-code.mobi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/build/future-of-code.mobi -------------------------------------------------------------------------------- /build/future-of-code.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/build/future-of-code.pdf -------------------------------------------------------------------------------- /manuscript/Book.txt: -------------------------------------------------------------------------------- 1 | 内容提要.md 2 | 译者序.md 3 | 中文版序.md 4 | 前言.md 5 | 第一章.md 6 | 第二章.md 7 | 第三章.md 8 | 第四章.md 9 | 第五章.md 10 | 第六章.md -------------------------------------------------------------------------------- /manuscript/Preview.txt: -------------------------------------------------------------------------------- 1 | 内容提要.md 2 | 译者序.md 3 | 中文版序.md 4 | 前言.md 5 | 第一章.md 6 | 第二章.md 7 | 第三章.md 8 | 第四章.md 9 | 第五章.md 10 | 第六章.md -------------------------------------------------------------------------------- /manuscript/Sample.txt: -------------------------------------------------------------------------------- 1 | 内容提要.md 2 | 译者序.md 3 | 中文版序.md 4 | 前言.md 5 | 第一章.md 6 | 第二章.md 7 | 第三章.md 8 | 第四章.md 9 | 第五章.md 10 | 第六章.md -------------------------------------------------------------------------------- /manuscript/images/originals/chapter1/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter1/1.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter1/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter1/2.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter1/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter1/3.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter1/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter1/4.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter1/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter1/5.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter1/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter1/6.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/1.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/10.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/2.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/3.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/4.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/5.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/6.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/7.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/8.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter2/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter2/9.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter3/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter3/1.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter3/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter3/2.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/1.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/10.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/11.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/12.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/2.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/3.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/4.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/5.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/6.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/7.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/8.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter4/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter4/9.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/1.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/10.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/11.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/12.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/13.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/14.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/15.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/16.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/17.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/18.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/19.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/2.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/20.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/3.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/4.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/5.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/6.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/7.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/8.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter5/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter5/9.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/1.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/10.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/11.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/12.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/13.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/14.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/14.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/15.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/15.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/16.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/16.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/17.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/17.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/18.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/18.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/19.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/19.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/2.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/20.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/20.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/3.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/4.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/5.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/6.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/7.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/8.jpg -------------------------------------------------------------------------------- /manuscript/images/originals/chapter6/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/originals/chapter6/9.jpg -------------------------------------------------------------------------------- /manuscript/images/title_page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yiibook/future-of-code/ec1e2c5827e48c3ac4545a3cb3ee3e9498cb2266/manuscript/images/title_page.jpg -------------------------------------------------------------------------------- /manuscript/中文版序.md: -------------------------------------------------------------------------------- 1 | # 中文版序 2 | 3 | 人类的力量是有限的,无法完全通晓未来,因此我们并不能确切地知道明天、明年究竟会发生什么事。 4 | 5 | 不过,仅就技术来说,一夜之间就冒出个新东西,这样的情况是非常罕见的,而大多数新技术都是沿着从过去到现在的技术轨迹逐步发展起来的。在IT的世界中,这样的倾向尤其显著。 6 | 7 | 《代码的未来》综述了我当前掌握的IT趋势,书中就摩尔定律、编程语言、多核、NoSQL等在未来几年中将备受关注的领域,介绍了相关的现状和基础知识。 8 | 9 | 当然,没人知道书中涉及的这些技术在更久远的未来是否还依然有用,但至少在不远的将来,它们应该是非常值得关注的技术。这些内容可以成为学习新技术的基础,对于想要成为优秀工程师、程序员的各位读者来说,这样的基础则能够成为生存竞争中的有力武器。 10 | 11 | 也许还有一些读者并非专职的程序员,但我认为本书同样值得他们一看。所谓技术,就是用来解决现实问题的手段。与现实问题展开的这场拉锯战,本身就是一件非常刺激和快乐的事,而这份快乐,也正是带动未来创新的源动力。 12 | 13 | 互联网和开源降低了参与创新的门槛。即便没有高学历,即便不属于任何一家企业,只要有技术和点子就有机会。可以想象,未来的创新就应该是这样。就IT方面来说,我认为大多数的创新应该都不外乎是本书介绍的这些技术的延伸。 14 | 15 | 有人说21世纪是亚洲的世纪。作为一个亚洲人,我开发的Ruby语言已经在全世界获得了广泛的应用,这也许从某种程度上印证了这种说法。这本书中包含了我的一些思考和见解,如果它能够对亚洲(恐怕应该是吧)各位读者的创新有所帮助,我会感到荣幸之至。 16 | 17 | 最后,希望中国的各位读者能够从本书中获益。 18 | 19 | 松本行弘 2013年4月 -------------------------------------------------------------------------------- /manuscript/内容提要.md: -------------------------------------------------------------------------------- 1 | # 内容提要 2 | 3 | 本书是*Ruby*之父松本行弘的又一力作。作者对云计算、大数据时代下的各种编程语言以及相关技术进行了剖析,并对编程语言的未来发展趋势做出预测,内容涉及*Go*、*VoltDB*、*node.js*、*CoffeeScript*、*Dart*、*MongoDB*、摩尔定律、编程语言、多核、*NoSQL*等当今备受关注的话题。 4 | 5 | 本书面向各层次程序设计人员和编程爱好者,也可供相关技术人员参考。 -------------------------------------------------------------------------------- /manuscript/前言.md: -------------------------------------------------------------------------------- 1 | # 前言 2 | 3 | 本书是在《日经Linux》上连载的《松本行弘:技术的剖析》(2009年6月号~2012年6月号)各期内容的合集。 4 | 5 | 老实说,写文章这件事很是让我头疼。我认为自己的本职工作是程序员,而不是作家。每个月构思一个主题、查阅资料、编写示例程序,然后再写成文章,这件事对我来说真是个负担。时间被占用,拖累了本职工作不说,截稿日前夕还得承受压力。因此那一阵子经常会感到无比焦虑。 6 | 7 | 话虽如此,但这件事也并非一无是处。在构思文章主题的时候,需要放眼于日常工作以外的世界,这样便拓宽了视野。其实,我本来也并不是那么讨厌写文章。说起来,在学生时代我成绩最好的科目还是语文和英语呢,而最差的科目则是数学。 8 | 9 | 因为是给杂志社供稿,所以我每个月都是选择当时那个时间点上比较热门的、能够引起我的兴趣的话题来写,并没有考虑到主题的连贯性。不过,借着编辑成书的机会回过头来看看以前连载的文章,和编辑讨论之后,头脑中便一下子浮现出“未来”这个关键词。连载中的每一篇文章原本都是独立的,但它们中的大多数都体现了“从过去到未来”、“应对即将到来的未来”这样的主题。作为这些文章的作者,我自己也感到颇为意外。 10 | 11 | 毋庸置疑,IT技术正在创造着我们的现在和未来。无论是专业人士,还是业余爱好者,像我们这样的IT技术人,可以说是会最早与未来遭遇的“人种”吧。正是为了这些人,我才将《技术的剖析》这个专题连载至今。这些连载能浮现出“未来”这个共同的关键词,虽说事先没有预料到,但从某种意义上来说,也许是水到渠成自然而然的结果。 12 | 13 | 然而,IT技术人的真正价值应该并非只有“最早与未来遭遇”而已,我们不仅要能够及早触及未来,还应该拥有自己创造未来的力量————创造出比这本书所预见的未来还要更加美好的未来。 14 | 15 | 松本行弘 2012年4月于樱花盛开的松江市 -------------------------------------------------------------------------------- /manuscript/第一章.md: -------------------------------------------------------------------------------- 1 | # 第一章:编程的时间和空间 2 | 3 | ## 1.1 编程的本质 4 | 5 | 在一部古老的电影《星际迷航4:抢救未来》中有这样一个镜头:从23世纪的未来穿越时空来到现代(1986年)的“进取号”乘务员,为了操作计算机(Classic Mac)而手持鼠标与“计算机”讲话。看来在星际迷航的世界中,用人类语言作为操作界面就可以指挥计算机工作了。 6 | 7 | 不过,现代的计算机还无法完全理解人类的语言。市面上也有一些可以用日语来操作的软件,但距离实用的程度还差得很远。计算机本来是为了运行由0和1组成的机器语言而设计的,但与此同时,对于人类来说,要理解这种二进制位所构成的序列到底代表什么意思,却是非常困难的。 8 | 9 | 因此,创造出一种人类和计算机都能够理解的语言(编程语言),并通过这样的语言将人类的意图传达给计算机,这样的行为就叫做编程。 10 | 11 | 话虽如此,但是将编程仅仅认为是“因为计算机无法理解人类语言才产生的替代品”,我觉得也是不合适的。人类的语言其实非常模糊,有时根本就不符合逻辑。 12 | 13 | Time flies like an arrow. 14 | 15 | 这句话的意思是“光阴似箭”(时间像箭一样飞走了),不过flies也有“苍蝇”(复数形态)的意思,因此如果你非要解释成“时蝇喜箭”也未尝不可,只要你别去纠结“时蝇”到底是啥这种朴素的问题就好了。 16 | 17 | 另一方面,和自然语言(人类的语言)不同,编程语言在设计的时候就避免了模糊性,因此不会产生这样的歧义。使用编程语言,就可以将步骤更加严密地描述出来。 18 | 19 | 用编程语言将计算机需要执行的操作步骤详细描述出来,就成了软件。计算机的软件,无论是像文字处理工具和Web浏览器这样的大型软件,还是像操作系统这样的底层软件,全部都是用编程语言编写出来的。 20 | 21 | ### 编程的本质是思考 22 | 23 | 由于我几乎一整天都对着计算机,因此我的家人可能认为我的工作是和计算机打交道。然而,将编程这个行为理解成“向计算机传达要处理的内容”是片面的。这样的理解方式,和实际的状态并不完全一致。 24 | 25 | 的确,程序员都是对着计算机工作的,但作为其工作成果的软件(中的大部分)都是为了完成人类所要完成的工作而设计出来的(图1)。因此,“人们到底想要什么?想要这些东西的本质又是什么?要实现这个目的严格来说需要怎样的操作步骤?”思考并解决这些问题,才是软件开发中最重要的工作。换句话说,编程的本质在于“思考”。 26 | 27 | C>![](images/originals/chapter1/1.jpg) 28 | 29 | C>图1 编程不是和计算机打交道,而是和人打交道 30 | 31 | 尽管看上去是和计算机打交道的工作,但实际上编程的对象还是人类,因此这是个非常“有人味”的工作。个人认为,编程是需要人来完成的工作,因此我不相信在将来计算机可以自己来编程。 32 | 33 | 我是从初三的时候开始接触编程的。当时父亲买了一台夏普的袖珍计算机(PC-1210),可以使用BASIC来编程。虽然这台袖珍计算机只能输入400个步骤,但看到计算机可以按照我的命令来运行,仿佛自己什么都能做到,一种“万能感”便油然而生。 34 | 35 | ### 创造世界的乐趣 36 | 37 | 尽管已经过去了20多年,但我从编程活动中所感到的“心潮澎湃”却是有增无减。 38 | 39 | 这种心潮澎湃的感觉,是不是由创造新世界这一行为所产生的呢?我喜欢编程,多少年来从未厌倦,这其中最大的理由,就是因为我把编程看作是一项创造性的工作吧。 40 | 41 | 只要有了计算机这个工具,就可以从零开始创造出一个世界。在编程的世界中,基本上没有现实世界中重力和因果关系这样的制约,如此自由的创造活动,可以说是绝无仅有的。能够按照自己的意愿来创造世界,这正是编程的最大魅力所在(图2)。 42 | 43 | C>![](images/originals/chapter1/2.jpg) 44 | 45 | C>图2 编程的乐趣在于创造性 46 | 47 | 正如现实世界是由物理定律所支撑的一样,编程所创造的世界,是由程序员输入的代码所构筑的规则来支撑的。通过创造一个像Ruby这样的编程语言,我对此尤其感触颇深,不过,即便只是编写一个很小的程序,其本质也是相同的。 48 | 49 | 因此,正是因为具有创造性这样重要的特质,编程才吸引了包括我在内的无数程序员,投入其中而一发不可收拾。将来,如果真能够像在《星际迷航》的世界那样,只要通过跟计算机讲话就可以获取所有的信息,那么编程也许就变得没有那么必要了。 50 | 51 | 其实,在搜索引擎出现之后,类似的状况已经正在上演了。拿我的孩子们来说,他们也经常频繁地坐在电脑跟前,但却从来没有进行过编程。对他们来说,电脑只是一个获取信息的渠道,或者是一个和朋友交流的媒介而已。编程这种事,是“爸爸在做的一种很复杂的事”,他们觉得这件事跟自己没什么关系。 52 | 53 | 不过,通过编程来自由操作计算机,并创造自己的世界,这样的乐趣如果不让他们了解的话,我觉得也挺遗憾的。但这样的乐趣并不是通过强加的方式就能够感受到的,而且用强制的方式可能反而会在他们心里埋下厌恶的种子,对此我也感到进退两难。教育孩子还真是不容易呢。 54 | 55 | 编程所具有的创造性同时也有艺术的一面。在摄影出现之后,绘画已经基本上丧失了用于记录的功能,但即便如此,颇具艺术性的绘画作品还是层出不穷。将来,即便编程的必要性逐渐消失,可能我还是会为了艺术性和乐趣而继续编程的吧。其实,像《星际迷航》中的世界那样,“计算机,请给我打开一个Debian GNU/Linux 8.0模拟器,我要写个程序”,这样的世界也挺有意思的不是吗? 56 | 57 | ### 快速提高的性能改变了社会 58 | 59 | 我们来换一个视角。在计算机业界,有很多决定方向性问题的重要“定律”,其中最重要的莫过于“摩尔定律”了。摩尔定律是由美国英特尔公司创始人之一的高顿·摩尔于40多年前的1965年,在其发表的论文中提出的,这个定律的内容如下: 60 | 61 | LSI中的晶体管数量每18个月增加一倍。 62 | 63 | LSI的集成度每18个月就翻一倍,这意味着3年就可以达到原来的4倍,6年就可以达到16倍,呈指数增长。因此,30年后,我们来算算看,就可以达到原来的100万倍呢。LSI的集成度基本上与CPU性能和内存容量直接相关,可以说,在这40年中,计算机的性能就是以指数关系飞速增长的。此外,集成度也可以影响价格,因此性能所对应的价格则是反过来呈指数下降的。 64 | 65 | 想想看,现在你家附近电子商店中售价10万日元左右(约合人民币8000元)的笔记本电脑,性能恐怕已经超过20多年前的超级计算机了(图3)。况且,超级计算机光一个月的租金就要差不多1亿日元(约合人民币800万元),就连租都已经这么贵了,如果真要买下的话得花多少钱啊…… 66 | 67 | C>![](images/originals/chapter1/3.jpg) 68 | 69 | C>图3 20年前的超级计算机和现在的笔记本电脑性能处于同一水平 70 | 71 | 我在大学毕业之后就职的第一家公司里,用过一台索尼生产的Unix工作站,配置大概是这样的: 72 | 73 | * CPU:摩托罗拉68020 25MHz 74 | * 操作系统:NEWS-OS 3.3a(基于4.3BSD) 75 | * 内存:8MB 76 | * 硬盘:240MB 77 | * 价格:定价155万日元(约合人民币12万元) 78 | 79 | 现在我几乎不敢相信索尼曾经生产过UNIX工作站,以至于连“工作站”(Workstation)这个词本身都已经几乎被淘汰了。工作站曾经指的是那些工程上使用的、性能比一般个人电脑要高一些的计算机(大多数情况下安装的是UNIX系操作系统)。 80 | 81 | 这台工作站也曾经是我最初开始编写Ruby所使用的机器。现在我自己家里的计算机已经拥有Core2 duo 2.4GHz的CPU、4GB内存和320GB硬盘,单纯比较一下的话,CPU频率大约是那台工作站的100倍,内存容量大约是500倍,硬盘容量大约是1300倍。这两台计算机的发售时间大约差了18年,按照摩尔定律来计算,集成度的增加率应该为64倍,可见内存和硬盘容量的增加速度已经远远超过摩尔定律所规定的速率了。 82 | 83 | 在当时的网络上,电子邮件和网络新闻组是主流,网络通信还是在电话线路上通过调制解调器(Modem)来进行的。回头翻翻当时的杂志,看到像“9800bit/s超高速调制解调器售价198 000日元(约合人民币1.6万元)”这样的广告还是感到挺震惊的。最近我们已经很少见到模拟方式的调制解调器了,我最后见过的调制解调器速度为56Kbit/s,售价大约数千日元。 84 | 85 | 这正是摩尔定律的力量。在这个业界的各个领域中都经历着飞跃式的成长,近半个世纪以来,与计算机相关的所有部件,都随着时间变得性能更高、容量更大、价格更便宜。 86 | 87 | 在摩尔定律的影响下,我们的社会也发生了翻天覆地的变化。计算机现在已经变得随处可见,这应该说是摩尔定律为社会所带来的最大变化了吧。 88 | 89 | 我现在用的手机是iPhone,这个东西与其说是个手机,不如说是一个拥有通信功能的迷你计算机。作为玩具它实在是很有趣,但因为整天鼓捣它还是被家里人给了差评。这样一个东西花几万日元就能买到,不得不感叹文明的进步。差不多在同样的时间,我给我的一个女儿买了一部普通的手机,这部手机跟iPhone不一样,只是那种一般的多功能机,但仔细一看,这种手机也能上网,还装有Web浏览器、电子邮件、日程表等软件,也算得上一台不错的计算机了。 90 | 91 | 当初,让我感到最惊奇的是这个手机上居然安装了Java虚拟机,这样一来说不定能运行JRuby呢。不光是日本,全世界的人现在都能拥有这样的便携式计算机,并通过无线网络联系在一起,这样的情景在20年前简直是很难想象的。因此可以说,计算机的大规模普及,甚至改变了整个社会的形态。 92 | 93 | ### 以不变应万变 94 | 95 | 由摩尔定律所引发的计算机方面的变化可以用翻天覆地来形容,但也并不是所有的一切都在发生变化(图4)。 96 | 97 | C>![](images/originals/chapter1/4.jpg) 98 | 99 | C>图4 计算机在不断进化,而算法则保持不变 100 | 101 | 比如说,算法就能以不变应万变。被称为最古老算法的辗转相除法,是在公元前300年左右被提出的。此外,计算机科学中的大多数基本算法都是在20世纪60年代被提出的。 102 | 103 | 我们来想想看电子邮件的情形。15年前,几乎没什么人会使用电子邮件,但现在,电子邮件成了大家身边如影随形的工具,甚至有不少人一天到晚都在拿手机收发邮件。邮件影响了很多人的生活,甚至改变了我们的生活方式。 104 | 105 | 然而,邮件的基础技术却是出人意料地古老。世界上第一封电子邮件是在1971年发送的,而现在包括手机邮件在内所遵循的RFC822规范则是在1982年制定的,差不多是距今30多年前的东西了。此外,现在依然作为主流而被广泛使用的TCP/IP互联网通信协议也差不多是在那个时候制定的。 106 | 107 | 也就是说,这个技术本身是很早以前就存在的,只是一般人不知道而已。而更重要的一个原因是,人类自身的变化并没有那么快。读一读《圣经》之类的古典著作你就会惊奇地发现,人类从几千年前到现在所纠结的那些事情几乎没什么变化。从人类的本质来看,技术的进步只不过是些细枝末节的改变罢了。 108 | 109 | 摩尔定律所带来的变化,并不是改变了人类自身以及计算的本质,而是将以往非常昂贵的计算机,以及只有特殊部门才需要的东西,普及到“老百姓”的手上。从这个侧面来讲,它所带来的变化的确是十分巨大的。 110 | 111 | ### 摩尔定律的局限 112 | 113 | 无论如何,在这40年里,摩尔定律的确在一直改变着世界,但是这个定律真的是完美的吗? 114 | 115 | 呈指数增长的趋势在如此长的时期内能够一直成立,这本身就很不自然。实际上,这个看似无敌的摩尔定律,最近也仿佛开始显露出一些破绽。我们可以预料到,在不远的将来,一定会出现一些因素,对摩尔定律的继续生效构成障碍。 116 | 117 | 首先是物理定律的局限。LSI也是现实世界中物理存在的东西,自然受到物理定律的制约。在这40年里,LSI一直在不断变得更加精密,甚至快要到达量子力学所管辖的地盘了。当LSI的精密化达到这种程度,日常生活中一些从来不必在意的小事,都会变成十分严重的问题。 118 | 119 | 第一个重要的问题是光速。光速约为每秒30万千米,即1秒钟可以绕地球7圈半,这个数字十分有名,连小孩子都知道,不过正是因为光速实在太快,在日常生活中我们往往可以认为光速是无穷大的。 120 | 121 | 然而,CPU的时钟频率已经到达了GHz尺度,比如说,在3GHz的频率下,波形由开到关(即1个时钟周期)的时间内,光只能前进10cm的距离。 122 | 123 | 而且,最近的LSI中电路的宽度已经缩小到只有数十纳米(nm),而1nm等于100万分之一mm,是一个非常小的尺度,在1nm的长度上,只能排列几个原子,因此像这样在原子尺度上来制造电路是相当困难的。 124 | 125 | LSI中的电路是采用一种印刷技术印上去的,在这样细微的尺度中,光的波长甚至都成了大问题,因为如果图像的尺寸比光的波长还小,就无法清晰地转印。可见光的波长范围约为400~800nm,因此最近45nm制程的LSI是无法用可见光来制造的。 126 | 127 | 在这种原子尺度的电路中,保持绝缘也是相当困难的。简单来说,就是电流通过了原本不该通过的地方,这被称为漏电流。漏电流不但会浪费电力,某些情况下还会降低LSI的性能。 128 | 129 | 漏电流还会引发其他的问题,比如发热。随着LSI越来越精密,其密度也越来越高,热密度也随之提高。像现在的CPU这样高密度的LSI,其热密度已经跟电熨斗或者烧烤盘差不多高了,因此必须用风扇等装置持续进行降温。照这个趋势发展下去,热密度早晚要媲美火箭的喷气口,如果没有充分的散热措施,连LSI本身都会被熔化。 130 | 131 | 由于漏电流和热密度等问题,最近几年,CPU的性能提高似乎遇到了瓶颈。大家可能也都注意到了,前几年在店里卖的电脑还都配备了3GHz、4GHz的CPU,而最近主流的电脑配置却是清一色的2GHz上下。造成这个现象的原因之一就是上面提到的那些问题,使得CPU一味追求频率的时代走到了尽头。此外,现在的CPU性能对于运行Web浏览器、收发邮件等日常应用已经足够了,这也是一个原因。 132 | 133 | 看了上面这些,大家可能会感到称霸了40多年的摩尔定律就快要不行了,不过英特尔公司的人依然主张“摩尔定律至少还能维持10年”。实际上,人们可以使用特殊材料来制造LSI,以及使用X光代替可见光来进行光刻的转印等,通过这些技术的手段,摩尔定律应该还能再维持一阵子。 134 | 135 | 此外,由于通过提高单一CPU的密度来实现性能的提升已经非常困难,因此在一个LSI中集成多个CPU的方法逐渐成为主流。像英特尔公司的Core2 i5、i7这样在一个LSI上集成2~8个CPU核心的“多核”(Multi-core)CPU,目前已经用在了普通的电脑中,这也反映了上面提到的这一趋势。 136 | 137 | 比起拥有复杂电路设计的CPU来说,内存等部件由于结构简单而平均,因此其工艺的精密化更加容易。今后一段时间内,CPU本身的性能提升已经十分有限,而多CPU化、内存容量的增大、由硬盘向半导体SSD转变等则会成为主流。 138 | 139 | ### 社会变化与编程 140 | 141 | 前面我们讨论了摩尔定律和它所带来的变化,以及对今后趋势的简单预测。多亏了摩尔定律,我们现在才可以买到大量高性能低价格的计算机产品。那么这种变化又会对编程产生怎样的影响呢? 142 | 143 | 我最早接触编程是在20世纪80年代初,在那个时候,使用电脑的目的就是编写BASIC程序。无论是性能还是容量,那个时候的计算机都非常差劲,根本无法与现在的计算机相提并论,此外,还必须使用BASIC这种十分差劲的编程语言,这种环境对于编程的制约是相当大的。当时,我编写了许多现在看起来很不起眼的游戏,还对差劲的BASIC和计算机性能感到十分不爽,一边立志总有一天“一定要用上正经的计算机”,一边搜集着书本杂志中的信息做着自己的“春秋大梦”。 144 | 145 | 而另一方面,现在计算机已经随处可见,拿着手机这样的个人计算设备的人也不在少数。我的孩子们所就读的学校里,设有与理科教室、音乐教室等并列的电脑教室,有时也会用计算机来进行授课。这样一个时代中的年轻人,他们对于编程这件事又怎么看呢? 146 | 147 | 由于职业的关系,我家里有很多台计算机,算上不怎么经常用的,可以说计算机的数量比家里人的数量还要多,当然,要是再算上手机之类的话,那就更多了。即便是生活在这样充满计算机的家庭中,孩子们对于编程貌似也没有什么兴趣。 148 | 149 | 那么,他们用计算机都做些什么事呢?比如用邮件和博客与朋友交流,用维基百科查阅学习上所需要的信息,还有在YouTube上看看动画片之类的。 150 | 151 | 上初中时学校曾经组织过用一种叫做“Dolittle”的编程语言来做实习,孩子们也好像也挺感兴趣,不过并没有再进一步发展为真正的编程。对于他们来说,上上网站、看看YouTube、发发邮件,有时候玩玩网购和在线竞拍,这些已经足够了。 152 | 153 | 我一个学生时代的朋友,现在正在大学任教,他对我说,现在信息技术类专业不但不如以前热门,而且招进来的学生中有编程经验的比例也下降了。这似乎意味着,计算机的普及率提高了,但是编程的普及率却一点都没有提高,真是令人嗟叹不已。 154 | 155 | 我猜想,大概是由于随着软件的发展,不用编程也可以用好计算机,因此学习编程的动力也就没有那么强了。此外,现在大家都认为软件开发是一份非常辛苦的工作,这可能也是导致信息技术类专业人气下滑的一个原因。 156 | 157 | 话虽如此,但并是说真的一点希望都没有了。这几年来,我在一个叫做“U20 Pro Con”的以20岁以下青少年为对象的编程大赛中担任评委,每年的参赛作品中,总能见到一些水平非常高的程序。 158 | 159 | 也许是因为我担任评委的缘故,每年当我看到有自制编程语言方面的参赛作品时,总会感到十分震惊和欣慰。在我自己还是高中生的时候,虽然也想过创造一种编程语言,但完全不知道该怎样去做,到头来毫无进展。从这个角度来看,这些参赛的年轻人能够完整设计并实现一种编程语言,比当年的我可优秀多了,因此我对他们将来的发展充满期待。 160 | 161 | 在这个世界上也有一些人,即便不去培养,他们也拥有想要编程的欲望,这样的人虽然只是小众,但他们会通过互联网获取丰富的知识,并不断攀登编程领域的高峰。编程的领地不会像计算机的普及那样飞速地扩展,但水平最高的人,水平却往往变得越来越高。这样的状况是我们希望看到的呢,还是不希望看到的呢?我也没办法做出判断。 162 | 163 | 现代社会已经离不开计算机和驱动计算机的软件了,从这个角度来说,我希望有更多的人能够积极地参与到编程工作中来。此外,我也希望大家不仅仅是将软件开发作为一份工作来做,而是希望更多的人能够感受到软件开发所带来的那种“创造的乐趣”和“心潮澎湃的感觉”。 164 | 165 | ## 1.2 未来预测 166 | 167 | 没有哪个人能够真的看到未来,也许正是因为如此,人们才想要预知未来,并对预言、占卜等方式充满兴趣。以血型、出生日期、天干地支、风水等为依据的占卜非常热门,事实上,杂志和早间电视节目中每次都有占卜的内容。 168 | 169 | 这些毫无科学依据的占卜方式是不靠谱的,虽说如此,占卜却还是大肆流行起来,其中有这样一些理由。 170 | 171 | 首先,最大的理由莫过于“巴纳姆效应”了。“巴纳姆效应”是一种心理学现象,指的是将一些原本是放之四海而皆准的、模棱两可的一般性描述往自己身上套,并认为这些描述对自己是准确的。比如,找一些受试者做一份心理测试问卷,无论受试者如何回答问卷上的问题,都向他们提供事先准备好的内容差不多的测试结果,大多数的受试者都会认为这个结果对自己的描述非常准确。你觉得“占卜好准啊”,其实多半都是巴纳姆效应所导致的。即使是随便说说的一些话,也会有人深信不疑,这说不定是人类的一种本能吧。人类的心理到底为什么会拥有这样的性质呢? 172 | 173 | 其次,有很多算命先生和自称预言家的人,实际上都是利用了被称为“冷读术”(Cold reading)和“热读术”(Hot reading)的技巧,来让人们相信他们真有不同寻常的“能力”。 174 | 175 | 冷读术,就是通过观察对方言行举止中的一些细微之处来进行揣测的技巧,就像夏洛克·福尔摩斯对他的委托人所运用的那种技巧差不多。例如通过说话的口音来判断出生地,通过衣服上粘着的泥土来判断对方之前去过什么地方等等。冷读术中的“冷”代表“没有事先准备”的意思。 176 | 177 | 相对地,热读术则是通过事先对对方进行详细的调查,来准确说出对方的情况(逢场作戏)。通过事先调查,掌握对方的家庭构成、目前所遇到的问题等等,当然能够一语中的,再加上表演得像是拥有超能力一样,总会有人深信不疑的。 178 | 179 | 结论,占卜之类的方法都不靠谱,它们都是不科学的。那么,有没有科学一点的方法能够预测未来呢?比如说,像艾萨克·阿西莫夫的基地系列中所描写的心理史学那样。 180 | 181 | ### 科学的未来预测 182 | 183 | 心理史学是阿西莫夫所创造的虚构学科。用气体分子运动论来类比,我们虽然无法确定每个气体分子的运动方式,但对于由无数气体分子所组成的气体,我们却可以计算出其整体的运动方式。同样地,对于由无数的人所组成的集团,其行为也可以通过数学的方法来进行预测。这样一类比的话,是不是感到很有说服力呢? 184 | 185 | 基地系列正是以基于心理史学的未来预测为轴,描写了以整个银河系为舞台,数兆人类的数千年历史。 186 | 187 | 然而,在现实中,特定个人的行动往往能够大幅左右历史的走向,即便是从整体来看,用数学公式来描述人类的行为还是太过复杂了,心理史学也许只能停留在幻想中而已。虽然心理史学只是一门完全虚构的学科,但这并不意味着不可能通过科学的方法来预测未来。虽然我们无法对未来作出完全准确的预测,但在限定条件下,还是可以在一定概率上对未来作出预测的,尤其是当我们要预测的不是未来人类的行动,而是纯粹预测技术发展的情况下。因此,IT领域可以说是比较容易通过上述方式进行未来预测的一个领域了吧。 188 | 189 | ### IT未来预测 190 | 191 | 之所以说IT领域的未来比较容易预测,最大的一个理由是:从计算机的出现到现在已经过了约半个世纪,但在这40多年的时间里,计算机的基本架构并没有发生变化。现在主流的CPU架构是英特尔的x86架构,它的基础却可以追溯到1974年问世的8080,而其他计算机的架构,其根本部分都是大同小异。这意味着计算机进步的方向不会有什么很大的变化,我们有理由预测,未来应该位于从过去到现在这个方向的延长线上(图1)。 192 | 193 | C>![](images/originals/chapter1/5.jpg) 194 | 195 | C>图1 从过去到未来的发展方向 196 | 197 | 如果像量子计算机这样和现在的计算机架构完全不同的东西成为主流的话,我们的预测也就不成立了,不过还好,在短时间内(比如5年之类的)这样的技术应该还无法实现。此外,在这个行业中,5年、10年以后的未来已经算是相当遥远了,即便预测了也没有什么意义。总之目前来看,这样的趋势还是问题不大的。 198 | 199 | 支配计算机世界“从过去到未来变化方向”的一个代表性理论,就是在1-1中已经讲解过的“摩尔定律”。 200 | 201 | LSI中的晶体管数量每18个月增加一倍。 202 | 203 | 在摩尔定律的影响下,电路变得更加精密,LSI的成本不断降低,性能不断提高。其结果是,在过去的近40年中: 204 | 205 | * 价格下降 206 | * 性能提高 207 | * 容量增大 208 | * 带宽增加 209 | 210 | 这些都是呈指数关系发展的。呈指数关系,就像“一传十、十传百”一样,其增大的速度是十分惊人的(图2)。 211 | 212 | C>![](images/originals/chapter1/6.jpg) 213 | 214 | C>图2 性能呈指数关系增加 215 | 216 | 而这一呈指数关系发展的趋势,预计在今后也会保持差不多的速度,这就是IT未来预测的基础。 217 | 218 | 另外一个需要考虑的问题,就是不同领域各自的发展速度。IT相关的各种数值的确都在以指数关系增加,但大家的步调也并不是完全一致的。例如,相比CPU处理速度的提高来说,存储器容量的增加速度更快,而与上面两者相比,数据传输速度的增加又显得跟不上了。这种发展的不平衡也会左右我们的未来。 219 | 220 | ### 极限未来预测 221 | 222 | 下面我们来介绍一种预测未来的时候所用到的,名叫“极限思考法”的简单技巧。 223 | 224 | 曾提出过“极限编程”(eXtreme Programming,简称XP)手法的肯特·贝克,在其著作《解析极限编程》中这样写道: 225 | 226 | 当我第一次构建出XP时,我想到了控制板上的旋钮。每个旋钮都是一种(经验告诉我的)有效的实践,我将所有的旋钮都调到10,然后等着看会出现什么情况。 227 | 228 | 我们也可以用同样的方法来对未来作出预测。比如说,“如果计算机的价格越来越便宜,那当它便宜到极致的时候会怎么样呢?”“如果我们能够买到超高性能的计算机会怎么样呢?”“如果计算机的存储容量增大到超乎想象的程度会怎么样呢?”“如果网络带宽变得非常大的话会怎么样呢?” 229 | 230 | 大家怎么认为呢? 231 | 232 | ### 从价格看未来 233 | 234 | 首先,我们来看看价格。如果今后计算机的价格不断下降,这将意味着什么呢?我想这意味着两件事。第一,普通人所能拥有的计算机的性能将比现在大大提高;第二,现在还没有使用计算机的地方,以后都会安装上计算机。 235 | 236 | *** 237 | 238 | 这里有一个很有意思的现象,根据摩尔定律,关于计算机的很多指标都在发生剧烈的变化,但PC的价格似乎变化并没有那么大。1979年发售的NEC PC-8001的定价为16万8000日元(约合人民币1.3万元),而现在主力PC的价格也差不多是在10万日元(约合人民币8000元)上下,即便考虑物价变化的因素,也还是出人意料地稳定。这可能意味着人类对于PC的购买力也就差不多只有这个程度,在不断提高的性能和价格之间在寻求一种平衡,因此我估计普及型计算机的价格今后也不太可能会大幅下降。关于将来PC(PC型计算机)的样子,我们会在“性能”一节中进行讨论。 239 | 240 | 关于在目前尚未开发的领域中安装计算机这件事,其实现在已经在上演了。例如,以前纯粹由电子电路所构成的电视机,现在也安装了CPU、内存、硬盘等部件,从硬件上看和PC没什么两样,并且还安装了Linux这样的操作系统。此外,以前用单片机来实现的部分,现在也开始用上了带有操作系统的“计算机”,在这样的嵌入式系统中,软件所占的比例越来越大。今后,可以说外观长得不像计算机的计算机会越来越多,为这样的计算机进行软件开发的重要性也就越来越高。例如,现在由于内存容量和CPU性能的限制而无法实现的开发工具和语言,以后在“嵌入式软件”开发中也将逐渐成为可能。 241 | 242 | ### 从性能看未来 243 | 244 | 从近10年计算机性能变化的趋势来看,CPU自身的性能提高似乎已经快要到达极限了。近几年,很多人会感觉到PC的时钟频率似乎到了2GHz就再也上不去了。这种性能提高的停滞现象,是由耗电、漏电流、热密度等诸多原因所导致的,因此从单一CPU的角度来看,恐怕无法再继续过去那样呈指数增长的势头了。 245 | 246 | 那么这样下去结果会怎样呢?要推测未来计算机的性能,最好的办法是看看现在的超级计算机。因为在超级计算机中为了实现高性能而采用的那些技术,其中一部分会根据摩尔定律变得越来越便宜,在5到10年后的将来,这些技术就会被用在主流PC中。 247 | 248 | 那么,作为现在超级计算机的代表,我们来看看2012年目前世界最快的超级计算机“京”的性能数据(表1)。虽然它的性能看起来都是些天文数字,但再过20年,这种程度的性能很可能就只能算是“一般般”了。 249 | 250 | C>表1 超级计算机“京”的指标 251 | 252 | |性能 |10000TFLOPS | 253 | |价格 |1120亿日元(约合人民币90亿元) | 254 | |CPU数量 |88128个 | 255 | |核心数量 |705024个 | 256 | |内存 |2.8PB(平均每个CPU拥有32GB) | 257 | 258 | 说不定在不久的将来,1024核的笔记本电脑就已经是一般配置了。如果是服务器环境的话,也许像现在的超级计算机这样数万CPU、数十万核心的配置也已经非常普遍了。难以置信吧? 259 | 260 | 在这样的环境下,编程又会变成什么样子呢?为了充分利用这么多的CPU,软件及其开发环境又会如何进化呢? 261 | 262 | 考虑到这样的环境,我认为“未来的编程语言”之间,应该在如何充分利用CPU资源这个方面进行争夺。即便是现在,也已经有很多语言提供了并行处理的功能,而今后并行处理则会变得愈发重要。如果能将多个核心的性能充分利用起来,说不定每个单独核心的性能就变得没有那么重要的。 263 | 264 | ### 从容量看未来 265 | 266 | 存储器的容量,即内存容量和外存(硬盘等)容量,是增长速度最快的指标。2012年春,一般的笔记本电脑也配备了4GB的内存和500GB左右的硬盘,再加上外置硬盘的话,购买2~3TB的存储容量也不会花上太多的钱。一个普通人所拥有的存储容量能达到TB级,这在10年前还是很难想象的事情,而仅仅过了没多少时间,我们就可以在电子商店里轻松买到TB级容量的硬盘了。 267 | 268 | 那么,存储器容量的增加,会对将来带来哪些变化呢?大家都会想到的一点是,到底从哪里才能搞到那么多的数据,来填满如此巨大的容量呢? 269 | 270 | 实际上,这一点根本用不着担心。我们来回想一下,无论存储容量变得多大,不知怎么回事好像没过多久就又满了。为了配合不断增加的存储容量,图片数据和视频数据都变得更加精细,尺寸也就变得更大。另外,软件也变得越来越臃肿,占用的内存也越来越多。以前的软件到底是怎样在那么小的内存下运行得如此流畅的呢?真是想不通啊。 271 | 272 | 因此,问题是我们要如何利用这些数据呢?也许面向个人的数据仓库之类的数据分析工具会开始受到关注。当然,这种工具到底应该在客户端运行,还是在服务器端运行,取决于性能和带宽之间的平衡。 273 | 274 | 在存储器容量方面,与未来预测相关并值得关注的一个动向,就是访问速度。虽然容量在以惊人的速度增长,但读取数据的速度却没有按照匹配的速度来提高。硬盘的寻址速度没什么长进,总线的传输速度也是半斤八两。不过,像闪存这样比硬盘更快的外部存储设备,现在也已经变得越来越便宜了,由闪存构成的固态硬盘(Solid State Drive,SSD)已经相当普遍,完全可以作为硬盘的替代品。按照这个趋势发展下去,在不久的将来,说不定由超高速低容量的核心内置缓存、高速但断电会丢失数据的主内存(RAM),以及低速但可永久保存数据的外部存储器(HDD)所构成的层次结构将会消失,取而代之的可能将会是由大规模的缓存,以及高速且能永久保存数据的内存所构成的新的层次结构。如果高速的主内存能够永久保存数据,依赖过去结构的数据库等系统都将产生大规模的结构改革。实际上,以高速SSD为前提的数据库系统,目前已经在进行研发了。 275 | 276 | ### 从带宽看未来 277 | 278 | 带宽,也就是网络中数据传输的速度,也在不断增大。一般家庭的上网速度,已经从模拟调制解调器时代的不到100Kbit/s,发展到ADSL时代的10Mbit/s,再到现在光纤时代的超过100Mbit/s,最近连理论上超过1Gbit/s的上网服务也开始面向一般家庭推出了。 279 | 280 | 网络带宽的增加,会对网络两端的平衡性产生影响。在网络速度很慢的时代,各种处理只能在本地来进行,然后将处理结果分批发给中央服务器,再由中央服务器进行统计,这样的手法十分常见。这就好像回到了计算机还没有普及,大家还用算盘和账本做着“本地处理”的时代。 281 | 282 | 然而后来,各种业务的处理中都开始使用计算机,每个人手上的数据都可以发送到中央计算机并进行实时处理。但由于那时的计算机还非常昂贵,因此只是在周围布置了一些被称为“终端”的机器,实际的处理还是由设在中央的大型计算机来完成的。那是一个中央集权的时代。 283 | 284 | 在那以后,随着计算机价格的下降,每个人都可以拥有自己的一台计算机了。由于计算机可以完成的工作也变多了,因此每个人手上的“客户端”计算机可以先完成一定程度的处理,然后仅仅将最终结果传送给位于中央的“服务器”,这样的系统结构开始普及起来,也就是所谓的“客户端/服务器系统”(Client-Server system),也有人将其简称为“CS系统”。 285 | 286 | 然而,如果网速提高的话,让服务器一侧完成更多的处理,在系统构成上会更加容易。典型的例子就是万维网(World Wide Web,WWW)。在网速缓慢的年代,为了查询数据而去直接访问一个可能位于地球背面的服务器,这种事是难以想象的,如此浪费贵重的带宽资源,是要被骂得狗血淋头的。话说,现在的网络带宽已经像白菜一样便宜了,这样一来,客户端一侧只需要准备像“浏览器”这样一个通用终端,就可以使用全世界的各种服务了,如此美好的世界已经成为了现实。由于大部分处理是在服务器一侧执行的,因此乍看之下仿佛是中央集权时代的复辟,不同的是,现在我们可以使用的服务多种多样,而且它们位于全世界的各个角落。 287 | 288 | 但是,计算机性能和带宽之间的平衡所引发的拔河比赛并没有到此结束。近年来,为了提供更丰富的服务,更倾向于让JavaScript在浏览器上运行,这实际上是“客户端/服务器系统”换个马甲又复活了。此外,服务器一侧也从一台计算机,变成了由许多台计算机紧密连接所构成的云计算系统。换个角度来看的话,以前由一台大型机所提供的服务,现在变成由一个客户端/服务器结构来提供了。 289 | 290 | 今后,在性能和带宽寻求平衡的过程中,网络彼此两端的系统构成也会像钟摆一样摇个不停。从以往的情况来看,随着每次钟摆的来回,系统的规模、扩展性和自由度都能够得到提高,今后的发展也一定会遵循这样一个趋势。 291 | 292 | ### 小结 293 | 294 | 在这里,我们瞄准从过去到现在发展方向的延长线,运用极限思考法,尝试着对未来进行了预测。书籍是可以存放很久的,5年、10年之后再次翻开这本书的时候,到底这里的预测能不能言中呢?言中的话自然感到开心,没言中的话我们就一笑了之吧,胜败乃兵家常事嘛。 -------------------------------------------------------------------------------- /manuscript/第六章.md: -------------------------------------------------------------------------------- 1 | # 第六章:多核时代的编程 2 | 3 | ## 6.1 摩尔定律 4 | 5 | 关于摩尔定律,本书中已经提到了很多次。摩尔定律是由美国英特尔公司的戈登·摩尔(Gordon Moore)提出的,指的是“集成电路中的晶体管数量大约每两年翻一倍”。下面我们就摩尔定律进行一些更深入的思考。 6 | 7 | 实际上,在1965年的原始论文中写的是“每年翻一倍”,在10年后的1975年发表的论文中又改成了“每两年翻一倍”。在过去的40年中,CPU的性能大约是每一年半翻一倍,因此有很多人以为摩尔定律的内容本来是“每18个月翻一倍”。 8 | 9 | 其实,在几年前对此进行考证之前,我也是这么以为的。然而,似乎没有证据表明戈登·摩尔提出过“18个月”这个说法。但英特尔公司的David House曾经在发言中提到过“LSI(大规模集成电路)的性能每18个月翻一倍”,因此18个月一说应该是起源于他。 10 | 11 | 虽然摩尔定律也叫定律,但它并非像物理定律那样严格,而只是一种经验法则、技术趋势或者说是目标。然而,令人惊讶的是,从1965年起至今,这一定律一直成立,并对社会产生了巨大的影响。 12 | 13 | ### 呈几何级数增长 14 | 15 | “两年变为原来的两倍”,就是说4年4倍、6年8倍、2n年2的n次方倍这样的增长速度。像这样“n年变为K的m次方倍”的增长称为几何级数增长。 16 | 17 | 对于我们来说,摩尔定律的结果已经司空见惯了,也许一下子很难体会到其惊人的程度。下面我们通过一个故事,来看一看这种增长的速度是何等令人震惊。 18 | 19 | 很久很久以前,在某个地方有一位围棋大师,他的围棋水平天下无双,于是领主说:“你想要什么我就可以赏给你什么。”大师说:“我的愿望很简单,只要按照棋盘的格子数,每天给我一定数量的米就可以了。第一天一粒米,第二天两粒米,每天都比前一天的粒数翻倍。” 20 | 21 | “什么嘛,从一粒米开始吗?”领主笑道,“你可真是无欲无求啊。好,明天就开始吧。”围棋的棋盘有19×19个格子,也就是说领主要在361天中每天赏给大师相应的米。第一天给1粒,第二天是两粒,然后是4粒、8粒、16粒、32粒。一开始大家都觉得:“也就这么点米嘛。”但过了几天之后情况就发生了变化。两周还没到,赏赐的米粒一碗已经装不下了,要用更大的盆子才能装下,这时,有一位家臣发现情况不妙。 22 | 23 | “主公,大事不好!”“怎么了?”“就是赏给大师的那些米,我算了一下,这个米的数量可不得了,最后一天,也就是第361天,要赏给他的米居然有23485425827738332278894805967893370 24 | 25 | 27375682548908319870707290971532209025114608443463698998384768703031934976粒。这么多米,别说我们这座城,就是全世界的米都加起来也不够啊!”“天呐!”无奈,领主只能把大师叫来,请他换一个愿望。 26 | 27 | 看了上面这个故事,我想大家应该明白几何级数增长会达到一个多么惊人的数字了。而在半导体业界,这样的增长已经持续了40多年。大量技术人员不懈努力才将这样的奇迹变成现实,这是一项多么了不起的成就啊。 28 | 29 | ### 摩尔定律的内涵 30 | 31 | 半导体的制造使用的是一种类似印刷的技术。简单来说,是在被称为“晶圆”(wafer)的圆形单晶硅薄片上涂一层感光树脂(光刻胶),然后将电路的影像照射到晶圆上。其中被光照射到并感光的部分树脂会保留下来,其余的部分会露出硅层。接下来,对露出的硅的部分进行加工,就可以制作成晶体管等元件。摩尔定律的本质,即如何才能在晶圆上蚀刻出更细微的电路,是对技术人员的一项巨大的挑战。 32 | 33 | 技术人员可不是为了自我满足才不断开发这种细微加工工艺的。电路的制程缩小一半,就意味着同样的电路在硅晶圆上所占用的面积可以缩小到原来的1/4。也就是说,在电路设计不变的情况下,用相同面积的硅晶圆就可以制造出4倍数量的集成电路,材料成本也可以缩减到原来的1/4。 34 | 35 | 缩减制程的好处还不仅如此。构成CPU的MOS(Metal-Oxide Semiconductor,金属氧化物半导体)晶体管,当制程缩减到原来的1/2时,就可以实现2倍的开关速度和1/4的耗电量。这一性质是由IBM的Robert Dennard发现的,因此被命名为Dennard Scaling。 36 | 37 | 综上所述,如果制程缩减一半,就意味着可以用同样的材料,制造出4倍数量、2倍速度、1/4耗电量的集成电路,这些好处相当诱人,40多年来摩尔定律能够一直成立,其理由也正在于此。缩减制程所带来的好处如此之大,足以吸引企业投入巨额的研发经费,甚至出资建设新的半导体制造工厂也在所不惜。 38 | 39 | ### 摩尔定律的结果 40 | 41 | 可以说,最近的计算机进化和普及,基本上都是托了摩尔定律的福。半导体技术的发展将摩尔定律变为可能,也推动了计算机性能的提高、存储媒体等容量的增加,以及价格难以置信般的下降。 42 | 43 | 例如,现在一般的个人电脑价格都不超过10万日元(约合人民币8000元),但其处理性能已经超过了30年前的超级计算机。而且,当时的超级计算机光租金就要超过每月1亿日元(约合人民币800万元),从这一点上来说,变化可谓是天翻地覆的。 44 | 45 | 30年前(1980年左右)的个人电脑,我能想到的就是NEC(日本电气)的PC-8001(1979年发售),和现在的电脑对比一下,我们可以看到一些非常有趣的变化(表1)。 46 | 47 | 即使不考虑这30年间物价水平的变化,这一差距也可谓是压倒性的。而且,现在的笔记本电脑还配备了液晶显示屏、大容量硬盘和网络接口等设备,而30年前最低配置的PC-8001除了主机之外,甚至都没有配备显示屏和软驱,这一点也很值得关注。 48 | 49 | C>表1 30年间个人计算机的变化 50 | 51 | C>![](images/originals/chapter6/1.jpg) 52 | 53 | ### 摩尔定律所带来的可能性 54 | 55 | 不过,摩尔定律所指的只是集成电路中晶体管数量呈几何级数增长这一趋势,而计算机性能的提高、价格的下降,以及其他各种变化,都是晶体管数量增长所带来的结果。 56 | 57 | 让我们来思考一下,通过工艺的精细化而不断增加的晶体管,是如何实现上述这些结果的呢?最容易理解的应该就是价格了。单位面积中晶体管数量的增加,同时也就意味着晶体管的单价呈几何级数下降。当然,工艺的精细化必然需要技术革新的成本,但这种成本完全可以被量产效应所抵消。 58 | 59 | 工艺的精细化,意味着制造相同设计的集成电路所需的成本越来越低。即便算上后面所提到的为提升性能而消费的晶体管,其数量的增长也是绰绰有余的。也就是说,只要工艺的精细化能够得以不断地推进,成本方面就不会存在什么问题。不仅是CPU,电脑本身就是电子元件的集合。像这样由工艺改善带来的成本下降,就是上面所提到的30年来个人电脑在价格方面进化的原动力。 60 | 61 | 精细化所带来的好处并不仅仅是降低成本。由于前面提到的Dennard Scaling效应,晶体管的开关速度也得以实现飞跃性的提升。相应地,CPU的工作时钟频率也不断提高。30年前CPU的工作时钟频率还只有几Mhz,而现在却已经有几GHz了,实际提高了差不多1000倍。 62 | 63 | 由于构成CPU的晶体管数量大幅增加,通过充分利用这些晶体管来提高性能,也为CPU的高速化做出了贡献。现代的CPU中搭载了很多高速化方面的技术,例如将命令处理分割成多段并行执行的流水线处理(pipeline);不直接执行机器语言,而是先转换为更加细化的内部指令的微指令编码(micro-operation de-coding);先判断指令之间的依赖关系,对没有依赖关系的指令改变执行顺序进行乱序执行(out-of-order execution);条件分支时不等待条件判断结果,而是先继续尝试执行投机执行(spec-ulative execution)等。 64 | 65 | 在现代CPU的内部,都配备了专用的高速缓存,通过高速缓存可以在访问内存时缩短等待时间。从CPU的运行速度来看,通过外部总线连接的主内存访问起来非常缓慢。仅仅是等待数据从内存传输过来的这段时间,CPU就可以执行数百条指令。 66 | 67 | 还好,对内存的访问存在局部性特点,也就是相同的数据具有被反复访问的倾向,因此只要将读取过的数据存放在位于CPU内部的快速存储器中,就可以避免反复访问内存所带来的巨大开销。这种方法就是高速缓存。缓存英文写作cache,原本是法语“隐藏”的意思,大概指的是将内存中的数据贮藏起来的意思吧。 68 | 69 | 不过,CPU内部配备的高速缓存容量是有限的,因此也有不少CPU配备了作为第二梯队的二级缓存。相比能够从CPU直接访问的高速、高价、低容量的一级缓存来说,二级缓存虽然速度较慢(但仍然比内存的访问速度高很多),但容量很大。还有一些CPU甚至配备了作为第三梯队的三级缓存。如果没有高速缓存的话,每次访问内存的时候,CPU都必须等待能够执行数百条指令的漫长时间。 70 | 71 | 最近的电脑中已经逐渐普及的多核和超线程(HyperThreading)等技术,都是利用晶体管数量来提高运算性能的尝试。 72 | 73 | ### 为了提高性能 74 | 75 | 接下来,我们就来具体看一看,那些增加的晶体管到底是如何被用来提高CPU性能的。 76 | 77 | CPU在运行软件的时候,看起来似乎是逐一执行指令的,但其实构成CPU的硬件(电路)是能够同时执行多个操作的。将指令执行的操作进行分割,通过流水作业的方式缩短每一个单独步骤的处理时间,从而提升指令整体的执行速度,这种流水线处理就是一种提高性能的基本技术(图1)。 78 | 79 | C>![](images/originals/chapter6/2.jpg) 80 | 81 | 图1 CPU的流水线处理典型的处理步骤包括:①取出指令(fetch);②指令解码(decode);③取出运算数据(data fetch);④运算;⑤输出运算结果(write-back)等。 82 | 83 | 我们可以看出,将操作划分得越细,每一级的处理时间也会相应缩短,从而提升指令执行的吞吐量。出于这样的考虑,现代的CPU中流水线都被进一步细分,例如在Pentium4中被细分为31级(英特尔最新的Core架构是采用14级的设计)。 84 | 85 | 不过,流水线处理也并非十全十美。当流水作业顺利执行的时候是没什么问题的,一旦流水线上发生一个问题,就会接连引发一连串的问题。要想让流水线处理顺利进行,需要让各步骤都以相同的步伐并肩前进,而这一条件并非总能得到满足。 86 | 87 | 我们来看一个CPU加法指令的例子。x86的加法指令形是: 88 | 89 | {lang="text"} 90 | ADD a b 91 | 92 | 这条指令的意思是将a和b相加,并将结果保存在a中。a和b可以是寄存器,也可以是内存地址,但对于CPU来说,访问寄存器和访问内存所需要的时间是天壤之别的。如果需要对内存进行访问,则在执行取出数据这一步的时间内,整个流水线就需要等待几百个时钟周期,这样一来流水线化对指令执行速度带来的那一点提升也就被抵消了。 93 | 94 | 像这样流水线发生停顿的问题被称为气泡(bubble /pipeline stall)。产生气泡的原因有很多种,需要针对不同的原因采取不同的对策。 95 | 96 | 上述这样由于内存访问速度缓慢导致的流水线停顿问题,被称为“数据冒险”(data hazard),针对这种问题的对策,就是我们刚刚提到过的“高速缓存”。高速缓存,实际上是消耗一定数量的晶体管用作CPU内部高速存储空间,从而提升速度的一种技术。 97 | 98 | 然而,高速缓存也不是万能的。即使晶体管数量大幅增长,其数量也不是无限的,因此高速缓存在容量上是有限制的。而且,缓存的基本工作方式是“将读取过一次的数据保存下来,使下次无需重新读取”,因此对于从未读取过的数据,依然还是要花费几百个时钟周期去访问位于CPU外部的内存才行。 99 | 100 | 还有其他一些原因会产生气泡,例如由于CPU内部电路等不足导致的资源冒险(resource hazard);由于条件分支导致的分支冒险(branch hazard)等。资源冒险可以通过增设内部电路来进行一定程度的缓解。 101 | 102 | 这里需要讲解一下分支冒险。在CPU内部遇到条件分支指令时,需要根据之前命令的执行结果,来判断接下来要执行的指令的位置。不过,指令的执行结果要等到该命令的WB(回写)步骤完成之后才能知晓,因此流水线的流向就会变得不明确(图2)。 103 | 104 | C>![](images/originals/chapter6/3.jpg) 105 | 106 | C>图2 分支冒险 107 | 108 | 在图2中,首先执行第1条指令,与之并行执行第2条指令的取出操作(到第4个周期)。然而,第1条指令执行完毕之前,无法执行第2条指令的分支(第5个周期),这算是一种资源冒险吧。第1条指令的执行完全结束之后,才可以轮到第2条指令,而第2条指令的回写操作完成之后,才能够确定第3条指令位于哪个位置,也就是说,这时能够执行第3条指令的取出操作。 109 | 110 | 分支冒险可没那么容易解决。分支预测是其中的一种方案。分支预测是利用分支指令跳转目标上的偏向性,事先对跳转的目标进行猜测,并执行相应的取出指令操作。 111 | 112 | 图3是分支预测的执行示意图。到第2个指令为止的部分,和图2是相同的,但为了避免产生气泡,这里对分支后的指令进行预测并开始取出指令的操作。当预测正确时,整个执行过程需要9个周期,和无分支的情况相比只增加了2个。当预测错误时,流水线会被清空并从头开始。只要猜中就赚到了,没猜中也只是和不进行预测的结果一样而已,因此整体的平均执行速度便得到了提升。 113 | 114 | C>![](images/originals/chapter6/4.jpg) 115 | 116 | C>图3 分支预测 117 | 118 | 最近的CPU已经超越了分支预测,发展出更进一步的投机执行技术。所谓投机执行,就是对条件分支后的跳转目标进行预测后,不仅仅是执行取出命令的操作,还会进一步执行实际的运算操作。当然,当条件分支的预测错误时,需要取消刚才的执行,但当预测正确时,对性能的提升就可以比仅进行分支预测来得更加高效。 119 | 120 | 流水线是一种在垂直方向上对指令处理进行重叠来提升性能的技术,相对地,在水平方向上将指令进行重叠的技术称为超标量(superscalar)。也就是说,在没有相互依赖关系的前提下,多条指令可以同时执行。 121 | 122 | 例如,同时执行两条指令的超标量执行情况如图4所示。从理论上说,最好的情况下,执行6条指令只需要7个周期,这真的是了不起的加速效果。在图3的例子中是同时执行两条指令,但只要增加执行单元,就可以将理论极限提高到3倍甚至4倍。实际上,在最新的CPU中,可同时执行的指令数量大约为5条左右。 123 | 124 | C>![](images/originals/chapter6/5.jpg) 125 | 126 | C>图4 超标量执行 127 | 128 | 不过,事情总是没有这么简单。采用超标量架构的CPU,实际能够同时执行的指令数量要远远低于理想的值,这是因为数据间的依赖关系妨碍了指令的同时执行。例如: 129 | 130 | {lang="text"} 131 | a = b + c 132 | d = e + f 133 | g = h + i 134 | 135 | 像上述这样的运算,各行运算之间没有相互依赖关系,最极端的情况下,即便打乱这些运算的顺序,结果也不会发生任何变化。像这样的情况,就能够发挥出超标量的最大性能。然而,如果是下面这样的话: 136 | 137 | {lang="text"} 138 | a = b + c 139 | d = a + e 140 | g = d + h 141 | 142 | 第2行的运算依赖第1行的结果,第3行的运算依赖第2行的结果。这就意味着第1行的运算得出结果之前,无法执行第2行的运算;第2行的运算得出结果之前,也无法执行第3行的运算,也就是说,无法实现同时执行。 143 | 144 | 于是,为了增加能够同时执行的指令数量,可以使用“乱序执行”技术。这个问题的本质在于,一个指令和用于计算它所依赖的结果的指令距离太近。正是由于相互依赖的指令距离太近,才导致CPU没有时间完成相应的准备工作。 145 | 146 | 那么,我们可以在不改变计算结果的范围内,改变指令的执行顺序,这就是乱序执行。乱序执行的英文out of order原本多指“故障”的意思,但这里“order”指的是顺序,也就是命令执行顺序与排列顺序不同的意思。 147 | 148 | 例如: 149 | 150 | {lang="text"} 151 | a = b + c 152 | d = a + e (依赖第1行) 153 | g = d + h (依赖第2行) 154 | j = k + l 155 | m = n + o 156 | 157 | 这样的运算,如果将顺序改为: 158 | 159 | {lang="text"} 160 | a = b + c 161 | j = k + l 162 | d = a + e (依赖第1行) 163 | m = n + o 164 | g = d + h (依赖第3行) 165 | 166 | 就可以填满空闲的执行单元,顺利的话,就能够稀释指令之间的相互依赖关系,从而提高执行效率。 167 | 168 | 为了充分利用流水线带来的好处,出现了一种叫做RISC的CPU架构。RISC是Reduced Instruction Set Computer(精简指令集)的缩写,它具备以下特征: 169 | 170 | * 精简且高度对称的指令集。 171 | * 指令长度完全相同(也有例外)。 172 | * 和传统CPU相比寄存器数量更多。 173 | * 运算的操作数只能为寄存器,内存中的数据需要显式地加载到寄存器中。 174 | 175 | 这样的特征所要达到的目的如下: 176 | 177 | * 通过减少指令种类使电路设计简单化(高速化)。 178 | * 通过统一指令的粒度使流水线更加容易维持。 179 | * 根据依赖关系对指令的重新排序可通过编译器的优化来实现。 180 | 181 | 大约20年之前,RISC架构是非常流行的,其中比较有名的有MIPS和SPARC等。现在,RISC虽然没有被非常广泛的应用,但像智能手机中使用的ARM处理器就属于RISC架构,此外,PlayStation 3等设备中采用的CELL芯片也是RISC架构的。 182 | 183 | 不过,RISC的指令集与传统CPU(与RISC相对的叫做CISC:Complex Instruction Set Computer,复杂指令集)的指令集是完全不同的,它们之间完全不具备兼容性,这也成为了一个问题。过去的软件资产都无法充分利用,这不得不说是一个很大的障碍。 184 | 185 | 于是,最近的x86系CPU中,使用微指令转换技术,在保持传统CISC指令的同时,试图获得一些RISC的优势。这种技术就是在外部依然使用传统x86指令集的同时,在内部将x86指令转换为粒度更小的RISC型指令集来执行。一条x86指令会被转换成多条微指令。通过这种方式,在保持兼容x86指令集的同时,根据软件的实际情况,可以获得除依赖关系控制之外的RISC优势。但即便如此,要填充所有超标量的执行单元,还是十分困难的。 186 | 187 | 那么,为什么执行单元无法被有效填充呢?原因在于数据之间存在相互依赖关系。既然如此,那可以将没有依赖关系的多个执行同时进行吧?这也就是所谓的超线程(Hyper Threading)技术。超线程是英特尔公司的一个专有名词,这一技术的一般名称应该叫做SMT(Simultaneous Multi-Threading,同时多线程),不过为了简便起见,这里统一使用超线程一词。 188 | 189 | 所谓超线程,就是通过同时处理多个取出并执行指令的控制流程,从而将没有相互依赖关系的运算同时送入运算器中,通过这一手段,可以提高超标量的利用效率。实际上,为了同时处理多个控制流程(线程),还需要增加相应的寄存器等资源。 190 | 191 | 超线程是对空闲运算器的一种有效利用,但并不是说可以按线程数量成比例地提高性能。根据英特尔公司发布的数据,超线程最多可提升30%左右的性能。不过,为了实现这30%的性能提升,晶体管数量仅仅增加了5%。用5%的晶体管增加换取30%的性能提升,应该说是一笔划算的交易。 192 | 193 | 除了上述这些以外,还有其他一些提高性能的方法。例如在一块芯片中封装多个核心的多核(multi-core)技术。最近的操作系统中,多进程早已司空见惯,对多核的运用空间也愈发广阔。多核分为两种形式,即包含多个相同种类核心的同构多核(ho-mogeneous multi-core),以及包含多个不同种类核心的异构多核(heterogeneous multi-core)。在异构多核中,除了通常的CPU以外,还可以包含GPU(Graphic Processing Unit,图像处理单元)和视频编码核心等。此外,包含数十个甚至数百个核心的芯片也正在研究,这被称为超多核(many-core)。 194 | 195 | ### 摩尔定律的极限 196 | 197 | 在过去40年里一直不断改变世界的摩尔定律,在今后是否能够继续有效下去,从目前的形势上看并不乐观。出于几个理由,芯片集成度的提高似乎已经接近了极限。 198 | 199 | 第一个极限就是导线宽度。随着半导体制造技术的进步,到2010年时,最小的制程已经达到32nm(纳米,1纳米为1米的10亿分之一)。刚才已经讲过,集成电路是采用一种类似印刷的技术来制造的,即用光照射模板,按照模板上的图案感光并在半导体上形成电路。问题是,电路已经变得过于精密,甚至比感光光源的波长还要小。目前采用的感光光源是紫外激光,而紫外激光的波长为96.5nm。 200 | 201 | 在森林里,阳光透过茂密的树叶在地面上投下的影子会变得模糊,无法分辨出一枚枚单独的树叶。同样,当图案比光的波长还小时,也会发生模糊而无法清晰感光的情况。为了能够印制出比光的波长更细小的电路,人们采用了各种各样的方法,例如在透镜和晶圆之间填充纯水来缩短光的波长等,但这个极限迟早会到来。下一步恐怕会使用波长更短的远紫外线或X射线。但波长太短的话,透镜也就无法使用了,处理起来十分困难。或许可以用反射镜来替代透镜,但曝光机构会变得非常庞大,成本也会上升。 202 | 203 | 其次,即便这样真的能够形成更加细微的电路,还会发生另理而进入量子物理范畴的现象,其中一个例子就是“隧穿效应”。关于隧穿效应的详细知识在这里就省略了(因为我自己也不太明白),简单来说,即便是电流本该无法通过的绝缘体,在微观尺度上也会有少量电子能够穿透并产生微弱的电流。这样的电流被称为渗漏电流,现代CPU中有一半以上的电力都消耗在了渗漏电流上。 204 | 205 | 精密电路中还会产生发热问题。电流在电路中流过就会产生热量,而随着电路的精密化,其热密度(单位面积所产生的热量)也随之上升。现代CPU的热密度已经和电烤炉差不多了,如果不用风扇等进行冷却,恐怕真的可以用来煎蛋了。上面提到的渗漏电流也会转化为热量,因此它也是提升热密度的因素之一。 206 | 207 | 假设电路的精密化还保持和现在一样的速度,恐怕不久的将来就会看到这样的情形——按下开关的一瞬间整个电路就蒸发掉了(如果没有适当的冷却措施的话)。 208 | 209 | 最后,也是最大的一个难题,就是需求的饱和。最近的电脑CPU性能已经显得有些驻足不前。CPU指标中最为人知的应该就是主频了。尽管每个CPU单位频率的性能有所差异,但过去一直快速增长的CPU主频,从几年前开始就在2GHz水平上止步不前,即便是高端CPU也是如此。而过去在Pentium 4时代还能够见到的4GHz级别主频的产品,今天已经销声匿迹了。 210 | 211 | 这是因为,像收发邮件、浏览网页、撰写文稿这些一般大众级别的应用所需要的电脑性能,用低端CPU就完全可以满足,主频竞争的降温与这一现状不无关系。 212 | 213 | 进一步说,过去人们一直习惯于认为CPU的性能是由主频决定的。而现在,在多核等技术的影响下,主频已经不是决定性能的唯一因素了。这也成为主频竞争的必然性日趋下降的一个原因。实际上,以高主频著称的Pentium 4,其单位频率性能却不怎么高,可以说是被主频竞争所扭曲的一代CPU吧。 214 | 215 | 上面介绍的这些对摩尔定律所构成的障碍,依靠各种技术革新来克服它们应该说也并非不可能,只是这样做伴随着一定的成本。从技术革新的角度来看,如果制造出昂贵的CPU也能卖得出去,这样的环境才是理想的。当然,总有一些领域,如3D图形、视频编码、物理计算等,即便再强大的CPU也不够用。但是这样的领域毕竟有限。每年不断高涨的技术革新成本到底该如何筹措,还是应该放弃技术革新从竞争中退出?近年来受到全球经济形势低迷的影响,半导体制造商们也面临着这一艰难的抉择。 216 | 217 | ### 超越极限 218 | 219 | 正如之前讲过的,摩尔定律已经接近极限,这是不争的事实。退一万步说,即使集成电路的精密化真的能够按现有的速度一直演进下去,总有一天一个晶体管会变得比一个原子还小。 220 | 221 | 不过,距离这一终极极限尚且还有一定的余地。现在我们所面临的课题,解决起来的确很有难度,但并没有到达无法克服的地步。 222 | 223 | 首先,关于导线宽度的问题,运用远紫外线和X射线的工艺已经处于研发阶段。由于这些波长极短的光源难以掌控,因此装置会变得更大,成本也会变得更高。但反过来说,我们已经知道这样的做法是行得通的,剩下的事情只要花钱就能够解决了。 224 | 225 | 比较难以解决的是渗漏电流及其所伴随的发热问题。随着半导体工艺技术的改善,对于如何降低渗漏电流,也提出了很多种方案。例如通过在硅晶体中形成二氧化硅绝缘膜来降低渗漏电流的SOI(Silicon On Insulator)等技术。此外,采用硅以外的材料来制造集成电路的技术也正在研究之中,但距离实用化还比较遥远。 226 | 227 | 从现阶段来看,要从根本上解决渗漏电流的问题是很困难的,但是像通过切断空闲核心和电路的供电来抑制耗电量(也就抑制了发热),以及关闭空闲核心并提升剩余核心工作主频(Hy-per Boost)等技术,目前都已经实用化了。 228 | 229 | ### 不再有免费的午餐 230 | 231 | 看了上面的介绍,想必大家已经对摩尔定律,以及随之不断增加的晶体管能够造就何等快速的CPU有了一个大致的了解。现代的CPU中,通过大量晶体管来实现高速化的技术随处可见。 232 | 233 | 然而与此同时,我们印象中的CPU执行模型,与实际CPU内部的处理也已经大相径庭。由条件分支导致的流水线气泡,以及为了克服内存延迟所使用的高速缓存等,从8086时代的印象来看都是难以想象的。 234 | 235 | 而且,什么也不用考虑,随着时间的推移CPU自然会变得越来越快,这样的趋势也快要接近极限了。长期以来,软件开发者一直受到硬件进步的恩惠,即便不进行任何优化,随着计算机的更新换代,同样的价格所能够买到的性能也越来越高。不过,现在即便换了新的计算机,有时也并不能带来直接的性能提升。要想提升性能,则必须要积极运用多核以及CPU的新特性。 236 | 237 | 最近,GPGPU(General Purpose GPU,即将GPU用于图形处理之外的通用编程)受到了越来越多的关注,由于GPU与传统CPU的计算模型有着本质的区别,因此需要采用专门的编程技术。 238 | 239 | 即便什么都不做,CPU也会变得越来越快的时代结束了,今后为了活用新的硬件,软件开发者必须要付出更多的努力——这样的情况,我将其称为“免费午餐的终结”。 240 | 241 | 在未来的软件开发中,如果不能了解CPU的新趋势,就无法提高性能。新的计算设备必然需要新的计算模型,而这样的时代已经到来。 242 | 243 | ## 6.2 UNIX管道 244 | 245 | 诞生于20世纪60年代后半的UNIX,与之前的操作系统相比,具有一些独到的特点,其中之一就是文件的结构。在UNIX之前,大多数操作系统中的文件指的是结构化文件。如果熟悉COBOL的话解释起来会容易一些,所谓结构化文件就是拥有结构的记录的罗列(图1)。 246 | 247 | 但UNIX的设计方针是重视简洁,因此在UNIX中抛弃了对文件本身赋予结构的做法,而是将文件定义为单纯的字节流。对于这些字节流应当如何解释,则交给每个应用程序来负责。文件的内容是文本还是二进制,也并没有任何区别。 248 | 249 | 例如,图1中所示的平面文件(flat file),是采用每行一条记录、记录的成员之间用逗号进行分隔的CSV(Comma Sepa-rated Values)格式来表现数据的。这并不是说UNIX对CSV这种文件格式有特别的规定,而只是相应的应用程序能够对平面文件中存放的CSV数据进行解释而已。 250 | 251 | C>![](images/originals/chapter6/6.jpg) 252 | 253 | C>图1 结构化文件与平面文件 254 | 255 | UNIX的另一个独到之处就是Shell。Shell是UNIX用来和用户进行交互的界面,同时也是能够将命令批处理化的一种语言。 256 | 257 | 在UNIX之前的操作系统中,也有类似的命令管理语言,如JCL(Job Control Language)。但和JCL相比,Shell作为编程语言的功能更加丰富,可以对多个命令进行灵活地组合。如果要重复执行同样的操作,只要将操作过程记录到文件中,就能够很容易地作为程序来执行。像这样由“执行记录”生成的程序,被称为脚本(script),这也是之后脚本语言(script language)这一名称的辞源。 258 | 259 | 在UNIX中至今依然存在script这个命令,这个命令的功能是将用户在Shell中的输入内容记录到文件中,根据所记录的内容可以编写出脚本程序。script一词原本是“剧本”的意思,不过命令行输入是一种即兴的记录,也许叫做improvisation(即兴表演)更加合适。 260 | 261 | 最后一点就是串流管道(stream pipeline)。UNIX进程都具有标准输入和标准输出(还有标准错误输出)等默认的输入输出目标,而Shell在启动命令时可以对这些输入输出目标进行连接和替换。通过这样的方式,就可以将某个命令的输出作为另一个命令的输入,并将输出进一步作为另一个命令的输入,也就是实现了命令的“串联”。 262 | 263 | 在现代的我们看来,这三个特征都已经是司空见惯了的,但可以想象,在UNIX诞生之初,这些特征可是相当创新的。 264 | 265 | ### 管道编程 266 | 267 | 下面我们来看一看运用了串流管道的实际程序。图2是经常被用作MapReduce例题的用于统计文件中单词个数的程序。 268 | 269 | 通过这个程序来读取Ruby的README文件,会输出图3这样的结果。 270 | 271 | {lang="shell"} 272 | tr -c '[:alnum:]' '¥n' | grep -v '^[0-9]*$' | sort | uniq -c | sort -r 273 | 274 | C>图2 单词计数程序 275 | 276 | Shell中,用“|”连接的命令,其标准输出和标准输入会被连接起来形成一个管道。这个程序是由以下5条命令组成的管道。 277 | 278 | {lang="text"} 279 | 1. tr -c '[:alnum:]' '\n' 280 | 2. grep -v '^[0-9]*$' 281 | 3. sort 282 | 4. uniq -c 283 | 5. sort -r 284 | 285 | 下面我们来具体讲解一下每个命令的功能。 286 | 287 | “tr”是translate的缩写,其功能是将输入的数据进行字符替换。tr会将第一个参数所指定的字符集合(这里的[:alnum:]表示字母及数字的意思)用第二个参数所指定的字符进行替换。“-c(complement)”选项的意思是反转匹配,整体来看这条命令的功能就是“将除字母和数字以外的字符替换成换行符”。 288 | 289 | grep命令用来搜索与模板相匹配的行。在这里,模板是通过正则表达式来指定的: 290 | 291 | {lang="text"} 292 | ^[0-9]*$ 293 | 294 | 这里,“^”表示匹配行首,“$”表示匹配行尾,“[0-9]*”表示匹配“0个或多个数字组成的字符串”。结果,这一模板所匹配的是“只有数字的行或者是空行”。 295 | 296 | -v(revert)选项表示反转匹配,也就是显示不匹配的行。因此,这条grep命令的执行结果是“删除空行或者只有数字的行”。 297 | 298 | 之前的tr命令已经将字母和数字之外的字符全部替换成了换行符,也就是说将符号、空格等全部转换成了只有一个换行符的行(即空行)。对空行计数是没有意义的,因此需要忽略这些空行。此外,只有数字的行也不能算是单词,因此也需要忽略。 299 | 300 | 接下来的sort是对行进行重新排序的命令。到这条命令之前,数据流已经被转换成每行一个单词的排列形式,通过sort命令可以对原文中出现的单词按照字母顺序进行排序。这一排序操作看似没什么用,但接下来我们需要用uniq命令去掉重复的行,因此必须事先对输入的数据流进行排序。 301 | 302 | {lang="text"} 303 | 33 ruby 304 | 23 the 305 | 19 to 306 | 16 prefix 307 | 16 DESTDIR 308 | 13 and 309 | 13 Ruby 310 | 11 lib 311 | 11 is 312 | 11 TEENY 313 | 11 MINOR 314 | 11 MAJOR 315 | 7 of 316 | 6 you 317 | 6 org 318 | 6 lang 319 | 6 in 320 | 6 be 321 | 5 not 322 | (中略) 323 | 1 Author 324 | 1 Aug 325 | 1 Advanced 326 | 327 | C>图3 单词计数结果(节选) 328 | 329 | uniq是unique的缩写,该命令可以从已排序的文件中去掉重复的行。-c(count)选项表示在去掉重复行的同时显示重复的行数。在这里我们输入的文件是每行一个单词的形式,因此统计出已排序的单词序列中重复的行数,也就相当于是统计出了单词的数量。uniq命令才是单词计数的本质部分。 330 | 331 | 最后我们用sort -r命令对输出的信息进行整形。uniq命令执行完毕之后,就完成了“统计单词数量”这一任务,但从人类的角度来看,将单词按出现的数量降序排列才是最自然的,因此我们再执行一次sort命令。 332 | 333 | 我们希望在查看统计结果时将出现数量最多的单词(可以认为是比较重要的单词)放在前面,因此这次我们对sort命令加上了-r(reverse)选项,这个选项代表降序排列的意思。这个命令有一个副作用,就是出现数量相同的单词,会被按照字母逆序排列,这一点就请大家多多包涵吧。 334 | 335 | 将单词按出现数量降序排列的同时,还要将出现数相同的单词按字母顺序排列,实现起来是出乎意料地麻烦。这里就当是给各位读者留个思考题吧。其实用Ruby和Awk就可以比较容易地解决这个问题了。 336 | 337 | 像上面这样,将完成一个个简单任务的命令组合起来形成管道,就可以完成各种各样的工作,这就是UNIX范儿的管道编程。 338 | 339 | ### 多核时代的管道 340 | 341 | 在UNIX诞生的20世纪60年代末,多核CPU还不存在,因此管道原本的设计也并非以运用多核为前提。然而,不知是偶然还是必然,管道对于多核的运用却是非常有效的。 342 | 343 | 下面我们来看看在多核环境中,管道的执行是何等高效。 344 | 345 | 首先,我们来思考一下非常原始的单任务操作系统,例如MS-DOS。说是“原始”,但其实MS-DOS相比UNIX来说算是非常年轻的,在这里我们先忽略这一点吧。在MS-DOS中,同时只能有一个进程在工作,因此管道是通过临时文件来实现的。例如,当执行下列管道命令时: 346 | 347 | {lang="shell"} 348 | command-a | command-b 349 | 350 | MS-DOS(准确地说应该是相当于Shell的command.com)会生成一个临时文件,并将“command-a”的输出结果写入文件中。command-a的执行结束之后,再以该临时文件作为输入源来执行“command-b”。由于MS-DOS是一个单任务操作系统,每次只能进行一项处理,当然也就无法对多核进行运用(图4)。 351 | 352 | C>![](images/originals/chapter6/7.jpg) 353 | 354 | C>图4 单任务操作系统的管道 355 | 356 | 接下来我们来思考一下单核环境下的多任务操作系统。在这样的环境下,管道的命令是并行执行的。但由于只有一个核心,因此无法做到完全同时进行。和刚才一样,执行下列命令: 357 | 358 | {lang="shell"} 359 | command-a | command-b 360 | 361 | 这次command-a与command-b是同时启动的。 362 | 363 | 然后,进程会在不断相互切换中各自执行,command-b会进入等待输入的状态。当command-b为读取数据发出系统调用时,如果暂时没有立即可供读取的数据,则操作系统会在数据准备好之前暂停command-b的进程,并使其休眠。 364 | 365 | 另一方面,command-a继续执行,其结果会输出到某个地方。这样一来command-b就有了可供读取的数据,command-b的进程就会被唤醒并恢复执行。 366 | 367 | 像这样,数据输出以接力棒的形式进行运作,多个进程交替工作,就是单核多任务环境中的执行方式(图5)。 368 | 369 | C>![](images/originals/chapter6/8.jpg) 370 | 371 | C>图5 多任务操作系统的管道(单核) 372 | 373 | 和单任务相比,多任务环境下的优势在于没有了无谓的文件输入输出操作,从而削减了相应的开销。 374 | 375 | 而且,由于多个进程是依次执行的,先得出的结果会立即通过管道传递,因此获取结果也会比较快一些。 376 | 377 | 不过,在多任务环境下,进程的切换也需要一定的开销,从总体来看,执行时间也未必会缩短。 378 | 379 | 接下来终于要讲到多核环境下的管道了。简单起见,在这里我们假设将command-a和command-b分别分配给两个不同的核心,在这样的情况下,管道执行如图6所示。 380 | 381 | C>![](images/originals/chapter6/9.jpg) 382 | 383 | C>图6 多任务操作系统的管道(多核) 384 | 385 | 我们可以看出,和图5相比,同时执行的部分增多了。非常粗略地数了一下,图4中需要11步完成的处理,这里只需要8步就完成了。不过我们投入了两个核心,理想状态下应该比单核缩短一半,但这样的理想状态是很难实现的。 386 | 387 | 假设操作系统足够聪明的前提下,只要增加管道的级数,使能够重叠的部分也相应增加,即便不特意去管多个核心的配置,只要自然编写程序形成管道,操作系统就会自动利用多个核心来提高处理能力。之所以说串流管道是非常适合多核的一种编程模型,原因也正是在于此。 388 | 389 | ### xargs——另一种运用核心的方式 390 | 391 | 大家知道xargs这个命令吗?xargs是用于将标准输入转换成命令行参数的命令。 392 | 393 | 例如,要在当前目录下搜索所有文件名中以“~”结尾的文件,需要执行find命令: 394 | 395 | {lang="shell"} 396 | # find . -name '*~' 397 | 398 | 这样就会将符合条件的文件名在标准输出中列出。 399 | 400 | 那么,如果我要将这些文件全部删除的话又该怎么做呢?这时就该轮到xargs命令出场了。 401 | 402 | {lang="shell"} 403 | # find . -name '*~'|xargs rm 404 | 405 | 这样一来,传递到xargs标准输入的文件名列表就作为命令行参数传递给了rm命令,于是就删除了符合条件的所有文件。 406 | 407 | 还有一个很少有人会实际碰到的问题,那就是命令行参数的数量是有上限的,如果传递的参数过多,命令执行就会失败。xargs也考虑了这一点,当参数过多时会分成几条命令分别执行。 408 | 409 | 上面所讲的内容与多核没什么关系,不过xargs提供了一个用于多核的命令行参数“-P”。 410 | 411 | 如图7所示,是用于将当前目录下未压缩的(即扩展名不是.gz的)文件全部进行压缩的管道命令。 412 | 413 | {lang="shell"} 414 | # find . \! -name *.gz -type f -print0 | xargs -null -P 4 -r gzip -9 415 | 416 | C>图7 文件压缩管道命令 417 | 418 | 首先是find命令,它的含义如下: 419 | 420 | * “.”表示当前目录下 421 | * “\! -name *.gz”表示文件名不以.gz结尾 422 | * “-type f”表示一般文件(而不是目录等特殊文件) 423 | * “-print0”表示将符合上述条件的文件名打印到标准输出。为了应对包含空格的文件名,采用null作为分隔符。 424 | 425 | 这样我们就得到了“当前目录下未压缩的文件名列表”。得到该列表之后,xargs命令被执行。xargs命令中的“-P”选项,表示同时启动指定数量的进程,这里我们设定为同时执行4个进程。“-r”选项表示当输入为空时不启动命令,即当不存在符合条件的文件时就表示不用进行压缩,因此我们在这里使用了“-r”选项。 426 | 427 | 为了应付空格,find命令使用了“-print0”选项,相应地,必须同时使用“-null”选项。通过这样的操作,就实现了将要压缩的对象文件名作为参数传递给“gzip -9”命令来执行。 428 | 429 | gzip命令的“-9”选项表示使用较高的压缩率(会花费更多的时间)。 430 | 431 | 我们知道,文件的压缩比单纯的输入输出要更加耗时,而且,多个文件的压缩操作之间没有相互依赖的关系,这些操作是相互独立进行的。对于这样的操作,如果能够分配到多个进程来同时进行,应该说是最适合多核环境的工作方式。 432 | 433 | 在多核环境中,是否对xargs命令使用“-P”选项,直接影响了处理所需要的时间。由于gzip命令的输入输出等操作也需要一定的处理时间,因此-P设定的进程数应该略大于实际的核心数。我用手上的双核电脑进行了测试,用两个核心设定4个进程来执行时,可以获得最高的性能。 434 | 435 | 不过,在我所做的测试中,当文件数量较少时,即便使用了-P选项,也只能启动一个进程,无法充分利用多核。在这种情况下,对xargs命令使用-n选项来设定gzip一次性处理的文件数量,也许是个好主意。 436 | 437 | 例如,如果使用“-n 10”选项,就可以对每10个文件启动一个gzip进程。在我所做的测试中,启动4个进程进行并行压缩时,处理速度可以提高大约40%。理想状态下,两个核心应该可以得到100%的性能提升,因此40%的成绩比我预想的要低。当然,这也说明在实际的处理中,有很大一部分输入输出的开销是无法通过增加核心数量来弥补的。 438 | 439 | ### 注意瓶颈 440 | 441 | 在这里需要注意的是,瓶颈到底发生在哪里。 442 | 443 | 多核环境是将任务分配给多个CPU来提高单位时间处理能力的一种手段。也就是说,只有当CPU能力成为处理瓶颈时,这一手段才能有效改善性能。 444 | 445 | 然而,一般的多核计算机上,尽管搭载了多个CPU,但其他设备,如内存、磁盘、网络设备等是共享的。当处理的瓶颈存在于CPU之外的这些地方时,即便投入多个核心,也丝毫无法改善性能。 446 | 447 | 在这种情况下,我们需要的不仅是多个CPU,而是由多台“计算机”组成的分布式计算环境。分布式计算也是一项相当重要的技术,我们在这里不再过多赘述。 448 | 449 | ### 阿姆达尔定律 450 | 451 | 阿姆达尔定律是一个估算通过多核并行能够获得多少性能提升的经验法则,是由吉恩·阿姆达尔(Gene Amdahl,1922~)提出的,它的内容是: 452 | 453 | (通过并行计算所获得的)系统性能提升效果,会随着无法并行的部分而产生饱和。 454 | 455 | 正如在刚才xargs的示例中所遇到的,即便是多核计算机,一般也只有一个输入输出控制器,而这个部分无法获得并行计算所带来的效果,很容易成为瓶颈。 456 | 457 | 而且,当数据之间存在相互依赖关系时,在所依赖的数据准备好之前,即便有空闲的核心也无法开始工作,这也会成为瓶颈。 458 | 459 | 综上所述,大多数的处理都不具备“只要增加核心就能够提高速度”这一良好的性质,这一点与在CPU内部实现流水线的艰辛似乎存在一定的相似性。 460 | 461 | 根据阿姆达尔定律,并行化之后的速度提升比例可以通过图8的公式来估算。假设N为无穷大,速度的提升最多也只能达到: 462 | 463 | {lang="text"} 464 | 1 / (1 - P) 465 | 466 | 例如,即便在P为90%这一非常理想的情况下,无论如何提高并行程度,整体上最多能够获得的性能提升也无法超过基准的10倍。这是因为,“(1 - P)”所代表的无法并行化的部分成为了瓶颈,使得并行化效果存在极限。 467 | 468 | C>![](images/originals/chapter6/10.jpg) 469 | 470 | C>图8 并行化后速度提升比例的公式 471 | 472 | ### 多核编译 473 | 474 | 像我们这些工程师在用电脑时,最消耗CPU的工作恐怕就是编译了。当然,编译也伴随一定的输入输出操作,但预处理、语法解析、优化、代码生成等操作对于CPU的开销是相当大的。 475 | 476 | 要编译一个文件,首先需要将C语言源文件(*.c)进行预处理(cpp)。cpp会进行头文件(*.h)加载(#include)、宏定义(#define)、宏展开等操作。 477 | 478 | cpp的运行结果被送至编译器主体(ccl)。ccl会进行语句、语法解析和代码优化,并输出汇编文件(*.s)。随后,汇编器会将汇编文件转换为对象文件(*.o),也有些编译器可以不通过汇编器直接输出对象文件。 479 | 480 | 当每个C语言源文件都完成编译,并生成相应的对象文件之后,就可以启动连接器(ld)来生成最终的可执行文件了。连接器会将对象文件与各种库文件(静态链接库*.a和动态链接库*.so)进行连接(某些情况下还会进行一些优化),并输出最终的可执行文件(图9)。 481 | 482 | C>![](images/originals/chapter6/11.jpg) 483 | 484 | C> 图9 C语言编译流程 485 | 486 | UNIX的“make”工具中提供了一个正好可以用于多核的选项——“-j(jobs)”,通过这个选项可以设定同时执行的进程数量。例如: 487 | 488 | {lang="shell"} 489 | # make -j4 490 | 491 | 就表示用4个线程进行并行编译。从过去的经验来看,-j的设置应该略大于实际的核心数量为佳。 492 | 493 | ### ccache 494 | 495 | 我们先放下多核的话题,说点别的。有一个叫做ccache的工具,可以有效提高编译的速度。ccache是通过将编译结果进行缓存,来减少再次编译的工作量,从而提高编译速度的。 496 | 497 | 使用方法很简单,编译时在编译器名称前面加上ccache即可。例如: 498 | 499 | {lang="shell"} 500 | # CC='ccache gcc' make -j4 501 | 502 | 这样就可以让再次编译时所需的时间大幅缩短。每次都指定的话比较麻烦,也可以一开始就写到Makefile中。 503 | 504 | 当源代码或者其所依赖的头文件等发生修改时,make会重新执行编译。不过,源文件中也有很多行是和实际变更的部分无关的,而ccache会将(以函数为单位的)编译结果保存在主目录下的“.ccache”目录中,然后,在实际执行编译之前,与过去的编译结果进行比较,如果源代码的相应部分没有发生修改,则直接使用过去的编译结果。 505 | 506 | 在CPU中,缓存是高速化的一个重要手段,而在改善编译速度的场景中,也可以应用缓存技术。像这样,类似的手段出现在各种不同的场景中,的确是很有意思的事情。 507 | 508 | ### distcc 509 | 510 | 还有其他一些改善编译速度的方法,例如distcc就是一种利用多台计算机来改善编译速度的工具。 511 | 512 | 和ccache一样,只要在编译器名称前面加上distcc就可以改善编译性能了。不过在此之前,需要先配置好用哪些计算机来执行编译操作。在下列配置文件中: 513 | 514 | {lang="shell"} 515 | # ~/.distcc/hosts 516 | 517 | 填写用于执行编译的主机名(多个主机名之间用逗号分隔)。 518 | 519 | 当然,并不是随便填哪台主机都可以的。基本上,用于执行编译的主机应该是启动了distccd服务的主机,或者是可以通过ssh来登录的主机才行。启动distccd服务的主机直接填写主机名,可在ssh登录的主机前面加上一个“@”。当登录的用户名和本机不同时,需要在@前面写上用户名。 520 | 521 | 通过ssh来执行会提高安全性(distccd没有认证机制),但由于加密等带来的开销,编译性能会下降25%左右,因此用户需要在性能、安全性和易用性之间做出选择。 522 | 523 | 准备妥当之后,执行: 524 | 525 | {lang="shell"} 526 | # CC='distcc gcc' make -j4 527 | 528 | 就可以实现分布式编译了。 529 | 530 | distcc的伟大之处在于,虽然是分布式编译,但无需拥有所有的头文件和库文件等完整的环境,只要(在同一个CPU下)安装了编译器,并能够运行ssh的主机,就可以很容易地实现分布式编译。之所以能够实现这一点,秘密在于预处理器和连接器是在本地执行的,而发送给远程主机的是已经完成预处理的文件。 531 | 532 | 编译性能测试 533 | 534 | 那么,通过使用上述这些手段,到底能够对编译性能带来多大的改善呢?我们来实际测试一下。 535 | 536 | 表1显示了运用各种手段后的测试结果,其中编译的对象是最新版的Ruby解释器。用于执行编译的是我那台有些古老的爱机——ThinkPad X61 Core2 duo 2.2GHz(双核)。distcc分布式编译使用的是一台Quad-Core AMD Opteron 2.4GHz(四核)的计算机。 537 | 538 | C>表1 编译性能测试 539 | 540 | |编译条件|编译时间(秒)| 541 | |仅gcc -j|118.464| 542 | |仅gcc -j|210.611| 543 | |仅gcc -j|410.823| 544 | |仅gcc -j|811.006| 545 | |ccache -j|120.874| 546 | |ccache -j1(第2次)|0.454| 547 | |distcc -j2|11.649| 548 | |distcc -j4|7.138| 549 | |distcc -j8|7.548| 550 | 551 | 使用未经过任何优化的gcc进行编译时,整个编译过程需要约18.5秒。使用make的-j选项启动多个进程时,由于充分利用了两个核心,使得速度提高了40%以上。 552 | 553 | ccache首次执行时比通常情况还要慢一点,但由于编译结果被缓存起来,在删除对象文件之后,用完全相同的条件再次编译时,由于完全不需要执行实际的编译操作,只需要取出缓存的内容就可以完成处理,因此编译速度快得惊人。 554 | 555 | distcc的测试中只用了一台主机,在make -j2的情况下,由于ssh的开销较大,因此和本地执行相比性能改善不大,但如果设置更多的进程数量,执行时间就可以大大缩短。 556 | 557 | ### 小结 558 | 559 | 阿姆达尔定律指出,并行性是存在极限的,因此只靠多核无法解决所有的问题。但是大家应该能够看出,只要配合适当的编程技巧,还是能够比较容易地获得很好的效果。可以说,多核在将来还是颇有前途的。 560 | 561 | ## 6.3 非阻塞I/O 562 | 563 | 在需要处理大量连接的服务器上,如果使用线程的话,内存负荷和线程切换的开销都会变得非常巨大。因此,监听“有输入进来”等事件并进行应对处理,采用单线程来实现会更加高效。像这样通过“事件及应对处理”的方式来工作的软件架构,被称为事件驱动模型(event driven model)。 564 | 565 | 这种模型虽然可以提高效率,但也有缺点。在采用单线程来进行处理的情况下,当事件处理过程中由于某些原因需要进行等待时,程序整体就会停止运行。这也就意味着即便产生了新的事件,也无法进行应对了。 566 | 567 | 像这样处理发生停滞的情况被称为阻塞。阻塞多半会在等待输入输出的时候发生。对于事件驱动型程序来说,阻塞是应当极力避免的。 568 | 569 | ### 何为非阻塞I/O 570 | 571 | 由于大部分输入输出操作都免不了会遇到阻塞,因此在输入输出时需要尤其注意。输入输出操作速度并不快,因此需要进行缓冲。当数据到达缓冲区时,读取操作只需要从缓冲区中将数据复制出来就可以了。 572 | 573 | 在缓冲机制中,有两种情况会产生等待。一种是当缓冲区为空时,需要等待数据到达缓冲区(读取时);另一种是在缓冲区已满时,需要等待缓冲区腾出空间(写入时)(图1)。这两种“等待”就相当于程序停止工作的“阻塞”状态。 574 | 575 | 尤其是在输入(读取)时,如果在数据到达前试图执行读取操作,就会一直等待数据的到达,这样肯定会发生阻塞。 576 | 577 | 相比之下,输出时由于磁盘写入、网络传输等因素,也有可能会发生阻塞,但发生的概率并不高。而且即便发生了阻塞,等待时间也相对较短,因此不必过于在意。 578 | 579 | 要实现非阻塞的读取操作,有下列几种方法。 580 | 581 | * 使用read(2)的方法 582 | * 使用read(2)+select的方法 583 | * 使用read(2)+O_NONBLOCK标志的方法 584 | * 使用aio_read的方法 585 | * 使用信号驱动I/O的方法 586 | 587 | 这些方法各有各的优缺点,我们来逐一讲解一下。 588 | 589 | C>![](images/originals/chapter6/12.jpg) 590 | 591 | C>图1 输入输出中发生阻塞的原因 592 | 593 | ### 使用read(2)的方法 594 | 595 | 首先,我们先来确定示例程序的结构。在这里,我们只写出了程序中实际负责读取处理的回调部分。 596 | 597 | 我们将回调函数命名为callback,它的参数用于读取的文件描述符(int fd)和注册回调函数时指定的指针(void *data)(图2)。关于输出,我们再设置一个output函数。 598 | 599 | {lang="c"} 600 | int 601 | callback(int fd, void *data) 602 | { 603 | .... 604 | /* 返回值成功为1 */ 605 | /* 到达EOF为0 */ 606 | /* 失败为-1 */ 607 | } 608 | 609 | void 610 | output(int fd, const char *p, intlen) 611 | { 612 | .... 613 | } 614 | 615 | C>图2 回调函数与输出函数 616 | 617 | 在实际的程序中,需要在事件循环内使用选择可读写文件描述符的“select系统调用”和“epoll”等,对文件描述符进行监视,并对数据到达的文件描述符调用相应的回调函数。 618 | 619 | 我们先来看看只使用read系统调用的实现方法。对了,所谓read(2),是UNIX中广泛使用的一种记法,代表“手册显示命令man的第2节中的read”的意思。由于第2节是系统调用,因此可以认为read(2)相当于“read系统调用”的缩写。 620 | 621 | 只使用read(2)构建的回调函数如图3所示。 622 | 623 | 624 | {lang="c"} 625 | void 626 | callback(int fd, void *data) 627 | { 628 | char buf[BUFSIZ]; 629 | int n; 630 | 631 | n = read(fd, buf, BUFSIZ); 632 | if (n < 0) return -1; /* 失败 */ 633 | if (n == 0) return 0; /* EOF */ 634 | output(fd, buf, n); /* 写入 */ 635 | return 1; /* 成功 */ 636 | } 637 | 638 | C>图3 用read(2)实现的输入操作(ver.1) 639 | 640 | 程序非常简单。当这个回调函数被调用时,显然输入数据已经到达了,因此只要调用read系统调用,将积累在输入缓冲区中的数据复制到buf中即可。当输入数据到达时,read系统调用不会发生阻塞。 641 | 642 | read系统调用的功能是:①失败时返回负数;②到达EOF时返回0;③读取成功时返回读取的数据长度。只要明白了这些,就很容易理解图2中程序的行为了吧。小菜一碟。 643 | 644 | 不过,这样简单的实现版本中必然隐藏着问题,你发现了吗?这个回调函数正确工作的前提是,输入数据的长度要小于BUF-SIZ(C语言标准IO库中定义的一个常量,值貌似是8192)。 645 | 646 | 但是,在通信中所使用的数据长度一般都不是固定的,某些情况下需要读取的数据长度可能会超过BUFSIZ。于是,能够支持读取长度超过BUFSIZ数据的版本如图4所示。 647 | 648 | {lang="ruby"} 649 | void 650 | callback(int fd, void *data) 651 | { 652 | char buf[BUFSIZ]; 653 | int n; 654 | 655 | for (;;) { 656 | n = read(fd, buf, BUFSIZ); 657 | if (n < 0) return -1; /* 失败 */ 658 | if (n == 0) return 0; /* EOF */ 659 | output(fd, buf, n); /* 写入 */ 660 | if (n < BUFSIZ) break; /* 读取完毕,退出 */ 661 | } 662 | 663 | return 1; /* 成功 */ 664 | } 665 | 666 | C>图4 用read(2)实现的输入操作(ver.2) 667 | 668 | 在版本2中,当读取到的数据长度小于BUFSIZ时,也就是当输入缓冲区中的数据已经全部读取出来的时候,程序结束。当读取到的数据长度等于BUFSIZ时,则表示缓冲区中还可能有残留的数据,因而可通过反复循环,直到读取完毕为止。 669 | 670 | 问题都解决了吗?还没有,事情可没那么简单。当输入的数据长度正好等于BUFSIZ时,这个程序会发生阻塞。我们说过,避免阻塞对于回调函数来说是非常重要的,因此这个程序还无法实际使用,我们还需要进行一些改进。 671 | 672 | ### 边沿触发与电平触发 673 | 674 | 好了,接下来我要宣布一件重要的事。我们刚才说图3的程序只能支持读取长度小于BUFSIZ的数据,但其实只要将读取的数据直接输出,它还是可以正常工作的,而且不会发生阻塞。不过,要实现这一点,负责事件监视的部分需要满足一定的条件。 675 | 676 | 在事件监视中,对事件的检测方法有两种,即边沿触发(edgetrigger)和电平触发(level trigger)。这两个词原本是用在机械控制领域中的,边沿触发是指只在状态变化的瞬间发出通知,而电平触发是指在状态发生变化的整个过程中都持续发出通知(图5)。 677 | 678 | C>![](images/originals/chapter6/13.jpg) 679 | 680 | C>图5 边沿触发与电平触发 681 | 682 | select系统调用属于电平触发,epoll默认也是电平触发,但epoll也可以通过显式设置来实现边沿触发。 683 | 684 | 具体来说,是在epoll_event结构体的events字段通过EPOLLET标志来进行设置的。 685 | 686 | 要让图3的程序在不阻塞的状态下工作,事件监视就必须采用电平触发的方式。也就是说,在调用回调函数执行输入操作之后,如果读取缓冲区中还有残留的数据,在电平触发的方式下,就会再次调用回调函数来进行读取操作。 687 | 688 | 那么,采用电平触发就足够了吗?边沿触发的存在还有什么意义呢?由于边沿触发只在数据到达的瞬间产生事件,因此总体来看事件发生的次数会比较少,这也就意味着回调函数的启动次数也会比较少,可以提高效率。 689 | 690 | ### 使用read(2) + select的方法 691 | 692 | 刚才已经讲过,图3版本的程序,会将输入缓冲区中积累的数据全部读取出来,而当输入缓冲区为空时,调用read系统调用就会发生阻塞。为了避免这个问题,需要在调用read之前检查输入缓冲区是否为空。 693 | 694 | 下面,我们来创建一个checking_read函数。它先调用read系统调用,然后通过select系统调用检查输入缓冲区中是否有数据(图6)。为了判断是否有剩余数据,checking_read比read增加了一个参数。调用checking_read来代替read,如果参数cont的值为真,就表示输入缓冲区中还有剩余的数据。 695 | 696 | 用这种方法,在边沿触发的方式下也可以正常工作。边沿触发的好处就是能够减少事件发生的次数,但相对地,select系统调用的调用次数却增加了。此外,在每次调用read系统调用时,还要问一下“还有剩下的数据吗”,总让人感觉怪怪的。 697 | 698 | {lang="c"} 699 | #include 700 | #include 701 | 702 | int 703 | checking_read(int fd, char *buf, int len, int *cont) 704 | { 705 | int n; 706 | *cont = 0; /* 初始化 */ 707 | n = read(fd, buf, len); /* 调用read(2) */ 708 | if (n > 0) { /* 读取成功 */ 709 | fd_set fds; 710 | struct timeval tv; 711 | int c; 712 | 713 | FD_ZERO(&fds); /* 准备调用select(2) */ 714 | FD_SET(fd, &fds); 715 | tv.tv_sec = 0; /* 不会阻塞 */ 716 | tv.tv_usec = 0; 717 | c = select(fd+1, &fds, NULL, NULL, &tv); 718 | if (c == 1) { /* 返回值为1=缓冲区不为空 */ 719 | *cont = 1; /* 设置继续标志 */ 720 | } 721 | } 722 | return n; 723 | } 724 | 725 | void 726 | callback(int fd, void *data) 727 | { 728 | char buf[BUFSIZ]; 729 | int n, cont; 730 | 731 | for (;;) { 732 | n = checking_read(fd, buf, BUFSIZ, &cont); 733 | if (n < 0) return -1; /* 失败 */ 734 | if (n == 0) return 0; /* EOF */ 735 | output(fd, buf, n); /* 写入 */ 736 | if (!cont) continue; /* 读取完毕,退出 */ 737 | } 738 | return 1; /* 成功 */ 739 | } 740 | 741 | C>图6 用read(2)实现的输入操作(ver.3) 742 | 743 | ### 使用read+O_NONBLOCK标志 744 | 745 | 毕竟read系统调用可以直接接触输入缓冲区,那么理所当然地,在读取数据之后它应该知道缓冲区中是否还有剩余的内容。那么,能不能实现“调用read,当会发生阻塞时通知我一下”这样的功能呢? 746 | 747 | 当然可以。只要在文件描述符中设置一个O_NONBLOCK标志,当输入输出中要发生阻塞时,系统调用就会产生一个“继续执行的话会发生阻塞”的错误消息提示,这个功能在UNIX系操作系统中是具备的。使用O_NONBLOCK的版本如图7所示。 748 | 749 | {lang="c"} 750 | #include 751 | #include 752 | 753 | /* (a) 初始化程序的某个地方 */ 754 | inf fl; 755 | 756 | fl = fcntl(fd, F_GETFL); 757 | fcntl(fd, F_SETFL, fl|O_NONBLOCK); 758 | /* 到此为止 */ 759 | void 760 | callback(int fd, void *data) 761 | { 762 | char buf[BUFSIZ]; 763 | int n; 764 | 765 | for (;;) { 766 | n = read(fd, buf, BUFSIZ); 767 | if (n < 0) { 768 | if (errno == EAGAIN) { /* EAGAIN=缓冲区为空 */ 769 | return 1; /* 读取操作结束 */ 770 | } 771 | return -1; /* 失败 */ 772 | } 773 | if (n == 0) return 0; /* EOF */ 774 | output(fd, buf, n); /* 写入 */ 775 | } 776 | } 777 | 778 | C>图7 用read(2)实现的输入操作(ver.4) 779 | 780 | 怎么样?由于这次我们能够从本来拥有信息的read直接收到通知,整体上看比图6的版本要简洁了许多。 781 | 782 | 这个功能仅在对文件描述符设置了O_NONBLOCK标志时才会有效,因此在对文件描述符进行初始化操作时,不要忘记使用图7(a)中的代码对标志进行设置。 783 | 784 | 这种方法效率高、代码简洁,可以说非常优秀,但有一点需要注意,那就是大多数输入输出程序在编写时都没有考虑到文件描述符设置了O_NONBLOCK标志的情况。 785 | 786 | 当设置了O_NONBLOCK标志的文件描述符有可能发生阻塞时,会返回一个错误,而不会发生实质上的阻塞。一般的输入输出程序都没有预想到这种行为,因此发生这样的错误就会被认为是读取失败,从而引发输入输出操作的整体失败。 787 | 788 | 使用O_NONBLOCK标志时,一定要注意文件描述符的使用。O_NONBLOCK标志会继承给子进程,因此在使用fork的时候要尤其注意。以前曾经遇到过这样的bug:①对标准输入设置了O_NONBLOCK;②用system启动命令;③命令不支持O_NONBLOCK,导致诡异的错误。那时,由于忘记了子进程会继承O_NONBLOCK标志这件事,结果花了大量的时间才找到错误的原因。 789 | 790 | ### Ruby的非阻塞I/O 791 | 792 | 刚才我们对C语言中的非阻塞I/O进行了介绍,下面我们来简单介绍一下Ruby的非阻塞I/O。 793 | 794 | Ruby从1.8.7版本开始提供这里介绍过的两个实现非阻塞I/O的方法,即read_partial和read_nonblock。read_partial方法可以将当前输入缓冲区中的数据全部读取出来。 795 | 796 | read_partial可以指定读取数据的最大长度,其使用方法是: 797 | 798 | {lang="ruby"} 799 | str = io.read_partial(1024) 800 | 801 | read_partial基本上不会发生阻塞,但若输入缓冲区为空且没有读取到EOF时会发生阻塞。也就是说,仅在一开始原本就没有数据到达的情况下会发生阻塞。 802 | 803 | 换句话说,只要是通过事件所触发的回调中,使用read_partial是肯定不会发生阻塞的,因此read_partial在实现上相当于read+select的组合。 804 | 805 | 将图6的程序改用Ruby进行的实现如图8所示。不过,和图6的程序不同的是,当数据大于指定的最大长度时不会循环读取。在读取的数据长度等于最大长度时,如果循环调用read_partial就有可能会发生阻塞。这真是个难题。 806 | 807 | {lang="ruby"} 808 | def callback(io, data) 809 | input = io.read_partial(4096) 810 | output(io, input) 811 | end 812 | 813 | C>图8 使用read_partial的示例 814 | 815 | 相对地,read_nonblock则相当于read+O_NONBLOCK的组合。read_nonblock会对io设置O_NONBLOCK并调用read系统调用。read_nonblock在可能要发生阻塞时,会返回IO::WaitReadable模块所包含的异常。 816 | 817 | read_nonblock的参数和read_partial是相同的。我们将图7的程序用read_nonblock改写成Ruby程序(图9)。和C语言的版本相比,Ruby版显得更简洁,而且read_nonblock会自动设置O_NONBLOCK标志,因此不需要进行特别的初始化操作。 818 | 819 | {lang="ruby"} 820 | def callback(io, data) 821 | loop do 822 | begin 823 | input = io.read_nonblock(4096) 824 | output(io, input) 825 | rescue IO::WaitReadable 826 | # 缓冲区为空了,结束 827 | return 828 | end 829 | end 830 | end 831 | 832 | C>图9 使用read_nonblock的例子 833 | 834 | ### 使用aio_read的方法 835 | 836 | POSIX提供了用于异步I/O的“aio_XXXX”函数集(表1)。例如,aio_read用于以异步方式实现和read系统调用相同的功能。这里的aio就是异步I/O(Asynchronous I/O)的缩写。 837 | 838 | C>表1 异步I/O函数 839 | 840 | |名称|功能| 841 | |aio_read|异步read| 842 | |aio_write|异步write| 843 | |aio_fsync|异步fsync| 844 | |aio_error|获取错误状态| 845 | |aio_return|获取返回值| 846 | |aio_cancel|请求取消| 847 | |aio_suspend|请求等待| 848 | 849 | aio函数集的功能,是将通常情况下会发生阻塞的系统调用(read、write、fsync)在后台进行执行。这些函数只负责发出执行系统调用的请求,因此肯定不会发生阻塞。 850 | 851 | 运用aio_read的最简单的示例程序如图10所示,它的功能非常简单: 852 | 853 | * 打开文件 854 | * 用aio_read发出读取请求 855 | * 用aio_suspend等待执行结束 856 | * 或者用aio_error检查执行结束 857 | * 用aio_return获取返回值 858 | 859 | 下面我们来看看程序的具体内容。 860 | 861 | {lang="c"} 862 | 1 /* 异步I/O所需头文件 */ 863 | 2 #include 864 | 3 865 | 4 /* 其他头文件 */ 866 | 5 #include 867 | 6 #include 868 | 7 #include 869 | 8 #include 870 | 9 871 | 10 int 872 | 11 main() 873 | 12 { 874 | 13 struct aiocb cb; 875 | 14 const struct aiocb *cblist[1]; 876 | 15 char buf[BUFSIZ]; 877 | 16 int fd, n; 878 | 17 879 | 18 /* 准备文件描述符 */ 880 | 19 fd = open("/tmp/a.c", O_RDONLY); 881 | 20 882 | 21 /* 初始化控制块结构体 */ 883 | 22 memset(&cb, 0, sizeof(struct aiocb)); /* 清空 */ 884 | 23 cb.aio_fildes = fd; /* 设置fd */ 885 | 24 cb.aio_buf = buf; /* 设置buf */ 886 | 25 cb.aio_nbytes = BUFSIZ; /* 设置buf长度 */ 887 | 26 888 | 27 n = aio_read(&cb); /* 请求 */ 889 | 28 if (n < 0) perror("aio_read"); /* 请求失败检查 */ 890 | 29 891 | 30 #if 1 892 | 31 /* 使用aio_suspend检查请求完成 */ 893 | 32 cblist[0] = &cb; 894 | 33 n = aio_suspend(cblist, 1, NULL); 895 | 34 #else 1 896 | 35 /* 使用aio_error也能检查执行完成情况 */ 897 | 36 /* 未完成时返回EINPROGRESS */ 898 | 37 while (aio_error(&cb) == EINPROGRESS) 899 | 38 printf("retry\n"); 900 | 39 #endif 901 | 40 902 | 41 /* 执行完成,取出系统调用的返回值 */ 903 | 42 n = aio_return(&cb); 904 | 43 if (n < 0) perror("aio_return"); 905 | 44 906 | 45 /* 读取的数据保存在aio_buf中 */ 907 | 46 printf("%d %d ---\n%.*s", n, cb.aio_nbytes, cb.aio_nbytes, cb.aio_buf); 908 | 47 return 0; 909 | 48 } 910 | 911 | C>图10 异步I/O示例 912 | 913 | 第19行将文件open并准备文件描述符。不过,这只是一个例子,并没有什么意义,因为实际的异步I/O往往是以套接字为对象的。根据我查到的资料来看,像HP-UX等系统中,aio_read甚至是只支持套接字的。 914 | 915 | 从第22行开始对作为aio_read等的参数使用的控制块(aiocb)结构体进行初始化操作。read系统调用有3个参数:文件描述符、读取缓冲区、缓冲区长度,但aio_read则是将上述这些参数分别通过aiocb结构体的aio_fildes、aio_buf、aio_nbytes这3个成员来进行设置。aiocb结构体中还有其他一些成员,保险起见我们用memset将它们都初始化为0(第22行)。 916 | 917 | 随后,我们使用aiocb结构体,通过aio_read函数预约执行read系统调用(第27行)。aio_read只是提交一个请求,而不会等待读取过程的结束。对实际读取到的数据所做的处理,是在读取结束之后才进行的。 918 | 919 | 在这里我们使用aio_suspend执行挂起,直到所提交的任意一个请求执行完毕位置(第33行)。不过话说回来,我们也就提交了一个请求而已。 920 | 921 | 对请求执行完毕的检查也可以使用aio_error来实现。使用提交请求的aiocb结构体来调用aio_error函数,如果请求未完成则返回EINPROGRESS,成功完成则返回0,发生其他错误则返回相应的errno值。在这里,有一段对预处理器标明不执行的代码(34~39行),这段代码使用aio_error用循环来检查请求是否完成。这是一个忙循环(busy loop),会造成无谓的CPU消耗,因此在实际的代码中是不应该使用的。 922 | 923 | 读取请求完成之后,就该对读取到的数据进行处理了(42~46行)。read系统调用的返回值可以通过aio_return函数来获取。此外,读取到的数据会被保存到aiocb结构体的aio_buf成员所指向的数组中。 924 | 925 | 图10的程序中是使用aio_suspend和aio_error来检查请求是否完成的,其实异步I/O也提供了在读取完成时调用回调函数的功能。在回调函数的调用上,有信号和线程两种方式,下面(为了简单起见)我们来介绍使用线程进行回调的方式(图11)。 926 | 927 | 图11的程序和图10的程序基本上是相同的,不同点在于,回调函数的指定(48~50行)、回调函数(10~31行)以及叫处理转交给回调函数并停止线程的select(第56行)。 928 | 929 | {lang="c"} 930 | 1 /* 异步I/O所需头文件 */ 931 | 2 #include 932 | 3 933 | 4 /* 其他头文件 */ 934 | 5 #include 935 | 6 #include 936 | 7 #include 937 | 8 #include 938 | 9 939 | 10 static void 940 | 11 read_done(sigval_t sigval) 941 | 12 { 942 | 13 struct aiocb *cb; 943 | 14 int n; 944 | 15 945 | 16 cb = (struct aiocb*)sigval.sival_ptr; 946 | 17 947 | 18 /* 检查请求的错误状态 */ 948 | 19 if (aio_error(cb) == 0) { 949 | 20 /* 获取已完成的系统调用的返回值 */ 950 | 21 n = aio_return(cb); 951 | 22 if (n < 0) perror("aio_return"); 952 | 23 953 | 24 /* 读取到的数据存放在aio_buf中 */ 954 | 25 printf("%d %d ---\n%.*s", n, cb->aio_nbytes, cb->aio_nbytes, cb->aio_buf); 955 | 26 956 | 27 /* 示例到此结束 */ 957 | 28 exit(0); 958 | 29 } 959 | 30 return; 960 | 31 } 961 | 32 962 | 33 int 963 | 34 main() 964 | 35 { 965 | 36 struct aiocb cb; 966 | 37 char buf[BUFSIZ]; 967 | 38 int fd, n; 968 | 39 969 | 40 /* 准备文件描述符 */ 970 | 41 fd = open("/tmp/a.c", O_RDONLY); 971 | 42 972 | 43 /* 初始化控制块结构体 */ 973 | 44 memset(&cb, 0, sizeof(struct aiocb)); /* 清空 */ 974 | 45 cb.aio_fildes = fd; /* 设置fd */ 975 | 46 cb.aio_buf = buf; /* 设置buf */ 976 | 47 cb.aio_nbytes = BUFSIZ; /* 设置buf长度 */ 977 | 48 cb.aio_sigevent.sigev_notify = SIGEV_THREAD; 978 | 49 cb.aio_sigevent.sigev_notify_function = read_done; 979 | 50 cb.aio_sigevent.sigev_value.sival_ptr = &cb; 980 | 51 981 | 52 n = aio_read(&cb); /* 请求 */ 982 | 53 if (n < 0) perror("aio_read"); /* 请求失败检查 */ 983 | 54 984 | 55 /* 停止线程,处理转交给回调函数 */ 985 | 56 select(0, NULL, NULL, NULL, NULL); 986 | 57 return 0; 987 | 58 } 988 | 989 | C>图11 异步I/O示例(回调) 990 | 991 | {lang="c"} 992 | /* 异步I/O所需头文件 */ 993 | #include 994 | /* 其他头文件 */ 995 | #include 996 | #include 997 | #include 998 | #include 999 | #include 1000 | 1001 | static void 1002 | read_done(sigval_t sigval) 1003 | { 1004 | struct aiocb *cb; 1005 | int n; 1006 | 1007 | cb = (struct aiocb*)sigval.sival_ptr; 1008 | 1009 | if (aio_error(cb) == 0) { 1010 | /* 获取完成的系统调用的返回值 */ 1011 | n = aio_return(cb); 1012 | if (n == 0) { /* EOF */ 1013 | printf("client %d gone\n", cb->aio_fildes); 1014 | aio_cancel(cb->aio_fildes, cb); /* 取消提交的请求 */ 1015 | close(cb->aio_fildes); /* 关闭fd */ 1016 | free(cb); /* 释放cb结构体 */ 1017 | return; 1018 | } 1019 | printf("client %d (%d)\n", cb->aio_fildes, n); 1020 | /* 直接写回 */ 1021 | /* 读取到的数据存放在aio_buf中 */ 1022 | /* 严格来说write也可能阻塞,但这里我们先忽略这一点 */ 1023 | write(cb->aio_fildes, (void*)cb->aio_buf, n); 1024 | aio_read(cb); 1025 | } 1026 | else { /* 错误 */ 1027 | perror("aio_return"); 1028 | return; 1029 | } 1030 | return; 1031 | } 1032 | 1033 | static void 1034 | register_read(int fd) 1035 | { 1036 | struct aiocb *cb; 1037 | char *buf; 1038 | 1039 | printf("client %d registered\n", fd); 1040 | cb = malloc(sizeof(struct aiocb)); 1041 | buf = malloc(BUFSIZ); 1042 | 1043 | /* 初始化控制块结构体 */ 1044 | memset(cb, 0, sizeof(struct aiocb)); /* 清空 */ 1045 | cb->aio_fildes = fd; /* 设置fd */ 1046 | cb->aio_buf = buf; /* 设置buf */ 1047 | cb->aio_nbytes = BUFSIZ; /* 设置buf长度 */ 1048 | cb->aio_sigevent.sigev_notify = SIGEV_THREAD; 1049 | cb->aio_sigevent.sigev_notify_function = read_done; 1050 | cb->aio_sigevent.sigev_value.sival_ptr = cb; 1051 | /* 提交请求 */ 1052 | aio_read(cb); 1053 | } 1054 | 1055 | int 1056 | main(){ 1057 | struct sockaddr_in addr; 1058 | int s = socket(PF_INET, SOCK_STREAM, 0); 1059 | 1060 | addr.sin_family = AF_INET; 1061 | addr.sin_addr.s_addr = INADDR_ANY; 1062 | addr.sin_port = htons(9989); 1063 | if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) < 0) { 1064 | perror("bind"); 1065 | exit(1); 1066 | } 1067 | listen(s, 5); 1068 | 1069 | for (;;) { 1070 | /* 接受来自客户端套接字的连接 */ 1071 | int c = accept(s, NULL, 0); 1072 | if (c < 0) continue; 1073 | 1074 | /* 开始监听客户端套接字 */ 1075 | register_read(c); 1076 | } 1077 | } 1078 | 1079 | C>图12 使用aio_read的echo服务器(节选) 1080 | 1081 | 由于这里我们只需要对回调函数的调用进行一次等待,因此在初始化完毕后用select进行等待,在回调函数中则使用exit。实际的事件驱动服务器程序中,主程序应该是“接受客户端连接并注册回调函数”这样一个循环。此外,在回调函数的最后也不应该用exit,而是通过aio_read再次提交read请求,用于再次读取数据。 1082 | 1083 | 如果用SIGEV_THREAD设置回调函数并调用aio_read,从系统内部来看,实际上是用多个线程来实现异步I/O。也就是说,回调函数是在独立于主线程的另一个单独的线程中执行的。因此,一旦使用了SIGEV_THREAD,在回调函数中就不能调用非线程安全的函数。 1084 | 1085 | 只要是会修改全局状态的函数,都是非线程安全的。POSIX标准库函数中也有很多是非线程安全的。如果在回调函数中直接或间接地调用了这些函数,就有可能引发意想不到的问题。 1086 | 1087 | SIGEV_THREAD是用线程来实现回调的,但并不是所有的输入处理都会使用独立的线程,因此不必担心线程数量会出现爆发性地增长。 1088 | 1089 | 最后,为了让大家对如何用异步I/O来编写事件驱动型程序有一个直观的印象,在图12中展示了一个使用了aio_read等手段实现的echo服务器的代码节选。通过上面的讲解,大家是否对不使用libev等手段的情况下,如何实现事件驱动编程有了一些了解呢? 1090 | 1091 | Linux中也提供了io_getevents等其他的异步I/O函数,这些函数的性能应该说更好一些。不过,它们都是Linux专用的,而且其事件响应只能支持通常文件,使用起来制约比较大,因此本书中的介绍,还是以POSIX中定义的,在各种平台上都能够使用的aio_read为主。 1092 | 1093 | ## 6.4 node.js 1094 | 1095 | 1990年,我大学毕业后进入软件开发公司工作,到现在已经有20年了,不由得感叹日月如梭。在这20年间,我从事软件开发工作的感受,就是无论是软件开发还是其他工作,最重要的就是提高效率。工作就像一座大山一样压在你面前,不解决掉它你就没饭吃。然而,自己所拥有的能力和时间都是有限的,要想在规定期限内解决所有的问题非常困难。 1096 | 1097 | 话说回来,在这20年的工作生涯中,我几乎没有开发过供客户直接使用的软件,这作为程序员似乎挺奇葩的。不过,我依然是一名程序员。从软件开发中,程序员能够学到很多丰富人生的东西。我从软件开发中学会了如何提高效率,作为应用,总结出了下面几个方法: 1098 | 1099 | * 减负 1100 | * 拖延 1101 | * 委派 1102 | 1103 | 看起来这好像都是些浑水摸鱼的歪门邪道,其实这些方法对于提高工作效率是非常有用的。 1104 | 1105 | ### 减负 1106 | 1107 | 在计算机的历史上,提高处理速度的最有效手段,就是换一台新的电脑。计算机的性能不断提升,而且价格还越来越便宜,仅靠更新硬件就能够获得成倍的性能提升,这并不稀奇。 1108 | 1109 | 不过很遗憾,摩尔定律并不适用于人类,人类的能力不可能每两年就翻一倍,从工作的角度来看,上面的办法是行不通的。然而,如果你原地踏步的话,早晚会被更年轻、工资更便宜的程序员取代,效率先不说,至少项目的成本降低了,不过对于你来说这可不是什么值得高兴的事。 1110 | 1111 | 说点正经的,在软件开发中,如果不更换硬件,还可以用以下方法来改善软件的运行速度: 1112 | 1113 | * 采用更好的算法 1114 | * 减少无谓的开销 1115 | * 用空间来换时间 1116 | 1117 | 如果将这些方法拿到人类的工作中来,那么“采用更好的算法”就相当于思考更高效的工作方式;“减少无谓的开销”则相当于减少低级重复劳动,用计算机来实现自动化。 1118 | 1119 | “用空间来换时间”可能不是很容易理解。计算机即便进行重复劳动也不会有任何怨言,但还是需要人类来进行管理。如果能够将计算过一次的结果保存在某个地方,就可以缩短计算时间。 1120 | 1121 | 这样一来,所需要的内存空间增加了,但计算时间则可以得到缩短。在人类的工作中,应该是相当于将复杂的步骤和判断实现并总结成文档,从而提高效率的方法吧。 1122 | 1123 | 然而,在有限的条件下,提高工作效率的最好方法就是减负。我们所遇到的大部分工作都可以分为三种,即非得完成不可的、能完成更好但并不是必需的,以及干脆不做为好的。有趣的是,这三种工作之间的区别并非像外人所想象的那样简单。有一些工作虽然看起来绝对是必需的,但仔细想想的话就会发现也未必。 1124 | 1125 | 人类工作的定义比起计算机来说要更加模棱两可,像这样伴随不确定性,由惯性思维所产生的不必要不紧急的工作,如果能够砍掉的话,就能够大幅度提高工作效率。 1126 | 1127 | ### 拖延 1128 | 1129 | 减少不必要不紧急的工作,就能够更快地完成必要的工作,提高效率,关于这一点恐怕大家没有什么异议。不过,到底哪项工作是必要的,而哪项工作又不是必要的,要区分它们可比想象中困难得多。要找出并剔除不必要的工作,还真没那么容易。 1130 | 1131 | 其实,要做出明确的区分,还是有诀窍的,那就是利用人类心理上的弱点。人们普遍都有只看眼前而忽略大局的毛病,因此,当项目期限逼近时,就会产生“只要能赶上工期宁愿砸锅卖铁”这样的念头。 1132 | 1133 | 即便如此,估计也解决不了问题,还不如将计就计,干脆拖到不能再拖为止,这样一来,工期肯定赶不上了,只好看看哪些工作是真正必需的,剩下的那些就砍掉吧。换作平时,要想砍掉一些工作,总会有一些抵触的理由,如“这个说不定以后要用到”、“之前也一直这么做的”之类的,但在工期大限的压力面前,这些理由就完全撑不住了。这就是拖延的魔力。 1134 | 1135 | 不过,这个方法的副作用还是很危险的。万一估算错了时间,连必要的工作也完成不了,那可就惨了。所以说这只是个半开玩笑(但另一半可是认真的)的拖延用法,但除此之外,还有其他一些利用拖延的方法。 1136 | 1137 | 例如,每个任务都各自需要一定的时间才能完成,有些任务只要5分钟就能完成,而有些则需要几个月。如果能够实现列出每项任务的优先级和所需的时间,就可以利用会议之间的空档等碎片时间,来完成一些较小的工作。 1138 | 1139 | 这种思路,和CPU中的乱序执行如出一辙。进一步说,对于一项任务,还可以按照“非常紧急必须马上完成的工作”、“只要不忘记,什么时候完成都可以的工作”等细分成多个子任务。 1140 | 1141 | 这样,按照紧急程度从高到低来完成任务的话,就可以进一步提高自己的工作效率。在这里,和“乱序执行”一样,需要注意任务之间的相互依赖关系。当相互依赖关系很强时,即使改变这些任务的顺序,也无法提高效率,这一点无论在现实的工作中还是CPU中都是相通的。 1142 | 1143 | ### 委派 1144 | 1145 | 大多数人都无法同时完成多个任务,因此可以看成是只有单一核心的硬件。即便用拖延的手段提高了工作效率,但由于同时只能处理一项任务,而每天24小时这个时间是固定不变的,因此一个人能完成的工作总量是存在极限的。 1146 | 1147 | 这种时候,“委派”就成了一个有效的手段。“委派”这个词给人的印象可能不太好,说白了,就是当以自己的能力无法处理某项任务时,转而借用他人的力量来完成的意思。如果说协作、协调、团队合作的话,大概比委派给人的印象要好一些吧。说起来,这和用多核代替单核来提升处理能力的方法如出一辙。 1148 | 1149 | 不过,和多核一样,这种委派的做法也会遇到一些困难。多核的困难大概有下面几种: 1150 | 1151 | * 任务分割 1152 | * 通信开销 1153 | * 可靠性 1154 | 1155 | 这些问题,无论是编程还是现实工作中都是共通的。 1156 | 1157 | 如何进行妥善的任务分割是一个难题。如果将处理集中在某一个核心(或者人员)上,效率就会降低,然而要想事先对处理所需要的时间做出完全的预测也是很困难的。尤其是某项任务和其他任务有相互依赖关系的情况下,可能无论如何分割都无法提高工作效率。 1158 | 1159 | 我们可以把任务分为两种:存在后续处理,在任务完成时需要获得通知的“同步任务”;执行开始后不必关心其完成情况的“异步任务”。同步任务也就意味着存在依赖关系,委派的效果也就不明显。因此,如何将工作分割成异步任务就成了提高效率的关键。 1160 | 1161 | 在有多名成员参与的项目中,通信开销(沟通开销)也不可小觑。在人类世界中,由于“想要传达而没能传达”、“产生了误会”等原因导致的通信开销,比编程世界中要更为显著。我认为导致软件开发项目失败的最大原因,正是由于没有对这种沟通开销引起足够的重视。 1162 | 1163 | 最后一个问题就是“可靠性”。固然,一个人工作的话,可能会因为生病而导致工作无法进行,这也是一种风险,但随着参与项目的人数增加,成员之中有人发生问题的概率也随之上升。这就好比只有一台电脑时,往往不太担心它会出故障,但在管理数据中心里上千台的服务器时,就必须要对每天都有几台机器会出故障的情况做好准备。 1164 | 1165 | 当项目规模增大时,万一有人中途无法工作,就需要考虑如何修复这一问题。当然,分布式编程也是一样的道理。 1166 | 1167 | ### 非阻塞编程 1168 | 1169 | 在编程世界中,减负、拖延和委派是非常重要的,特别是拖延和委派恐怕还不为大家所熟悉,但今后应该会愈发成为一种重要的编程技巧。下面我们先来介绍一下在编程中最大限度利用单核的拖延方法,然后再来介绍一下运用多核的委派方法。 1170 | 1171 | 如果对程序运行时间进行详细分析就可以看出,大多数程序在运行时,其中一大半的时间CPU都在无所事事。实际上,程序的一大部分运行时间都消耗在了等待输入数据等环节上,也就是说“等待”消耗了大量的CPU时间。 1172 | 1173 | 这样的等待被称为阻塞(blocking),而非阻塞编程的目的正是试图将阻塞控制在最低限度。下面我们来介绍一下作为非阻塞编程框架而备受瞩目的“node.js”。在这里,我们使用JavaScript来进行讲解。 1174 | 1175 | ### node.js框架 1176 | 1177 | node.js是一种用于JavaScript的事件驱动框架。提到JavaScript,大家都知道它是一种嵌入在浏览器中、工作在客户端环境下的编程语言,而node.js却是在服务器端工作的。 1178 | 1179 | 默认嵌入到各种浏览器中的客户端语言,恐怕只有JavaScript这一种了,但在服务器端编程中,语言的选择则更为自由,那么为什么还要使用JavaScript呢?那是因为在服务器端使用JavaScript有它特有的好处。 1180 | 1181 | 首先是性能。随着Google Chrome中v8引擎的出现,各大浏览器在JavaScript引擎性能提升方面的竞争愈演愈烈。可以说,JavaScript是目前动态语言中处理性能最高的一种,而node.js也搭载了以高性能著称的Google Chrome v8引擎。 1182 | 1183 | 其次,JavaScript的标准功能很少,这也是一个好处。和其他一些独立语言,如Ruby和Python等不同,JavaScript原本就是作为浏览器嵌入式语言诞生的,甚至都没有提供标准的文件I/O等功能。 1184 | 1185 | 然而,在事件驱动框架上编程时,通常输入输出等可能产生的“等待”是非常麻烦的,后面我们会详细讲解这一点。node.js所搭载的JavaScript引擎本身就没有提供可能会产生阻塞的功能,因此不小心造成阻塞的风险就会相应减小。当然,像死循环、异常占用CPU等导致的“等待”还是无法避免的。 1186 | 1187 | ### 事件驱动编程 1188 | 1189 | 下面我们来介绍一下,在node.js这样的事件驱动框架中,应该如何编程。在传统的过程型编程中,各项操作是按照预先设定好的顺序来执行的(图1)。这与人类完成工作的一般方式相同,因此很容易理解。 1190 | 1191 | C>![](images/originals/chapter6/14.jpg) 1192 | 1193 | C>图1 过程型编程 1194 | 1195 | 相对地,在事件驱动框架所提供的事件驱动编程中,不存在事先设定好的工作顺序,而是对来自外部的“事件”作出响应,并调用与该事件相对应的“回调函数”。这里所说的事件,包括“来自外部的输入”、“到达事先设定的时间”、“发生异常情况”等情况。在事件循环框架中,主循环程序一般是用一个循环来等待事件的发生,当检测到事件发生时,找到并启动与该事件相对应的处理程序(回调函数)。当回调函数运行完毕后,再次返回到循环中,等待下一个事件(图2)。 1196 | 1197 | C>![](images/originals/chapter6/15.jpg) 1198 | 1199 | C>图2 事件驱动编程 1200 | 1201 | 我们可以认为,过程型编程类似于每个单独的员工完成工作的方式,而事件驱动编程则类似于公司整体完成工作的方式。当发生客户下订单的事件时,销售部门(事件循环)会在接到订单后将工作转交给各业务部门(回调函数)来完成,这和事件驱动编程的模型有异曲同工之妙。 1202 | 1203 | ### 事件循环的利弊 1204 | 1205 | 要实现和事件循环相同的功能,除了用回调函数之外,还可以采用启动线程的方式。不过,回调只是一种普通的函数调用,相比之下,线程启动所需要的开销要大得多。而且,每个线程都需要占用一定的栈空间(Linux中默认为每个线程8MB左右)。 1206 | 1207 | 当然,我们可以使用线程池技术,事先准备好一些线程分配给回调函数来使用,但即便如此,在资源消耗方面还是单线程方式更具有优势。此外,使用单线程方式,还不必像多线程那样进行排他处理,由此引发的问题也会相对较少。 1208 | 1209 | 另一方面,单线程方式也有它的缺点。虽然单线程在轻量化方面具备优势,但也同时意味着无法利用多核。此外,如果回调函数不小心产生了阻塞,就会导致事件处理的整体停滞,但在多线程/线程池方式中就不存在这样的问题。 1210 | 1211 | ### node.js编程 1212 | 1213 | 无论是Debian GNU/Linux还是我所使用的sid(开发版)中,都提供了node.js软件包,安装方法如下: 1214 | 1215 | {lang="shell"} 1216 | # apt-get install nodejs 1217 | 1218 | 如果你所使用的发行版中没有提供软件包,就需要用源代码来安装。和其他大多数开源软件一样,node.js的编译安装也是按configure、make、make install的标准步骤来进行的。 1219 | 1220 | 安装完毕后就可以使用node命令了,这是node.js的主体。不带任何参数启动node命令,就会进入下面这样的交互模式。 1221 | 1222 | {lang="shell"} 1223 | % node 1224 | > console.log("hello world") 1225 | > hello world 1226 | 1227 | console.log是用于在交互模式下进行输出的函数。在非交互模式下则应该使用标准输出,因此可以认为,在正式环境下是不会使用它的。不过,如果要确认node.js是否工作正常,这个函数是非常方便的。在这里我们基本上是在交互模式下进行讲解的,因此会经常使用到console.log函数。 1228 | 1229 | 好,下面我们来引发一个事件试试看。要设置一个定时发生的事件及其相应的回调函数,可以使用setTimeout()函数。 1230 | 1231 | {lang"shell"} 1232 | > setTimeout(function(){ 1233 | > ... console.log("hello"); 1234 | > ... }, 2000) 1235 | 1236 | 调用setTimeout()的作用是,在经过第二个参数指定的时间(单位为毫秒)之后引发一个事件,并在该事件发生时调用第一个参数所指定的回调函数。Timeout事件是一个仅发生一次的事件。 1237 | 1238 | {lang="javascript"} 1239 | function () { 1240 | console.log("hello"); 1241 | } 1242 | 1243 | 这个部分是一个匿名函数,和Ruby中的lambda功能差不多。这里的重点是,调用setTimeout()函数之后,该函数马上就执行完毕并退出了。 1244 | 1245 | setTimeout()的作用仅仅是预约一个事件的发生,并设置一个回调函数,并没有必要进行任何等待。这与Ruby中的sleep方法是不同的,node.js会持续对事件进行监视,基本上不会发生阻塞。 1246 | 1247 | node.js的对话模式,表面上看似乎是在一直等待标准输入,但实际上只是对标准输入传来数据这一事件进行响应,并设置一个回调函数,将输入的字符串作为JavaScript程序进行编译并执行。因此,在交互模式下输入JavaScript代码,就会被立即编译和执行,执行完毕后,会再度返回node.js事件循环,事件处理和对回调函数的调用,都是在事件循环中完成的。 1248 | 1249 | setTimeout()会产生一个仅发生一次的事件。如果要产生以一定间隔重复发生的事件,可以使用“setInterval()”函数来设置相应的回调函数。 1250 | 1251 | {lang="shell"} 1252 | > var iv = setInterval(function(){ 1253 | ... console.log("hello"); 1254 | ... }, 2000) 1255 | hello 1256 | hello 1257 | 1258 | 通过setInterval()函数,我们设置了一个每2000毫秒发生一次的事件,并在发生事件时调用指定的回调函数。不过,每隔两秒就显示一条hello实在是太烦人了,我们还是把这个定期事件给取消掉吧。 1259 | 1260 | 为此我们需要使用clearInterval()函数。将setInterval()的返回值作为参数调用clearInterval()就可以解除指定的定期事件。 1261 | 1262 | {lang="shell"} 1263 | > clearInterval(iv); 1264 | 1265 | ### node.js网络编程 1266 | 1267 | 网络服务器才是发挥node.js本领的最好舞台。我们先来实现一个最简单的服务器,即将从套接字接收的数据原原本本返回的“回声服务器”。用node.js实现的回声服务器如图3所示。 1268 | 1269 | {lang="javascript"} 1270 | var net = require("net"); 1271 | net.createServer(function(sock){ 1272 | sock.on("data", function(data) { 1273 | sock.write(data); 1274 | }); 1275 | }).listen(8000); 1276 | 1277 | C>图3 用node.js实现的回声服务器 1278 | 1279 | 作为对照,我们不用事件驱动框架,而是用Ruby实现另一个版本的回声服务器,如图4所示。 1280 | 1281 | {lang="ruby"} 1282 | require "socket" 1283 | 1284 | svr = TCPServer.open(8000) 1285 | socks = [svr] 1286 | 1287 | loop do 1288 | result = select(socks); 1289 | next if result == nil 1290 | for s in result[0] 1291 | if s == svr 1292 | ns = s.accept 1293 | socks.push(ns) 1294 | else 1295 | if s.eof? 1296 | s.close socks.delete(s) 1297 | elsif str = s.readpartial(1024) 1298 | s.write(str) 1299 | end 1300 | end 1301 | end 1302 | end 1303 | 1304 | C>图4 用Ruby实现的回声服务器 1305 | 1306 | 我们来连接一下试试看。在node.js中,要启动回声服务器,需要将图3中的程序保存到文件中,如echo.js,然后执行: 1307 | 1308 | {lang="shell"} 1309 | % node echo.js 1310 | 1311 | 就可以启动程序了。Ruby版则可以将图4的程序保存为echo.rb,并执行: 1312 | 1313 | {lang="shell"} 1314 | % ruby echo.rb 1315 | 1316 | 客户端我们就偷个懒,直接用netcat了。无论是使用node.js版还是Ruby版,都可以通过下列命令来连接: 1317 | 1318 | {lang="shell"} 1319 | % netcat localhost 8000 1320 | 1321 | 连接后,只要用键盘输入字符,就会得到一行和输入的字符相同的输出结果。要结束telnet会话,可以使用“Ctrl+C”组合键。 1322 | 1323 | 将图3程序和图4程序对比一下,会发现很多不同点。首先,图4的Ruby程序实际上是自行实现了相当于事件循环的部分。套接字的监听、注册、删除等管理工作也是自行完成的,这导致代码的规模变得相对较大。 1324 | 1325 | node.js版则比Ruby版要简洁许多。虽说采用node.js需要熟悉回调风格,但作为服务器的实现来说,显然还是事件驱动框架更加高效。 1326 | 1327 | 下面我们来详细看看node.js版和Ruby版之间的区别。 1328 | 1329 | 首先,node.js版中,开头使用require函数引用了net库,net库提供了套接字通信相关的功能。接下来调用的net.create-Server函数,用于创建一个TCP/IP服务器套接字,并在该套接字上接受来自客户端的连接请求。在createServer的参数中所指定的函数,会被作为回调函数进行调用,当回调函数被调用时,会将客户端连接的套接字(sock)作为参数传递给它。sock的on方法用于设置sock相关事件的回调函数。 1330 | 1331 | 当来自客户端的数据到达时会发生data事件,收到的数据会被传递给回调函数。这里我们要实现的是一个回声服务器,因此只需要将收到的数据原本返回即可。listen方法用于在服务器套接字上监听指定的端口号。 1332 | 1333 | 随后,程序到达末尾,进入事件循环。需要注意的是,node.js程序中,程序主体仅负责对事件和回调函数进行设置和初始化,实际的处理是在事件循环内完成的。 1334 | 1335 | 相对地,Ruby版则需要自行管理事件循环和套接字,因此程序结构相对复杂一些。大体上是这样的: 1336 | 1337 | (1)通过TCPSever.open打开服务器套接字。 1338 | 1339 | (2)通过select等待事件。 1340 | 1341 | (3)如果是对服务器套接字产生的事件,则通过accept打开客户端连接套接字。 1342 | 1343 | (4)除此之外的事件,如遇到eof?(连接结束)则关闭客户端套接字。 1344 | 1345 | (5)读取数据,并将数据原原本本回写至客户端。 1346 | 1347 | 在node.js中,上述(2)、(3)、(4)的部分已经嵌入在框架中,不需要以显式代码来编写,而且,程序员也不必关心资源管理等细节。正是由于这些特性,使得在回声服务器的实现上,node.js的代码能够做到非常简洁。 1348 | 1349 | ### node.js回调风格 1350 | 1351 | 像图3这样将多个回调函数叠加起来的编程风格,恐怕还是要习惯一下才能上手。 1352 | 1353 | 下面我们通过实现一个简单的HTTP服务器,来仔细探讨一下回调风格。图5是运用node.js库实现的一个超简单的HTTP服务器。无论收到任何请求,它都只返回hello world这个纯文本字符串。 1354 | 1355 | 我们来实际访问一下试试看。 1356 | 1357 | {lang="shell"} 1358 | % curl http://localhost:8000/ 1359 | hello world 1360 | 1361 | 很简单吧。 1362 | 1363 | 我们来思考一下回调风格。图6所示的,是一个读取当前目录下的index.html文件并返回其内容的HTTP服务器。in-dex.html的读取是用fs库的readFile函数来完成的。 1364 | 1365 | 这个函数会在文件读取完毕后调用回调函数,也就是说即便是简单的文件输入输出也采用了回调风格。传递给回调函数的参数包括是否发生错误的信息,以及读取到的字符串。 1366 | 1367 | node.js的fs库中,也提供了用于同步读取操作的read-FileSync函数,但在node.js中,还是推荐采用无阻塞风险的回调风格。 1368 | 1369 | 像这样,随着接受请求、读取文件等操作的叠加,回调函数的嵌套也会越来越深,这是回调风格所面临的一个课题。当然,我们也有方法来应对,不过关于这个问题,我们还是将来有机会再讲吧。 1370 | 1371 | {lang="javascript"} 1372 | var http = require('http'); 1373 | http.createServer(function(req, res) { 1374 | res.writeHead(200, {'Content-Type':'text/plain'}); 1375 | res.write("hello world\n"); 1376 | res.end(); 1377 | }).listen(8000); 1378 | 1379 | C>图5 用node.js编写的HTTP服务器(1) 1380 | 1381 | {lang="javascript"} 1382 | var http = require("http"); 1383 | var fs = require("fs"); 1384 | 1385 | http.createServer(function(req, res) { 1386 | fs.readFile("index.html", function(err, content) { 1387 | if (err) { 1388 | res.writeHead(404, {"Content-Type":"text/plain"}); 1389 | res.write("index.html: no such file\n"); 1390 | } 1391 | else { 1392 | res.writeHead(200, {"Content-Type":"text/html; charset=utf-8"}); 1393 | res.write(content); 1394 | } 1395 | res.end(); 1396 | }); 1397 | }).listen(8000); 1398 | 1399 | C>图6 用node.js编写的HTTP服务器(2) 1400 | 1401 | ### node.js的优越性 1402 | 1403 | 通过刚才的介绍,大家是不是能够感觉到,用node.js可以很容易地实现一个互联网服务器呢?即使必须要习惯node.js的回调风格,这样的特性也是非常诱人的。 1404 | 1405 | 不过,用node.js来实现服务器的优越性并非只有“容易”这一点而已。首先,在事件检测上,node.js并没有采用随连接数的增长速度逐渐变慢的select系统调用这一传统方式,而是采用了与连接数无关,能够维持一定性能的epoll(Linux)和kqueue(FreeBSD)等方式。因此,在连接数上限值方面可以比较令人放心。但是,对于每个进程来说,文件描述符的数量在操作系统中是存在上限的,要缓解这一上限可能还需要一些额外的设置。 1406 | 1407 | 其次,node.js的http库采用了HTTP1.1的keep-alive方式,来自同一客户端的连接是可以重复使用的。TCP套接字的连接操作需要一定的开销,而通过对连接的重复使用,当反复访问同一台服务器时,就可以获得较高的性能。 1408 | 1409 | 再有,通过运用事件驱动模型,可以减少每个连接所消耗的资源。综合上述这些优势可以看出,同一客户端对同一服务器进行频繁连接,且连接数非常大的场景,例如网络聊天程序的实现,使用node.js是最合适的。 1410 | 1411 | 我们对图3的回声服务器进行些许改造,就实现了一个简单的聊天服务器(图7)。这里所做的改造,只是将回声服务器返回输入的字符串的部分,改成了将字符串发送给当前连接的所有客户端。另外,我们还为连接中断时发生的end事件设置了一个回调函数,用于将客户端从连接列表中删除。 1412 | 1413 | {lang="javascript"} 1414 | var net = require("net"); 1415 | var clients = []; 1416 | 1417 | net.createServer(function(sock){ 1418 | clients.push(sock); 1419 | sock.on("data", function(data) { 1420 | for (var i=0; i图7 用node.js编写的网络聊天程序 1432 | 1433 | 通过这样简单的修改,当多个客户端连接到服务器时,从其中任意客户端上输入的信息就可以在所有客户端上显示出来。当然,作为简易聊天程序来说,它还无法显示出某条消息是由谁发送的,因此还不是很实用。但如果能够从连接的套接字获取相关信息的话,修改起来也应该不难。 1434 | 1435 | 此外,我们在这里直接使用了TCP连接,但只要运用keep-alive和Ajax(Asynchronous JavaScript and XML)技术,要用HTTP实现实时聊天(也就是所谓的COMET)也并非难事。能够轻松开发出可负担如此大量连接的互联网服务器,正是node.js这一事件驱动框架的优势所在。 1436 | 1437 | ### EventMachine与Rev 1438 | 1439 | 当然,面向Ruby的事件驱动框架也是存在的,其中最具代表性的当属EventMachine和Rev。EventMachine是面向Ruby的事件驱动框架中的元老,实际上,在node.js的官方介绍中,也用了“像EventMachine”这样的说法。之所以在这里没有介绍它们,是因为相比这些框架所提供的“为每个事件启动相应的对象方法”的方式来说,node.js这样注册回调函数的方式更加容易讲解。 1440 | 1441 | ## 6.5 ZeroMQ 1442 | 1443 | 大家还记得6.4节中学到的那些提高工作效率的关键词吗?就是拖延和委派。所谓拖延,就是将工作分解成细小的任务,将无法马上着手的工作拖到后面再做,从而减少等待和无用的时间,提高整体的工作效率。在6.4节中,我们通过node.js这一彻底杜绝等待的非阻塞框架,对拖延策略进行了具体的实践。 1444 | 1445 | 然而,无论如何削减无谓的等待,在单位时间内,每个人所能完成的工作量,或者每个CPU所能处理的工作量,都是存在极限的。因此,我们需要另一个策略,即委派。也就是说,将工作交给多个人,或者多个CPU来共同分担,从而提升整体的处理效率。 1446 | 1447 | 接下来我们来具体学习一下委派策略。在编程中,委派就意味着充分运用多个CPU来进行分布式处理。 1448 | 1449 | ### 多CPU的必要性 1450 | 1451 | 对多CPU系统的需求主要出于两个原因:一个是CPU在摩尔定律影响下的发展趋势;另一个是对绝对性能的需求。 1452 | 1453 | 关于前者,在摩尔定律的影响下,一直以来CPU性能的提升都是通过晶体管数量的增加来实现的,但随着渗漏电流等问题所形成的障碍,这种传统的性能提升方式很快就会达到极限。于是,在一块芯片上搭载多个CPU核心的“多核”技术便应运而生。 1454 | 1455 | 最近的电脑中,多个程序同时工作的多任务方式已经成为主流,因此如果CPU拥有多个核心能够进行并行处理,那么必然会带来直接的性能提升。美国英特尔公司推出的Core i7 CPU搭载了4个物理核心,通过超线程技术,对外能够体现8个核心,面向普通电脑的CPU能做到这样的地步,已经着实令人惊叹了。 1456 | 1457 | 既然普通电脑都已经配备多核CPU了,那么积极运用这一技术来提高工作效率也是理所当然的事。然而,和过去CPU本身的性能提升不同,要想在多核环境下提升处理性能,软件方面也必须要支持多核才行。 1458 | 1459 | 在产生对多核系统需求的两个原因中,前者属于环境问题,即“存在多核CPU,因此想要对其进行活用”这样的态度;而后者,即对绝对处理性能的需求,可以说是一种刚性需求了。有个词叫做“信息爆炸”,也就是说,我们每天所要接触的数据量正在不断增加。各种设备通过连接到电脑和网络,在不断获取新的信息,而原本孤立存在的数据,也将通过互联网开始相互流通。 1460 | 1461 | 在这一变化的影响下,我们每天所要接触到的数据量正在飞速增长。既然单个CPU的性能提升已经遇到了瓶颈,那么通过捆绑多个CPU来提升性能可以说是一个必然的趋势。 1462 | 1463 | 无论如何,可以说,要提升处理性能,充分运用多CPU是毫无悬念的,为此,必须要开发出能够活用多CPU的软件。 1464 | 1465 | ### 阿姆达尔定律 1466 | 1467 | 不过,首先大家必须要记住一点,从某种意义上说也是理所当然的,那就是即便为活用多CPU开发了相应的软件,也不可能无限地提升工作效率。 1468 | 1469 | 阿姆达尔定律说的就是这件事。前面我们已经讲过,阿姆达尔定律是由吉恩·阿姆达尔(Gene Amdahl)提出的,其内容是:“(通过并行计算所获得的)系统性能提升效果,会随着无法并行的部分而产生饱和”。 1470 | 1471 | 能够活用多CPU的处理,基本上可以分解为下列步骤: 1472 | 1473 | (1)数据分割、分配 1474 | 1475 | (2)对已分配的数据进行并行处理 1476 | 1477 | (3)将已处理的数据进行集约 1478 | 1479 | 其中,能够并行处理的部分基本上只有(2),而数据的分割和集约无论有多少个CPU,也无法最大限度地运用它们的性能。 1480 | 1481 | ### 多CPU的运用方法 1482 | 1483 | 运用多CPU的手段,大体上可以分成线程方式和进程方式两种。 1484 | 1485 | 除此之外,也有一些支持分布式编程的框架,但从内部原理来看,还是采用了线程、进程两种方式中的一种。 1486 | 1487 | 线程和进程都是多CPU的有效运用手段,但各自拥有不同的性质,也拥有各自的优点和缺点,应该根据应用程序的性质进行选择。 1488 | 1489 | 线程是在一个进程中工作的控制流程,其最大的特点是,工作在同一个进程内的多个线程之间,其内存空间是共享的。这一特点可以说是喜忧参半,却决定了线程的性格。 1490 | 1491 | 共享内存空间,就意味着在线程间操作数据时不需要进行复制。尤其是在线程间需要操作的数据非常多的情况下,像这样无需复制就能够传递数据,在处理性能方面是非常有利的。 1492 | 1493 | 然而,在获得上述好处的同时,也会带来一定的隐患。共享内存空间,也意味着某个线程中操作的数据结构,可能被其他线程修改。由于各线程是独立工作的,因此可能会导致一些特殊时机才出现的、非常难以发现的bug。 1494 | 1495 | 虽然大多数情况下不会出问题,然而在非常偶然的情况下,两个线程同时访问同一数据结构,就会导致程序崩溃,而且这样的bug是很难重现的。一想到可能要去寻找这样的bug,就会不由得感到眼前发黑。 1496 | 1497 | 此外,线程是在一个进程中工作的控制流程,反过来说,所有的处理都必须在同一个进程中完成,这也就意味着,如果要只采用线程方式来运用多CPU,就必须在一台电脑上完成所有的处理。 1498 | 1499 | 然而,即便是在多核已经司空见惯的现在,一台电脑上能够使用的核心数量最多也就是4核,算上超线程也就是8核。服务器的话可能会配备更多的核心。但无论如何,现在还无法达到数百甚至数千核心的规模。如果要实现更高的并行度,仅靠线程还是会遇到极限的。 1500 | 1501 | 相对地,多进程方式同样是喜忧参半的,其特点正好和线程方式相反,即无法共享内存空间,但处理不会被局限在一台计算机上完成。 1502 | 1503 | 无法共享内存空间,就意味着在操作数据时需要进行复制操作,对性能有不利影响。但是,在并发编程中,数据共享一向是引发问题的罪魁祸首,因此从牺牲性能换取安全性的角度来说,也可以算是一个优点。 1504 | 1505 | 此外,刚才也提到过,在一台计算机所搭载的CPU数量不多的情况下,使用进程方式能够通过多台计算机构成的系统运用更多的CPU进行并行处理,这是一个很大的优点。不过,在这样的场景中,选择何种手段实现进程间的数据交换就显得非常重要。 1506 | 1507 | 尽管个人喜好可能并不可靠,但相比线程方式来说,我更加倾向于使用进程方式。 1508 | 1509 | 理由有两个。首先,要安全使用线程相当困难。相比共享内存带来的性能提升来说,由于状态的共享会导致一些偶发性bug,因此风险大于好处。 1510 | 1511 | 其次,对性能提升带来的贡献,会受到该计算机中搭载的CPU核心数量上限的制约,因此其可扩展性相对较低。换句话说,可能搞得很辛苦,却得不到太多的好处,性价比不高。 1512 | 1513 | 当然,在某些情况下,线程方式比进程方式更合适,并不是说线程方式就该从世界上消失了。不过,我认为线程方式只应该用在有限的情况中,而且是用在一般用户看不见的地方,而不应该在应用程序架构模型的尺度上使用。当然,我知道一定有人不同意我的看法。 1514 | 1515 | ### 进程间通信 1516 | 1517 | 由于线程是共享内存空间的,因此不会发生所谓的通信。但反过来说,则存在如何防止多个进程同时访问数据的排他控制问题。 1518 | 1519 | 相对地,由于进程之间不共享数据,因此需要显式地进行通信。进程间通信的手段有很多种,其中具有代表性的有下列几种。 1520 | 1521 | * 管道 1522 | * SysV IPC 1523 | * TCP套接字 1524 | * UDP套接字 1525 | * UNIX套接字 1526 | 1527 | 下面我们来分别简单介绍一下。 1528 | 1529 | ### 管道 1530 | 1531 | 所谓管道,就是能够从一侧输出,然后从另一侧读取的文件描述符对。Shell中的管道等也是通过这一方式实现的。 1532 | 1533 | 文件描述符在每个进程中是独立存在的,但创建子进程时会继承父进程中所有的文件描述符,因此它可以用于在具有父子、兄弟关系的进程之间进行通信。 1534 | 1535 | 例如,在具有父子关系的进程之间进行管道通信时,可以按下列步骤操作。在这里为了简单起见,我们只由子进程向父进程进行通信。 1536 | 1537 | {lang="text"} 1538 | ①首先,使用pipe系统调用,创建一对文件描述符。下面我们将读取一方的文件描述符称为“r”,将写入一侧的文件描述符称为“w”。 1539 | ②通过fork系统调用创建子进程。 1540 | ③在父进程一方将描述符w关闭。 1541 | ④在子进程一方将描述符r关闭。 1542 | ⑤在子进程一方将要发送给父进程的数据写入描述符w。 1543 | ⑥在父进程一方从描述符r中读取数据。 1544 | 1545 | 为了实现进程间的双向通信,需要按与上述相同的步骤创建两组管道。虽然比较麻烦,但难度不大。 1546 | 1547 | 和Shell一样,要在两个子进程之间进行通信,只要创建管道并分配给各子进程,各子进程之间就可以直接通信了。为了将进程与进程联系起来,每次都需要执行上述步骤,一旦自己亲自尝试过一次之后,就会明白Shell有多么强大了。 1548 | 1549 | 管道通信只能用于具有父子、兄弟关系、可共享文件描述符的进程之间,因此只能实现同一台电脑上的进程间通信。实际上,如果使用后面要介绍的UNIX套接字,就可以在不具有父子关系的进程之间传递文件描述符,但只能用在同一台电脑上的这一限制依然存在。 1550 | 1551 | ### SysV IPC 1552 | 1553 | UNIX的System V(Five)版本引入了一组称为SysV IPC的进程间通信API,其中IPC就是Inter Process Communica-tion(进程间通信)的缩写。 1554 | 1555 | SysV IPC包括下列3种通信方式。 1556 | 1557 | * 消息队列 1558 | * 信号量 1559 | * 共享内存 1560 | 1561 | 消息队列是一种用于进程间数据通信的手段。管道只是一种流机制,每次写入数据的长度等信息是无法保存的,相对地,消息队列则可以保存写入消息的长度。 1562 | 1563 | 信号量(semaphore)是一种带有互斥计数器的标志(flag)。这个词原本是荷兰语“旗语”的意思,在信号量中可以设定对某种“资源”同时访问数量的上限。 1564 | 1565 | 共享内存是一块在进程间共享的内存空间。通过将共享内存空间分配到自身进程内存空间中(attach)的方式来访问。由于对共享内存的访问并没有进行排他控制,因此无法避免一些偶发性问题,必须使用信号量等手段进行保护。 1566 | 1567 | 不过,说实话,我自己从来没用过SysV IPC。原因有很多,最重要的一个原因是资源泄漏。由于SysV IPC的通信路径能够跨进程访问,因此在使用时需要向操作系统申请分配才能进行通信,通信完全结束之后还必须显式销毁,如果忘记销毁的话,就会在操作系统内存中留下垃圾。相比之下,管道之类的方式,在其所属进程结束的同时会自动销毁,因此比SysV IPC要更加易用。 1568 | 1569 | 其次,学习使用新的API要花一些精力,但结果也只能用在一台电脑上的进程间通信中,真是让人没什么动力去用呢。 1570 | 1571 | 最后一个原因,就是在20多年前我开始学习UNIX编程的时候,并非所有的操作系统都提供了这一功能。当时,擅长商用领域的AT&T系System V UNIX和加州大学伯克利分校开发的BSD UNIX正处于对峙时期。那个时候,我主要用的是BSD系UNIX,而这个系统就不支持SysV IPC。现在大多数UNIX系操作系统,包括Linux,都支持SysV IPC了,但过去则并非如此,也许正是这种历史原因造成我一直都没有去接触它。 1572 | 1573 | 后来,System V与BSD之间的对峙,随着双方开始吸收对方的功能而逐步淡化,再往后,严格来说,不属于System V和BSD两大阵营的Linux成为了UNIX系操作系统的最大势力,而曾经的对峙也成为了历史,这个结局恐怕在当时是谁都无法想象的吧。 1574 | 1575 | 说到底,用都没用过的东西要给大家介绍实在是难上加难。关于SysV IPC的用法,大家可以在Linux中参考一下: 1576 | 1577 | {lang="shell"} 1578 | # man svipc 1579 | 1580 | 其他操作系统中,也可以从创建消息队列的msgget系统调用的man页面中找到相关信息。 1581 | 1582 | ### 套接字 1583 | 1584 | System V所提供的进程间通信手段是SysV IPC,相对地,BSD则提供了套接字的方式。和其他进程间通信方式相比,套接字有一些优点。 1585 | 1586 | * 通信对象不仅限于同一台计算机,或者说套接字本身主要就是为计算机间的通信而设计的。 1587 | * (和SysV IPC不同)套接字也是一种文件描述符,可进行一般的输入输出。尤其是可以使用select系统调用,在通常I/O的同时进行“等待”,这一点非常方便。 1588 | * 套接字在进程结束后会由操作系统自动释放,因此无需担心资源泄漏的问题。 1589 | * 套接字(由于其优秀的设计)从很早开始就被吸收进Sys-tem V等系统了,因此在可移植性方面的顾虑较少。 1590 | 1591 | 现代网络几乎完全依赖于套接字。各位所使用的几乎所有服务的通信都是基于套接字实现的,这样说应该没有什么大问题。 1592 | 1593 | 套接字分为很多种,其中具有代表性的包括: 1594 | 1595 | * TCP套接字 1596 | * UDP套接字 1597 | * UNIX套接字 1598 | 1599 | TCP(Transmission Control Protocol,传输控制协议)套接字和UDP(User Datagram Protocol,用户数据报协议)套接字都是建立在IP(Internet Protocol,网际协议)协议之上的上层网络通信套接字。这两种套接字都可用于以网络为媒介的计算机间通信,但它们在性质上有一些区别。 1600 | 1601 | TCP套接字是一种基于连接的、具备可靠性的数据流通信套接字。所谓基于连接,是指通信的双方是固定的;而所谓具备可靠性,是指能够侦测数据发送成功或是发送失败(出错)的状态。 1602 | 1603 | 所谓数据流通信,是指发送的数据是作为字节流来处理的,和通常的输入输出一样,不会保存写入的数据长度信息。 1604 | 1605 | 看了上面的内容,大家可能觉得这些都是理所当然的嘛。我们和UDP套接字对比一下,就能够理解其中的区别了。 1606 | 1607 | UDP套接字和TCP套接字相反,是一种能够无需连接进行通信、但不具备可靠性的数据报通信套接字。所谓能够无需连接进行通信,是指无需固定连接到指定对象,可以直接发送数据;不具备可靠性,是指可能会出现中途由于网络状况等因素导致发送数据丢失的情况。 1608 | 1609 | 在数据报通信中,发送的数据在原则上是能够保存其长度的。但是,在数据过长等情况下,发送的数据可能会被分割。 1610 | 1611 | 先不说无连接通信这一点,UDP的其他一些性质可能会让大家感到非常难用。这是因为UDP几乎是原原本本直接使用了作为其基础的IP协议。相反,TCP为了维持可靠性,在IP协议之上构建了各种机制。UDP的特点是结构简单,对系统产生的负荷也较小。 1612 | 1613 | 因此,在语音通信(如IP电话等)中一般会使用UDP,因为通信性能比数据传输的可靠性要更加重要,也就是说,相比通话中包含少许杂音来说,还是保证较小的通话延迟要更加重要。 1614 | 1615 | TCP套接字和UDP套接字都是通过IP地址和端口号来进行工作的。例如,http协议中的“http://www.rubyist.net:80/”就表示与www.rubyist.net(2012年3月27日当时的IP地址为221.186.184.67)所代表的计算机的80号端口建立连接。 1616 | 1617 | ### UNIX套接字 1618 | 1619 | 同样是套接字,UNIX套接字和TCP、UDP套接字相比,可以算是一个异类。基于IP的套接字一般是通过主机名和端口号来识别通信对象的,而UNIX套接字则是在UNIX文件系统上创建一个特殊文件,并用该文件的路径进行识别。由于这种方式使用的是文件系统,因此大家可以看出,UNIX套接字只能用于同一台计算机上的进程间通信。 1620 | 1621 | UNIX套接字并不是基于IP的套接字,它可用于向同一台计算机上其他进程提供服务的某种服务程序。例如有一种叫做canna的汉字转换服务,就是通过UNIX套接字来接受客户端连接的。 1622 | 1623 | ### ZeroMQ 1624 | 1625 | 在进程间通信手段中,套接字算是非常好用的,但即便如此,在考虑对工作进行“委派”时,其易用性还并不理想。套接字本来是为网络服务器的实现而设计的,但作为构建分布式应用程序的手段来说,却显得有些过于原始了。 1626 | 1627 | ZeroMQ就是为了解决这一问题而诞生的,它是一种为分布式应用程序开发提供进程间通信功能的库。 1628 | 1629 | ZeroMQ的特点在于灵活的通信手段和丰富的连接模型,并且它可以在Linux、Mac OS X、Windows等多种操作系统上工作,也支持由多种语言进行访问。ZeroMQ所支持的语言列表如表1所示。 1630 | 1631 | C>表1 ZeroMQ支持的语言一览 1632 | 1633 | C>![](images/originals/chapter6/16.jpg) 1634 | 1635 | ZeroMQ提供了下列底层通信手段。无论使用哪种手段,都可以通过统一的API进行访问,这一点可以说是ZeroMQ的魅力。 1636 | 1637 | * tcp 1638 | * ipc 1639 | * inproc 1640 | * multicast 1641 | 1642 | tcp就是TCP套接字,它使用主机名和端口号进行连接。根据TCP套接字的性质,从其他计算机也可以进行连接,但由于ZeroMQ不存在身份认证这样的安全机制,因此建议大家不要在互联网上公布ZeroMQ的端口号。 1643 | 1644 | ipc用于在同一台计算机上进行进程间通信,使用文件路径来进行连接。实际通信中使用何种方式与实现有关,在UNIX系操作系统上采用的是UNIX套接字,在Windows上也许是用一般套接字来通信的吧。 1645 | 1646 | inproc用于同一进程中的线程间通信。由于线程之间是共享内存空间的,因此这种通信方式是无需复制的。使用inproc通信,可以在活用线程的同时,避免麻烦的数据共享,不仅通信效率高,编写的程序也比较易读。 1647 | 1648 | multicast是一种采用UDP实现的多播通信。为了实现一对多的通信,如果使用一对一的TCP方式,则需要对多个对象的TCP连接反复进行通信,但如果使用原本就用于多播通信的multicast,就可以避免无谓的重复操作。 1649 | 1650 | 不过,UDP通信,尤其是多播传输,在一些路由器上是被禁止的,因此这种方式并不能所向披靡,这的确是个难点。 1651 | 1652 | ### ZeroMQ的连接模型 1653 | 1654 | ZeroMQ为分布式应用程序的构建提供了丰富多彩的连接模型,主要有以下这些。 1655 | 1656 | * REQ/REP 1657 | * PUB/SUB 1658 | * PUSH/PULL 1659 | * PAIR 1660 | 1661 | REQ/REP是REQUEST/REPLY的缩写,表示向服务器发出请求(request),服务器向客户端返回应答(reply)这样的连接模型(图1)。 1662 | 1663 | C>![](images/originals/chapter6/17.jpg) 1664 | 1665 | C>图1 REQ/REP模型 1666 | 1667 | 作为网络连接来说,这种方式是非常常见的。例如HTTP等协议,就遵循REQ/REP模型。通过网络进行函数调用的RPC(Remote Procedure Call,远程过程调用)也属于这一类。REQ/REP是一种双向通信。 1668 | 1669 | PUB/SUB是PUBLISH/SUBSCRIBE的缩写,即服务器发布(publish)信息时,在该服务器上注册(subscribe,订阅)过的客户端都会收到该信息(图2)。这种模型在需要向大量客户端一起发送通知,以及数据分发部署等场合非常方便。PUB/SUB是一种单向通信。 1670 | 1671 | C>![](images/originals/chapter6/18.jpg) 1672 | 1673 | C>图2 PUB/SUB模型 1674 | 1675 | PUSH/PULL是向队列中添加和取出信息的一种模型。PUSH/PULL模型的应用范围很广,如果只有一个数据添加方和一个数据获取方的话,可以用类似UNIX管道的方式来使用(图3a),如果是由一台服务器PUSH信息,而由多台客户端来PULL的话,则可以用类似任务队列的方式来使用(图3b)。 1676 | 1677 | 在图3b的场景中,处于等待状态的任务中只有一个能够取得数据。相对地,PUB/SUB模型中则是所有等待的进程都能够取得数据。 1678 | 1679 | 反过来说,如果有多个进程来PUSH,则能够用来对结果进行集约(图3c)。和PUB/SUB一样,PUSH/PULL也是一种单向通信。 1680 | 1681 | C>![](images/originals/chapter6/19.jpg) 1682 | 1683 | C>图3 PUSH/PULL模型 1684 | 1685 | PAIR是一种一对一的双向通信。说实话,在我所了解的范围内,还不清楚这种模型应该如何使用。 1686 | 1687 | ### ZeroMQ的安装 1688 | 1689 | 首先安装ZeroMQ库的主体。在Debian中提供的软件包叫做libzmq-dev,安装方法如下: 1690 | 1691 | {lang="shell"} 1692 | $ apt-get install libzmq-dev 1693 | 1694 | 如果在你所使用的平台上没有提供二进制软件包,也可以从http://www.zeromq.org/下载源代码进行编译安装。截止到2012年3月27日,其最新版本为2.1。 1695 | 1696 | ZeroMQ的标准API是以C语言方式提供的,但C语言实在太繁琐了,因此这里的示例程序我们用Ruby来编写。Ruby的ZeroMQ库叫做zmq,可以通过RubyGems进行安装。在安装ZeroMQ基础库之后,运行 1697 | 1698 | {lang="shell"} 1699 | $ gem install zmq 1700 | 1701 | 即可安装ZeroMQ的Ruby库。 1702 | 1703 | ### ZeroMQ示例程序 1704 | 1705 | 首先,我们来看看最简单的REQ/REP方式。图4是用Ruby编写的REQ/REP服务器。在这里我们只接受来自本地端口的请求,如果将127.0.0.1的部分替换成“*”就可以接受来自任何主机的请求了。客户端程序如图5所示。 1706 | 1707 | {lang="ruby"} 1708 | require 'zmq' 1709 | 1710 | context = ZMQ::Context.new 1711 | socket = context.socket(ZMQ::REP) 1712 | socket.bind("tcp://127.0.0.1:5000") 1713 | 1714 | loop do 1715 | msg = socket.recv 1716 | print "Got ", msg, "\n" 1717 | socket.send(msg) 1718 | end 1719 | 1720 | C>图4 ZeroMQ REQ/REP服务器 1721 | 1722 | require 'zmq' 1723 | 1724 | context = ZMQ::Context.new 1725 | socket = context.socket(ZMQ::REQ) 1726 | socket.connect("tcp://127.0.0.1:5000") 1727 | 1728 | for i in 1..10 1729 | msg = "msg %s" % i 1730 | socket.send(msg) 1731 | print "Sending ", msg, "\n" 1732 | msg_in = socket.recv 1733 | print "Received ", msg, "\n" 1734 | end 1735 | 1736 | C>图5 ZeroMQ REQ/REP客户端 1737 | 1738 | 这样我们就完成了一个万能echo服务器及其相应的客户端。 1739 | 1740 | ZeroMQ可以发送和接收任何二进制数据,如果我们发送JSON和MessagePack字符串的话,就可以轻松实现一种RPC的功能。要进行通信,可以按顺序启动图4和图5的程序。有意思的是,一般的套接字程序中,必须先启动服务器,但ZeroMQ程序中,先启动客户端也是可以的。 1741 | 1742 | ZeroMQ是按需连接的,因此当连接对象尚未初始化时,客户端会进入待机状态。启动顺序自由这一点非常方便,尤其是PUB/SUB和PUSH/PULL模型的连接中,如果所有的服务器和客户端只能按照一定顺序来启动,那制约就太大了,而ZeroMQ则可以将我们从这样的制约中解放出来。 1743 | 1744 | 此外,ZeroMQ还可以同时连接多个服务器。如果在图5程序的第5行,即connect那一行之后,再添加一行相同的语句(例如只改变端口号),就可以对两个服务器交替发送请求。通过这样的方式,可以很容易实现负载的分配。 1745 | 1746 | 下面我们再来看看用PUSH/PULL、PUB/SUB模型实现的一个简单的聊天程序。这个示例由3个程序构成。程序1(图6)是聊天发言用的程序。通过将命令行中输入的字符PUSH给服务器来“发言”。程序2(图7)是显示发言用的程序。通过SUB-SCRIBE的方式来获取服务器PUBLISH的发言信息,并显示在屏幕上。实际的聊天系统中,客户端应该是由程序1和程序2结合而成的。 1747 | 1748 | {lang="ruby"} 1749 | require 'zmq' 1750 | 1751 | context = ZMQ::Context.new 1752 | socket = context.socket(ZMQ::PUSH) 1753 | socket.connect("tcp://127.0.0.1:7900") 1754 | 1755 | socket.send(ARGV[0]) 1756 | 1757 | C>图6 聊天发言程序 1758 | 1759 | require 'zmq' 1760 | 1761 | context = ZMQ::Context.new 1762 | socket = context.socket(ZMQ::SUB) 1763 | socket.connect("tcp://127.0.0.1:7901") 1764 | # 显示执行SUBSCRIBE操作并对消息进行取舍选择 1765 | # 空字符串表示全部获取的意思 1766 | socket.setsockopt(ZMQ::SUBSCRIBE, "") 1767 | 1768 | loop do 1769 | puts socket.recv 1770 | end 1771 | 1772 | C>图7 聊天显示程序 1773 | 1774 | 程序3(图8)是聊天服务器,它通过PULL来接收发言数据,并将其原原本本PUBLISH出去,凡是SUBCRIBE到该服务器的客户端,都可以收到发言内容(图9)。无论有多少个客户端连接到服务器,ZeroMQ都会自动进行管理,因此程序的实现就会比较简洁。 1775 | 1776 | {lang="ruby"} 1777 | require 'zmq' 1778 | 1779 | context = ZMQ::Context.new 1780 | receiver = context.socket(ZMQ::PULL) 1781 | receiver.bind("tcp://127.0.0.1:7900") 1782 | clients = context.socket(ZMQ::PUB) 1783 | clients.bind("tcp://127.0.0.1:7901") 1784 | 1785 | loop do 1786 | msg = receiver.recv 1787 | printf "Got %s¥n", msg 1788 | clients.send(msg) 1789 | end 1790 | 1791 | C>图8 聊天服务器程序 1792 | 1793 | C>![](images/originals/chapter6/20.jpg) 1794 | 1795 | C>图9 聊天程序的工作方式 1796 | 1797 | ### 小结 1798 | 1799 | ZeroMQ是一个用简单的API实现进程间通信的库。和直接使用套接字相比,它在一对多、多对多通信的实现上比较容易。在对多CPU的运用中,横跨多台计算机的多进程间通信是不可或缺的,因此在需要考虑可扩展性的软件开发项目中,像Ze-roMQ这样的进程间通信库,今后应该会变得越来越重要。 1800 | 1801 | **“多核时代的编程”后记** 1802 | 1803 | 有一句话在这本书中说过很多次,各位读者可能也已经听腻了,不过在这里我还是想再说一次:现在是多核时代。 1804 | 1805 | 所谓多核,原本是指在一块芯片上封装多个CPU核心的意思。截至2012年4月,一般能够买到的电脑基本上都搭载了Intel Core i5等多核CPU芯片,这一事实也是这个时代的写照。 1806 | 1807 | 本书中所说的多核,并不单指多核CPU的使用,大多数情况下指的是“运行一个软件系统可以利用多个CPU核心”这个意思。在这样的场景中,并不局限于一块芯片。由多块芯片,甚至是多台计算机组成的环境,也可以看作是多核。按照这样的理解,云计算环境可以说是一种典型的多核环境吧。 1808 | 1809 | 多核环境中编程的共同点在于,在传统的编程风格中,程序是顺序执行的,因此只能用到单独一个核心。而要充分发挥多核的优势,就必须通过某些方法,积极运用多个CPU的处理能力。 1810 | 1811 | 本书中介绍了一些活用多个CPU的方法,包括UNIX进程的活用、通过异步I/O实现并行化、消息队列等,这些都是非常有前途的技术。然而,UNIX进程(在基本的使用方法中)只能用在一台计算机中;而异步I/O虽然能提高效率,但其本身无法运用多核;消息队列目前也没有强大到能够支持数百、数千节点规模系统的构建。 1812 | 1813 | 从超级计算机的现状进行推测,在不远的将来,云计算环境中的“多核系统”就能够达到数万节点、数十万核心的规模。要构建这样的系统,用现在的技术是可以实现的,但并非易事。 1814 | 1815 | 因此,在这一方面,今后还需要更大的进步。 -------------------------------------------------------------------------------- /manuscript/第四章.md: -------------------------------------------------------------------------------- 1 | # 第四章:云计算时代的编程 2 | 3 | ## 4.1 可扩展性 4 | 5 | 根据美国加州大学伯克利分校所做的一项名为“How Much Informa-tion?”的调查结果,2002年人类新创造的数据总量已超过5艾字节(EB)。其中艾(Exa,艾克萨)是10的18次方,或者说是2的60次方的前缀。这类前缀还有很多,按顺序分别为千(Kilo,10的3次方)、兆(Mega,10的6次方)、吉(Giga,10的9次方)、太(Tera,10的12次方)、拍(Peta,10的15次方)、艾(Exa,10的18次方)。 6 | 7 | 此外,根据这项调查做出的预测,2006年人类的信息总量可达到161EB,2010年可达到约988EB(约等于1ZB,Z为Zetta,即10的21次方字节)。这意味着,人类在1年内所产生并记录的数据量,已经超过了截止到20世纪末人类所创造的全部信息的总量。 8 | 9 | 如此大量的信息被创造、流通和记录,这被称为信息爆炸。生活在21世纪的我们,每天都必须要处理如此庞大的信息量。 10 | 11 | 信息爆炸并不仅仅是社会整体所面临的问题,我们每个人所拥有的数据每天也在不断增加。在我最早接触计算机的20世纪80年代初,存储媒体一般采用5英寸软盘。面对320KB的“大容量”,当时还是初中生的我曾经感叹到:这些数据容量恐怕一辈子都用不完吧。 12 | 13 | 然而,在20多年以后,我所使用的电脑硬盘容量就已经有160GB之多,相当于5英寸软盘的50万倍。更为恐怖的是,这些容量的8成都已经被各种各样的数据所填满了。刚刚我查了一下,就光我手头保存的电子邮件,压缩之后也足足有3.7GB之多,而这些邮件每天还在不断增加。 14 | 15 | ### 信息的尺度感 16 | 17 | 在物理学的世界中,随着尺度的变化,物体的行为也会发生很大的变化。量子力学所支配的原子等粒子世界中,以及像银河这样的天文学世界中,都会发生一些在我们身边绝对见不到的现象。 18 | 19 | 在粒子世界中,某个粒子的存在位置无法明确观测到,而只能用概率论来描述。据说,这是因为要观测粒子,必须要通过光(也就是光子这种粒子)等其他粒子的反射才能完成,而正是这种反射,就干扰了被观测粒子在下一瞬间的位置。 20 | 21 | 不仅如此,在量子力学的世界中,仿佛可以无视质量守恒定律一样,会发生一些神奇的现象,比如从一无所有的地方产生一个粒子,或者粒子以类似瞬间移动的方式穿过毫无缝隙的墙壁等,这真是超常识的大汇演。 22 | 23 | 天文世界也是一样。两端相距数亿光年的银河星团,以及由于引力太强连光都无法逃出的黑洞,这些东西仅凭日常的感觉是很难想象的。 24 | 25 | 这些超乎常理的现象的发生,是因为受到了一些平常我们不太留心的数值的影响。例如光速、原子等粒子的大小、时间的尺度等,它们的影响是无法忽略的。 26 | 27 | 在IT世界中也发生了同样的事情。从小尺度上来说,电路的精密化导致量子力学的影响开始显现,从而影响到摩尔定律;从大尺度上来说,则产生了信息爆炸导致的海量数据问题。 28 | 29 | 和人不同,计算机不会感到疲劳和厌烦,无论需要多少时间,最终都能够完成任务。然而,如果无法在现实的时间范围内得出结果,那也是毫无用处的。当数据量变得很大时,就会出现以前从来没有考虑过的各种问题,对于这些问题的对策必须要仔细考量。 30 | 31 | 下面我们以最容易理解的例子,来看一看关于数据保存和查找的问题。 32 | 33 | ### 大量数据的查找 34 | 35 | 所谓查找,就是在数据中找出满足条件的对象。最简单的数据查找算法是线性查找。所谓线性查找其实并不难,只要逐一取出数据并检查其是否满足条件就可以了,把它叫做一种算法好像也确实夸张了一些。 36 | 37 | 线性查找的计算量为O(n),也就是说,和查找对象的数据量成正比。在算法的性能中,还有很多属于O(n2)、O(n·log n)等数量级的,相比之下O(n)还算是好的(图1)。 38 | 39 | C>![](images/originals/chapter4/1.jpg) 40 | 41 | C>图1 算法计算性能的差异 42 | 43 | 即便如此,随着数据量的增加,查找所需的时间也随之不断延长。假设对4MB的数据进行查找只需要0.5秒,那么对4GB(=4000MB)的查找计算就需要8分20秒,这个时间已经算比较难以忍受的了。而如果是4TB(=4000GB)的数据,单纯计算的时间就差不多需要6天。 44 | 45 | 像Google等搜索引擎所搜索的数据量,早已超过TB级,而达到了PB级。因此很明显,采用单纯的线性查找是无法实现的。那么,对于这样的信息爆炸,到底应该如何应对呢? 46 | 47 | ### 二分法查找 48 | 49 | 从经验上看,计算性能方面的问题,只能用算法来解决,因为小修小补的变更只能带来百分之几到百分之几十的改善而已。 50 | 51 | 在这里,我们来介绍一些在一定前提条件下,可以极大地改善查找计算量的算法,借此来学习应对信息爆炸在算法方面的思考方式。 52 | 53 | 对于没有任何前提条件的查找,线性查找几乎是唯一的算法,但实际上,大多数情况下,数据和查找条件中都存在着一定的前提。利用这些前提条件,有一些算法就可以让计算量大幅度减少。首先,我们来介绍一种基本的查找算法——二分法查找(binary search)。 54 | 55 | 使用二分法查找的前提条件是,数据之间存在大小关系,且已经按照大小关系排序。利用这一性质,查找的计算量可以下降到O(log n)。 56 | 57 | 线性查找大多数是从头开始,而二分法查找则是从正中间开始查找的。首先,将要查找的对象数据和正好位于中点的数据进行比较,其结果有三种可能:两者相等;查找对象较大;查找对象较小。 58 | 59 | 如果相等则表示已经找到,查找就结束了。否则,就需要继续查找。但由于前提条件是数据已经按照大小顺序进行了排序,因此如果查找对象数据比中点的数据大,则要找的数据一定位于较大的一半中,反之,则一定位于较小的一半中。通过一次比较就可以将查找范围缩小至原来的一半,这种积极缩小查找范围的做法,就是缩减计算量的诀窍。 60 | 61 | 这个算法用Ruby编写出来如图2所示。图2中定义的方法接受一个已经排序的数组data,和一个数值value。如果value在data中存在的话,则返回其在data中的元素位置索引,如果不存在则返回nil。 62 | 63 | {lang="ruby"} 64 | def bsearch(data, value) 65 | lo = 0 66 | hi = data.size 67 | while lo < hi 68 | mid = (lo + hi) / 2 # Note: bug in Programming Pearl 69 | if data[mid] < value 70 | lo = mid + 1; 71 | else 72 | hi = mid; 73 | end 74 | end 75 | if lo < data.size && data[lo] == value 76 | lo # found 77 | else 78 | nil # not found 79 | end 80 | end 81 | 82 | C>图2 二分法查找程序 83 | 84 | 二分法查找的计算量在n(=数据个数)较小时差异不大,但随着n的增大,其差异也变得越来越大。 85 | 86 | 表1显示了随着数据个数的增加,log n的增加趋势。当只有10个数据时,n和log n的差异为4.3倍;但当有100万个数据时,差异则达到了72000倍。 87 | 88 | C>表1 O(n)和O(log n)的计算量变化 89 | 90 | |n|log n|n / log n(倍)| 91 | |10|2.302585093|4.342944819| 92 | |100|4.605170186|21.7147241| 93 | |1000|6.907755279|144.7648273| 94 | |10000|9.210340372|1085.736205| 95 | |100000|11.51292546|8685.889638| 96 | |1000000|13.81551056|72382.41365| 97 | 98 | 说句题外话,出人意料的是,二分法查找的实现并非一帆风顺。例如,1986年出版的Jon Bentley所著的《编程珠玑》(Programming Pearls)一书中,就介绍了二分法查找的算法。虽然其示例程序存在bug,但直到2006年,包括作者自己在内,竟然没有任何人注意到。 99 | 100 | 这个bug就位于图2的第5行Note注释所在的地方。《编程珠玑》中原始的程序是用C语言编写的。在C这样的语言中,lo和hi之和有可能会超过正整数的最大值,这样的bug被称为整数溢出(integer overflow)。 101 | 102 | 因此,在C语言中,这个部分应该写成 103 | 104 | {lang="ruby"} 105 | mid = lo + ((hi - lo) / 2) 106 | 107 | 来防止溢出。在1986年的计算机上,索引之和超过整数最大值的情况还非常少见,因此,在很长一段时间内,都没有人注意到这个bug。 108 | 109 | 再说句题外话的题外话,Ruby中是没有“整数的最大值”这个概念的,非常大的整数会自动转换为多倍长整数。因此,图2的Ruby程序中就没有这样的bug。 110 | 111 | ### 散列表 112 | 113 | 从计算量的角度来看,理想的数据结构就是散列表。散列表是表达一个对象到另一个对象的映射的数据结构。Ruby中有一种名为Hash的内建数据结构,它就是散列表。从概念上来看,由于它是一种采用非数值型索引的数组,因此也被称为“联想数组”,但在Ruby中(Perl也是一样)从内部实现上被称为Hash。而相应地,Smalltalk和Python中相当于Hash的数据结构则被称为字典(Dictionary)。 114 | 115 | 散列表采用了一种巧妙的查找方式,其平均的查找计算量与数据量是无关的。也就是说,用O记法来表示的话就是O(1)。无论数据量如何增大,访问其中的数据都只需要一个固定的时间,因此已经算是登峰造极了,从理论上来说。 116 | 117 | 在散列表中,需要准备一个“散列函数”,用于将各个值计算成为一个称为散列值的整数。散列函数需要满足以下性质: 118 | 119 | * 从数据到整数值(0~N-1)的映射 120 | * 足够分散 121 | * 不易冲突 122 | 123 | “足够分散”是指,即便数据只有很小的差异,散列函数的计算结果也需要有很大的差异。“不易冲突”是指,不易发生由不同的数据得到相同散列值的情况。 124 | 125 | 当存在这样一个散列函数时,最简单的散列表,可以通过以散列值为索引的数组来表现(图3)。 126 | 127 | {lang="ruby} 128 | hashtable = [nil] * N ← 根据元素数量创建数组 129 | def hash_set(hashtable, x, y) ← 数据存放(将散列值作为索引存入) 130 | hashtable[hash(x)] = y 131 | end 132 | 133 | def hash_get(hashtable, x) ← 数据取出(将散列值作为索引取出) 134 | hashtable[hash(x)] 135 | end 136 | 137 | C>图3 最简单的散列表 138 | 139 | 由于散列值的计算和指定索引访问数组元素所需的时间都和数据个数无关,因此可以得出,散列表的访问计算量为O(1)。 140 | 141 | 不过,世界上没有这么简单的事情,像图3这样单纯的散列表根本就不够实用。作为实用的散列表,必须能够应对图3的散列表没有考虑到的两个问题,即散列值冲突和数组溢出。 142 | 143 | 虽然散列函数是数据到散列值的映射,但并不能保证这个映射是一对一的关系,因此不同的数据有可能得到相同的散列值。像这样,不同的数据拥有相同散列值的情况,被称为“冲突”。作为实用的散列表,必须要能够应对散列值的冲突。 144 | 145 | 在散列表的实现中,应对冲突的方法大体上可以分为链地址法(chain-ing)和开放地址法(open addressing)两种。链地址法是将拥有相同散列值的元素存放在链表中,因此随着元素个数的增加,散列冲突和查询链表的时间也跟着增加,就造成了性能的损失。 146 | 147 | 不过,和后面要讲到的开放地址法相比,这种方法的优点是,元素的删除可以用比较简单且高性能的方式来实现,因此Ruby的Hash就采用了这种链地址法。 148 | 149 | 另一方面,开放地址法则是在遇到冲突时,再继续寻找一个新的数据存放空间(一般称为槽)。寻找空闲槽最简单的方法,就是按顺序遍历,直到找到空闲槽为止。但一般来说,这样的方法太过简单了,实际上会进行更复杂一些的计算。Python的字典就是采用了这种开放地址法。 150 | 151 | 开放地址法中,在访问数据时,如果散列值所代表的位置(槽)中不存在所希望的数据,则要么代表数据不存在,要么代表由于散列冲突而被转存到别的槽中了。于是,可以通过下列算法来寻找目标槽: 152 | 153 | {lang="text"} 154 | (1)计算数据(key)的散列值 155 | (2)从散列值找到相应的槽(如果散列值比槽的数量大则取余数) 156 | (3)如果槽与数据一致,则使用该槽→查找结束 157 | (4)如果槽为空闲,则散列表中不存在该数据→查找结束 158 | (5)计算下一个槽的位置 159 | (6)返回第3步进行循环 160 | 161 | 由于开放地址法在数据存放上使用的是相对普通的数组方式,和链表相比所需的内存空间更少,因此在性能上存在有利的一面。 162 | 163 | 不过,这种方法也不是尽善尽美的,它也有缺点。首先,相比原本的散列冲突发生率来说,它会让散列冲突发生得更加频繁。因为在开发地址法中,会将有冲突的数据存放到“下一个槽”中,这也就意味着“下一个槽”无法用来存放原本和散列值直接对应的数据了。 164 | 165 | 当存放数据的数组被逐渐填满时,像这样的槽冲突就会频繁发生。一旦发生槽冲突,就必须通过计算来求得下一个槽的位置,用于槽查找的处理时间就会逐渐增加。因此,在开放地址法的设计中,所使用的数组大小必须是留有一定余量的。 166 | 167 | 其次,数据的删除比较麻烦。由于开放地址法中,一般的元素和因冲突而不在原位的元素是混在一起的,因此无法简单地删除某个数据。要删除数据,仅仅将删除对象的数据所在的槽置为空闲是不够的。 168 | 169 | 这样一来,开放地址法中的连锁就可能被切断,从而导致本来存在的数据无法被找到。因此,要删除数据,必须要将存放该元素的槽设定为一种特殊的状态,即“空闲(允许存放新数据)但不中断对下一个槽的计算”。 170 | 171 | 随着散列表中存放的数据越来越多,发生冲突的危险性也随之增加。假设真的存在一种理想的散列函数,对于任何数据都能求出完全不同的散列值,那么当元素个数超过散列表中槽的个数时,就不可避免地会产生冲突。尤其是开放地址法中当槽快要被填满时,所引发的冲突问题更加显著。 172 | 173 | 无论采用哪种方法,一旦发生冲突,就必须沿着某种连锁来寻找数据,因此无法实现O(1)的查找效率。 174 | 175 | 因此,在实用的散列表实现中,当冲突对查找效率产生的不利影响超过某一程度时,就会对表的大小进行修改,从而努力在平均水平上保持O(1)的查找效率。例如,在采用链地址法的Ruby的Hash中,当冲突产生的链表最大长度超过5时,就会增加散列表的槽数,并对散列表进行重组。另外,在采用开放地址法的Python中,当三分之二的槽被填满时,也会进行重组。 176 | 177 | 即便在最好的情况下,重组操作所需的计算量也至少和元素的个数相关(即O(n)),不过,只要将重组的频度尽量控制在一个很小的值,就可以将散列表的平均访问消耗水平维持在O(1)。 178 | 179 | 散列表通过使用散列函数避免了线性查找,从而使得计算量大幅度减少,真是一种巧妙的算法。 180 | 181 | ### 布隆过滤器 182 | 183 | 下面我们来介绍另一种运用了散列函数的有趣的数据结构——布隆过滤器(Bloom filter)。 184 | 185 | 布隆过滤器是一种可以判断某个数据是否存在的数据结构,或者也可以说是判断集合中是否包含某个成员的数据结构。布隆过滤器的特点如下: 186 | 187 | * 判断时间与数据个数无关(O(1)) 188 | * 空间效率非常好 189 | * 无法删除元素 190 | * 偶尔会出错(!) 191 | 192 | “偶尔会出错”这一条貌似违背了我们关于数据结构的常识,不过面对大量数据时,我们的目的是缩小查找的范围,因此大多数情况下,少量的误判并不会产生什么问题。 193 | 194 | 此外,布隆过滤器的误判都是假阳性(false positive),也就是说只会将不属于该集合的元素判断为属于该集合,而不会产生假阴性(false negative)的误判。像布隆过滤器这样“偶尔会出错”的算法,被称为概率算法(prob-abilistic algorithm)。 195 | 196 | 布隆过滤器不但拥有极高的时间效率(O(1)),还拥有极高的空间效率,理论上说(假设误判率为1%),平均每个数据只需要9.6比特的空间。包括散列表在内,其他表示集合的数据结构中都需要包含原始数据,相比之下,这样的空间效率简直是压倒性的。 197 | 198 | 布隆过滤器使用k个散列函数和m比特的比特数组(bit array)。作为比特数组的初始值,所有比特位都被置为0。向布隆过滤器插入数据时,会对该数据求得k个散列值(大于0小于m),并以每个散列值为索引,将对应的比特数组中的比特位全部置为1。 199 | 200 | 要判断布隆过滤器中是否包含某个数据,则需求得数据的k个散列值,只要其对应的比特位中有任意一个为0,则可以判断集合中不包含该数据。 201 | 202 | 即便所有k个比特都为1,也可能是由于散列冲突导致的偶然现象而已,这种情况下就产生了假阳性。假阳性的发生概率与集合中的数据个数n、散列函数种类数k,以及比特数组的大小m有关。如果m相对于n太小,就会发生比特数组中所有位都为1,从而将所有数据都判定为阳性的情况。 203 | 204 | 此外,当k过大时,每个数据所消耗的比特数也随之增加,比特数组填充速度加快,也会引发误判。相反,当k过小时,比特数组的填充速度较慢,但又会由于散列冲突的增多而导致误判的增加。 205 | 206 | 在信息爆炸所引发的大规模数据处理中,像布隆过滤器这样的算法,应该会变得愈发重要。 207 | 208 | ### 一台计算机的极限 209 | 210 | 刚才我们介绍的二分法查找、散列表和布隆过滤器,都是为了控制计算量,从而在现实的时间内完成大量数据处理的算法。 211 | 212 | 然而,仅仅是实现了这些算法,还不足以应对真正的信息爆炸,因为信息爆炸所产生的数据,其规模之大是不可能由一台计算机来完成处理的。最近,一般能买到的一台电脑中所搭载的硬盘容量最大也就是几TB,内存最大也就是8GB左右吧。 213 | 214 | 在摩尔定律的恩泽下,虽然这样的容量已然是今非昔比,但以数TB的容量来完成对PB级别数据的实时处理,还是完全不够的。 215 | 216 | 那该怎么办呢?我们需要让多台计算机将数据和计算分割开来进行处理。一台计算机无法处理的数据量,如果由100台、1000台,甚至是1万台计算机进行合作,就可以在现实的时间内完成处理。幸运的是,计算机的价格越来越便宜,将它们连接起来的网络带宽也越来越大、越来越便宜。Google等公司为了提供搜索服务,动用了好几个由数十万台PC互相连接起来所构成的数据中心。“云”这个词的诞生,也反映出这种由多台计算机实现的分布式计算,重要性越来越高。 217 | 218 | 然而,在数万台计算机构成的高度分布式环境中,如何高效进行大量数据保存和处理的技术还没有得到普及。因为在现实中,能够拥有由如此大量的计算机所构成的计算环境的,也只有Google等屈指可数的几家大公司而已。 219 | 220 | 假设真的拥有了大量的计算机,也不能完全解决问题。在安装大量计算机的大规模数据中心中,最少每天都会有几台计算机发生故障。也就是说,各种分布式处理中,都必须考虑到由于计算机故障而导致处理中断的可能性。这是在一台计算机上运行的软件中不太会考虑的一个要素。其结果就是,相比不包含分布式计算的程序开发来说,高度分布式编程得难度要高出许多。 221 | 222 | ### DHT(分布式散列表) 223 | 224 | 在分布式环境下工作的散列表被称为DHT(Distributed Hash Table,分布式散列表)。DHT并非指的是一种特定的算法,而是指将散列表在分布式环境中进行实现的技术的统称。实现DHT的算法包括CAN、Chord、Pas-try、Tapestry等。 225 | 226 | DHT的算法非常复杂,这种复杂性是有原因的。在分布式环境,尤其是P2P环境中实现散列表,会遇到以下问题: 227 | 228 | * 由于机器故障等原因导致节点消失 229 | * 节点的复原、添加 230 | * 伴随上述问题产生的数据位置查找(路由)困难的问题 231 | 232 | 因此,基本上数据都会以多份副本进行保存。此外,为了应对节点的增减,需要重新计算数据的位置。 233 | 234 | 近年来,运用DHT技术,在分布式环境下实现非关系型数据库的键-值存储(key-value store)数据库受到越来越多的关注。键-值存储的例子包括CouchDB、TokyoTyrant、Kai、Roma等。 235 | 236 | 简单来说,这些数据库是通过网络进行访问的Hash,其数据分别存放在多台计算机中。它们都有各自所针对的数据规模、网络架构和实现语言等方面的特点。 237 | 238 | 关于分布式环境下的数据存储,除了键-值存储以外,还有像GFS(Google File System)这样的分布式文件系统技术。GFS是后面要讲到的MapReduce的基础。 239 | 240 | GFS并不是开源的,只能在Google公司内部使用,但其基本技术已经以论文的形式公开发表,基于论文所提供的信息,也出现了(一般认为)和GFS具备同等功能的开源软件“HFS”(Hadoop File System)。 241 | 242 | ### Roma 243 | 244 | 作为键-值存储数据库的一个例子,下面介绍我参与开发的Roma。Roma(Rakuten On-Memory Architecture)是乐天技术研究所开发的键-值存储数据库,是在乐天公司内部为满足灵活的分布式数据存储需求而诞生的。其特点如下: 245 | 246 | * 所有数据都存放在内存中的内存式数据库(In-Memory Database,IMDB) 247 | * 采用环状的分布式架构 248 | * 数据冗余化:所有数据除了其本身之外,还各自拥有两个副本 249 | * 运行中可自由增减节点 250 | * 以开源形式发布 251 | 252 | Roma是由多台计算机构成的,这些节点的配置形成了一个虚拟的环状结构(图4)。这种圆环状的结构让人联想到罗马竞技场,这也正是Roma这个名字的由来。 253 | 254 | C>![](images/originals/chapter4/2.jpg) 255 | 256 | C>图4 Roma的架构 257 | 258 | 当客户端需要向Roma存储一个键-值对时,首先根据键的数据求出其散列值。Roma中的散列值是一个浮点数,在圆环状的结构中,每个节点都划定了各自所负责的散列值范围,客户端根据散列值找出应该存放该数据的节点,并向该节点请求存储键所对应的值。由于节点的选择是通过散列函数来计算的,因此计算量是固定的。 259 | 260 | Roma中一定会对数据进行冗余化,所以在数据被写入时,该节点会向其两边相邻的节点发起数据副本请求。因此,对于所有的数据,都会在其负责节点以及两个相邻节点的总共三个节点中保存副本。 261 | 262 | Roma的数据基本上是保存在各个节点的内存中的,但为了避免数据丢失,会在适当的时机以文件的形式输出快照。万一遇到Roma系统整体紧急关闭的情况,通过快照和数据写入日志,就可以恢复所有的数据。数据的取出也是同样通过计算散列值找到相应的节点,并对该节点发出请求。 263 | 264 | 对于像Roma这样的分布式键-值存储数据库来说,最大的难题在于节点的增减。由大量计算机所构成的系统,必须时刻考虑到发生故障的情况。此外,有时候为了应对数据量和访问量的急剧增加,也会考虑在系统工作状态下增加新节点。 265 | 266 | 在故障等原因导致节点减少的情况下,一直保持联系的相邻节点会注意到这个变化,并对环状结构进行重组。首先,消失的节点所负责的散列值范围由两端相邻的节点承担。然后,由于节点减少导致有些数据的副本数减少到两个,因此这些数据需要进行搬运,以便保证副本数为三个。 267 | 268 | 增加节点的处理方法是相同的。节点在圆环的某个地方被插入,并被分配新的散列值负责范围。属于该范围的数据会从两端相邻节点获取副本,新的状态便稳定下来了。 269 | 270 | 假设,由于网络状况不佳导致某个节点暂时无法访问时,由于数据无法正常复制,可能出现三个数据副本无法保持一致性的问题。实际上,Roma中的所有数据都通过一种时间戳来记录最后的更新时间。当复制的数据之间发生冲突时,其各自的时间戳必然不同,这时会以时间戳较新的副本为准。 271 | 272 | Roma的优点在于容易维护。只要系统搭建好,节点的添加和删除是非常简单的。根据所需容量增加新的节点也十分方便。 273 | 274 | ### MapReduce 275 | 276 | 数据存储的问题,通过键-值存储和分布式文件系统,在一定程度上可以得到解决,但是在高度分布式环境中进行编程依然十分困难。在分布式散列表中我们也已经接触到了,要解决多个进程的启动、相互同步、并发控制、机器故障应对等分布式环境特有的课题,程序就会变得非常复杂。在Google公司,通过MapReduce这一技术,实现了对分布式处理描述的高效化。MapReduce是将数据的处理通过Map(数据的映射)、Reduce(映射后数据的化简)的组合来进行描述的。 277 | 278 | 图5是用MapReduce统计文档中每个单词出现次数的程序(概念)。实际上要驱动这样的过程还需要相应的中间件,不过这里并没有限定某种特定的中间件。 279 | 280 | {lang="ruby"} 281 | def map(name, document) ← 接收一个文档并分割成单词 282 | # name: document name 283 | # document: document contents 284 | for word in document 285 | EmitIntermediate(word, 1) 286 | end 287 | end 288 | 289 | def reduce(word, values) ← 对每个单词进行统计并返回合计数 290 | # key: a word 291 | # values: a list of counts of the word 292 | result = 0 293 | for v in values 294 | result += v.to_i 295 | end 296 | Emit(result) 297 | end 298 | 299 | C>图5 MapReduce编写的单词计数程序(概念) 300 | 301 | 根据图5这样的程序,MapReduce会进行如下处理: 302 | 303 | * 将文档传递给map函数 304 | * 对每个单词进行统计并将结果传递给reduce函数 305 | 306 | MapReduce的程序是高度抽象化的,像分配与执行Map处理的数据接近的最优节点、对处理中发生的错误等异常情况进行应对等工作,都可以实现高度的自动化。对于错误的应对显得尤其重要,在混入坏数据的情况下,对象数据量如果高达数亿个的话,一个一个去检查就不现实了。 307 | 308 | 在MapReduce中,当发生错误时,会对该数据的处理进行重试,如果依然发生错误的话则自动进行“最佳应对”,比如忽略掉该数据。 309 | 310 | 和GFS一样,MapReduce也没有开源,但基于Google发表的论文等信息,也出现了Hadoop这样的开源软件。在Google赞助的面向大学生的高度分布式环境编程讲座中,也是使用的Hadoop。 311 | 312 | ### 小结 313 | 314 | 随着信息爆炸和计算机的日用品化,分布式编程已经与我们近在咫尺,但目前的软件架构可以说还不能完全应对这种格局的变化,软件层面依然需要进化。 315 | 316 | ## 4.2 C10K问题 317 | 318 | 几年前,我去参加驾照更新的讲座,讲师大叔三令五申“开车不要想当然”。所谓“开车想当然”,就是抱着主观的想当然的心态去开车,比如总认为“那个路口不会有车出来吧”、“那个行人会在车道前面停下来吧”之类的。这就是我们在2-5节中讲过的“正常化偏见”的一个例子。作为对策,我们应该提倡这样的开车方式,即提醒自己“那个路口可能会有车出来”、“行人可能会突然窜出来”等。 319 | 320 | 在编程中也会发生完全相同的状况,比如“这个数据不会超过16比特的范围吧”、“这个程序不会用到公元2000年以后吧”等。这种想法正是导致10年前千年虫问题的根源。人类这种生物,仿佛从诞生之初就抱有对自己有利的主观看法。即便是现在,世界上依然因为“想当然编程”而不断引发各种各样的bug,包括我自己在内,这真是让人头疼。 321 | 322 | ### 何为C10K问题 323 | 324 | C10K问题可能也是这种“想当然编程”的副产品。所谓C10K问题,就是Client 10000 Problem,即“在同时连接到服务器的客户端数量超过10000个的环境中,即便硬件性能足够,依然无法正常提供服务”这样一个问题。 325 | 326 | 这个问题的发生,有很多背景,主要的背景如下: 327 | 328 | * 由于互联网的普及导致连接客户端数量增加 329 | * keep-alive等连接保持技术的普及 330 | 331 | 前者纯粹是因为互联网用户数量的增加,导致热门网站的访问者增加,也就意味着连接数上限的增加。 332 | 333 | 更大的问题在于后者。在使用套接字(socket)的网络连接中,不能忽视第一次建立连接所需要的开销。在HTTP访问中,如果对一个一个的小数据传输请求每次都进行套接字连接,当访问数增加时,反复连接所需要的开销是相当大的。 334 | 335 | 为了避免这种浪费,从HTTP1.1开始,对同一台服务器产生的多个请求,都通过相同的套接字连接来完成,这就是keep-alive技术。 336 | 337 | 近年来,在网络聊天室等应用中为了提高实时性,出现了一种新的技术,即通过利用keep-alive所保持的套接字,由服务器向客户端推送消息,如Comet,这样的技术往往需要很多的并发连接数。 338 | 339 | 在Comet中,客户端先向服务器发起一个请求,并在收到服务器响应显示页面之后,用JavaScript等手段监听该套接字上发送过来的数据。此后,当发生聊天室中有新消息之类的“事件”时,服务器就会对所有客户端一起发送响应数据(图1)。 340 | 341 | C>![](images/originals/chapter4/3.jpg) 342 | 343 | C>图1 以网络聊天室为例对比抓取型和推送型 344 | 345 | 以往的HTTP聊天应用都是用抓取型方式来实现的,即以“用户发言”时、“按下刷新按钮”时或者“每隔一定时间”为触发条件,由客户端向服务器进行轮询。这种方式的缺点是,当聊天室中的其他人发言时,不会马上反映到客户端上,因此缺乏实时性。 346 | 347 | 相对地,Comet以比较简单的方式实现了高实时性的推送型服务,但是它也有缺点,那就是更多的并发连接对服务器造成的负荷。用Comet来提供服务的情况下,会比抓取型方式更早遇到C10K问题,从而导致服务缺乏可扩展性。Comet可以说是以可扩展性为代价来换取实时性的一种做法吧。 348 | 349 | ### C10K问题所引发的“想当然” 350 | 351 | 在安全领域有一个“最弱连接”(Weakest link)的说法。如果往两端用力拉一条由很多环(连接)组成的锁链,其中最脆弱的一个连接会先断掉。因此,锁链整体的强度取决于其中最脆弱的一环。安全问题也是一样,整体的强度取决于其中最脆弱的部分。 352 | 353 | C10K问题的情况也很相似。由于一台服务器同时应付超过一万个并发连接的情况,以前几乎从未设想过,因此实际运作起来就会遇到很多“想当然编程”所引发的结果。在构成服务的要素中,哪怕只有一个要素没有考虑到超过一万个客户端的情况,这个要素就会成为“最弱连接”,从而导致问题的发生。 354 | 355 | 下面我们来看看引发C10K问题的元凶——历史上一些著名的“想当然”吧。同时工作的进程数不会有那么多吧。 356 | 357 | 同时工作的进程数不会有那么多吧。 358 | 359 | 出于历史原因,UNIX的进程ID是一个带符号的16位整数。也就是说,一台计算机上同时存在的进程无法超过32767个。实际上,各种服务的运行还需要创建一些后台进程,因此应用程序可以创建的进程数量比这个数字还要小一些。 360 | 361 | 不过,现在用16位整数作为进程ID的操作系统越来越少了。比如我手边的Linux系统就是用带符号的32位整数来作为进程ID的。 362 | 363 | 虽然由数据类型所带来的进程数上限几乎不存在了,不过允许无限地创建进程也会带来很大的危害,因此进程数的上限是可以在内核参数中进行设置的。看一下手边的Linux系统,其进程数上限被设定为48353。 364 | 365 | 现代操作系统的进程数上限都是在内核参数中设置的,但我们会在后面要讲的内存开销的问题中提到,如果进程数随着并发连接数等比例增加的话,是无法处理大量的并发连接的。这时候就需要像事件驱动模型(event drivenmodel)等软件架构层面的优化了。 366 | 367 | 而且,Linux等系统中的进程数上限,实际上也意味着整个系统中运行的线程数的上限,因此为每个并发连接启动一个线程的程序也存在同样的上限。 368 | 369 | 内存的容量足够用来处理所创建的进程和线程的数量吧。 370 | 371 | 进程和线程的创建都需要消耗一定的内存。如果一个程序为每一个连接都分配一个进程或者线程的话,对状态的管理就可以相对简化,程序也会比较易懂,但问题则在于内存的开销。虽然程序的空间等可以通过操作系统的功能进行共享,但变量空间和栈空间是无法共享的,因此这部分内存的开销是无法避免的。此外,每次创建一个线程,作为栈空间,一般也会产生1MB到2MB左右的内存开销。 372 | 373 | 当然,操作系统都具备虚拟内存功能,即便分配出比计算机中安装的内存(物理内存)容量还要多的空间,也不会立刻造成停止响应。然而,超出物理内存的部分,是要写入访问速度只有DRAM千分之一左右的磁盘上的,因此一旦分配的内存超过物理内存的容量,性能就会发生难以置信的明显下滑。 374 | 375 | 当大量的进程导致内存开销超过物理内存容量时,每次进行进程切换都不得不产生磁盘访问,这样一来,消耗的时间太长导致操作系统整体陷入一种几乎停止响应的状态,这样的情况被称为抖动(thrashing)。 376 | 377 | 不过,计算机中安装的内存容量也在不断攀升。几年前在服务器中配备2GB左右的内存是常见的做法,但现在,一般的服务器中配置8GB内存也不算罕见了。随着操作系统64位化的快速发展,也许在不久的将来,为每个并发连接都分配一个进程或线程的简单模型,也足够应付一万个客户端了。但到了那个时候,说不定还会产生如C1000K问题之类的情况吧。 378 | 379 | 同时打开的文件描述符的数量不会有那么多吧。 380 | 381 | 所谓文件描述符(file descriptor),就是用来表示输入输出对象的整数,例如打开的文件以及网络通信用的套接字等。文件描述符的数量也是有限制的,在Linux中默认状态下,一个进程所能打开的文件描述符的最大数量是1024个。 382 | 383 | 如果程序的结构需要在一个进程中对很多文件描述符进行操作,就要考虑到系统对于文件描述符数量的限制。根据需要,必须将设置改为比默认的1024更大的值。 384 | 385 | 在UNIX系操作系统中,单个进程的限制可以通过setrlimit系统调用进行设置。系统全局上限也可以设置,但设置的方法因操作系统而异。 386 | 387 | 或者我们也可以考虑用这样一种方式,将每1000个并发连接分配给一个进程,这样一来一万个连接只要10个进程就够了,即便使用默认设置,也不会到达文件描述符的上限的。 388 | 389 | 要对多个文件描述符进行监视,用select系统调用就足够了吧。 390 | 391 | 正如上面所说的,“一个连接对应一个进程/线程”这样的程序虽然很简单,但在内存开销等方面存在问题,于是我们需要在一个进程中不使用单独的线程来处理多个连接。在这种情况下,如果不做任何检查就直接对套接字进行读取的话,在等待套接字收到数据的过程中,程序整体的运行就会被中断。 392 | 393 | 用单线程来处理多个连接的时候,像这种等待输入时的运行中断(被称为阻塞)是致命的。为了避免阻塞,在读取数据前必须先检查文件描述符中的输入是否已经到达并可用。 394 | 395 | 于是,在UNIX系操作系统中,对多个文件描述符,可以使用一个叫做select的系统调用来监视它们是否处于可供读写的状态。select系统调用将要监视的文件描述符存入一个fd_set结构体,并设定一个超时时间,对它们的状态进行监视。当指定的文件描述符变为可读、可写、发生异常等状态,或者经过指定的超时时间时,该调用就会返回。之后,通过检查fd_set,就可以得知在指定的文件描述符之中,发生了怎样的状态变化(图2)。 396 | 397 | {lang="c"} 398 | #define NSOCKS 2 399 | int sock[NSOCKS], maxfd; ← sock[1]、sock[2]...... 400 | 中存入要监视的socket。maxfd中存入最大的文件描述符 401 | fd_set readfds; 402 | struct timeval tv; 403 | int i, n; 404 | 405 | FD_ZERO(&readfds); ← fd_set初始化 406 | for (i=0; i图2 select系统调用的使用示例(节选) 435 | 436 | 然而,如果考虑到在发生C10K问题这样需要处理大量并发连接的情况,使用select系统调用就会存在一些问题。首先,select系统调用能够监视的文件描述符数量是有上限的,这个上限定义在宏FD_SETSIZE中,虽然因操作系统而异,但一般是在1024个左右。即便通过setrlimit提高了每个进程中的文件描述符上限,也并不意味着select系统调用的限制能够得到改善,这一点特别需要注意。 437 | 438 | select系统调用的另一个问题在于,在调用select时,作为参数的fd_set结构体会被修改。select系统调用通过fd_set结构体接收要监视的文件描述符,为了标记出实际上发生状态变化的文件描述符,会将相应的fd_set进行改写。于是,为了通过fd_set得知到底哪些文件描述符已经处于可用状态,必须每次都将监视中的文件描述符全部检查一遍。 439 | 440 | 虽然单独每次的开销都很小,但通过select系统调用进行监视的操作非常频繁。当需要监视的文件描述符越来越多时,这种小开销累积起来,也会引发大问题。 441 | 442 | 为了避免这样的问题,在可能会遇到C10K问题的应用程序中尽量不使用select系统调用。为此,可以使用epoll、kqueue等其他(更好的)用于监视文件描述符的API,或者可以使用非阻塞I/O。再或者,也可以不去刻意避免使用select系统调用,而是将一个进程所处理的连接数控制在select的上限以下。 443 | 444 | ### 使用epoll功能 445 | 446 | 很遗憾,如果不通过select系统调用来实现对多个文件描述符的监视,那么各种操作系统就没有一个统一的方法。例如FreeBSD等系统中有kqueue,Solariszh则是/dev/poll,Linux中则是用被称为epoll的功能。把这些功能全都介绍一遍实在是太难了,我们就来看看Linux中提供的epoll这个功能吧。 447 | 448 | epoll功能是由epoll_create、epoll_ctl和epoll_wait这三个系统调用构成的。用法是先通过epoll_create系统调用创建监视描述符,该描述符用于代表要监视的文件描述符,然后通过epoll_ctl将监视描述符进行注册,再通过epoll_wait进行实际的监视。运用epoll的程序节选如图3所示。和select系统调用相比,epoll的优点如下: 449 | 450 | * 要监视的fd数量没有限制 451 | * 内核会记忆要监视的fd,无需每次都进行初始化 452 | * 只返回产生事件的fd的信息,因此无需遍历所有的fd 453 | 454 | 通过这样的机制,使得无谓的复制和循环操作大幅减少,从而在应付大量连接的情况下,性能能够得到改善。 455 | 456 | 实际上,和使用select系统调用的Apache 1.x相比,使用epoll和kqueue等新的事件监视API的Apache 2.0,仅在这一点上性能就提升了约20%~30%。 457 | 458 | 459 | {lang="c"} 460 | int epfd; ← ①首先创建用于epoll的fd,MAX_EVENTS为要监视的fd的最大 461 | 数量 462 | if ((epfd = epoll_create(MAX_EVENTS)) < 0) { ← epoll用fd创建失败 463 | exit(1); 464 | } 465 | 466 | struct epoll_event event; ← ②将要监视的fd添加到epoll,根据要监视 467 | 的数量进行循环 468 | int sock; 469 | 470 | memset(&event, 0, sizeof(event)); ← 初始化epoll_event结构体 471 | ev.events = EPOLLIN; ← 对读取进行监视 472 | ev.data.fd = sock; 473 | 474 | if (epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &event) < 0) { ← 将 475 | socket添加到epoll。fd添加失败 476 | exit(1); 477 | } 478 | 479 | int n, i; ← ③通过epoll_wait进行监视 480 | struct epoll_event events[MAX_EVENTS]; 481 | 482 | while (1) { 483 | /* epoll_wait的参数 484 | 第一个:epoll用的fd 485 | 第二个:epoll_event结构体数组 486 | 第三个:epoll_event数组的大小 487 | 第四个:timeout时间(毫秒) 488 | 超时时间为负数表示永远等待 */ 489 | n = epoll_wait(epfd, events, MAX_EVENTS, -1); 490 | 491 | if (n < 0) { ← 监视失败 492 | exit(1); 493 | } 494 | for (i = 0; i < n; i++) { ← 对每个fd的处理 495 | do_something_on_event(events[i]) 496 | } 497 | } 498 | close(epfd); ← 用一般的close来关闭epoll的fd 499 | 500 | C>图3 epoll_create的3段示例程序 501 | 502 | ### 使用libev框架 503 | 504 | 即便我们都知道epoll和kqueue更加先进,但它们都只能在Linux或BSD等特定平台上才能使用,这一点让人十分苦恼。因为UNIX系平台的一个好处,就是稍稍用心一点就可以(比较)容易地写出具备跨平台兼容性的程序。 505 | 506 | 于是,一些框架便出现了,它们可以将平台相关的部分隐藏起来,实现对文件描述符的监视。在这些框架之中,我们来为大家介绍一下libev和EventMachine。 507 | 508 | libev是一个提供高性能事件循环功能的库,在Debian中提供了libev-dev包。libev是通过在loop结构体中设定一个回调函数,当发生事件(可读/可写,或者经过一定时间)时,回调函数就会被调用。图4展示了libev大概的用法。由于代码中加了很多注释,因此大家应该不难对libev的用法有个大致的理解。 509 | 510 | {lang="c"} 511 | /* 首先包含 */ 512 | #include 513 | 514 | /* 其他头文件 */ 515 | #include 516 | #include 517 | #include 518 | 519 | ev_io srvsock_watcher; 520 | ev_timer timeout_watcher; 521 | 522 | /* 读取socket的回调函数 */ 523 | static void 524 | sock_cb(struct ev_loop *loop, ev_io *w, int revents) 525 | { 526 | /* 读取socket处理 */ 527 | /* 省略do_socket_read的实现部分 */ 528 | /* 到达EOF则返回EOF */ 529 | if (do_socket_read(w->fd) == EOF) { 530 | ev_io_stop(w); /* 停止监视 */ 531 | close(w->fd); /* 关闭fd */ 532 | free(w); /* 释放ev_io */ 533 | } 534 | } 535 | 536 | /* 服务器socket的回调函数 */ 537 | static void 538 | sv_cb(struct ev_loop *loop, ev_io *w, int revents) 539 | { 540 | struct sockaddr_storage buf; 541 | ev_io *sock_watcher; 542 | int s; 543 | 544 | /* 接受客户端socket连接 */ 545 | s = accept(w->fd, &buf, sizeof(buf)); 546 | if (s < 0) return; 547 | 548 | /* 开始监视客户端socket */ 549 | sock_watcher = malloc(sizeof(ev_io)); 550 | ev_io_init(sock_watcher, sock_cb, s, EV_READ); 551 | ev_io_start(loop, sock_watcher); 552 | } 553 | 554 | /* 超时的回调函数 */ 555 | /* 事件循环60秒后调用 */ 556 | static void 557 | timeout_cb(struct ev_loop *loop, ev_timer *w, int revents) 558 | { 559 | puts("timeout"); 560 | /* 结束所有的事件循环 */ 561 | ev_unloop(loop, EVUNLOOP_ALL); 562 | } 563 | 564 | int 565 | main(void) 566 | { 567 | /* 获取事件循环结构体 */ 568 | /* 一般用default就可以了 */ 569 | struct ev_loop *loop = ev_default_loop(0); 570 | 571 | /* 服务器socket的获取处理 */ 572 | /* 篇幅所限,省略get_server_socket的实现部分 */ 573 | /* socket, bind, 执行socket、bind、listen等*/ 574 | int s = get_server_socket(); 575 | 576 | /* 开始监视服务器socket */ 577 | ev_io_init(&srvsock_watcher, sv_cb, s, EV_READ); 578 | ev_io_start(loop, &srvsock_watcher); 579 | 580 | /* 设置超时时间(60秒) */ 581 | ev_timer_init(&timeout_watcher, timeout_cb, 60.0, 0.0); 582 | ev_timer_start(loop, &timeout_watcher); 583 | 584 | /* 事件循环体 */ 585 | ev_loop(loop, 0); 586 | 587 | /* unloop被调用时结束事件循环 */ 588 | return 0; 589 | } 590 | 591 | C>图4 libev的用法 592 | 593 | 程序基本上就是对用于监视的对象watcher进行初始化,然后添加到事件循环结构体(第66~72行),最后调用事件循环(第75行)就可以了。接下来,每次发生事件时,就会自动调用watcher中设定的回调函数(第12~52行)。在服务器套接字的回调函数(第27~42行)中,会将已接受的来自客户端连接的套接字添加到事件循环中去。 594 | 595 | 在这个例子中,只涉及了输出输出以及超时事件,实际上libev能够监视表1所示的所有这些种类的事件。 596 | 597 | C>表1 libev可监视的事件一览 598 | 599 | |事件名|行为| 600 | |ev_io|输入输出| 601 | |ev_timer|相对时间(n秒后)| 602 | |ev_periodic|绝对时间| 603 | |ev_stat|文件属性的变化| 604 | |ev_signal|信号| 605 | |ev_child|子进程的变化| 606 | |ev_idle|闲置| 607 | 608 | libev可以根据不同的平台自动使用epoll、kqueue、/dev/poll等事件监视API。如果这些API都不可用,则会使用select系统调用。使用了libev,就可以在监视并发连接时无需担心移植性了。 609 | 610 | 在使用像libev这样的事件驱动库时,必须要注意回调函数不能发生阻塞。由于事件循环是在单线程下工作的,因此在回调函数执行过程中,是无法对别的事件进行处理的。不仅是libev,在所有事件驱动架构的程序中,都必须尽快结束回调的处理。如果一项工作需要花费很多时间,则可以将其转发给其他进程/线程来完成。 611 | 612 | ### 使用EventMachine 613 | 614 | 刚刚我们做了很多底层编程的工作,例题也是用C语言来写的。不过,仔细想想的话,正如一开始所讲过的那样,C10K问题的本质其实是“明明硬件性能足够,但因来自客户端的并发连接过多导致处理产生破绽”。既然我们完全可以不那么在意CPU的性能,那是不是用Ruby也能够应对C10K问题呢? 615 | 616 | 答案是肯定的。实际上,用Ruby开发能应付大量并发连接的程序并不难,支持这一功能的框架也已经有了。下面我们来介绍一种Ruby中应对C10K问题的事件驱动框架——EventMachine。用Ruby的软件包管理系统RubyGems就可以很轻松地完成EventMachine的安装: 617 | 618 | {lang="shell"} 619 | $ sudo gem install eventmachine 620 | 621 | 表示换行我们用EventMachine来实现了一个Echo(回声)服务器,它的功能就是将写入socket的数据原原本本返回来,程序如图5所示。图4中运用libev编写的程序足足有80行,这还是在省略了本质的处理部分的情况下,而图5的程序完整版也只需要20行。由此大家也可以感受到Ruby颇高的表达能力了吧。 622 | 623 | {lang="ruby"} 624 | require 'eventmachine' 625 | 626 | module EchoServer 627 | def post_init 628 | puts "-- someone connected to the echo server!" 629 | end 630 | 631 | def receive_data data 632 | send_data data 633 | close_connection if data =~ /quit/i 634 | end 635 | 636 | def unbind 637 | puts "-- someone disconnected from the echo server!" 638 | end 639 | end 640 | 641 | EventMachine::run { 642 | EventMachine::start_server "127.0.0.1", 8081, EchoServer20 643 | } 644 | 645 | C>图5 运用EventMachine编写的Echo服务器 646 | 647 | 在EventMachine中,回调是以Ruby模块的形式进行定义的。在图5的例子中,EchoServer模块扮演了这个角色。这个模块中重写了几个方法,从而实现了回调,也就是一种Template Method设计模式吧。实现回调的方法如表2所示。 648 | 649 | C>表2 EventMachine的回调方法 650 | 651 | |方法名|调用条件|目的| 652 | |post_init|socket连接后|初始化连接| 653 | |receive_data(data)|数据接收后|读取数据| 654 | |unbind|连接终止后|终止处理| 655 | |connection_completed|连接完成时初始化客户端连接| 656 | |ssl_handshake_completed|SSL连接时|SSLssl_verify_peerSSL连接时SSL节点验证| 657 | |proxy_target_unbound|proxy关闭时|转发目标| 658 | 659 | 同样是事件驱动框架,但libev和EventMachine在功能上却有很大的不同。 660 | 661 | libev的目的只是提供最基本的事件监视功能,而在套接字连接、内存管理等方面还需要用户自己来操心。同时,它能够支持定时器、信号、子进程状态变化等各种事件。libev是用于C语言的库,虽然程序可能会变得很繁琐,但却拥有可以应付各种状况的灵活性。 662 | 663 | 另一方面,EventMachine提供了多并发网络连接处理方面的丰富功能。从图5的程序中应该也可以看出,由于它对套接字连接、数据读取都提供了相应的支持,因此在网络编程方面可以节约大量的代码,但相对来说,它所支持的事件种类只有输入输出和定时器。 664 | 665 | 作为C语言的库,libev的功能专注于对事件的监视;而作为面向Ruby的框架,EventMachine则支持包括服务器、客户端的网络连接和输入输出,甚至是SSL加密。这也许也反映了两种编程语言性格之间的差异吧。 666 | 667 | 其实,关于libev和EventMachine是否真的能够处理大量并发连接,最好是做个性能测试,但以我手上简陋的环境来说,恐怕无法尝试一万个客户端的连接,也不可能为了这个实验准备一万台笔记本电脑吧。而且,要进行可扩展性的实验,还是需要准备一个专门的网络环境才行。不过话说回来,libev和EventMachine都已经在各种服务中拥有一些应用实例,应该不会存在非常极端的性能上的问题吧。 668 | 669 | ### 小结 670 | 671 | 在libev、EventMachine等事件驱动框架中,如何尽量缩短回调的执行时间是非常重要的,因为在回调中如果发生输入输出等待(阻塞),在大量并发连接的情况下是致命的。于是,在输入输出上如何避免阻塞(非阻塞I/O)就显得愈发重要。 672 | 673 | ## 4.3 HashFold 674 | 675 | 在4-1中,我们介绍了大量信息被创造和记录所引发的“信息爆炸”,以及为应付信息爆炸而将处理分布到多台计算机中进行的方法。对于运用多台计算机构成的高度分布式处理环境中的编程模型,我们介绍了美国Google公司提出的MapReduce。下面我们要介绍的HashFold正是它的一种变体,Steve Krenzel在其网站中(http://stevekrenzel.com/improving-mapreduce-with-hashfold)也对此做了介绍。 676 | 677 | MapReduce通过分解、提取数据流的Map函数和化简、计算数据的Reduce函数,对处理进行分割,从而实现了对大量数据的高效分布式处理。 678 | 679 | 相对地,HashFold的功能是以散列表的方式接收Map后的数据,然后通过Fold过程来实现对散列表元素的去重复。这种模型将MapReduce中一些没有细化的部分,如Map后的数据如何排序再进行Reduce等,通过散列表这一数据结构的性质做了清晰的描述,因此我个人很喜欢HashFold。不过,虽然我对HashFold表示支持,但恐怕它要成为主流还是很困难的。 680 | 681 | 即便无法成为主流,对于大规模数据处理中分布式处理的实现,Hash-Fold简洁的结构应该也可以成为一个不错的实例。 682 | 683 | HashFold的Map过程在接收原始数据之后,将数据生成key、value对。然后,Fold过程接收两个value,并返回一个新的value。图1所示的就是一个运用HashFold的单词计数程序。 684 | 685 | {lang="ruby"} 686 | def map(document) ← 接收文档并分解为单词 687 | # document: document contents 688 | for word in document 689 | # key=单词,计数=1 690 | yield word, 1 691 | end 692 | end 693 | 694 | def fold(count1, count2) ← 对单词进行统计 695 | # count1, count2: two counts for a word 696 | return count1 + count2 697 | end 698 | 699 | C>图1 用HashFold编写的单词计数程序(概念) 700 | 701 | 单词计数是MapReduce主要的应用实例,这个说法已经是老生常谈了,每次提到MapReduce的话题,就会把它当成例题来用。而HashFold则是单词计数的最佳计算模型,当然,它也可以用来进行其他的计算。 702 | 703 | 下面我们按照图1的概念,来实现一个简单的HashFold库。因为如果不实际实现一下的话,我们就无法判断这种模型是否具有广泛的适应性。 704 | 705 | 为了进行设计,我们需要思考满足HashFold性质的条件,于是便得出了以下结论。 706 | 707 | 首先,由于在Ruby中无法通过网络发送过程,因此HashFold的主体不应是函数(过程),而应该是对象。如果是对象的话,只要通过某些手段事先共享类定义,我们就可以用Ruby中内建的Marshal功能通过网络来传输对象了。 708 | 709 | 我们希望这个对象最好是HashFold类的子类。这样一来,HashFold类所拥有的功能就可以被继承下来,从而可以使用Template Method模式来提供每个单独的Map和Fold(图2) 710 | 711 | {lang="ruby"} 712 | class WordCount < HashFold 713 | def map(document) ← 分割单词 714 | for word in document 715 | yield word, 1 716 | end 717 | end 718 | 719 | def fold(count1, count2) ← 重复的单词进行合并计算 720 | return count1 + count2 721 | end 722 | end 723 | h = WordCount.new.start(documents) ← 得到结果Hash(单词=>单词出现数) 724 | 725 | C>图2 HashFold库API(概念) 726 | 727 | 唔,貌似挺好用的。下面就我们来制作一个满足上述设计的HashFold库。 728 | 729 | ### HashFold库的实现(Level 1) 730 | 731 | 好,我们已经完成了API的设计,现在我们来实际进行HashFold库的实现吧。首先,我们先不考虑分布式环境,而是先从初级版本开始做起。 732 | 733 | 要实现一个最单纯级别的HashFold是很容易的。只要接收输入的数据,并对其执行Map过程,如果出现重复则通过Fold过程来解决。实际的程序如图3所示。 734 | 735 | 在不考虑分布式环境的情况下,HashFold的实现其实相当容易,这也反映了HashFold“易于理解”这一特性。 736 | 737 | 不过,不实际运行一下,就不知道它是不是真的能用呢。于是我们准备了一个例题程序。 738 | 739 | 图4就是为HashFold库准备的例题程序,它可以看成是对图2中的概念进行具体化的结果。这是一个依照MapReduce的传统方式,对单词进行计数的程序。今后我们会对HashFold库进行升级,但其API是不变的,因此单词计数程序也不需要进行改动。 740 | 741 | {lang="ruby"} 742 | class HashFold 743 | def start(inputs) 744 | hash = {} ← 保存结果用的Hash 745 | inputs.each do |input| ← 对传递给start的各输入数据调用map方法 746 | self.map(input) do |k,v| 747 | if hash.key?(k) ← 在代码块中传递的key和value如果出现重复则调用fold方法 748 | hash[k] = self.fold(hash[k], v) 749 | else 750 | hash[k] = v ← 如果尚未存在则存放到Hash中 751 | end 752 | end 753 | end 754 | hash ← 返回结果Hash 755 | end 756 | end 757 | 758 | C>图3 HashFold库(Level 1) 759 | 760 | {lang="ruby"} 761 | class WordCount < HashFold 762 | /* 不需要进行计数的高频英文单词 763 | STOP_WORDS = %w(a an and are as be for if in is it of or the to with) ← 将输入的参数作为文件名 764 | def map(document) 765 | open(document) do |f| ← 对文件各行执行操作 766 | for line in f ← 将所有标点符号视为分割符(替换成空格) 767 | line.gsub!(/[!#"$%&\'()*+,-.\/:;<=>?@\[\\\]^_`{\|}~]/, " ") ← 将一行的内容分割为单词 768 | for word in line.split ← 将字母都统一转换为小写 769 | word.downcase! ← 高频单词不计数 770 | next if STOP_WORDS.include?(word) ← key=单词,计数=1,传递给代码块 771 | yield word.strip, 1 ← 解决重复 772 | end 773 | end 774 | end 775 | end 776 | 777 | def fold(count1, count2) ← 对单词计数进行简单累加 778 | return count1 + count2 ← 命令行参数用于指定要统计单词的文件。 779 | 随后按照计数将单词倒序排列(从大到小),并输出排在前20位的单词。 780 | end 781 | end 782 | 783 | WordCount.new.start(ARGV).sort_by{|x|x[1]}.reverse.take(20).each 784 | do |k,v| 785 | print k,": ", v, "\n" 786 | end 787 | 788 | C>图4 单词计数程序 789 | 790 | 将图3的库和图4的程序结合起来,就完成了一个最简单的HashFold程序。我们暂且将这个程序保存在“hfl.rb”这个文件中。 791 | 792 | 那么,我们来运行一下看看。首先将Ruby代码仓库中的“Ruby trunk”分支下的ChangeLog(变更履历)作为单词计数的对象。运行结果如图5所示。 793 | 794 | {lang="shell"} 795 | % ruby hf1.rb ChangeLog 796 | ruby: 11960 797 | rb: 11652 798 | c: 10231 799 | org: 7591 800 | lang: 5494 801 | test: 4224 802 | lib: 3804 803 | ext: 3582 804 | 2008: 3172 805 | ditto: 2669 806 | dev: 2382 807 | nobu: 2334 808 | nakada: 2313 809 | nobuyoshi: 2313 810 | 2007: 1820 811 | h: 1664 812 | matz: 1659 813 | yukihiro: 1648 814 | matsumoto: 1648 815 | tue: 1639 816 | 817 | C>图5 单词计数的运行结果 818 | 819 | 从这个结果中,我们可以发现很多有趣的内容。比如,ruby这个单词出现次数最多,这是理所当然的,而出现次数最多的名字(提交者)是nobuyoshi nakada(2313次),远远超出位于第二名的我(1648次)。原来我已经被超越那么多了呀。 820 | 821 | 除此之外,我们还能看出提交发生最多的日子是星期二。如果查看一下20位之后的结果,就可以看出一周中每天提交次数的排名:最多的是星期二(1639次),然后依次是星期四(1584次)、星期一(1503次)、星期五(1481次)、星期三(1477次)、星期六(1234次)和星期日(1012次)。果然周末的提交比较少呢,但次数最多的居然是星期二,这个倒是我没有想到的。 822 | 823 | 不过,光统计一个文件中的单词还不是很有意思,我们来将多个文件作为计数对象吧,比如将ChangeLog以及其他位于Ruby trunk分支中所有的“.c”文件作为对象。我算了一下,要统计的文件数量为292个,大小约6MB,正好我们也可以来统计一下运行时间(图6)。这里我们的运行环境是Ruby 1.8.7,Patch Level 174。 824 | 825 | {lang="shell"} 826 | % time ruby hf1.rb ChangeLog **/*.c 827 | rb: 31202 828 | 0: 17155 829 | 1: 13784 830 | ruby: 13205 831 | (中略) 832 | ruby hf0.rb ChangeLog **/*.c 37.89s user 3.89s system 98% cpu 42.528 total 833 | 834 | C>图6 以多个文件为对象的运行结果(附带运行时间) 835 | 836 | 我用的shell是“zsh”,它可以通过“**/*.c”来指定当前目录下(包括子目录下)所有的.c文件。这个功能非常方便,甚至可以说我就是为了这个功能才用zsh的吧。 837 | 838 | 在命令行最前面加上time就可以测出运行时间。time是shell的内部命令,因此每种shell输出的格式都不同,大体上总会包含以下3种信息。 839 | 840 | * user:程序本身所消耗的时间 841 | * system:由于系统调用在操作系统内核中所消耗的时间 842 | * total:从程序启动到结束所消耗的时间。由于系统中还运行着其他进程,因此这个时间要大于user与system之和 843 | 844 | 从这样的运算量来看,用时42秒还算不赖。不过,6MB的数据量,即便不进行什么优化,用简单的程序来完成也没有多大问题。 845 | 846 | 作为参考,我用Ruby 1.9也测试了一下,所用的是写稿时最新的trunk,ruby1.9.2dev(2009-08-08trunk 24443)。 847 | 848 | 运行结果为user 18.61秒、system 0.14秒、total 18.788秒,也就是说,和Ruby 1.8.7的42.528秒相比,速度达到了两倍以上(2.26倍)。看来Ruby1.9中所搭载的虚拟机“YARV(Yet Another Ruby VM)”的性能不可小觑呢。 849 | 850 | 从此之后,我们基本上都使用1.9版本来进行测试,主要是因为我平常最常用的就是Ruby 1.9。此外,由于性能测试要跑很多次,如果等待时间能缩短的话可是能大大提高(写稿的)生产效率的。 851 | 852 | ### 运用多核的必要性 853 | 854 | 如果程序运行速度变快,恐怕没人会有意见。相反,无论你编写的程序运行速度有多快,总会有人抱怨说“还不够快啊”。这种情况的出现几乎是必然的,就跟太阳每天都会升起来一样。 855 | 856 | 问题不仅仅如此。虽然CPU的速度根据摩尔定律而变得越来越快,但也马上就要遇到物理定律的极限,CPU性能的提升不会像之前那样一帆风顺了。这几年来,CPU时钟频率的提升已经遇到了瓶颈,Intel公司推出的像Atom这样低频率、低能耗的CPU的成功,以及让普通电脑也能拥有多个CPU的多核处理器的普及,这些都是逐步接近物理极限所带来的影响。 857 | 858 | 此外,还有信息爆炸的问题摆在我们面前。当要处理的数据量变得非常巨大时,光数据传输所消耗的时间都会变得无法忽略了。在Google公司所要处理的PB级别数据量下,光是数据的拷贝所花费的时间,就能达到“天”这个数量级。 859 | 860 | MapReduce正是在这一背景下诞生的技术,HashFold也需要考虑到这方面因素而不断提升性能。 861 | 862 | 所幸的是,我所用的联想ThinkPad X61安装了Intel Core2 Duo这个双核CPU,没有理由不充分利用它。通过使用多个CPU进行同时处理,即并发编程,为处理性能的提高提供了新的可能性。 863 | 864 | ### 目前的Ruby实现所存在的问题 865 | 866 | 然而,从充分利用多核的角度来看,目前的Ruby实现是存在问题的。作为并发编程的工具,我们可以使用线程,但Ruby 1.8中的线程功能是在解释器级别上实现的,因此只能同时进行一项处理,并不能充分利用多核的性能。在Ruby 1.9中,线程的实现使用了操作系统提供的pthread库,本来应该是可以利用多核的,但在Ruby 1.9中,为了保护解释器中非线程安全的部分而加上了一个称为GIL(Giant Intepreter Lock)的锁,由于这个锁的限制,每次还是只能同时执行一个线程,看来在Ruby 1.9中要利用多核也是很困难的。 867 | 868 | 那么,如果要在Ruby上利用多核,该怎样做呢?一种方法是采用没有加GIL锁的实现。所幸,在JVM上工作的JRuby就没有这个锁,因此用JRuby就可以充分利用多核了。不过,我作为Ruby的实现者,在这一点上却非要使用JRuby不可,总有点“败给它了”的感觉。 869 | 870 | ### 通过进程来实现HashFold(Level 2) 871 | 872 | “如果线程不行的话那就用进程好了。”不过,仔细想想就会发现,利用多个CPU的手段,操作系统不是原本就已经提供了吗?那就是进程。如果启动多个进程,操作系统就会自动进行调配,使得各个进程分配到适当的CPU资源。 873 | 874 | 这样的功能不利用起来真是太浪费了。首先,我们先来做一个最简单的进程式实现,即为每个输入项目启动一个进程。 875 | 876 | 为每个输入启动一个进程的HashFold实现如图7所示。和线程不同,进程之间是不共享内存的,因此为了返回结果就需要用到进程间通信。在这里,我们使用UNIX编程中经典的父子进程通信手段pipe。 877 | 878 | 基本处理流程很简单。对各输入启动相应的进程,各个文件的单词计数在子进程中进行。计数结果的Hash需要返回给父进程,但和线程不同,父子进程之间无法共享对象,因此需要使用pipe和Marshal将对象进行复制并转发。父进程从子进程接收Hash后,将接收到的Hash通过fold方法进行合并,最终得到单词计数的结果。 879 | 880 | 说到这里,大家应该明白图7程序的大致流程了。而作为编程技巧,希望大家记住关于fork和pipe的用法,它们在使用进程的程序中几乎是不可或缺的技巧。在Ruby中,fork方法可以附带代码块来进行调用,而代码块可以只在子进程中运行,当运行到代码块末尾时,子进程会自动结束。 881 | 882 | {lang="ruby"} 883 | class HashFold 884 | def hash_merge(hash,k,v) ← 调用fold,由于要调用多次,因此构建在方法中 885 | if hash.key?(k) 886 | hash[k] = self.fold(hash[k], v) ← 如果遇到重复则调用fold方法 887 | else 888 | hash[k] = v ← 尚未存在则存放到Hash中 889 | end 890 | end 891 | 892 | def start(inputs) 893 | hash = nil 894 | inputs.map do |input| ← 对传递给start的每个输入进行循环 895 | p,c = IO.pipe ← 创建用于父子进程间通信用的pipe 896 | fork do ← 创建子进程(fork),在子进程中运行代码块 897 | p.close ← 关闭不使用的pipe 898 | h = {} ← 保存结果用的Hash 899 | self.map(input) do |k,v| ← 调用map方法,由于完全复制了父 900 | 进程的内存空间,因此可以看到父进程的对象(input) 901 | hash_merge(h,k,v) ← 存放数据,解决重复 902 | end 903 | Marshal.dump(h,c) ← 将结果返回给父进程,这次使用Marshal 904 | end 905 | c.close ← 这是父进程,关闭不使用的pipe 906 | p ← 对父进程一侧的pipe进行map 907 | end.each do |f| ← 读取来自子进程的结果 908 | h = Marshal.load(f) 909 | if hash ← 将结果Hash进行合并 910 | h.each do |k,v| 911 | hash_merge(hash, k, v) 912 | end 913 | else 914 | hash = h 915 | end 916 | end 917 | hash 918 | end 919 | end 920 | 921 | #单词计数的部分是共通的 922 | 923 | C>图7 运用进程实现的HashFol 924 | 925 | 重要的事情总要反复强调一下,fork的作用是创建一个当前运行中的进程的副本。由于是副本,因此现在可以引用的类和对象,在子进程中也可以直接引用。但是,也正是由于它只是一个副本,因此如果对对象进行了任何变更,或者创建了新的对象,都无法直接反映到父进程中。这一点,和共享内存空间的线程是不同的。 926 | 927 | 在不共享内存空间的进程之间进行信息的传递有很多种方法,在具有父子关系的进程中,pipe恐怕是最好的方法了。 928 | 929 | pipe方法会创建两个分别用来读取和写入的IO(输入输出)。 930 | 931 | {lang="ruby"} 932 | r,w = IO.pipe 933 | 934 | 在这两个IO中,写入到w的数据可以从r中读取出来。正如刚才所讲过的,由于子进程是父进程的副本,在父进程中创建pipe,并在子进程中对pipe进行写入的话,就可以从父进程中将数据读取出来了。作为好习惯,我们应该将不使用的IO(在这里指的是父进程中用于写入的,和子进程中用于读取的)关闭掉,避免对资源的浪费。 935 | 936 | 在这个程序中,从子进程传递结果只需要创建一对pipe,如果需要双向通信则要创建两对pipe。 937 | 938 | 好,我们来运行一下看看。将图7的HashFold和图4的单词计数程序组合起来保存为“hf2.rb”,并运行这个程序。在1.9环境下的运行结果为user0.66秒、system 0.08秒、total 11.494秒。和非并行版的运行时间18.788秒相比,速度是原来的1.63倍。考虑到并行处理产生的进程创建开销,以及Marshal的通信开销,63%的改善还算是可以吧。 939 | 940 | 之所以user和system时间非常短,是因为实际的单词计数处理几乎都是在子进程中进行的,因此没有被算进去。顺便,在1.8.7上的运行时间是25.528秒,是1.9上的2.25倍。 941 | 942 | 然而,仔细看一看的话,这个程序还是有一些问题的。这个程序中,对每一个输入文件都会启动一个进程,这样会在瞬间内产生大量的进程。这次我们对292个文件的单词进行计数,创建了293个(文件数量+管理进程)进程,而大量的进程则意味着巨大的内存开销。如果要统计的对象文件数量继续增加,就会因为进程数量太多而引发问题。 943 | 944 | ### 抖动 945 | 946 | 当进程数量过多时,就会产生抖动现象。 947 | 948 | 随着大量进程的产生,会消耗大量的内存空间。在最近的操作系统中,当申请分配的内存数量超过实际的物理内存容量时,会将现在未使用的进程的内存数据暂时存放到磁盘上,从表面上看,可用内存空间变得更多了。这种技术被称为虚拟内存。 949 | 950 | 然而,磁盘的访问速度和实际的内存相比要慢上几百万倍。当进程数量太多时,几乎所有的时间都消耗在对磁盘的访问上,实际的处理则陷于停滞,这就是抖动。 951 | 952 | 其实,用Ruby只需要几行代码就可以产生大量的进程,从而故意引发抖动,不过在这里我们还是不介绍具体的代码了。 953 | 954 | 当然,操作系统方面也考虑到了这一点。为了尽量避免发生抖动,也进行了一些优化。例如写时复制(Copy-on-Write)技术,就是在创建子进程时,对于所有的内存空间并非一开始就创建副本,而是先进行共享,只有当实际发生对数据的改写时才进行复制。通过这一技术,就可以避免对内存空间的浪费。 955 | 956 | 在Linux中还有一个称为OOM Killer(Out of Memory Killer)的功能。当发生抖动时,会选择适当的进程并将其强制结束,从而对抖动做出应对。当然,操作系统不可能从人类意图的角度来判断哪个进程是重要的,因此OOM Killer有时候会错杀掉一些很重要的进程,对于这个功能的评价也是毁誉参半。 957 | 958 | ### 运用进程池的HashFold(Level 3) 959 | 960 | 大量产生进程所带来的问题我们已经了解了。那么,我们可以不每次都创建进程然后舍弃,而是重复利用已经创建的进程。线程和进程在创建的时候就伴随着一定的开销,因此像这样先创建好再重复利用的技术是非常普遍的。这种重复利用的技术被称为池(pooling)(图8)。 961 | 962 | {lang="ruby"} 963 | class HashFold 964 | class Pool ← 用于进程池的类 965 | def initialize(hf, n) ← 初始化,指定HashFold对象以及进程池中的进程数量 966 | pool = n.times.map{ ← 创建n个进程 967 | c0,p0 = IO.pipe ← 通信管道:从父进程到子进程(输入) 968 | p1,c1 = IO.pipe ← 通信管道:从子进程到父进程(输出) 969 | fork do ← 创建子进程 970 | p0.close ← 关闭不使用的pipe 971 | p1.close 972 | loop do ← 重复利用,执行循环 973 | input = Marshal.load(c0) rescue exit ← 用Marshal等待输入, 974 | 输入失败则exit 975 | hash = {} ← 保存结果用的Hash 976 | hf.map(input) do |k,v| ← 调用HashFold对象的nap方法 977 | hf.hash_merge(hash,k,v) ← 数据保存,解决重复 978 | end 979 | Marshal.dump(hash,c1) ← 将结果返回父进程 980 | end 981 | end 982 | c0.close ← 父进程中也关闭不使用的pipe 983 | c1.close 984 | [p0,p1] ← 对输入输出用的pipe进行map 985 | } 986 | @inputs = pool.map{|i,o| i} ← 向进程池写入用的IO 987 | @outputs = pool.map{|i,o| o} ← 由进程池读出用的IO 988 | @ichann = @inputs.dup ← 可以向进程池写入的IO 989 | @queue = [] ← 写入队列 990 | @results = [] ← 读出队列 991 | end 992 | 993 | def flush ← 将写入队列中的数据尽量多地写入 994 | loop do 995 | if @ichann.empty? 996 | o, @ichann, e = IO.select([], @inputs, []) ← 使用select寻找可写的IO(a) 997 | break if @ichann.empty? ← 如果没有可写的IO则放弃 998 | end 999 | break if @queue.empty? ← 如果不存在要写入的数据则跳出循环 1000 | Marshal.dump(@queue.pop, @ichann.pop) ← 可写则执行写入 1001 | end 1002 | end 1003 | 1004 | private :flush ← 这是一个用作内部实现的方法,因此声明为private 1005 | 1006 | def push(obj) ← 向Pool写入数据的方法 1007 | @queue.push obj 1008 | flush 1009 | end 1010 | 1011 | def fill ← 从读出队列中尽量多地读出数据 1012 | t = @results.size == 0 ? nil: 0 ← result队列为空时用select阻塞,不为空时则只检查(timeout=0) 1013 | ochann, i, e = IO.select(@outputs, [], [], t) ← 获取等待读出的IO(b) 1014 | return if ochann == nil ← 发生超时的时候 1015 | ochann.each do 1016 | c = ochann.pop 1017 | begin 1018 | @results.push Marshal.load(c) 1019 | rescue => e 1020 | c.close 1021 | @outputs.delete(c) 1022 | end 1023 | end 1024 | end 1025 | private :fill ← 用于内部实现的方法,因此声明为private 1026 | 1027 | def result 1028 | fill ← 从Pool中获取数据的方法 1029 | @results.pop 1030 | end 1031 | end 1032 | 1033 | def initialize(n=2) 1034 | @pool = Pool.new(self,n) ← HashFold初始化,参数为构成池的进程数 1035 | end ← 仅创建进程池 1036 | 1037 | def hash_merge(hash,k,v) 1038 | if hash.key?(k) ← Hash合并 1039 | hash[k] = self.fold(hash[k], v) 1040 | else 1041 | hash[k] = v 1042 | end 1043 | end 1044 | 1045 | def start(inputs) 1046 | inputs.each do |input| ← HashFold计算开始 1047 | @pool.push(input) ← 将各输入传递给Pool 1048 | end 1049 | 1050 | hash = {} 1051 | inputs.each do |input| 1052 | @pool.result.each do |k,v| ← 获取结果用的Hash 1053 | hash_merge(hash, k,v) ← 将结果Hash进行合并 1054 | end 1055 | end 1056 | hash 1057 | end 1058 | end 1059 | 1060 | C>图8 运用进程池的HashFold 1061 | 1062 | 和图7程序相比,由于增加了重复利用的代码,因此程序变得更复杂了。不过,要想象出这个程序的行为也并不难。 1063 | 1064 | 和图7程序相比,具体的区别在于并非每个输入都生成一个进程,而是实现启动一定数量的进程,对这些进程传递输入,再从中获取输出,如此反复。因此,图7的程序中只需要用一对pipe,而这次的程序就需要分别用于输入和输出的两对pipe。 1065 | 1066 | 此外,在并发编程中还有一点很重要,那就是不要发生阻塞。如果试图从一个还没有准备好数据的pipe中读取数据的话,在数据传递过来之前程序就会停止响应。这种情况被称为阻塞。 1067 | 1068 | 如果是非并行的程序,在数据准备好之前发生阻塞也是很正常的。不过,在并行程序中,在阻塞期间其他进程的输入也会停滞,从结果上看,完成处理所需要的时间就增加了。 1069 | 1070 | 因此,我们在这里用select来避免阻塞的发生。select的参数是IO排列而成的数组,它可以返回数据已准备好的IO数组。select可以监视读取、写入、异常处理3种数据,这次我们对读取和写入各自分别调用select。 1071 | 1072 | 图8的(a)处,对位于池中进程的写入检查,我们使用了select。select的参数是要监视的IO数组,但这里我们需要检查的只是写入,因此只在第2个参数指定了一个IO数组,第1、第3参数都指定了空的数组。 1073 | 1074 | 图8的(b)处,我们对从进程池中读出结果进行检查。select在默认情况下,当不存在可读出的IO时会发生阻塞,但当读出队列中已经有的数据时我们不希望它发生阻塞。因此我们指定了一个第4参数,也就是超时时间。select的第4参数指定一个整数时,等待时间不会超过这个最大秒数。在这次的程序中,当队列不为空时我们指定了0,也就是立即返回的意思。 1075 | 1076 | 要避免发生阻塞,除了select之外还有其他手段,比如使用其他线程。不过,一般来说,通过fork创建进程和线程不推荐在一个程序中同时使用,最大的理由是,pthread和fork组合起来时,实际可调用的系统调用非常有限,因此在不同的平台上很难保证它总能够正常工作。 1077 | 1078 | 出于这个原因,同时使用fork和线程的程序,可能会导致Ruby解释器出现不可预料的行为。例如有报告说在Linux下可以工作,但在FreeBSD下则不行,这会导致十分棘手的bug。 1079 | 1080 | 那么,我们用图8的HashFold来测试一下实际的运行速度吧。和之前其他程序一样在1.9的相同条件下运行,结果是user 0.72秒,system 0.06秒,total 10.604秒。由于不存在生成大量进程所带来的开销,性能有了稍许提升。此外,对抖动的抵抗力应该也提高了。顺便提一句,1.8.7下的运行时间为25.854秒。 1081 | 1082 | ### 小结 1083 | 1084 | 对于我们这些老古董程序员来说,fork、pipe、select等都是已经再熟悉不过的多进程编程API了,而这些API甚至可以用在最新的多核架构上面,真是感到无比爽快。 1085 | 1086 | 不过,目前市售的一般PC,虽说是多核,但对于一台电脑来说也就是双核或者四核,稍微贵一些的服务器可以达到8核,而一台电脑拥有数十个CPU核心的超多核(many-core)环境还尚未成为现实。HashFold等计算模型本来的目的是为了应对信息爆炸,而以目前这种程度的CPU核心数量,尚无法应对信息爆炸级别的数据处理。 1087 | 1088 | 看来今后我们必须要更多地考虑多台计算机构成的分布式环境了。 1089 | 1090 | ## 4.4 进程间通信 1091 | 1092 | 在有限的时间内处理大量的信息,其基本手段就是“分割统治”。也就是说,关键是如何分割大量的数据并进行并行处理。在并行处理中,充分利用多核(一台电脑具备多个CPU)和分布式环境(用多台计算机进行处理)就显得非常重要。 1093 | 1094 | ### 进程与线程 1095 | 1096 | 并行处理的单位,大体上可以分为进程和线程两种(表1)。 1097 | 1098 | C>表1 处理的单位和同时运行的特征 1099 | 1100 | |处理的单位|内存空间共享|利用多核| 1101 | |线程|是|因实现不同而不同| 1102 | |进程|否|是| 1103 | 1104 | 进程指的是正在运行的程序。各进程是相互独立的,用一般的方法,是无法看到和改变其他进程的内容和状态的。在Linux等UNIX系操作系统中,进程也无法中途保存状态或转移到另一台计算机上。即便存在让这种操作成为可能的操作系统,也只是停留在研究阶段而已,目前并没有民用化的迹象。 1105 | 1106 | 另一方面,多个线程可以在同一个进程中运行,线程间也可以相互合作。所属于同一个进程的各线程,其内存空间是共享的,因此,多个线程可以访问相同的数据。这是一个优点,但同时也是一个缺点。 1107 | 1108 | 说它是优点,是因为可以避免数据传输所带来的开销。在各进程之间,内存是无法共享的,因此进程间通信就需要对数据进行拷贝,而在线程之间进行数据共享,就不需要进行数据的传输。 1109 | 1110 | 而这种方式的缺点,就是由于多个线程会访问相同的数据,因此容易产生冲突。例如引用了更新到一半的数据,或者对相同的数据同时进行更新导致数据损坏等,在线程编程中,由于操作时机所导致的棘手bug是肯定会遇到的。 1111 | 1112 | 虽然灵活使用线程是很重要的,但总归线程的使用范围是在一台计算机中,而大规模的数据仅靠一台计算机是无法处理的。在这一节中,我们主要来介绍一下多台计算机环境中的进程间通信。 1113 | 1114 | ### 同一台计算机上的进程间通信 1115 | 1116 | 首先,我们来看同一台计算机上的进程间通信。正如我们在4-3中讲过的HashFold的实现,在同一台计算机上充分利用多个进程可以带来一定的好处。尤其是在现在的Ruby实现中,由于技术上的障碍使得靠线程来利用多核变得很困难(JRuby除外),因此对进程的活用也就变得愈发重要了。 1117 | 1118 | 在Linux等UNIX系操作系统中,同一台计算机上进行进程间通信的手段有以下几种: 1119 | 1120 | * 管道(pipe) 1121 | * 消息(message) 1122 | * 信号量(semaphore) 1123 | * 共享内存 1124 | * TCP套接字 1125 | * UDP套接字 1126 | * UNIX域套接字 1127 | 1128 | 我们从上到下依次解释一下。管道是通过pipe系统调用创建一对文件描述符来进行通信的方式。所谓文件描述符,就是表示输入输出对象的一种识别符,在Ruby中对应了IO对象。当数据从某个pipe写入时,可以从另一端的pipe读出。事先将管道准备好,然后用“fork”系统调用创建子进程,这样就可以实现进程间通信了。 1129 | 1130 | 消息、信号量和共享内存都是UNIX的System V(5)版本中加入的进程间通信API。其中消息用于数据通信,信号量用于互斥锁,共享内存用于在进程间共享内存状态。它们结合起来被称为sysvipc。 1131 | 1132 | 不过,上述这些手段都不是很流行。例如管道的优点在于非父子关系的进程之间也可以实现通信,但是当不再使用时必须显式销毁,否则就会一直占用操作系统资源。说实话这并不是一个易用的API,而关于它的使用信息又很少,于是就让人更加不想去用了,真是一个恶性循环。 1133 | 1134 | 套接字(socket)指的是进程间通信的一种通道。它原本是4.2BSD中包含的一个功能,但除了UNIX系操作系统之外,包括Windows在内的各种其他操作系统都提供了这样的功能。 1135 | 1136 | 套接字根据通信对象的指定方法以及通信的性质可以分为不同的种类,其中主要使用的包括TCP套接字、UDP套接字和UNIX域套接字三种。它们的性质如表2所示。 1137 | 1138 | 使用套接字进行通信,需要在事先设定好的连接目标处,通过双方套接字的相互连接创建一个通道。这个连接目标的指定方法因套接字种类而异,在使用最多的TCP套接字和UDP套接字中,是通过主机地址(主机名或者IP地址)和端口号(1到65535之间的整数)的组合来指定的。 1139 | 1140 | C>表2 套接字的分类与特征 1141 | 1142 | C>![](images/originals/chapter4/4.jpg) 1143 | 1144 | 位于网络中的每台计算机,都拥有一个被称为IP地址的识别码(IPv4是4字节的序列,IPv6是16字节的序列)。例如在IPv4中,自己正在使用的电脑所对应的IP地址为“127.0.0.1”。在开始通信时,通过指定对方计算机的IP地址,就相当于决定了要和哪台计算机进行通信。 1145 | 1146 | IP地址是一串数字,非常难记,因此每台计算机都还有一个属于自己的“主机名”。在这里就不讲述或多细节了,不过简单来说,通过DNS(Do-main Name System,域名系统)这一机制就可以由主机名来获得IP地址了。 1147 | 1148 | 另一方面,UNIX域套接字则是使用和文件一样的路径来指定连接目标。在服务器一端创建监听用的UNIX域套接字时,需要指定一个路径,而结果就是将UNIX域套接字创建在这个指定的路径中。 1149 | 1150 | 以路径作为连接目标,就意味着UNIX域套接字只能用于同一台计算机上的进程间通信。不过,UNIX域套接字还具备一些特殊的功能,它不仅可以传输一般的字节流,还可以传输文件描述符。TCP套接字被称为流套接字(stream socket),写入的数据只能作为单纯的字节流来对待,因此无法保存每次写入的数据长度信息。 1151 | 1152 | 相对地,UDP套接字和UNIX流套接字中,写入的数据是作为一个包(数据传输的单位)来发送的,因此可以获取每次写入的数据长度。不过,当数据过长时,数据包会根据各操作系统所设定的最大长度进行分割。 1153 | 1154 | 对于UDP套接字,有一点需要注意,那就是基于UDP套接字的通信不具备可靠性。所谓没有可靠性,就是说在通信过程中可能会发生数据到达顺序颠倒,最坏的情况下,还可能发生数据在传输过程中丢失的情况。 1155 | 1156 | ### TCP/IP协议 1157 | 1158 | 利用网络进行通信的协议(protocol)迄今为止已经出现了很多种,但其中一些因为各种原因已经被淘汰了,现在依然幸存下来的就是一种叫做TCP/IP的协议。TCP套接字就是“用TCP协议进行通信的套接字”的意思。 1159 | 1160 | TCP是Transmission Control Protocal(传输控制协议)的缩写。TCP是负责错误修恢复、数据再发送、流量控制等行为的高层协议,它是在一种更低层级的IP协议(即Internet Protocol)的基础之上实现的。 1161 | 1162 | UDP则是User Datagram Protocol(用户数据报协议)的缩写。UDP实际上是在IP的基础上穿了一件薄薄的马甲,和TCP相比,它有以下这些不同点。 1163 | 1164 | #### 1. 保存通信数据长度 1165 | 1166 | 在TCP中,发送的数据是作为字节流来处理的。虽然在实际的通信过程中,数据流会被分割为一定大小的数据包,但在TCP层上这些包是连接在一起的,无法按照包为单位来查看数据。 1167 | 1168 | 相对地,通过UDP发送的数据会直接以数据包为单位进行发送,作为发送单位的数据包长度会一直保存到数据接收方。不过,如果包的长度超过操作系统所规定的最大长度(一般为9KB左右)就会被分割开,因此也无法保证总是能获取原始的数据长度。 1169 | 1170 | #### 2. 没有纠错机制 1171 | 1172 | 要发送的数据在经过网络到达接收方的过程中,可能会发生一些状况,比如数据包的顺序发生了调换,最坏的情况下甚至发生整个数据包丢失。在TCP中,每个数据包都会被赋予一个编号,如果包顺序调换,或者本来应该收到的包没有收到,接收方会通知发送方“某个编号的包没有收到”,并请求发送方重新发送该包,这样就可以保证数据不会发生遗漏。 1173 | 1174 | 此外,还可以在网络繁忙的时候,对一次发送数据包的大小和数量进行调节,以避免网络发生阻塞。 1175 | 1176 | 相对地,UDP则没有这些机制,像“顺序调换了”、“发送的数据没收到”这样的情况,必须自己来应付。 1177 | 1178 | #### 3. 不需要连接 1179 | 1180 | 在TCP中,通信对象是固定的,因此,如果要和多个对象进行通信,则需要对每个对象分别使用不同的套接字。 1181 | 1182 | 相对地,UDP则是使用sendto系统调用来显式指定发送目标,因此每次发送数据时可以发送给不同的目标。在接收数据时,也可以使用recvfrom系统调用,一并获取到发送方的信息。虽然UDP不需要进行连接,但在需要的情况下,也可以进行显式的连接来固定通信对象。 1183 | 1184 | #### 4. 高速 1185 | 1186 | 由于TCP可以进行复杂的控制,因此使用起来比较方便。但是,由于需要处理的工作更多,其实时性便打了折扣。 1187 | 1188 | UDP由于处理工作非常少,因而能够发挥IP协议本身的性能。在一些实时性大于可靠性的网络应用程序中,很多是出于性能上的考虑而选择了UDP。 1189 | 1190 | 例如,在音频流的传输中,即便数据发生丢失也只不过是造成一些音质损失(例如产生一些杂音)而已。相比之下,维持较低的延迟则显得更加重要。在这样的案例中,比较适合采用UDP协议来进行通信。 1191 | 1192 | ### 用C语言进行套接字编程 1193 | 1194 | 在套接字的使用上,已经有了用系统调用构建的C语言API。通过C语言可以访问的套接字相关系统调用如表3所示。TCP套接字的使用方法和步骤,以及无连接型UDP的步骤如图1所示。 1195 | 1196 | C>表3 套接字相关系统调用 1197 | 1198 | C>![](images/originals/chapter4/5.jpg) 1199 | 1200 | *** 1201 | 1202 | C>![](images/originals/chapter4/6.jpg) 1203 | C>图1 连接型TCP套接字和无连接型UDP套接字的使用方法 1204 | 1205 | 图2是一个使用套接字相关系统调用进行套接字通信的客户端程序。这个程序访问本机(localhost)的13号端口,将来自该端口的数据直接输出至标准输出设备。 1206 | 1207 | {lang="c"} 1208 | #include 1209 | #include 1210 | #include 1211 | #include 1212 | #include 1213 | 1214 | int main() 1215 | { 1216 | int sock; 1217 | struct sockaddr_in addr; 1218 | struct hostent *host; 1219 | char buf[128]; 1220 | int n; 1221 | sock = socket(PF_INET, SOCK_STREAM, 0); ← socket系统调用 1222 | //----------------------------------------------------------- 1223 | // 指定连接目标 1224 | //----------------------------------------------------------- 1225 | addr.sin_family = AF_INET; 1226 | host = gethostbyname("localhost"); 1227 | memcpy(&addr.sin_addr, host->h_addr, sizeof(addr.sin_addr)); 1228 | addr.sin_port = htons(13); /* daytime service */ 1229 | //----------------------------------------------------------- 1230 | connect(sock, (struct sockaddr*)&addr, sizeof(addr)); 1231 | n = read(sock, buf, sizeof(buf)); 1232 | buf[n] = '\0'; 1233 | fputs(buf, stdout); 1234 | } 1235 | 1236 | C>图2 用C语言编写的网络客户端 1237 | 1238 | 13号端口是返回当前时间的“daytime”服务端口号码,所返回的当前时间是一个字符串。最近的操作系统倾向于关闭所有不必要的服务,因此daytime服务可能不可用。如果你电脑上的daytime服务正常工作的话,运行这个程序将显示类似下面这样的字符串: 1239 | 1240 | {lang="text"} 1241 | Sat Oct 10 15:26:28 2009 1242 | 1243 | 用C语言来编写程序,仅仅是打开套接字并读取数据这么简单的操作,也需要十分繁琐的代码。 1244 | 1245 | 那我们就来看一看程序的内容吧。首先通过第15行的socket系统调用创建套接字。其中参数的意思是使用基于IP协议的流连接(TCP)(表4)。第3个参数“0”表示使用指定通信域的“最普通”的协议,一般情况下设为0就可以了。 1246 | 1247 | 表4 socket系统调用的参数 1248 | 1249 | |协议类型|说明| 1250 | |PF_INET|IPv4协议| 1251 | |PF_INET6|IPv6协议| 1252 | |PF_APPLETALK|ApleTalk协议| 1253 | |PF_IPX|IPX协议| 1254 | 1255 | |实例方法| 1256 | |SOCK_STREAM|字节流套接字| 1257 | |SOCK_DGRAM|数据报套接字| 1258 | 1259 | 第16~19行用于指定连接目标。sockaddr_in结构体中存放了连接目标的地址类型(类别)、主机地址和端口号。 1260 | 1261 | 需要注意的是第19行中指定端口号的htons()。htons()函数的功能是将16位整数转换为网络字节序(network byte order),即各字节的发送顺序。由于套接字连接目标的指定全部需要通过网络字节序来进行,如果忘记用这个函数进行转换的话就无法正确连接。 1262 | 1263 | 服务器端程序则更加复杂,因此在这里不再赘述,不过大家应该对用C语言处理网络连接有一个大概的了解了吧。 1264 | 1265 | ### 用Ruby进行套接字编程 1266 | 1267 | 以系统调用为基础的C语言套接字编程相当麻烦。那么,Ruby怎么样呢?图3是和图2的C语言程序拥有相同功能的Ruby程序。 1268 | 1269 | {lang="ruby"} 1270 | require 'socket' 1271 | print TCPSocket.open("localhost", "daytime").read 1272 | 1273 | C>图3 Ruby编写的网络客户端 1274 | 1275 | 值得注意的是,除了库引用声明“require”那一行之外,实质上只需要一行代码就完成了套接字连接和通信。和C语言相比,Ruby的优势相当明显。 1276 | 1277 | 用套接字进行网络编程是Ruby最擅长的领域之一,原因如下。 1278 | 1279 | #### 1. 瓶颈 1280 | 1281 | 在程序开发中,对于是否采用Ruby这样的脚本语言,犹豫不决的理由之一就是运行性能。 1282 | 1283 | 在比较简单的工作中,如果由于解释器的实现方式导致性能下降,其影响是相当大的。如果用一个简单的循环来测试程序性能,那么Ruby程序速度可能只有C语言程序的十分之一甚至百分之一。光从这一点来看,大家不禁要担心,这么慢到底能不能用呢? 1284 | 1285 | 不过,程序的运行时间其实大部分都消耗在C语言编写的类库内部,对于拥有一定规模的实用型程序来说,差距并没有那么大。 1286 | 1287 | 更进一步说,对于以网络通信为主体的程序来说,其瓶颈几乎都在于通信部分。和本地访问数据相比,网络通信的速度是非常慢的。既然瓶颈在于通信本身,那么其他部分即便运行速度再快,也和整体的性能关系不大了。 1288 | 1289 | #### 2. 高级API 1290 | 1291 | C语言中可以使用的套接字API包括结构体和多个系统调用,非常复杂。 1292 | 1293 | 在图2的C语言程序中,为了指定连接目标,必须初始化sockaddr_in结构体,非常麻烦。相对地,在Ruby中由于TCPSocket类提供了比较高级的API,因此程序可以变得更加简洁易懂。如果想和C语言一样使用套接字的全部功能,通过支持直接访问系统调用的Socket类就可以实现了。 1294 | 1295 | #### Ruby的套接字功能 1296 | 1297 | 那么,我们来详细看看Ruby的套接字功能吧。在Ruby中,套接字功能是由“socket”库来提供的。要使用socket库的功能,需要在Ruby程序中通过下面的方式来加载这个库: 1298 | 1299 | {lang="go"} 1300 | require 'socket' 1301 | 1302 | socket库中提供的类包括BasicSocket、IPSocket、TCPSocket、TCPServer、UDPSocket、UNIXSocket、UNIXServer和Socket(图4)。在客户端编程上,恐怕其中用得最多的应该是TCPSocket,而在服务器端则是TCPServer。 1303 | 1304 | C>![](images/originals/chapter4/7.jpg) 1305 | 1306 | C>图4 套接字相关的类 1307 | 1308 | 其中Socket类可以调用操作系统中套接字接口的所有功能,但由于是直接访问操作系统的接口,因此程序就会变得比较复杂。Ruby的套接字属于IO的子类,因此对套接字也可以进行普通的输入输出操作,这一点非常方便。 1309 | 1310 | BasicSocket是IO的直接子类,同时也是其他所有套接字类的超类。Ba-sicSocket是一个抽象类,并不能创建实例。BasicSocket类中的方法如表5所示。 1311 | 1312 | C>表5 BasicSocket类的方法 1313 | 1314 | |实例方法|说明| 1315 | |close_read|关闭读取| 1316 | |close_write|关闭写入| 1317 | |getpeername|连接目标套接字信息| 1318 | |getsockname|自己的套接字信息| 1319 | |getsockopt(opt)|获取套接字选项| 1320 | |recv(len[,flag])|数据接收| 1321 | |send(str[,flag])|数据发送| 1322 | |setsockopt(opt,val)|设置套接字选项| 1323 | |shutdown([how])|结束套接字通信| 1324 | 1325 | IPSocket是BasicSocket的子类,也是TCPSocket、UDPSocket的超类,它包含了这两个类共通的一些功能,也是一个抽象类。IPSocket类中的方法如表6所示。 1326 | 1327 | C>表6 IPSocket类的方法 1328 | 1329 | |实例方法|说明| 1330 | |addr|自己的套接字信息| 1331 | |peeraddr|连接目标套接字信息| 1332 | |recvfrom(len[,flag])|数据接收| 1333 | 1334 | TCPSocket是连接型套接字,即和通信对方进行连接并进行连续数据传输的套接字。TCPSocket是一个具体类(可以直接创建实例的类)。创建实例需要使用new方法,new方法的调用方式为new(host, port),可以完成套接字的创建和连接操作。 1335 | 1336 | TCPServer是TCPSocket的服务器版本,通过这些类可以大大简化服务器端的套接字处理。当为new方法指定两个参数时,可以限定只接受来自第一个参数所指定的主机的连接(表7)。 1337 | 1338 | C>表7 TCPServer类的方法 1339 | 1340 | |类方法|说明| 1341 | |new([host,] port)|套接字的创建和连接| 1342 | |实例方法|说明| 1343 | |accept|接受连接| 1344 | |listen(n)|设置连接队列| 1345 | 1346 | UDPSocket是对UDP型套接字提供支持的类。UDP型套接字是无连接型套接字,其特征是可以保存每次写入的数据长度。UDPSocket类中的方法如表8所示。 1347 | 1348 | C>表8 UDPSocket类的方法 1349 | 1350 | |类方法|说明| 1351 | |new([socktype])|创建套接字| 1352 | |实例方法|说明| 1353 | |bind(host,port)|为套接字命名| 1354 | |connect(host, port)|套接字连接| 1355 | |send(data[,flags,host,port])|发送数据| 1356 | 1357 | UNIXSocket是用于UNIX域套接字的类。UNIX域套接字是一种用于同一台计算机上进程间通信的手段,在通信目标的指定上采用“文件路径”的方式,其他方面和TCPSocket相同,也是需要连接并进行流式输入输出。UNIXSocket类中的方法如表9所示。 1358 | 1359 | C>表9 UNIXSocket类的方法 1360 | 1361 | |类方法|说明| 1362 | |new(path)|创建套接字| 1363 | |socketpair|创建套接字对| 1364 | |实例方法|说明| 1365 | |path|套接字路径| 1366 | |addr|自己的套接字信息| 1367 | |peeraddr|连接目标套接字信息| 1368 | |recvfrom(len[,flag])|数据接收| 1369 | |send_io(io)|发送文件描述符| 1370 | |recv_io([klass,mode])|接收文件描述符| 1371 | 1372 | send_io和recv_io这两个方法是UNIX域套接字的独门功夫。使用这两个方法,可以通过UNIX域套接字将文件描述符传递给其他进程。一般来说,在进程间传递文件描述符,只能通过具有父子关系的进程间共享这一种方式,但使用UNIX域套接字就可以在非父子关系的进程间实现文件描述符的传递了。 1373 | 1374 | UNIXServer是UNIXSocket的服务器版本。和TCPServer一样,用于简化套接字服务器的实现。其中所补充的方法也和TCPServer相同。 1375 | 1376 | 最后要介绍的Socket类是一个底层套接字接口。Socket类所拥有的方法对应着C语言级别的全部套接字API,因此,只要使用Socket类,就可以和C语言一样进行同样细化的程序设计,但由于这样实在太繁琐所以实际上很少用到。Socket类中的方法如表10所示,套接字相关各类的功能一览如表11所示。 1377 | 1378 | C>表10 Socket类的方法 1379 | 1380 | |类方法|说明| 1381 | |new(domain,type,protocol)|创建套接字| 1382 | |socketpair(domain,type,protocol)|创建套接字对| 1383 | |gethostname|获取主机名| 1384 | |gethostbyname(hostname)|获取主机信息| 1385 | |gethostbyaddr(addr, type)|获取主机信息| 1386 | |getservbyname(name[,proto])|获取服务信息| 1387 | |getaddrinfo(host,service[,family,type,protocol])|获取地址信息| 1388 | |getnameinfo(addr[,flags])|获取地址信息| 1389 | |pack_sockaddr_in(host,port)|创建地址结构体| 1390 | |unpack_sockaddr_in(addr)|解包地址结构体| 1391 | |实例方法|说明| 1392 | |accept|等待连接| 1393 | |bind(addr)|为套接字命名| 1394 | |connect(host, port)|连接套接字| 1395 | |listen(n)|设置连接队列| 1396 | |recvfrom(len[,flag])|数据接收| 1397 | 1398 | C>表11 套接字相关的类 1399 | 1400 | |BasicSocket|所有套接字类的超类(抽象类)| 1401 | |IPSocket|执行IP通信的抽象类| 1402 | |TCPSocket|连接型流套接字| 1403 | |TCPServer|TCPSocket用的服务器套接字| 1404 | |UDPSocket|无连接型数据报套接字| 1405 | |UNIXSocket|用于同一主机内进程间通信的套接字| 1406 | |UNIXServer|UNIXSocket用的服务器套接字| 1407 | |Socket|可使用Socket系统调用所有功能的类| 1408 | 1409 | ### 用Ruby实现网络服务器 1410 | 1411 | 我们已经通过C、Ruby两种语言介绍了客户端套接字编程的例子,下面我们来看看服务器端的设计。刚才那个访问daytime服务的程序可能有很多人都无法成功运行,于是我们来编写一个和daytime服务器拥有相同功能的服务器程序。原来的daytime服务端口只能由root账号使用(1024号以内的端口都需要root权限),因此我们将连接端口设置为12345(图5)。 1412 | 1413 | {lang="ruby"} 1414 | require 'socket' 1415 | s = TCPServer.new(12345) 1416 | loop { 1417 | cl = s.accept 1418 | cl.print Time.now.strftime("%c") 1419 | cl.close 1420 | } 1421 | 1422 | C>图5 Ruby编写的网络服务器 1423 | 1424 | 这样就完成了。网络服务器可能给人的印象很庞大,其实却出人意料地简单。这也要归功于TCPServer类所提供的高级API。 1425 | 1426 | 先运行这个程序,然后从另一个终端窗口中运行刚才的客户端程序(C语言版见图2,Ruby版见图3),运行之前别忘了将daytime的部分替换成“12345”。运行结果如果显示出类似下面这样的一个时间就表示成功了。 1427 | 1428 | {lang="text"} 1429 | Mon Jun 12 18:52:38 2006 1430 | 1431 | 下面我们来简单讲解一下图5的这个程序。第2行我们创建了一个TCPServer,参数是用于连接的端口号,仅仅如此我们就完成了TCP服务的建立。 1432 | 1433 | 第3行开始是主循环。第4行中对于TCPServer套接字调用accept方法。accept方法会等待来自客户端的连接,如果有连接请求则返回与客户端建立连接的新套接字,我们在这里将新套接字赋值给变量cl。客户端套接字是TCPSocket的对象,即IO的子类,因此它也是一个可以执行一般输入输出操作的对象。 1434 | 1435 | 第5行print当前时间,daytime服务的处理就这么多了。处理完成后将客户端套接字close掉,然后调用accept等待下一个连接。 1436 | 1437 | 图5的程序会对请求逐一进行处理。对于像daytime这样仅仅是返回一个时间的服务也许还好,如果是更加复杂的处理的话,这样可就不行了。如果Web服务器在完成前一个处理之前无法接受下一个请求,其处理性能就会下降到无法容忍的地步。在这样的情况下,使用线程或进程进行并行处理是比较常见的做法。使用线程进行并行化的程序如图6所示。 1438 | 1439 | {lang="ruby"} 1440 | require 'socket' 1441 | s = TCPServer.new(12345) 1442 | loop { 1443 | Thread.start(s.accept) { |cl| 1444 | cl.print Time.now.strftime("%c") 1445 | cl.close 1446 | } 1447 | } 1448 | 1449 | C>图6 用线程实现并行处理的程序 1450 | 1451 | 正如大家所见,用Ruby进行网络编程是非常容易的。有很多人认为提到Ruby就必然要提到Web编程,或许不如说,只有网络编程才能发挥Ruby真正的价值吧。 1452 | 1453 | ### 小结 1454 | 1455 | 利用套接字,我们就可以通过网络与地球另一端的计算机进行通信。不过,套接字所能传输的数据只是字节序列而已,如果要传输文本以外的数据,在传输前需要将数据转换为字节序列。 1456 | 1457 | 这种转换一般称为序列化(serialization)或者封送(marshaling)。在分布式编程环境中,由于会产生大量数据的传输,因此序列化通常会成为左右整体性能的一个重要因素。 1458 | 1459 | ## 4.5 Rack与Unicorn 1460 | 1461 | Web应用程序服务器主要由HTTP服务器与Web应用程序框架构成。说起HTTP服务器,Apache是很有名的一个,但除此之外还有其他很多种,例如高性能的新型轻量级服务器nginx、以纯Ruby实现并作为Ruby标准组件附带的WEBrick,以及以高速著称的Mongrel和Thin等。 1462 | 1463 | 此外,Web应用程序框架方面,除了鼎鼎大名的Ruby on Rails之外,还出现了如Ramaze、Sinatra等“后Rails”框架。于是,对于Web框架来说,就必须要对所有的HTTP服务器以及应用程序服务器提供支持,这样的组合方式真可为多如牛毛。 1464 | 1465 | 为了解决如此多的组合,出现了一种叫Rack的东西(图1)。Rack是在Python的WSGI的影响下开发的用于连接HTTP服务器与框架的库。HTTP服务器一端,只需对Rack发送请求,然后接受响应并进行处理,就可以连接所有支持Rack的框架。同样地,在框架一端也只需要提供对Rack的支持,就可以支持大多数HTTP服务器了。最近以Ruby为基础的Web应用程序框架,包括Rails在内,基本上都已经支持Rack了。 1466 | 1467 | C>![](images/originals/chapter4/8.jpg) 1468 | 1469 | C>图1 Web应用程序服务器架构 1470 | 1471 | Rack的基本原理,是将对Rack对象发送HTTP请求的“环境”作为参数来调用call方法,并将以返回值方式接收的请求组织成HTTP请求。Rack的Hello World程序如图2所示。 1472 | 1473 | {lang="ruby"} 1474 | class HelloApp 1475 | def call(env) 1476 | [200, {"Content-Type" => "text/plain"}, 1477 | ["Hello, World"]] 1478 | end 1479 | end 1480 | 1481 | C>图2 Hello World Rack应用程序 1482 | 1483 | Rack对象所需的要素包括下面两个: 1484 | 1485 | * 带有一个参数的call方法。call方法在调用时,其参数为表示请求环境的Hash。 1486 | * call方法返回带3个元素的数组。第1个元素为状态代码(整数),第2个元素为表示报头的Hash,第3个元素为数据本体(字符串数组)。 1487 | 1488 | 像Rack专用类等特殊的数据结构是不需要的。 1489 | 1490 | 好了,为了看看Rack应用程序实际是如何工作的,我们将图2的程序保存到“hello.rb”这个文件中,并另外准备一个名为“hello.ru”的配置文件(图3)。hello.ru虽然说是一个配置文件,但其实体只是一个单纯的Ruby程序而已。准备好hello.ru文件之后,我们就可以使用Rack应用程序的启动脚本“rackup”来启动应用程序了。 1491 | 1492 | {lang="ruby"} 1493 | require 'rubygems' 1494 | require 'rack' 1495 | require 'hello' 1496 | 1497 | run HelloApp.new 1498 | 1499 | C>图3 配置文件hello.ru 1500 | 1501 | {lang="shell} 1502 | $ rackup hello.ru 1503 | 1504 | 然后,我们只要用Web浏览器访问http://localhost:9292/,就会显示出Hello World了。这次我们都用了默认配置,端口号为“9292”,HTTP服务器则是用了“WEBrick”,但通过配置文件是可以修改这些配置的。 1505 | 1506 | ### Rack中间件 1507 | 1508 | Rack的规则很简单,就是将HTTP请求作为环境对象进行call调用,然后再接收响应。因此,无论是HTTP服务器还是框架都可以很容易地提供支持。 1509 | 1510 | 应用这样的机制,只要在实际对框架进行调用之前补充相应的call,就可以在不修改框架的前提下,对所有支持Rack的Web应用程序增加具备通用性的功能。 1511 | 1512 | 这种方式被称为“Rack中间件”。Rack库中默认自带的Rack中间件如表1所示。 1513 | 1514 | C>表1 Rack中间件 1515 | 1516 | C>![](images/originals/chapter4/9.jpg) 1517 | 1518 | 中间件的使用可以通过在“.ru”文件中用“use”来进行指定。例如,如果要对Web应用添加显示详细日志、产生异常时声称生成错误页面以及显示错误状态页面的功能,可以将图3的hello.ru文件改写成图4这样。每个功能的添加只需要一行代码就可以完成,可见其表述力非常优秀。 1519 | 1520 | {lang="ruby"} 1521 | require 'rubygems' 1522 | require 'rack' 1523 | require 'hello' 1524 | 1525 | use Rack::CommonLogger 1526 | use Rack::ShowExceptions 1527 | use Rack::ShowStatus 1528 | 1529 | run HelloApp.new 1530 | 1531 | C>图4 hello.ru(使用中间件) 1532 | 1533 | ### 应用程序服务器的问题 1534 | 1535 | 正如上面所讲到的,只要使用Rack,HTTP服务器与Web框架就可以进行自由组合了。这样一来,我们可以根据情况选择最合适的组合,但如果网站的流量达到一定的规模,更常见的做法是将Apache和nginx放在前端用作负载均衡,而实际的应用程序则通过Thin和Mongrel进行工作(图5)。 1536 | 1537 | C>![](images/originals/chapter4/10.jpg) 1538 | 1539 | C>图5 Web应用程序架构 1540 | 1541 | 其中,Apache(或者nginx)负责接收来自客户端的请求,然后将请求按顺序转发给下属的Thin服务器,从而充分利用I/O等待等情况所产生的空闲时间。此外,最近的服务器大多都安装了多核CPU,像这样用多个进程分担工作的架构则可以最大限度地利用多核的性能。 1542 | 1543 | Thin是一种十分快速的HTTP服务器,在大多数情况下,这样的架构已经足够了。但在某些情况下,这种架构也会发生下面这些问题。 1544 | 1545 | * 响应缓慢 1546 | * 内存开销 1547 | * 分配不均衡 1548 | * 重启缓慢 1549 | * 部署缓慢 1550 | 1551 | 下面我们来具体看看这些问题的内容。 1552 | 1553 | #### 1. 响应缓慢 1554 | 1555 | 由于应用程序的bug,或者数据库的瓶颈等原因,应用程序的响应有时候会变得缓慢。虽然这是应用方面的问题,HTTP服务器是没有责任的,但这样的问题导致超时是成为引发更大问题的元凶。 1556 | 1557 | 为了避免这样的问题对其他的请求产生过大的负面影响,默认情况下Thin会停止30秒内没有响应的任务并产生超时。但不幸的是,不知道是不是Ruby在线程实现上的关系,这个超时的机制偶尔会失灵。 1558 | 1559 | #### 2. 内存开销 1560 | 1561 | 有些情况下,负责驱动Web应用程序的Thin等服务器程序的内存开销会变得非常大。这种内存开销的增加往往是由于数据库连接未释放,或者垃圾回收机制未能回收已经死亡的对象等各种原因引发的。 1562 | 1563 | 无论如何,服务器上的内存容量总归是有限的,如果内存开销产生过多的浪费,就会降低整体的性能。 1564 | 1565 | 和响应缓慢一样,内存开销问题也可能会引发其他的问题。内存不足会导致处理负担增加,处理负担增加会导致其他请求响应变慢,响应变慢又会导致用户不断尝试刷新页面,结果让情况变得更加糟糕。一旦某个环节出现了问题,就会像“多米诺骨牌”一样产生连锁反应,这样的情况不算少见。 1566 | 1567 | #### 3. 分配不均衡 1568 | 1569 | 用Apache或nginx作为反向代理,对多个Thin服务器进行请求分配的情况下,请求会由前端服务器按顺序转发给下属的Thin服务器。这种形态很像是上层服务器对下层的“发号施令”,因此又被称为推模式(pushmodel)。 1570 | 1571 | 一般来说,在推模式下,HTTP服务器将请求推送给下属服务器时,并不知道目标服务器的状态。原则上说,HTTP服务器只是按顺序依次将请求转发给下属各服务器而已。 1572 | 1573 | 然而,当请求转发目标的服务器由于某些原因没有完成对前一个请求的处理时,被分配给这个忙碌服务器的请求就只能等待,而且前一个请求也不知道什么时候才能处理完毕,只能自认倒霉了。 1574 | 1575 | #### 4. 重启缓慢 1576 | 1577 | 像上面所说的情况,一旦对请求的处理发生延迟,负面影响就会迅速波及到很大的范围。当由于某些原因导致处理消耗的时间过长时,必须迅速对服务器进行重启。虽然Thin自带超时机制,但对于内存开销,以及基于CPU时间进行服务器状态监控,需要通过Monit、God等监控程序来完成。 1578 | 1579 | 这些程序会监控服务器进程,当发现问题时将进程强制停止并重新启动,但即便如此,重启服务依然不是一件简单的事。当监控程序注意到请求处理的延迟时,马上重启服务器进程,这时,框架和应用程序的代码需要全部重新加载,恢复到可以进行请求处理的状态至少需要几秒钟的时间。而在这段 1580 | 时间中,如果有哪个倒霉的请求被分配到这个正在重启的服务器进程,就又不得不进行长时间的等待了。 1581 | 1582 | #### 5. 部署缓慢 1583 | 1584 | 当需要对Web应用程序进行升级时,就必须重启目前正在运行的所有应用程序服务器。正如刚才所讲到的,仅仅是重启多个服务器进程中的一个,就会殃及到一些不太走运的请求,如果重启所有的服务器进程的话,影响就会更大。哪怕Web应用整体仅仅停止响应10秒钟,对于访问量很大的网站来说,也会带来超乎想象的损失。 1585 | 1586 | 有一种说法指出,对于网站从开始访问到显示出网页之间的等待时间,一般用户平均可以接受的极限为4秒。由于这个时间是数据传输的时间和浏览器渲染HTML时间的总和,因此Web应用程序用于处理请求的时间应尽量控制在3秒以内。 1587 | 1588 | 如果上述说法成立的话,那么目前这种在前端配置一个反向代理,并将请求推送给下属服务器的架构,虽然平常没有问题,但一旦发生问题,其负面影响就很容易迅速扩大,这的确可以说是一个缺点。 1589 | 1590 | 于是我们下面要介绍的,就是一个面向UNIX的Rack HTTP服务器——Unicorn。Unicorn是以解决上述问题为目标而开发的高速HTTP服务器。之所以说是“面向UNIX”的,是因为Unicorn使用了UNIX系操作系统所提供的fork系统调用以及UNIX域套接字,因此无法在Windows上工作。 1591 | 1592 | ### Unicorn的架构 1593 | 1594 | Unicorn系统架构如图6所示。这张图上使用的是Apache,换成nginx也是一样的。 1595 | 1596 | C>![](images/originals/chapter4/11.jpg) 1597 | 1598 | C>图6 Web应用程序架构 1599 | 1600 | Unicorn和图5中采用的Thin在架构上的区别在于:Apache只需要通过UNIX域套接字和单一的Master进行通信。 1601 | 1602 | 在采用Thin的架构中,Apache负责负载均衡,为下属各服务器分配请求,而在采用Unicorn的架构中,Apache只需要和一个称为Master的进程进行通信即可。这种通信是通过UNIX域套接字来完成的。 1603 | 1604 | 一般的套接字都是通过主机名和端口号来指定通信对象,而UNIX域套接字则是通过路径来指定的。服务器端(数据的接收方)通过指定一个路径来创建UNIX域套接字时,在指定的路径就会生成一个特殊的文件。开始通信的一方只要像一般文件一样写入数据,在接收方看来就像是在通过套接字来进行通信一样。 1605 | 1606 | UNIX域套接字具有一些方便的特性:①客户端可以像文件一样来进行读写操作;②进程之间不具备父子关系也可以进行通信。不过它也有缺点,由于通信对象的指定是采用路径的形式,因此只能用于同一台主机上的进程间通信。 1607 | 1608 | 然而,对于多台主机的分布式环境,也有通过Unicorn进行负载均衡的需求,这种情况下也可以用TCP套接字来代替UNIX域套接字,虽然性能会有一定的下降。 1609 | 1610 | 由Apache转发给Unicorn-Master的请求,会把转发给由Master通过fork系统调用启动的Slave,而实际的处理会在Slave中完成。然后,Master会在Slave处理完成之后,将响应转发给Apache。 1611 | 1612 | ### Unicorn的解决方案 1613 | 1614 | 不过,这样的机制如何解决Thin等所遇到的问题呢?对于上面提到的那5个问题,我们逐一来看一看。 1615 | 1616 | #### 1. 响应缓慢 1617 | 1618 | Web应用响应慢,本质上说还是应用自身的问题,因此无法保证一定能够避免。对于服务器来说,重要的是,当问题出现时如何避免波及到其他无关的请求。 1619 | 1620 | 在简单的推模式中,转发请求的时候,并不会向被分配到请求的服务器确认其是否已经完成对上一个请求的处理,因此导致对其他无关请求的处理发生延迟。 1621 | 1622 | 相对地,在Unicorn中,完成处理的Slave会主动去获取请求,即拉模式(pull model),因此从原理上说,不会发生某个请求被卡死在一个忙碌的服务器进程中的情况。 1623 | 1624 | 不过,即便是在拉模式下,也并非完全不存在请求等待的问题。当访问量超出了Slave的绝对处理能力时,由于没有空闲的Slave能够向Master索取请求,于是新来的请求便不得不在Master中进行等待了。 1625 | 1626 | 如果由于某种原因导致Slave完全停止运行的情况下,由于可用的Slave少了一个,整体的处理能力也就随之下降了。在Unicorn中,对于这样的情况所采取的措施是,当发现某个Slave的处理超出规定时间则强制重启该Slave。 1627 | 1628 | #### 2. 内存开销 1629 | 1630 | 和响应缓慢的问题一样,内存的消耗也会影响到其他的请求。因此,当发生问题时,最重要的是如何在不影响其他请求的前提下完成重启。由于Unicorn可以快速完成对Slave的重启,因此在可以比较轻松地应对内存消耗的问题,理由我们稍后再介绍。 1631 | 1632 | #### 3. 分配不均 1633 | 1634 | 正如之前所讲过的,在采用拉模式的Unicorn中,不会发生将请求分配给不可用的服务器进程的问题。在Unicorn中,来自Apache的请求会通过UNIX域套接字传递给单一的Unicorn-Master,而下属的Slave会在自己的请求处理完成之后向Master索取下一个请求。综上所述,在Unicorn中不会发生分配不均的问题。 1635 | 1636 | #### 4. 重启缓慢 1637 | 1638 | 采用拉模式来避免分配不均是Unicorn的一大优点,而另一大优点就是它能够快速重启。 1639 | 1640 | Unicorn对Slave进行重启时有两个有利因素。第一,由于采用了拉模式,因此即便重启过程中某个Slave无法工作,也不用担心会有任何请求分配到该Slave上,这样一来,整体上来看就不会发生处理的停滞。 1641 | 1642 | 第二,Unicorn在Slave的启动方法上很有讲究,使得实际重启所花费的时间因此得以大大缩短。 1643 | 1644 | 当由于超时、内存开销过大等原因被监控程序强制终止,或者由于其他原因导致Slave进程停止时,Master会注意到Slave进程停止工作,并立即通过fork系统调用创建自身进程的副本,并使其作为新Slave进程来启动。由于Unicorn-Master在最开始启动时就已经载入了包括框架和应用程序在内的全部代码,因此在启动Slave时只需要调用fork系统调用,并将Slave用的处理切换到子进程中就可以了。 1645 | 1646 | 在最近的UNIX系操作系统中,都具备了“Copy-on-Write”(写时复制)功能,从而不需要在复制进程的同时复制内存数据。只需要在内核中重新分配一个表示进程的结构体,该进程所分配的内存空间就可以与调用fork系统调用的父进程实现共享。随着进程的执行,当实际发生对内存数据的改写时,仅将发生改写的内存页进行复制,也就是说,对内存的复制是随着进程执行的过程而循序渐进的,这样一来,基本上就可以避免因内存复制的延迟导致的Slave启动开销。 1647 | 1648 | Thin等应用程序服务器的重启过程则更为复杂。首先,需要启动Ruby解释器,然后还要重新载入框架和应用程序代码。相比之下,运用了Unicorn系统中的Slave的重启时间要短得多,这样一来,就可以毫不犹豫地重启发生问题的Slave。此外,由于恢复工作可以快速完成,也可以避免系统整体响应产生延迟。 1649 | 1650 | #### 5. 部署缓慢 1651 | 1652 | Slave重启的速度很快,也就意味着需要服务器整体重启的部署工作也可以快速完成。此外,在Unicorn中,针对缩短部署时间还进行了其他一些优化。当Unicorn-Master进程收到USR2信号(稍后详述)时,Master会进行下述操作步骤: 1653 | 1654 | **(1)启动新Master** 1655 | 1656 | Master在收到USR2信号后,会启动Ruby解释器,运行一个新Master进程。 1657 | 1658 | **(2)重新加载新** 1659 | 1660 | Master载入框架和应用程序代码。这个过程和Thin的重启一样,需要消耗一定的时间。但在这个过程中,旧Master依然在工作,因此没有任何问题。 1661 | 1662 | **(3)启动Slave** 1663 | 1664 | 新Master通过fork系统调用启动Slave,这样一来一个新版本的Web应用就准备完毕,可以提供服务了。 1665 | 1666 | 当新Master启动第一个Slave时,该Slave如果检测到存在旧Master,则对其进程发送“QUIT”信号,命令旧Master结束进程。 1667 | 1668 | 然后,新Master开始运行新版本的应用程序。此时,旧Master依然存在,但服务的切换工作本身已经完成了。 1669 | 1670 | **(4)旧Master结束** 1671 | 1672 | 收到QUIT信号的旧Master会停止接受请求,并对Slave发出停止命令。旧Slave继续处理现存的请求,并在处理完毕后结束运行。当确认所有Slave结束后,旧Master本身也结束运行。到此为止,Unicorn整体重启过程就完成了,而服务停止的时间为零。 1673 | 1674 | #### 6. 信号 1675 | 1676 | 在Unicorn重启的部分我们提到了“信号”这个概念。对于UNIX系操作系统不太了解的读者可能看不明白,信号也是UNIX中进程间通信的手段之一,但信号只是用于传达某种事件发生的通知而已,并不能随附其他数据。 1677 | 1678 | 信号的种类如表2所示,用kill命令可以发送给进程。 1679 | 1680 | {lang="shell"} 1681 | $ kill -QUIT <进程ID> 1682 | 1683 | C>表2 UNIX信号一览表(具有代表性的) 1684 | 1685 | |名称|功能|默认动作| 1686 | |HUP|挂起|Term| 1687 | |INT|键盘中断|Term| 1688 | |QUIT|键盘终止|Core| 1689 | |ILL|非法命令|Core| 1690 | |ABRT|程序的abort|Core| 1691 | |FPE|浮点数异常|Core| 1692 | |KILL|强制结束(不可捕获)|Term| 1693 | |SEGV|非法内存访问|Core| 1694 | |BUS|总线错误|Core| 1695 | |PIPE|向另一端无连接的管道写入数据|Term| 1696 | |ALRM|计时器信号|Term| 1697 | |TERM|结束信号|Term| 1698 | |USR1|用户定义信号1|Term| 1699 | |USR2|用户定义信号2|Term| 1700 | |CHLD|子进程暂停或结束|Ign| 1701 | |STOP|进程暂停(不可捕获)|Stop| 1702 | |CONT|进程恢复|Cont| 1703 | |TSTP|来自tty的stop|Stop| 1704 | |TTIN|后台tty输入|Stop| 1705 | |TTOU|后台tty输出|Stop| 1706 | 1707 | 当kill命令中没有指定信号名时,则默认发送INT信号。在程序中则可以使用kill系统调用来发送信号,Ruby中也有用于调用kill系统调用的Process.kill方法。 1708 | 1709 | 这些信号根据各自的目的都设有默认的动作,默认动作根据不同的信号分为Term(进程结束)、Ign(忽略信号)、Core(内核转储)、Stop(进程暂停)、Cont(进程恢复)5种。如果程序对于信号配置了专用的处理过程(handler),则可以对这些信号进行特殊处置。不过,KILL信号和STOP信号是无法改变处理过程的,因此即便因某个软件中配置了特殊的处理过程而无法通过TERM信号来结束,也可以通过发送KILL信号来强制结束。 1710 | 1711 | 信号原本是为特定状况下对操作系统和进程进行通知而设计的。例如,在终端窗口中按下Ctrl+C,则会对当前运行中的进程发送一个SIGINT。 1712 | 1713 | 然而,信号不光可以用来发送系统通知,也经常用来从外部向进程发送命令。在这些信号中,已经为用户准备了像USR1和USR2这两种可自定义的信号。 1714 | 1715 | Unicorn中也充分运用了信号机制。刚才我们已经讲到过,向Slave发送QUIT信号可以使其结束。Master/Slave对于各个信号的响应方式如表3所示,其中有一些信号的功能看起来被修改得面目全非(比如TTIN),这也算是“UNIX流派”所特有的风格吧。 1716 | 1717 | C>表3 Unicorn的信号响应 1718 | 1719 | |Master|| 1720 | |信号|动作| 1721 | |HUP|重新读取配置文件,重新载入程序| 1722 | |INT/TERM|立刻停止所有Slave| 1723 | |QUIT|向所有Slave发送QUIT信号,等待请求处理完毕后结束| 1724 | |USR1|重新打开日志| 1725 | |USR2|系统重启。重启完成后当前Master会收到QUIT信号| 1726 | |TTIN|增加一个Slave| 1727 | |TTOU|减少一个Slave| 1728 | |Slave|| 1729 | |信号|动作| 1730 | |INT/TERM|立即停止| 1731 | |QUIT|当前请求处理完毕后结束| 1732 | |USR1|重新打开日志| 1733 | 1734 | 信号还可以通过Shell或者其他程序来发送,因此,编写一个用于从外部重启Unicorn的脚本也是很容易的。 1735 | 1736 | ### 性能 1737 | 1738 | 在http://github.com/blog/517-unicorn专栏中,对Uni-corn的性能与Mongrel进行了比较。根据这篇评测,无调优默认状态下的Unicorn,性能已经稍优于Mongrel了。尽管Thin比Mongrel的性能更好一些,但Unicorn决不会甘拜下风的。 1739 | 1740 | 考虑到Unicorn几乎全部是用Ruby编写的(除HTTP报头解析器外)这一点,可以说是实现了非常优秀的性能。此外,从刚才所介绍的Unicorn的特点来看,在大多数情况下,用Uni-corn来替代Mongrel和Thin还是有一定好处的。 1741 | 1742 | 不过,Unicorn也并非万能。Unicorn只适合每个请求处理时间很短的应用,而对于应用程序本身来说,在外部(如数据库查询等)消耗更多时间的情况,则不是很适合。 1743 | 1744 | 对于Unicorn来说,最棘手的莫过于像Comet这种,服务器端基本处于待机状态,根据状况变化推送响应的应用了。在Unicorn中,由于每个请求需要由一个进程来处理,这样会造成Slave数量不足,无法满足请求的处理,最终导致应用程序整体卡死。对于这样的应用程序,应该使用其他的一些技术,使得通过少量的资源就能够接受大量的连接。 1745 | 1746 | 为了弥补Unicorn的这些缺点,又出现了一个名叫“Rain-bows!”的项目。在Rainbows!中,可以对N个进程分配M个请求,从而缓和大量的连接数和有限的进程数之间的落差。 1747 | 1748 | ### 策略 1749 | 1750 | 综上所述,Unicorn的关键是,不是由HTTP服务器来主动进行负载均衡,而是采用了完成工作的Slave主动获取请求的拉模式。对于Slave之间的任务分配则通过操作系统的任务切换来完成。这个案例表明,在大多数情况下,与其让身为用户应用的HTTP服务器来进行拙劣的任务分配,还不如将这种工作交给内核这个资源管理的第一负责人来完成。 1751 | 1752 | 另一个关键是对UNIX系操作系统功能的充分利用。例如,通过fork系统调用以及背后的Copy-on-Write技术加速Slave的启动。UNIX中最近才加入了线程功能,Unicorn则选择不依赖线程,而是对已经“过气”的进程技术加以最大限度的充分利用。线程由于可以共享内存空间,从性能上来说比进程要更加有利一些。但反过来说,正是因为内存空间的共享,使得它容易引发各种问题。因此Unicorn很干脆地放弃了使用线程的方法。 1753 | 1754 | 如此,Unicorn充分利用了UNIX系操作系统长年积累下来的智慧,在保持简洁的同时,提供了充分的性能和易管理性。 1755 | 1756 | 近年来,由于考虑到C10K问题(客户端超过一万台的问题)而采用事件驱动模型等,Web应用程序也在用户应用程序的水平上变得越来越复杂。但Unicorn却通过将复杂的工作交给操作系统来完成,从而实现了简洁的架构。因为事件处理、任务切换等等本来就是操作系统所具备的功能。当然,仅凭Unicorn在客户端并发连接的处理上还是存在极限,如果请求数量过大也有可能处理不过来,但我们可以使用反向代理,将多个Unicorn系统捆绑起来,从而实现横向扩展(图7)。 1757 | 1758 | C>![](images/originals/chapter4/12.jpg) 1759 | 1760 | C>图7 Unicorn的横向扩展 1761 | 1762 | ### 小结 1763 | 1764 | Unicorn最大限度利用了UNIX的优点,同时实现了高性能和易管理性。此外,它采用了进程模式而非线程模式、拉模式而非推模式,通过追求实现的简洁,实现了优秀的特性,对于这一点我非常喜欢。今后,随着服务器端多任务处理的需求不断增加,像Unicorn这样简洁的方式会越来越体现其价值。 1765 | 1766 | **“云计算时代的编程”后记** 1767 | 1768 | 首先,关于HashFold我想做一些补充。HashFold从首次出现在我的专题连载中,到现在已经过了两年半的时间,它不但没有引起广泛关注,反倒是完全消亡了。对于使用Hash而非流(stream)的这个主意我觉得很有趣,但仅凭有趣还是无法推动潮流的吧。只不过,文章的内容本身,作为使用线程和进程来进行数据处理的实例来说,还是有足够的价值的,因此我还是决定将它放在这本书中。 1769 | 1770 | 现在反观HashFold,在大量数据的处理上,比起运用Hash这样“容器”型数据结构的模型来说,我感觉“流”处理的方式在印象上要更好一些。此外,HashFold真的要普及的话,最重要的是需要像Hadoop这样对MapReduce的高性能实现,而仅凭纸上谈兵恐怕是不会有什么结果的。 1771 | 1772 | 在思考今后“云计算时代的编程”这个话题的时候,本章中介绍的内容应该还会作为基础的技术继续存在下去,但程序员所看到的“表面”的抽象程度应该会越来越高。 1773 | 1774 | 今后,随着云计算的普及,节点的数量也会不断增加,对每个节点进行管理也几乎会变成一件不可能完成的事情。于是,节点就成了性能实现的一个“单位”,而作为一个单个硬件的节点概念则会逐步被忽略。在这样的环境中,恐怕不会再进行以显式指定节点的方式通信这样的程序设计了吧。说不定,在Linda这个系统中所提供的“黑板模型”会再次引起大家的关注。 1775 | 1776 | 这种模型是利用一块共享的黑板(称为tuple space),先在上面写入信息,需要的人读取上面的信息并完成工作,再将结果写到黑板上。在Ruby中也利用dRuby提供了一个叫做Rinda的系统。 1777 | 1778 | 虽然Linda是20世纪80年代的一项古老的技术,但借着云计算的潮流,在这个领域中也不断要求我们温故而知新吧。 1779 | -------------------------------------------------------------------------------- /manuscript/译者序.md: -------------------------------------------------------------------------------- 1 | # 译者序 2 | 3 | 依靠其简洁、优雅的语言特色,以及Rails等开发框架的成功,Ruby在Web开发领域早已成为一种人气颇高的动态脚本语言。然而,当今世界上流行的编程语言中,只有Ruby来自亚洲,作为Ruby语言的发明者,松本行弘(Matz)表示自己常因此而感到孤独。 4 | 5 | 作为这本书的译者,2012年11月借中国Ruby大会的机会,我有幸以图灵特派记者的身份对Matz进行了一次专访。穿着UNIQLO的格子衬衫,充满技术宅范儿的Matz,平时看起来不苟言笑,谈起技术话题来就好像打开了话匣子一般滔滔不绝,在Twitter上的发言也相当活跃。在访谈中,Matz谈到了Ruby的发展方向,他希望Ruby能够在Web开发之外的领域(科学计算、高性能计算和嵌入式系统)有更多的发展,同时他也希望中国的程序员们能够积极为开源社区做出贡献,努力成为能够影响世界的工程师。 6 | 7 | Matz一直称自己是一个普通的程序员,创造Ruby只不过是他编程生涯中的一小部分。无论是以“资深UNIX程序员”的身份,还是“Ruby之父”的身份,Matz都有足够的资格对现今的编程语言和技术品头论足;另一方面,计算机技术的发展可谓日新月异,Matz认为有必要从过去到未来,以发展的眼光来看待这些技术的演进。用资深程序员的视角和发展的眼光来剖析技术,这就是*Matz*笔下的《代码的未来》。 8 | 9 | 在这本书中,Matz将和大家一起探讨丰富多彩的技术话题,并对编程语言的未来发展趋势做出自己的预测。像Lisp这样拥有最简核心的函数型语言真的会是未来的发展趋势吗?垃圾回收、闭包、高阶函数、元编程等编程语言中的要素是如何发展出来的?Google为什么要开发Go和Dart,它们能取代C语言和JavaScript吗?大数据时代经常提到的Hadoop、MapReduce、NoSQL等名词到底是什么意思?关系型数据库真的已经走到穷途末路了吗?要充分运用多核心和分布式环境,在软件层面需要做出怎样的应对,又有哪些技术可以使用?如果你对上面这些话题感兴趣,无论心中是否已经有了自己的答案,都可以看一看来自Matz的解读。 10 | 11 | 和《松本行弘的程序世界》一样,这本书也是Matz在《日经Linux》杂志连载的专栏文章的一个合集,书中选取的文章之间有近四年的时间跨度,且章节的安排也和原稿写作的时间顺序有所不同。不了解这个背景的读者,可能会被书中一些貌似前后重复或者“穿越”的地方搞得一头雾水——少安毋躁,这不是bug。相比《松本行弘的程序世界》的14个主题来说,这本书的主题更加集中和深入,而不变的是,话题依然丰富,观点依然犀利,内容依然扎实,读起来畅快淋漓。 12 | 13 | 最后,感谢*Matz*在本书翻译过程中所给予的帮助和指导,感谢图灵公司各位编辑的辛苦工作,希望每位读者都能够从中有所收获。 14 | 15 | 周自恒 2013年3月于上海 --------------------------------------------------------------------------------