基礎●●●○○

システムコール

System Call ・ しすてむこーる

アプリがカーネルに仕事を頼む正式な入口。ファイルも通信もプロセスの生成も、この呼び出しの先にある。

✦ 30秒で試す — 航海print を1行呼ぶだけで、プログラムはOSの関所をくぐっている4096バイトずつまとめて依頼すると、かかる時間は?予想してみる

概要

システムコールは、アプリケーションがカーネルに仕事を頼むための正式な入口です。ファイルを開く、データを読む、ネットワークに接続する、新しいプロセスを生む——ユーザー空間で動くプログラムは、こうしたハードウェアや他のプロセスに関わる操作を自力ではできず、そのたびにカーネルへ「これをやってください」と依頼します。この依頼の呼び出しがシステムコールです。

普段書くコードでシステムコールが直接見えることは多くありませんが、重要な操作のほとんどはその先にあります。C の fopen も Python の print も、掘り下げていけば open や write というシステムコールに行き着きます。プログラムが外の世界に影響を及ぼすときは、ほぼ必ずこの入口を通ります。だからこそ「このプログラムは実際に何をしているのか」を知りたいとき、システムコールの列を眺めるのが最も確実な観察方法になります。

アプリが自分からカーネルに入る入口をこの一種類に絞っているのには理由があります。ハードウェアを直接触らせない設計を貫く以上、カーネルの力を借りる経路を決まった形に揃え、そこで必ず引数と権限を検査する必要があるからです。この関所が、OSの安定性と安全性を支えています。

なぜ生まれたか

カーネルは、ハードウェアに触れることを許された唯一の存在として作られました。しかしそうすると、アプリケーションが「ファイルを読みたい」「メモリが欲しい」と思っても、自分ではどうにもできません。かといって、ユーザープログラムがカーネルの関数を好きな場所から直接呼び出せてしまうと、権限の壁は意味をなくします。悪意あるプログラムがカーネルの内部処理の途中に飛び込めるなら、特権モードを分けた意味がありません。

そこで必要になったのが「制御された入口」です。ユーザープログラムは、あらかじめ番号で決められた機能だけを、決められた作法で呼び出せる。呼び出しの瞬間にCPUの特権レベルがユーザーモードからカーネルモードへ切り替わり、飛び込む先はカーネルが登録した入口の1か所だけ。カーネルはそこで「誰が・何を・どんな引数で」要求したかを検査してから処理を実行します。1970年代の UNIX は、この入口の数をごく少数に絞り、ファイル・プロセス・入出力をわずかな呼び出しの組み合わせで表しました。その設計は、今日の Linux や macOS の数百のシステムコールの骨格として残っています。

詳細

代表的なシステムコール

システムコールの多くは、ファイルと入出力に関わるものです。open でファイルを開いてファイルディスクリプタ(開いた対象を指す小さな整数の番号。fd)を得て、read で読み、write で書き、close で閉じる。プロセスに関わるものとしては、fork で自分の複製を作り、execve でそのプロセスを別のプログラムに置き換え、exit で終了し、wait で子の終了を回収します。メモリでは mmap や brk で領域を確保し、通信では socket・connect・sendto・recvfrom を使います。Linux の x86-64 版には 300 を超えるシステムコールがありますが、日常的に登場するのはこの程度の顔ぶれです。

すべてはファイル — ファイルディスクリプタの先

ここには一貫した思想があります。UNIX 系のOSでは、ファイルシステム上の通常のファイルも、端末も、デバイスも、プロセス間をつなぐパイプも、ソケットによるネットワーク通信さえも、同じファイルディスクリプタで表され、同じ read/write で扱えます。プロセスが持っているのは番号だけで、番号の先に何がつながっているかを知っているのはカーネルです。

