28 - 进程与线程

进程是操作系统中最重要的抽象之一——它是”正在运行的程序”。从内核的 task_struct 到用户空间的 fork(),本章将进程与线程的经典理论映射到 Linux 的实际实现中,让每一个概念都有对应的观测命令和代码。


28.1 进程概念

进程 = 程序 + 执行上下文

程序 (Program) 进程 (Process)
─────────────── ──────────────
静态文件(磁盘上的 ELF) → 动态实体(内存中运行)
一个程序 多个进程(同一程序可多次运行)
被动 主动(拥有 CPU 时间的实体)

进程的完整上下文包括:

# 查看进程的完整地址空间布局
cat /proc/$$/maps | head -30
# 555555554000-555555555000 r--p text(代码段,只读)
# 555555555000-5555555a0000 r-xp text(代码段,可执行)
# 5555555a0000-5555555c4000 r--p rodata(只读数据)
# 5555555c4000-5555555c8000 rw-p 数据段(已初始化全局变量)
# 5555555c8000-5555555d1000 rw-p BSS 段(未初始化全局变量)
# 7ffff7c00000-7ffff7c28000 r--p libc.so 映射
# 7ffffffde000-7ffffffff000 rw-p 栈(局部变量、返回地址)
 
# 堆空间的增长方向
cat /proc/$$/maps | grep heap
# 01742000-01763000 rw-p 00000000 00:00 0 [heap]

进程地址空间的经典布局

graph TD
 subgraph "进程地址空间 (32位示意)"
 STACK["栈 (Stack)<br/>↘ 向下增长<br/>局部变量、返回地址"]
 GAP["⫶ 空间间隙 ⫶"]
 MMAP["内存映射区 (mmap)<br/>共享库、文件映射"]
 GAP2["⫶ 空间间隙 ⫶"]
 HEAP["堆 (Heap)<br/>↗ 向上增长<br/>malloc 分配"]
 BSS["BSS 段<br/>未初始化全局变量"]
 DATA["数据段 (.data)<br/>已初始化全局变量"]
 TEXT["代码段 (.text)<br/>机器指令(只读)"]
 end
 style STACK fill:#ffcdd2,stroke:#333
 style HEAP fill:#c8e6c9,stroke:#333
 style TEXT fill:#e1f5fe,stroke:#333

28.2 进程状态

五种基本状态

stateDiagram-v2
 [*] --> Ready: 创建完毕
 Ready --> Running: 调度器选择
 Running --> Ready: 时间片耗尽
 Running --> Blocked: 等待 I/O / 事件
 Blocked --> Ready: I/O 完成 / 事件到达
 Running --> Terminated: 进程结束
 Terminated --> [*]

Linux 中的进程状态码

状态码名称说明如何产生
RRunning/Runnable正在运行或在运行队列中等待CPU 密集型任务
SSleeping (Interruptible)可中断睡眠,等待事件sleep(), read() 等待数据
DDisk Sleep (Uninterruptible)不可中断睡眠(通常在等待 I/O)write() 同步到磁盘
TStopped被信号暂停Ctrl+Z (SIGSTOP)
tTracing Stop被 ptrace 暂停(调试器)gdb 附加
ZZombie已退出但未被父进程回收子进程 exit 后父进程未 wait
XDead正在被回收(瞬间状态,很难观测到)父进程调用 wait
# 查看所有进程的状态
ps aux | awk '{print $8, $11}' | sort | uniq -c | sort -rn
 
# 查看特定进程的详细状态
cat /proc/$$/status | grep State
# State: S (sleeping)
 
# 统计各状态进程数量
ps -eo stat | sed 's/^\(.\).*/\1/' | sort | uniq -c | sort -rn

状态转换实战

# 观察 D 状态(需要 root 权限和恰好进行的 I/O)
# 在一个终端执行大量同步写
dd if=/dev/zero of=/tmp/testfile bs=1M count=1000 oflag=sync &
# 在另一个终端查看
ps aux | grep ' D' # 看到 D 状态
 
