C/C++程序CPU问题分析

转载地址:http://www.10tiao.com/html/473/201606/2651473094/1.html

  程序的CPU问题是另外一类典型的程序性能问题,很多开发人员都受到过程序CPU占用过高的困扰。本次我们收集了14个CPU类的问题,和大家一起分析下这些问题的种类和原因。另外,对于C/C++程序而言,目前已经有了很多CPU问题定位的工具,本文也会进行比较分析。

CPU问题分析

  程序CPU类问题的主要现象是:程序占用的CPU过高,比程序升级前有很大的升高。导致程序CPU占用过高的主要原因是程序设计不合理,绝大部分的CPU问题都是程序设计的问题。因此,提高程序的设计质量是避免CPU问题的主要手段。

1.1 大量低效操作引起的问题

在程序设计中,有些程序的写法是比较低效的,没有经验的同学很容易使用一些低效的函数或方法,这就是我们常说的“坑”。我们搜集到了一些“坑”,跟大家分享下。

memset是一个很常见的性能坑。如果在程序中使用的memset过多,会导致程序的CPU消耗很大。memset使用过多,往往在不经意间就让程序下降了一大截。关于memset函数,一种常见的误用是在循环中对较大的数据结构进行memset。在这个例子中,一个query中memset 1M的内容,在整体1500qps的情况下,每秒进行重置的内存达到1.5G,导致程序的CPU IDLE答复下降。

上面这个例子,是memset一种比较明显的问题,通过代码review等方式是比较容易发现的。在一些情况下,memset操作是在隐式发生的,问题的排查难度也随之加大。

#代码片段1
char buffer[1024] = {0};
  • 1
  • 2

代码片段1中的简简单单的一行代码,其实在实际的运行过程中是会调用memset的。这个就是一个坑:在栈内存中申请缓冲区,然后再赋值,会隐式的调用memset,将内存初始化为0。这个问题也导致了一个产品线的核心模块性能大幅下降,引起了严重的性能问题。

  另外,在使用一些系统函数或库函数时,也需要仔细阅读使用手册,避免出现大量的无效的内存申请、释放和重置操作。

代码片段2
memset(&t_data->preq, 0, sizeof(pusrinfo_req_t));
memset(&t_data->pres, 0, sizeof(pusrinfo_res_t));

odb_renew(t_data->cur_field_dict);
  • 1
  • 2
  • 3
  • 4
  • 5

代码片段2中的这段代码中,第1、2行中的memset会导致程序的CPU使用过多,但即使是将这两行的代码注释掉,程序的性能依然没有明显的改观。问题的根源在于代码片段2中最后一行代码调用的odb_renew函数有释放内存和大量的memset操作,导致消耗的CPU很多。如果在程序中调用了大量的odb_renew函数,其性能一定不太好。

  strncpy这个字符串操作函数是比较耗费性能的,同strncpy函数实现类似功能的函数有snprintf和memcpy+strlen这两种方式。表1是在一台测试机上对这三种方式的性能比较。 
这里写图片描述
从表1可以看出,memcpy的性能最好。令人欣喜的是snprintf在大数据下性能渐渐逼近memcpy。稍微看了一下几个函数的源代码,memcpy用了page copy和word copy结合,所以性能优化的比较好,而且strlen也是用4字节做循环步长的。strncpy只是简单地逐字节拷贝,并且会将目标buffer后面所有的空闲空间全部填为0,这在很多情况下是非常耗费性能的。

  整体上,对于这类问题的主要解决方法是:识别CPU消耗多的函数并且尽量减少这类函数的使用。比如,有些数据结构的memset是没有必要的,这些数据结构会被下一个query的数据自然填充。又或者采用更高效的初始化的方法。典型的例子是,字符串数组的初始化,只需要将第一个字符设置为0即可。

1.2 容器使用不当引起的问题

  程序设计中,容器的使用是必不可少的。不同类型的容器,其设计的目的是不同的,因此某些方面的性能天然地会比较低。我们在程序设计的时候,要能够正确的识别容器各种用法的性能,减少低效的使用。 
这里写图片描述
代码片段3中的第6行代码,将计算列表长度的方法放到了循环中,本身list类型求取长度的函数复杂度就是O(n),在这个操作放到循环中以后,直接将这段代码的复杂度提高到了O(n2),在列表中元素较多的情况下,对程序的性能将产生非常大的影响。代码片段3中的例子2是另外一种错误用法,算法复杂度也是O(n2)。 
这里写图片描述

  上面的两个例子,还有一个典型的特点,就是存在循环。对于循环程序来说,要尽量避免在循环体内进行大量消耗CPU的操作。即使是每次消耗的CPU较少,但是由于存在循环,算法复杂度提升了一个数量级,因此要特别的小心。

1.3 锁&上下文切换过多引起的问题