プロセスユーザー空間read(0, buf, n)write(3, buf, n)write(5, buf, n)持っているのは番号だけ番号カーネル空間 — 番号の先にある実体を知っているのはカーネルだけfd 表0 標準入力1 標準出力2 標準エラー3456端末キーボードと画面通常のファイルファイルシステムを経てディスクへパイプ別のプロセスの標準入力へソケットネットワークの向こうのプロセスへデバイス/dev/null・/dev/urandom など
プロセスが渡すのは fd の番号だけ。その先が端末でもファイルでもパイプでもソケットでも、同じ read / write で読み書きできる

この統一のおかげで、ローカルファイルへの書き込みとネットワークへの送信を、ほとんど同じコードで書けます。シェルが > でコマンドの出力先をファイルに付け替えられるのも、コマンドは「1番に書く」とだけ決めていて、1番の先をシェルがすり替えているからです。

呼び出しの往復を追う

システムコールが実際にどう動くかを、read の一往復で追ってみます。アプリケーションはたいてい、システムコールを直接書くのではなく、標準ライブラリ(C 言語なら libc)の薄いラッパー関数を呼びます。そのラッパーが、決められたレジスタにシステムコール番号と引数を並べ、x86-64 なら syscall、ARM なら svc という専用の命令を実行します。この命令がCPUを特権モードに切り替え、制御をカーネルが登録しておいた入口へ移します。

ディスクカーネルlibcアプリディスクカーネルlibcアプリ番号と引数と権限を検査プロセスを眠らせ スケジューラが別のプロセスを動かす眠っていたプロセスを起こすread を呼ぶsyscall 命令で特権モードへページキャッシュに無いので読み出しを依頼転送完了の割り込みデータを写してユーザーモードへ戻る読めたバイト数を返す

カーネルは受け取った要求の番号と引数と権限を検査し、読みたいデータがすでにメモリ上のページキャッシュにあれば、それをアプリのバッファに写してすぐ戻ります。無ければディスクに読み出しを依頼しますが、ディスクの応答はCPUから見て非常に遅いので、カーネルはその間このプロセスを「待ち」の状態にして眠らせ、スケジューラが別のプロセスにCPUを回します。これが ブロッキング(呼び出しが結果が出るまで戻ってこないこと)の正体です。ディスクが転送を終えると割り込みがカーネルに知らせ、カーネルは眠っていたプロセスを起こして、データを写してからユーザーモードへ戻します。アプリから見れば、ただの関数呼び出しが少し遅れて返ってきただけです。

何がコストになるのか

システムコールの往復には、通常の関数呼び出しにはないコストがかかります。特権レベルの切り替え、レジスタの退避と復元、引数の検査、そしてカーネルのコードとデータがCPUキャッシュを押しのけることの影響です。2018年に CPU の脆弱性 Meltdown・Spectre が見つかってからは、カーネルとユーザーのページ表を切り替える対策(KPTI)などが加わり、1往復の代金はさらに上がりました。環境によりますが、何もしないシステムコールでも数十〜数百ナノ秒、関数呼び出しの数十倍から百倍以上です。

ここで区別しておきたいのが、コンテキストスイッチ(CPUを別のプロセスやスレッドに明け渡すこと)です。システムコールそのものは「同じプロセスが特権モードに入って、また戻ってくる」だけで、プロセスは切り替わりません。切り替わりが起きるのは、上の read のように待ちが発生してプロセスが眠ったときや、戻る直前にスケジューラが交代を決めたときです。「システムコールが重い」の中身が、モード切り替えの代金なのか、眠って起きるまでの待ちなのかを分けて考えると、性能の議論で混乱せずに済みます。

観察する — strace

システムコールは、プログラムの挙動を外から観察する最良の窓でもあります。Linux の strace コマンドを使うと、対象のプロセスが発行したシステムコールを、引数と戻り値つきで時系列に並べて見られます。「設定ファイルが読めずに落ちる」なら、openat が目当てのパスに対して ENOENT(ファイルが無い)や EACCES(権限が無い)を返している行が、そのまま原因を指し示します。strace -c で呼び出しの回数と合計時間を集計すれば、1バイトずつ write しているような無駄な呼び方も一目で見つかります。

コストを減らす工夫