# 观察 Z 状态(僵尸进程)
bash -c '
( sleep 1 & ) # 创建子进程
sleep 3 # 子进程在 1 秒后退出,父进程存活
# 在这 2 秒窗口内观察僵尸
' &
sleep 1.5; ps aux | grep ' Z'

28.3 进程控制块(PCB)与 task_struct

task_struct:Linux 的 PCB 实现

Linux 中每个进程在内核中由一个 task_struct 结构表示(定义于 include/linux/sched.h),其大小约 1.7KB(随版本变化):

// task_struct 的核心字段(简化版)
struct task_struct {
 pid_t pid; // 进程 ID
 pid_t tgid; // 线程组 ID(主线程的 PID)
 volatile long state; // 当前状态(-1=不可运行, 0=可运行, >0=停止)
 struct mm_struct *mm; // 内存描述符(地址空间)
 struct files_struct *files; // 打开的文件表
 struct signal_struct *signal; // 信号处理
 struct list_head children; // 子进程链表
 struct list_head sibling; // 兄弟进程链表
 u64 start_time; // 进程启动时间(纳秒)
 // ... 还有大量字段
};

通过 /proc 观察 task_struct

# /proc/PID/ 就是该进程的 task_struct 的用户空间视图
ls -la /proc/$$/ | head -20
 
# PID 与 TGID 的观察(线程场景)
cat /proc/$$/status | grep -E "^Pid|^Tgid|^PPid"
# Pid: 12345 当前轻量级进程 ID
# PPid: 12344 父进程 ID
# Tgid: 12345 线程组 ID
 
# 进程的命名空间
ls -la /proc/$$/ns/
# cgroup ipc mnt net pid pid_for_children time time_for_children user uts
 
# 打开的文件描述符
ls -la /proc/$$/fd/ | head -20
# 0 -> /dev/pts/1 (stdin)
# 1 -> /dev/pts/1 (stdout)
# 2 -> /dev/pts/1 (stderr)

28.4 进程创建:fork、vfork、clone

fork() 的原理

sequenceDiagram
 participant P as 父进程
 participant K as 内核
 participant C as 子进程

 P->>K: fork()
 K->>K: 1. 分配新 PID
 K->>K: 2. 复制 task_struct
 K->>K: 3. 复制 mm_struct(页表)
 K->>K: 4. 复制 files_struct(文件表)
 K->>K: 5. 标记页为 COW(写时复制)
 K-->>P: 返回子进程 PID (>0)
 K-->>C: 返回 0
 Note over C: 子进程从 fork() 后继续执行
// fork 基础示例
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
 
int main() {
 pid_t pid = fork();
 
 if (pid < 0) {
 perror("fork failed");
 return 1;
 } else if (pid == 0) {
 // 子进程:fork 返回 0
 printf("Child: PID=%d, PPID=%d\n", getpid(), getppid());
 _exit(0);
 } else {
 // 父进程:fork 返回子进程 PID
 printf("Parent: PID=%d, Child PID=%d\n", getpid(), pid);
 wait(NULL); // 等待子进程结束
 }
 return 0;
}

clone():Linux 的真正创建原语

// fork、vfork 和 pthread_create 最终都调用 clone()
// clone 的不同 flag 组合产生了进程或线程
 
// clone() 的调用等价关系:
// fork() ≈ clone(SIGCHLD, 0);
// vfork() ≈ clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
// pthread_create ≈ clone(CLONE_VM | CLONE_FS | CLONE_FILES | 
// CLONE_SIGHAND | CLONE_THREAD, 0);
系统调用控制权地址空间文件表信号处理
fork()父子并发执行COW 写时复制复制复制
vfork()父进程阻塞直到子进程 exec/exit共享(慎用)共享共享
clone()取决于 flags取决于 flags取决于 flags取决于 flags
# 使用 strace 观察 clone 调用
strace -e clone bash -c 'ls' 2>&1 | head -5
 
# 观察创建进程时内核的详细行为
sudo bpftrace -e 'kprobe:copy_process { printf("new PID=%d\n", ((struct task_struct *)retval)->pid); }'

fork 的性能影响:写时复制(COW)

