プロセスとスレッド 基礎

我々の curl は プロセス として走る —— ページテーブル、ディスクリプタ表、数 KB のカーネル簿記、そして中の スレッド。5 セクションで示すのは、fork がコピーするのは索引でデータではないこと、スレッドは同じ syscall にフラグを足しただけであることだ。

01

プロセスとは何か、そして fork が実際にコピーするもの

走っているプログラムはディスク上のファイルではない。ページテーブルとディスクリプタ表と、スケジューラが名前で呼べる数 KB のカーネル簿記だ。

シェルが curl を走らせると fork()、続けて execve() を呼び、カーネルは task_struct、アドレス空間の mm_struct、ディスクリプタ表、PID を確保する。そのうち最大で、圧倒的に空っぽなものはスタックの上から始まる —— 128 TB のユーザ半分に沿ってドラッグし、ズームを引いて、マッピングがその中に消えるまで戻してみてほしい:

0x7ffd_9c1b_0800. 横にドラッグして窓を動かす。矢印キーで 1 段ずつ、Home でスタックに戻る。
0x7ffd_9c1b_0800 —— [stack]

そこで見つかるものがどれだけ少ないかに注目してほしい。9 つのマッピング —— text、rodata、data、ヒープ、共有ライブラリ 3 つ、スタック、vDSO —— 合わせて 5.5 MB、それが座っている空間の 100 万分の 4 パーセントだ。アドレス空間はメモリではない。47 ビットの名前空間に針が数本刺さっているだけで、ページテーブルがその針の在処を言う索引だ。

fork() がコピーするのはまさにその索引だ。データはコピーしない —— 親と子は同じ物理フレームを指し続ける。親の常駐サイズをドラッグして、データが動かないままページテーブルだけが育つのを見てほしい:

ページテーブル 2 MB、fork ≈ 1.1 ms

親が 1 GB のとき、カーネルは 262,144 個の葉エントリを歩いて 2 MB の新しいページテーブルを作り、データは 1 バイトもコピーしない。 1.1 ms はそこから来ていて、fork がタダではなく RSS の 1 次関数である理由でもある: では同じ歩行が 128 MB のテーブルと 67 ms になる —— Redis のバックグラウンド保存が隠さなければならない停止だ。

この取引は両側とも条件付きだ。共有フレームはすべて読み取り専用でマップされるので、子の最初の書き込みはカーネルにトラップする。親の 1 GB に対する書き込み量をドラッグして、コピーが積み上がるのを見てほしい:

0 B コピー、フォールト時間 0 ns

マスではなく請求書のほうを見てほしい。フォールト 1 回が 1.2 µs —— 越境 2 回、フレーム確保 1 回、4 KB コピー 1 回 —— でページは 262,144 枚あるので、 書く子は 1.1 ms の fork に加えて 325 ms をフォールトに払う。コピーオンライトは安いのではない。先送りなのであって、全部に触る子は請求書を全額受け取る。

ディスクリプタ表もコピーされる —— ただし表だけだ。その下にあるオープンファイル記述、つまりオフセットが住んでいる場所は共有される。親と子で交互に書いてから、別々の open() 2 回に切り替えてみてほしい:

0 回書き込み、f_pos = 0 —— 1 本のストリーム、1 バイトも失われない

両方のディスクリプタが同じオープンファイル記述を指しているので、f_pos は書き込みごとに 1 回進み、4 バイトのレコード 12 個が端から端まで並ぶ。プロセスごとに自前の open() を与えると両方ともオフセット 0 から始まり、12 回の書き込みのうち 11 回が上書きされ、ファイルは 4 バイトになる。

続いて execve() がアドレス空間を捨て、task を残す。 1 歩ずつ進めてから O_CLOEXEC を入れて、そのディスクリプタが一緒に行くかどうかを決めてみてほしい:

execve の前

新しいイメージがマップされた瞬間、4 つのマッピングは消え、4 つの task フィールドはそのまま残る —— 同じ PID、同じ cwd、同じ credentials、同じ fd 3。最後のものは漏洩だ:O_CLOEXEC がなければ、たまたま握っていたディスクリプタは exec した先のプログラムに手渡される。サーバのリッスンソケットが自分で spawn した子の中に現れるのはこれだ —— フラグ 1 つの違いで、コンパイラには見分けがつかない:

int fd = open(p, O_RDWR);             /* 漏れる */
int fd = open(p, O_RDWR | O_CLOEXEC); /* 漏れない */

どちらもコンパイルもテストも通る。同じ形は 1 階層上にもある ——C ライブラリ自身のバッファだ。printf はプロセスメモリに書くので fork がそれも複製する。何行か印字してから fork してみてほしい:

0 行出力 —— 重複なし

両方のプロセスが終了時に同じバイトをフラッシュするので、fork 前の printf 4 回はパイプに 8 行出る。先に fflush(stdout) すれば空のバッファが残り、複製するものがない。man ページは何も言わず、警告も出ない。

02

スレッドは同じ syscall にフラグを足しただけ

スレッドはスケジューラが CPU を渡す相手だ。Linux ではプロセスと同じ呼び出しで作られ、共有ビットが多く立っているだけだ。

pthread_create はスレッド用の syscall を呼ばない。呼ぶのは clone() ——fork() が呼ぶのと同じもの —— でフラグワードが違うだけだ。人が「プロセス」「スレッド」と呼んでいるものは 1 本のダイヤルの両端であって、どこで止めるかについてカーネルに意見はない。

そのダイヤルには 5 つのくぼみがあり、馴染みのある名前は両端にしか現れない。共有フラグを 1 つずつ足して、子がリソースごとに自前のコピーを持つのをやめていくのを見てほしい:

fork() —— 新しいプロセス

フラグを 1 つも立てなければ fork() になる ——私的なコピーが 5 つ。 立てればスレッドになる —— アドレス空間 1 つ、作業ディレクトリ 1 つ、ディスクリプタ表 1 つ、シグナル処理 1 つ、スレッドグループ 1 つ。あいだはすべて合法で、有用なものもある:CLONE_VM なしの CLONE_FILES は、自前のメモリを持つ子とディスクリプタを共有する。

そのスレッドの値段は 2 つの別々の数字で、本当にあるのは片方だけだ。スレッド数を動かして、予約されたアドレス空間と常駐しているメモリが離れていくのを見てほしい:

8 MB 予約、8 KB 常駐

1,024 スレッドの時点で、プロセスは8 GB を予約し、実際に使っているのは 8 MB だという点に注目したい。 8 MB のスタックは ulimit -s、つまり予約であり、起動直後のスレッドが触るのは 2 ページだ。本当にあるコストはスレッドあたり 25 KB のカーネルメモリ —— 16 KB のカーネルスタックと 9 KB の task_struct —— で、そちらは RSS には現れない。

だから上限を決めるのはあなたのメモリではなくカーネルのメモリで、しかも普通はまったく無関係な第 2 の上限が先に来る。マシンの RAM をドラッグして、2 つの上限が入れ替わるのを見てほしい:

threads-max 131,072、pid_max 32,768

threads-max は総 RAM ÷ 32 ページなので、16 GB のマシンは 131,072 スレッドを許す。しかし pid_max の既定は 32,768 で、スレッドは 1 つずつ消費するので、32,768 が本当の答えだ —— 16 GB のマシンでも 1 TB のマシンでも、誰かが sysctl を上げるまで同じだ。 ではメモリ側の上限が先に効き、16,384 で止まる。

アドレス空間を共有することには言語レベルの帰結がある:グローバル変数は、何スレッドが読もうと1 つのセルだ。スレッドを選んでから、宣言を thread_local に切り替えてほしい:

1 つのセルに 4 人の書き手

static int n では 4 スレッドとも同じワードをインクリメントするので 4 と読め、 1 回ごとが競合になる。thread_local と宣言すると、コンパイラは絶対アドレスではなく %fs:-0x8 を出し、各スレッドは自分の 1 を読む。現場のスレッドごとアロケータキャッシュも CPU ごとカウンタも、全部この技だ。

並行タスク 1 つにカーネルスレッド 1 本という方式は、メモリが尽きるはるか手前で成り立たなくなる。タスク数をドラッグして、ランタイム自身のタスクと1 タスク 1 スレッドの値段を比べてほしい:

タスク 4 MB、スレッド 33 MB

では、goroutine は 1 つ 4 KB で 4 GB、スレッドは 1 つ 33 KB で 33 GB —— しかしスレッドの棒は 32,768 でローズに変わる。メモリよりずっと手前で pid_max が止めたからだ。M:N の論拠はそこにある:スレッドが遅いのではなく、スレッドには硬い個数があるということだ。

