このページの目次

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

カバー範囲: task_struct の主要フィールドと進化 → プロセス状態マシン → fork/exec/exit の完全パス → カーネルスレッド カーネルバージョン: 2.6 ~ 6.x、5.x 以降の変更点を重点的に注釈

概要

Linux では「すべてがプロセス」です。カーネルスレッドもプロセスであり、ユーザー空間スレッドもプロセスです。その違いは、clone() の際に何を共有するかという点だけです。この統一されたモデルが機能しているのは、task_struct という巨大な構造体が、スケジューリング、メモリ、ファイル、シグナルなどのサブシステムを結びつけているおかげです。

本稿では、task_struct の主要フィールドから始め、プロセスが fork() で誕生し exit() で消滅するまでの完全なライフサイクルを追跡し、各ステップでカーネルが何を行い、データ構造がどのように変化するかを解説します。


task_struct:プロセス記述子

include/linux/sched.h で定義されており、カーネル内で最も複雑な構造体の一つです(現在のメインラインでは約 700 行)。初回閲覧時にすべてのフィールドを暗記する必要はありません。⁠階層的な所属関係を理解すれば十分です。

flowchart LR
    subgraph TS["task_struct (プロセス記述子)"]
        ID["🆔 身元識別<br/>pid, tgid<br/>comm (プロセス名)"]
        SCHED["⏱️ 状態とスケジューリング<br/>__state, prio<br/>se (CFS) / rt / dl"]
        MM["🧠 メモリ管理<br/>mm / active_mm<br/>vmacache"]
        FS["📂 ファイルと IO<br/>files (fd テーブル)<br/>fs (root/pwd)"]
        REL["👥 親族関係<br/>parent / children<br/>sibling"]
        SIG["📡 シグナル<br/>signal (共有記述子)<br/>sighand (処理テーブル)"]
        STACK["📚 カーネルスタック<br/>stack → thread_info"]
        STAT["📊 統計と監査<br/>start_time, utime/stime<br/>sum_exec_runtime"]
    end

    classDef ts fill:#e3f2fd,stroke:#1565c0
    classDef cat fill:#fff3e0,stroke:#ef6c00
    class ID,SCHED,MM,FS,REL,SIG,STACK,STAT cat

なぜスレッドもプロセスなのか?

Linux は「プロセス」と「スレッド」を区別しません。両方とも task_struct です。違いは clone() のフラグにあります。

// fork(): 新規プロセス
clone(SIGCHLD, NULL);

// pthread_create(): アドレス空間を共有する「スレッド」
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
      CLONE_THREAD | CLONE_SYSVSEM, ...);

// vfork(): 子プロセスが exec/exit するまで親プロセスをブロック(COW による fork の普及によりほぼ置き換え済み)
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, NULL);

主要なフラグ:

フラグ効果
CLONE_VMmm_struct を共有(アドレス空間をコピーしない)
CLONE_FSfs_struct を共有(root/pwd が一致)
CLONE_FILESfiles_struct を共有(fd テーブルを共有)
CLONE_SIGHANDsighand_struct を共有(シグナル処理関数を共有)
CLONE_THREAD同一スレッドグループ(tgid が親プロセスと同じ)
CLONE_VFORK子プロセスが exec/exit するまで親プロセスをブロック

getpid() が返すのは tgid(スレッドグループ ID)であり、pid ではありません。同じプロセス内のすべてのスレッドは同じ tgid を持ちますが、それぞれが独立した pid を持っています。これが ps -eLf で同じ PID の下に複数の LWP が表示される理由です。

task_struct はどのようにして見つかるのか?

// 現在実行中のプロセスの task_struct (x86_64 で最速)
#define current get_current()

// 実装: カーネルスタックポインタの下位ビットマスクから thread_info を取得し、そこから task を取得
static __always_inline struct task_struct *get_current(void) {
    return this_cpu_read_stable(pcpu_hot.current_task);
}

5.x 以降、task_struct * は直接 per-CPU 変数 pcpu_hot.current_task に格納され、カーネルスタックポインタからの間接計算は行われなくなりました。


