一、进程是什么:不只是 "一个程序 " “进程是程序的一次执行”这个定义过于抽象,难以触
“进程是程序的一次执行”这个定义过于抽象,难以触及内核实现的本质。
免费影视、动漫、音乐、游戏、小说资源长期稳定更新! 👉 点此立即查看 👈
从操作系统内核的视角看,进程是一个名为 task_struct 的数据结构体。它如同进程在内核中的“身份证”和“资源清单”,完整记录了该执行实体的所有状态与资源。这个结构体具体包含哪些关键信息?
┌─ task_struct ─────────────────┐
│ pid = 1234 │
│ mm → 虚拟内存空间 │
│ files → 文件描述符表 │
│ signals → 信号处理 │
│ regs → CPU寄存器状态 │
│ sched → 调度信息 │
└───────────────────────────────┘
因此,一个更精确的技术定义是:进程 = task_struct + 独立的虚拟地址空间。这两者共同构成了一个可被操作系统调度和管理的完整执行实体。
创建新进程最经典的系统调用是 fork()。其核心行为是复制当前进程(父进程),生成一个几乎完全相同的子进程。
pid_t pid = fork();
if (pid == 0) {
// 子进程:pid == 0
printf("我是子进程,PID=%d\n", getpid());
} else {
// 父进程:pid == 子进程的PID
printf("我是父进程,子进程PID=%d\n", pid);
}
fork() 的设计非常巧妙:它在父子进程中各返回一次。父进程获得子进程的PID,子进程则得到0。通过判断返回值,两者可以轻松执行不同的代码路径。
这里引出一个关键的性能问题:如果父进程占用2GB内存,fork() 是否要立即复制2GB数据?对于Nginx这类需要创建大量worker进程的服务,内存开销岂不巨大?
答案是否定的。这依赖于Linux内核的核心优化技术:写时复制(Copy-On-Write,COW)。
fork() 调用后,父子进程共享相同的物理内存页,内核仅将这些页的页表项标记为“只读”。当任一进程尝试写入共享页时,会触发缺页异常,内核此时才真正复制该页给写入进程。这就是“按需复制”。

COW机制极大地优化了常见场景。例如Shell执行命令:fork() 出子进程后,子进程通常会立即调用 exec() 加载新程序(如 ls)。既然要替换整个地址空间,子进程就无需复制父进程的任何数据页。fork() 的主要开销仅是复制父进程的页表,代价远低于复制全部内存。
fork() 复制了父进程,但子进程通常需要执行全新程序。这个“变身”操作由 exec() 函数族完成。
exec() 调用会重置进程的虚拟地址空间——原有的代码段、数据段、堆栈被清空,新程序的代码和数据被加载进来。进程PID保持不变,已打开的文件描述符(除非设置了 O_CLOEXEC 标志)也会被继承。
fork() + exec() 是Shell执行命令的标准模式,其完整流程如下:

这解释了为何在bash中输入 ls 会执行 /bin/ls。Bash先 fork() 出一个自身副本(子进程),子进程再调用 exec() 将自己“替换”为 ls 程序。ls 执行完毕退出后,父进程bash继续运行,等待下一条命令。
用代码简化表示此过程:
pid_t pid = fork();
if (pid == 0) {
// 子进程:替换成 ls
execv("/bin/ls", argv);
// exec 成功不会返回到这里
} else {
// 父进程:等子进程结束
waitpid(pid, NULL, 0);
}
“进程和线程的区别”是经典的面试题。
核心区别在于资源共享的范围:线程是进程内部的执行单元,同一进程下的所有线程共享该进程的虚拟地址空间。
下图清晰地展示了进程与线程在资源上的共享边界:

如图所示:进程之间完全隔离,各自拥有独立的代码、堆、栈和文件描述符表。而同一进程内的多个线程,则共享代码段、堆、全局变量和文件描述符表,每个线程私有的只有自己的栈、寄存器和线程ID。
由于共享内存,线程间通信变得非常简单——直接读写同一块内存即可,无需借助管道、消息队列等进程间通信(IPC)机制。但这也带来了风险:一个线程若写坏了堆数据,会影响同一进程内的所有线程。
这一点可能令人意外:在Linux内核中,并没有独立于进程的“线程”概念。 线程,本质上是共享了特定资源的进程。
Linux创建线程和创建进程,使用同一个底层系统调用——clone()。区别仅在于传入的标志位(flags)不同:
// 创建进程:fork() 内部调用 clone,大部分资源不共享
clone(fn, stack, SIGCHLD, arg);
// 创建线程:pthread_create() 内部调用 clone,共享地址空间等
clone(fn, stack,
CLONE_VM | // 共享虚拟内存
CLONE_FS | // 共享文件系统信息
CLONE_FILES | // 共享文件描述符表
CLONE_SIGHAND | // 共享信号处理器
CLONE_THREAD, // 同一线程组
arg);
其中,CLONE_VM 标志是关键。设置此标志,父子“进程”将共享同一个 mm_struct(虚拟内存描述符),即共享整个地址空间——这就是我们通常所说的“线程”。去掉此标志,父子拥有独立的地址空间——这就是标准的“进程”。
在内核看来,它们都是 task_struct,调度器对它们一视同仁。