# 实验:验证 COW 效果
# 编译并运行以下 C 程序
cat > /tmp/cow_test.c << 'EOF'
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <string.h>
 
#define SIZE (100 * 1024 * 1024) // 100MB
 
int main() {
 char *buf = malloc(SIZE);
 memset(buf, 0, SIZE);
 
 printf("Before fork, check /proc/%d/status VmRSS\n", getpid());
 getchar(); // 暂停,方便查看内存
 
 pid_t pid = fork();
 if (pid == 0) {
 printf("Child: PID=%d, check VmRSS now (should be low)\n", getpid());
 getchar(); // COW 阶段,内存未真正复制
 
 // 修改内存,触发实际复制
 for (int i = 0; i < SIZE; i += 4096)
 buf[i] = 1;
 
 printf("Child: after writing, VmRSS should be ~100MB higher\n");
 getchar();
 _exit(0);
 } else {
 wait(NULL);
 }
 return 0;
}
EOF
gcc -o /tmp/cow_test /tmp/cow_test.c
# 运行并在另一个终端用 top/htop 观察 VmRSS 变化

28.5 进程终止与等待

exit、_exit 与返回的区别

函数行为典型使用
exit(status)C 库函数:调用 atexit 注册的函数,刷新 stdio 缓冲,调用 _exit正常退出
_exit(status)系统调用:直接终止,不做任何清理fork 出的子进程退出
return n在 main 中等价于 exit(n)main 函数返回
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
 
int main() {
 printf("Hello"); // 无换行,在 stdout 缓冲区
 // _exit(0); // 直接退出,"Hello" 不会输出!
 exit(0); // exit 会刷新缓冲区,"Hello" 会输出
 return 0;
}

wait / waitpid 详解

#include <sys/wait.h>
 
// waitpid 的宏,用于解析子进程退出状态
int status;
pid_t child = waitpid(-1, &status, 0);
 