プロセス状態マシン

stateDiagram-v2
    [*] --> TASK_RUNNING : fork()
    TASK_RUNNING --> TASK_INTERRUPTIBLE : wait_event_*()
    TASK_RUNNING --> TASK_UNINTERRUPTIBLE : io_schedule()
    TASK_RUNNING --> TASK_STOPPED : SIGSTOP / ptrace
    TASK_RUNNING --> EXIT_ZOMBIE : do_exit()
    
    TASK_INTERRUPTIBLE --> TASK_RUNNING : シグナル / wake_up()
    TASK_UNINTERRUPTIBLE --> TASK_RUNNING : IO 完了 / wake_up()
    TASK_STOPPED --> TASK_RUNNING : SIGCONT
    EXIT_ZOMBIE --> EXIT_DEAD : 親プロセス wait()
    EXIT_DEAD --> [*] : RCU grace period

各状態の詳細

TASK_RUNNING (R 状態) — CPU 上で実行中、または runqueue でスケジューリングを待機中。ps/top で表示される R 状態のプロセスは、すべてが現在実行中であるとは限りません。CPU を待っている可能性があります。

cat /proc/<pid>/status | grep State  # または /proc/<pid>/stat の第3フィールドも参照可

TASK_INTERRUPTIBLE (S 状態) — 割込み可能なスリープ。待機状態の大多数はこの状態です:パイプのデータ待ち、ソケット待ち、セマフォ待ちなど。シグナルによってウェイクアップ可能(-EINTR を返す)。

TASK_UNINTERRUPTible (D 状態) — 割込み不可能なスリープ。典型的なシナリオはディスク IO の完了待ちです。この状態が存在する理由は、一部の IO パスがシグナルによって中断できないためです(例:ページキャッシュのライトバック時の lock_page())。D 状態が長引くと、top や負荷表示で「D state hang」と表示されます。

5.x 以降、TASK_KILLABLE が導入されました。これは NFS などの低速 IO 時に使用できる新しいスリープモードです。UNINTERRUPTIBLE との違いは、SIGKILL で終了できる点です。これにより、「NFS がダウンしてプロセスが D 状態で永遠に終了しない」という古典的な問題が解決されました。実装は wait_event_killable() マクロで行われ、内部で fatal_signal_pending() をチェックします。

TASK_STOPPED (T 状態)SIGSTOP/SIGTRAP/ptrace による一時停止。プロセスはプロセステーブル内に残っており、SIGCONT によって再開できます。

TASK_TRACED (t 状態) — ptrace によるデバッグ中。STOPED との違いは、シグナルの伝達と ptrace イベントの処理方法が異なる点です。

EXIT_ZOMBIE (Z 状態) — プロセスは死亡しましたが、task_struct はまだ残っており、親プロセスが wait() して終了コードを読み取るのを待機中。ゾンビはメモリを占有しません(mm は解放済み)が、PID と task_struct は占有します。

EXIT_DEAD — 親プロセスが wait() した後、task_struct は RCU grace period の終了後に実際に回收されます。これは、他の CPU がまだ rcu_dereference 参照を持っている可能性を防ぐためです。

状態の保存の進化

// 旧 (2.6 ~ 5.12): long state
task->state = TASK_UNINTERRUPTIBLE;

// 新 (5.14+): WRITE_ONCE/READ_ONCE を通じてアクセス
WRITE_ONCE(task->__state, TASK_UNINTERRUPTIBLE);

__state に変更された目的は、WRITE_ONCE/READ_ONCE マクロの使用を強制し、コンパイラによる書き込みの破断(tearing)や順序違反による状態の不整合を防ぐことです。


fork:プロセスの誕生

エントリポイントとパス

// glibc: fork() → clone(SIGCHLD, ...)
// カーネル (5.x 統一エントリ):
SYSCALL_DEFINE0(fork)kernel_clone(SIGCHLD, ...)
SYSCALL_DEFINE2(clone3, ...)kernel_clone(args->flags, ...)