その請求は最初のブロッキング syscall で来る。カーネルに入ったタスクは、直前まで走っていた OS スレッドを握ったままだからだ。ブロック中のタスクの割合を上げてから、ハンドオフを入れてほしい:

8 コア中 8 コアが使える

ハンドオフなしで 100% まで振り切ると、8 本の OS スレッドが read() に座ったまま、ランタイムは使えるコアがゼロになる。Go の sysmon は 20 µs 後に P を取り戻して代わりのスレッドを起こす。 Java の仮想スレッドはブロック地点でアンマウントする。どちらもランタイムから見えないブロックでは同じように壊れる —— マップされたファイル上のページフォールト、CGO 呼び出し、dlopen。

タスクのスタックも「ある点までは安い」ものだ。Go のスタックは 2 KB から始まり、フレームが入らなくなると倍になり、生きているフレームを全部コピーする。呼び出しの深さを上げてみてほしい:

スタック 2 KB、0 B コピー

1,024 フレームの深さでスタックは 5 回倍化して 64 KB になり、62 KB をコピーしている —— 最初から 8 MB 予約するpthreadが決して払わないコストだ。「goroutine は安い」はメモリとスケジューリングの主張であって、syscall や深い再帰の主張ではない。

03

特権境界と、それを越える値段

CPU が強制する 2 つの特権レベル。どちらにいるかが、アドレスの意味と、命令に許されることを決める。

x86-64 には ring が 4 つあり、使うのは 2 つだ —— アプリケーションは ring 3、カーネルは ring 0。ARM64 では EL0 と EL1 と呼ぶ。カーネル側の半分もあなたと同じページテーブルで記述されており、 ring 3 の読み取りを止めているのはカーネルではなくハードウェアだ。

つまり境界はアドレスの属性であって、別のメモリのことではない。アドレスを 64 ビット全体にわたってドラッグし、2 つの判定が食い違うのを見てほしい:

0x5745_d174_5d17. 横にドラッグしてアドレスを動かす。矢印キーで 1 段ずつ、Home でヒープに戻る。
0x5745_d174_5d17 —— 許可

空間の真ん中はそもそもメモリではない、という点に注目したい。正準なのは下位 128 TB と上位 128 TB だけで —— ビット 48〜63 はビット 47 の複製でなければならない —— そのあいだのアドレスは、ページテーブルが引かれる前にアドレスの形そのもので例外になる。カーネル半分はマップされているのに ring 3 は読めない:それはページテーブルエントリの U/S ビットで、 MMU がアクセスごとに検査している。

正規の手順で ring 0 に入るには 8 ステップかかり、最初のステップは CPU がアトミックに済ませるので、途中の半端な状態は観測できない。syscall を 1 回ぶん歩いてみてほしい:

ユーザコード、ring 3

ページテーブルがどこで変わるかを見てほしい。ステップ 5 が KPTI だ。 Meltdown 以降、ユーザ側のページテーブルにはカーネルが入っていないので、入るには CR3 を書き、出るにも書き戻す。 8 ステップのうち 2 つは 2018 年のハードウェアバグのためだけに存在し、そしてその 2 つがいちばん高い。

どれだけ高いかは、あなたのカーネルが何を有効にしているかで決まる。緩和策の構成を切り替え、syscall のレートを上げて、コアの棒が動くところまで持っていってほしい:

越境 1 回 105 ns、コア 0.01 本

素の越境は命令 55 ns 分だ。KPTI + PCID は CR3 の 2 回の書き込みに 50 ns を足す。 PCID なしの KPTI は 190 ns を足す —— 書き込みのたびに TLB 全体が流れるからだ。毎秒 10 万 syscall —— ごく普通の忙しいサーバ —— なら PCID ありでコアの 1.1%、なしで 2.5%。 では PCID ありで 11%、なしで 25% だ。

いちばん安い越境は、起きない越境だ。clock_gettime はカーネルが全プロセスにマップするページを読むので、ring 3 から出ない。呼び出しレートを上げてみてほしい:

vDSO 2.5%、syscall 34%

