Linux下C/C++程序CPU问题分析及优化心得

一、前言

程序的CPU问题是另外一类典型的程序性能问题,很多开发人员都受到过程序CPU占用过高的困扰。本文首先探讨了CPU占用率的排查方法,其次针对一些典型的CPU问题进行分析,最后总结了一些实践心得。

 

二、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只是其中一部分。

而在Linux系统中,最简单直接的方法就是执行top指令查看当前cpu的整体情况(下图中32230进程占用cpu最高):

执行top -p 32230 -H 指令,查看当前进程下 各线程占用cpu情况:

 

 执行 pstack 32230指令,查看当前线程的堆栈信息,定位到具体函数。(备注:pstack 是一个shell脚本)

#!/bin/sh
  
if test $# -ne 1; then
    echo "Usage: `basename $0 .sh` <process-id>" 1>&2
    exit 1
fi
 
if test ! -r /proc/$1; then
    echo "Process $1 not found." 1>&2
    exit 1
fi
 
# GDB doesn't allow "thread apply all bt" when the process isn't
# threaded; need to peek at the process to determine if that or the
# simpler "bt" should be used.
 
backtrace="bt"
if test -d /proc/$1/task ; then
    # Newer kernel; has a task/ directory.
    if test `/bin/ls /proc/$1/task | /usr/bin/wc -l` -gt 1 2>/dev/null ; then
    backtrace="thread apply all bt"
    fi
elif test -f /proc/$1/maps ; then
    # Older kernel; go by it loading libpthread.
    if /bin/grep -e libpthread /proc/$1/maps > /dev/null 2>&1 ; then
    backtrace="thread apply all bt"
    fi
fi
 
GDB=${GDB:-gdb}
 
# Run GDB, strip out unwanted noise.
# --readnever is no longer used since .gdb_index is now in use.
$GDB --quiet -nx $GDBARGS /proc/$1/exe $1 <<EOF 2>&1 |
set width 0
set height 0
set pagination no
$backtrace
EOF
/bin/sed -n \
    -e 's/^\((gdb) \)*//' \
    -e '/^#/p' \
    -e '/^Thread/p'

 

三、CPU问题分析

一般来说,导致程序CPU占用过高的主要原因是程序设计不合理,绝大部分的CPU问题都是程序设计的问题。因此,提高程序的设计质量是避免CPU问题的主要手段。下面举例说明常见的CPU问题:

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

在程序设计中,有些程序的写法是比较低效的,没有经验的程序员很容易使用一些低效的函数或方法,从而入“坑”。

memset是一个很常见的性能坑。如果在程序中使用的memset过多,会导致程序的CPU消耗很大。memset使用过多,往往在不经意间就让程序下降了一大截。关于memset函数,一种常见的误用是在循环中对较大的数据结构进行memset。有些memset的问题,通过代码review等方式比较容易发现,但是需要注意识别某些情况下,隐式的memset操作也会发生,导致排查难度加大。

char buffer[1024] = {0};

上述代码在实际的运行过程中是会调用memset的:在栈内存中申请缓冲区,然后再赋值,会隐式的调用memset,将内存初始化为0。

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

strncpy这个字符串操作函数是比较耗费性能的,同strncpy函数实现类似功能的函数有snprintf和memcpy+strlen这两种方式。通过几个函数的源代码可以看出,memcpy用了page copy和word copy结合,所以性能优化的比较好,而且strlen也是用4字节做循环步长的。strncpy只是简单地逐字节拷贝,并且会将目标buffer后面所有的空闲空间全部填为0,这在很多情况下是非常耗费性能的。

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

 

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

程序设计中,容器的使用是必不可少的。不同类型的容器,其设计的目的是不同的,因此某些方面的性能天然地会比较低。我们在程序设计的时候,要能够正确的识别容器各种用法的性能,减少低效的使用。

std::list<int> lst;
// some logics
for(int i=0; i < lst.size(); i++){
    // some logics
}

