システムコール
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 で扱えます。プロセスが持っているのは番号だけで、番号の先に何がつながっているかを知っているのはカーネルです。
この統一のおかげで、ローカルファイルへの書き込みとネットワークへの送信を、ほとんど同じコードで書けます。シェルが > でコマンドの出力先をファイルに付け替えられるのも、コマンドは「1番に書く」とだけ決めていて、1番の先をシェルがすり替えているからです。
呼び出しの往復を追う
システムコールが実際にどう動くかを、read の一往復で追ってみます。アプリケーションはたいてい、システムコールを直接書くのではなく、標準ライブラリ(C 言語なら libc)の薄いラッパー関数を呼びます。そのラッパーが、決められたレジスタにシステムコール番号と引数を並べ、x86-64 なら syscall、ARM なら svc という専用の命令を実行します。この命令がCPUを特権モードに切り替え、制御をカーネルが登録しておいた入口へ移します。
カーネルは受け取った要求の番号と引数と権限を検査し、読みたいデータがすでにメモリ上のページキャッシュにあれば、それをアプリのバッファに写してすぐ戻ります。無ければディスクに読み出しを依頼しますが、ディスクの応答は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(1バイト) を押すと、アプリが「hello, kernel!」を1文字ずつ書きます。1回ごとに何が境界を越えるか、下のメーターに何が積み上がるかを見てください。
時間は目安です(往復 150ns・カーネル内の書き込み 50ns・コピー 1ns/B・リングへの記入 10ns/件)。実際の値は CPU や対策の有無で数倍変わります。
試問
アプリが文字を書くたびに、カーネル(OS の中枢)へ write を依頼します。方式は「素の write(1バイトごとに依頼)」「バッファあり(溜めてからまとめて依頼)」「io_uring(共有の輪に要求を積み、カーネル側が拾う)」から選べ、「1 MB 書く」で方式ごとの時間が下のメーター(1目盛りで10倍)に積み上がります。
write(1バイト) を押すと、アプリが「hello, kernel!」を1文字ずつ書きます。1回ごとに何が境界を越えるか、下のメーターに何が積み上がるかを見てください。
時間は目安です(往復 150ns・カーネル内の書き込み 50ns・コピー 1ns/B・リングへの記入 10ns/件)。実際の値は CPU や対策の有無で数倍変わります。
