首页 游戏 软件 资讯 排行榜 专题
首页
业界动态
Linux 进程与线程深度解析:fork()、exec()、线程原理,一次彻底搞懂

Linux 进程与线程深度解析:fork()、exec()、线程原理,一次彻底搞懂

热心网友
90
转载
2026-04-17

一、进程是什么:不只是"一个程序"

教科书上那句“进程是程序的一次执行”,听起来总有点隔靴搔痒,不够透彻。

在内核的视角里,事情要具体得多。一个进程,本质上就是一个名为 task_struct 的结构体。你可以把它想象成一张记录了这个执行单元所有家当的“户口本”或“档案表”。里面都记了些什么呢?

  • 进程 ID(pid)、父进程 ID(ppid)
  • 虚拟内存映射(mm_struct)
  • 打开的文件表(files_struct)
  • 信号处理表、CPU 寄存器状态
  • 调度信息(优先级、运行时间片)
┌─ task_struct ─────────────────┐
│  pid = 1234                   │
│  mm      → 虚拟内存空间       │
│  files   → 文件描述符表       │
│  signals → 信号处理           │
│  regs    → CPU寄存器状态      │
│  sched   → 调度信息           │
└───────────────────────────────┘

所以,一个更精确的定义是:进程 = task_struct + 独立的虚拟地址空间。这个组合,才构成了一个完整的、能够被调度和执行的实体。

二、fork():最快的“复制粘贴”

创建新进程,最经典的方式就是 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 进程的服务,内存岂不是瞬间爆炸?

当然不会。这就用到了我们上一篇提到的核心技术:写时复制(Copy-On-Write,COW)

fork() 之后,父子进程实际上共享同一批物理内存页,内核只是将这些页的页表项标记为“只读”。当任何一个进程试图去写这些共享页时,才会触发缺页中断,此时内核才真正地为这个进程复制它所写的那一页。这就是“谁写,谁复制”。

COW 的精妙之处在于极大优化了常见场景。比如 Shell 执行命令:fork() 出一个子进程后,子进程通常会立刻调用 exec() 来加载一个新程序(如 ls)。既然要彻底换掉地址空间,那么父进程原来的那些数据页,子进程一页都不需要真正复制。fork() 的主要开销,其实就变成了复制父进程的页表,这个代价要小得多。

三、exec():换一套衣服继续跑

fork() 复制了父进程,但子进程往往并不想“子承父业”,而是要去执行一个全新的程序。这个“变身”的步骤,就由 exec() 家族函数来完成。

exec() 调用后,进程的虚拟地址空间会被完全重置——旧的代码段、数据段、堆栈都被清空,然后装载新程序的代码和数据。不过,进程的 PID 保持不变,已经打开的文件描述符(除非设置了 O_CLOEXEC 标志)也会被继承下来。

fork() + exec() 的组合,是 Shell 执行命令的标准模式,其流程如下图所示:

这解释了为什么你在 bash 里输入 ls,执行的是 /bin/ls 而不是 bash 自己。Bash 先 fork() 出一个自己的副本(子进程),然后这个子进程调用 exec() 把自己“替换”成 ls 程序。ls 执行完毕退出后,父进程 bash 继续运行,等待你的下一条命令。

用代码来简化表示就是这个过程:

pid_t pid = fork();
if (pid == 0) {
    // 子进程:替换成 ls
    execv("/bin/ls", argv);
    // exec 成功不会返回到这里
} else {
    // 父进程:等子进程结束
    waitpid(pid, NULL, 0);
}

四、进程 vs 线程:共享的边界在哪里?

“进程和线程的区别是什么?” 这是个经典的面试题。

最核心的答案在于共享资源的范围:线程是进程内部的执行单元,同一进程下的所有线程共享该进程的虚拟地址空间。

下面这张图清晰地展示了进程与线程在资源上的共享边界:

从图中可以一目了然:进程之间是完全隔离的,各自拥有独立的代码、堆、栈和文件描述符表。而同一进程内的多个线程,则共享代码段、堆、全局变量和文件描述符表,每个线程私有的只有自己的栈、寄存器和线程 ID。

正因为共享内存,线程间通信变得异常简单——直接读写同一块内存即可,无需借助管道、消息队列等进程间通信(IPC)机制。但这也带来了副作用:一个线程如果写坏了堆上的数据,整个进程内的所有线程都会受到影响。

五、Linux 线程的真相:它和进程是同一个东西

这一点可能出乎很多人的意料:在 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() 出来开始,其生命周期会经历一系列状态变迁,如下图所示:

对这些状态需要稍作说明:

  • RUNNABLE ⇄ RUNNING:这是进程最活跃的状态,在就绪和运行之间高频切换,完全由调度器掌控。
  • INTERRUPTIBLE(S 状态):最常见的睡眠状态。你用 ps 命令看到的大部分睡眠进程都在这里,它们可以被信号唤醒。
  • UNINTERRUPTIBLE(D 状态):进程在等待不可中断的 I/O(如某些磁盘操作)时进入此状态。此时连 kill -9 都无法杀死它。经典的案例就是 NFS 网络文件系统挂起导致的进程“卡死”。
  • ZOMBIE(Z 状态):进程“已死未葬”。它不占用内存,但仍占用着一个 PID 资源,需要父进程来“收尸”。

