システムコールと割り込み 基礎

コアはディスクにもソケットにも他プロセスのメモリにも触れない。外の世界にすることは、すべて制御がカーネルへ渡って戻ったから起きる。手で動かせる主張が 3 つ。扉を選ぶのは引き起こした側だ。値段は 4 桁にわたる。 2 ns の関数呼び出しから 26 µs の冷えた尾まで。高いのはその命令ではない —— 流された TLB と冷えたキャッシュと、起こされたスレッドだ。

01

線を越える 3 つの扉

ユーザモードを離れる道は 3 本。分けるのはカーネルの次の動きではなく、その瞬間を誰が選んだかだ。

コードは ring 3 で走り、ここでは特権命令はフォールトする。 ring 0 へ行くのは番地へのジャンプではなく、扉は 3 つ、4 つ目はない。

syscall 命令はあなたのものだ。ページフォールトは、それを起こした load に釘付けになる。割り込みはデバイスがパイプラインを捕まえた場所に落ちる —— 扉を選び、紫の印を動かして、 3 つのうちどれが手についてくるかを見てほしい:

8 番目と 9 番目の間。左右にドラッグ;矢印キーで 1 つずつ動き、Home で最初の位置に戻ります
システムコール —— syscall 命令の上 —— 7 番目

動くのは割り込みだけだと分かる。システムコールを選んだままドラッグすると、紫の破線は命令列を端まで走るのに、横断そのものは 7 番目から動かない。例外に切り替えれば、同じ頑固さで 4 番目に居座る。同期と非同期の違いはそれが全部だ。システムコールとフォールトは命令列の帰結であり、割り込みは命令列とただ偶然重なっただけである。

差がもっと大きいのは戻り道のほうだ。再生を押して、印が命令列を離れ、カーネルを横切り、また降りてくるのを見てほしい —— それから扉を切り替えて、着地するセルの動きを見てほしい:

システムコール —— 実行中 · ring 3

システムコールは 8 番目、つまり次の命令に降りる。割り込みは 9 番目、離れたその場所に戻る。例外は 4 番目 —— 同じ load をもう一度走らせる。ハンドラの仕事は、今度こそそれを成功させることだったからだ。

入る道では 3 つとも同じハードウェアの請求書を払う。そして 2018 年以降、その請求書には追加料金が並んだ。1 つずつ足して、空の getpid() がどう登るかを見てほしい:

0 個オン —— 空システムコール 1 回 50 ns

どの段も 1 つ上の段が終わった場所から始まることに注目してほしい。棒の端に書かれた数は累計で、2 本の段差がその緩和の値段だ。、 PCID のあるハードウェアで 、。見るべきは真ん中の段だ。同じ CR3 書き込み 2 回が、タグを付けられるときは 36 ns、付けられないときはさらに 77 ns かかる。タグのない書き込みは、そのスレッドが持っていた変換をすべて捨てるからである。

ナノ秒は掛け算して初めて面白くなる。1 本目のスライダで呼び出し速度を、 2 本目で緩和の数を決めて、コア 1 個のうちどれだけが出入りだけに消えるかを読んでほしい:

100 k/s —— コア 1 個の 0.86%

速度を上げるにつれて棒が埋まっていくのを見てほしい。 —— ありふれた web サーバ —— なら 0.86%。 なら 8.6%。 と 97% になる。コア 1 個が線をまたぐことで埋まりきり、あなたの命令は 1 つも実行されない。io_uring と epoll が存在するのはそのためだ。

02

入る道でハードウェアがすること

最初のカーネル命令が走る前に 4 つのことが起きている。そのどれもが、費用かバグを隠せる場所である。

ring を上げるだけでは何の役にも立たない。上げた先の番地が要る。割り込みと例外にとってその番地は、カーネルが起動時に埋めた表から出てくる。索くのは CPU 自身で、その経路にソフトウェアは 1 行も入らない。

256 個のベクタはどれも 16 バイトのゲートを持つ —— ハンドラの番地、目標の ring、スタック切り替えの指示。ベクタ番号を歩いて、カーネルが何を置いたかを見てほしい:

ベクタ 14 —— CPU 例外

この表は 4,096 バイト。16 バイトのゲートが 256 個で、ちょうど 1 ページぶんであり、それは偶然ではない。最初の 32 項はアーキテクチャが固定していて —— OS がどう思おうとベクタ 14 はページフォールトだ —— 32 より上はデバイスを探すときにカーネルが割り当てる。