上述代码将计算列表长度的方法放到了循环中,本身list类型求取长度的函数复杂度就是O(n),在这个操作放到循环中以后,直接将这段代码的复杂度提高到了O(n2),在列表中元素较多的情况下,对程序的性能将产生非常大的影响。 

该类型问题建议的解决方法:对于循环程序来说,要尽量避免在循环体内进行大量消耗CPU的操作。即使是每次消耗的CPU较少,但是由于存在循环,算法复杂度提升了一个数量级,值得注意。

 

3.3 锁及上下文切换过多引起的问题

程序中存在过多的加锁/解锁操作,是程序CPU性能恶化的另外一大类原因,其典型的现象是:系统态的CPU过高,甚至超过了用户态CPU。

自旋锁和互斥锁一样,是常见的解决系统资源互斥的方法。与互斥锁不同,自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。一般情况下,自旋锁锁定的资源释放的都比较快,在这种情况下,由于调用者不需要睡眠,减少了系统的切换,因此可以提高程序的性能。但随着程序处理能力、流量、数据大小的变化,自旋锁有时也会导致程序性能恶化。在我们收集的一个百度知道的案例中,程序访问cache的时候,通过自旋锁进行同步的控制。程序刚开始上线时并无问题,但随着流量的增大,当程序的qps达到1700时,系统态CPU高达73%,自旋锁引起了严重的性能瓶颈。对于这个case,主要的解决方案就是要“去锁”,减少锁操作。另外一个例子也是关于加锁过多的。凤巢检索端的一个模块,处理一个请求时,每次最多可以得到4096个词,程序需要获取这些词的信息,这些信息大部分是存储在cache中,如果cache中不存在,则需要重新计算并更新cache。曾经一个存在的问题是,程序的设计逻辑是每次从cache中查询一个词的信息,并且在查询cache时要进行加锁/解锁操作。那么一次请求,最多要进行4096次操作。对于一个qps达到1000的程序来说,每秒的加锁操作达到了百万级,程序的性能严重恶化。

在操作系统课程中,当线程需要等待一定的条件时,会被操作系统放入的休眠队列中,直到被唤醒。程序的上下文切换过多,也会导致程序的性能恶化。

曾经在在一个模块中出现这样的现象,机器的系统态CPU出现周期性的增长,经过排查,发现引起这种现象的原因是代码片段中的一行shell代码引起的。这行代码的作用是将日志中含有keyword的最后100条日志找出来,并进行重新写数据。这行代码会被周期性的执行。下图给出了代码执行过程的示意图。grep写标准输出还经过标准C库这么一层缓冲,缓冲区大小默认是4K,也就是说grep先调用fwrite写标准C库缓冲区,写满4K以后,标准C库调用write系统调用将标准C库缓冲区刷到内核中的管道缓冲区,然后tail进程调用read系统调用从内核中的管道缓冲区一次性读取4K字节。很明显,grep写满内核中管道缓冲区以后,必须等待tail读取完成,才能继续写,那么这个时候,它就要被切换出去, 进入一个等待队列,tail进程被切换进来,读取4K字节,然后唤醒grep,tail被切换出去,grep被切换进来……随着需要grep的文件越来越大,进程切换的次数也越来越多,系统态的CPU占用也水涨船高。

 

 该类型问题建议的解决方法:要“去锁”,减少锁操作,减少不必要的上下文切换。

 

3.4 其他问题分析

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

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

 

四、总结与心得

本文探讨了CPU占用率的排查方法,然后针对一些典型的CPU问题进行分析,通过对这些问题的分析,我们发现CPU相关的性能问题,很多都是由于程序设计问题引起的。减少低效的调用,充分释放CPU的能力,是提升程序CPU性能的关键。从更大的层面上来看,程序的CPU性能还需要更好的架构设计,充分调用各种资源来高效地完成任务。google perf tools套件中的CPU Profiler工具是一个非常优秀的定位CPU热点的工具,希望大家多借助这类工具来优化程序的CPU。

 

参考:

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

posted @ 2023-02-10 17:41  小金乌会发光-Z&M  阅读(2078)  评论(0编辑  收藏  举报