一文讀懂進程怎麼綁定 CPU

昨天在羣裏有朋友問:把進程綁定到某個 CPU 上運行是怎麼實現的。

首先,我們先來了解下將進程與 CPU 進行綁定的好處。

進程綁定 CPU 的好處:在多核 CPU 結構中,每個核心有各自的 L1、L2 緩存,而 L3 緩存是共用的。如果一個進程在覈心間來回切換,各個核心的緩存命中率就會受到影響。相反如果進程不管如何調度,都始終可以在一個核心上執行,那麼其數據的 L1、L2 緩存的命中率可以顯著提高。

所以,將進程與 CPU 進行綁定可以提高 CPU 緩存的命中率,從而提高性能。而進程與 CPU 綁定被稱爲:CPU 親和性

設置進程的 CPU 親和性

前面介紹了進程與 CPU 綁定的好處後,現在來介紹一下在 Linux 系統下怎麼將進程與 CPU 進行綁定的(也就是設置進程的 CPU 親和性)。

Linux 系統提供了一個名爲 sched_setaffinity 的系統調用,此係統調用可以設置進程的 CPU 親和性。我們來看看 sched_setaffinity 系統調用的原型:

int sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask);

下面介紹一下 sched_setaffinity 系統調用各個參數的作用:

參數 mask 的類型爲 cpu_set_t,而 cpu_set_t 是一個位圖,位圖的每個位表示一個 CPU,如下圖所示:

例如,將 cpu_set_t 的第 0 位設置爲 1,表示將進程綁定到 CPU0 上運行,當然我們可以將進程綁定到多個 CPU 上運行。

我們通過一個例子來介紹怎麼通過 sched_setaffinity 系統調用來設置進程的 CPU 親和性:

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

int main(int argc, char **argv)
{
    cpu_set_t cpuset;

    CPU_ZERO(&cpuset);    // 初始化CPU集合,將 cpuset 置爲空
    CPU_SET(2, &cpuset);  // 將本進程綁定到 CPU2 上

    // 設置進程的 CPU 親和性
    if (sched_setaffinity(0, sizeof(cpuset)&cpuset) == -1) {
        printf("Set CPU affinity failed, error: %s\n", strerror(errno));
        return -1; 
    }

    return 0;
}

CPU 親和性實現

知道怎麼設置進程的 CPU 親和性後,現在我們來分析一下 Linux 內核是怎樣實現 CPU 親和性功能的。

本文使用的 Linux 內核版本爲 2.6.23

Linux 內核爲每個 CPU 定義了一個類型爲 struct rq 的 可運行的進程隊列,也就是說,每個 CPU 都擁有一個獨立的可運行進程隊列。

一般來說,CPU 只會從屬於自己的可運行進程隊列中選擇一個進程來運行。也就是說,CPU0 只會從屬於 CPU0 的可運行隊列中選擇一個進程來運行,而絕不會從 CPU1 的可運行隊列中獲取。

所以,從上面的信息中可以分析出,要將進程綁定到某個 CPU 上運行,只需要將進程放置到其所屬的 可運行進程隊列 中即可。

下面我們來分析一下 sched_setaffinity 系統調用的實現,sched_setaffinity 系統調用的調用鏈如下:

sys_sched_setaffinity()
└→ sched_setaffinity()
   └→ set_cpus_allowed()
      └→ migrate_task()

從上面的調用鏈可以看出,sched_setaffinity 系統調用最終會調用 migrate_task 函數來完成進程與 CPU 進行綁定的工作,我們來分析一下 migrate_task 函數的實現:

static int
migrate_task(struct task_struct *p, int dest_cpu, struct migration_req *req)
{
    struct rq *rq = task_rq(p);

    // 情況1:
    // 如果進程還沒有在任何運行隊列中
    // 那麼只需要將進程的 cpu 字段設置爲 dest_cpu 即可
    if (!p->se.on_rq && !task_running(rq, p)) {
        set_task_cpu(p, dest_cpu);
        return 0;
    }

    // 情況2:
    // 如果進程已經在某一個 CPU 的可運行隊列中
    // 那麼需要將進程從之前的 CPU 可運行隊列中遷移到新的 CPU 可運行隊列中
    // 這個遷移過程由 migration_thread 內核線程完成

    // 構建進程遷移請求
    init_completion(&req->done);
    req->task = p;
    req->dest_cpu = dest_cpu;
    list_add(&req->list, &rq->migration_queue);

    return 1;
}

我們先來介紹一下 migrate_task 函數各個參數的意義:

所以,migrate_task 函數的作用就是將進程描述符爲 p 的進程綁定到編號爲 dest_cpu 的目標 CPU 上。

migrate_task 函數主要分兩種情況來將進程綁定到某個 CPU 上:

而進程遷移過程由 __migrate_task 函數完成,我們來看看 __migrate_task 函數的實現:

static int 
__migrate_task(struct task_struct *p, int src_cpu, int dest_cpu)
{
    struct rq *rq_dest, *rq_src;
    int ret = 0, on_rq;
    ...
    rq_src = cpu_rq(src_cpu);    // 進程所在的原可運行隊列
    rq_dest = cpu_rq(dest_cpu);  // 進程希望放置的目標可運行隊列
    ...
    on_rq = p->se.on_rq;  // 進程是否在可運行隊列中(可運行狀態)
    if (on_rq)
        deactivate_task(rq_src, p, 0);  // 把進程從原來的可運行隊列中刪除

    set_task_cpu(p, dest_cpu);

    if (on_rq) {
        activate_task(rq_dest, p, 0);   // 把進程放置到目標可運行隊列中
        ...
    }
    ...
    return ret;
}

__migrate_task 函數主要完成以下兩個工作:

其工作過程如下圖所示(將進程從 CPU0 的可運行隊列遷移到 CPU3 的可運行隊列中):

如上圖所示,進程原本在 CPU0 的可運行隊列中,但由於重新將進程綁定到 CPU3,所以需要將進程從 CPU0 的可運行隊列遷移到 CPU3 的可運行中。

遷移過程首先將進程從 CPU0 的可運行隊列中刪除,然後再將進程插入到 CPU3 的可運行隊列中。

當 CPU 要運行進程時,首先從它所屬的可運行隊列中挑選一個進程,並將此進程調度到 CPU 中運行。

總結

從上面的分析可知,其實將進程綁定到某個 CPU 只是將進程放置到 CPU 的可運行隊列中。

由於每個 CPU 都有一個可運行隊列,所以就有可能會出現 CPU 間可運行隊列負載不均衡問題。如 CPU0 可運行隊列中的進程比 CPU1 可運行隊列多非常多,從而導致 CPU0 的負載非常高,而 CPU1 負載非常低的情況。

當出現上述情況時,就需要對 CPU 間的可運行隊列進行重平衡操作,有興趣的可以自行閱讀源碼或參考相關資料。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/m6QdRpNpJSwuowJg0SJ74Q