阅读 501

聊一下CPU占用高的解决方案

前言:

在软件开发和性能测试中,CPU占用率是服务器开发一个很重要的指标,到底有哪些因素会导致CPU占 用率上升呢?又有哪些手段可以降低CPU的占用率呢?

如果你看了这篇文章后仍然没有解决项目问题的思路,请在下方留言或公众号后台留言。(后续我将更新一到两篇关于内存优化,内存泄漏检测的分享)

废话少说,来点干货。

查看CPU占用率

1.Windows平台,你直接查看任务管理器,你很清楚的能看到各个进程的CPU占用情况。

​2.实际上我重点要给大家分享的是linux环境下的CPU监控。

我们一般使用top -Hp 进程ID

例如:top -Hp 5490

​这里我们就能看到当前进程下所有线程的CPU占用情况(%CPU这一列)

不错,眼尖的你可能已经发现了这里有几个子线程的CPU占用率特别高,已经超过85%,所以这块将会是我们将要优化的地方。

如何排查CPU占用率高问题

有哪些因素导致CPU占用率上升?

(1)复杂计算

运行一些算法处理,比如:音视频编解码、图像处理、科学计算等等,特别是一些浮点数的运算。另外频繁的循环嵌套也会造成CPU很高

例如这段循环嵌套的代码:

int main(){    
    vector<int> aa;     
    for(int i=0;i<10000;i++){       
        for(int j=0;j<1000;j++){          
            for(int k=0;k<1000;k++){             
                aa.push_back(i);           
            }        
        }     
    }     
    return 0;
}
复制代码

接下来我们 编译后生成可执行文件./t 运行的情况如下:

​​

你可能会感到吃惊,CPU占用率一直在99.7%-100%, 三层for循环嵌套下来,再加上频繁的往vector里push_back数据的确是很耗CPU。

(2)持续占用CPU

某些高优先级的进程/线程持续占用CPU,很少或者从来不sleep,类似 while(1) 0

这种现象实际上在多线程中比较常见,

假如有一个线程池,工作work子线程中比如有如下逻辑:

void ThreadPool::work(){    
// every thread will compete to pick up task from the queue to do the task    
    while (is_running_)    {        
ThreadTask* task = nullptr;        {            
std::unique_lock<std::mutex> lk(mtx_);           
 if (!tasks_.empty())            {​                
task = tasks_.front();                
tasks_.pop(); // remove the task           
 }            
else if (is_running_ && tasks_.empty())                
cond_.wait(lk);        
}        
if (task != nullptr)//    task(); // do the task       
 {            int ret = task->Run();            
if(ret == RET_PAUSE_TASK)            
{                
//need to run this task again,push to task queue                
std::unique_lock<std::mutex> lk(mtx_);                
tasks_.push(task);                
cond_.notify_one(); // wake a thread to to the task            
 }else{               
 delete task;                

task = nullptr;               
   }        
}    
}
}
复制代码

这段代码看着似乎没有什么问题,如果线程任务执行Run后返回RET_PAUSE_TASK,执行第26行后,相当于是唤醒线程池里的一个线程来抢占这个任务并执行,可惜不幸的是当前任务总是被当前子线程抢占,不断的Run,又不断的执行26行代码,如此下去,CPU已经被榨干了,你会发现CPU已经接近100%。

(3)数据拷贝

频繁进行大量的数据拷贝。

这里我不多说了,如果你有大量的memcpy,那你得注意下你的cpu了,尤其是Linux环境下的memcpy性能不怎么高,毕竟他是一字节一字节的拷贝,所以过于频繁的大数据拷贝必然增加CPU消耗。

(4)频繁的系统调用

比如:频繁调用printf打印、读写硬盘、网络收发等等。

虽然IO所需要的CPU资源非常少。大部分工作是分派给DMA(Direct Memory Access)直接内存存取完成的。但是先说说并发(Concurrencey)。一个非常不严谨的解释就是同时做A和B两件事。先做一会儿进程A,然后上下文切换,再做一会儿B。过一会儿在切回来继续做A。因此给我们造成一个假象,我们同时在做A和B两件事。这就是著名的进程模型。

这看上去很炫酷,但实际上并没有任何卵用。因为A,B两件事你都得做完不是?不论你是做完A再做B还是来回切换,花得时间应该是一样的。甚至还要更多,因为还要考虑到上下文切换的开销。

所以,正因为这样派发任务,通讯,等待的过程,并发系统才彰显出它的意义。当然实际过程可能比这个复杂一万倍。比如CPU是不会直接和硬盘对话的,他们之间有个中间人,叫DMA(Direct Memory Access)芯片