kernel_clone() (kernel/fork.c) は統一された clone エントリポイントです(5.x でリファクタリングされ、以前は _do_fork() と呼ばれていました)。渡されるフラグによって、どのリソースを共有するかが決まります。

copy_process — 中核(約 1500 行)

copy_process(): fork 内部のコピーパイプライン (kernel/fork.c) 概ねこの順序で実行されますが、clone フラグによっては一部ステップがスキップされます ① dup_task_struct() · task_struct とカーネルスタックのコピー (vmap スタック) ② sched_fork() · スケジューリングエンティティ se/rt/dl の初期化 ③ copy_semundo() · SysV セマフォ undo ④ copy_files() · fd テーブルのコピー ⑤ copy_fs() · fs_struct のコピー ⑥ copy_sighand() · シグナル処理テーブルのコピー ⑦ copy_signal() · signal_struct のコピー ⑧ copy_mm() · COW マッピング、ページテーブルのみコピーし物理ページはコピーしない ⑨ copy_namespaces() · ネームスペースのコピー ⑩ copy_io() · IO コンテキストのコピー ⑪ copy_thread() · 子プロセスの戻りアドレスの設定 ⑫ sched_post_fork() · スケジューリング後の処理 ⑬ pid / tgid の書き込み 重要なのは ⑧ copy_mm() です。CLONE_VM でない限り、dup_mmap() はページテーブルのみをコピーし、読み取り専用としてマークします (COW)。 実際のデータのコピーは、書き込み時に発生するページフォールトに遅延されます。これが fork() が <1ms で完了できる理由です。

Copy-on-Write が fork を高速にする秘密

// kernel/fork.c: copy_page_range() → copy_p4d_range() → ... → copy_pte_range()
static inline int copy_present_pte(struct vm_area_struct *dst_vma, ...) {
    // 1. 両プロセスの PTE が同じ物理ページを指す
    // 2. 両方の PTE が読み取り専用としてマークされる(元々書き込み可能でも)
    // 3. 物理ページの refcount++
    
    if (is_cow_mapping(vma->vm_flags)) {
        ptep_set_wrprotect(src_mm, addr, src_pte);  // 親プロセスの PTE → 読み取り専用
        set_pte_at(dst_mm, addr, dst_pte, entry);   // 子プロセスの PTE → 読み取り専用
        atomic_inc(&page->_refcount);                 // 物理ページ +1 引用
    }
}

後続のページフォールト時に初めて実際にコピーが行われます:

親または子のいずれかが書き込み → ページフォールト → do_wp_page()
  → wp_page_copy() → alloc_page() → copy_user_highpage() → 各自が書き込み可能に

これが fork() が <1ms で完了でき、実際のメモリオーバーヘッドが書き込み時にのみ発生する理由です。

vfork の微妙な点

vfork() は、元々 fork がページテーブルをコピーするオーバーヘッドを避けるために存在しました(COW の登場以前)。現在は COW によってほぼ置き換えられていますが、posix_spawn() パスで使用され続けています。親プロセスは CLONE_VFORK によってブロックされ、子プロセスが exec するまで待機します。これにより、無意味な COW フォールトを回避します。カーネルは completion メカニズムを使用して、親プロセスのブロック待機を実現しています。


exec:生まれ変わり

// fs/exec.c
SYSCALL_DEFINE3(execve, ...)do_execve()do_execveat_common()exec_binprm()

exec は新しいプロセスを作成せず、現在実行中のプロセスのアドレス空間全体を置き換えます。PID は変わりませんが、実行中のコード、データ、スタックはすべて新しくなります。

実行フロー

