task_struct とプロセスのライフサイクル

このページの目次

シェルからプログラムを起動すると、通常は子の作成、リダイレクトの準備、プログラムイメージの置換、終了待機を行います。この流れから、Linux のプロセス管理にある タスクの識別、資源の共有、終了後の回収 という境界が見えてきます。

疑問読む場所
メモリを共有するスレッドが同時実行できるのはなぜ?第 1 節:タスクと資源オブジェクト
fork でファイル位置は共有するのに、メモリ書き込みは分離される?第 1~2 節:参照と COW
exec で PID が同じままプログラムが変わる?第 4 節:イメージ置換
終了した子が Z として残るのはなぜ?第 5 節:終了と回収

順に読むとライフサイクルを追えます。CPU の配分はスケジューラ、実行の引き継ぎはコンテキストスイッチで扱います。

1. task_struct:識別情報と資源をつなぐ

スケジュール可能な各スレッドは、それぞれ task_struct、カーネルスタック、スケジューリング状態を持ちます。スレッドグループ内でアドレス空間、ファイル記述子表、シグナル処置を共有しても、TID、レジスタの文脈、シグナルマスク、スレッド宛ての保留シグナルは個別です。資源の共有はスケジューリング実体の統合を意味せず、2 スレッドは 2 CPU で同時に実行できます。

ログの「PID」が何を指すかを確認します。 getpid() はグループの TGID、gettid() は呼び出しスレッドの TID を返します。グループリーダーでは一致し、他のスレッドでは通常異なります。PID namespace によっても番号は変わります。コンテナの PID 1 はホストでは別の番号になり得るため、ログを対応させるにはタスクと観測側の namespace の両方が必要です。

task_struct はプロセスの全内容を内包する箱ではありません。寿命の異なるオブジェクトをポインタと参照でつなぎます。

オブジェクト・フィールド役割そこからは言えないこと
mmユーザーアドレス空間、ページテーブル、VMAmm の共有はカーネルスタックの共有ではない
files記述子番号から開いたファイルへの対応別々の表でも同じオープンファイル記述を参照できる
fsカレントディレクトリ、ルート、umask記述子表とは別のオブジェクト
sighand / signalシグナル処置とグループのシグナル・統計状態マスクは引き続きスレッドごと
スケジューリング関連ポリシー、優先度、実行キューの記帳TASK_RUNNING は CPU 使用中とは限らない
cred と namespace 参照資格情報と資源の見え方タスク作成で namespace が自動新設されるわけではない

2 つの fd 3 が同じオフセットを進める理由

fork 後の親子は別々の記述子表を持ちますが、両方の fd 3 が同じ struct file を指すことがあります。初期オフセットを 0 とします。

操作記述子表共有オブジェクト
親が 100 バイトの読み取りに成功親子の fd 3 はどちらも存在オフセットが 100 へ進む
子が続けて読む子の表の fd 3 を使う共有オフセット 100 から読む
子が fd 3 を閉じる子の参照だけを除去親の参照は残る

2 本の矢印が同じオブジェクトを指すことが、オフセット共有の理由です。

fork:表を複製して同じオープンファイル記述を参照 親タスク 独立した task_struct 子タスク 独立した task_struct 親の fd 表 fd 3 子の fd 表 fd 3 同じ struct file オフセットと状態フラグを共有 close は参照を 1 つ除去。CLOEXEC は記述子に、オフセットはオープンファイル記述に属する。

独立したオフセットには通常、別々の open() が必要です。dup() と fork() は元のオープンファイル記述への参照を引き継ぎます。

図を準備しています
次の読み取りはどこから始まる?

fork 継承では両者が同じオフセットを進め、別々の open では個別のオフセットを持ちます。任意の順序で読み取り、同じ操作履歴で比較できます。

ソースを読むとき: current の実装はアーキテクチャに依存します。カーネルスタックの切り捨て式や固定の task_struct オフセットを前提にしないでください。

2. 作成:関係を複製してもデータを即座にコピーするとは限らない

