ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
# 二、深入底层 我在自己的Web站点上花了许多时间聊关于令人激动的“巨幅图画”素材方面的内容, 比如说.NET与Java啦,XML策略啦,锁定啦,竞争策略啦,软件设计啦,体系结构啦,不一 而足。所有这些素材在某种意义上讲,都是一块夹心蛋糕。位于顶层的是软件策略,接下来 是诸如.NET之类的体系结构,然后是各种产品,即Java之类的软件开发产品,或者Windows 之类的平台。 请继续往蛋糕的底层看。是动态连接库DLL?对象?函数?不!还要看得更低一些!在 某个点上,要考虑用编程语言书写的代码行。 这样的层次还不够低,今天我要考察的是CPU—一不停地向四周传输字节的小块硅片。 假设您是一位编程新手,现在不妨抛弃业己建立的编程、软件与管理等方面的所有知识,重 新回到底层的冯?诺依曼(Von Neumann)基本素材上来,并且暂时将」2EE踢出脑海,而只 考虑“字节”。 这样做的意义何在?我觉得,人们所犯的一些最大错误(即使在体系结构的最高层)的 根源在于,对处于最底层的几个简单事物理解不够或者一知半解。虽然金碧辉煌的宫殿己经 建起来了,但地基却是一团糟。所用的建材不是上好的水泥板,而是零乱的碎石。于是乎, 宫殿虽然很好看,但浴缸不时在浴室的地面滑来滑去,而让人不知所措。 好了,现在做一次深呼吸。看着我,这个小小的练习将用C语言来完成。 记住C语言中字符串的工作方式:字符串由系列字节组成,后跟一个取值为0[1]的空 (null)字符。这里很明确地表明了两点: 1. 如果不遍历字符串并查找末尾的空字符,就没有办法知道字符串在何处结束(即字 符串长度)。 2. 字符串中不能包含任何零。因此,C字符串中不能存放诸如JPEG图片之类的任何二进制数据块。 为什么C字符串要以这种方式工作?那是因为在上面开发了 UNIX与C程序设计语言的 PDP-7微处理器有一种ASCIZ字符串类型。ASCIZ的中文意思是“末尾有一个零的ASCII”。 这是存放字符串的惟一途径吗?不是。事实上,这是存放字符串最差的方式之一。对于 重要程序、API、操作系统与类库,用户应该像躲避瘟疫一样地避开使用ASCIZ字符串。为 什么呢? 下面从编写函数strcat的一个代码版本入手进行讨论。该函数的功能是将一个字符串附 加在另一个字符串之后。 ``` void strcat( char* dest, char* src ) { while (*dest) dest++; while (*dest++ = *src++); } ``` 对代码稍作研究,就可以明白这个函数正在做些什么。首先,它遍历第一个字符串以寻 找空终止字符。一旦找到空字符,就逐字符将第二个字符串一次性拷贝到第一个字符串之后。 虽然这种_*_作与串接字符串的方法对于Kernighan与Ritchie[2]来说己经足够好了,但 是也有它的问题。比如,假设有一串名字要在一个大字符串中串接起来,那就会暴露出来一 些问题: ``` char bigString[1000]; /*永远也不知道需要分配多大的存储空间...*/ bigString[0] = '\0'; strcat(bigString,MJohn,"); strcat(bigString,"aul,"); strcat(bigString,"George,"); strcat(bigString,"Joel "); ``` 这能够应付,不是吗?是的。并且,看起来也很漂亮整洁。 函数性能如何?能够运行得尽可能快吗?可扩展性如何?要是有一百万个字符串要追 加,这样处理还算得上很好的方式吗? 不,该代码使用的是蹩脚的Shlemiel喷涂算法。Shlemiel是谁?他是下面这则笑话中的 人物: Shlemiel得到一份当街道油漆匠的工作,工作内容是在马路中间喷涂点画线。第一天, 他拿出一罐漆来到他负责的路段,喷涂了 300码长的线。“干得不错! ”他的老板称赞道, “真是一位麻利的工匠”,然后赏给他一个戈比(一种俄罗斯辅币,译者注)。 第二天,Shlemiel只喷涂了 150码。“喏,虽然不如昨天那样好,但你仍然算得上一位 麻利的工匠! 150码还是值得肯定的一个长度,”老板说完又赏给他一戈比。 接下来的一天,Shlemiel只喷涂了30码长的马路。“才30码!”他的老板吼道。“这太 令人难以接受了!第一天你干的工作量是今天的10倍!接下来是怎么回事?〃 “我尽力了,”Shlemiel说道。“一天一天下去,我离油漆罐越来越远!〃 (对于规模特大的应用来说,到底要“赊”多大存储空间[3] ?)这则蹩脚的笑话很贴 切地说明了,如果像前面给出的代码那样使用strcat函数将会出现什么结果。由于strcat 的第一部分代码必须每次都扫描整个目的字符串,以反复寻找那个捉摸不定的空终止字符, 因此该函数将比所希望的速度慢得多,并且它根本谈不上存在伸缩性。你每天使用的许多代 码都有这个问题。很多文件系统是以一种在一个目录中放太多的文件的不恰当方式来实现的。 说它不恰当的原因在于,一旦目录中达到成千上万个条目时,其运行性能就开始急剧下降。 试着打开一个装填过度的Windows垃圾箱来看看运行情况如何一一要花几个小时才能显示出 文件,它与所包含的文件数目基本上不成线性关系。这里面的某个地方必定存在一个Shlemiel喷涂算法。不管什么时候,只要某个应用项目看起来应该具有线性性能,可它却表 现出n2的性能,那么就得寻找隐藏于其中的那些Shlemiel们。它们通常是被库文件隐藏起来 了。看到一个循环体中出现一列或者一个strcat函数未必要大声嚷嚷“找到n2 了”,但那 一定是问题之所在。 如何修正strcat函数呢?有几个聪明的C语言程序员是这样来实现他们自己的mystrcat 函数的: ``` char* mystrcat( char* dest, char* src ) { while (*dest) dest++; while (*dest++ = *src++); return --dest; } ``` 这里的代码做了些什么?以非常小的额外代价,就返回了一个指向新的较长字符串尾部 的指针。如此一来,调用该函数的代码就能够决定进一步处理字符串附加_*_作,而不用首 先重新扫描整个字符串了: ``` char bigString[1000]; /*永远不知道要分配多少存储空间*/ char *p = bigString; bigString[0] = '\0'; p = mystrcat(p'John,"); p = mystrcat(p,"aul,"); p = mystrcat(p,"George,"); p = mystrcat(p,"Joel "); ``` 这个性能当然是线性而不是n2的,因此它不会在串接许多字符串时而导致性能下降。 Pascal语言的设计人员意识到了这个问题,并通过将字符串的首字节用于存放字节个数,对该问题加以解决。由此得到的字符串称为Pascal字符串。这样的字符串可以包含取值为0 的字节,它不会被当做空终止字符。由于一个字节可以表示的最大数值为255,因此Pascal 字符串只能限于255个字节的长度。但由于它们不是以空字符作为终止符,所以占据与ASCIZ 字符串相同数量的存储器。Pascal字符串最主要的亮点在于用户完全没有必要仅仅为了弄清 字符串的长度而使用一个循环语句体。确定一个Pascal字符串的长度只用到一条汇编指令, 而不是整整一个循环体。这是非常快的。 旧的Macintosh***作系统处处用到了 Pascal字符串。许多C程序员在其他平台上也用到 了Pascal字符串以提高处理速度。Excel在内部使用Pascal字符串,这就解释了为什么Excel 中许多地方的字符串限于255个字节的原因,同时它也是Excel的速度快得出奇的一个原因。 在相当长的一段时间内,如果想在C代码中放一个Pascal字符串文本,就不得不写一条 如下形式的代码: ``` char* str = "\006Hello!"; ``` 对啰,用户得亲自动手计算字节的数目,并将它以编码的形式放在字符串的第一个字节 之中。偷懒的程序员会按如下形式来实现该功能,但得到的程序运行起来很慢: ``` char* str = "*Hello!"; str[0] = strlen(str) - 1; ``` 需要引起注意的是,在这种情况下得到的字符串,同时也是以空字符作为终止符的(由 编译器来做这件事)。我习惯于将它们称为杂交串,因为这比称它们是以空字符结尾的Pascal 串要简洁一些。不过,这是一个高速变化的频道,因此将来得使用较长的名称。 前面忽略了一个重要的问题。还记得这样的一个代码行吗? ``` char bigString[1000]; /*从不知道要分配多少存储空间 */ ``` 既然现在谈到了位,就不应该忽略掉这个问题。我本来应该正确地处理该问题:弄清楚 到底需要多少个字节,并分配合适的内存量。 难道不应该吗? 大家知道,如果不这样做,那么聪明的黑客会读取该代码,并注意到用户只分配了 1000 个字节而觉得它够用了。黑客们还会找到一些比较聪明的方式,诱骗用户将1100个字节的字 符串用strcat放到1000个字节的内存中,从而重写堆栈页面并改变返回地址。结果造成在该 函数返回时,它执行了黑客自己写的一些代码。这就是他们在说某特殊程序具有缓冲区溢出 易感性(buffer overflow susceptibility)时所谈论的内容。在那一段Microsoft Outlook 使黑客攻击行为容易得让十来岁的小孩也可以做到之前的岁月里,这曾经是出现黑客和蠕虫 的首要成因。 好了,看来那些程序员都不过是些跛脚驴。他们本来应该去弄清需要分配多少内存的。 不过,说实在话,对于一般程序员而言,C语言并没有使该问题变得容易解决。还是回 过头来看看前面有关那个披头士的例子吧: ``` char bigString[1000]; /*从不知道要分配多少内存*/ char *p = bigString; bigString[0] = '\0'; p = mystrcatCp'John,"); p = mystrcat(p,"aul,"); p = myst「cat(p,"Geo「ge,"); p = mystrcat(p,"Joel "); ``` 应该分配多少内存?不妨试着以合理的方式来做这件事。 ``` char* bigString; int i = 0; i = strlen("John,") + strlen("aul,") + strlen("George,") + strlen("Joel "); bigString = (char*) malloc (i + 1); /*别忘了存放空终止字符也要空间! */ ``` 我的眼睛花了。或许读者己经准备换频道了。我不责怪你,但烦请忍耐一下,因为真正 令人感兴趣的内容到来了。 仅仅为了弄清字符串有多大,就必须一次扫描完所有的字符串,然后在串接时还要再次 对它们进行扫描。如果使用Pascal字符串,至少strlen***作是很快的。也许,我们可以编 写一个能够为我们重新分配内存的strcat函数版本。 它将另外一整罐蠕虫(内存分配器)打开。你知道malloc函数是如何工作的吗? malloc 函数的实质体现在,它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表。调用 malloc函数时,它沿连接表寻找一个大到足以满足用户请求所需要的内存块。然后,将该内 存块一分为二(一块的大小与用户请求的大小相等,另一块的大小就是剩下的字节)。接下 来,将分配给用户的那块内存传给用户,并将剩下的那块(如果有的话)返回到连接表上。 调用free函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的 小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要 求的片段了。于是,malloc函数请求延时,并开始在空闲链上翻箱倒柜地检查各内存片段, 对它们进行整理,将相邻的小空闲块合并成较大的内存块。 这要用去三天的时间。经过一通“鸡飞狗跳”的混乱状况之后所得到的最终结果变成: malloc的性能表现为永远也快不起来(总是要遍历空闲链),有时候甚至显得不可预测,在 清理内存时慢得使人害怕。(顺便说一下,这与垃圾收集系统的性能特征类似,不令人吃惊 才怪呢。可见,人们做出的关于垃圾收集行为如何导致性能损失的所有断言都不完全成立。 因为典型的malloc实现形式具有同样的性能损失,尽管略显温和。) 聪明的程序员通过总是分配大小为2的幂的内存块,而最大限度地降低潜在的malloc性 能丧失。也就是说,所分配的内存块大小为4字节、8字节、16字节、18446744073709551616 字节,等等。至于说原因,任何与Lego打过交道的人应该是一目了然的,这样做最大限度地 减少了进入空闲链的怪异片段(各种尺寸的小片段都有)的数量。尽管看起来这好像浪费了 空间,但也容易看出浪费的空间永远不会超过50%。因此,程序不会使用大小超过所需尺寸 两倍的内存,这并不是大得不得了。 现在,不妨假设自己要编写可以自动重新分配目的缓冲区的“智能〃st「cat函数。总是 应该分配所需要的实际尺寸吗?我的老师兼顾问Stan Eisenstat [4]建议,在调用realloc 函数时,应分配出两倍于以前所分配的内存。这意味着,用户决不会调用「ealloc函数达lg (n)次以上。该性能特征即使对于大型字符串也可以忍受,并且决不会浪费多于50%的内存。 总而言之,在字节领地里,越往下走,生活就显得越来越凌乱。你难道不对自己再也用 不着使用C语言编写内存分配函数而感到高兴吗?我们拥有Perl、Java、VB与XSLT这些伟大 的编程语言,它们再也不会让你去考虑此类事情。不用问为什么,它们只管去做好了。 不过,竖直的基础结构偶尔也会从起居室的中部凸现出来,于是我们就得考虑是否使用 String类,或者StringBuilde「类,或者相关方面的一些差别,因为编译器仍然没有聪明到 能够理解我们正在尽力完成的一切,从而试图帮助我们不在无意之中写出一些Shlemiel喷涂 算法。 这篇随笔文章在我写了一篇说明以XML形式存储的数据不能用SELECT author FROM books 实现性能很快的SQL语句的即兴网评[1]之后,受到了不友善攻击。正是因为大家没有理解我 在说些什么,以及既然我们整天都在CPU周围打滚,这个主张可能才更显得有意义。 关系数据库是如何实现SELECT author FROM books的?在关系数据库里,关系表(例如 books表)的每一行具有相同的字节长度,而每个字段距离各行开头的偏移量是大小固定的。 因此,如果books表的每条记录是100个字节长,并且autho「字段处在偏移量为23的位置, 那么作者们的名字就存放在第23, 123, 223, 323等位置的字节处。移到该查询结果的下一 条记录的代码是什么?大体上讲,这条代码为: ``` pointer += 100; ``` 只用了一条CPU指令!快,快,实在是快! 现在我们来看XML中的books表。 ``` <?xml blah blah> <books> <book> <title>UI Design for Programmers</title> <author>Joel Spolsky</author> </book> <book> <title>The Chop Suey Club</title> <author>Bruce Weber</author> </book> </books> ``` 马上就想到的另外一个问题是,移到下一条记录的代码是什么? 唔…… 在这一点上,好的程序员会说,喏,将XML解析成内存中的树结构吧,以便能够相当快 速地处理它。在这里,针对SELECT author FROM books语句,CPU必须完成的工作量绝对会 将人折磨至疯。正如每个编译器设计人员都知道的那样,语法分析与解释是在编译处理过程 中最慢的部分。只要谈我们在解释、分析与建立抽象的内存语法树时,发现它涉及许多处理 起来很慢的字符串素材,以及许多执行起来很慢的内存分配内容就够了。 况且,这还假定了有足够的内存用于一次性加载整个内容。对于关系数据库来说,在记 录之间执行移动_*_作的性能是固定不变的,实际上它不过是一条指令的事。这是费了好大 的力气有意为之的。同时,因为得益于内存映像文件,用户只需加载将要实际使用的那些磁 盘页面。 对于XML来说,如果要做预分析的话,那么在记录之间执行移动操作的性能是固定不变 的,只是启动时间极大;如果不做预分析,那么在记录之间执行移动操作的性能根据它前 面的记录长度而变化,并且CPU指令长达好几百条。 这在我看来意味着,如果用户讲究性能,并且数据量很大,那么就不能使用XML。如果 只有一点儿数据,或者事情做得不必很快,那么XML不失为一个好的形式。并且,如果用户 确实希望鱼与熊掌兼得,就必须找出一种途径用于紧***XML存放元数据。元数据跟Pascal 字符串中用于计数的字节的作用差不多,它向用户给出提示信息,以便不用去分析与扫描字 符串就能确定它们在文件的什么地方。当然,这样一来用户就不能使用文本编辑器去编辑文 件了,因为那会搞乱元数据,从而它不再是真正的XML了。 对于听众中三个仍然在这一点上支持我的和颜悦色者,我希望你们己经学到了些东西或 者重新考虑过一些事情。我希望针对诸如strcat与malloc函数到底如何工作之类的那些令人 厌烦的一年级计算机课程素材所做出的思考,己经向你们提供了新的工具手段,从而在处理 XML之类的技术问题时,制定出最新的关于顶层策略和体系方面的决定。 作为家庭作业,思考一下为什么Transmeta芯片总是会觉得行动迟缓?为什么关于 TABLES的原始HTML说明设计得如此之差,以致Web页面的大型表不能够快速地显示给使用调 制解调器的人们?为什么COM是如此棒,但在跨越进程边界时却不是这样的?还有,为什么 NT人将显示器驱动程序放在内核空间,而不是用户空间? 所有这些事情都要求用户去思考字节,字节影响着用户在各种体系与策略方面做出决定。 这就是我为什么坚持一种教学观点一一大学一年级学生需要从基础学起,即用C语言以及从 CPU开始向上逐步构建自己程序设计技能一一的原因。我真的从心底里厌烦在多得出奇的计 算机课程计划中,将Java看做是一门好的入门语言的做法。这些计划的理由是:Java语言虽 然很“简易”且不会陷入所有那些令人厌烦的字符串与malloc素材之中,但是可以学习特别 棒的OOP知识,从而使大型程序具有如此之多的模块化特性。 这是一场即将发生的教学灾难。一届又一届的毕业生正在侵袭我们,他们正在忽左忽右 地创建着Shlemiel油漆匠算法,而他们甚至还没有意识到这一点。原因在于,他们根本就没 有那种字符串在深层次上处理起来很困难的理念,即使在Perl脚本上也不能很好地看到那一 点。如果想在某个方面把别人教好,自己首先得从最底层开始研究。这就像空手道功夫小子 要做的事情一样。打蜡,剥蜡。打蜡,剥蜡。这样的过程要进行三个星期。然后,他就可以 轻松击败其他小孩了。