毎秒 100 万回 —— ホットループでリクエストごとにタイムスタンプを取るのがまさにこれだ —— では、vDSO がコアの 2.5%、syscall 経路が 34% を食う。14 倍だ。仕掛けは何もない:カーネルが共有ページに時計を書き、 vDSO がユーザ空間で算術をするだけだ。io_uring は同じ発想を I/O に適用したものだ。

フォールトもこの境界を越えるし、値段はどれも同じではない。ポインタを 16 ページ分ドラッグして、各アクセスが実際にいくらかかるか読んでほしい:

TLB ヒット。横にドラッグしてアドレスを動かす。矢印キーで 1 段ずつ、Home で常駐ページに戻る。
TLB ヒット、1.0 ns

幅は 5 桁ある。TLB ヒットが 1 ナノ秒、マイナーフォールトが 900 ns、メジャーフォールトはページをデバイスから取ってくるので 100 µs、SIGSEGV は 2.2 µs でその後プロセスは消える。一覧でいちばん安いものが、唯一プログラムを終わらせるものだ —— そしてそれが良いケースでもある。高いほうは黙って走り続けるからだ。

だからスループットを求めるサーバは syscall を速くしない —— 数を減らす。仕事量を毎秒 100 万操作に固定したまま、数個ずつまとめて投入してみてほしい:

毎秒 1.0M 越境、コア 0.105 本

越境は操作ごとではなく投入ごとなので、深さ 128 の io_uring キューは毎秒 100 万回の越境を 7.8K に変える —— コアの 0.105 から 0.0008 へ。仕事が安くなったのではないし、 ring の切り替えは今も 55 ns だ。ただ回数が 128 分の 1 になっただけだ。

04

コンテキストスイッチと、それが残す請求書

2 つのスレッドは 1 コアで同時に走れないので、カーネルが入れ替える。直接コストは 1 マイクロ秒未満。残していくものはその 100 倍だ。

スイッチが起きるのは、タイマが走行中スレッドのスライスを使い切ったとき、そのスレッドが I/O でブロックしたとき、より緊急なものが起きたときだ。カーネルはレジスタを保存し、次に走るスレッドを選び、そのレジスタを読み込む。変わるのは、入ってくるスレッドが同じプロセスのものかどうかだ。

その 1 問が、6 ステップのうち 1 つが起きるかどうかを決める。スイッチを歩いてから、入ってくるスレッドが誰かを変えてほしい:

走っているスレッド —— 直接コスト 730 ns

ステップ 4 が switch_mm で、同一プロセス間のスイッチはこれを飛ばす。入ってくるスレッドはすでに正しいページテーブルを持っているので、 CR3 は書かれない。直接の差はそれだけだ —— スレッドで 730 ns、プロセスで 930 ns。もしこれで話が終わりなら、 1 リクエスト 1 プロセスは 27% 悪いだけで、誰もランタイムなど書かなかった。

話は終わらない。TLB のキーが CR3 だからだ。入ってくるスレッドのワーキングセットを大きくして、持っていたはずの変換を歩き直す値段を見てほしい:

4 KB に対しページウォーク 25 ns

曲線が登るのをやめる場所に注目したい。L2 STLB は 1,536 エントリなので、 6 MB を超えるワーキングセットはそもそも全部はキャッシュされていない。 から上では、フラッシュが余計に負わせられるのはページウォーク 38 µs までだ。この天井は良い知らせのほうで、38 µs はそれを起こしたスイッチの直接コストの 41 倍にあたる。

だから TLB はフラッシュされなくなった。各エントリはプロセス id を持ち、 Linux は CPU あたり 6 個を保持する。1 コアにプロセスを足して、タグを使い切らせてみてほしい:

変換は残った

TLB_NR_DYN_ASIDS は 6 だ。1 CPU を回る 6 プロセスまでは毎回のスイッチで変換を保持し、930 ns で済む。7 番目が誰かを追い出すと、そこからはスイッチごとに 930 ns プラス歩き直し —— 2 MB のワーキングセットなら 13 µs だ。PCID は「フラッシュが消えた」ではなく、「ランキューが短ければフラッシュは消えている」だ。

キャッシュにはそもそもそんなタグ付けがない。もう一度ワーキングセットを広げ、直接コストが、スイッチが引きずってきた 2 つのものに埋もれていくのを見てほしい:

L2 の再充填に 341 ns