fork() は独立した実行の流れを持つ子を作ります。親には子の PID、子には 0 が返り、その後の実行順は保証されません。作成には識別情報、資源参照、スケジューリング実体、初期実行文脈の構築が必要です。ユーザーのプライベートメモリは通常コピーオンライトで即時のデータコピーを減らしますが、ページテーブルやメタデータの処理にもコストがあります。「task_struct を 1 つコピーするだけ」ではありません。

プライベートページ:最初は共有し、書き込みを分離する

コピーオンライトはコピーの遅延と分離を両立します。最初に親子が同じ物理ページを参照しているとします。親が自身のプライベートマッピングへ初めて書くと書き込み保護フォールトが発生し、カーネルはプライベート書き込みの意味を満たすページマッピングを構築します。子には元の内容が見えます。物理ページの共有は実装手段で、書き込みが相互に混入しないことが意味上の約束です。MAP_SHARED は別に考えます。ページテーブルとフォールトの詳細は仮想メモリへ進みます。

clone フラグで共有関係を選ぶ

clone() / clone3() のフラグには依存関係があります。CLONE_THREAD は CLONE_SIGHAND を要求し、後者は CLONE_VM を要求します。

作成方法アドレス空間とファイル表各タスクで独立
典型的な POSIX スレッドmm、ファイル表、シグナル処置を共有スタック、スレッド識別、実行文脈
通常の forkmm と表は別、下位の共有オブジェクトあり子の識別、スタック、実行文脈

namespace は通常継承されます。新設には対応フラグ、権限などの条件が必要です。

関数とスタックを受け取る glibc の clone() ラッパーと、アーキテクチャ依存の生システムコール ABI は区別します。kernel_clone()、copy_process() は実装を探す入口であり、アプリケーション向けインターフェースではありません。clone(2)

マルチスレッドの fork:解錠する人がいないロック

スレッド B がユーザー空間のロックを保持している間に A が fork すると、子には A だけが残りますが、ロック済みのメモリ状態は継承され得ます。子の A がそのロックを取得しようとすると、子にはいない B の解放を待ち続ける可能性があります。

fork 後:ロック状態は残るが、保持するスレッドはいない 親プロセス 子プロセス スレッド A スレッド B ロック済み A のみ ロック継続 fork 解錠する B がいない atfork などで修復しないユーザーロックの例。マルチスレッドの fork 後は速やかに exec へ。

このためマルチスレッドの fork から exec まで、子には非同期シグナル安全な関数だけを呼ぶなどの制約があります。準備を posix_spawn() に任せる方が管理しやすい場合があります。fork(2)

vfork はさらに厳しい制約があります。 子は一時的にアドレス空間を共有し、呼び出し元スレッドは停止しますが、親の他のスレッドは動き得ます。子で通常の C 処理を自由に実行できるわけではありません。vfork(2)

3. 実行と待機:状態はスケジューリング条件を表す

TASK_RUNNING は実行中と実行可能の両方を含みます。1 CPU にこの状態のタスクが 10 個あっても、通常同時に動くのは 1 つです。I/O、ロック、イベントを待つタスクはスリープすることがあります。起床は実行資格を戻しますが、実際に CPU を得る時刻はスケジューラが決めます。

観測値意味解釈の限界
R実行中または実行可能CPU 飽和や待ち時間はこれだけでは不明
S割り込み可能なスリープEINTR になるかは API と再開規則にも依存
D割り込み不能系の待機ディスク I/O 以外もある。カーネルの待機箇所を調べる
T / tジョブ制御停止 / トレース停止通常のスリープや終了とは異なる
Z終了済み、回収待ち実行を止め、回収に必要な情報を保持

内部状態はビットとプロトコルで構成され、終了は exit_state などでも管理されます。観測文字を単純な列挙値として代入するモデルではありません。TASK_KILLABLE は対応する待機を致命的シグナルで中断可能にしますが、「新しいカーネルでは D がすべて kill 可能」という意味ではありません。

