Linux 进程概念(上):从冯诺依曼到进程调度

《Linux 系统编程》第三章的学习笔记。原文太长(一万五千多字),拆成两篇发,方便分段消化。

上篇回答四个问题:计算机为什么长成冯诺依曼那样;操作系统到底在「管理」什么;进程究竟是什么;它有哪些状态、怎么抢 CPU、怎么被切换。

目录

    • 一、先看硬件:冯诺依曼体系结构
      • [1.1 五个部件](#1.1 五个部件)
      • [1.2 三条必须记住的结论](#1.2 三条必须记住的结论)
      • [1.3 为什么非得这么设计](#1.3 为什么非得这么设计)
      • [1.4 拿它解释一件具体的事:QQ 发一条消息](#1.4 拿它解释一件具体的事:QQ 发一条消息)
    • 二、操作系统:一个纯粹的「管理者」
      • [2.1 它到底是什么](#2.1 它到底是什么)
      • [2.2 它的定位:一款纯正的「搞管理」的软件](#2.2 它的定位:一款纯正的「搞管理」的软件)
      • [2.3 什么叫「管理」------学生、辅导员、校长](#2.3 什么叫「管理」——学生、辅导员、校长)
      • [2.4 分层:系统调用和库函数](#2.4 分层:系统调用和库函数)
    • 三、进程:一个被描述出来的执行流
      • [3.1 三个层次的定义](#3.1 三个层次的定义)
      • [3.2 PCB 与 task_struct](#3.2 PCB 与 task_struct)
      • [3.3 组织:从双链表说起](#3.3 组织:从双链表说起)
      • [3.4 怎么看见它:/proc、ps、top](#3.4 怎么看见它:/proc、ps、top)
      • [3.5 getpid / getppid](#3.5 getpid / getppid)
      • [3.6 fork:一次调用,两次返回](#3.6 fork:一次调用,两次返回)
    • 四、进程状态:七个字母的故事
      • [4.1 内核源码怎么定义的](#4.1 内核源码怎么定义的)
      • [4.2 逐个说清楚](#4.2 逐个说清楚)
      • [4.3 僵尸进程](#4.3 僵尸进程)
      • [4.4 孤儿进程](#4.4 孤儿进程)
    • [五、优先级:CPU 只有一个,进程有几百个](#五、优先级:CPU 只有一个,进程有几百个)
      • [5.1 为什么需要优先级](#5.1 为什么需要优先级)
      • [5.2 PRI 和 NI](#5.2 PRI 和 NI)
      • [5.3 怎么改](#5.3 怎么改)
    • [六、进程切换与 O(1) 调度](#六、进程切换与 O(1) 调度)
      • [6.1 上下文切换到底是什么](#6.1 上下文切换到底是什么)
      • [6.2 时间片](#6.2 时间片)
      • [6.3 Linux 2.6 的 O(1) 调度](#6.3 Linux 2.6 的 O(1) 调度)
        • [第一层:一个 CPU 一个 runqueue](#第一层:一个 CPU 一个 runqueue)
        • [第二层:140 个优先级队列](#第二层:140 个优先级队列)
        • 第三层:怎么找下一个该跑的进程
        • [第四层:bitmap 的妙用](#第四层:bitmap 的妙用)
        • [第五层:active 和 expired 双队列](#第五层:active 和 expired 双队列)
        • [闭环:为什么叫 O(1)](#闭环:为什么叫 O(1))
    • 七、命令行参数与环境变量
      • [7.1 环境变量是什么](#7.1 环境变量是什么)
      • [7.2 三个必知的环境变量](#7.2 三个必知的环境变量)
      • [7.3 一个实验:为什么 `ls` 不用带路径,`./hello` 就要](#7.3 一个实验:为什么 ls 不用带路径,./hello 就要)
      • [7.4 和环境变量相关的命令](#7.4 和环境变量相关的命令)
      • [7.5 环境表是怎么组织的](#7.5 环境表是怎么组织的)
      • [7.6 代码里怎么拿环境变量](#7.6 代码里怎么拿环境变量)
      • [7.7 全局性:能不能被子进程继承](#7.7 全局性:能不能被子进程继承)
      • [7.8 留一个问题:环境表到底存在哪](#7.8 留一个问题:环境表到底存在哪)
    • 八、上篇小结
      • [8.1 一条主线](#8.1 一条主线)
      • [8.2 复习清单](#8.2 复习清单)
    • 下篇预告

一、先看硬件:冯诺依曼体系结构

1.1 五个部件

我们手上绝大多数计算机------笔记本、台式机、服务器------都遵守冯诺依曼体系。拆开看就五个部分:

text 复制代码
        ┌─────────────┐         ┌─────────────┐
        │  输入设备    │         │  输出设备    │
        │ 键盘 鼠标    │         │ 显示器 打印机│
        └──────┬──────┘         └──────▲──────┘
               │                       │
               │    ┌─────────────┐    │
               └───►│    内存      │◄───┘
                    │  (存储器)    │
                    └──────▲──────┘
                           │
                    ┌──────┴──────┐
                    │    CPU      │
                    │ 运算器+控制器│
                    └─────────────┘

注意箭头的方向:输入设备只指向内存,输出设备只从内存出发,CPU 也只跟内存相连。没有一条线是「键盘直连 CPU」或者「CPU 直连显示器」的。

1.2 三条必须记住的结论

课件强调了三句话,值得单独拎出来:

  1. 这里的「存储器」指的是内存,不是硬盘,也不是 CPU 里的寄存器。
  2. 不考虑缓存的情况下,CPU 能且只能对内存进行读写,不能直接访问任何外设(数据层面)。
  3. 外设要输入或输出数据,也只能写进内存或从内存读。

一句话总结:所有设备都只能直接和内存打交道,内存是唯一的交通枢纽。

1.3 为什么非得这么设计

这个问题的本质是速度不匹配。把各类硬件的量级摆在一起看:

部件 大致速度 相对 CPU 的倍数
CPU 寄存器 亚纳秒级 1
CPU Cache (L1) 1~4 个周期 几倍
内存 约 100 个周期 百倍
SSD 微秒级 万倍以上
机械硬盘 毫秒级 百万倍以上
键盘输入 人的反应速度(百毫秒) 上亿倍

如果让 CPU 直接等键盘,那就是「让法拉利等红绿灯」。所以必须有一个缓冲区在中间做速率解耦------内存就是这个缓冲区。

内存的速度虽然还是比 CPU 慢两个数量级,但至少差距缩小到了「可以用 Cache 掩盖」的程度。这就是为什么整个体系结构长成中间是内存、四周是慢速设备的样子。

顺带纠正一个常见误解:有人会问「DMA 不就是设备直接访问内存吗,那 CPU 不也参与了?」------DMA 确实是设备 ↔ 内存直接搬运,不经过 CPU 的运算器,但它依然遵守「设备只和内存打交道」这条规则,只是把「谁来搬」从 CPU 换成了 DMA 控制器。规则没被打破。

1.4 拿它解释一件具体的事:QQ 发一条消息

课件在这里留了个思考题,我展开说一下,因为这是检验「有没有真的理解冯诺依曼」的最好方式。

你在 QQ 窗口敲下「在吗」并点发送:

  1. 键盘 (输入设备)产生中断,把扫描码送进内存中的键盘缓冲区;
  2. QQ 进程(在 CPU 上跑)从内存读走这些数据,转成字符,在内存里拼成消息;
  3. 网卡驱动把内存里的这段数据交给网卡(输出设备)发出去;
  4. 对方机器的网卡 (输入设备)收到电信号,写入内存;
  5. 对方的 QQ 进程从内存读出来,渲染成界面,通过显卡写显存 ,最后到显示器(输出设备)。

全程没有任何一步是「CPU 直接和网卡对话」或者「键盘直接送到显示器」。你在键盘上敲的字,物理上先在内存里躺了一会儿。

如果发的是文件,唯一的区别是数据块更大,以及中间多了一层磁盘读写------文件从本地磁盘读进内存,再交给网卡。路径变了,规则没变。


二、操作系统:一个纯粹的「管理者」

2.1 它到底是什么

任何计算机系统里都有一大坨基本程序,合起来叫操作系统(Operating System)。分两块:

  • 内核:进程管理、内存管理、文件管理、驱动管理
  • 其他程序:函数库、shell 等等

很多人以为「我打开终端敲的 ls 就是操作系统」,其实 ls 只是跑在操作系统之上的一个普通用户程序。真正意义上的操作系统,是那个一直在后台、你根本看不见的内核。

2.2 它的定位:一款纯正的「搞管理」的软件

这句话是课件里最精辟的一句。展开就是两个方向:

  • 对下:和硬件交互,管理所有软硬件资源
  • 对上:给用户程序(应用程序)提供一个良好的执行环境

注意它是个软件。硬件资源(CPU、内存、磁盘、网卡)本身没有「被管理」的自觉,是操作系统用代码把它们统一纳管起来,再包装成一套接口卖给上层。

2.3 什么叫「管理」------学生、辅导员、校长

这是理解整个 Linux 内核设计思想的钥匙,课件用了这个例子:

text 复制代码
校长 ──管理──► 辅导员 ──管理──► 学生
  ▲                              │
  └──────── 信息(数据)──────────┘

校长要管几千个学生,他不可能每天去宿舍点名。他的做法是:

  1. 描述:给每个学生建一份档案(姓名、学号、成绩、违纪记录......)
  2. 组织:把这些档案按学院、按班级串起来,需要时能快速找到

然后校长所有的「管理动作」,本质上都是对档案的操作,而不是对真人的操作。要给学生记过,改档案;要评奖学金,翻档案排序。学生本人甚至不知道校长动过手。

操作系统管硬件,用的是同一招:

  1. 描述 :用 struct 结构体把硬件属性记录下来
  2. 组织:用链表或其他高效数据结构把这些结构体串起来
c 复制代码
struct device {
    char name[32];
    int status;
    void *regs;       /* 寄存器基址 */
    struct device *next;
    /* ... */
};

于是「操作系统管理所有硬件」这句话,翻译成代码就是「操作系统维护着一堆结构体,并对这些结构体做增删改查」。

这条思路贯穿整章 。后面讲进程,第一句话就是「进程先被描述成 task_struct,再被组织成双链表」;讲内存,是「先描述成 mm_struct,再组织成红黑树」。你会发现内核里所有子系统都是这个套路。看懂一个,后面的都能推出来。

顺便解释一个「为什么」。为什么不直接操作硬件,非要先建一份数据结构?

  • 速度:查内存里的链表比读硬件寄存器快几个数量级;
  • 统一 :不同厂商的网卡寄存器布局完全不同,抽象成 struct 后上层代码只认结构体;
  • 解耦:上层只管调接口,硬件换了实现,上层不用改。

2.4 分层:系统调用和库函数

操作系统对外表现为一个整体,但它必须暴露一部分接口 给上层用,否则应用程序没法干活。这套接口叫系统调用(system call)。

text 复制代码
┌──────────────────────────────────┐
│        用户程序(QQ、vim、ls)    │
├──────────────────────────────────┤
│     库函数(glibc: printf/fopen)│  ← 对系统调用的封装
├──────────────────────────────────┤
│     系统调用(open/read/write)   │  ← 唯一合法入口
├──────────────────────────────────┤
│            内核                   │
│  进程管理│内存管理│文件管理│驱动  │
├──────────────────────────────────┤
│            硬件                   │
└──────────────────────────────────┘

为什么要有两层,而不是让应用直接调系统调用?

  1. 系统调用功能基础、门槛高 。write(fd, buf, n) 只认文件描述符和字节流,你得自己处理缓冲区、错误码、格式化。这对普通开发者太不友好了。
  2. 跨平台 。Windows 的 CreateFile 和 Linux 的 open 完全不同。如果 C 程序直接调系统调用,代码一行都搬不走。而 fopen 是 C 标准库提供的,它在 Linux 下调 open、在 Windows 下调 CreateFile,把差异吃掉了。
  3. 性能优化空间 。printf 不是每打一个字就调一次 write,它先在用户态缓冲区里攒着,攒够或遇到 \n 才真正陷进内核。这个优化只有库函数层能做。

所以关系是:库函数是系统调用的封装,系统调用是进入内核的唯一合法通道。你在用户态写的任何代码,想动硬件、想创建进程、想读写文件,最终都必须通过系统调用「陷入」内核。

一个容易混的点 :printf 不是系统调用,write 才是;malloc 不是系统调用,brk/mmap 才是。面试常问,别答错。


三、进程:一个被描述出来的执行流

3.1 三个层次的定义

课本定义:程序的一个执行实例,正在执行的程序。

这个定义有两个毛病:太模糊(「正在执行」到底是什么状态?),而且解释不了「同一个程序跑两份,算一个进程还是两个」。

内核观点:担当分配系统资源(CPU 时间、内存)的实体。

这个定义准了------进程是资源分配的单位。CPU 时间分给谁、内存页分给谁,都是按进程分配的。

现在的理解(最实用):

text 复制代码
进程 = 内核数据结构(task_struct) + 自己的程序代码和数据

这个公式是本章的地基。它把「进程」拆成了看得见的部分(代码和数据,存在磁盘上)和看不见的部分(内核里的那坨结构体,存在内存里)。

为什么要拆成这样? 想一下「程序」和「进程」的区别:

  • 程序是静态 的,就是磁盘上一个 a.out 文件,睡在那里不动;
  • 进程是动态的,它需要 CPU 时间、需要内存空间、需要打开的文件、需要信号处理......

这些东西没法写在可执行文件里,因为它们每个时刻都在变。所以内核必须额外维护一份「运行时状态」,就是 task_struct。

一个程序可以对应多个进程(你开两个终端各跑一次 ./a.out,就是两个进程,各自的 task_struct 互不相干),但一个进程一定对应一个程序。

3.2 PCB 与 task_struct

进程的所有信息被放在一个叫**进程控制块(PCB, Process Control Block)**的数据结构里。课本统称 PCB,Linux 下的具体实现是 task_struct。

task_struct 是 Linux 内核的一种数据结构类型,会被装载到 RAM 里,包含进程的全部信息。它的字段按用途分几类:

分类 内容 解决什么问题
标识符 PID、TGID 区分不同进程
状态 运行/睡眠/停止/僵尸,退出代码,退出信号 进程现在什么情况
优先级 相对于其他进程的优先级 谁先被 CPU 执行
程序计数器 即将执行的下一条指令地址 被打断后从哪继续
内存指针 代码段、数据段指针,共享内存块指针 数据在哪
上下文数据 处理器寄存器中的值 切换回来时恢复现场
I/O 状态 分配的 I/O 设备、打开的文件列表 它在等什么、拿着什么
记账信息 CPU 时间总和、时钟数、时间限制 统计与限制

这张表不用背,但要有印象:一个进程活着所需的全部「身外之物」,都在这里。

3.3 组织:从双链表说起

所有 task_struct 在内核里以双链表的形式串起来。课件原话是「所有运行在系统里的进程都以 task_struct 双链表的形式存在内核里」。

text 复制代码
        tasks(链表头)
             │
   ┌─────────┴─────────┐
   ▼                   ▼
┌──────┐  ┌──────┐  ┌──────┐
│ PCB1 │◄►│ PCB2 │◄►│ PCB3 │
│ pid=1│  │pid=xx│  │pid=yy│
└──────┘  └──────┘  └──────┘

用双链表而不是单链表,是因为内核经常要「从当前进程找到下一个」,也可能要「从某进程回退」,双向指针让两种操作都是 O(1)。

补充一点课件没展开的 :现代 Linux 的 task_struct 不止挂在一个链表上。为了快速按 PID 查找,它同时被挂在一棵红黑树 (pid_hash/pid_rbtree)和哈希表里;为了调度,它还要挂在运行队列的红黑树中。同一个结构体可以同时属于多个容器 ------这是内核数据结构设计的常见手法,靠的是把 list_head/rb_node 作为成员嵌进结构体,而不是让结构体去继承容器。这个技巧叫「侵入式链表」,是读内核代码必须跨过的一道坎。

3.4 怎么看见它:/proc、ps、top

进程信息可以通过 /proc 这个伪文件系统 查看。要获取 PID 为 1 的进程信息,就看 /proc/1/ 这个目录:

bash 复制代码
ls /proc/1/
# attr  cmdline  cwd  environ  exe  fd  maps  status  ...

/proc 里的文件不是真实存在于磁盘上的,是内核在你读取时才动态生成 的。读 /proc/1/status 就是让内核现场把 task_struct 里的字段格式化成文本吐给你。这就是「描述 + 组织」的直接体现------你看到的一切,都是结构体的投影。

常用的用户级工具:

bash 复制代码
ps aux      # 所有进程,用户友好格式
ps axj      # 显示进程组、会话、父进程等作业控制信息
top         # 动态刷新

几个参数的含义(容易记混):

参数 作用
a 显示一个终端所有进程,包括其他用户的
x 显示没有控制终端的进程(如后台守护进程)
u 以用户为中心格式显示,含 CPU/内存占用
j 显示进程组 ID、会话 ID、父进程 ID

3.5 getpid / getppid

c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    printf("pid: %d\n", getpid());
    printf("ppid: %d\n", getppid());
    return 0;
}

每次运行的 PID 都不一样(内核分配,通常递增),但 PPID 通常是固定的------因为你的程序是被 shell(bash)或者 IDE fork 出来的,父进程一直是那个 shell。

bash 复制代码
$ ./a.out
pid: 12345
ppid: 12300
$ echo $$        # shell 自己的 pid
12300

对上了。这就是「进程之间有父子关系」的最直接证据。

3.6 fork:一次调用,两次返回

这是本章第一个硬骨头。

c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    int ret = fork();
    printf("hello proc : %d!, ret: %d\n", getpid(), ret);
    sleep(1);
    return 0;
}

输出:

text 复制代码
hello proc : 12345!, ret: 0
hello proc : 12344!, ret: 12345

一行 printf,打了两遍。因为 fork 之后,世界上多了一个进程,两个进程各自从 fork 之后继续往下跑。

fork 的返回值规则:

返回值 含义
< 0 创建失败,errno 被设置
== 0 当前是子进程
> 0 当前是父进程,返回值是子进程的 PID

所以正常写法一定要分流:

c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    pid_t ret = fork();
    if (ret < 0) {
        perror("fork");
        return 1;
    } else if (ret == 0) {
        printf("I am child : %d!, ret: %d\n", getpid(), ret);
    } else {
        printf("I am father : %d!, ret: %d\n", getpid(), ret);
    }
    sleep(1);
    return 0;
}
为什么会有两个返回值

关键在于:「返回值」这个动作,是在两个进程里各做了一次。

fork 在内核里做的事情,粗略说是三步:

  1. 复制父进程的 task_struct、地址空间(页表)、文件描述符表,造出一个新的 PCB;
  2. 把这个新 PCB 挂进调度队列,让它变成「可运行」;
  3. 把子进程的 PID 塞进父进程的返回值存放位置(x86 上是 eax 寄存器),把 0 塞进子进程的返回值存放位置。

第 3 步是精髓。内核在返回用户态之前,改了两个不同进程的寄存器。于是:

  • 父进程从 fork 返回,看到 eax = 子进程 PID;
  • 子进程从「它诞生的那一刻」返回,看到 eax = 0。

它们看到的不是同一个值,因为寄存器本来就不是同一份。

那为什么 if 和 else if 会「同时成立」

初学者最困惑的是这个:ret 明明是一个变量,怎么能既等于 0 又不等于 0?

因为 ret 也不是同一个变量了。

fork 之后,父子进程有各自独立的地址空间 。父进程的 ret 和子进程的 ret 是两个不同物理内存位置上的两份数据,只是它们在各自的虚拟地址里「看起来地址相同」(下一节讲地址空间时会解释这个诡异现象)。

所以严格来说,不是「一个变量同时满足两个分支」,而是「两个进程各走各的分支」。你以为你写了一个程序,实际上操作系统把它变成了两个。

代码共享,数据私有:写时拷贝

课件这里写了一句很关键的话:父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)。

fork 刚完成时,内核并没有真的把父进程的内存复制一遍。它做的是:

  1. 让子进程的页表指向父进程的同一批物理页;
  2. 把这些页的权限改成只读(原本可写的也先标成只读)。

然后父子各自跑。只要没人写,就一直共享,一分钱内存都不多花。

一旦某一方试图写某个页,CPU 触发缺页异常,内核一看:「哦,这个页是共享的,有人在写」------于是真正复制一份新的物理页,把写的那一方的页表指向新页,恢复可写,然后重新执行那条写指令。

text 复制代码
fork 之前:        [ 物理页 A ]
                     ▲     ▲
                 父页表  子页表

fork 之后(未写): [ 物理页 A ](只读)
                     ▲     ▲
                 父页表  子页表

子进程写入后:     [ 物理页 A ]   [ 物理页 B ]
                     ▲              ▲
                 父页表          子页表

这叫 Copy-on-Write(COW,写时拷贝) 。好处很直接:fork 变得极快,而且如果子进程马上就 exec(这是最常见的用法),那一整份复制就白做了。

一个必须澄清的误解 :很多人说「fork 会复制父进程的全部内存」,这在语义上对,在实现上是错的。实际复制发生在第一次写的时候,而且只复制被写的那一页。这个差别在进程体积很大时是数量级的。

父子谁先跑

不确定。 完全由调度器决定。可能父先、可能子先、可能交替。任何依赖「父进程一定先执行」的代码都是错的,必须用 wait/waitpid 或者管道、信号来同步。


四、进程状态:七个字母的故事

4.1 内核源码怎么定义的

Linux 内核里进程也叫「任务」(task)。状态定义在源码里:

c 复制代码
/*
 * The task state array is a strange "bitmap" of
 * reasons to sleep. Thus "running" is zero, and
 * you can test for combinations of others with
 * simple bit tests.
 */
static const char *const task_state_array[] = {
    "R (running)",      /*  0 */
    "S (sleeping)",     /*  1 */
    "D (disk sleep)",   /*  2 */
    "T (stopped)",      /*  4 */
    "t (tracing stop)", /*  8 */
    "X (dead)",         /* 16 */
    "Z (zombie)",       /* 32 */
};

先注意那段注释,它解释了一个很奇怪的现象:为什么这些值不是 0、1、2、3、4、5、6,而是 0、1、2、4、8、16、32?

因为内核注释说得很清楚------这是一个 bitmap of reasons to sleep(睡眠原因位图)。每个值是 2 的幂,占一个独立的比特位:

text 复制代码
R = 0b000000
S = 0b000001
D = 0b000010
T = 0b000100
t = 0b001000
X = 0b010000
Z = 0b100000

这样设计的好处是可以用位运算组合判断 。比如内核想知道「这个进程是不是睡着」,不需要写 if (state == T || state == t || state == S || state == D),只要 if (state & 0x1F) 一句话。而且 R 被设计成 0,意味着「运行」这个最常见的情况判定成本最低(state == 0 一条指令)。

这就是「为什么源码要那样写」的典型例子。看内核代码时,遇到 2 的幂的常量,第一反应就应该是「这是位标志」。

4.2 逐个说清楚

R --- 运行状态(running)

这个状态不代表进程此刻一定在 CPU 上跑。它表示:进程要么正在运行,要么在运行队列里排队等着被运行。两者都算 R。

这是最容易被误解的一条。你在单核机器上一个死循环程序 while(1);,ps 看到它是 R,但 CPU 显然不可能 100% 只给它------它其实大部分时间在排队。

S --- 可中断睡眠(interruptible sleep)

进程在等某个事件完成,比如等用户输入、等 sleep 时间到、等网络数据到达。这个等待是可被信号打断 的------你 kill 它,它会醒过来处理信号。

绝大多数「闲着」的进程都在 S 状态。比如你的 shell 在等你敲命令,就是 S。

D --- 不可中断睡眠(disk sleep / uninterruptible sleep)

进程通常在等待 I/O 结束,而且这个等待不响应信号。

这是最需要小心理解的状态。为什么会有「不可被打断」的睡眠?因为内核在做某些关键操作时,如果被信号打断,数据结构可能处于半完成状态,会崩溃。典型场景:进程向磁盘发起写请求,内核必须等磁盘返回「写成功/失败」,才能知道数据到底落盘没有,中途不能撤。

特别注意:kill -9 对 D 状态的进程无效。 你发了信号,它也不会醒,因为它的唤醒条件只有「I/O 完成」。这时候你只能等,或者处理底层 I/O 问题(比如 NFS 服务器挂了,一堆进程会卡在 D 状态)。这也是为什么有时候 kill -9 杀不掉进程------不是 kill 不灵,是进程压根没在听。

T --- 停止状态(stopped)

通过 SIGSTOP 信号可以让进程进入 T 状态(暂停)。这个被暂停的进程可以通过 SIGCONT 信号恢复运行。

注意 SIGSTOP 和 SIGCONT 不能被捕获、不能被忽略 ,这是内核强制规定的。对比一下 SIGINT(Ctrl+C)是可以被程序捕获并自行处理的。

t --- 跟踪停止(tracing stop)

被调试器(gdb)跟踪的进程在断点处停下来,就是这个状态。和 T 的区别是:T 是被人为 SIGSTOP 停的,t 是被调试器停的。

X --- 死亡状态(dead)

这只是一个返回状态 ,你不可能在任务列表里看到它。进程真正被销毁的瞬间太短暂,ps 抓不到。

Z --- 僵尸状态(zombie)

下面单独讲,这是重点。

4.3 僵尸进程

怎么产生的
text 复制代码
子进程退出 → 内核保留它的退出状态(退出码)→ 等父进程来读
                              ↓
                    父进程一直不读(没调 wait)
                              ↓
                        子进程进入 Z 状态

课件原话:僵死状态是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时,就会产生僵死进程。

复现代码(父子各睡一会儿,制造时间窗口):

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 1;
    } else if (id > 0) {          /* 父进程 */
        printf("parent[%d] is sleeping...\n", getpid());
        sleep(30);                 /* 父进程不回收,干睡 */
    } else {                       /* 子进程 */
        printf("child[%d] is begin Z...\n", getpid());
        sleep(5);
        exit(EXIT_SUCCESS);        /* 子进程先退出 */
    }
    return 0;
}

编译后运行,在另一个终端里监控:

bash 复制代码
ps aux | grep a.out
# 能看到 <defunct> 字样,STAT 列是 Z+

<defunct> 就是「已死但未安葬」。Z+ 里的 + 表示在前台进程组。

为什么必须保留

有人会问:进程都退出了,为什么不干脆立刻把 task_struct 释放掉,还要留个僵尸?这不是浪费吗?

因为退出状态是有价值的 。进程退出时,它要告诉父进程一件事:「你交给我的任务,我办得怎么样了」。这个信息就是退出码 (exit(0) 的 0、main 返回的值、或者被信号杀死时的信号编号)。

如果内核立刻把 PCB 释放了,父进程再去 wait 的时候就什么都拿不到了------它甚至不知道子进程是正常结束还是段错误挂掉的。

所以内核的设计是:进程的代码和数据可以释放,但 PCB 里的退出状态必须留着,等父进程来取。 取走之后(wait 成功返回),PCB 才真正销毁。

这也解释了为什么僵尸进程看起来「什么也没干」却占着资源------它占的是内核里的一个 task_struct,不是内存里的代码段。

危害

课件列了几条,我串起来说:

  1. PCB 本身要占内存 。task_struct 是个几百字节到几 KB 的结构体(现代内核约 9KB 含相关结构),fork 一万次不回收,就是几十 MB 的内核内存被白白占着。
  2. PID 会耗尽 。Linux 的 PID 是有限的(默认 pid_max 通常是 32768 或 4194304),僵尸不会释放 PID。僵尸攒多了,新进程 fork 就会失败,报 EAGAIN。这是比内存泄漏更致命的问题------新进程起不来,整个服务就废了。
  3. 无法用信号清理 。僵尸已经是死人了,kill -9 没有任何意义。唯一的办法是让它的父进程 去 wait,或者干脆把父进程杀掉,让它变成孤儿被 init 收养,由 init 来回收。

面试常考的追问:「怎么清理僵尸进程?」标准答案是:杀掉父进程,让子进程变孤儿被 1 号进程收养并回收。但要注意副作用------父进程死了,它的其他子进程也全变孤儿了。

4.4 孤儿进程

反过来:父进程先退出,子进程后退出,这个子进程就叫孤儿进程。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if (id < 0) {
        perror("fork");
        return 1;
    } else if (id == 0) {          /* 子进程 */
        printf("I am child, pid : %d\n", getpid());
        sleep(10);                  /* 子进程活得久 */
    } else {                        /* 父进程 */
        printf("I am parent, pid: %d\n", getpid());
        sleep(3);
        exit(0);                    /* 父进程先死 */
    }
    return 0;
}

父进程死后,子进程变成孤儿,被 1 号进程(init 或 systemd)领养 。用 ps 看,它的 PPID 会变成 1。

孤儿进程有害吗?没有。 因为 init/systemd 是个尽职的养父,它会 wait 每一个被收养的子进程,所以孤儿退出后会被正常回收,不会变僵尸。

text 复制代码
父进程活着,子进程死了 + 不回收  →  僵尸(有害,占 PCB)
父进程死了,子进程还活着          →  孤儿(无害,被 init 收养)

这两个概念经常被搞混,记住一句话就行:看谁先死。


五、优先级:CPU 只有一个,进程有几百个

5.1 为什么需要优先级

CPU 资源只有少量,甚至只有一个,而系统里的进程数目众多。这就是竞争性------进程之间天然在抢资源。

为了「高效完成任务,更合理地竞争相关资源」,就有了优先级:优先权高的进程有优先执行的权利。

补充两个必须一起理解的概念:

独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。你写的程序里一个野指针崩了,不会影响隔壁 QQ 跑得好好的------这就是独立性,靠的是各自独立的地址空间(第八节细讲)。

并行 vs 并发(这两个概念每年都有人弄混):

并行(Parallel) 并发(Concurrent)
CPU 数量 多个 一个
机制 真正同时运行 快速切换,轮流推进
宏观感受 同时 「看起来」同时
例子 4 核跑 4 个计算任务 1 核跑 100 个进程
text 复制代码
并行(2 核 2 进程):
CPU0: ──A──A──A──A──
CPU1: ──B──B──B──B──      真同时

并发(1 核 2 进程):
CPU0: ─A─B─A─B─A─B──      切换,宏观上都在推进

一句话:并发是逻辑上的同时,并行是物理上的同时。单核机器上你说的「同时聊微信和听歌」是并发,不是并行。

5.2 PRI 和 NI

ps -l 能看到几个关键字段:

bash 复制代码
$ ps -l
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY      TIME CMD
0 S  1000  1234  1233  0  80   0 -  1234 wait   pts/0 00:00:00 bash
字段 含义
UID 执行者身份
PID 进程代号
PPID 父进程代号
PRI 可被执行的优先级,值越小越早被执行
NI nice 值,优先级的修正数值

公式:

text 复制代码
PRI(new) = PRI(old) + nice

几个要点:

  1. nice 值为负 时,PRI(new) 变小 → 优先级变高 → 更快被执行。
  2. nice 的取值范围是 -20 到 19,一共 40 个级别。
  3. 所以调优先级,在 Linux 下就是调 nice 值。
  4. nice 不是优先级,它是优先级的修正数据。这个区别在面试里经常被拿来挖坑。

一个容易踩的坑 :课件里说 PRI(old) 是固定的 80,每次调整都是在 80 的基础上加 nice,而不是在「上一次调整后的值」上累加。也就是说,renice 是覆盖 而不是叠加 。你把 nice 从 0 调到 -5,PRI 变 75;再调到 5,PRI 是 85,不是 80。原因是内核里 PRI = 80 + nice 是算出来的,不是存下来的。

补充一个显示口径的问题 :ps 里看到的 PRI 基准是 80,而内核代码里普通优先级范围是 100~139(nice 0 对应 120)。两者差 40,纯粹是历史遗留的显示习惯。看到「100~139」和「80」不要觉得矛盾,它们是同一样东西的两种刻度。

5.3 怎么改

方法一:top 交互式

bash 复制代码
top
# 按 r → 输入 PID → 输入 nice 值

方法二:命令行

bash 复制代码
nice -n 10 ./a.out        # 启动时指定
renice -n -5 -p 1234      # 修改已运行进程

方法三:系统调用

c 复制代码
#include <sys/time.h>
#include <sys/resource.h>

int getpriority(int which, int who);
int setpriority(int which, int who, int prio);

which 可以是 PRIO_PROCESS(按进程)、PRIO_PGRP(按进程组)、PRIO_USER(按用户)。

权限规则要记住 :普通用户只能调高 nice(降低优先级) ,想调低(提高优先级)必须 root。这是为了保护系统------否则谁都能把自己设成 -20,把别人饿死。这个规则和「普通用户不能 chown 给别人」是同一个思路:不能让用户获得破坏公平性的能力。


六、进程切换与 O(1) 调度

6.1 上下文切换到底是什么

CPU 上下文切换(context switch),实际含义就是任务切换,或者叫 CPU 寄存器切换。

过程:

text 复制代码
1. 内核决定要换人了
2. 把当前任务的 CPU 寄存器全部内容,保存到该任务自己的栈(或 PCB)里
   → 这就是"上下文"
3. 把下一个任务的寄存器内容,从它的栈里重新装回 CPU 寄存器
4. 跳到新任务的代码继续执行

为什么必须保存寄存器?因为 CPU 只有一套寄存器,而进程有几百个。寄存器里存着程序计数器(下一条指令在哪)、栈指针(栈顶在哪)、各种临时变量。这些一旦被覆盖,进程就再也回不来了。

text 复制代码
进程 A 运行中         切换             进程 B 运行
──────────────      ──────          ──────────────
CPU 寄存器:                          CPU 寄存器:
  eip = 0x401000                       eip = 0x402000
  esp = 0x7ffd1000                     esp = 0x7ffd2000
  eax = 42                             eax = 7
       │                                   ▲
       │ ① 全存进 A 的栈/PCB                │ ② 从 B 的栈/PCB 恢复
       ▼                                   │
  A.stack: [eip,esp,eax...]          B.stack: [eip,esp,eax...]

6.2 时间片

当代计算机都是分时操作系统 ,每个进程有它合适的时间片(本质就是一个计数器)。时间片一到,进程就被操作系统从 CPU 上剥离下来,换下一个。

这个「剥离」是强制的,进程自己无法拒绝(除非它在内核态做不可中断的关键操作,也就是 D 状态的那段时间)。

顺带说一句切换的成本 :上下文切换不是免费的。除了保存恢复寄存器,更贵的是 Cache 和 TLB 失效------新进程的数据不在 Cache 里,得从内存重新加载;新进程的页表映射不在 TLB 里,得重新查页表。一次切换的代价在微秒级,看着不多,但一秒切几万次就非常可观。这就是为什么「进程不是越多越好」,也是后来线程(共享地址空间,切换不用换页表)被发明出来的重要原因。

6.3 Linux 2.6 的 O(1) 调度

这是本章最抽象的一块,我们拆成几层看。

第一层:一个 CPU 一个 runqueue

每个 CPU 有自己的运行队列(runqueue)。

text 复制代码
CPU0 ──► runqueue0 ──► 待运行的进程们
CPU1 ──► runqueue1 ──► 待运行的进程们

为什么不让所有 CPU 共用一个?因为共享就要加锁,加锁就有竞争,竞争就慢。一个 CPU 一个队列,大多数操作都是本地的,不需要锁。

代价是出现了负载均衡 问题:如果 CPU0 排了 100 个进程,CPU1 只有 1 个,那就浪费了。所以需要一套机制在 CPU 之间迁移进程。课件只点了一句「如果有多个 CPU 就要考虑进程个数的负载均衡问题」,实际内核里有 sched_domain、load_balance 一整套逻辑。

第二层:140 个优先级队列
c 复制代码
struct prio_array {
    unsigned int  nr_active;              /* 有多少个可运行的进程 */
    DECLARE_BITMAP(bitmap, MAX_PRIO + 1); /* 每个优先级一个比特位 */
    struct list_head queue[MAX_PRIO];     /* 140 个链表头 */
};
  • 普通优先级:100 ~ 139(我们的进程都在这,正好对应 nice 的 -20~19)
  • 实时优先级:0 ~ 99(实时进程,本章不关心)

queue[140] 是个数组,数组下标就是优先级 。相同优先级的进程在同一个链表里,按 FIFO 规则排队。

text 复制代码
queue[0]   → (空,实时优先级)
...
queue[100] → [进程A] → [进程B]        ← nice = -20
...
queue[120] → [进程C]                  ← nice = 0
...
queue[139] → [进程D] → [进程E]        ← nice = 19
第三层:怎么找下一个该跑的进程

朴素做法:从下标 0 开始遍历 queue[140],找到第一个非空的队列,取它的队头。

c 复制代码
for (int i = 0; i < 140; i++) {
    if (!list_empty(&queue[i])) {
        next = list_first_entry(&queue[i]);
        break;
    }
}

这是常数时间(最多 140 次比较,与进程数量无关),但课件评价得很直接:「时间复杂度是常数!但还是太低效了!」

为什么低效?因为 140 次循环每次都跑,而实际上绝大多数队列是空的。CPU 的时间应该花在进程上,不是花在找进程上。

第四层:bitmap 的妙用
text 复制代码
bitmap[5]: 一共 140 个优先级,用 5*32 个比特位表示队列是否为空

32 位平台上 unsigned long 是 32 位,5 个 long = 160 个比特位,够表示 140 个优先级还剩下 20 个位置。哪个队列非空,就把对应的比特位置 1:

text 复制代码
bitmap:  0000 0000 ... 0001 0000 0001 0000 ...
                        ↑           ↑
                     queue[120]  queue[128]

查找:不遍历数组,直接找"第一个为 1 的比特位"
     → 一条 CPU 指令(bsf / __ffs)搞定
     → 真正的 O(1)

从 140 次循环压缩到一条位扫描指令,这就是「提高查找效率」的全部含义。

补一句 64 位的情况 :现在都是 64 位机器,unsigned long 是 64 位,DECLARE_BITMAP(bitmap, MAX_PRIO+1) 会自动算成 3 个 long(141/64 向上取整)。课件里的 bitmap[5] 是 32 位时代的写法。看内核代码时要注意这种跟着平台走的宏。

第五层:active 和 expired 双队列

问题来了:如果只有一套队列,一个进程时间片用完了,把它放回原队列,那它可能马上又被选中------因为它的优先级还是最高的。结果是高优先级进程把 CPU 全吃掉,低优先级进程饿死。

解法是两套队列:

c 复制代码
struct rq {
    ...
    struct prio_array *active, *expired, arrays[2];
    ...
};
  • active 队列:时间片还没结束的进程
  • expired 队列:时间片已经耗尽的进程

新进程进来放 active。跑完时间片就挪到 expired,并且重新计算时间片 。等 active 队列被处理空了(nr_active == 0),把 active 和 expired 两个指针交换:

text 复制代码
交换前:
  active  ──► arrays[0](已空)
  expired ──► arrays[1](塞满了时间片耗尽的进程,时间片已重算)

交换后:
  active  ──► arrays[1](这批进程又满血复活了)
  expired ──► arrays[0](空,等着接收新的耗尽者)

这个交换只是一次指针赋值,O(1)。 不需要搬数据、不需要重建队列。

这就是 O(1) 调度器的核心技巧:把所有 O(n) 的操作(重算时间片、重新排队)都摊到单个进程身上,把全局操作压缩成 O(1) 的指针交换。

闭环:为什么叫 O(1)

课程总结说得很清楚:

在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度 O(1) 算法。

注意关键词是不随进程增多而增加 。进程从 10 个涨到 10 万个,选下一个该跑谁的时间不变。这在 2.6 时代是个了不起的突破,因为之前的调度器(O(n) 调度器)每次调度都要遍历整个进程链表,进程一多就卡。

补一段课件外的背景 :O(1) 调度器后来被完全公平调度器(CFS)取代了(2.6.23 起)。原因不是 O(1) 不够快,是它不公平 ------它靠「优先级 + 固定时间片」分配 CPU,对交互式进程(比如你敲键盘的那个终端)响应不够好,而且睡眠惩罚机制很粗糙。CFS 换成了红黑树 + 虚拟运行时间(vruntime),查找是 O(log n),虽然理论上慢了,但公平性和交互体验好得多。

学 O(1) 调度器的价值不在于它现在还在用,而在于它展示了「用数据结构把复杂度打下来」的思路------bitmap 位扫描、双队列指针交换,这两个技巧在很多地方都能见到。


七、命令行参数与环境变量

7.1 环境变量是什么

**环境变量(environment variables)**一般是指在操作系统中用来指定运行环境的一些参数。

课件举了个特别好的例子:我们写 C/C++ 代码时,链接的时候从来不知道所链接的动态静态库在哪里,但照样能链接成功------原因就是有相关环境变量帮编译器查找。

环境变量有两个特点:

  1. 通常具有某些特殊用途(不是给你随便存的)
  2. 通常具有全局特性(可以被所有进程看到、被子进程继承)

7.2 三个必知的环境变量

变量 含义
PATH 指定命令的搜索路径
HOME 用户的主工作目录(登录时默认进入的目录)
SHELL 当前 shell,值通常是 /bin/bash

查看方式:echo $NAME

bash 复制代码
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

$ echo $HOME
/home/y3562

$ echo $SHELL
/bin/bash

7.3 一个实验:为什么 ls 不用带路径,./hello 就要

bash 复制代码
$ cat hello.c
#include <stdio.h>
int main()
{
    printf("hello world!\n");
    return 0;
}

$ gcc hello.c -o hello
$ ./hello
hello world!
$ hello
bash: hello: command not found

为什么 ls 直接敲就能跑,hello 必须加 ./?

因为 shell 执行命令时,会去 PATH 里列的每个目录下找同名文件 。ls 在 /usr/bin/ls,/usr/bin 在 PATH 里,所以找到了。而你的 hello 在当前目录,当前目录不在 PATH 里 (注意:. 默认不在 PATH 中,这是刻意的安全设计),所以找不到。

验证一下 PATH 的作用:

bash 复制代码
$ export PATH=$PATH:$PWD
$ hello
hello world!

把当前目录加进 PATH,hello 就能直接跑了。

为什么不把 . 默认放进 PATH? 因为那等于把「当前目录下的任何可执行文件」都变成「可信命令」。你在 /tmp 下解压了一个压缩包,里面有个叫 ls 的恶意程序,你敲 ls 就中招了。这是经典的安全设计取舍。

还有另一个办法能不带路径运行:把程序放到 PATH 里已有的目录 (比如 /usr/local/bin,需要 root 权限)。

7.4 和环境变量相关的命令

命令 作用
echo $NAME 显示某个环境变量的值
export NAME=value 设置一个新的环境变量
env 显示所有环境变量
unset NAME 清除环境变量
set 显示本地定义的 shell 变量和环境变量

注意 env 和 set 的区别:env 只显示环境变量,set 显示 shell 变量 + 环境变量。这个区别是 7.7 节实验的关键。

7.5 环境表是怎么组织的

每个程序都会收到一张环境表:

text 复制代码
        环境表 (char *env[])
        ┌──────────┐
env[0]  │    *─────┼──► "PATH=/usr/local/sbin:..."
        ├──────────┤
env[1]  │    *─────┼──► "HOME=/home/y3562"
        ├──────────┤
env[2]  │    *─────┼──► "SHELL=/bin/bash"
        ├──────────┤
 ...    │   ...    │
        ├──────────┤
env[n]  │   NULL   │   ← 结尾标记
        └──────────┘

环境表是一个字符指针数组 ,每个指针指向一个以 '\0' 结尾的环境字符串,格式是 NAME=value。数组以 NULL 结尾------所以遍历时可以写 for (i = 0; env[i]; i++),靠的就是最后那个空指针。

这个结构和 argv 完全一样,都是 char *[] + NULL 结尾。这不是巧合,它们在内核里就是挨着放的,都在用户栈的最顶端(第八节会看到它们的地址)。

7.6 代码里怎么拿环境变量

方式一:main 的第三个参数

c 复制代码
#include <stdio.h>

int main(int argc, char *argv[], char *env[])
{
    int i = 0;
    for (; env[i]; i++) {
        printf("%s\n", env[i]);
    }
    return 0;
}

main 其实可以有三个参数,第三个就是环境表。这个参数是启动代码(crt0)帮你传进来的,不是你写了才有。

方式二:第三方变量 environ

c 复制代码
#include <stdio.h>

int main(int argc, char *argv[])
{
    extern char **environ;
    int i = 0;
    for (; environ[i]; i++) {
        printf("%s\n", environ[i]);
    }
    return 0;
}

libc 里定义了一个全局变量 environ,指向环境变量表。它没有包含在任何头文件里 ,所以用的时候必须自己 extern 声明。这是个历史遗留问题,但面试可能会问。

方式三:getenv(最常用)

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
    printf("%s\n", getenv("PATH"));
    return 0;
}

getenv 按名字查,返回指向值部分的指针(NAME= 后面那段),找不到返回 NULL。配套的还有 putenv/setenv,用来设置。

7.7 全局性:能不能被子进程继承

课件这个实验很经典,值得亲手做一遍。

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
    char *env = getenv("MYENV");
    if (env) {
        printf("%s\n", env);
    }
    return 0;
}

第一次跑:

bash 复制代码
$ ./a.out
$                        # 没输出,说明 MYENV 不存在

导出后再跑:

bash 复制代码
$ export MYENV="hello world"
$ ./a.out
hello world

结论:环境变量可以被子进程继承。

然后做对比实验(课件留的思考题):

bash 复制代码
$ MYENV2="hello world"    # 只赋值,不 export
$ ./a.out                  # 读 MYENV2 → 读不到

为什么?这里牵扯到 shell 的一个关键区分:

shell 变量(本地变量) 环境变量
定义方式 NAME=value export NAME=value
存在位置 只在当前 shell 进程内 在 shell 的环境表里
能否被子进程继承 不能 能
env 能看到吗 不能 能
set 能看到吗 能 能

fork 出的子进程只继承环境表 ,不继承 shell 的普通变量。export 的作用就是把一个本地变量「提升」到环境表里。

这也解释了另一个现象:

bash 复制代码
$ bash              # 开一个子 shell
$ echo $MYENV2      # 空
$ exit
$ echo $MYENV2      # 有值(回到父 shell 了)

再补一个延伸 :想让变量永久生效,得写进配置文件。~/.bashrc(每次开交互式 shell 读)、~/.bash_profile(登录时读)、/etc/profile(全局)。但要注意:改了 ~/.bashrc 之后必须 source ~/.bashrc 或者重新登录,否则当前 shell 用的是老环境。

7.8 留一个问题:环境表到底存在哪

到这里,「环境变量能被子进程继承」这件事已经被实验证实了。但还有一个问题没答:

这张环境表,在进程的内存里究竟放在什么位置?为什么它天然就能被继承?

这不是一个玄学问题,它有精确的答案------就写在进程地址空间的最顶端。下篇第二章打印地址时会看到它。


八、上篇小结

8.1 一条主线

text 复制代码
硬件:冯诺依曼 → 所有设备只和内存打交道 → 必须有中间人协调
   ↓
中间人:操作系统 → 管理 = 描述(struct)+ 组织(链表/树)
   ↓
管理对象:进程 → 描述成 task_struct,组织成链表
   ↓
进程活着会怎样:
   ① 有状态(R/S/D/T/X/Z)→ 状态字段存在 task_struct 里
   ② 抢 CPU(PRI/NI)    → 优先级字段存在 task_struct 里
   ③ 被切换(上下文)    → 上下文存在 task_struct 里

这一篇的所有内容,都可以归到「描述 + 组织」这四个字上。 冯诺依曼是为什么要描述,操作系统是怎么描述,进程是描述的结果,状态/优先级/上下文是描述的具体字段,调度是组织的方式。

8.2 复习清单

冯诺依曼

  • 为什么 CPU 不能直接读硬盘?DMA 算不算打破了这个规则?
  • 从你敲键盘到对方看到消息,数据在内存里走了几趟?

操作系统

  • 「管理 = 描述 + 组织」,在进程管理里具体对应什么?
  • printf 和 write 是什么关系?malloc 和 brk 呢?
  • 为什么需要库函数这一层,不能直接调系统调用?

进程

  • 程序和进程的区别?一个程序能有几个进程?
  • task_struct 里的「上下文数据」是干什么用的?不存会怎样?
  • fork 为什么有两个返回值?if/else 为什么「都成立」?
  • 写时拷贝解决了什么问题?如果不做 COW 会怎样?
  • fork 之后父子谁先跑?

状态

  • task_state_array 为什么是 0/1/2/4/8/16/32?
  • R 状态等于「正在 CPU 上跑」吗?
  • D 状态为什么 kill -9 杀不掉?
  • 僵尸进程占的是什么资源?为什么不立刻释放?
  • 僵尸和孤儿的区别,一句话说清?

优先级与调度

  • nice 是优先级吗?范围是多少?
  • renice 是叠加还是覆盖?
  • 并发和并行的区别?
  • O(1) 调度里的 bitmap 省掉了什么操作?
  • active/expired 双队列为什么能防止饿死?

下篇预告

上篇有一个问题从头到尾没回答:

fork 之后,父子进程打印同一个变量,地址一模一样,值却不一样。

这用「内存就是一块连续的物理空间」这个直觉完全解释不了------同一个门牌号,不可能进到两个不同的房间。

下篇就从这个矛盾开始,一路讲到 mm_struct、VMA、两级页表、PTE 位布局、缺页中断和写时拷贝。见《Linux 进程概念(下):程序地址空间与虚拟内存》。


如果这篇帮你把「进程是什么」理顺了,点个赞再走。

相关推荐
Z21-X1 小时前
云原生运维笔记:硬核拆解OpenStack计算与存储——从主机聚合到Cinder挂载全流程
运维·笔记·学习·云原生·openstack
邪修king1 小时前
Re:Linux系统篇(三十八):进程间通信ChapterSystem V 信号量深度解析|同步互斥、内核源码、C 指针多态、实战代码
java·linux·c语言
开开心心就好1 小时前
电脑语音实时转文字工具,会议记录很省事
运维·服务器·前端·c#·ocr·perl·1024程序员节
坤坤子吖1 小时前
Linux网络编程基础:从 MAC 到 IP,理解网络数据是怎么传输的
linux·网络·笔记·学习
一只鹿鹿鹿1 小时前
医院绩效管理体系方案
大数据·运维·安全·web安全·制造
java_logo1 小时前
Docker 部署 Portainer CE 中文版:轻松搭建容器管理平台
运维·docker·容器·portainer·容器管理·轩辕镜像·portainer ce
山东孙燕姿1 小时前
前端框架升级:不敢动的组件怎么办
大数据·运维·服务器·网络·人工智能·前端框架
古法老登瞎折腾9 小时前
大数据智能运维实战(十一):运维 MCP 的工程化——权限、限流、审计与错误处理
java·大数据·运维·spring ai·mcp
琥珀.10 小时前
mysql中json数组转列表查询
linux·运维·服务器