地球人(如果程序员还算是人的话)对字符串处理都不陌生,不管是低级语言还是高级语言,都离不开对字符串的处理,而常用的开发工具都对字符处操作提供了尽可能详尽的帮助,标准C中的各种字符串函数(strcpy、strcmp和strcat……),C++标准库中提供了功能更强大的string,而微软的MFC中提供的CString更是强大至极,是否还有理由写自己的字符串处理类?幸福的程序员都是相似的,你们完全可以在已存在的各种类库中选择,而我这年迈的老程序员却非常不幸,因为我有太多的理由要写自己的字符串处理类……表面看是为了终极优化,其实归根结底是因为我是屌丝,因为我很穷,因为我没有钱购买更多更好的服务器。
先交待一下背景,本人某年革月某日突然对搜索引擎技术发生了深厚的兴趣,对于爱玩技术的我来说,这股激情涌来已难于抑制,向老婆大人汇报了我的思想动态,强烈要求提供资金支持。老婆大人向来对我的这种玩劲不支持不提倡不反对,在她看来我玩技术是比打麻将更高雅一点的娱乐而已,男人嘛,总得有一点不良爱好。所以这次她大方了一点,大笔一挥批了一万元“赌资”。一万元的资金,包括购买服务器和服务器托管等,而服务器托管费用一年至少要七千多吧?那服务器的价格只能是两千多了(我不知道比普通PC还要廉价的服务器是否还能叫服务器)。而对于一个完整的搜索引擎来说,大体上需要几个大模块:数据抓取、数据分析、中文分词、创建索引、信息去重、信息自动聚类、大数据存储、数据压缩和解压缩、查询和排序、WEB页面生成、WEB服务器……如果你对这些的计算量还没有概念的话,我可以告诉你,某公司对几十万个商品进行自动聚类计算,需要采用两台服务器跑三天……所有这些工作我都得在一台最低端的“山寨服务器”上实时完成,这无异于要把大象装进冰箱,不管元芳怎么看,采用常规思路恐怕是行不通了,唯有优化并且往死命里优化,才有可能杀出生机。 对于搜索引擎的整个工作来说,从数据抓取、数据分析、中文分词、WEB页面拼装等都是对字符串在操作,可以说90%以上的时间都与字符串操作有关,所以除了框架上的设计要有讲究之外,字符串的处理效率直接影响到搜索引擎的整体性能。那得回答两个问题:现成的字符串类在效率方面怎么样?是否还有可优化的空间?经本人对各种字符串类做了详尽研究后发现,现成的字符串类在性能方面虽然表现不错,但仅仅只是不错而已,还有非常大的优化空间。下面结合我的具体应用慢慢道来。 一、对于服务器来说,内存管理是最重要的事情之一 对于搜索引擎服务来说,各种数据,特别是字符串的构造、拉接、清空、子串代替、提取子串、重置长度等等特别频繁,对内存分配和释放操作几乎每一毫秒都在发生,这会有什么吗?本人曾在新浪微博中提过一个说法“一个混沌的系统如何能够自然而然地达到有序?这是不可能发生的事情,在非人工干预的情况下,所有事情的发展都是从有序到混沌的过程。对于长时间连续运行的服务器系统来说,内存资源不停地申请和释放交替进行,不可避免地形成内存碎片。意识到这一点你成不了大牛,但没有意识到这一点就连牛B都吹不响。” 当你意识到内存碎片对服务器的影响里,首先想到的是内存池。不错,一个设计优良的内存池可以减少内存碎片,同时也大大提升了性能。话说咱们的字符串类,首要考虑的问题就是与内存池技术的结合了。如何搞呢?很多现成的字符串类都提供定制内存管理的接口(网上已有很多这方面的资料,如有兴趣可以搜索),字符串类也必须有这个。由于搜索引擎的工作机制,各线程都是独立工作的,很少有线程间的互访问题。所以字符串对象的所有操作都是在一个线程内进行,那么在内存池的设计中为了性能的原因而采用“无锁编程”技术是相当容易的,边内存的申请和释放都不应使用系统的new和delete(因为这两个操作是有锁的),而改用私有堆的方式。这虽与字符串的性能有很大关系,但毕竟是内存管理方面的内容,以后有空再详细聊了,先此打住。 二、支持写时拷贝技术是提升性能的基本 写时拷贝是什么东西?写时拷贝的英文缩写COW (copy-on-write), 简单来说就是在复制一个对象的时候并不是真正的把原先的对象复制到内存的另外一个位置上,而是在新对象的内存映射表中设置一个指针,指向源对象的位置,并把那块内存内部做只读标志。这样,在对新的对象执行读操作的时候,内存数据不发生任何变动,直接执行读操作;而在对新的对象执行写操作(有变化)时,才将真正的对象复制到新的内存地址中,并修改新对象的内存映射表指向这个新的位置,并在新的内存位置上执行写操作。 这种写时拷贝的技术在操作系统底层是普通存在的,比如在同一个系统中运行多个进程,将多进程中同样的对象(数据)在物理存储其中只有一个物理存储空间,而当其中的某一个进程试图对该区域进行写操作时,内核就会在物理存储器中开辟一个新的物理页面,将需要写的区域内容复制到新的物理页面中,然后对新的物理页面进行写操作。这时就是实现了对不同进程的操作而不会产生影响其他的进程,同时也节省了很多的物理存储器。字符串虽然与操作系统不在同一层级,但也有其共有的原理。在各函数中,免不了以字符串对象为参数或作为返回值的,还有在大量的字符串运算场合中,写时拷贝技术将大大减少不必要的内存申请、释放、拷贝等。 在我所能找到的字符串类中,基本上可以分成三种,一是有的库提供的字符串类竟没有支持写时复制;二是有支持写时复制的,但为了线程安全而采用了锁技术,造成性能慢得象老牛;三、也有支持写时复制技术而不采用锁的,但在内存管理接口竟是全局的,无法支持“无锁编程”。我一直记得前面说过的,前提是已拥有基于私有堆的无锁的高效的内存池,如果咱们的字符串类没有利用这一成果,那是自宫行为。 三、优化格式化函数 字符串的格式化操作几乎是无所不在,有小的操作,比如整数、浮点数、日期、时间等转字符串形式。而大的操作,比如在生成WEB页面时需要往模板中按格式填入真实数据。在传统的字符串处理类中(比如MFC带的CString)提供了Format和AppendFormat两个方法,在应用时会发现性能低下,经分析发现好家伙,这两个函数在内部潜伏了CPU大鳄:GetFormattedLengt。这是干什么的呢?就是在真正格式化之前先计算所需要存储区的长度的,这个函数相当的费时间。我采用空间换时间的理念,为Format和AppendFormat两个方法增加估计长度参数,在外部调用前就先估计其长度(一般都是比实现可能长度还要大很多,虽然有可能造成空间的小浪费,但得到性能的回报也是值得的),这样在内部真实执行格式化时不需要再计算,性能直接提升一倍多。 四、优化“+=”操作 别告诉我你没有见过字符串的“+=”操作,字符串的拼接操作比街上的美女还要多,如果你采用“Str1 = Str1 + Str2”的形式而不是“Str1 += Str2”的形式来拼接,这无异于让美女放弃优雅与你吐口水玩。虽然由于有写时拷贝技术的支持,“Str1 = Str1 + Str2”形式的操作虽不优雅但在性能上也并不差得太多,而毕竟是有“相加”和“赋值”两个操作在里面,给后续优化带来了困难。对于WEB服务器来说,拼接一个几十K甚至几百K的WEB页面是分分秒秒的事情。而传统的字符串类对此并没有做什么特别的处理,频繁而连续的“+=”操作造成内存不断地被申请、释放、拷贝。针对这种应用场景,我采用了两个策略,一个是上面提到的“空间换时间”策略,在开始就对字符串对象分配足够大的内存。二是因为“足够大”是不好评估的,为了避免太频繁的大内存移动操作, 我对“+=”操作搞了一点点策略性的优化,也就是先用小内存块列表保存新加进来的数据而不是立即拼接到原来的字符串后面,在达到一定规模或者需要进行其它操作时才一次性地分配内存并完成真正的拼接过程。在应用环境下进行实测发现,性能提高了一个数量级。 五、针对小数据字符串做特殊处理 在购物搜索中,小数据字符串象星星一样遍布各地,这些家伙个头不大但数量繁多,比如达百万级的关键词和达千万级的用户查询短语,如果都使用上面所说的“空间换时间”理念,那浪费的内存空间会让你死得很难看(打个比方,这就象你买房子多花那么千儿八百的那无所谓了,如果你每天吃饭都多花千儿八百的,就等着你老婆砍你吧)。对于小数据的情况,一方面是内存分配时的成长粒度要上,要斤斤计较,另一方面在内存池的处理上也要对小内存的分配和释放做策略上的调整。这说起来好象挺简单的,在实施过程中比较折磨人,没有固定的模式,全是经验模型。 以上就是比较接近常规方式的字符串处理类的优化过程,在实际应用中的效果相当令人满意。有兴趣的朋友可以看看本人的作品(绝非产品)谷壳购物搜索。 之所以说以上的优化都是接近常规的方式,是因为本人还疯狂地创建了“零内存”字符串类,以几乎不增加内存的方式对大量字符串数据做各种处理,比如为了提高搜索性能而对关键字创建搜索树等。既然是非常规方式,那也也只适用于特定应用场景。