性能

做人不要斤斤计较,但写代码一定要斤斤计较!
来看一个Linux-2.4内核中的例子,学习世界顶尖高手的做法。内核中经常要访问进程控制块(PCB),其在内核中定义为task_struct结构。为此在include\asm-i386\current.h中定义了一个宏current,提供指向当前进程的task_strcut结构的指针。

static inline struct task_struct * get_current(void)
{
	struct task_struct *current;
	__asm__("andl %%esp,%0; ":"=r" (current) : "0" (~8191UL));
	return current;
}
#define current get_current()

在内联函数get_current中,首先将esp堆栈指针和~8191UL做“与”操作,就算出了task_struct的地址,然后MOV到current中,再将其返回。注意,这里只涉及到两条指令且都是针对寄存器的操作,一条AND指令,一条MOV指令。而一条AND指令只需要4个CPU时钟周期,一条从寄存器到寄存器MOV指令只需要2个CPU时钟周期,总共只需要6个CPU时钟周期。
而如果采用另外一种方式:定义一个全局变量,在每次调度新进程运行时将该进程的task_struct指针写入这个全局变量,之后使用时再直接访问这个全局变量。实际上每次访问全局变量的话,会涉及到内存的操作,其执行将超过6个CPU时钟周期。因此用内核中的办法每次使用task_struct时把它的地址计算出来反而效率更高 [毛德操、胡希明]。

优化性能热点

记住80-20规则:20%的代码占用了80%的时间。程序的大部分性能消耗都是集中在小部分代码上,这小部分代码就是性能热点。
当然首先你得找到这些热点所在,除了使用一些性能分析工具外(比如Profiler),最简单有效的方法就是在程序块中增加日志。首选对整个程序的运行流程进行划分,将其分成各个粒度更小的子流程,然后在这些子流程的首尾增加代码,在开始的地方获取系统当前的tick,在结束的地方再获取一次当前tick,然后两者相减,并把结果记录到日志中。当然记录日志的前提是日志本身的I/O对程序运行时间的影响可以忽略。如果I/O对程序影响较大时,可以考虑先把相关的时间信息记录到一个内存块中,待程序流程执行结束后再将内存块中的时间信息输出。
找到耗时较长的子流程后,需对其代码进行分析,查看是否有比较耗时的操作,再有针对性的进行优化。通常的优化方法包括以下这些。

  • 避免顺序查找

顺序查找应该是最简单直接的编程方法之一。在数据建模时使用数组,然后查找时用一个for循环。但是当该数组中的数据量巨大时,问题就来了,大的循环会消耗很多性能,形成性能热点。
实际上在我负责的公司招聘中一直使用着一个“潜规则”,如果应聘者在解题时使用了顺序查找,那我会慎重考虑,他被录用的可能性极小。这不光是在考察算法,而且还体现出一个软件设计师在开发时的性能意识。如果他把这种不好的开发习惯带到项目中,那后期还要投入更多的人力和时间去补救。
要避免顺序查找,首先在数据结构设计阶段就需要花些心思。针对所要管理的数据集合,寻找它们的特点。通常集合中的各个元素都会有一个唯一标识的ID,该ID可能是一维的,也可能是多维的。把这个ID做为索引,构建相应的hash数据结构,而不是简单的使用数据。C++和JAVA都提供了很多容器(比如hash,map等),可以直接使用。如果是C语言,则需要自己封装一套hash算法。你也可以下载示例(url)HashSample做为参考。
另外,如果这些ID是有序的(比如是int类型),你也可以将管理集合构造为一个有序的数组,在查找时使用二分法。

  • 减少大块内存赋值

在大型系统中的多个模块间传递数据是不可避免的,这些数据包括参数和返回的结果。如果每个模块都定义了一套自己的数据类型,那么在传递时就需要对数据进行赋值转换。
假设有3个模块(类)A、B、C都提供了一个公有函数do_some_thing,都定义了各自的数据类型作为入参。

class A
{
public:
	struct PA{
		int a;
		int b;
		int c;
	};
	void do_some_thing(struct PA* p);

private:
	B *m_b;
};

class B
{
public:
	struct PB{
		int b;
		int c;
		int a;
	};
	void do_some_thing(struct PB* p);

private:
	C *m_c;
};

class C
{
public:
	struct PC{
		int c;
		int a;
		int b;
	};
	void do_some_thing(struct PC* p);
};

如果它们之间存在如下调用关系:A->B->C。那么在每次调用前就需要对参数进行转换。

