CPUアフィニティを0-7にしたのに2-3でしか動かない: Linuxのcpusetがマスクを絞る仕組み

sched_setaffinityに渡したCPUマスクは、cpuset/cgroupが許可するCPUや実在するCPUと黙って積集合を取られます。実際に許可されたCPUの確認方法とEINVALの切り分けを、manページをもとに整理します。

linuxcpu-affinitycgroupscheduler

sched_setaffinity()にCPU 0〜7を渡し、戻り値は0でした。ところがそのスレッドの/proc/<TID>/statusを見るとCpus_allowed_listは2-3です。呼び出しは失敗しておらず、カーネルのバグでもありません。要求したマスクはそのまま適用されるわけではなく、カーネルは許可範囲と重なる部分だけを残します。しかもそのことを知らせてはくれません。

この記事では、その積集合がどこで生まれるのか、実際に許可されたCPUをどう確かめるのか、積集合が空になってEINVALが返ったときに何から見ればよいのかを、sched_setaffinity(2)、cpuset(7)、proc_pid_status(5)、cgroup v2のドキュメントをもとにまとめます。

要求したマスクと実際に動けるCPUは別物

sched_setaffinity(2)のNOTESには次のように書かれています。呼び出し後にスレッドが実際に動くCPUの集合は、mask引数で指定した集合と、システムに実在するCPUの集合の積集合になる。cpusetの仕組みが使われていれば、システムはそれをさらに絞ることがある。そしてこれらの制限はカーネルが黙って(silently)課す。

cpuset(7)は同じことを反対側から説明しています。cpusetはsched_setaffinity(2)と統合されており、アフィニティでプロセスのcpusetが許可していないCPUを使うことはできません。両者がぶつかればcpusetの配置が優先され、カーネルは要求されたCPUをそのcpusetが許すCPUに黙って制限します。

まとめると、層は三つです。

層 中身 確認する場所
要求 sched_setaffinity()に渡したmask 呼び出し側のコード
許可 そのスレッドが属するcpusetが許可するCPU cgroup v2: cpuset.cpus.effective / v1: cpusetのcpuset.cpus
実在 システムに実際にあるCPU /sys/devices/system/cpu/、sysconf(_SC_NPROCESSORS_ONLN)など

スレッドが動けるのは、この三つがすべて重なる部分だけです。冒頭の例でcpusetが2-3しか許可していなければ、0-7を要求しても残るのは2-3です。

成功するとき、EINVALになるとき

積集合が一部でも残れば呼び出しは成功します。エラーになるのは積集合が完全に空になったときです。sched_setaffinity(2)のERRORSはEINVALを「マスクに、現在システムに物理的に存在し、かつcpuset cgroupやcpuset(7)の仕組みによってそのスレッドに許可されたプロセッサが一つも含まれていない場合」と説明しています。cpuset(7)も、cpusetの範囲に絞った結果が空集合になると呼び出しがエラーを返すことがあると書いています。

先ほどと同じcpuset(2-3を許可)なら、次のようになります。これはドキュメントの規則を当てはめた説明用の例で、実測した出力ではありません。

要求 結果
0-7 成功。実際には2-3でのみ実行
3 成功。3でのみ実行
0-1 EINVAL

sched_setaffinity()が返すほかのエラーは原因が違うので、分けて考えます。

  • EPERM: 権限不足。呼び出し側の実効UIDが対象スレッドの実UIDまたは実効UIDと等しいか、対象スレッドのユーザー名前空間でCAP_SYS_NICEを持っている必要があります。
  • ESRCH: pidに該当するスレッドがない。
  • EFAULT: 不正なメモリアドレス。

EINVALにはもう一つ原因があります。sched_getaffinity()でcpusetsizeがカーネルの使うアフィニティマスクより小さい場合です(Linux 2.6.9より前はsched_setaffinity()も同様)。glibcのcpu_set_tは128バイト固定で、表せるCPU番号は1023までです。カーネルのマスクがそれより大きいと、sizeof(cpu_set_t)を渡したsched_getaffinity()はEINVALで失敗します。manページは、CPU_ALLOC(3)でマスクを動的に確保し、失敗しなくなるまでサイズを増やして探るよう案内しています。これはcpusetとは無関係なので、EINVALを見たらまずどの呼び出しが返したのかを確認してください。

アフィニティはスレッド単位

アフィニティマスクはプロセスではなくスレッドの属性です。sched_setaffinity(2)によれば、スレッドグループ内の各スレッドが個別に持てます。pidにgettid(2)の値を渡せばそのスレッド、0なら呼び出したスレッド、getpid(2)の値ならスレッドグループのメインスレッドが対象です。POSIXスレッドAPIを使う場合はpthread_setaffinity_np(3)を使うよう書かれています。fork(2)で作った子は親のマスクを引き継ぎ、execve(2)をまたいでもマスクは保たれます。