exec_binprm(): exec による生まれ変わりの 6 つのフェーズ (fs/exec.c) 順序: 左列 ①→③ を読み、次に右列 ④→⑥ を読む ① prepare_binprm() ELF ヘッダ (先頭 256B) を読み込み → フォーマットを識別: ELF / #! / a.out / misc ② binary handler を検索 search_binary_handler() が formats リストを走査 load_elf_binary / load_script(#!) / load_misc_binary ③ flush_old_exec() de_thread() でスレッドグループを脱退 → flush_old_files() で O_CLOEXEC fd を閉じる → exec_mmap() で古い mm を解放し、新しい mm を作成 ④ setup_new_exec() __set_task_comm() で新しいプロセス名を設定 (15 文字に切り捨て) setup_arg_pages() でスタックを配置: argv + envp + auxv ⑤ load_elf_binary() elf_map()→vm_mmap() で LOAD セクションをマッピング;set_brk() でヒープ開始を設定 load_elf_interp() で ld.so をロード;start_thread() で IP を entry に設定 ⑥ ユーザー空間へ復帰 entry point (または ld.so) から実行開始 exec は新しいプロセスを作成せず、現在実行中のプロセスのアドレス空間全体を置き換えます——PID は変わりませんが、コード、データ、スタックはすべて新しくなります。

setuid/setgid と資格情報 (credential)

ELF ファイルに suid ビットが設定されている場合、exec は資格情報も置き換えます。

// fs/exec.c
if (bprm->cred->uid != bprm->file->f_owner.uid) {
    // setuid バイナリ: euid/egid などを置き換え
    bprm->cred->euid = bprm->file->f_owner.uid;
}

これはカーネル内の権限モデルの中核であり、prepare_binprm() で完了します。


exit:プロセスの消滅

do_exit パス

// kernel/exit.c
void __noreturn do_exit(long code) {
    // 1. フラグの設定
    tsk->flags |= PF_EXITING;
    
    // 2. リソースの解放
    exit_mm(tsk);        // mm_struct の解放(他のスレッドが共有していない場合)
    exit_sem(tsk);       // SysV セマフォ undo
    exit_shm(tsk);       // SysV システム共有メモリ
    exit_files(tsk);     // ファイルのクローズ(共有されていない場合)
    exit_fs(tsk);        // fs_struct の解放
    exit_task_namespaces(tsk);
    exit_rcu(tsk);
    
    // 3. 通知
    exit_notify(tsk, group_dead);
    //    ├─ 子プロセスの新しい親プロセスの検索 (reparent)
    //    └─ 親プロセスへの SIGCHLD 送信
    
    // 4. 最後のスケジューリング — 決して戻らない
    do_task_dead();
}

ゾンビプロセスの詳細

ゾンビプロセスの回収チェーン: do_exit() から実際の解放までの 3 つの段階 EXIT_ZOMBIE (do_exit() 後) · task_struct はまだ存在 · mm/files は解放されている可能性あり (他のスレッドが共有していない場合) 親プロセス wait() 親プロセス wait() → release_task() · __exit_signal() でシグナルをクリーンアップ · __unhash_process() でハッシュから削除 PID ハッシュ grace period RCU grace period 後 実際に回収 · put_task_struct() で遅延解放 · delayed_put_task_struct() · free_task() → kmem_cache_free() ゾンビはメモリを占有しません (mm は解放済み)。PID と task_struct のみを占有します。親プロセスが wait() を行わない場合、 親プロセスを kill して init (PID 1) に養子縁組させ、自動的に回収させるしかありません。これが PID 1 が循環 wait() を行う理由です。

孤児プロセスの養子縁組⁠:親プロセスが子プロセスよりも先に終了した場合、exit_notify()find_new_reaper() を呼び出して、子プロセスを以下に引き渡します。

  • 同一スレッドグループ内の他のスレッド
  • 同一 PID ネームスペースの init (PID 1)

これが、PID 1 がゾンビを溜め込まない理由です。init プロセスは積極的に循環 wait() を行います。

ゾンビプロセスのデバッグ

# ゾンビプロセスの検索
ps aux | awk '$8 == "Z" { print $2, $11 }'

# ゾンビが占有するリソース
cat /proc/<zombie_pid>/status  # State: Z が確認できますが、ほとんどのフィールドは 0 です

# ゾンビの親プロセス
cat /proc/<zombie_pid>/stat | awk '{print "PPID:", $4}'