void A::do_some_thing(struct PA* p)
{
	struct B::PB pb;
	pb.b = p->b;
	pb.c = p->c;
	pb.a = p->a;
	m_b->do_some_thing(&pb);
}

void B::do_some_thing(struct PB* p)
{
	struct C::PC pc;
	pc.c = p->c;
	pc.a = p->a;
	pc.b = p->b;
	m_c->do_some_thing(&pc);	
}

这样的转换繁琐且耗时。通常如果这些模块都是出自同一个开发人员,一般不会出现这样的问题。往往一个大型系统中不同的模块都有不同的人员来维护,由于开发人员的“自负”和交流不畅,往往容易出现类似的问题。出现这种情况时需要架构师介入加以协调,针对各个软件层次对参数的需求加以分析,提炼出一个独立且不依赖于任何其它模块的公共参数模块,来统一各模块间的接口。使得参数能在一个业务流程的各层代码之间直接传递。改进的代码如下:

struct P{
	int c;
	int a;
	int b;
};

class A
{
	void do_some_thing(struct P* p);
};

class B
{
	void do_some_thing(struct P* p);
};

class C
{
	void do_some_thing(struct P* p);
};

另外还有一种大块内存赋值的情况比较隐蔽,容易被人忽视。下面C++例子中的函数参数类型为vector:

void do_some_thing(std::vector<struct P> vp)

C++的参数传递都有一次拷贝赋值,如果数组中的元素数量巨大时,这个拷贝过程会非常耗时,同时也伴随着大量内存消耗。之前在我们的项目中出现过类似的问题,数组中元素个数达到10万的量级,程序运行时直接就“卡死”了!
改进的办法也很简单,将这类参数改为引用(用&修饰)就可避免拷贝赋值。此外,还需要增加数据安全性考虑,因此再加一个const修饰就可以避免函数内部误修改导致数据出错:

void do_some_thing(const std::vector<struct P>& vp)

当然这类问题在JAVA中不会出现,因为JAVA的对象参数传递默认都是传引用的方式。

  • 提炼循环中的公共操作

有时候循环不可避免,比如确实需要针对数据集合中的每个元素进行某项操作时。此时需要分析循环中的每一项操作,是不是有可以提炼的公共部分,类似下面的代码:

	for(int i = 0; i < size; i++)
	{
		v[i]._a = get_some_thing();
		v[i]._b = calc(i);
	}

如果get_some_thing每次返回的值都相同,那么可以把它提到循环外面,只执行一次。下面的优化将该值保存在someV中,以后每次直接赋值。

int someV= get_some_thing();
for(int i = 0; i < size; i++)
{
	v[i]._a = someV;
	v[i]._b = calc(i);
}

还有一种复杂情况,你可能在之前已经对代码进行了封装:

void treat_v(struct V &v, int i)
{
	v._a = get_some_thing();
	v._b = calc(i);
}
for(int i = 0; i < size; i++)
{
	treat_v(v[i], i);
}

这种情况下你需要对代码进行重构,将公共部分和个性部分拆分开来,再重新组织。

  • 避免使用耗时的API

如果你的系统工作在应用层,那难免要和操作系统的api打交道。但是有些api在调用时会非常耗时,例如我曾参与过的一个Windows应用系统开发,其中使用了一些OLE接口,函数的运行时间就非常长。
如果刚好在某些关键流程中使用了这类api,那就得对技术方案进行重新评估。因为我们无法修改其内部代码,只能想办法怎么绕开它。首先可以考虑有没有替代的方案,比如通过其它方法也能达到目的并且性能很好,或者找找有没有某些第三方的程序库也支持该功能,或者自己实现一个性能更好的算法。其次需要分析是否可以降低对它的调用频率,比如只调用一次然后把它的结果缓存起来,以后需要的时候直接查表,而不是每次需要时都调用。
总之如果碰到这类问题很不幸也很无奈。要么绕开它,要么默默忍受

  • 合并数据库语句