cpusetも同様です。cpuset(7)のNOTESによると、名前に反してpid引数は実際にはスレッドIDで、スレッドグループの各スレッドを別々のcpusetに所属させられます。

切り分けのときにはここが効いてきます。メインスレッドだけを見て「アフィニティが効いていない」と判断すると、実際にマスクを変えたワーカースレッドを見落とすことがあります。

実際の許可範囲を確かめる

1. /proc/<TID>/status

grep Cpus_allowed /proc/<TID>/status

proc_pid_status(5)はCpus_allowedを「このプロセスが実行され得るCPUの16進マスク」、Cpus_allowed_listを同じ内容のリスト形式と説明しています。cpuset(7)もこの行を「どのCPUでスケジュールされ得るか」と説明しています。人が読むならCpus_allowed_listのほうが楽です。0-4,9はCPU 0、1、2、3、4、9という意味です。

Cpus_allowedのマスク形式は、32ビットのワードごとに16進で書き、ワードが複数ならカンマで区切り、最上位ビットが先に来ます。00000001はCPU 0のみ、000000ff,00000000はCPU 32〜39です。

proc_pid_status(5)によると、このファイルのPidフィールドはスレッドIDです。上のコマンドにプロセスIDを入れると、そのIDを持つスレッド、つまりメインスレッドを見ることになります。ワーカースレッドを調べるなら、そのスレッドのTID(そのスレッドでgettid(2)が返す値)を使ってください。

2. cgroup v2: cpuset.cpusとcpuset.cpus.effective

まず、対象スレッドがどのcgroupにいるかを確認します。ここでもTIDを使います。プロセスIDを入れるとメインスレッドのcgroupが表示され、ワーカースレッドのものとは異なることがあります。

cat /proc/<TID>/cgroup

cgroup v2のエントリは常に0::$PATHの形式です。このパスをcgroup2のマウント先の下につなげると、そのcgroupのディレクトリになります。マウント先はシステムによって違うので、以下の$MOUNT_POINTは自分の環境に読み替えてください。

cat "$MOUNT_POINT$PATH/cpuset.cpus"
cat "$MOUNT_POINT$PATH/cpuset.cpus.effective"

この二つのファイルの違いが切り分けの要です。

  • cpuset.cpusは、このcgroupが要求したCPUです。実際に与えられるCPUは親の制約を受けるため、この値と異なることがあります。空なら、値を持つ最も近い祖先cgroupの設定に従い、そのような祖先がなければ利用可能なすべてのCPUを使います。CPUのホットプラグがあってもこの値は変わりません。
  • cpuset.cpus.effectiveは、親がこのcgroupに実際に与えたオンラインのCPUで、cgroup内のタスクが使えるCPUです。ホットプラグで値が変わります。cpuset.cpusに書いたCPUが一つも与えられない場合は、cpuset.cpusが空のときと同じ扱いになります。

cpuset.cpusだけを見て「0-7を許可したのになぜ2-3なのか」と悩むのはこのためです。実際の許可範囲はcpuset.cpus.effectiveのほうにあります。cpusetコントローラは階層的で、親が許可していないCPUは使えません。値が期待と違うなら、パスをたどって親cgroupのcpuset.cpus.effectiveも順に確認してください。

コンテナの中から見る場合はもう一つ注意があります。cgroup名前空間の中のプロセスには、/proc/$PID/cgroupが名前空間のルートからの相対パスで見えます。ドキュメントの例では、ホストから見て0::/batchjobs/container_id1だったパスが、名前空間の中では0::/になります。同じプロセスでも、どこから見るかでパスが変わります。

3. レガシーのcpusetファイルシステム(cgroup v1)

cpuset(7)が説明している旧インターフェースを使うシステムでは、対象スレッドのTIDでパスを次のように確認します。

cat /proc/<TID>/cpuset

出力はcpusetファイルシステムのルートからのパスで、そのディレクトリのcpuset.cpusが許可されたCPUの一覧です。このmanページは一般的なマウント先として/dev/cpusetを挙げています。cpusetを組み込んだカーネルではすべてのプロセスがいずれかのcpusetに属していますが、設定を変えていないかファイルシステムをマウントしていなければ、動作には影響しません。

4. システムに実在するCPU

sched_setaffinity(2)は、CPUの数を調べる方法として/proc/cpuinfoの確認、sysconf(3)の_SC_NPROCESSORS_CONFと_SC_NPROCESSORS_ONLN、/sys/devices/system/cpu/以下のCPUディレクトリの一覧を挙げています。システムに存在しないCPU番号は、cpusetの設定に関係なく積集合から外れます。

自分で確かめる小さなプログラム

