进程/线程与CPU核绑定


概述

看Redis的时候提到了这一点,对尾延迟进行优化的话往往需要对CPU下功夫,因为往往是Redis 实例运行时的CPU 的 context switch 次数比较多导致的。而服务器上往往是CPU多核的环境,一个线程先在一个 CPU 核上运行,之后又切换到另一个 CPU 核上运行,这时就会发生 context switch。本质上是对任务调度进行调优。

基本概念

cpu亲和性(affinity)

CPU的亲和性, 就是进程要在指定的 CPU 上尽量长时间地运行而不被迁移到其他处理器,也称为CPU关联性;再简单的点的描述就将指定的进程或线程绑定到相应的cpu上;在多核运行的机器上,每个CPU本身自己会有缓存,缓存着进程使用的信息,而进程可能会被OS调度到其他CPU上,如此,CPU cache命中率就低了,当绑定CPU后,程序就会一直在指定的cpu跑,不会由操作系统调度到其他CPU上,性能有一定的提高。

软亲和性(affinity)

就是进程要在指定的 CPU 上尽量长时间地运行而不被迁移到其他处理器,Linux 内核进程调度器天生就具有被称为 软 CPU 亲和性(affinity) 的特性,这意味着进程通常不会在处理器之间频繁迁移。这种状态正是我们希望的,因为进程迁移的频率小就意味着产生的负载小。

硬亲和性(affinity)

简单来说就是利用linux内核提供给用户的API,强行将进程或者线程绑定到某一个指定的cpu核运行。

  • 提高CPU缓存命中率

CPU各核之间是不共享缓存的,如果进程频繁地在多个CPU核之间切换,则会使旧CPU核的cache失效,失去了利用CPU缓存的优势。如果进程只在某个CPU上执行,可以避免进程在一个CPU上停止执行,然后在不同的CPU上重新执行时发生的缓存无效而引起的性能成本。

  • 适合对时间敏感的应用

在实时性要求高应用中,我们可以把重要的系统进程绑定到指定的CPU上,把应用进程绑定到其余的CPU上。这种做法确保对时间敏感的应用程序可以得到运行,同时可以允许其他应用程序使用其余的计算资源。

如何绑定

通过taskset:(-p:pid; -c:cpu list)能够将进程绑定在指定的核上:

taskset -c 0 ./redis-server

在多CPU也就是NUMA架构下,可以为了提升 Redis 的网络性能,把操作系统的网络中断处理程序和 CPU 核绑定(其实如果看过之前的文章你会发现软中断已经绑定了,谁调用绑谁)。这个做法可以避免网络中断处理程序在不同核上来回调度执行,的确能有效提升 Redis 的网络处理性能。

不过,需要注意的是在 CPU 的 NUMA 架构下,对 CPU 核的编号规则,并不是先把一个 CPU Socket 中的所有逻辑核编完,再对下一个 CPU Socket 中的逻辑核编码,而是先给每个 CPU Socket 中每个物理核的第一个逻辑核依次编号,再给每个 CPU Socket 中的物理核的第二个逻辑核依次编号。

假设有 2 个 CPU Socket,每个 Socket 上有 6 个物理核,每个物理核又有 2 个逻辑核,总共 24 个逻辑核。我们可以执行 lscpu 命令,查看到这些核的编号:

lscpu

Architecture: x86_64
...
NUMA node0 CPU(s): 0-5,12-17
NUMA node1 CPU(s): 6-11,18-23
...

在绑定多个核的时候不要绑定错了,接下来回到正题,当我们把 Redis 实例绑到一个 CPU 逻辑核上时,就会导致子进程、后台线程和 Redis 主线程竞争 CPU 资源,一旦子进程或后台线程占用 CPU 时,主线程就会被阻塞,导致 Redis 请求延迟增加,当然不止Redis,别的程序同样也通用。

目前有两个解决方案:

方案一:一个实例对应绑一个物理核

在给 Redis 实例绑核时,我们不要把一个实例和一个逻辑核绑定,而要和一个物理核绑定,也就是说,把一个物理核的 2 个逻辑核都用上。如果我们的CPU架构还是和之前一样的话,那么可以这样绑定在一个物理CPU上:

taskset -c 0,12 ./redis-server

和只绑一个逻辑核相比,把 Redis 实例和物理核绑定,可以让主线程、子进程、后台线程共享使用 2 个逻辑核,可以在一定程度上缓解 CPU 资源竞争。但是,因为只用了 2 个逻辑核,它们相互之间的 CPU 竞争仍然还会存在。

方案二:通过系统调用绑定

Linux中,用结构体cpu_set_t来表示CPU Affinity掩码,同时定义了一系列的宏来用于操作进程的可调度CPU集合:

#define _GNU_SOURCE 
#include <sched.h>
void CPU_ZERO(cpu_set_t *set);
void CPU_SET(int cpu, cpu_set_t *set);
void CPU_CLR(int cpu, cpu_set_t *set);
int CPU_ISSET(int cpu, cpu_set_t *set);
int CPU_COUNT(cpu_set_t *set);

具体的作用如下:

CPU_ZERO():清除集合的内容,让其不包含任何CPU。
CPU_SET():添加cpu到集合中。
CPU_CLR():从集合中移除cpu
CPU_ISSET() :测试cpu是否在集合中。
CPU_COUNT():返回集合中包含的CPU数量。

Linux中,可以使用以下两个函数设置和获取进程的CPU Affinity属性:

#define _GNU_SOURCE 
#include <sched.h>
int sched_setaffinity(pid_t pid, size_t cpusetsize,const cpu_set_t *mask);
int sched_getaffinity(pid_t pid, size_t cpusetsize,cpu_set_t *mask

另外可以通过下面的函数获知当前进程运行在哪个CPU上:

int sched_getcpu(void);

如果调用成功,该函数返回一个非负的CPU编号值。

那么,怎么在编程时把这三个函数结合起来实现绑核呢?很简单,我们分四步走就行。

  • 第一步:创建一个 cpu_set_t 结构的位图变量;
  • 第二步:使用 CPU_ZERO 函数,把 cpu_set_t 结构的位图所有的位都设置为 0;
  • 第三步:根据要绑定的逻辑核编号,使用 CPU_SET 函数,把 cpu_set_t 结构的位图相应位设置为 1;
  • 第四步:使用 sched_setaffinity 函数,把程序绑定在 cpu_set_t 结构位图中为 1 的逻辑核上。
//线程函数
void worker(int bind_cpu){
    cpu_set_t cpuset;  //创建位图变量
    CPU_ZERO(&cpu_set); //位图变量所有位设置0
    CPU_SET(bind_cpu, &cpuset); //根据输入的bind_cpu编号,把位图对应为设置为1
    sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在cpu_set_t结构位图中为1的逻辑核

    //实际线程函数工作
}

int main(){
    pthread_t pthread1
    //把创建的pthread1绑在编号为3的逻辑核上
    pthread_create(&pthread1, NULL, (void *)worker, 3);
}

和给线程绑核类似,当我们使用 fork 创建子进程时,也可以把刚刚说的四步操作实现在 fork 后的子进程代码中,示例代码如下:

int main(){
   //用fork创建一个子进程
   pid_t p = fork();
   if(p < 0){
      printf(" fork error\n");
   }
   //子进程代码部分
   else if(!p){
      cpu_set_t cpuset;  //创建位图变量
      CPU_ZERO(&cpu_set); //位图变量所有位设置0
      CPU_SET(3, &cpuset); //把位图的第3位设置为1
      sched_setaffinity(0, sizeof(cpuset), &cpuset);  //把程序绑定在3号逻辑核
      //实际子进程工作
      exit(0);
   }
   ...
}

对于 Redis 来说,生成 RDB 和 AOF 日志重写的子进程分别是下面两个文件的函数中实现的。

  • rdb.c 文件:rdbSaveBackground 函数;
  • aof.c 文件:rewriteAppendOnlyFileBackground 函数。

这两个函数中都调用了 fork 创建子进程,所以,我们可以在子进程代码部分加上绑核的四步操作。使用源码优化方案,我们既可以实现 Redis 实例绑核,避免切换核带来的性能影响,还可以让子进程、后台线程和主线程不在同一个核上运行,避免了它们之间的 CPU 资源竞争。相比使用 taskset 绑核来说,这个方案可以进一步降低绑核的风险。

延伸

对于一个多CPU的服务器,可以尽量将应用分散在多个CPU上,这样可以更好地提高L3 Cache的命中率、内存利用率、避免使用到Swap:

1、由于CPU Socket1和2分别有自己的L3 Cache,如果把所有实例都绑定在同一个CPU Socket上,相当于这些实例共用这一个L3 Cache,另一个CPU Socket的L3 Cache浪费了。这些实例共用一个L3 Cache,会导致Cache中的数据频繁被替换,访问命中率下降,之后只能从内存中读取数据,这会增加访问的延迟。而8个实例分别绑定CPU Socket,可以充分使用2个L3 Cache,提高L3 Cache的命中率,减少从内存读取数据的开销,从而降低延迟。

2、如果这些实例都绑定在一个CPU Socket,由于采用NUMA架构的原因,所有实例会优先使用这一个节点的内存,当这个节点内存不足时,再经过总线去申请另一个CPU Socket下的内存,此时也会增加延迟。而8个实例分别使用2个CPU Socket,各自在访问内存时都是就近访问,延迟最低。

3、如果这些实例都绑定在一个CPU Socket,还有一个比较大的风险是:用到Swap的概率将会大大提高。如果这个CPU Socket对应的内存不够了,也可能不会去另一个节点申请内存(操作系统可以配置内存回收策略和Swap使用倾向:本节点回收内存/其他节点申请内存/内存数据换到Swap的倾向程度),而操作系统可能会把这个节点的一部分内存数据换到Swap上从而释放出内存给进程使用(如果没开启Swap可会导致直接OOM)。因为Redis要求性能非常高,如果从Swap中读取数据,此时Redis的性能就会急剧下降,延迟变大。所以8个实例分别绑定CPU Socket,既可以充分使用2个节点的内存,提高内存使用率,而且触发使用Swap的风险也会降低。

在NUMA架构下,也经常发生某一个节点内存不够,但其他节点内存充足的情况下,依旧使用到了Swap,进而导致软件性能急剧下降的例子。所以在运维层面,我们也需要关注NUMA架构下的内存使用情况(多个内存节点使用可能不均衡),并合理配置系统参数(内存回收策略/Swap使用倾向),尽量去避免使用到Swap。

注意事项

虽然绑核技术可以提高程序性能,但也需要注意以下几点:

  1. 不要过度绑定:过度绑定可能会出现线程之间的竞争和CPU利用率低下的情况。
  2. 绑定前需要评估:在进行核心绑定之前,需要对程序进行评估,以确定性能瓶颈位置和绑定的核心数。
  3. 不要跨核心访问内存:如果一个进程已经绑定到一个核心上,那么该进程所使用的内存也只应该在该核心专用的内存上进行操作。如果在不同核心之间频繁地进行内存操作,则会影响程序的性能。

评论
  目录