我们的系统中有一个流程是把一个报告表的数据插入到数据库的一个特定表中。测试中发现,当有上10万条记录需要入库时,整个流程执行非常缓慢。
因为每一条SQL语句在执行时都会有一次和数据库的交互,底层会进行socket通信:发送SQL指令,数据库接收后进行处理,再将执行结果返回应用程序。因为涉及到网络的通信和调度,这样的交互本身是有一定耗时的。少量的语句执行还感觉不出,但如果是10万次这样的交互,那时间就大大延长了,到了无法接受的程度。
解决办法是将多条记录拼装成一条SQL的INSERT语句,然后再执行。需要注意每个数据库都对SQL语句有长度限制,在拼装时需要保证语句长度不要超过它。这样就大大减少了底层socket通信的次数,整体性能大幅提升,可以满足要求。
这个问题实际上属于上面描述的“提炼循环中的公共操作”中的一种场景,其比较典型,因此单独进行说明。

非热点代码也有优化空间

大部分应用程序在经过上述的方法优化后基本都能达到性能要求。然而如果你的程序是工作在系统底层且对性能要求极高,很可能在实施了上述优化后仍无法满足性能需求。那么是否就黔驴技穷了呢?当然不是。此时我们不得不把目光转向代码的另外80%,从这里面“抠”性能。本节开始处列举的Linux内核中对进程控制块的访问方法就是一个例子。此外还有一些方法可以尝试。

  • 执行概率高的分支提前

下面的伪代码中,if…else…语句中有两部分代码,“代码片段A”和“代码片段B”:

if ( 条件A ) {
	代码片段A
}
else {
	代码片段B
}

如果在实际应用场景中 “条件A”为“假”的概率更高,即程序更多的情况下会走入else分支执行“代码片段B”,那么你需要将这个if…else…语句的逻辑调整如下:

if ( ! 条件A ) {
	代码片段B
}
else {
	代码片段A
}

在if语句的判断中将“条件A”取“否”,并将两个代码片段对调位置。这样的调整并不会影响代码的逻辑,但执行速度能有所提高。这是因为在程序顺序执行的过程中,“代码片段B”的代码段更容易载入CPU的高速缓存(cache),从而获得更高的效率。

  • 使用查表法

假设下面的switch…case语句,其根据不同的命令码来调用相应的函数:

switch(cmdCode){
case 0:
	do_some_thing_0();
	break;
case 1:
	do_some_thing_1();
	break;
//注意这里没有 case 2
case 3:
	do_some_thing_3();
	break;
……此处省略其它case
case 10:
	do_some_thing_10();
	break;
default:
	do_error();
	break;
}

switch…case在编译后是以二分法来查找相应的分支入口,算法已经很快了。但是当分支过多时,仍然需要消耗一定的时间。针对这种情况可以构造一个函数入口表,通过查表法进行处理:

    typedef void (*fpFunc)();
    
    	

    const int maxCmd = 10;
	static fpFunc funMap[maxCmd] = {
		do_some_thing_0,
		do_some_thing_1,
		NULL,
		do_some_thing_3,
		……此处省略其它赋值
		do_some_thing_10,
	};

	if(cmdCode >= 0 && cmdCode <= maxCmd && funMap[cmdCode] != NULL){
		(*funMap[cmdCode])();
	}
	else{
		do_error();
	}

通过构造了一个funMap,直接根据cmdCode找到相应的函数,算法的时间复杂度从O(log2n)降为O(1)。运用这个方法有几点需要注意:

1.所构造的表很可能是个稀疏矩阵,因为并非所有的值都有case,需要将相应的项初始化为NULL。上面的例子中就没有case 2分支。
2.cmdCode的取值范围有可能很大,需要评估内存的开销是否能接受。
3.cmdCode的取值并非从0开始,必要时可以进行一定的转换。比如减去某个固定值以使其从0开始。
4.要求所有case的处理函数接口都相同,在该例中都为void (*fp)()。如果存在函数接口不同的情况,则需要先进行统一。

使用这种方法存在一定的限制条件,因此在修改时需要进行充分的评估。但是这种利用空间换时间的做法提供了一种很有效的性能优化思路。

  • 使用内联函数

函数调用时需要将参数压栈,执行函数跳转指令,存在一定的开销。在绝大部分场景这样的开销是可以忽略的,代码重构的方法指导我们尽量封装小函数以增加代码的内聚性和可维护性。
但是当确实有性能需求而又没有其它更好的优化手段时,就只有在这些小地方动心思了。C++语言提供了内联机制,可以通过inline将函数定义为内联的,这样在编译时函数代码会在每一处调用的地方被展开,实际上也就没有了调用函数的开销。
除了inline还有一种方法我非常不情愿使用,但有的时候又不得不用它。我在H公司曾经参与过的一个项目是基于C语言的VxWorks嵌入式开发。遗憾的是编译器不支持inline。当时项目组在做性能优化时采用了替代方案:把小函数改成宏,用宏来代替内联。但这个方法会带来其它麻烦,比如不利于调试、代码难以维护。写代码很多时候会面临这种两难的选择,当性能和可读性发生冲突时,如果不得不提高性能以满足产品需求,那还是只能选择前者。至于可读性,只能通过加强注释说明来进行补充。

  • 减少乘除法的使用

