欢迎光临
我们一直在努力

Linux 学习笔记(十八)系统编程:进程的诞生、状态转换与并发调度机制及fork实战全解析

一、 课前复盘:二进制解析与递归遍历的避坑指南

在进入进程之前,我们先对文件操作的进阶实战进行复盘。

1. JPEG 协议解析:二进制读写的终极考验

解析JPEG图片是检验文件I/O和结构体映射的经典案例。JPEG是由多个“段”组成的标准二进制格式,每个段以 0xFF 开头,第二个字节标识段类型(如 0xE0 为 APP0,0xC0 为 SOF0)。

  • 大小端转换:JPEG默认采用大端存储(Big-Endian)。当我们在小端机器(如x86)上解析2字节的Short或4字节的Int时,必须手动进行高低字节交换。

  • 结构体对齐陷阱:为了将文件数据直接映射到C语言结构体,必须使用 #pragma pack(1) 强制一字节对齐。否则,编译器为了优化访问速度会自动填充空白字节,导致结构体大小与文件格式不匹配,解析全乱套。

  • 高效定位:使用 memmem 函数在内存缓冲区中快速查找 0xFFE0 等标记,比逐字节遍历高效得多,且能应对段顺序不固定的情况。

2. 递归目录遍历 (ls -R):资源泄漏的隐形杀手

实现递归遍历时,核心逻辑是将目录遍历封装为独立函数,接收路径作为参数。但在实际操作中,有两个极其致命的坑:

  • 无限递归:readdir 读出的目录项中包含 .(当前目录)和 ..(上级目录)。如果不做 strcmp 判断跳过它们,程序会陷入无限递归,瞬间撑爆栈空间(默认8MB)导致段错误。

  • 文件描述符耗尽:每次 opendir 都会消耗一个文件描述符(FD)。系统默认限制一个进程最多打开 1024 个 FD。在递归中如果忘记 closedir,深层遍历时程序会直接卡死报错。原则:opendir 必须与 closedir 成对出现。


二、 揭开进程的面纱:从静态到动态的蜕变

1. 程序 vs 进程

  • 程序(Program):存放在硬盘上的静态二进制文件(如 a.out),它不占用 CPU 和运行内存。

  • 进程(Process):程序被加载到内存中并开始执行的动态实体。它不仅包含代码,还包含了 CPU 寄存器状态、堆栈、打开的文件等资源。

2. PCB (进程控制块):内核的档案袋

Linux 内核为了管理海量进程,为每个进程分配了一个数据结构,即 PCB(Process Control Block),在内核源码中对应 task_struct。有进程必有 PCB。

PCB 核心成员全解析:

  • PID(进程标识符):系统分配的唯一整数(1号进程通常是 init/systemd)。即便同一个程序运行两次,PID 也不同。

  • 当前工作路径:记录进程启动时的目录。这就是为什么代码里 fopen("test.txt") 会在当前终端路径下创建文件。可以用 chdir() 修改。

  • umask(权限掩码):影响新建文件/目录的权限。系统默认 umask 是 0002。你在代码里写 mkdir("dir", 0777),实际生成的是 0777 & ~0002 = 0775。

  • 文件描述符表 (FD Table):这是一个数组,默认大小 1024。0 代表标准输入,1 标准输出,2 标准错误。open 文件时依次分配 3, 4, 5…

  • 信号处理字段:记录进程对 Ctrl+C (SIGINT) 等信号的响应策略(默认终止)。

  • UID / GID:标志进程属于哪个用户和组,决定了它能访问哪些资源。

3. 资源限制查看 (ulimit -a)

在终端敲下 ulimit -a,重点关注两项:

  • Open files (-n) 1024:如果程序频繁报错 Too many open files,说明 FD 泄漏了,或者需要调大上限。

  • Stack size (-s) 8192 KB (8MB):栈用于存放局部变量和函数参数。如果你在函数里定义了 char buf[10 * 1024 * 1024],程序会立刻崩溃(栈溢出)。大内存需求请用 malloc 去堆区申请。


三、 进程的内存分布(32位系统视角)

