基礎●●○○○

プロセス

Process ・ ぷろせす

実行中のプログラムを表すOSの管理単位。専用のメモリ空間と資源を持ち、互いに隔離される。

✦ 30秒で試す — 航海ブラウザが落ちても、隣のエディタは無事でいられる子が使っていた、プロセス表の行はどうなる?予想してみる

概要

プロセスとは「実行中のプログラム」を表すOSの管理単位です。プログラムはディスク上ではただの実行ファイルにすぎませんが、起動されるとOSがメモリ空間・CPU時間・開いたファイルなどの資源を割り当て、「プロセス」として生きた存在になります。同じプログラムを2回起動すれば、それぞれ独立した2つのプロセスができます。

プロセスの最大の特徴は「隔離」です。各プロセスは自分専用の仮想メモリ空間を持ち、ほかのプロセスのメモリを直接読み書きすることはできません。ブラウザが落ちてもエディタは無事なのは、この隔離のおかげです。ターミナルで ps や top を打ったときに並ぶ一行一行が、いままさに動いているプロセスたちで、それぞれに PID(プロセスID)という番号が付いています。

プロセスには親子関係もあります。どのプロセスも別のプロセス(親)から生み出され、終わるときには親に終了コードを返します。この「生んだ者が最後を看取る」という約束を知っておくと、ゾンビプロセスやコンテナの落とし穴が一本の筋で理解できます。

なぜ生まれたか

1960年代、高価なコンピュータを複数の利用者や複数のプログラムで分け合う時分割システムが作られ始めたとき、OSは根本的な問題を2つ抱えました。1つは「プログラムを切り替えるとき、実行途中の状態(CPUのレジスタの値や作業中のメモリ)をどう保存し、どう戻すか」。もう1つは「あるプログラムのバグや悪意が、ほかのプログラムやOS本体を壊さないようにどう守るか」です。

この2つを同時に解いた抽象が「プロセス」でした。実行状態を丸ごと1つの管理単位にまとめれば、OSはいつでも切り替え(コンテキストスイッチ)ができ、どのプロセスにCPUを渡すかはスケジューラが決められます。さらに各プロセスに独立した仮想メモリ空間を与えれば、他人のメモリには物理的に手が届かなくなります。「実行の単位」と「保護の単位」を一体にしたこの設計が、マルチタスクOSの安定性の土台になりました。のちに、保護の単位はそのままに実行の単位だけを軽く増やしたくなって生まれたのがスレッドです。

詳細

プロセスは資源の入れ物

プロセスは単なる「動いているコード」ではなく、資源の入れ物です。カーネルはプロセスごとに管理用の記録(Linux では task_struct という構造体)を持ち、そこから次のようなものをたどれるようにしています。

親: シェルPID 3990fork で生んだ親プロセス(PID 4213)仮想アドレス空間(高い番地)スタック ↓空きmmap・共有ライブラリヒープ ↑データコード(低い番地)ファイルディスクリプタ表0 標準入力1 標準出力2 標準エラー3 ソケット4 app.logスレッド(1本以上)レジスタ・PC・スタック位置資格・環境UID・環境変数・cwd端末ネットワークファイル終わるとOSがまとめて回収する。PID と終了コードだけは、親が wait で受け取るまで残る
プロセスが持つもの。専用のアドレス空間、開いたファイルの一覧(ファイルディスクリプタ表)、1本以上のスレッド、資格と環境。終わるとOSがまとめて回収する

中心にあるのは、コードとデータを載せた専用の仮想アドレス空間です。その中にヒープとスタックが置かれます(スタックとヒープ)。次に、開いているファイルやソケットの一覧であるファイルディスクリプタ表があります。プログラムは「3番」のような小さな番号で開いた相手を指し、0・1・2 番は標準入力・標準出力・標準エラーと決まっています。さらに、CPUの上で実際に命令を進めるスレッドが1本以上あり、実行ユーザー(UID)・環境変数・カレントディレクトリといった資格と環境が付きます。プロセスが終わると、OSはこれらをまとめて回収します。プログラムがファイルを閉じ忘れても終了すれば後始末されるのは、この入れ物単位の管理のおかげです。

fork と exec — プロセスはプロセスから生まれる

UNIX系のOSでは、新しいプロセスは既存のプロセスの複製として生まれます。fork というシステムコールを呼ぶと、呼んだプロセス(親)とほぼ同じ中身を持つ子プロセスができ、両方が fork の直後から実行を続けます。子はたいていすぐに exec を呼び、自分の中身を別のプログラムに入れ替えます。シェルで ls と打つと、シェルが fork して子を作り、子が exec で ls に変身し、シェルは子の終わりを wait で待ってから次のプロンプトを出す — この3つの呼び出しの組み合わせが、あらゆるコマンド実行の裏で起きています。

親のメモリを丸ごと複製するのは重そうに見えますが、実際にはコピーオンライト(copy-on-write)で安く済ませています。fork の直後、子のページは複製されず、親と同じ物理メモリを指したまま「書き込み禁止」の印を付けられます。どちらかが書き込もうとした瞬間にだけ、OS がそのページ1枚を複製して付け替えます。すぐ exec するなら、ほとんど何も複製されずに済むわけです。

一生と回収 — ゾンビと孤児

プロセスは生まれてから、CPUを割り当てられて「実行中」と「待ち」を行き来し、やがて終了して終了コードを親に返します。この一生は状態遷移として整理すると見通しがよくなります。

fork で生まれる

スケジューラが CPU を割り当て

割り当て時間を使い切る

入出力の完了を待つ

入出力が完了

exit で終了コードを残す