システムコールが重いという事実は、性能設計に直結します。基本の対策は 回数を減らす ことです。1バイトずつ write を呼ぶのではなく、ユーザー空間のバッファに溜めてまとめて書き出す(バッファリング)だけで、往復の回数が桁違いに減ります。C の stdio や Java の BufferedWriter が内部にバッファを持つのはこのためです。ただしバッファに溜まったまま異常終了すると、書いたつもりのデータが失われます。ログを確実に残したい場面では、明示的に flush する位置を決めておく必要があります。

入口そのものを通らずに済ませる工夫もあります。現在時刻を取る clock_gettime や gettimeofday は非常に頻繁に呼ばれるため、Linux はカーネルが用意した小さなコードと時刻データを各プロセスのメモリに貼り付けておく vDSO(virtual Dynamic Shared Object)という仕組みで、カーネルに入らずに答えられるようにしています。

大量の入出力をさばくサーバでは、さらに踏み込んだ工夫が使われます。多数の接続のうち読み書きできるものを1回の呼び出しでまとめて教えてもらう epoll、そして 2019年に Linux 5.1 で入った io_uring です。io_uring では、アプリとカーネルが共有するリング状のバッファに要求を積み、結果も別のリングで受け取ります。まとめて1回の呼び出しで知らせることも、カーネル側のスレッドにリングを見張らせて、呼び出しそのものを省くこともできます。

下の部品で確かめてください。同じ文字列を、1バイトずつ write する場合、バッファに溜めてから書く場合、io_uring のリングに積む場合で、境界を越える回数と、アプリが費やす時間がどう変わるかが見えます。

⚡ 体験: write を呼ぶたびに境界を越える
方式
ユーザー空間(ユーザーモード)カーネル空間(特権モード)境界を越えた回数 0 往復stdio のバッファ(ユーザー空間)書く文字列: hello, kernel!⏎io_uring の共有リングSQPOLL が拾うアプリwrite を呼ぶカーネル検査して書く端末に届いたアプリが費やした時間(対数目盛り。1目盛りで10倍)1ns10ns100ns1µs10µs100µs1ms10ms100ms1s素の writeバッファありio_uring

write(1バイト) を押すと、アプリが「hello, kernel!」を1文字ずつ書きます。1回ごとに何が境界を越えるか、下のメーターに何が積み上がるかを見てください。

時間は目安です(往復 150ns・カーネル内の書き込み 50ns・コピー 1ns/B・リングへの記入 10ns/件)。実際の値は CPU や対策の有無で数倍変わります。

試問

アプリが文字を書くたびに、カーネル(OS の中枢)へ write を依頼します。方式は「素の write(1バイトごとに依頼)」「バッファあり(溜めてからまとめて依頼)」「io_uring(共有の輪に要求を積み、カーネル側が拾う)」から選べ、「1 MB 書く」で方式ごとの時間が下のメーター(1目盛りで10倍)に積み上がります。

✦ 命題挑戦中

1 MB のデータを、アプリの時間 5ms 以内で書け

  • どれかの方式で 1 MB を書く
  • その方式で、アプリの時間が 1 MB あたり 5ms 以下
⚡ 体験: write を呼ぶたびに境界を越える
方式
ユーザー空間(ユーザーモード)カーネル空間(特権モード)境界を越えた回数 0 往復stdio のバッファ(ユーザー空間)書く文字列: hello, kernel!⏎io_uring の共有リングSQPOLL が拾うアプリwrite を呼ぶカーネル検査して書く端末に届いたアプリが費やした時間(対数目盛り。1目盛りで10倍)1ns10ns100ns1µs10µs100µs1ms10ms100ms1s素の writeバッファありio_uring

write(1バイト) を押すと、アプリが「hello, kernel!」を1文字ずつ書きます。1回ごとに何が境界を越えるか、下のメーターに何が積み上がるかを見てください。

時間は目安です(往復 150ns・カーネル内の書き込み 50ns・コピー 1ns/B・リングへの記入 10ns/件)。実際の値は CPU や対策の有無で数倍変わります。