我们要先实现业务功能,还是先优化代码?

开发 前端
在日常的代码开发里,由于偷懒不想写一个组合的逻辑表达式,从而增加分支逻辑的现象,实际上是较为常见的。

在做软件设计咨询工作时,我常常发现:许多高性能软件产品的研发团队,在软件开发阶段,仅仅关注并实现业务的特性功能。待功能交付后,才花费大量时间对软件代码进行调整优化。

而且,在与这些程序员接触的过程中,我还观察到一个有趣的现象:大家普遍认为,在软件编码实现阶段,过早考虑代码优化意义不大,应等到功能开发完成,再基于打点 Profiling(数据分析)去优化代码实现。

其实,这个想法是否可取,也曾困扰过我。然而,在经历众多由低级编码导致的性能问题后,我发现高性能编码实现极具价值,且能让我更好地处理编码实现优化与 Profiling 优化之间的关系。

建立正确的高性能编码价值观

首先,提及高性能编码,想必您肯定听说过现代计算机科学的鼻祖高德纳(Donald Knuth)的那句名言:“We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” 意思是:我们应该忘掉那些效率低下的事情,告诫自己在 97% 的情况下:过早优化是万恶之源。但是,我们也不应该在关键的 3% 上错过优化机会。——《Computer Programming as an Art (1974)》P671。

不过我认为,或许很多程序员仅仅记住了这句话的前半部分,即 “97% 的情况下,过早优化是万恶之源”,却没有留意到这句话还有后半句:我们不应该放弃掉那关键的 3% 的优化机会。

所以,由此造成的后果是:过度推崇不要对代码进行提前优化,并将此当作编写低性能软件代码的借口。也就是说,当下我们在软件编码过程中所遇到的大多数问题,并非由过早优化所致,而是因为在编写代码时对执行效率缺乏关注所引发的。

其实,在编写代码阶段树立追求高性能实现的意识极为重要,主要有两方面原因。

第一个原因在于,或许原本只是一个细微的编码问题,却有可能引发软件较大的性能问题。例如,我曾经参与的一个 C++ 高性能软件开发项目,由于一位研发人员在编码时不慎遗漏了函数行参的引用符号,致使函数调用开销增大,软件版本性能显著下降。而且这个问题较为隐蔽,我们后续耗费了大量精力,在代码中增添了众多定位手段,才得以发现问题。

第二个原因是,一旦错失高性能编码的时机,将性能问题遗留到软件生命周期的后期,很可能因为错过了当时编写代码的具体情境,后续就难以再察觉这个问题。在此,我再为您举个例子加以说明。

可以看到,在如下所示的代码片段中,这个类的实例在接收数据之后,会更新各个 Channel 中的数据量大小,而后对外提供了一个方法,用于判断所有通道中是否存在数据

public class ChannelGroup {
class Channel{
public String channelname;
public int dataSize;
    }


    Channel[] channels;


public Channels() {
        channels = new Channel[10]; 
    }


public receiveData(...)  {....}  // 收到数据更新Channel中信息,省略
public boolean hasData() {   // 判断所有通道是否有数据。
for (Channel Channel : Channels) {
if (Channel.dataSize > 0){
return true;
            }
        }
return false;
    }
}

那么在看完这段代码之后,您认为这段代码实现中的方法 hasData ,能算作高性能实现吗?倘若仅依据这段代码实现来加以分析,会觉得它似乎不存在性能问题。毕竟,对于一个仅有 10 个元素的数组而言,运用二分法查找来提高查找速度的必要性并非很大。

好,我们暂且如此认为,接着再做一个假设:在真实的编写代码过程中,存在这样一个潜在的上下文信息,即在绝大多数业务场景下,是第三个通道收到数据。那么针对这种情况,如果再采用从前向后的顺序遍历,必然不是性能最佳的实现方式,而应当先判断第三个通道中的数据。

所以通过这两个例子,您应该明白了,如果在编码实现阶段并非从高性能实现的角度出发,而是打算在后续通过打点数据分析来优化解决问题,几乎是不太可行的。

实际上,依我的思考和实践经验而言,在开发一个高性能软件系统时,在编码阶段考虑高性能的实现方法,与完成业务功能后再进行代码调优,两者并不冲突,应当同等重视。因为前期的高性能编码实现过程,大多由人主观把控,所以可能会因判断失误或者实现过程中的疏忽,引入一些低效率的代码实现。如此一来,后期通过热点代码分析以及代码调优的过程,是不可省略的。

而且说实话,在我心中,优秀的软件代码应当兼具代码简洁与性能,倘若对编码性能嗤之以鼻,我认为这样的程序员通常也写不出高质量的代码。

好了,在理解了应当如何看待高性能编码之后,接下来的问题便是,如何才能掌握实现高性能编码的方法,下面我们就具体来瞧瞧。

高性能编码实现方法