进程拥有独立的虚拟地址空间(0~4GB),其中 0~3GB 为用户空间,3~4GB 为内核空间。用户空间由低到高分为:

  • 代码段 (Text):存放编译后的机器指令,具有只读属性,防止运行时被恶意篡改。

  • 数据段 (Data):存放已初始化的全局变量和静态变量(static)。

  • BSS 段:存放未初始化的全局变量和静态变量,系统在加载时自动清零。

  • 堆区 (Heap):由程序员通过 malloc / calloc 申请,free 释放。向高地址生长,容量极大。

  • 共享库映射区 (Mmap):存放 libc.so 等动态链接库的代码,多个进程可共享此区域以节省物理内存。

  • 栈区 (Stack):存放局部变量、函数形参、返回地址。向低地址生长,系统自动管理,函数返回即销毁。


  • 四、 状态与调度:CPU的统筹艺术

    1. 状态转换图

    一个进程的一生会在不同状态间切换:

    • 三态模型:

      • 就绪态:万事俱备,只欠 CPU(排队中)。

      • 运行态:获得 CPU,正在执行。

      • 阻塞态:等待某事件(如键盘输入、文件读写完成)。此时即使把 CPU 给它,它也无法运行。

    • Linux 的五态模型扩展(ps aux 中的 STAT 列):

      • R (Running):运行或就绪。

      • S (Interruptible Sleep):可中断睡眠。等待条件满足(如 scanf 等待用户输入),可以被 Ctrl+C 等信号唤醒。

      • D (Uninterruptible Sleep):不可中断睡眠。通常发生在底层硬件 I/O 读写时,必须一气呵成,不能被信号打断。

      • T (Stopped):暂停态。常见于 GDB 调试打断点或按 Ctrl+Z。

      • Z (Zombie):僵尸态。进程已结束,但 PCB 未被回收。

    2. 调度算法与并发特征

    • CFS (完全公平调度):Linux 默认算法。它将 CPU 时间划分为极短的时间片,轮流分配给就绪进程。为了防止低优先级进程“饿死”,CFS 会根据进程的等待时间动态调整其优先级(Nice值),等待越久,优先级越高。

    • 上下文切换 (Context Switch):进程切换时,内核必须保存当前进程的寄存器状态、PC指针等现场信息到 PCB 中,并加载下一个进程的现场。这需要消耗 CPU 时间,因此进程不是越多越好。

    • 并发 vs 并行:

      • 并发:单核 CPU,通过时间片轮转,宏观上“同时”运行,微观上串行。

      • 并行:多核 CPU,同一时刻多个进程真正同时运行。


    五、 核心函数 fork():进程的克隆机

    fork 是 Linux 下创建进程的唯一方式。它最神奇的地方在于:调用一次,返回两次。

    1. 返回值机制

    • 在父进程中:返回新创建子进程的 PID(> 0)。

    • 在子进程中:返回 0。

    • 出错时:返回 -1。

    2. 代码实战:变量不共享与写时复制

    早期 Linux 的 fork 会完整复制父进程的 0-3G 内存,极其耗时。现代系统引入了 Copy-on-Write (写时复制):fork 刚完成时,父子进程共享物理内存页。只有当其中一方尝试修改数据时,系统才会在后台为其拷贝一份真实的内存。

    【代码实战:验证写时复制】

    c

    #include <stdio.h>
    #include <unistd.h>

    int a = 10; // 全局变量

    int main() {
    pid_t pid = fork();

    if (pid > 0) {
    sleep(3); // 让子进程先跑
    printf("父进程: a = %d\\n", a);
    } else if (pid == 0) {
    a = 50; // 触发写时复制
    printf("子进程: a = %d\\n", a);
    }
    return 0;
    }

    底层运行剖析:
    子进程修改 a 时,系统为子进程分配了新的内存页,a 变成 50;而父进程完全不知情,3秒后打印仍然是 10。这就证明了父子进程内存完全隔离。

    3. 调度随机性与并发执行

    fork 之后,父子进程谁先执行完全由 CFS 调度器决定。在死循环中,两者交替输出(宏观看是并行,微观看是串行)。

    【代码实战:无人机并发模拟】

    c

    #include <stdio.h>
    #include <unistd.h>

    int main() {
    pid_t pid = fork();

    if (pid > 0) {
    while (1) { printf("父进程: 发送视频…\\n"); sleep(1); }
    } else if (pid == 0) {
    while (1) { printf("子进程: 接收控制指令…\\n"); sleep(1); }
    }
    return 0;
    }

    运行效果:由于父子进程执行顺序不确定,有时会连续打印两条“发送视频”,这属于调度器的正常随机性。

    4. 进程身份证:getpid 与 getppid

    • getpid():获取自身 PID。

    • getppid():获取父进程 PID。
      进程间形成了一棵树状结构。1号进程是树根。


    六、 进程的生死存亡:退出、孤儿与僵尸

    1. 进程退出的 8 种场景

    • 正常退出:

    • main 函数中 return。

    • 调用 exit():标准库函数,退出前会刷新缓冲区,关闭所有流。

    • 调用 _exit() / _Exit():系统调用,直接退出,不刷新缓冲区(慎用)。

    • 主线程退出。

    • 主线程调用 pthread_exit()。

    • 异常退出:

    • 收到致命信号(如段错误 SIGSEGV、kill -9)。

    • 调用 abort()。

    • 最后一个线程被取消(pthread_cancel)。

    2. 孤儿进程(父进程先退,子进程未退)

    父进程退出后,子进程会被操作系统的 1号进程(init 或 systemd) 接管。这属于正常现象,系统会自动回收。

    【代码实战:04organ.c】

    c

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>

    int main(int argc, char **argv)
    {
    int ret = fork();
    if (ret > 0)
    {
    // 父亲:直接退出
    printf("father pid:%d, ppid:%d\\n", getpid(), getppid());
    exit(0);
    }
    else if (0 == ret)
    {
    // 孩子:睡1秒,等父亲死透了自己再打印
    sleep(1);
    printf("child pid:%d, ppid:%d\\n", getpid(), getppid());
    }
    return 0;
    }

    验证方法:在终端运行程序后,子进程打印的 ppid 会变成 1(或系统初始化进程的 PID),说明它成功被系统收养。

    3. 僵尸进程(子进程先退,父进程未回收)

    子进程退出时,它的用户空间(代码、数据)会被释放,但它的 PCB 会被保留在内核中,等待父进程通过 wait() 或 waitpid() 读取其退出状态。如果父进程一直不读取,它就成了“僵尸”。

    【代码实战:05zombie.c】

    c

    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>

    int main(int argc, char **argv)
    {
    int ret = fork();
    if (ret > 0)
    {
    // 父亲:活着,但不处理子进程的后事
    printf("father pid:%d, ppid:%d\\n", getpid(), getppid());
    sleep(20); // 期间不去回收子进程
    exit(0);
    }
    else if (0 == ret)
    {
    // 孩子:生下来就死了
    printf("child pid:%d, ppid:%d\\n", getpid(), getppid());
    exit(0);
    }
    return 0;
    }

    验证方法:重新打开一个终端,输入 ps aux | grep Z。你会看到状态列为 Z+,内存占用为 0,名字显示为 <defunct> 的僵尸进程。
    危害:不消耗 CPU,但占用 PID 和内核内存。如果父进程不断产生子进程且从不回收,系统 PID 会被耗尽。
    解决:父进程调用 wait() 或 waitpid() 主动收尸;或者直接杀掉父进程,让僵尸进程成为孤儿,由 init 进程接管并清理。


    七、 进程管理实战命令大全

  • ps aux:

    • Process Snapshot(进程快照)。显示执行瞬间的进程状态。

    • 关键列:USER(谁启动的)、PID(进程号)、STAT(状态:R/S/D/Z)、COMMAND(启动命令)。

    • 小技巧:结合管道 ps aux | more 可以分页查看,空格翻页,Q退出。

  • top:

    • 动态监控工具,类似 Windows 的任务管理器。

    • 默认按 CPU 占用率排序,每 3 秒刷新。

    • 关键列:PR(优先级,越小越高)、NI(Nice值,用于微调)、id(CPU空闲率,越接近100%代表系统越闲)。

  • kill / killall:

    • kill -9 PID:发送信号 9 (SIGKILL),强制杀死进程(不可被捕获,立即死亡)。

    • kill -2 PID:发送信号 2 (SIGINT),类似 Ctrl+C,进程可以做清理工作后再死。

    • killall 进程名:批量杀死同名进程。

  • pstree:

    • 以树状图(Tree)显示进程间的父子关系。可以非常直观地看到谁是谁的儿子,对于排查进程锁死非常有帮助。


  • 八、 进程管理常用命令与避坑总结

    1. 常用命令

    • ps aux:查看系统所有进程的快照。重点关注 STAT 列(R运行,S睡眠,Z僵尸)和 PID。

    • top:动态实时刷新进程资源占用情况,类似任务管理器。

    • kill / killall:向进程发送信号。如 kill -9 PID 强制杀死进程(信号 9 不可被捕获或忽略)。

    • pstree:以树状结构展示进程的父子关系,非常直观。

    2. 编程避坑总结

  • 变量不共享:fork 之后,父子进程的全局变量、局部变量是完全独立的。别指望在子进程改了变量,父进程能感知到。

  • 执行顺序不可控:在没有同步机制的情况下,绝不能默认“父进程一定先执行”或“子进程一定先执行”。需要严格顺序时,必须引入 wait 或进程间通信(IPC)机制。

  • 警惕僵尸:在实际工程中,只要用到了 fork,必须想好在什么时机回收子进程,否则代码跑久了系统必崩溃。

  • 孤儿无害:父进程死了不要紧,Linux 的进程树会随时被 init 进程“接盘”,孤儿进程对系统没有任何危害。

  • 赞(0)
    未经允许不得转载:171主机测评 » Linux 学习笔记(十八)系统编程:进程的诞生、状态转换与并发调度机制及fork实战全解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址