我曾经写过一个消息处理函数,在模块接收到消息时会在入口处记录一条日志:

void MsgTreat(int msg)
{
	logstr("receive msg:%d\n", msg);
……
}

测试时发现该模块接收的消息非常多而且很频繁,日志文件很快会被写满。于是我采取了一个折中的方法,每1000条消息才记录一次日志,日志只是进行一个统计参考:

void MsgTreat(int msg)
{
msgCnt++;
	if( (msgCnt % 1000) == 1){
		logstr("receive msg:%d, msgCnt=%d\n", msg, msgCnt);
	}
	……
}

这样一来就引入了一个除法操作。除法指令本身性能开销较大,而且这个函数调用频繁,会对性能造成一定影响。因此我又换了一种方法,把“%1000”改成“&0x3ff”:

void MsgTreat(int msg)
{
	msgCnt++;
	if( (msgCnt & 0x3ff) == 1){
		logstr("receive msg:%d, msgCnt=%d\n", msg, msgCnt);
	}
	……
}

新的方法相当于除以1024取余,即每收到1024个消息记录一次日志。既然只是想做个统计,那么每1000条消息记录一次和每1024条消息记录一次其实差别不大,关键是避免了除法运算。
此外在能够将乘除法转换成位移运算的时候,则尽量使用位移运算。比如将一个整数左移1位,相当于乘以2;右移1位相当于除以2。如果你的运算中乘数或除数刚好是2的n次幂,则可以直接使用这种方法。
如果乘数或除数不是2的n次幂,也有变通的方法,比如(n15)可以写成(n<<3 – n),相当于(n16-n)。当然如果程序不涉及到大量的数学运算,一般不会用到这种方法。

  • 选择合适的日志接口

我在Z公司曾参与过一个接入网产品项目,在进行压力测试时呼叫指标始终达不到要求。当时的要求是1万次呼叫最多只允许有4次失败。项目组进行了一个月多的攻关后依然一筹莫展。后来一个同事突发奇想在系统底层把日志关闭(在日志函数入口直接retuen),结果呼叫测试顺利达标!
该问题究其原因主要有两点。首先,当时我们缺乏一个良好的日志框架。我们是用printf进行日志输出,该函数虽然没有文件I/O,但是有两个较耗时的操作,一是对格式化字符串进行处理,因为调用printf一般都要输入若干个参数,需要将参数转化为字符串。二是将字符串内容打印到串口上,有一次串口I/O。
其次,整个项目对日志的记录没有有效的规划管理。所有开发人员为了便于问题定位,都进行了较多的日志记录。这样导致整个呼叫流程中日志量非常巨大。
在电信系统中,对呼叫的接续时延有严格的要求,一旦某个流程超时就会导致呼叫失败。由于上述原因,在呼叫时大量日志的涌入,导致呼叫流程的程序执行耗时变长,最终导致失败。
我们最终的解决方案也非常简单粗暴:在默认情况下把日志关闭,只有在需要定位问题时再临时把日志打开。
当然更好的方法是采用一种改良的日志方案。因为格式化字符串需要性能开销,文件I/O也有开销,所以新的方案需要避免这两种操作。在“日志”一节中介绍的日志框架中还提供了一个日志接口log,其支持3个参数:

log(int logId, int p1, int p2);

第1个参数是日志ID,你可以定义一种ID的编号规则,保证在你的整个工程中一个ID只被一个地方使用。因为这个日志接口没有使用代码所在的文件名和行号,所以在分析日志时只能通过该ID来查找对应的代码。如果同一个ID在多个地方被使用,则无法通过该ID唯一确定是哪一处代码输出的日志。第2、3个参数为日志需要记录的信息。调用该函数时仅将这3个参数记录到一个内存块中后函数即返回,执行速度很快。在日志框架有另外一个线程会周期的把这个内存块中的信息写入日志文件。
这个方法的缺点是使用起来有一些局限性,但优点是性能较好。因此建议仅在对性能要求极高的代码中使用该方法,其它场景还是使用logstr、logmem等方法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值