一文讀懂 | 進程怎么綁定 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 系統調用各個參數的作用:

  • pid:進程ID,也就是要進行綁定 CPU 的進程ID。
  • cpusetsize:mask 參數所指向的 CPU 集合的大小。
  • mask:與進程進行綁定的 CPU 集合(由于一個進程可以綁定到多個 CPU 上運行)。

參數 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(0sizeof(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 函數各個參數的意義:

  • p:要設置 CPU 親和性的進程描述符。
  • dest_cpu:綁定的 CPU 編號。
  • req:進程遷移請求對象(下面會介紹)。

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

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

  • 情況1:如果進程還沒有在任何 CPU 的可運行隊列中(不可運行狀態),那么只需要將進程描述符的 cpu 字段設置為 dest_cpu 即可。當進程變為可運行時,會根據進程描述符的 cpu 字段來自動放置到對應的 CPU 可運行隊列中。
  • 情況2:如果進程已經在某個 CPU 的可運行隊列中,那么需要將進程從之前的 CPU 可運行隊列中遷移到新的 CPU 可運行隊列中。遷移過程由 migration_thread 內核線程完成,migrate_task 函數只是構建一個進程遷移請求,并通知 migration_thread 內核線程有新的遷移請求需要處理。

而進程遷移過程由 __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 間的可運行隊列進行重平衡操作,有興趣的可以自行閱讀源碼或參考相關資料。