&meps; 程序中存在过多的加锁/解锁操作,是程序CPU性能恶化的另外一大类原因,其典型的现象是:系统态的CPU过高,甚至超过了用户态CPU。 
  自旋锁和互斥锁一样,是常见的解决系统资源互斥的方法。与互斥锁不同,自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。一般情况下,自旋锁锁定的资源释放的都比较快,在这种情况下,由于调用者不需要睡眠,减少了系统的切换,因此可以提高程序的性能。但随着程序处理能力、流量、数据大小的变化,自旋锁有时也会导致程序性能恶化。在我们收集的一个百度知道的案例中,程序访问cache的时候,通过自旋锁进行同步的控制。程序刚开始上线时并无问题,但随着流量的增大,当程序的qps达到1700时,系统态CPU高达73%,自旋锁引起了严重的性能瓶颈。对于这个case,主要的解决方案就是要“去锁”,减少锁操作。 
  另外一个例子也是关于加锁过多的。凤巢检索端的一个模块,处理一个请求时,每次最多可以得到4096个词,程序需要获取这些词的信息,这些信息大部分是存储在cache中,如果cache中不存在,则需要重新计算并更新cache。曾经一个存在的问题是,程序的设计逻辑是每次从cache中查询一个词的信息,并且在查询cache时要进行加锁/解锁操作。那么一次请求,最多要进行4096次操作。对于一个qps达到1000的程序来说,每秒的加锁操作达到了百万级,程序的性能严重恶化。 
  在操作系统课程中我们学习过,当线程需要等待一定的条件时会被操作系统放入的休眠队列中,直到被唤醒。程序的上下文切换过多,也会导致程序的性能恶化。曾经在在一个模块中出现这样的现象,机器的系统态CPU出现周期性的增长,现象如图1所示。 
这里写图片描述 
  经过排查,发现引起这种现象的原因是代码片段5中的一行shell代码引起的。这行代码的作用是将日志中含有keyword的最后100条日志找出来,并进行重新写数据。这行代码会被周期性的执行。图2给出了代码执行过程的示意图。grep写标准输出还经过标准C库这么一层缓冲,缓冲区大小默认是4K,也就是说grep先调用fwrite写标准C库缓冲区,写满4K以后,标准C库调用write系统调用将标准C库缓冲区刷到内核中的管道缓冲区,然后tail进程调用read系统调用从内核中的管道缓冲区一次性读取4K字节。很明显,grep写满内核中管道缓冲区以后,必须等待tail读取完成,才能继续写,那么这个时候,它就要被切换出去, 进入一个等待队列,tail进程被切换进来,读取4K字节,然后唤醒grep,tail被切换出去,grep被切换进来……随着需要grep的文件越来越大,进程切换的次数也越来越多,系统态的CPU占用也水涨船高。 
这里写图片描述

1.4 其他问题分析

  还有很多情况,都可能导致程序的CPU消耗过多,比如I/O操作过多。I/O操作过多问题中最常见的一类是程序打印了过多的日志。曾经在后羿系统就发生这样的例子,由于程序输出的日志从二进制升级为了字符串,整体的I/O量增加了30%,导致程序的吞吐量从3.8万降低到了2.1万,几乎下降一半。还有一个典型的I/O问题是程序中有很多的DEBUG日志,虽然最终在线上没有开启DEBUG日志打印,但是程序在运行过程中还是会走到DEBUG日志相关的程序逻辑,只是不进行日志的输出。如果在日志输出的地方,存在复杂的计算逻辑,那么程序的性能也下降。代码片段6中的代码就是一个例子。在这个debug日志输出的过程中调用了material对象的to_string函数,而这个函数非常的消耗性能。尽管程序最终没有输出DEBUG日志,但是to_string函数还是被调用到了,程序的性能依然会受到影响。 
这里写图片描述

Fast JSON是阿里巴巴提供的开源JSON工具,支持对JSON的序列化和反序列化的功能,号称是最快的JSON解析工具,在百度电影的部分模块中使用了这个工具。Fast JSON的1.2.2版本存在调用java.lang.System.getProperty时,多线程需要加锁,会带来线程hang住,引起系统性能降低的问题。这个问题导致了电影的这个模块出现了比较严重的线上问题。

CPU问题定位工具比较分析

对于C/C++程序,目前业界使用的比较多的CPU热点定位工具有:valgrind中组件callgrind,gprof(GNU Profiler),google perf tools组件中的CPU Profiler和Oprofiler。

• callgrind工具(valgrind套件之一):valgrind整体采用虚拟机的解决方案,将被测程序的指令转换了valgrind自身的代码Ucode,这样就可以实现对被测程序全面的分析(CPU, MEM)。

• gprof(GNU Profiler)工具 : GNU提供的工具,已经存在了30年左右了。主要通过在函数入口处插入代码的方式来统计函数的调用关系、次数及CPU使用方式。

• google perf tools(CPU Profile):对程序的调用栈进行采样分析,通过调用栈反推出函数的调用次数、关系和CPU消耗时间。

• Oprofile :利用cpu硬件提供的性能计数器,通过技术采样,从进程、函数、代码层面分析性能问题。更多的用于分析系统层面个的问题,用户态cpu只是其中一部分。

在c++ perf tools初体验这篇文章中,有比较详细的各类工具的用法和原理说明,有兴趣的同学可以深度阅读。

表2从多个维度对这4种工具进行了比较,综合比较这些因素后,我还是推荐使用google perf tools套件中的CPU Profiler,这个工具在灵活性、应用性等方面的优势非常明显。但就像表格中提到的,这种工具会让程序一定概率core dump。 
这里写图片描述

总结

本文收集并分析了十几个C/C++程序CPU性能问题,通过对这些问题的分析,我们发现CPU相关的性能问题,很多都是由于程序设计问题引起的。减少低效的调用,充分释放CPU的能力,是提升程序CPU性能的关键。从更大的层面上来看,程序的CPU性能还需要更好的架构设计,充分调用各种资源来高效地完成任务。google perf tools套件中的CPU Profiler工具是一个非常优秀的定位CPU热点的工具,希望大家能够多用这类工具来优化程序的CPU。

原文地址:https://www.cnblogs.com/lidabo/p/10253502.html