ゲートには切り替え先のスタックも書いてある。ユーザスタックは信用できないからだ。トラップフレームを、カーネル自身のスタックへ 1 スロットずつ積んでみてほしい:

0 スロット —— 0 バイト

最初の 5 つが先に降りる。あれは CPU 自身がマイクロコードで、カーネル命令が 1 つも走らないうちに積むものだ。残りは進入スタブの仕事である。21 スロット、168 バイトを 16 KiB のスタックに —— その 1% だ。カーネルスタックの溢れが深い呼び出し連鎖から来て、このフレームから来ないのはそのためである。

システムコールはその表をまったく使わない。SYSCALL は LSTAR MSR の番地へ直接跳ぶ —— そしてその近道の代金をレジスタで払う:

C 呼び出し —— 引数 4 は rcx を通る

C の規約は 4 番目の引数を rcx に置く。カーネルは r10 で受け取る。あなたのコードが止まる前に SYSCALL が戻り番地を rcx に、RFLAGS を r11 に書いてしまうからだ。違うセルはちょうど 1 つで、手書きのシステムコールスタブを書く人は皆その 1 つで一度は間違える。

最後の 1 つが KPTI で、カーネルに自分専用のページテーブルを与える。つまり横断ごとに CR3 書き込みが 2 回入り、それが痛いかどうかは 1 ビットで決まる。ワーキングセットを決めて、タグを切ってみてほしい:

何も流されていない

タグ付きの書き込みは項目をそのままにするので、64 本すべてが生き残り、歩き直すものはない。では同じ書き込みがそれを流す。64 ページなら復帰後に 533 ns の歩き直し、なら 12.8 µs —— これは続く数千命令に薄く広がり、システムコールの外に置いたどんな計測器にも見えない。

03

前半がごく小さくなければならない理由

ハードウェアハンドラは自分の割り込み線をマスクしたまま走る。その一点が Linux のすべてのデバイスドライバの形を決めている。

マスクは礼儀ではない。いま処理している当の割り込みでハンドラが再入されるのを止めるためだ。だがそれは同時に、ハンドラが返るまでデバイスが他に何も言えないということでもある。

だからハンドラの長さが線全体の上限になる。右端をドラッグして、いくつの到着が耳の聞こえない窓に落ちるかを見てほしい:

ハンドラ 1.5 µs. 左右にドラッグ;矢印キーで 1 つずつ動き、Home で最初の位置に戻ります
1.5 µs マスク —— 上限 667 k/s

敷居の低さに注目してほしい。1.5 µs のハンドラは線を毎秒 66.7 万に抑え、これはすでに 10 GbE のフルサイズフレーム毎秒 81.3 万パケットを下回る。10 マイクロ秒 —— まったく妥当な量のコードだ —— なら毎秒 10 万、線速の 8 分の 1 になる。

Linux の答えは仕事を 2 つに切ることだ。マスクされた窓の外へ出し、割り込みを戻した状態で走る softirq に入れてしまう:

0.00% 後回し —— 線の上限 100 k/s

何が動いて何が動かないかを見てほしい。線の上限は毎秒 10 万から 250 万へ上がる。耳の聞こえない窓が 10 µs から 0.4 に縮んだからだ。一方コアの上限は最後まで毎秒 10 万のままだ。後回しにした仕事が依然として誰かの 10 µs を使うからである。この分割が買うのは可用性であってスループットではない —— そこを取り違えるのが、ドライバは「直った」のに箱は相変わらずパケットを落とす理由だ。

softirq も永遠に走ってよいわけではない。捌ける分を捌き、残りを ksoftirqd —— あなたのスレッドと競合するただのスケジュール対象スレッド —— に渡す。リングを埋めて壁を探してほしい:

150 待ち —— 0 が ksoftirqd へ

壁は 200 パケットにある。誰もが調整する netdev_budget の 300 ではない。net_rx_action は 300 パケットまたは netdev_budget_usecs で止まり、 2,000 µs の実時間は 10 µs のパケット 200 個で尽きる。この箱でパケット予算を上げても、何ひとつ変わらない。

残るのは割り込みそのもの、まだ 1 パケットに 1 回だ。NAPI はこれも消す。最初の 1 回を受けたら、あとはポーリングに切り替える。 1 回のポーリングが捌く束を大きくして、1 パケットあたりの費用が崩れるのを見てほしい:

1 パケット待ち。左右にドラッグ;矢印キーで 1 つずつ動き、Home で最初の位置に戻ります
1 パケット —— 1 個あたり 2.0 µs

1 ポーリング 1 パケットなら、割り込みはそのパケットの 10 µs のうち 2.0 µs を食う。NAPI_POLL_WEIGHT の 64 では 31 ns、300 では 6.7 ns —— 1% を切る。この曲線の尾こそ、同じドライバが空いた回線では低遅延、混んだ回線では高スループットになり、その間に誰もつまみを回していない理由である。

04

コンテキストスイッチの本当の値段

誰もが引く数字はマイクロベンチマークに見える分であり、それは請求書の小さいほうの半分である。

スイッチは扉ではない。 3 つの扉のどれかを通ったあと、カーネルの内側で起きる。だがブロックするシステムコールが実際に買っているのはこれなので、値段はこのページに属する。

測りやすいほうから始めよう。タスクの組を選んで、直接費用のどの行が課金されるかを見てほしい:

2 スレッド —— 700 ns

この組が払わない行は、枠だけ描かれて塗られていないことに注目してほしい。同じプロセスの 2 スレッドはアドレス空間を共有するので CR3 書き込みは要らない。700 ns。2 つのプロセスには 1 回要る。1.2 µs。KPTI では両側の出入りもテーブルを入れ替える。1.9 µs。この梯子の項目はどれもレジスタの移動かテーブルポインタで、lat_ctx が報告するのはこれだけである。

そして新しいスレッドが走り出す。他人のデータで一杯のキャッシュの中へだ。引き戻すワーキングセットをドラッグして、尾とスイッチを比べてほしい:

ワーキングセット 32 KiB. 左右にドラッグ;矢印キーで 1 つずつ動き、Home で最初の位置に戻ります
尾 3.3 µs —— 切り替えの 2.7 倍

—— L1 1 個ぶん —— で尾は 3.3 µs、すでにスイッチの 2.7 倍だ。 では 26 µs、21.8 倍になる。これが Li、Ding、Shen が 2007 年に測った費用であり、何もしない 2 スレッドを往復するベンチマークが、あなたのサーバについてほとんど何も語らない理由である。

そしてこれが、安いシステムコールを高くする。ページキャッシュが答える read は CPU を離れない。ブロックするほうはスイッチ 2 回と起床 1 回を払う。デバイスに待ち時間を与えて比べてほしい:

ブロックする read は 8.6 µs

1.2 µs 対 8.6 µs —— 7 倍、しかもデバイスが即答した場合である。カーネルはその時間を遊ばせない。誰か別の仕事が隙間で走る。ブロックが存在する理由はまさにそれだ。払うのはあなたのレイテンシであって、機械のスループットではない。

これを何回払うかを決めるのがスケジューラで、両取りはできない。時間量子を動かして、 2 本の曲線が互いの領域に踏み込むのを見てほしい:

費用 0.04% —— 最悪待ち 21 ms

2 つの費用が入れ替わるのを見てほしい。CFS 自身の下限 では、切り替えは 0.04% で、8 本目の実行可能タスクは 21 ms 待つ。 まで下げると待ちは 350 µs、切り替えは 2.3%。 では 11% で、機械はほとんど気を変えているだけだ。 2 本の曲線が実際に交わることはない —— 単位が違う —— が、両方に良い設定は存在しない。

05

シグナル —— 出ていく道

カーネルはあなたのプロセスへの扉を自前で持っていない。あなたの扉が開くのを待つしかなく、シグナルの奇妙さはすべてそこから出る。

kill(pid, SIGTERM) は何も呼ばない。対象の保留マスクにビットを 1 つ立てて返るだけだ。そのビットは、対象が次にカーネルからユーザモードへ戻るときに読まれる —— そこでしか読まれない。

だから遅れはカーネルのスケジューリングではなく、対象が中にどれだけ留まるかで決まる。スリープ状態を選び、そこで過ごす時間を伸ばしてほしい:

実行中 —— すぐ配送される

実行中の対象も割り込み可能スリープも、すぐに受け取る —— スリープはまさにこのために中断される。割り込み不可スリープ、つまり D 状態は、確認する経路にいない。シグナルは 6 ms を待ち切り、SIGKILL も一緒に待つ。死んだ NFS マウントで固まったプロセスが殺せないのはこれで、kill -9 が信じられているような切り札ではないのもこれである。