待機では「条件確認後、スリープ登録前」にイベントが落ちる隙間をなくします。READ_ONCE() と schedule() だけでは不十分です。wait_event*() などを使い、条件に必要なロックやメモリ順序でデータを公開します。起床取りこぼしはコンテキストスイッチで具体的に扱います。

4. exec:識別を保ち、プログラムイメージを置換する

成功した execve() は別プロセスを作らず、元の呼び出し位置にも戻りません。同じプロセスの識別のもとで、新しいアドレス空間、ユーザースタック、入口状態を読み込みます。動的リンク ELF では通常インタプリタ/動的リンカから始まり、ランタイム初期化を経て main() に到達します。カーネルが main を直接呼ぶわけではありません。

対象exec 成功後アプリケーションへの影響
プライベートアドレス空間ヒープ、スタック、マッピングを置換旧ポインタを新プログラムのデータに使えない
他のスレッド元のグループの他スレッドを終了非リーダーが呼ぶ場合は TID も調整
ファイル記述子通常 FD_CLOEXEC のないものを保持準備済みの標準入出力を引き継げる
シグナル状態ハンドラはリセット、無視は通常保持、マスクは保持旧関数を新イメージのコールバックにできない
資格情報ファイル属性と実行制約に依存no_new_privs、マウント属性、トレースを考慮

シェルのリダイレクトは記述子の保持を利用します。 子がパイプ端を標準出力へ複製してから exec すると、新プログラムは作成手順を知らずに同じオープンファイルへ書き込めます。

図を準備しています
プログラムが変わっても同じプロセス?

exec は同じプロセス識別のもとでコード、データ、スタックを置換します。CLOEXEC のない標準出力は元のパイプ接続を保ちます。

通常 exec が戻ったら失敗として errno を処理します。ただし、復帰不能な段階を越えたまれな失敗では、旧イメージに戻らずプロセスが終了することがあります。execve(2)

5. exit と wait:終了しても zombie が残る理由

子の終了時に親は別の作業をしているかもしれません。結果まで即座に捨てると、親は成功、エラー終了、シグナルによる終了を後から知ることができません。そのため終了はアドレス空間やファイルへの参照を解放しつつ、終了結果と関連メタデータを適切な待機者のために保持します。

zombie はユーザーコードを実行しませんが、カーネルメモリを全く使わないわけではありません。また、親が同じオープンファイルを保持したり、別プロセスが共有メモリを使ったりするため、終了で全オブジェクトが破棄されるわけでもありません。多くのオブジェクトでは最後の参照の解放が寿命を決めます。

実行の終了と回収の完了は別の境界 子タスクを作成 識別を割り当て、資源を複製・共有して実行可能に 1 実行と待機 実行、スリープ、起床。exec はイメージを置換できる 2 終了して資源参照を解放 実行を停止し、終了状態と回収用の情報を保持 3 親が観察・回収 WNOWAIT は観察のみ。通常の wait が終了結果を消費 4 通常の待機可能な子を図示。自動回収、スレッドグループ、ptrace により経路は変わる。

ユーザー空間の後処理とカーネルの終了を分ける

exit() は終了ハンドラと標準 I/O のフラッシュを行い、_exit() はその処理を省きます。Linux の glibc の _exit() は通常 exit_group でスレッドグループを終了しますが、生の SYS_exit は呼び出しスレッドだけを終了します。他のスレッドまで消えた理由を調べる際は libc と生システムコールを区別します。

観察と回収を分ける

waitpid() や waitid() は終了結果を消費できます。waitid(..., WNOWAIT) は後の待機を可能にしたまま観察します。次の図では両者を分け、終了前に押してもブロックしないよう WNOHANG を指定しています。

図を準備しています
終了コードを読むと回収も終わる?

終了は結果を保持します。WNOWAIT は終了コードを取得しても消費せず、回収が待機可能な結果を消費します。親が取得した値は残ります。

明示的な SIGCHLD の SIG_IGN 設定や SA_NOCLDWAIT は自動回収の動作に影響します。親が先に終了すると、適用される subreaper または PID namespace の init が孤児を引き取ります。