# 親プロセスが wait() を行わない場合、親プロセスを kill する必要があります
# その後、init がゾンビを養子縁組し、自動的に回収します

カーネルスレッド

カーネルスレッドは、ユーザー空間を持たない特殊なプロセスです(mm == NULL)。カーネルモードで実行され、ユーザープロセスとは異なる方法で作成および管理されます。

// include/linux/kthread.h
struct task_struct *kthread_create(int (*threadfn)(void *data), ...);
void kthread_bind(struct task_struct *k, unsigned int cpu);
int kthread_stop(struct task_struct *k);
bool kthread_should_stop(void);

// 簡易版: 作成 + 起動を一度に行う
#define kthread_run(threadfn, data, namefmt, ...)

kthreadd (PID 2)

すべてのカーネルスレッドの親プロセスです。起動パス:

start_kernel() → rest_init() → kernel_thread(kernel_init, ...)
                                    └─ kernel_thread(kthreadd, ...)

kthreaddkthread_create_list を循環処理します。リクエストを取り出し → create_kthread() を呼び出し → kernel_thread() で新しいスレッドを作成します。

メモリとスケジューリングの特徴

// カーネルスレッドは独自の mm を持たず、直前のプロセスの active_mm を借用する
task->mm = NULL;
task->active_mm = &init_mm;  // グローバルカーネルアドレス空間への参照

// コンテキストスイッチ時:
// if (prev->mm == NULL)  // 直前のプロセスがカーネルスレッドの場合
//     enter_lazy_tlb(prev->active_mm, next);
//     ページテーブルは切り替えられない(カーネルアドレス空間は共有されているため)

これにより、カーネルスレッドのコンテキストスイッチが極めて高速になる理由が説明できます。TLB のフラッシュは不要です(x86: CR3 を書き込まない)。

一般的なカーネルスレッド

スレッド役割
ksoftirqd/<N>softirq の処理(ネットワーク受信、タイマーなど)
kworker/<N>:<name>workqueue ワーカー
migration/<N>スケジューラの負荷分散
rcu_gp / rcu_par_gpRCU grace period の管理
jbd2/<dev>-<N>ext4 ジャーナルのコミット
kcompactd<N>メモリ断片化の解消
# すべてのカーネルスレッドを表示
ps -eo pid,comm,state | grep -E '\[.*\]' | head -20

# kthreadd の子プロセスツリー
pstree -p 2

主要な /proc インターフェース

# /proc/<pid>/stat — プロセス状態(純粋な数字、高速解析用)
cat /proc/1/stat
# フィールド: pid comm state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime ...

# /proc/<pid>/status — 人間が読みやすいバージョン
cat /proc/self/status
# 含まれるもの: Name, State, Tgid, Pid, PPid, Uid, VmSize, VmRSS, Threads, voluntary_ctxt_switches, nonvoluntary_ctxt_switches

# /proc/<pid>/task/<tid>/ — スレッドディレクトリ
ls /proc/self/task/  # 各スレッドに 1 つのディレクトリ

# /proc/sys/kernel/pid_max — PID の上限(デフォルト 32768、4194304 まで調整可能)
cat /proc/sys/kernel/pid_max

参考文献と拡張

  • カーネルドキュメント⁠: Documentation/filesystems/proc.rst, Documentation/scheduler/sched-design-CFS.rst
  • LWN 深度記事⁠:
    • 「The task_struct and process creation」シリーズ
    • 「clone(), fork(), and vfork()」(lwn.net/Articles/176929/)
    • 「TASK_KILLABLE」(lwn.net/Articles/288056/)
  • ソースファイル⁠:
    • include/linux/sched.h — task_struct の定義
    • kernel/fork.c — copy_process(), kernel_clone()
    • fs/exec.c — do_execve(), exec_binprm(), load_elf_binary()
    • kernel/exit.c — do_exit(), release_task()
    • kernel/kthread.c — kthreadd の管理

キーワード: task_struct, fork, COW, exec, zombie, kernel_clone, TASK_KILLABLE, カーネルスレッド, kthreadd