其实在软件开发的进程中,高性能编码实现的方法与技术繁多,不同的编程语言之间还会存在一定差异,很难在一节课里介绍周全。

所以,今天我主要从编写的代码映射到执行过程的视角,为您介绍四种高性能编码实现方法,以及相应的实现原则和手段,分别是循环实现、函数方法实现、表达式实现以及控制流程实现。

在实际的软件编码过程中,您也能够依照这样的角度和思路,尝试去理解与分析软件代码的运行态过程,逐步积累并完善高性能编码的实现技巧。

好,接下来我们就从循环实现入手,来瞧瞧这种高性能实现的原则和方法。

高性能循环实现

我们都清楚,在编写代码时,循环体内的代码会被多次执行,因而其代码开销会被放大,常常会出现在热点代码当中。也就是说,怎样实现高效循环是达成高性能编码最为关键的一步。

那么,编写高效循环代码的重要参考原则有哪些呢?我觉得主要有两个,下面我们具体来了解一下。

第一点,尽量避免对循环起始条件和终止条件的重复计算。为了让您更轻松地理解这个原则,我先带您来看一个高效循环的反面例子。在下面这个代码示例中,所实现的功能是循环遍历并更新字符串中的值,您会发现,在循环执行的过程中,strlen 被调用了多次,所以性能较低。

void updateStr(char* str)
{
for(int i = 0; i<strlen(str); i++)
    {
        str[i]= '*';
    }
}

那么,针对这种情况,我们就应该在循环开始时,将字符串长度值保存在一个变量中,从而避免重复计算。修改好的代码如下:

void updateStr(char* str)
{
int length = strlen(str);
for(int i = 0; i< length; i++)
    {
        str[i]= '*';
    }
}

第二点,尽量避免循环体中存在重复的计算逻辑。

我们同样也来看一个反模式的代码示例。在下面这段代码的实现过程中,x*y的值并没有发生变化,但是在循环体中被执行了很多遍。

void initData(int[] data, int length, int x, int y){
for(int i = 0; i < length; i++)
    {
        data[i] = x * y + 100;
    }
}

因此,从高性能编码实现的角度出发,我们能够将 x*y 值的计算过程迁移至循环体之外,以此降低这部分的冗余计算开销。实际上到这里,您可以记住这么一句话:编写高效循环代码的本质,就是尽可能让循环体中执行的代码越少越好,剔除掉所有能够冗余的重复计算。

那么在具体的代码实现里,需要检查的循环优化点其实还有众多。例如,您还需要检查是否存在重复的函数调用、多余的对象申请和构造、多余的局部变量定义等等。所以,在编写循环代码时,您需要留意识别并剥离出此类代码实现。

高性能函数方法实现

实现高性能的函数方法,存在两个重要的出发点:尽可能通过内联来降低运行期函数调用,尽可能减少不必要的运行期多态。接下来,我为您讲解一下为何要从这两个点出发,以及应当如何去做。

第一点,尽可能通过内联来降低运行期函数调用。所谓 “通过内联”,指的是将代码直接插入到代码调用中进行执行,从而减少运行期函数调用。那为何要减少生成真实的运行期函数呢?这是由于函数调用自身会产生一些额外的性能开销。在函数调用的过程中,需要先把当前局部变量压栈,在调用结束后还需要出栈操作,同时还需要更新相关寄存器。所以当函数体内部的逻辑较小时,所产生的额外开销所占比例会比较高。因此,对于较小的函数方法,我们应尽量采用内联实现,以此降低不必要的调用开销。

实际上,不同的编程语言,支撑函数方法内联的语法和机制存在一定差异。在 Java 语言的开发过程中,我建议您尽量使用 final 来定义方法,因为在这种场景下,Java 的 JIT 有较大概率将这个代码方法内联掉。而在 C++ 中,针对一些热点小函数,您可以使用 Inline 关键字来定义方法,如此便能明确告知编译器尽量将代码内联掉。补充:在早期 C 语言的开发过程中,由于没有内联语法,程序员常常使用编译宏来定义方法,以此减少真实方法的调用开销。然而,最终编译器或解释器能否将代码内联掉,还存在许多隐性约束条件,所以您在编码实现时需要多加留意。

第二点,尽量减少不必要的运行期多态。

多态的本质即为函数指针,它需要在运行过程中获取内存中变量的值,以此判断代码执行需要跳转至哪个位置。而这种在运行期动态决定跳转地址的情况,极易导致指令集流水线的中断,使得指令 Cache Miss 的概率增加,进而引发性能下降。

不过在 Java 语言中,由于类方法模式均是抽象的,所以我们能够将关键方法定义为静态方法,从而避免多态调用;对于 C++ 而言,在定义类方法时,我们可以依据需求决定是否使用抽象方法,以减少不必要的多态;而在 C 语言中,我们能够通过尽量避免使用不必要的函数指针,来降低运行期多态。