SIGCHLD のデフォルト動作が「無視」であることと、明示的な SIG_IGN の回収動作は同じではありません。waitpid(2)

実行例:パイプでライフサイクルを追う

Linux の単一スレッドの例で fork、リダイレクト、exec、終了の観察、回収をつなぎます。lifecycle.c に保存し、cc -std=c11 -O2 -Wall -Wextra -Werror lifecycle.c -o lifecycle でコンパイルします。受信する PID は exec 後のプログラムから届きます。上の図と異なり最初の 2 回の待機は結果が得られるまでブロックします。終了コード 7 を観察し、次に消費し、最後に再待機できないことを確認します。

#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>

int main(int argc, char **argv) {
    (void)argv;
    if (argc == 2) {
        printf("%ld\n", (long)getpid());
        return 7;
    }
    struct sigaction sa = { .sa_handler = SIG_DFL };
    sigemptyset(&sa.sa_mask);
    assert(sigaction(SIGCHLD, &sa, NULL) == 0);
    int p[2];
    assert(pipe2(p, O_CLOEXEC) == 0);
    /* 標準入力・出力・エラーが既に開かれていることを前提とする。 */
    assert(p[0] > STDERR_FILENO && p[1] > STDERR_FILENO);
    pid_t child = fork();
    assert(child >= 0);
    if (child == 0) {
        close(p[0]);
        if (dup2(p[1], STDOUT_FILENO) < 0) _exit(126);
        close(p[1]);
        execl("/proc/self/exe", "lifecycle", "child", (char *)NULL);
        _exit(127);
    }
    close(p[1]);
    FILE *in = fdopen(p[0], "r");
    assert(in != NULL);
    long reported;
    assert(fscanf(in, "%ld", &reported) == 1);
    assert(reported == (long)child);
    assert(fclose(in) == 0);
    siginfo_t info = {0};
    assert(waitid(P_PID, child, &info, WEXITED | WNOWAIT) == 0);
    assert(info.si_code == CLD_EXITED && info.si_status == 7);
    int status;
    assert(waitpid(child, &status, 0) == child);
    assert(WIFEXITED(status) && WEXITSTATUS(status) == 7);
    errno = 0;
    assert(waitpid(child, &status, WNOHANG) == -1 && errno == ECHILD);
    puts("exec preserved PID; exit 7 observed; child reaped");
}

ここで dup2() が作る標準出力には CLOEXEC がありません。元のパイプ記述子には CLOEXEC があり、明示的にも閉じます。最終的な標準出力にまで CLOEXEC を付けると、新プログラムはそこから PID を報告できません。assert は実験の前提を表すため、NDEBUG を定義せずにコンパイルしてください。実サービスでは割り込み、資源不足、後処理にも通常のエラー処理が必要です。

6. カーネルスレッドと現場の調査

カーネルスレッドは通常 mm == NULL で、実行時に active_mm を借りることがあります。task、カーネルスタック、スケジューリングポリシーを持ち、ブロックもします。常に init_mm を使うという前提は置けません。

停止にはスレッドの協力が必要です。 kthread_stop() は停止を要求し、起床させ、終了を待ちます。関数側が kthread_should_stop() を確認し、待機経路も確認の機会を与える必要があります。永久に完了しない待機にはまっていれば、停止要求だけでは終了できません。

workqueue は管理されたワーカープールで処理し、投入ごとに専用スレッドを新設するわけではありません。モジュールやデバイスの終了時は、新規処理を止め、進行中の処理をキャンセルまたは待機し、最後にメモリを解放します。停止を要求しただけではデータ参照がなくなったと証明できません。

/proc/PID/status の Pid、Tgid、NSpid、Threads から調べ、/proc/PID/task/TID/ のスレッド表示へ進みます。/proc/PID/stat の括弧付き comm には空白が入り得るため、空白分割と固定インデックスでは正しく扱えません。R なら待ち時間、D なら待機対象、Z なら親の回収ロジックを調べ、記号から原因へ進みます。