CPU绑定的几种实现方式

CPU绑定的几种实现方式

1.了解你的CPU

在现代多处理器系统中(如下图),每个系统(System)可能安装多个处理器(Processor/Socket)芯片,每个处理器可能包含多个核心(Core),每个核心可能包含多个指令流水线。

通过lscpu命令可以查看CPU的信息,下图展示的是某台服务器的信息:

上述信息表明,该服务器:

  • 共有24个核心(CPU(s))
  • 每个核心有1个线程(即指令流水线)
  • 每颗处理器(Socket(s))有12个核心
  • 有两颗处理器(NUMA架构)

上述信息还展示了各个核心在NUMA节点上的分布,比如CPU 0是位于NUMA节点0上的。CPU 0通常被用来处理所有中断,比如网络、信号等,所以我们在绑定一些需要频繁处理中断的程序时,就可以把他们绑定在CPU 0或者其他与CPU 0位于同一NUMA节点的CPU上。

2.CPU绑定的意义

一是现代服务器大多采用NUMA多处理器架构,一台服务器会安装多颗处理器(称为NUMA节点),而NUMA架构各节点资源较为独立的设计,决定了在不同NUMA节点共享数据的成本高昂,因此尽量将数据交互较为频繁的程序绑定在同一NUMA节点上是很重要的。

二是进程/线程如果从一个核心切换至另一个核心上运行,需要面临上下文切换、缓存失效等问题,成本也很高。在对性能要求较高的软件中,这已经是造成时延抖动的一大来源之一。

上图展示了一个实测的例子,工作集(working set)较大的进程在进行上下文切换时,其时延可高达50微秒。

3.CPU绑定原理

在Linux系统下,进程都有一个CPU亲和力属性(affinity),通过以下命令可以查询:

以上查询结果的含义是,进程id为4500的进程,可以在0、1、2、3号CPU上运行,我的笔记本的CPU是四核的,因此默认情况下,进程是可以在任意一个核心上运行的。

我们编写下面这样一个程序,观察下Linux进程调度的现象:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

// g++ -o bind_1 bind_1.c

void print_running_cpu()
{
    char qry_cmd[1024] = { 0 };
    sprintf(qry_cmd, "ps -o pid,psr -p %d | tail -n 1 | awk {'print $2'}", getpid());
    FILE *fp=popen(qry_cmd,"r");
    if(fp == NULL)
        return ;

    char cpu_id_str[200] = { 0 };
    fgets(cpu_id_str,80,fp);
    fclose(fp);

    printf("current process %d is running on cpu(%d)\n", getpid(), atoi(cpu_id_str));
}

int main()
{
    print_running_cpu();

    while(1)
    {
        long loop = 4000000000;
        while(loop--)
            ;
        
        sleep(0);

        printf("----------------\n");
        printf("switched !\n");
        print_running_cpu();
    }

    return 0;
}

这段代码有两个关键逻辑:

  • 循环执行空语句:这样程序的CPU占用率会达到100%,方便我们观察程序在哪个核上运行
  • 每隔一段时间执行一次sleep(0):主动放弃CPU占用,让Linux重新调度一次,这样进程就有机会被切换到其他核心上执行

另外,该程序在运行时还会打印自己当前所在的CPU号,运行结果如下:

可以看到,进程会不断地在不同的CPU之间跳动。

用htop也能观察到同样的结果:

如果这是个时延敏感的系统,这样频繁的在CPU之间跳动,无疑会带来额外的进程上下文切换等开销,造成时延抖动。

4.通过命令绑定(进程)

通过taskset命令可以在不修改程序的情况下从外部将程序绑定至某个CPU核心。

以将上面的进程绑定至CPU 0为例:

从程序的输出来看,在运行时不会被切换到其他核心上:

从htop上看也是如此:

证明绑定的效果达到了。

另外,如果我们想将程序绑定到一个CPU列表上,比如绑定到NUMA node0上的所有CPU,通过taskset命令也可以做到:

这样的话,进程就只能在0号和2号CPU上运行了。

在实际的生产系统运维过程中,运行程序前是不知道程序的pid的,好在taskset命令还支持启动时指定亲和力,方法如下:

5.通过命令绑定(线程)

我们编写如下程序,启动两个线程,各个线程循环执行空语句,每隔一段时间sleep(0)一次,方便操作系统将线程切换到其他核上运行。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

// g++ -o bind_2 bind_2.c -lpthread