親が wait で回収

実行可能

実行中

待機

ゾンビ

注目したいのは、exit と消滅の間にゾンビという状態があることです。終わったプロセスのメモリや開いたファイルはすぐ回収されますが、PID と終了コードを載せたプロセス表の行だけは、親が wait で終了コードを受け取るまで消せません。親が wait を呼ばないままだと、ゾンビの行が溜まり続け、やがて PID が尽きて新しいプロセスを作れなくなります。

逆に、子より先に親が終わった場合、残された子は孤児になり、PID 1 のプロセス(ふつうは init や systemd)に引き取られます。init は引き取った子が終わるたびに wait して回収するので、孤児がゾンビのまま残ることはありません。ところがコンテナの中では、起動したアプリ自身が PID 1 になります。アプリが「引き取った子を wait する」処理を持っていないと、子プロセスを使うたびにゾンビが溜まっていきます。コンテナの PID 1 に tini のような小さな init を置く(docker run --init など)のは、この回収係を補うためです。

下の部品で、fork・書き込み・exit・wait を自分で起こしてみてください。ページの枠がいつ複製されるか、プロセス表の行がいつ消えるか、親が先に終わると子がどこへ行くかを確かめられます。PID 1 をアプリに切り替えると、コンテナの落とし穴も再現できます。

⚡ 体験: fork・exit・wait — 親子と回収の責任
PID 1
PID 1 init孤児を引き取り、回収するPID 100シェルF0F1F2F3forkexitプロセス表(8行)PID親状態1—実行中1001実行中空き空き空き空き空き空き物理メモリの枠(×n = その枠を指しているプロセスの数)F0F1F2F3F4F5F6F7F8F9F10F11F12F13F14F15

シェル(PID 100)の fork を押して、子プロセスを生んでみてください。下の升は各プロセスのページで、数字は指している物理メモリの枠です。

プロセス間通信

隔離されているがゆえに、プロセス同士が協力するには専用の通信手段(IPC: プロセス間通信)が要ります。パイプ(ls | grep の |。前のプロセスの標準出力を次のプロセスの標準入力につなぐ)、シグナル(kill コマンドで送る短い合図)、共有メモリ、そしてソケットが代表です。ネットワーク越しの通信も突き詰めれば「別のマシンのプロセスとのIPC」であり、マイクロサービスやメッセージキューは、プロセス間通信をシステム全体の設計原理にまで広げたものと見ることができます。

プロセスか、スレッドか

並行に仕事をさばく単位として、プロセスとスレッドのどちらを使うかは古典的な設計判断です。プロセスは隔離が強く、1つが落ちてもほかは無事ですが、生成や切り替えが重く、メモリを直接は共有できません。

サーバの歴史はこの判断の積み重ねです。初期のWebでは、CGI という仕組みでリクエストが来るたびに fork してプログラムを起動していました。Apache の prefork 方式は、それを改めてあらかじめ作っておいたプロセスのプールを使い回し、1プロセスが1接続ずつ受け持ちます。堅牢ですが、同時接続が増えるとプロセスの数とメモリが効いてきます。nginx は少数のワーカープロセスを CPU コア数ほど立て、それぞれがイベントループで多数の接続をさばきます。PostgreSQL は今も1接続に1プロセスを割り当てるため、接続を使い回すコネクションプールが欠かせません。反対に Chrome は、安全のためにあえてサイトごとにプロセスを分け、ページが乗っ取られても別サイトのデータに手が届かないようにしています。隔離と軽さのどちらを取るかで、答えは変わります。

実務での顔

コンテナの実体は、Linux カーネルの機能(名前空間と cgroup)で見える範囲と使える資源を絞られたプロセスです。OS ごと動かす仮想マシンとは違い、ホストのカーネルを共有するので一瞬で起動します。Gunicorn や PM2 のような運用ツールは、マスタープロセスが複数のワーカープロセスを生み、死んだら作り直すというプロセス管理そのものです。

落とし穴もいくつかあります。メモリを使いすぎたプロセスは Linux の OOM Killer に強制終了させられ、「アプリが突然消えた」障害の定番の原因になります。fork したあとに親子で同じファイルディスクリプタを共有したまま書き込むと、出力が混ざります。監視でプロセス数・ゾンビの数・メモリ使用量を追うのは、こうした事態を早めに察知するためです。

試問

コンテナの中のプロセスの木とプロセス表です。いちばん上の PID 1 はふつうのアプリで、引き取った孤児(親を失ったプロセス)を回収しません。各プロセスの fork(子を生む)・wait(子の終了を待って受け取る)・exit(終わる)を押せます。終わったプロセスの行は、回収されるまで「ゾンビ」として表に残ります。

✦ 命題挑戦中

PID 1 がふつうのアプリで、誰も孤児を回収しないコンテナの中。子プロセスを2つ生んで終わらせ、プロセス表にゾンビを1行も残すな

  • 子プロセスを 2つ以上生む(0/2)
  • 生んだプロセスが、すべて終わっている
  • プロセス表にゾンビが1行もない
⚡ 体験: fork・exit・wait — 親子と回収の責任
PID 1 アプリ孤児を引き取るが wait しないPID 100シェルF0F1F2F3forkexitプロセス表(8行)PID親状態1—実行中1001実行中空き空き空き空き空き空き物理メモリの枠(×n = その枠を指しているプロセスの数)F0F1F2F3F4F5F6F7F8F9F10F11F12F13F14F15

シェル(PID 100)の fork を押して、子プロセスを生んでみてください。下の升は各プロセスのページで、数字は指している物理メモリの枠です。