CPU计算文件地址 ——> 委派DMA读取文件 ——>DMA接管总线 ——> CPU的A进程阻塞,挂起——>CPU切换到B进程 ——>DMA读完文件后通知CPU(一个中断异常)——>CPU切换回A进程操作文件
复制代码

这个过程,对应下图(图源:《UNIX网络编程》),看到application这一列时间线了吗?aio_read操作之后,都是空白,CPU就不管了,可以做其他事去了。

假设原先读取文件CPU需要傻等50纳秒。现在尽管两次上下文切换要各消耗5纳秒。CPU还是赚了40纳秒时间片。一看上面这张图就知道,刚才讲的是传统5大IO模型中的“异步IO”的大致过程。想进一步了解,推荐直接读《UNIX网络编程》第一册套接字。

计算机硬件上使用DMA来访问磁盘等IO,也就是请求发出后,CPU就不再管了,直到DMA处理器完成任务,再通过中断告诉CPU完成了。所以,单独的一个IO时间,对CPU的占用是很少的,阻塞了就更不会占用CPU了,单线程环境下会导致程序都不继续运行了,在多线程环境下,CPU会把时间交给其它线程和进程了。

虽然IO不会占用大量的CPU时间,但是非常频繁的IO还是会非常浪费CPU时间的。

如何降低CPU占用率

实际上你知道了CPU占用高的原因,你慢慢研究,有针对性的就能找到答案。

(1)硬件加速和借助并行计算/多线程

常见的硬件加速有:多核计算、GPU、DMA、音视频的硬件编解码等,很多硬件加速功能需要平台提供API或者驱动支持。

对于一些复杂的或者高并发的数据处理,我们经常会想到使用线程池,那么关于线程池的用法,可以参考之前的文章线程池原理 ,

UNIX(多线程):21---线程池实现原理

UNIX(多线程):22---几种常见的线程池

UNIX(多线程):23---线程池注意事项和常见问题

线程是CPU调度和分配的基本单位,当多线程任务执行的时候为了争抢CPU资源,他们会打架,打赢了的会占有CPU资源,做他的任务,那么其他的线程任务就得等此线程让出CPU资源之后,大家再进行一番激烈的内斗,然后成王败寇,胜利者享有CPU资源跑任务...接着你发现,哦,一个CPU有点捉风见肘,那多搞几个CPU,毕竟"狼多肉少"的苦逼日子不好过,减少其中一个CPU的压力,不过线程也不是越多越好,毕竟“打群架”的时候真正干活的效率也不一定高。

比如常见的一些音视频编解码算法通常很复杂,使用GPU硬件加速来提高执行效率,同时也可以降低CPU消耗,另外像ffmpeg算法库里的编码库里有参数thread_count可以指定线程个数,你可以指定为2,3,4,这样,你的编码效率就会通过多线程计算得到效率和性能的提升。

(2)for循环导致的cpu切换的次数较多问题优化

比如我前面提到的for循环嵌套了3层,实际上逻辑上你可以尽可能的调整到更少的循环层级。

再看下这2段代码:

for(int i = 0;i<100;i++) {     for(int j = 0;j<10000;j++)     {        //TODO ...      }  }
复制代码

for(int i = 0;i<10000;i++)  {    for(int j = 0;j<100;j++)    {       //balabala14        }}
复制代码

哪个效率更高?第二个吗?在for循环中嵌套过深,cpu切换的次数也会比较多,因此最长循环放到内部可以提高I cache的效率,降低因为循环跳转造成cache的miss以及流水线flush造成的延时

2. 多次相同循环后也能提高跳转预测的成功率,提高流水线效率
3. 编译器会自动展开循环提高效率, 这个不一定是必然有效的
但不是绝对正确的,比如: 1 int x[1000][100];

for(i=0;i<1000;i++)    for(j=0;j<100;j++)  {    //access x[i][j]     }

  int x[1000][100];  for(j=0;j<100;j++)   for(i=0;i=1000;i++) {   //access x[i][j]  }
复制代码

这时候第一个的效率就比第二个的高,这个和硬件也有一些关系,CPU对于内存的访问都是通过数据缓存(cache)来进行的。比如一个通用CPU,一级缓存(L1-Cache)的大小为16K,而其组织结构为每32个字节一组(cache line size=32byte),
也就是每次从二级缓存或内存取数据到一级缓存,都是一次性取32个字节。
对于上面的第一段代码,每次取数据到一级缓存,都有连续8次内存访问可以共享一条缓存。
而对于第二段代码,每次取数据到一级缓存后,访问一次后,基本上就没有机会被再次使用了;
上面这两段代码的区别在于第一段代码,每次内存访问后,地址值需要加常数4,而第二段代码,每次访问后,地址值加400。
如果没有对于缓存访问的区别,那么这时我们的确可以将长的循环放在里层,短的放在外层。但是而其主要原因不是一般人所想象的指令数目的区别的问题,
而主要由于分支预测错误会引起的流水线中断从而导致性能的降低。

(3)学会“让出”CPU

在程序中,有多种方法可以“让出”CPU,第一种是sleep,第二种是await/signal机制, 任何编程语言都会有类似的接口。

另外,还有一种策略:适当降低、提升你的进程/线程的优先级。

实际上我刚才提供的线程池代码块里第26行执行完后加入sleep(0)就可以解决此问题。

当sleep(0)的时候,线程会放弃当前时间片,但是仍然保持可运行状态,直到下次有空闲时间片就会被重新运行,相当于放弃时间片后被放到了所有可运行线程的队列尾部。Sleep(0)的用途是,你仅仅想简单放弃时间片,给别的线程一个运行机会,而且希望系统有空闲的时候自己能尽快被再次调度。纯粹运算的线程可以通过sleep(0)放出一些时间片,给其他IO线程一个喘息的机会。频繁调用Sleep(0)会让性能大幅下降,请谨慎使用。在程序不大的时候而且cpu占有率很高的情况下,某种条件下,在while(ture)最后的位置可以放上sleep(0),可以起到降低cpu占比的作用。当然并不建议程序有死循环,而且sleep(0)这种办法并不是最好的办法,还是需要重新设计代码结构来避免cpu占有率过高的问题(比如通过await/signal机制)。

继续看例子:

这是我写的一个线程池模型代码:

链接:pan.baidu.com/s/15GNTo59M…

提取码:igsj

运行下来,你发现卧槽,就几个简单的printf就占用了这么高的CPU?

那你不妨顺着刚才的思路来看多线程问题,

我在子线程循环执行任务之后加个了sleep

结果大不一样,CPU降下来了,同时对执行效率也没有多大的影响。

(4)避免频繁的数据拷贝

在多线程编程中,数据拷贝是难免的,但是完全可以通过一些技巧减少一些不必要的拷贝,心中要有这理念,编程时多留点心,比如我刚才提到的memcpy,你可以自己去实现一套memcpy,比如你是4字节对齐方式,就不需要一字节一字节的拷贝,可以一次拷贝4字节内容。

(5)合并一些系统调用

很多时候,多次打印可以尽量合并到一起再打印、多次硬盘/网络访问请求也可以合并到一起再发送,或者你使用缓存方式,减少频繁的IO读写。

所以面对大量IO的任务,有时候是需要算法来合并IO,或者通过cache来缓解IO压力的。

假如我有一个函数fun需要将玩家的一些操作打印输出到log文件中,可是此函数调用比较频繁,这样频繁的写IO操作会造成CPU负担太重,那么我可以加上一个条件:if (log_count_++ % 500 == 0),每500条log做一次输出

if (log_count_++ % 500 == 0){            LOG_INFO("writing..., cmd(0x%08x), bigSeq=%u, samllSeq=%u, E=%u, payload=%u, confid=%u, groupid=%u, ssrc=%u, data_count=%u",                dts_msg->GetCommand(), dts_msg->GetSequence(), dts_msg->GetExtSEQ(), dts_msg->GetExtFlag(),                dts_msg->GetPayload(), conf_id_, dts_msg->GetGroupID(), dts_msg->GetSSRC(), log_count_);        }
复制代码

又比如,你有大量玩家/用户数据要记录写IO,但这些数据又是比较密集和频繁的接收过来,如果每接收一次数据,你就写一次IO,你再瞅瞅你的CPU。

那么为了降低CPU资源被IO这样过度消费,你不妨将数据先缓存一部分到record_msg_cache_,当数据量达到g_max_msg_size这个阈值的时候,将缓存数据一次性写入IO,这样能最大可能的保持数据的完整性,同时降低CPU资源的消耗。

    record_msg_cache_.push_back(tpb);     auto uSize = record_msg_cache_.size();    if (uSize > g_max_msg_size)    {      ret = storage_handler_->WriteData(conf_file, record_msg_cache_, msg_cache_size_);        record_msg_cache_.clear();        msg_cache_size_ = 0;    }
复制代码

比如你如果有大量的玩家数据需要读写,你可以借助共享内存,比如一些cache,映射到磁盘的一个文件块,当你有玩家数据更新的时候就会很方便。

(6)调整线程优先级

我们的操作系统OS很强大,提供了调度策略,这里我主要分享linux内核的三种 策略:

  • SCHED_OTHER 分时调度策略,(默认的)

  • SCHED_FIFO实时调度策略,先到先服务

  • SCHED_RR实时调度策略,时间片轮转

实时进程将得到优先调用,实时进程根据实时优先级决定调度权值,分时进程则通过nice和counter值决定权值,nice越小,counter越大,被调度的概率越大,也就是曾经使用了cpu最少的进程将会得到优先调度。

SHCED_RR和SCHED_FIFO的不同:

当采用SHCED_RR策略的进程的时间片用完,系统将重新分配时间片,并置于就绪队列尾。放在队列尾保证了所有具有相同优先级的RR任务的调度公平。

SCHED_FIFO一旦占用cpu则一直运行。一直运行直到有 更高优先级任务到达或自己放弃 。

如果有相同优先级的实时进程(根据优先级计算的调度权值是一样的)已经准备好,FIFO时必须等待该进程主动放弃后才可以运行这个优先级相同的任务。而RR可以让每个任务都执行一段时间。

相同点:

  • RR和FIFO都只用于实时任务。

  • 创建时优先级大于0(1-99)。

  • 按照可抢占优先级调度算法进行。

  • 就绪态的实时任务立即抢占非实时任务。

当所有任务都采用分时调度策略时(SCHED_OTHER):
1.创建任务指定采用分时调度策略,并指定优先级nice值(-20~19)。
2.将根据每个任务的nice值确定在cpu上的执行时间( counter )。
3.如果没有等待资源,则将该任务加入到就绪队列中。
4.调度程序遍历就绪队列中的任务,通过对每个任务动态优先级的计算(counter+20-nice)结果,选择计算结果最大的一个去运行,当这个时间片用完后(counter减至0)或者主动放弃cpu时,该任务将被放在就绪队列末尾(时间片用完)或等待队列(因等待资源而放弃cpu)中。
5.此时调度程序重复上面计算过程,转到第4步。
6.当调度程序发现所有就绪任务计算所得的权值都为不大于0时,重复第2步。

当所有任务都采用FIFO调度策略时(SCHED_FIFO):
1.创建进程时指定采用FIFO,并设置实时优先级rt_priority(1-99)。
2.如果没有等待资源,则将该任务加入到就绪队列中。
3.调度程序遍历就绪队列,根据实时优先级计算调度权值,选择权值最高的任务使用cpu, 该FIFO任务将一直占有cpu直到有优先级更高的任务就绪(即使优先级相同也不行)或者主动放弃(等待资源)。
4.调度程序发现有优先级更高的任务到达(高优先级任务可能被中断或定时器任务唤醒,再或被当前运行的任务唤醒,等等),则调度程序立即在当前任务堆栈中保存当前cpu寄存器的所有数据,重新从高优先级任务的堆栈中加载寄存器数据到cpu,此时高优先级的任务开始运行。重复第3步。
5.如果当前任务因等待资源而主动放弃cpu使用权,则该任务将从就绪队列中删除,加入等待队列,此时重复第3步。
当所有任务都采用RR调度策略(SCHED_RR)时:
1.创建任务时指定调度参数为RR, 并设置任务的实时优先级和nice值(nice值将会转换为该任务的时间片的长度)。
2.如果没有等待资源,则将该任务加入到就绪队列中。
3.调度程序遍历就绪队列,根据实时优先级计算调度权值,选择权值最高的任务使用cpu。
4. 如果就绪队列中的RR任务时间片为0,则会根据nice值设置该任务的时间片,同时将该任务放入就绪队列的末尾 。重复步骤3。
5.当前任务由于等待资源而主动退出cpu,则其加入等待队列中。重复步骤3。
系统中既有分时调度,又有时间片轮转调度和先进先出调度:
1.RR调度和FIFO调度的进程属于实时进程,以分时调度的进程是非实时进程。
2. 当实时进程准备就绪后,如果当前cpu正在运行非实时进程,则实时进程立即抢占非实时进程 。
3. RR进程和FIFO进程都采用实时优先级做为调度的权值标准,RR是FIFO的一个延伸。FIFO时,如果两个进程的优先级一样,则这两个优先级一样的进程具体执行哪一个是由其在队列中的未知决定的,这样导致一些不公正性(优先级是一样的,为什么要让你一直运行?),如果将两个优先级一样的任务的调度策略都设为RR,则保证了这两个任务可以循环执行,保证了公平。

这里举个例子:

  thread t = std::thread(&MixerImp::MixerThread, this);  sched_param sch;  sch.sched_priority = sched_get_priority_max(SCHED_FIFO);  if (pthread_setschedparam(t.native_handle(), SCHED_FIFO, &sch))         {​  }
复制代码

这里我将线程t的优先级提升为最高的(通过sched_get_priority_max来获取当前调度算法下的最高优先级),当然你可以使用pthread_getschedparam来查看当前线程的调度策略和线程优先级。

在通过调整线程优先级和调度算法之后,你会发现,wow,原来这个方法还是屡试不爽。

文章分类
后端
文章标签