void print_running_cpu()
{
    char qry_cmd[1024] = { 0 };
    sprintf(qry_cmd, "ps -o pid,spid,psr -T -p %d | grep %d | tail -n 1 | awk {'print $3'}", 
        getpid(), gettid());
    FILE *fp=popen(qry_cmd,"r");
    if(fp == NULL)
        return ;

    char cpu_id_str[200] = { 0 };
    fgets(cpu_id_str,80,fp);
    fclose(fp);

    printf("[%d] : current thread(%d@%d) is running on cpu(%d)\n", 
        gettid(), gettid(), getpid(), atoi(cpu_id_str));
}

void* thread_func(void* p_arg)
{
    print_running_cpu();
    while(1)
    {
        long loop = 4000000000;
        while(loop--)
            ;
        
        sleep(0);

        printf("[%d] : ----------------\n", gettid());
        printf("[%d] : switched !\n", gettid());
        print_running_cpu();
    }
}

int main()
{
    print_running_cpu();

    pthread_t thr_id_1, thr_id_2;

    pthread_create(&thr_id_1, NULL, thread_func, NULL);
    pthread_create(&thr_id_2, NULL, thread_func, NULL);
    
    while(1)
        sleep(1);

    return 0;
}

这段代码跟前面用的测试代码比较类似,只是把一些命令以及接口从进程换成了线程,运行效果如下:

可以看到如下信息:

  • 主线程id为30827(以下简称27)
  • 启动的两个线程分别为30834(以下简称34)和30833(以下简称33)
  • 线程34和线程33在运行期间都进行多次核心切换

以下命令可以用来查看进程以及它启动的线程(SPID列为线程id):

taskset命令依然适用于线程,执行如下命令查看CPU亲和性设置:

可以看到进程(37)以及他的两个线程都是可以在所有CPU上运行的。

执行如下命令设置线程的CPU亲和性(线程33绑定到1号CPU,线程34绑定到2号CPU):

设定后,线程不再切换:

htop上也能看出效果:

美中不足的是,这次我们不能通过taskset+程序名直接启动程序并实现各个线程绑定不同核心了,该命令只能支持到进程级别,也就是说它会把进程下的所有线程都设置为相同的亲和度,如下图:

不过也不是没有解决办法,我们可以编写脚本启动并获取线程id,再多次调用taskset,指定线程id,将其绑定到不同核心上即可。

6.编程绑定(C接口)

我们使用sched_xxxaffinity接口实现通过编程绑定CPU核心,该接口既能绑定进程又能绑定线程,示例代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>

// g++ -o bind_3 bind_3.c -lpthread

void print_running_cpu()
{
    char qry_cmd[1024] = { 0 };
    sprintf(qry_cmd, "ps -o pid,spid,psr -T -p %d | grep %d | tail -n 1 | awk {'print $3'}", 
        getpid(), gettid());
    FILE *fp=popen(qry_cmd,"r");
    if(fp == NULL)
        return ;

    char cpu_id_str[200] = { 0 };
    fgets(cpu_id_str,80,fp);
    fclose(fp);

    printf("[%d] : current thread(%d@%d) is running on cpu(%d)\n", 
        gettid(), gettid(), getpid(), atoi(cpu_id_str));
}

void print_thread_affinity()
{
    cpu_set_t cpu_mask;

    CPU_ZERO(&cpu_mask);
    sched_getaffinity(gettid(), sizeof(cpu_mask), &cpu_mask);

    printf("[%d] : current thread(%d@%d) can be running at cpu(",
        gettid(), gettid(), getpid());

    int cpu_num = sysconf(_SC_NPROCESSORS_CONF);
    for(int i = 0; i < cpu_num; ++i)
    {
        if (CPU_ISSET(i, &cpu_mask))//判断线程与哪个CPU有亲和力
        {
            printf("%d, ", i);
        }
    }
    printf(")\n");
}

void bind_thread_to_cpu(int cpu_id)
{
    cpu_set_t cpu_mask;
    CPU_ZERO(&cpu_mask);

    CPU_SET(cpu_id, &cpu_mask);
    print_thread_affinity();
    printf("[%d] : binding current thread(%d@%d) to cpu(%d)\n",
        gettid(), gettid(), getpid(), cpu_id);
    sched_setaffinity(gettid(), sizeof(cpu_mask), &cpu_mask);
    print_thread_affinity();
}

void* thread_func(void* p_arg)
{
    printf("[%d] : ----------------\n", gettid());
    printf("[%d] : setting cpu affinity for thread(%d@%d) ...\n",
        gettid(), gettid(), getpid());
    int bind_cpu_id = *(int *)p_arg;
    bind_thread_to_cpu(bind_cpu_id);
    printf("[%d] : ----------------\n\n", gettid());

    sleep(1);

    print_running_cpu();
    
    while(1)
    {
        long loop = 4000000000;
        while(loop--)
            ;
        
        sleep(0);

        printf("[%d] : ----------------\n", gettid());
        printf("[%d] : switched !\n", gettid());
        print_running_cpu();
    }
}