if (WIFEXITED(status)) {
 // 正常退出
 printf("exit code: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
 // 被信号杀死
 printf("killed by signal: %d\n", WTERMSIG(status));
 printf("core dumped: %d\n", WCOREDUMP(status));
} else if (WIFSTOPPED(status)) {
 printf("stopped by signal: %d\n", WSTOPSIG(status));
}
# 命令层面查看退出状态
false; echo "exit code: $?" # 1
true; echo "exit code: $?" # 0
 
# 查看信号值
kill -l
# 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
# 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
# ...

28.6 exec 家族:换壳运行

exec 函数族对比

// 六个 exec 变体的区别在于参数传递方式
#include <unistd.h>
 
// 列表形式 (l): 参数逐个列出,以 NULL 结尾
int execl(const char *path, const char *arg0, ..., (char *)NULL);
int execlp(const char *file, const char *arg0, ..., (char *)NULL);
 
// 数组形式 (v): 参数放在数组中
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
 
// 环境变量形式 (e): 显式指定环境变量
int execle(const char *path, const char *arg0, ..., (char *)NULL, char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[]);
前缀含义路径解析
execve底层系统调用(唯一真正的系统调用)基于给定路径
execv参数为数组基于给定路径
execvp参数为数组在 PATH 中搜索
execl参数为列表(可变参数)基于给定路径
execlp参数为列表在 PATH 中搜索
execle参数为列表 + 环境变量基于给定路径
# strace 观察:ls 命令实际调用的是 execve
strace -e execve ls /tmp 2>&1 | head -3
# execve("/usr/bin/ls", ["ls", "/tmp"], 0x7fff... /* 72 vars */)
 
# /proc/PID/exe 是一个符号链接,指向进程的可执行文件
ls -la /proc/$$/exe
# lrwxrwxrwx 1 root root 0 ... /proc/12345/exe -> /usr/bin/bash
 
# /proc/PID/cmdline:启动时的完整命令行
cat /proc/$$/cmdline | tr '\0' ' '

28.7 僵尸进程与孤儿进程

僵尸进程(Zombie)

当子进程退出时,内核不会立即释放它的 task_struct,而是保留退出状态供父进程通过 wait() 获取:

# 制造一个僵尸进程(1 秒后父进程 wait 之前)
(sleep 0.1 &) && sleep 2 &
PID=$!
sleep 1
ps -o pid,ppid,stat,comm -p $(pgrep -P $PID sleep)
# STAT = Z 或 Z+,证明僵尸存在
 
# 查找系统上所有僵尸进程
ps aux | awk '$8 ~ /Z/ {print}'
 
# 查看僵尸进程的退出状态(/proc/ZOMBIE_PID/status)
# State: Z (zombie) 即使 zombie,/proc 目录依然可用

孤儿进程与 init 收养

当父进程先于子进程退出时,子进程变成孤儿,由 init(PID 1,现在通常是 systemd)收养:

# 制造孤儿进程
bash -c '
bash -c "sleep 5; echo orphan says hi; ps -o pid,ppid,stat,comm -p \$\$" &
echo parent exiting...
' 
# 父进程 (外层 bash) 立即退出,内层子进程被 systemd(PID 1) 收养
 
# 验证:孤儿进程的 PPID 变为 1
ps -eo pid,ppid,stat,comm | awk '$3 == 1 {print}'
sequenceDiagram
 participant P as 父进程
 participant C as 子进程
 participant I as init (PID 1)

 P->>C: fork()
 C-->>C: 正在执行...
 P-->>P: 父进程退出
 Note over I: 内核检查<br/>子进程的父进程已死
 I->>C: 将 PPID 改为 1(收养)
 Note over C: "孤儿被 1 号收养"
 C->>C: 执行完毕
 C-->>I: 子进程成为僵尸
 I->>C: wait() 回收
 Note over C: 完全释放

28.8 线程:轻量级进程

进程 vs 线程

维度进程线程
地址空间独立(通过 MMU 隔离)共享(同一进程内的线程共享地址空间)
创建开销高(复制页表和文件表)低(仅复制少量结构)
上下文切换需要切换 MMU 上下文(刷新 TLB)不需要切换地址空间
通信方式IPC:管道、共享内存、消息队列、信号直接读写共享内存
隔离性强(一个进程崩溃不影响其他)弱(一个线程的野指针可破坏整个进程)
在 Linux 中通过 fork/clone 创建通过 clone(CLONE_VM

POSIX 线程(Pthread)基础

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
 
void *worker(void *arg) {
 int id = *(int *)arg;
 printf("Thread %d: PID=%d, TID=%lu\n", id, getpid(), pthread_self());
 sleep(1);
 return (void *)(long)(id * 10);
}
 
int main() {
 pthread_t threads[3];
 int ids[] = {1, 2, 3};
 void *retvals[3];
 
 for (int i = 0; i < 3; i++)
 pthread_create(&threads[i], NULL, worker, &ids[i]);
 
 for (int i = 0; i < 3; i++) {
 pthread_join(threads[i], &retvals[i]);
 printf("Thread %d returned: %ld\n", i, (long)retvals[i]);
 }
 return 0;
}
# 编译
gcc -pthread -o /tmp/thread_test thread_test.c
/tmp/thread_test
 
# 运行时用 htop 观察(F2 → Display Options → Show custom thread names)
# 或使用 ps 查看线程关系
ps -eLf | grep thread_test
# PID 相同,LWP(轻量级进程 ID)不同,NLWP(线程数)> 1

NPTL:Linux 的线程实现

Linux 历史上经历过 LinuxThreads 到 NPTL(Native POSIX Thread Library)的演进:

# 查看当前线程库(现代 Linux 使用 NPTL through glibc)
getconf GNU_LIBPTHREAD_VERSION
# NPTL 2.39
 
# 查看线程相关限制
ulimit -u # 用户最大进程/线程数
cat /proc/sys/kernel/threads-max # 系统级最大线程数
# 查看线程的信息
cat /proc/$$/status | grep Threads
# Threads: 1
 
# 多线程程序:每个线程都是一个 task_struct
# 它们共享 mm_struct,但拥有各自的栈(由 glibc 分配)
cat /proc/$$/maps | grep stack
# 7ffff7ffe000-7ffff7fff000 ---p (guard page,防止栈溢出)
# 7ffff7ffe000-7ffff8000000 rw-p (线程栈)

线程模型对比

模型描述优缺点
用户级线程 (1:1 之前的方案)线程在用户空间调度,内核不可见快但一个线程阻塞整个进程都阻塞
内核级线程 (1:1)每个用户线程对应一个内核线程NPTL 采用此方案,真正的并发
混合模型 (M:N)M 个用户线程映射到 N 个内核线程复杂,Go 的 goroutine 采用类似思路

28.9 /proc 文件系统深度探索

进程信息宝库

# 关键文件一览
PID=$$ # 当前 shell 的 PID
 
cat /proc/$PID/status # 进程状态总览
cat /proc/$PID/stat # 进程状态(机器可读)
cat /proc/$PID/statm # 内存使用(页为单位)
cat /proc/$PID/limits # 资源限制
cat /proc/$PID/environ # 环境变量(\0 分隔)
cat /proc/$PID/io # I/O 统计(需要 root)
cat /proc/$PID/fd/ # 打开的文件描述符
cat /proc/$PID/maps # 内存映射
cat /proc/$PID/smaps # 详细内存映射(含 RSS、PSS)
cat /proc/$PID/sched # 调度信息
cat /proc/$PID/stack # 内核栈回溯
cat /proc/$PID/mountinfo # 挂载信息
cat /proc/$PID/oom_score # OOM killer 评分
cat /proc/$PID/oom_score_adj # OOM 评分调整
 
# stat 的输出字段详解
cat /proc/$PID/stat | awk '{
 print "PID:", $1
 print "Comm:", $2
 print "State:", $3
 print "PPID:", $4
 print "PGRP:", $5
 print "Session:", $6
 print "utime:", $14 # 用户态时间
 print "stime:", $15 # 内核态时间
 print "starttime:", $22 # 启动时间
 print "vsize:", $23 # 虚拟内存大小
 print "rss:", $24 # 常驻内存(页)
}'

进程观察命令大全

# ps 的高级用法
ps -eo pid,ppid,pgid,sid,stat,pri,ni,wchan:20,cmd --sort=-%mem | head -10
# pri = 动态优先级 (越低越优先)
# ni = nice 值
# wchan = 等待通道(进程在等什么)
 
# pstree:进程树
pstree -a -p -s $$ # 显示当前 shell 的进程祖先
pstree -a $$ # 显示当前 shell 的进程后代
 
# pgrep:按名称查找 PID
pgrep -a bash # 所有 bash 进程
pgrep -u root -l # root 用户的进程
 
# htop(更友好)
# F2 进入设置 → Display options → Show custom thread names
# F6 按列排序
# F9 向进程发送信号

28.10 实际场景:多进程与多线程的选择

决策框架

graph TD
 A["需要并发吗?"] -->|是| B["任务间需要频繁<br/>共享大量数据吗?"]
 B -->|是| C["使用多线程<br/>(共享地址空间)"]
 B -->|否| D["对稳定性要求<br/>极高吗?"]
 D -->|是| E["使用多进程<br/>(进程隔离,一个崩了不影响)"]
 D -->|否| F["都可以<br/>线程更轻量,进程更安全"]
 
 style C fill:#c8e6c9,stroke:#333
 style E fill:#ffcdd2,stroke:#333
 style F fill:#fff9c4,stroke:#333

IPC 性能对比

方法延迟(相对)吞吐量复杂度适用场景
共享内存最低最高高(需同步)大数据量、高频通信
Unix Domain Socket本地 CS 模式通信
管道 (pipe)父子进程简单通信
消息队列 (POSIX)结构化消息异步处理
TCP 环回跨网络通信

经验法则:在不需强隔离保护的场景下,多线程因共享地址空间而通信高效;在多服务解耦架构(如微服务)下,多进程虽然通信成本更高,但提供了更强的故障隔离。Linux 的进程创建(fork+COW)已经很高效,使得多进程在服务器端依然常见。


相关链接