では、直接の 930 ns は棒の左端の細い線だ。ページウォーク 6.4 µs と、12 GB/s でのL2 再充填 87 µs が、実コストを 95 µs にする —— カーネルのカウンタが報告する数字の 102 倍で、上限は 127 µs だ。 Li、Ding、Shen は 2007 年に同じ形を測っている:間接コストは数マイクロ秒から 1 ミリ秒超まで変わる。

だからスケジューラの仕事は、許されるかぎりスイッチしないことだ。タイムスライスを縮めて、スイッチが取っていくコアの割合を見てほしい:

スライス 4.5 ms、0.02% がスイッチに

出荷時の既定 0.75 ms では、スイッチはコアの 0.097% —— 見えない。スライスを まで下げると 12.7% になり、上で見たキャッシュ効果すべてが 150 倍の頻度で起きるようになる。プリエンプション粒度は自由に回してよい公平性のつまみではない。税率だ。

そしてそれこそがスレッドプールが実際に選んでいるものだ。CPU に 200 µs、待ちに 2 ms を使うリクエストでプールを広げ、スループットとレイテンシが離れていくのを見てほしい:

455 req/s、平均レイテンシ 2.2 ms

スループットは 88 スレッドで飽和し —— 8 コア × 実時間対 CPU 時間の 11 倍 —— 4,096 までずっと平らだ。レイテンシは違う:ひざで 2.2 ms、512 で 13 ms、。ひざを超えたスレッドは何も買わず、遅延を足すだけだ。

この節から持ち帰る価値のある不変条件はこれだ:コアを取れない実行可能スレッドは待っているのではなく、他の全員を待たせている。プールサイズは容量のつまみではない。それは、前段に置いて捨てることもできたはずのキューを、自分のプロセスの内側に置くと決めた、その長さだ。

05

クイックリファレンス

そらで答えられる価値のある 3 問(うち 2 問にはスライダーが付いている)と、 5 つの危険信号。

いま自分はコンテキストスイッチにいくら払っているのか?

vmstat 1 の cs 列を読んで 730 ns を掛ける。 8 つのマスが 8 コアで、スライダーはそのレートがコアから持っていく分を歩く:

コア 0.01 本

毎秒 10 万スイッチ —— ごく普通の忙しいサーバ —— なら、あなたのコードが走る前にコア 0.07 本が消えるだけで、大したことはない。 では 8 本中 4.1 本が消え、残りの 3.9 本は冷えたキャッシュで走る。警報を出すべきはレートではなく、仕事あたりのレートだ: 1 リクエスト 2 スイッチは健全、20 なら設計の問題だ。

プロセス、スレッド、タスク —— 実際どう選ぶのか?

必要な並行数を、手元のメモリと PID の数に突き合わせる。並行数をドラッグして、どのモデルが先に尽きるかを見てほしい:

768 MB、8.3 MB、1 MB

どの棒が先にローズになるかに注目したい。 で1 リクエスト 1 プロセスは 24 GB を食い尽くす。1 リクエスト 1 スレッドは 32,768 まで生き延び、そこで pid_max に止められる —— メモリにではない。タスクは同じマシンで 420 万に届く。 1 リクエスト 1 プロセスが間違いなのではない —— nginx も Postgres も使っている —— リクエストごとにやるのが間違いだ。

では 1 つ作るのはいくらかかるのか?

生成レートが独立した予算項目になる程度には高い。レートを上げて、コアが消えるのを見てほしい:

0.36 · 0.03 · 0.0003

fork と execve で 355 µs、pthread_create は 25 µs、go func() は 300 ns。毎秒 1,000 で 0.36 / 0.03 / 0.0003 コアだ。

  • 1 リクエスト 1 プロセス。1 つ 3 MB の私的 RSS、そして親のページテーブルに比例する fork コスト。
  • 上限のないスレッドプール。天井は pid_max の 32,768、ひざは 88 だった。
  • 非同期タスク内のブロッキング呼び出し。下の OS スレッドを固定し、そこに並ぶすべてを飢えさせる。
  • 同期なしの共有可変状態。スレッドはアドレス空間を共有する。言語はそれを思い出させてくれない。
  • レイテンシのためにスケジューラ粒度を触る。スライスが 50 µs を割ると、そのために全コアの 1% 以上を払っている。