int main()
{
    int cpu_id_0 = 0;
    int cpu_id_1 = 1;
    int cpu_id_2 = 2;
    int cpu_id_3 = 3;

    pthread_t thr_id_1, thr_id_2;

    pthread_create(&thr_id_1, NULL, thread_func, &cpu_id_1);

    sleep(1);

    pthread_create(&thr_id_2, NULL, thread_func, &cpu_id_3);
    
    while(1)
        sleep(1);

    return 0;
}

运行效果如下:

结果不言而喻,这里仅简单解释下程序:

  • sched_xxxaffinity接口虽然明确说明只能传pid,但传入spid(线程id)也是可以的,线程id使用gettid()接口获得
  • CPU_SET等接口传入的cpu_id并不是掩码,而是0、1、2、3等这样的数字
  • sched_xxxaffinity接口的第一个参数可以填成0, 表示当前进程(线程)

7.编程绑定(pthread接口)

pthread也提供了类似的接口,示例程序如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

// g++ -o bind_4 bind_4.c -lpthread

void print_running_cpu()
{
    char qry_cmd[1024] = { 0 };
    sprintf(qry_cmd, "ps -o pid,spid,psr -T -p %d | grep %d | tail -n 1 | awk {'print $3'}", 
        getpid(), gettid());
    FILE *fp=popen(qry_cmd,"r");
    if(fp == NULL)
        return ;

    char cpu_id_str[200] = { 0 };
    fgets(cpu_id_str,80,fp);
    fclose(fp);

    printf("[%d] : current thread(%d@%d) is running on cpu(%d)\n", 
        gettid(), gettid(), getpid(), atoi(cpu_id_str));
}

void print_thread_affinity()
{
    cpu_set_t cpu_mask;

    CPU_ZERO(&cpu_mask);
    pthread_getaffinity_np(pthread_self(), sizeof(cpu_mask), &cpu_mask);

    printf("[%d] : current thread(%d@%d) can be running at cpu(",
        gettid(), gettid(), getpid());

    int cpu_num = sysconf(_SC_NPROCESSORS_CONF);
    for(int i = 0; i < cpu_num; ++i)
    {
        if (CPU_ISSET(i, &cpu_mask))//判断线程与哪个CPU有亲和力
        {
            printf("%d, ", i);
        }
    }
    printf(")\n");
}

void bind_thread_to_cpu(int cpu_id)
{
    cpu_set_t cpu_mask;
    CPU_ZERO(&cpu_mask);

    CPU_SET(cpu_id, &cpu_mask);
    print_thread_affinity();
    printf("[%d] : binding current thread(%d@%d) to cpu(%d)\n",
        gettid(), gettid(), getpid(), cpu_id);
    pthread_setaffinity_np(pthread_self(), sizeof(cpu_mask), &cpu_mask);
    print_thread_affinity();
}

void* thread_func(void* p_arg)
{
    printf("[%d] : ----------------\n", gettid());
    printf("[%d] : setting cpu affinity for thread(%d@%d) ...\n",
        gettid(), gettid(), getpid());
    int bind_cpu_id = *(int *)p_arg;
    bind_thread_to_cpu(bind_cpu_id);
    printf("[%d] : ----------------\n\n", gettid());

    sleep(1);

    print_running_cpu();
    
    while(1)
    {
        long loop = 4000000000;
        while(loop--)
            ;
        
        sleep(0);

        printf("[%d] : ----------------\n", gettid());
        printf("[%d] : switched !\n", gettid());
        print_running_cpu();
    }
}

int main()
{
    int cpu_id_0 = 0;
    int cpu_id_1 = 1;
    int cpu_id_2 = 2;
    int cpu_id_3 = 3;

    pthread_t thr_id_1, thr_id_2;

    pthread_create(&thr_id_1, NULL, thread_func, &cpu_id_1);

    sleep(1);

    pthread_create(&thr_id_2, NULL, thread_func, &cpu_id_3);
    
    while(1)
        sleep(1);

    return 0;
}

pthread提供的接口跟sched_xxxaffinity接口极其类似,主要区别在于接口的第一个参数不同:

  • 一个是通过pthread_self()获得,一个是通过gettid()获得
  • 两者的值并不相同
  • pthread_self()的值只在程序内可见,在程序外面无法通过命令查询,而gettid()的可以

发表回复