这个设计带来一个重要推论:Linux的线程切换和进程切换,在内核层面本质相同——都是保存和恢复一个 task_struct 的上下文。线程切换更快,主要是因为共享 mm_struct,无需切换页表,从而避免了昂贵的TLB刷新操作。
进程从被 fork() 创建开始,其生命周期会经历一系列状态变迁,如下图所示:

对这些状态需要明确理解:
ps 命令看到的大部分睡眠进程处于此状态,它们可以被信号唤醒。kill -9 都无法终止它。典型的案例是NFS网络文件系统挂起导致的进程“卡死”。这里重点说明僵尸进程(Zombie)。子进程退出后,其 task_struct 不会立即释放,而是等待父进程调用 wait() 或 waitpid() 来收集其退出状态。如果父进程一直不调用 wait(),子进程将保持僵尸状态。僵尸进程虽不消耗内存,但会占用有限的PID资源,积累过多可能导致无法创建新进程。
处理僵尸进程,通常有两种方法:
// 方案一:忽略 SIGCHLD 信号,内核自动回收僵尸子进程
signal(SIGCHLD, SIG_IGN);
// 方案二:非阻塞地 wait,收割所有已退出的子进程
waitpid(-1, NULL, WNOHANG);
#include
#include
int shared_counter = 0; // 全局变量,所有线程共享
void *worker(void *arg) {
int id = *(int *)arg;
// 注意:多线程操作 shared_counter 需要加锁!
shared_counter++;
printf("线程 %d,shared_counter = %d\n", id, shared_counter);
return NULL;
}
int main() {
pthread_t tid[3];
int ids[3] = {1, 2, 3};
for (int i = 0; i < 3; i++)
pthread_create(&tid[i], NULL, worker, &ids[i]);
for (int i = 0; i < 3; i++)
pthread_join(tid[i], NULL); // 等待所有线程结束
return 0;
}
编译时需要链接pthread库:gcc -o demo demo.c -lpthread。
Q:fork()之后父子进程谁先执行?
A:执行顺序是不确定的,完全由内核调度器决定。虽然在单CPU上,历史上父进程被设计为先继续运行的概率更高,但这并非绝对保证。编程时绝不能依赖执行顺序,必要时需使用同步机制。
Q:Linux 线程和进程切换的开销对比?
A:线程切换省去了切换页表和刷新TLB的开销(因为共享 mm_struct),因此通常比进程切换更快。但两者都涉及从用户态陷入内核态、保存和恢复寄存器上下文等操作。实际测试中,线程切换的速度大约是进程切换的2到5倍。
Q:fork()之后文件描述符怎么处理?
A:子进程会继承父进程所有打开的文件描述符(包括socket),并且它们指向内核中同一个文件表项。这正是Nginx的prefork模型中,多个worker进程能够共享同一个监听套接字的基础。如果不想让子进程继承某个fd,可以在打开文件时使用 O_CLOEXEC 标志,或者在 fork() 后、exec() 前手动关闭。
Q:什么是孤儿进程?和僵尸进程有什么区别?
A:孤儿进程是指父进程先于子进程退出,此时子进程会被内核的init进程(或systemd)接管,由它负责后续的 wait()。孤儿进程本身无害。
僵尸进程则是子进程先退出,但父进程没有调用 wait() 来回收,导致子进程的 task_struct 无法释放,PID被长期占用。僵尸进程积累会消耗系统PID资源,影响稳定性。
Q:多线程程序里fork()是安全的吗?
A:非常危险!fork() 在调用时,只会复制调用它的那个线程,其他线程在子进程中会“瞬间消失”。如果这些消失的线程正持有某个互斥锁,那么在子进程中,这把锁就永远无法被释放了,极易导致死锁。安全的做法是:要么在 fork() 后立即调用 exec()(前提是 fork() 时不持有任何锁),要么使用 pthread_atfork() 函数来注册fork前后的清理回调。
从 fork() 到 exec(),从进程到线程,梳理下来我们看到Linux内核设计的一个核心哲学:机制复用,通过标志位控制行为。
进程和线程在底层共享同一套 task_struct 机制,仅因 clone() 的标志位不同而呈现不同形态。写时复制(COW)让进程复制变得极其轻量,而 exec() 则赋予了进程彻底蜕变的能力。
深入理解这些底层机制,才能真正看懂Nginx的prefork模型为何高效,明白为何在多线程环境中调用 fork() 需要格外小心,也才能在面对相关面试问题时,做到条理清晰,直击本质。
菜鸟下载发布此文仅为传递信息,不代表菜鸟下载认同其观点或证实其描述。