另外,在实现高性能函数方法时,还有一些要点您也需要留意,例如尽量避免递归调用、尽量减少不必要的参数传递等等。不过这些均属于高性能编程的常识性问题,所以在此我就不再展开阐述了。

高性能表达式实现

其实,现在的编译器针对表达式级别的优化支持能力已经很强大了,比如说,如果你在编写代码的过程中,使用下面的乘法操作:

int y = x * 128;

那么,对于高性能的编译器(如新版的 GCC 9.x 等)而言,能够将这个乘法操作优化为移位操作,进而提升执行性能。然而,我们在编码的过程中,不能完全依赖这种编译器的能力,因为一方面编译器的优化能力存在边界,另一方面在编写代码的过程中,编译器对表达式的优化也只是顺便为之。所以在此,我为您总结了高性能表达式实现中几个较为重要的点,它们均属于简单的实现规则,您也可以在编写代码的过程中作为参考并加以注意。

第一点,尽量将常量计算放到一起。

比如你可以看看下面的代码,这是一个包含了 3 个乘法运算的表达式:

int z = 32 * x * 432 * y;

那么,如果将常量乘法计算放到一起,就很容易在编译期优化掉,从而就可以避免执行时再计算。

第二点,尽量将表达式简化,从而减少冗余运算开销。

我们同样来看一个例子。在下面的这段代码示例中,两个表达式的实现逻辑相同,都是先乘法再加法,不过您会发现,第二个表达式少了一次乘法运算,所以它的执行性能会更为出色:

int z = x * x + y *x  ;  //两个乘法操作,一个加法操作
 int z = x * (x+y); //一个乘法操作,一个加法操作

第三点,尽量减少除法运算。

目前 CPU 中对除法计算的开销仍然较大,因此倘若能够优化为移位操作或者乘法操作,那么都能够提升执行性能。

高性能控制流程实现首先您需要知晓的是,控制流程代码在执行的过程中,CPU 执行会通过指令分支预测,提前将接下来的执行指令搬移到 Cache 中,如果预测失败,就有可能导致指令流水线中断,从而对执行性能产生影响。

所以,您在编写控制流程代码时,就需要思考一下怎样才能更好地实现,以此来优化代码的执行性能。

那么具体该怎么做呢?在此,我也为您分享一下我在实践过程中总结的经验,即尽量减少不必要的分支判断。这个原则是最为重要、也是最容易被忽略的。为何这么说呢?我们来看一个具体的例子。在下面这段代码里,您可以发现 x==2 和 x==3 对应的分支场景是相同的,但是它们还是被放置在了两个代码分支当中,所以这样执行起来不但低效,而且还存在重复代码:

if ( 2 == x ) {   // 场景1
     printf("case 1");
 }
 if ( 3 == x ) {    //场景1
     printf("case 1");
 }
 if ( 4 == x ) {    //场景2
     printf("case 2");
 }

在日常的代码开发里,由于偷懒不想写一个组合的逻辑表达式,从而增加分支逻辑的现象,实际上是较为常见的。所以说,我们在实际编写控制流程代码的时候,务必要注意尽量减少不必要的代码分支,如此才能有效地提升执行性能。

然而这里您可能还存在一个问题,那就是如果深入挖掘优化代码中一些重复的分支逻辑,其中包含的门道还比较多。比如说,通过多态来避免代码中重复的 switch 分支逻辑,利用表驱动来减少 switch 逻辑和小的 for 循环平铺执行等等。所以在此,我给您一个小建议,在一些特殊场景下(比如 if 条件嵌套非常多的场景),您可以考虑使用 switch 来替换 if ,这样也有可能改进代码的执行性能。

责任编辑:武晓燕 来源: 二进制跳动
相关推荐

2011-03-07 17:11:21

云迁移云转型

2025-01-26 00:01:00

2021-03-19 07:40:22

缓存数据库日志

2023-09-16 18:48:28

代码逻辑

2021-01-13 05:23:27

缓存数据库高并发

2023-12-27 13:44:00

数据库系统分布式

2009-10-10 11:18:54

谷歌Android

2018-07-13 15:56:39

缓存数据库数据

2019-06-04 08:27:03

2020-04-03 14:30:01

数据科学远程工作数据科学家

2018-06-19 07:16:27

工业物联网IIoT物联网

2010-08-27 10:37:41

马云

2021-05-05 10:54:47

数据泄漏漏洞网络攻击

2024-12-16 08:01:57

2021-01-29 10:51:48

高并发数据库缓存

2019-12-24 09:12:10

运维架构技术

2019-08-14 16:11:41

硬件电子技术系统

2021-02-16 23:57:32

5G手机运营商

2023-08-27 21:57:07

技术债语言工具

2017-08-15 08:27:48

云备份问题恢复
点赞
收藏

51CTO技术栈公众号