这里重点说一下僵尸进程(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() 需要格外小心,也才能在面对相关面试问题时,做到条理清晰,直击本质。

来源:https://www.51cto.com/article/838763.html
免责声明: 游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

相关攻略

Linux端口占用解决方法与强制结束进程命令教程
系统平台
Linux端口占用解决方法与强制结束进程命令教程

遇到端口被占用,首先使用`lsof-i:端口号`命令查找占用进程的PID。找到后,优先使用`killPID`命令让进程优雅退出。若无效,再考虑使用`kill-9PID`强制终止。使用`killall`或`pkill`时需谨慎,建议附加用户或名称限制以避免误杀。若端口仍显示占用,可能是TCP的TIME_WAIT状态,可使用`ss`命令确认,通常端口可立即复用。

热心网友
05.14
Linux系统CPU漏洞检测指南 Spectre与Meltdown状态查看方法
系统平台
Linux系统CPU漏洞检测指南 Spectre与Meltdown状态查看方法

检测Linux系统是否受Spectre或Meltdown漏洞影响,需直接检查运行状态。最可靠的方法是读取 sys devices system cpu vulnerabilities 目录下的实时状态文件,观察各漏洞的缓解情况。也可使用第三方脚本进行交叉验证,重点关注漏洞状态与微码版本。此外,需确认内核启动参数是否已启用缓解措施,以确保防护生效。

热心网友
05.14
Linux SSH反向隧道配置教程与内网穿透步骤详解
系统平台
Linux SSH反向隧道配置教程与内网穿透步骤详解

配置SSH反向隧道时,常见问题包括隧道端口无法被外部访问、连接不稳定或连接被拒绝。这通常源于服务器SSH默认设置`GatewayPortsno`,导致端口仅绑定在本地回环地址。需修改为`clientspecified`或`yes`并重启服务。命令中`localhost`指内网机地址,若需外部访问,应使用`*:2222`绑定所有接口。为保持连接稳定,建议使用`

热心网友
05.14
Git LFS配置教程 高效管理大型二进制文件指南
系统平台
Git LFS配置教程 高效管理大型二进制文件指南

GitLFS用于管理Git中的大型二进制文件。配置时需先安装git-lfs工具并运行gitlfsinstall初始化。使用前必须用gitlfstrack指定跟踪文件类型并提交 gitattributes,再添加文件。克隆含LFS的仓库时,默认仅下载指针,需运行gitlfspull获取实际文件。若已有仓库误提交大文件,可使用gitlfsmigrate重写历史,

热心网友
05.14
Linux strace命令详解如何查看进程系统调用统计
系统平台
Linux strace命令详解如何查看进程系统调用统计

strace-c用于统计进程系统调用的耗时分布,反映内核态时间占比,而非CPU占用率。其输出百分比代表各调用在追踪总耗时中的比例,与top的CPU观测维度不同,属正常现象。该工具适用于排查启动慢、网络卡顿等问题,但需注意无法统计用户态计算耗时,且应结合时间序列分析以避免误判。

热心网友
05.14

最新APP

宝宝过生日
宝宝过生日
应用辅助 04-07
台球世界
台球世界
体育竞技 04-07
解绳子
解绳子
休闲益智 04-07
骑兵冲突
骑兵冲突
棋牌策略 04-07
三国真龙传
三国真龙传
角色扮演 04-07

热门推荐

华硕ROG枪神魔霸新锐2026游戏本预约开启
科技数码
华硕ROG枪神魔霸新锐2026游戏本预约开启

华硕ROG正式发布2026款枪神、魔霸及魔霸新锐系列游戏本并开启预约。枪神系列分为标准版与超竞版,均搭载酷睿Ultra9处理器,超竞版可选RTX5090显卡并配备光显矩阵屏。魔霸系列采用AMD锐龙处理器,高配可选锐龙99955HX3D与RTX5070Ti显卡。魔霸新锐系列主打性价比,配备RTX5060显卡,面向预算有限的玩家。

热心网友
05.15
锐龙5 9600X单通道内存电竞性能实测 依然轻松胜出
科技数码
锐龙5 9600X单通道内存电竞性能实测 依然轻松胜出

内存价格高企,单通道DDR5成为高性价比装机方案,但会降低游戏性能。测试显示,锐龙59600X凭借Zen5大核架构及对内存低延迟的优化,在搭配单条DDR56000内存时,游戏性能损失较小。相比之下,酷睿Ultra200SPLUS系列更依赖高带宽,单通道下性能下滑明显。在多款热门电竞网游实测中,锐龙59600X性能领先,且整机性价比优势显著。

热心网友
05.15
神牛ML40摄影灯内置锂电池版发布 售价568元起
科技数码
神牛ML40摄影灯内置锂电池版发布 售价568元起

神牛发布ML40系列摄影灯,包含ML40Bi和ML40R两款。ML40Bi售价568元,内置锂电池,支持边充边用及NFC快速连接,侧重便携智能。ML40R售价698元,具备更广色温调节范围,侧重专业色彩控制。两者均采用磁吸设计,兼容丰富附件,满足不同布光需求。

热心网友
05.15
华硕850W氮化镓电源白金重炮手849元入手
科技数码
华硕850W氮化镓电源白金重炮手849元入手

华硕TUFGaming系列推出新款850W白金重炮手氮化镓电源,到手价849元。该电源符合ATX3 1规范,长度150mm,采用全模组设计,配备12V-2×6接口支持600W峰值功率。其获得双白金效率认证与A-噪声认证,内部使用氮化镓元件与长寿电容,搭配135mm静音风扇,并提供8年质保,主打高效、安静与持久稳定。

热心网友
05.15
Falcon USD是什么币?USDF稳定币市值排名与投资价值解析
web3.0
Falcon USD是什么币?USDF稳定币市值排名与投资价值解析

FalconUSD(USDF)是一种与美元挂钩的稳定币,旨在为Web3生态系统提供可靠的交易媒介和价值储存工具。其运作依赖于储备资产支持和透明审计机制,在DeFi、跨境支付等场景有应用潜力。了解其技术原理、市场定位及潜在风险,有助于理性评估这一新兴数字资产的价值与前景。

热心网友
05.15