二八定律即80/20法则(The 80/20 Rule)。这个原理是由十九世纪末期与二十世纪初期的意大利经济学家兼社会学家维弗利度·帕累托所提出的。它的大意是:在任何特定群体中,重要的因子通常只占少数,而不重要的因子则占多数,因此只要能控制具有重要性 的少数因子即能控制全局。80/20的法则认为:原因和结果、投入和产出、努力和报酬之间本来存在着无法解释的不平衡。一般来说,投入和努力可以分为两种 不同的类型:
1.多数,它们只能造成少许的影响;
2.少数,它们造成主要的、重大的影响。
二八定律的现实社会中无处不在,同样体现在我们书写的程序中:80%的运行时间都花在执行20%的代码上。那么,在优化系统(程序)时,应该先找到性能的瓶颈,通常这20%的代码就是我们需要注意的关键。当然要优化,需要先找出这20%的瓶颈,系统的压力测试可以找出瓶颈所在。找到程序的瓶颈之后,需要对代码进行分析。
影响系统效率的因素很多,根据所处的行业,仅谈谈个人的一些见解。目前所处的行业系统的瓶颈主要在吞吐量和系统延迟,由于用户感知,所以对时效性要求很高。通常来说吞吐量和系统延迟的关系如此:
1. 吞吐量越大,延迟会越大。因为请求量过大,系统太繁忙,所以响应速度自然会低。
2. 延迟小,能支持的吞吐量就会越高。因为延迟短说明处理速度快,于是就可以处理更多的请求。
目前,系统采用的是多进程,多主机部署,分类原则根据不同客户端业务请求,客户端区域标志;多个进程之间采用socket/消息队列进行通信,进程中采用线程池+中间件处理,即one loop per thread + thread_pool模式编程。一般来说大型系统中底层(进程/线程通信于交互)趋于稳定,吞吐量和延迟决定于业务处理的效率,而业务处理中20%代码可能会有80%耗时。要对这些代码进行优化,首先要非常熟悉业务需求,一般来说在开始时确定一个清晰的业务思路,那么效率自然不会差。框架定了,那么在实现代码时,需要注意很多细节,这里谈谈我所遇到的问题:
1. 数据结构的选择。工作中主要用到的数据结构式AVLTree和HashTable。二者均用于存储数据,AVLTree通常将数据库中的数据信息(费用、清单等)加载到内存,AVLTree有排序功能,便于查找,可以方便的对数据进行汇总分类,一般使用方式为:
1 struct TObject 2 { 3 int attrId; 4 long cost; 5 bool operator <(const TObject &rhs) 6 { 7 return this->attrId < rhs.attrid 8 } 9 }; 10 11 TObject obj; 12 TObject *pObj; 13 while(fecth(&obj)) 14 { 15 pObj = avltree.Search(obj); 16 if(pObj == NULL) 17 { 18 avltree.insert(obj); 19 } 20 else 21 { 22 pObj->cost += obj.cost 23 } 24 }
AVLTree要对数据进行排序,所以要求保存在其中的结构类型重载operator<,效率为O(NlogN)。
相比于AVLTree,HashTable则主要用于对查找对象定位,理论上HashTable可以在O(1)时间内完成,但是事实上,如果输入集合不确定的情况下,可能出现大量的冲突,虽然有很多好的哈希函数,但是随着随机输入,大量冲突还是不可避免,可能出现最差情况。工作中用到的HashTable以模板方式实现,声明为:
template <typename Key, typename Value> class CHashtable;
在内存中的存储方式如下图:
需要注意,声明中Key类型需要定义hashcode函数,用于计算哈希码,并且重载operator=(或者定义equal函数),用来查找HashTable中某一hashcode下是否存在的当前元素。我目前接触到的HashTable中Key值得类型均为这两种之一,所以用了两个辅助类来完成Key类型对象的定义,即将int类型和char*类型进行封装,作为HashTable的Key类型。由于这一特点,HashTable主要用于将一些参数加载到内存中,在需要使用的时候高效的获取出来。不论在使用AVLTree还是HashTable时,都需要避免将作为比较的元素定义为字符串类型,尽量使用整型来进行比较,因为字符串的匹配操作的效率远低于整型。对于其他的数据结构,如果STL能够满足,尽量优先选择,不用自己重复造轮子。
2 内存优化。工作中,C++代码存在着大量的内存操作,而内存泄露是老生常谈的问题了(这里说的均为堆上new出来的内存),原则很简单,new/new[]于delete/delete[]配对,并且谁分配谁释放,遵循以上两点,基本不会出现内存泄露问题。但是现实代码中存在着一些堆上创建的内存,会在不同的线程之间传递/共享,比如两个线程直接传递的msg由堆上创建,并且存储在queue中,两个线程共享此queue,这里不但要对queue上锁,并且要从业务上区分谁负责new谁负责delete,这里容易出现的问题一是内存泄露,一是内存重复释放,解决这两点除了开发过程中细心外,可以通过带有引用计数的智能指针来完成,保证整个过程中释放一次,并且不用手动释放。
如果程序中存在着大量的new/delete操作,那么会造成内存碎片的增长,加重操作系统内存管理器的负担,降低程序运行效率,所以写程序时要注意内存的复用。我能想到的解决此问题的方法有三,前两种原理均为池化技术:
a. 定义template<typename T> class MemoryPool,作为经常class Item的成员函数,重载class Item的operator new/delete函数,内部采用MemoryPool的alloc/free函数封装。
b. 使用第三方的内存分配程序。之前只了解SGI STL中内存管理机制,工作中用到是在webservice开发中GSoap自带的内存管理机制,未深究。
c. 避免过于频繁的new/delete。开发的过程中遇到过这样的场景,服务端常驻进程,客户端发送消息,服务端接受到之后进行解析,解析过程中根据消息内容采用工厂模式匹配并返回对应的处理pItem,工厂模式每次返回的pItem都是从堆上new出来的,在处理完客户端请求之后,将pItem delete掉,开始的时候由于业务量较少,new/delete不会有效率上的缺陷,而之后业务量增大,频繁的new/delete会影响服务端处理的效率。对于这种情况,没有必要每次都new/delete,根据协议,服务端-客户端之间有几类消息请求能够确定,在服务端进程开始时,根据业务请求创建对应的所有pItem指针,消息解析到之后就直接返回对应的pItem,而处理完成之后调用pItem->clear()类似的函数,将pItem中的数据重新初始化,这中间省去了每次动态创建和销毁的过程,而所有pItem指针的销毁可以放在工厂类的析构函数中。由于服务端是常驻进程,也可以所有的pItem,在服务器进程结束时由OS进行内存的回收。
3 事件优先级,先处理主要的事件,将次要的事件放在之后处理。开发过一个服务端程序,其中Recv线程负责接收对端发送来的消息,并将消息解析,如果消息格式正常则后同步写oracle,错误则返回返回对端相应的错误码。开始时由于消息量少,线程处理的很快,后来由于业务量暴增,发现在接收线程将数据同步写入oracle时阻塞,导致服务超时严重。由于业务场景要求超时时限,所以入库这一步对于交互本身来说不是必须,因此将入库这一操作移除接收线程,让接收线程只负责消息校验与返回,而对于入库操作另起一线程,接收线程中消息格式无误的消息通过BlockQueue发送给入库线程,这样一来服务端客户端消息的发送返回不会收到IO的影响,效率自然得到了提升。
4 空间换时间。对于部署在大型linux机器上的系统和效率来说,内存是便宜的,可以使用空间换时间来提高程序的效率,具体来说是将程序中计算出现的值存储起来,之后就直接就能直接使用存储的值不必浪费时间重复计算,这里主要用到的是缓存技术。这里用大家熟悉的斐波那契数列来举个简单的例子,分别采用递归和缓存存储来实现:
1 int Fibonacci1(int n) 2 { 3 if(n==1 || n==2) 4 return 1; 5 6 return Fibonacci1(n-1) + Fibonacci1(n-2); 7 } 8 9 int *pfn = NULL; 10 int Fibonacci2(int n) 11 { 12 //数组小标从0开始 13 int index = n-1; 14 if(pfn[index] != 0) 15 return pfn[index]; 16 else 17 pfn[index] = Fibonacci2(n-1) + Fibonacci2(n-2); 18 19 return pfn[index]; 20 } 21 22 int _tmain(int argc, _TCHAR* argv[]) 23 { 24 int n=0; 25 cout<<"请输入n..."<<endl; 26 cin>>n; 27 pfn = new int[n]; 28 memset(pfn, 0x0, sizeof(int)*n); 29 pfn[0] = pfn[1] = 1; 30 31 clock_t start, end; 32 33 start = clock(); 34 cout<<Fibonacci1(n)<<endl; 35 end = clock(); 36 cout<<"Fibonacci1 cost cpu time... "<<end-start<<endl; 37 38 start = clock(); 39 cout<<Fibonacci2(n)<<endl; 40 end = clock(); 41 cout<<"Fibonacci2 cost cpu time... "<<end-start<<endl; 42 43 delete[] pfn; 44 45 return 0; 46 }
运行的效率如图:
空间换时间的方法还应用于桶排序,查表法等常见算法与技巧。工作中用到查表法替换简单的switch/if-else语句,下面例子展示了程序会根据区域标志来获取对于的区号,如果使用switch/if-else语句,让人看起来很繁琐,而且效率不高:
1 const char * getAreaCode(int region) 2 { 3 if(region == 1010) 4 return "029"; 5 else if(region == 1020) 6 return "0915"; 7 else if(region == 1030) 8 return "0914"; 9 else if(region == 1040) 10 return "0919"; 11 else if(region == 1050) 12 return "0913"; 13 else if(region == 1060) 14 return "0911"; 15 else if(region == 1070) 16 return "029"; 17 else if(region == 1080) 18 return "0912"; 19 else if(region == 1090) 20 return "0916"; 21 else if(region == 1095) 22 return "0917"; 23 }
注意到每个分区都有值于1010的关系,那么可以将上面的程序简化,用查表法来处理:
1 const char * getAreaCode(int region) 2 { 3 static const char * g_AreaCodeList[] = 4 {"029", "0915", "0914", "0919", "0913", "0911", "029", "0912", "0916", "0917"}; 5 6 //获取下标,double转换和+0.5用于1095分区下标的计算 7 int index = double(region-1010)/10+0.5; 8 return g_AreaCodeList[index]; 9 }
5 SQL语句优化。工作中用到的SQL语句太多了,说到优化,目前接触到最常见的两种方式:
a. 分区和索引。如果表的数据量特别大,并且查询频率很高,那么应当用到分区和索引,可以改善查询性能,经常接触oracle的人应该很清楚这两点。这里需要注意的是一些操作会导致索引失效,而建立索引时最好避免使用字符串类型,如果使用字符串,那么效率仍会大打折扣。通常使用执行计划可以获取到SQL语句的cost,根据其值进行优化。
b. 使用临时表。在系统中经常会出现多长表关联的情况,并且这些表大部分都是比较庞大,而关联的时候发现其中的某一张或者某几张表关联之后得到的结果集非常小,并且查询得到这个结果集的速度非常快,那么这个时候可以考虑创建临时表。采用临时表本质上来说也是一种空间换时间的做法。
影响系统的效率有太多方面的因素,而优化也应该在保证程序的正确性上来进行,也就是说先实现程序的基本功能,其次在进行优化。系统优化是一个很广泛的话题,影响效率的原因太多,这篇文章提到了工作中遇到的一些性能问题,以及当时的解决方案,方法可能不是最好的,但是也得到了一些提升,此处作以总结,还有一些暂时没有想到,之后一并补上。