ビットがようやく読まれたとき、カーネルはハンドラを直接呼ばない。あなたのスタックの上にフレームを組み、そこへ復帰する。レジスタ組を選び、渡したスタックを小さくしてみてほしい:

952 バイト —— 収まる

フレームがそのために空けた場所を追い越すのを見てほしい。SSE では 952 バイト。AVX-512 の xsave 領域を載せると 3,128 バイトで、昔の 2,048 バイトの MINSIGSTKSZ を 1,080 超える。この定数は 30 年ものあいだコンパイル時の数字だったが、実行時の値にせざるを得なくなった —— sysconf(_SC_MINSIGSTKSZ)、glibc 2.34 だ。命令セットのほうが定数を追い越したからである。

そしてハンドラは、シグナルが捕まえたその場所で走る。ちょうど malloc の中に落ちる配送を 1 歩ずつ辿り、それからハンドラのすることを変えてほしい:

ハンドラが malloc を呼ぶ —— malloc の中 · ロックは空き

中断されたコードが arena ロックを持っているので、malloc を呼ぶハンドラは自分のスレッドがすでに持つロックで止まり、それが解放されることは二度とない。安全なハンドラは sig_atomic_t に 1 回書いて返る。ハンドラ内で合法な関数の一覧は signal-safety(7) にあり、printf はそこにない。

もう 1 つ帰結があり、これは普通のコードにまで届く。遅いシステムコールに座っているスレッドへシグナルを配るには、その呼び出しを終わらせるしかない。シグナルが転送のどこに落ちるかを動かしてほしい:

1 バイトも届く前。左右にドラッグ;矢印キーで 1 つずつ動き、Home で最初の位置に戻ります
read が EINTR で −1 を返す

最初の 1 バイトより前なら read は −1 と EINTR を返す —— あるいは SA_RESTART があれば、カーネルが rip を巻き戻し、誰も気づかない。最初の 1 バイトのあとは、どちらの設定でも短い数と、エラーなしが返る。報告すべき結果がすでにあり、報告したものは取り消せないからだ。短い read をストリームの終わりとみなすループは、シグナルが来る日までは正しく、その後は静かに、再現もできない形で誤りになる。

06

クイックリファレンス

そらで答えられるとよい問いが 3 つ、常に正しいハンドラの形が 1 つ、そして赤旗が 5 つ。

横断は実際どれくらい高いのか。

どの横断かで 4 桁変わるので、役に立つ答えは梯子を一度に見ることだけだ。このページが測ったすべてを、素の関数呼び出しと比べる:

関数呼び出し —— 2.0 ns

上の 4 段が軸のどれだけ狭い範囲に固まっているかに注目してほしい。は関数呼び出し 43 回ぶん —— 面倒だが致命的ではない。 は 4,300 回、は 13,107 回だ。工夫する価値がある段はすべて軸の下半分にあり、その中に syscall 命令はない。

1 リクエストの時間はどこへ行くのか。

計るのではなく、横断の回数を数えることだ。1 リクエストが何回またぐかを決めて、自分の仕事 50 µs との取り分を読んでほしい:

59 µs —— うち 16% が横断

横断を足すにつれて真ん中の帯が伸びるのを見てほしい。で 1 リクエスト 59 µs、うち 16% が横断だ。なら 88 µs で 43%。直し方は readv か io_uring である。

安全なシグナルハンドラの形は何か。

ストア 1 回と、中断されることを見込んだループだ:

static volatile sig_atomic_t stop;
void on_term(int s) { stop = 1; }  /* all */

while (!stop) {
  n = read(fd, buf, len);
  if (n < 0 && errno == EINTR) continue;
  if (n <= 0) break;   /* real error, or EOF */
  consume(buf, n);     /* n may be short */
}

残る問いは 1 つ、何をもって安全かだ。線の両側にある 8 つの呼び出しを歩く:

write —— 安全

安全な側は素のシステムコールだけ、危険な側は先にロックを取る ——exit が危険で _exit が安全な理由だ。

レビューでの赤旗 5 つ

  • ストアより多くをするハンドラ。§05 のデッドロック。
  • EINTR の分岐がない read ループ。時々誤る。
  • フィールドごとの 1 システムコール。1 回 86 ns。
  • 時間量子を下げるレイテンシ対策。スライスを半分にすると待ちも半分になるが、切り替えは倍になる。 100 µs でコアの 1.2%、50 µs で 2.3%、10 µs で 11%。
  • worker の CPU の ksoftirqd。/proc/irq/N/smp_affinity で動かす。