0-7を要求したあと、自分自身のCpus_allowed_listを出力します。スレッドが一つだけのプログラムなので、pidの0(呼び出したスレッド)と/proc/self/status(メインスレッド)は同じスレッドを指します。

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

int main(void)
{
    cpu_set_t want;

    CPU_ZERO(&want);
    for (int cpu = 0; cpu < 8; cpu++)
        CPU_SET(cpu, &want);

    if (sched_setaffinity(0, sizeof(want), &want) == -1) {
        /* EINVALなら、0-7のうち実在しかつ許可されたCPUが一つもない */
        fprintf(stderr, "sched_setaffinity: %s\n", strerror(errno));
        return 1;
    }

    FILE *f = fopen("/proc/self/status", "r");
    if (f == NULL) {
        perror("fopen");
        return 1;
    }

    char line[4096];
    while (fgets(line, sizeof(line), f) != NULL)
        if (strncmp(line, "Cpus_allowed_list:", 18) == 0)
            fputs(line, stdout);

    fclose(f);
    return 0;
}

出力されたリストが0-7より狭ければ、その差がcpusetやCPUの実在の有無によって削られた分です。同じプログラムでも、別のcgroupで動かせば結果が変わることがあります。

切り分けの順番

  1. どの呼び出しが失敗したか。 sched_getaffinity()のEINVALなら、cpusetよりも先にマスクのサイズ(cpusetsize)を疑います。
  2. 要求したCPU番号は実在するか。 /sys/devices/system/cpu/などで確認します。
  3. そのスレッドのcgroupはどこか。 プロセスIDではなく対象スレッドのTIDで確認します。/proc/<TID>/cgroup(v1なら/proc/<TID>/cpuset)。
  4. そのcgroupのcpuset.cpus.effectiveは何か。 要求と一つも重ならなければEINVAL、一部だけ重なるなら黙って削られています。
  5. 実際の結果はCpus_allowed_listで確認。 スレッドごとに違い得ることを忘れずに。

ホットプラグとisolcpus

cpuset(7)のEXCEPTIONSには、逆向きの例外もあります。ホットプラグであるcpusetに割り当てられたCPUがすべて取り除かれると、カーネルは該当するプロセスのcpus_allowedをすべてのCPUを許可するよう更新します。許可されたCPUがすべてオフラインになったプロセスを飢えさせるより、cpusetの配置を破るほうを選ぶ、という説明です。ホットプラグでCPUを追加・削除するときは、cpusetがオンラインのCPUだけを指すようユーザーコード側で設定し直すよう勧めています。cpuset(7)はレガシーのcpusetインターフェースの文書なので、この例外をそのままcgroup v2に当てはめないでください。v2のドキュメントは、cpuset.cpus.effectiveがホットプラグの影響を受けるとだけ書いています。

ブートオプションisolcpusで隔離したCPUにはプロセスがスケジュールされず、sched_setaffinity(2)によれば、そこにプロセスを載せる方法はsched_setaffinity()かcpusetの仕組みだけです。cgroup v2のドキュメントによれば、isolcpusで隔離したCPUをパーティションに入れるなら、isolatedパーティションでなければなりません。

性能の話は測ってから

sched_setaffinity(2)は、アフィニティで性能上の利点を得られることがあると書き、CPUを移るときのキャッシュ無効化のコストを避けられる点を例に挙げています。同じページのサンプルも、二つのプロセスを同じCPU、同じコアの別CPU、別コアに置いたとき、実行時間がキャッシュの効果と配置によって変わることを示しています。ただし、それで自分のワークロードが速くなる保証にはなりません。この記事でも性能の数値は示しません。

本番サービスのアフィニティやcpusetを変える前に、まず今実際に許可されている範囲を確認し、変えたあとは同じ条件で何が変わったかを測ってください。cpusetを広げたり狭めたりすれば、同じcgroupのほかのプロセスにも影響します。

確認した範囲と限界

  • この記事は、Linux man-pagesプロジェクトのgitリポジトリにあるmanページのソース(未リリース版)と、docs.kernel.orgのcgroup v2ドキュメント(7.3.0-rc7ビルド)を2026-10-18に確認した内容に基づいています。ディストリビューションに入っているmanページやカーネルのバージョンによって内容が異なることがあります。
  • ソースにバージョンが書かれている項目: Cpus_allowedはLinux 2.6.24から、Cpus_allowed_listは2.6.26から、cpusetは2.6.12から、sched_setaffinityはLinux 2.5.8とglibc 2.3からです。
  • cpuset(7)はレガシーの/dev/cpusetインターフェースを説明しています。cgroup v2のファイル名と挙動はcgroup v2のドキュメントに従いました。
  • tasksetなどのコマンドラインツールの挙動は、今回確認した資料に含まれていないため扱っていません。

参考資料

2026-10-18に確認しました。

ブログへ