博主智算菩萨,专注于人工智能、Python编程、音视频处理及UI窗体程序设计等方向。致力于以通俗易懂的方式拆解前沿技术,从零基础入门到高阶实战,陪伴开发者共同成长。目前已开设五大技术专栏,累计发布多篇原创技术文章,深受读者好评。

📌 专栏导航

  • 人工智能前沿知识(已更144篇):深度剖析Transformer架构、生成式AI、强化学习、具身智能、神经符号系统、大模型及智能体(Agent)技术,系统性解析AI核心技术体系与前沿趋势。
  • Python基础小白编程(已更232篇):从零开始,以保姆式教程讲解变量、数据类型、流程控制、函数等核心语法,配有大量实战代码与避坑指南,真正做到学以致用。
  • 机器学习与深度学习(125篇):系统化拆解线性模型、决策树、随机森林、梯度提升树、神经网络等算法原理与工程实践,覆盖从公式推导到代码实现的全链路内容。
  • 音频、图像与视频处理理论与实战(81篇):涵盖FFmpeg多媒体处理、audio_shop开源工具、ComfyUI-WanVideoWrapper视频生成等实用技术,从基础操作到高级应用一应俱全。
  • UI窗体程序设计实战(78篇):深入讲解UI设计、动态窗体生成、游戏UI框架设计等实战技巧,提供从配置到编码的完整解决方案。
    智算菩萨,以代码为经,以算法为纬,在人工智能的星辰大海中,做你前行路上最可靠的导航者。本人最常用的AI对话工具是AIGCBAR

进程是操作系统中最核心的抽象概念之一,也是资源分配和调度的基本单位。Linux内核的进程管理子系统负责进程的创建、销毁、调度和同步,是整个操作系统运转的引擎。调度器作为进程管理子系统的核心组件,决定了哪个进程在何时获得CPU时间,直接影响着系统的吞吐量、响应性和公平性。自Linux 2.6.23版本引入CFS(Completely Fair Scheduler)完全公平调度器以来,Linux的进程调度进入了一个全新的时代。本讲将深入剖析Linux进程管理的方方面面,从进程的内核表示到CFS调度算法的数学模型,从进程的生命周期到调度策略的选择,帮助读者建立对Linux进程管理机制的系统性理解。

1 进程与线程的内核表示

在Linux内核中,进程和线程的区分并不像在其他操作系统中那样严格。Linux采用了"统一进程/线程表示"的设计哲学——线程被视为与其他线程共享地址空间的进程。这种设计极大地简化了内核的实现,因为调度器不需要区分进程和线程,它们都使用相同的数据结构和调度逻辑。

Linux内核使用task_struct结构体来表示进程和线程,这是内核中最重要的数据结构之一。task_struct包含了内核管理一个进程所需的全部信息,其字段超过300个,可以分为以下几大类:

类别 关键字段 功能描述
进程标识 pid, tgid, ppid, comm 进程ID、线程组ID、父进程ID、进程名
进程状态 state, exit_state 进程当前状态(运行、睡眠、停止等)
调度信息 prio, static_prio, rt_priority, sched_class 优先级、调度类
内存管理 mm, active_mm, stack 进程地址空间描述符、内核栈
信号处理 signal, blocked, pending 信号处理函数、信号掩码、待处理信号
文件系统 fs, files 根目录、当前目录、打开文件表
进程关系 parent, children, sibling, group_leader 父子进程关系、线程组组长
时间统计 utime, stime, start_time 用户态时间、内核态时间、启动时间
上下文信息 thread_info, cpu_context 体系结构相关的上下文保存区

task_struct的分配和释放是内核中频繁的操作。早期Linux内核直接从slab分配器分配task_struct结构体,而从2.6内核开始,task_struct的分配方式发生了变化——内核为每个进程分配两个连续的物理页面(8KB),其中底部约1KB用于thread_info结构体,其余空间用作内核栈,而task_struct则通过slab分配器动态分配。这种设计的好处是内核栈的大小固定且容易检测栈溢出,同时task_struct的大小不受内核栈空间的限制。在更近的内核版本中,内核栈的大小增加到了16KB(4个页面),以适应更复杂的内核调用路径。

进程标识符(PID)是用户空间识别进程的主要方式。在Linux中,每个进程都有一个唯一的PID,但PID的分配和管理比表面看起来复杂得多。内核内部使用pid结构体来管理PID,支持多个PID命名空间。在容器化场景中,同一个进程在不同命名空间中可能拥有不同的PID。线程组ID(TGID)是另一个重要的标识符——属于同一进程的多个线程共享相同的TGID,其中主线程的PID等于TGID。用户空间通过getpid()系统调用获取的实际上是TGID而非PID,而gettid()系统调用返回的才是真正的PID。

进程的内核栈是内核执行进程相关操作时使用的栈空间。每个进程拥有独立的内核栈,当进程通过系统调用或中断进入内核态时,CPU自动切换到该进程的内核栈。内核栈的大小有限(通常为8KB或16KB),因此内核代码中必须严格控制栈的使用深度,避免递归调用过深或在栈上分配大对象。内核提供了stack_not_used()函数来检测内核栈的剩余空间,以及add_taint()函数在栈溢出时标记内核状态。

2 进程状态与生命周期

Linux进程在其生命周期中会在多种状态之间转换,理解这些状态及其转换条件是掌握进程管理的基础。Linux内核定义了以下进程状态:

状态常量 含义 描述
TASK_RUNNING 0 可运行 进程正在执行或等待CPU
TASK_INTERRUPTIBLE 1 可中断睡眠 进程等待事件,可被信号唤醒
TASK_UNINTERRUPTIBLE 2 不可中断睡眠 进程等待事件,不可被信号唤醒
TASK_STOPPED 4 停止 进程收到SIGSTOP等信号后停止
TASK_TRACED 8 被跟踪 进程被调试器跟踪
EXIT_ZOMBIE 32 僵尸 进程已终止但父进程尚未回收
EXIT_DEAD 16 死亡 进程终态,即将被回收

TASK_RUNNING状态需要特别说明——它包含了两种子状态:正在CPU上运行的进程和已经在运行队列中等待CPU的进程。这两种子状态在内核中使用相同的state值来表示,但可以通过p->on_rq字段来区分。当进程正在运行时,on_rq被设置为1(或特定值),表示进程在运行队列上;当进程被调度器选中执行时,它仍然保持TASK_RUNNING状态,但从运行队列中移除。

TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE是两种不同的睡眠状态,它们的区别在于对信号的响应方式。处于TASK_INTERRUPTIBLE状态的进程可以被信号(如SIGKILL、SIGINT)唤醒,这使得用户可以通过Ctrl+C中断一个等待I/O的进程。而处于TASK_UNINTERRUPTIBLE状态的进程不会响应信号,只能被它所等待的事件唤醒。这种状态通常用于进程正在执行不可中断的底层操作(如等待磁盘I/O完成)时,确保这些操作不会被信号中断而导致数据不一致。TASK_KILLABLE是TASK_UNINTERRUPTIBLE的一个变体(通过TASK_WAKEKILL标志实现),它允许致命信号(SIGKILL)唤醒进程,但忽略其他信号,在NFS等网络文件系统的I/O等待中被广泛使用。

进程状态之间的转换关系可以用以下状态机来描述:

KaTeX parse error: Extra } at position 84: …arrow[\text{调度}}̲{\text{抢占}} \te…

TASK_RUNNING → 等待事件 TASK_INTERRUPTIBLE/UNINTERRUPTIBLE → 事件发生 TASK_RUNNING \text{TASK\_RUNNING} \xrightarrow{\text{等待事件}} \text{TASK\_INTERRUPTIBLE/UNINTERRUPTIBLE} \xrightarrow{\text{事件发生}} \text{TASK\_RUNNING} TASK_RUNNING等待事件 TASK_INTERRUPTIBLE/UNINTERRUPTIBLE事件发生 TASK_RUNNING

TASK_RUNNING → SIGSTOP TASK_STOPPED → SIGCONT TASK_RUNNING \text{TASK\_RUNNING} \xrightarrow{\text{SIGSTOP}} \text{TASK\_STOPPED} \xrightarrow{\text{SIGCONT}} \text{TASK\_RUNNING} TASK_RUNNINGSIGSTOP TASK_STOPPEDSIGCONT TASK_RUNNING

TASK_RUNNING → exit() EXIT_ZOMBIE → wait() EXIT_DEAD \text{TASK\_RUNNING} \xrightarrow{\text{exit()}} \text{EXIT\_ZOMBIE} \xrightarrow{\text{wait()}} \text{EXIT\_DEAD} TASK_RUNNINGexit() EXIT_ZOMBIEwait() EXIT_DEAD

僵尸进程(Zombie Process)是进程生命周期中的一个特殊状态。当子进程调用exit()退出时,它释放了大部分资源(内存、文件描述符等),但保留了task_struct和少量内核栈信息,以便父进程通过wait()系统调用获取子进程的退出状态。如果父进程没有调用wait(),子进程就会一直停留在EXIT_ZOMBIE状态,成为僵尸进程。僵尸进程不占用CPU和内存资源,但会占用PID和task_struct的slab缓存。大量僵尸进程会导致PID耗尽,使系统无法创建新进程。解决僵尸进程的方法有两种:修复父进程使其正确调用wait(),或者杀死父进程使僵尸进程被init进程收养并回收。

进程的创建通过fork()、vfork()和clone()系统调用来实现。这三个系统调用最终都调用内核的_do_fork()函数,但参数不同。fork()创建子进程时复制父进程的全部地址空间,使用写时复制(Copy-on-Write, COW)技术延迟实际的物理页面复制。vfork()创建子进程时不复制地址空间,子进程与父进程共享地址空间,子进程必须立即调用exec()或_exit(),在此之前父进程被阻塞。clone()是Linux特有的系统调用,通过flags参数精确控制父子进程之间共享哪些资源,是pthread线程库的底层实现基础。

clone()系统调用的flags参数是理解Linux进程/线程统一模型的关键。以下表格列出了主要的clone标志及其含义:

Clone标志 含义 效果
CLONE_VM 共享内存空间 父子进程共享同一地址空间(线程)
CLONE_FS 共享文件系统信息 共享根目录、当前目录、umask
CLONE_FILES 共享文件描述符表 共含打开的文件列表
CLONE_SIGHAND 共享信号处理 共享信号处理函数表
CLONE_THREAD 同一线程组 子进程加入父进程的线程组
CLONE_PARENT 共享父进程 子进程的父进程与调用者的父进程相同
CLONE_NEWPID 新PID命名空间 子进程在新PID命名空间中运行
CLONE_NEWNET 新网络命名空间 子进程在新网络命名空间中运行

当clone()使用CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD标志时,创建的就是一个线程——它与调用者共享地址空间、文件系统、文件描述符和信号处理,属于同一线程组。当不使用任何共享标志时,创建的就是一个完全独立的进程。这种通过标志位精确控制资源共享程度的设计,是Linux进程/线程统一模型的核心实现机制。

3 进程调度器架构

Linux调度器的架构采用了模块化设计,通过调度类(Scheduling Class)和调度实体(Scheduling Entity)两个核心抽象来实现多种调度策略的共存。这种设计使得不同类型的任务可以使用最适合的调度算法,同时保持调度器代码的清晰和可维护性。

调度类是Linux调度器架构的核心抽象。每个调度类实现了一组标准接口函数,调度器根据进程的类型选择相应的调度类来管理。Linux内核定义了以下调度类,按优先级从高到低排列:

调度类 调度策略 适用场景 实现文件
stop_sched_class - 内核停机任务,最高优先级 kernel/sched/stop_task.c
dl_sched_class SCHED_DEADLINE 实时截止时间调度 kernel/sched/deadline.c
rt_sched_class SCHED_FIFO, SCHED_RR 实时进程 kernel/sched/rt.c
fair_sched_class SCHED_NORMAL, SCHED_BATCH 普通进程(CFS) kernel/sched/fair.c
idle_sched_class - 空闲调度,最低优先级 kernel/sched/idle.c

调度类的优先级决定了调度器选择下一个运行进程的顺序。当调度器需要选择下一个运行的进程时,它从最高优先级的调度类开始查找,如果该调度类有可运行的进程,就选择它;否则继续查找下一个优先级的调度类。这意味着stop调度类的进程总是优先于dl调度类,dl优先于rt,rt优先于fair,fair优先于idle。这种设计确保了高优先级的实时进程总是能够抢占低优先级的普通进程。

每个CPU维护一个运行队列(Run Queue),由struct rq结构体表示。运行队列是调度器的核心数据结构,它包含了该CPU上所有可运行进程的信息。rq结构体中为每个调度类维护了独立的子队列:

struct rq {
    raw_spinlock_t lock;
    unsigned int nr_running;
    struct cfs_rq cfs;       // CFS运行队列
    struct rt_rq rt;         // 实时运行队列
    struct dl_rq dl;         // Deadline运行队列
    struct task_struct *curr; // 当前运行的进程
    struct task_struct *idle; // 空闲进程
    u64 clock;               // 运行队列时钟
    // ... 更多字段
};

调度实体的概念将调度对象从task_struct抽象出来。在CFS调度类中,调度实体可以是单个进程(sched_entity)或一组进程(cfs_rq,即调度组)。这种设计支持组调度(Group Scheduling),使得调度器可以在用户、cgroup等不同层级上实现公平调度。例如,在组调度模式下,CFS首先在用户组之间分配CPU时间,然后在每个用户组内部分配给具体的进程,确保不同用户获得公平的CPU份额。

调度器的核心函数是__schedule(),它负责选择下一个运行的进程并完成上下文切换。__schedule()的执行流程可以概括为以下几个步骤:首先,关闭内核抢占,获取当前CPU运行队列的锁;然后,从最高优先级的调度类开始,调用pick_next_task()选择下一个运行的进程;接着,如果选中的进程与当前进程不同,执行context_switch()完成地址空间和栈的切换;最后,释放运行队列锁,开启内核抢占。

上下文切换(Context Switch)是调度器最核心的操作,它包括两个主要部分:地址空间切换和处理器状态切换。地址空间切换通过切换页表基址寄存器(x86上的CR3)来完成,这使得新进程能够访问自己的虚拟地址空间。处理器状态切换通过保存和恢复通用寄存器、程序计数器、栈指针等CPU状态来完成。在x86_64架构上,上下文切换的入口是__switch_to()函数,它利用x86的FS/GS段寄存器来保存进程的thread_info和栈信息。上下文切换的开销通常在1-10微秒之间,具体取决于CPU架构和缓存状态。

4 CFS完全公平调度器原理

CFS(Completely Fair Scheduler)自Linux 2.6.23版本引入以来,一直是Linux默认的进程调度算法。CFS的设计理念源于一个简单而优雅的思想:理想状态下,所有可运行进程应该获得完全公平的CPU时间份额。在理想的处理器上,如果有 n n n个可运行进程,每个进程应该获得 1 n \frac{1}{n} n1的CPU时间,就好像它们在同时并行执行一样。

当然,现实中的处理器一次只能执行一个进程(在单个核心上),因此CFS通过维护每个进程的虚拟运行时间(Virtual Runtime, vruntime)来近似实现这种公平性。vruntime记录了进程在虚拟意义上已经运行了多长时间。CFS的核心调度决策非常简单:总是选择vruntime最小的进程运行,因为它是"最欠"CPU时间的进程。

虚拟运行时间的计算考虑了进程的优先级(权重)。高优先级进程的vruntime增长较慢,因此它能够更频繁地被调度执行;低优先级进程的vruntime增长较快,因此它被调度的频率较低。vruntime的更新公式为:

vruntime + = delta_exec × N I C E _ 0 _ L O A D weight \text{vruntime} += \text{delta\_exec} \times \frac{NICE\_0\_LOAD}{\text{weight}} vruntime+=delta_exec×weightNICE_0_LOAD

其中,delta_exec是进程实际运行的时间增量,NICE_0_LOAD是nice值为0对应的权重(1024),weight是当前进程的权重。这个公式确保了不同优先级进程的vruntime增长速率不同,从而实现了按权重分配CPU时间的效果。

进程的权重与nice值之间存在映射关系。Linux内核定义了一个优先级到权重的转换表(prio_to_weight数组),nice值从-20到+19对应不同的权重值。nice值每增加1,权重减少约10%,CPU份额也相应减少约10%。这意味着nice值为0的进程获得的CPU时间是nice值为1的进程的约1.1倍。nice值与权重的对应关系如下表所示:

Nice值 权重 相对CPU份额
-20 88761 约88.7倍
-10 9548 约9.5倍
-5 3121 约3.1倍
0 1024 基准(1倍)
5 335 约0.33倍
10 110 约0.11倍
19 15 约0.015倍

CFS使用红黑树(Red-Black Tree)来组织可运行进程,这是CFS设计的另一个关键创新。红黑树是一种自平衡二叉搜索树,它保证了树的高度为 O ( log ⁡ n ) O(\log n) O(logn),因此查找、插入和删除操作的时间复杂度都是 O ( log ⁡ n ) O(\log n) O(logn)。在CFS的红黑树中,进程按照vruntime从小到大排列,最左边的节点就是vruntime最小的进程,也就是下一个应该被调度的进程。

选择红黑树而非其他数据结构(如堆或跳表)是经过深思熟虑的设计决策。红黑树相比堆的优势在于:堆虽然查找最小值的时间复杂度为 O ( 1 ) O(1) O(1),但删除和插入操作的时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn),而且在删除最小元素后需要重新调整堆结构。更重要的是,CFS需要频繁地删除和重新插入进程(当进程睡眠和唤醒时),红黑树在这些操作上的性能更加稳定。红黑树相比跳表的优势在于:红黑树的实现更加紧凑,内存开销更小,且在Linux内核中已经有了经过充分测试的实现(lib/rbtree.c)。

CFS的时间片计算方式与传统调度器有着本质区别。传统调度器(如Linux早期的O(1)调度器)为每个进程分配固定的时间片,时间片用完后进程被抢占。CFS则不使用固定时间片,而是通过"调度延迟"(sched_latency_ns,默认6ms)来动态计算时间片。调度延迟定义了所有可运行进程至少运行一次所需的时间。如果有 n n n个可运行进程,每个进程的时间片为:

time_slice = sched_latency_ns × weight ∑ i = 1 n weight i \text{time\_slice} = \frac{\text{sched\_latency\_ns} \times \text{weight}}{\sum_{i=1}^{n} \text{weight}_i} time_slice=i=1nweightisched_latency_ns×weight

当可运行进程数量超过某个阈值(sched_nr_latency,默认8)时,CFS会使用最小粒度(sched_min_granularity_ns,默认0.75ms)来确保每个进程至少运行最小粒度的时间:

time_slice = max ⁡ ( sched_latency_ns n , sched_min_granularity_ns ) × weight ∑ i = 1 n weight i \text{time\_slice} = \max\left(\frac{\text{sched\_latency\_ns}}{n}, \text{sched\_min\_granularity\_ns}\right) \times \frac{\text{weight}}{\sum_{i=1}^{n} \text{weight}_i} time_slice=max(nsched_latency_ns,sched_min_granularity_ns)×i=1nweightiweight

这种动态时间片计算方式确保了:当系统负载较轻时,每个进程获得较大的时间片,减少上下文切换的开销;当系统负载较重时,每个进程获得较小的时间片,确保所有进程都能及时得到调度。

CFS的抢占决策基于vruntime的比较。当进程运行时,其vruntime不断增长。调度器通过定时中断(tick)周期性地检查当前进程是否应该被抢占。抢占条件为:如果运行队列中存在vruntime比当前进程小超过sched_wakeup_granularity_ns(默认1ms)的进程,则当前进程被抢占。这个"唤醒粒度"的设计避免了过于频繁的抢占——如果两个进程的vruntime差距很小,频繁切换的开销可能超过公平性带来的收益。

5 CFS调度器的核心数据结构

CFS调度器的实现依赖于几个核心数据结构,它们之间的关系构成了CFS运行机制的骨架。理解这些数据结构是深入理解CFS实现细节的前提。

sched_entity是CFS的调度实体,每个进程的task_struct中嵌入一个sched_entity。sched_entity的关键字段包括:

字段 类型 含义
load struct load_weight 调度权重(nice值对应的权重)
vruntime u64 虚拟运行时间
run_node struct rb_node 红黑树节点
on_rq unsigned int 是否在运行队列上
exec_start u64 开始执行的时间戳
sum_exec_runtime u64 累计执行时间
prev_sum_exec_runtime u64 上次被迁移时的累计执行时间
cfs_rq struct cfs_rq * 所属的CFS运行队列

cfs_rq是CFS的运行队列,每个CPU的rq结构体中包含一个cfs_rq。cfs_rq的关键字段包括:

字段 类型 含义
tasks_timeline struct rb_root 红黑树根节点
rb_leftmost struct rb_node * 最左节点(vruntime最小)
min_vruntime u64 队列中最小的vruntime
nr_running unsigned int 可运行进程数
load struct load_weight 队列总权重
curr struct sched_entity * 当前运行的调度实体

min_vruntime是cfs_rq中一个非常重要的字段。它不是简单的红黑树中最左节点的vruntime,而是经过单调递增处理的值。min_vruntime的作用是为新唤醒或新创建的进程提供初始vruntime的参考基准。当一个新进程被创建时,其初始vruntime被设置为当前cfs_rq的min_vruntime,这确保了新进程不会因为vruntime为0而立即抢占其他进程。当一个睡眠进程被唤醒时,其vruntime会被调整为max(proc->vruntime, cfs_rq->min_vruntime),这防止了睡眠进程因为vruntime过小而获得过多的CPU时间。

CFS的vruntime更新逻辑在update_curr()函数中实现,这是CFS最频繁调用的函数之一。每当定时中断到来或进程状态变化时,update_curr()都会被调用。其核心逻辑如下:

static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    u64 now = rq_clock_task(rq_of(cfs_rq));
    u64 delta_exec;

    if (unlikely(!curr))
        return;

    delta_exec = now - curr->exec_start;  // 计算实际运行时间
    curr->exec_start = now;

    curr->sum_exec_runtime += delta_exec;  // 更新累计执行时间
    curr->vruntime += calc_delta_fair(delta_exec, curr);  // 更新虚拟运行时间
    update_min_vruntime(cfs_rq);  // 更新队列的min_vruntime
}

calc_delta_fair()函数实现了vruntime的加权计算。对于nice值为0的进程,权重为1024(NICE_0_LOAD),delta_exec直接加到vruntime上。对于其他nice值的进程,vruntime的增量按照权重比例缩放。这种加权机制确保了高优先级进程的vruntime增长较慢,从而获得更多的CPU时间。

6 实时调度策略

除了CFS调度的普通进程外,Linux还支持两种实时调度策略:SCHED_FIFO和SCHED_RR。实时进程的优先级高于所有普通进程,调度器总是优先选择可运行的实时进程。实时调度策略适用于对响应时间有严格要求的场景,如音视频处理、工业控制、实时交易等。

SCHED_FIFO(First-In First-Out)是一种简单的实时调度策略。SCHED_FIFO进程没有时间片限制,它会一直运行直到主动让出CPU(通过sched_yield()或阻塞等待I/O)或被更高优先级的实时进程抢占。同一优先级的SCHED_FIFO进程按照先进先出的顺序执行——当一个SCHED_FIFO进程让出CPU后,同优先级的下一个SCHED_FIFO进程开始运行。

SCHED_RR(Round-Robin)是SCHED_FIFO的时间片轮转变体。SCHED_RR进程在相同优先级之间轮流执行,每个进程运行一个时间片后让给下一个同优先级的进程。时间片的长度可以通过/proc/sys/kernel/sched_rr_timeslice_ms来配置,默认值为100ms。SCHED_RR适用于需要在同优先级实时进程之间公平分配CPU时间的场景。

SCHED_DEADLINE是Linux 3.14引入的最新实时调度策略,它基于EDF(Earliest Deadline First)算法。SCHED_DEADLINE进程需要指定三个参数:运行时间(runtime)、周期(period)和相对截止时间(deadline)。内核保证在每个周期内,进程至少获得runtime的CPU时间,且在deadline之前完成。SCHED_DEADLINE的调度决策基于绝对截止时间——截止时间最早的进程优先执行。SCHED_DEADLINE适用于有严格时序要求的实时任务,如音视频编解码、机器人控制等。

SCHED_DEADLINE的准入控制(Admission Control)是确保系统可调度性的关键机制。当用户尝试设置SCHED_DEADLINE策略时,内核会检查新进程加入后系统是否仍然可调度。可调度性条件基于CPU利用率:

U = ∑ i = 1 n runtime i period i ≤ 1 U = \sum_{i=1}^{n} \frac{\text{runtime}_i}{\text{period}_i} \leq 1 U=i=1nperiodiruntimei1

如果所有SCHED_DEADLINE进程的CPU利用率之和不超过1(即100%),则系统是可调度的。内核在设置SCHED_DEADLINE策略时会验证这个条件,如果违反则拒绝设置。这个条件是EDF算法可调度性的充分条件,由Liu和Layland在1973年的经典论文中证明。

实时进程的优先级范围为1-99,数值越大优先级越高。普通进程的优先级(nice值)范围为-20到+19,映射到内部优先级100-139。实时优先级和普通优先级使用不同的数值空间,但调度器在内部统一处理。实时进程的优先级总是高于普通进程,这意味着即使最低优先级的实时进程(优先级1)也会抢占最高优先级的普通进程(nice值-20)。

实时进程的不当使用可能导致系统不可用——一个CPU密集型的SCHED_FIFO进程如果优先级设置过高,可能会独占CPU,使得系统无法调度其他进程(包括关键的内核线程)。因此,Linux对实时进程的CPU使用施加了限制。通过/proc/sys/kernel/sched_rt_runtime_us和sched_rt_period_us参数,系统管理员可以限制实时进程在每个周期内最多使用的CPU时间。默认配置下,实时进程最多使用95%的CPU时间,剩余5%留给普通进程,确保系统不会因为实时进程的bug而完全失去响应。

7 进程间通信机制

进程间通信(Inter-Process Communication, IPC)是多个进程协同工作的基础。Linux继承了System V IPC和POSIX IPC两套接口,同时提供了管道、信号、套接字等传统通信机制。理解各种IPC机制的特点和适用场景,是进行系统级编程的重要基础。

IPC机制 通信模式 数据传输方式 适用场景 内核对象
管道(Pipe) 半双工 字节流 父子进程间通信 pipe_inode_info
命名管道(FIFO) 半双工 字节流 任意进程间通信 pipe_inode_info
消息队列 双向 结构化消息 异步消息传递 msg_queue
共享内存 双向 内存映射 大量数据高速传输 shmid_kernel
信号量 双向 计数器 资源同步与互斥 sem_array
信号 异步 信号编号 事件通知 siginfo
套接字 双向 字节流/数据报 网络或本地通信 sock

管道是最简单的IPC机制,它创建一个单向数据通道,一端写入数据,另一端读取数据。管道在内核中通过pipe_inode_info结构体实现,本质上是一个固定大小的环形缓冲区(默认64KB)。管道的写入端在缓冲区满时阻塞,读取端在缓冲区空时阻塞。管道只能用于有亲缘关系的进程之间(通常是父子进程),因为管道的文件描述符需要通过fork继承来传递。

命名管道(FIFO)克服了管道只能用于亲缘进程的限制。FIFO在文件系统中创建一个特殊文件,任何知道文件路径的进程都可以打开它进行读写。FIFO的内核实现与管道相同,都使用pipe_inode_info结构体,只是FIFO通过VFS的inode与文件系统路径关联。

共享内存是速度最快的IPC机制,因为它避免了数据在内核空间和用户空间之间的复制。共享内存通过mmap()或shmget()/shmat()系统调用创建,多个进程将同一块物理内存映射到各自的虚拟地址空间中,直接通过内存读写进行通信。共享内存的缺点是需要额外的同步机制来协调对共享内存的并发访问,通常与信号量配合使用。

信号量提供了进程间的同步和互斥机制。Linux支持System V信号量和POSIX信号量两种接口。System V信号量是计数信号量,支持信号量集(多个信号量的组合),操作更加灵活但接口复杂。POSIX信号量分为有名信号量和无名信号量,接口更加简洁。信号量的核心操作是P(wait/proberen)和V(signal/verhogen),P操作将信号量值减1,如果值小于0则阻塞;V操作将信号量值加1,如果有进程在等待则唤醒一个。

信号是Linux中最轻量级的IPC机制,它是一种异步通知机制,用于通知进程某个事件的发生。Linux定义了31种标准信号和多种实时信号。信号的发送通过kill()、sigqueue()等系统调用实现,接收通过信号处理函数实现。信号的处理方式有三种:默认处理(通常是终止进程)、忽略和自定义处理(通过sigaction()注册处理函数)。信号处理的异步性使得它在编写安全代码时需要特别小心——信号处理函数中只能调用异步安全(async-signal-safe)的函数,不能访问全局变量(除非是volatile sig_atomic_t类型),不能调用malloc/free等非可重入函数。

8 进程组、会话与控制终端

进程组(Process Group)和会话(Session)是Linux进程管理中的组织概念,它们在作业控制和终端管理中发挥着重要作用。

进程组是一个或多个进程的集合,同一进程组中的进程共享同一个进程组ID(PGID)。进程组通常由管道连接的进程组成——当一个进程通过管道启动另一个进程时,它们属于同一个进程组。进程组的主要用途是支持作业控制——shell可以将整个进程组作为一个作业来管理,通过killpg()函数向整个进程组发送信号。进程组组长是PGID等于其PID的进程。当进程组组长退出时,进程组不会消失,组中的其他进程继续运行,但进程组变成了孤儿进程组(Orphaned Process Group)。

会话是一个或多个进程组的集合,用于管理一组作业和控制终端。会话ID(SID)等于会话首进程的PID。会话的主要作用是:将一组进程与一个控制终端关联,支持作业控制(前台/后台进程组切换),以及在用户注销时清理所有相关进程。当用户通过终端登录时,登录进程创建一个新会话,并将该终端设为控制终端。会话中的进程分为前台进程组和后台进程组,只有前台进程组可以从控制终端读取输入和接收终端产生的信号(如SIGINT、SIGQUIT)。

守护进程(Daemon)是一种特殊的后台进程,它脱离了控制终端和会话,在后台长期运行。创建守护进程的标准步骤包括:调用fork()创建子进程并让父进程退出;在子进程中调用setsid()创建新会话,使子进程成为会话首进程并脱离控制终端;再次fork()创建孙进程并让子进程退出,确保孙进程不是会话首进程,从而无法重新打开控制终端;更改工作目录为根目录;重设文件权限掩码;关闭所有继承的文件描述符。这些步骤确保了守护进程不会受到终端信号的影响,也不会占用任何终端资源。

9 cgroup与进程资源控制

cgroup(Control Group)是Linux内核提供的进程资源控制机制,它允许系统管理员将进程组织成层次化的组,并对每个组施加资源限制。cgroup是容器技术(Docker、Kubernetes等)的底层基础之一,理解cgroup对于理解现代Linux系统的资源管理至关重要。

cgroup的核心概念包括:层级(Hierarchy)——cgroup的组织结构是一棵树,每个节点是一个cgroup;子系统(Subsystem)——每个子系统控制一种资源类型,如CPU、内存、I/O等;控制组(Control Group)——层级中的每个节点,包含一组进程和一组资源控制参数。

Linux内核支持以下主要的cgroup子系统:

子系统 名称 功能描述
cpu cpu 限制CPU使用份额,支持CFS带宽控制
cpuacct cpuacct 统计CPU使用时间
cpuset cpuset 绑定CPU核心和内存节点
memory memory 限制内存使用量,统计内存使用情况
blkio blkio 限制块设备I/O带宽
devices devices 控制设备访问权限
freezer freezer 冻结/恢复cgroup中的进程
net_cls net_cls 标记网络数据包,用于流量控制
pids pids 限制进程数量

cgroup v1和cgroup v2是两个不兼容的版本。cgroup v1中,每个子系统可以独立挂载到不同的层级上,一个进程可以同时属于多个不同层级的cgroup。这种设计虽然灵活,但导致了语义混乱和管理复杂。cgroup v2采用了统一的层级结构——所有子系统共享同一棵cgroup树,一个进程只能属于一个cgroup。cgroup v2还引入了线程模式(Thread Mode),允许在cgroup内部创建线程子树,用于细粒度的线程级资源控制。

cgroup与调度器的集成是通过CFS带宽控制(CFS Bandwidth Control)机制实现的。CFS带宽控制允许为每个cgroup设置CPU配额(cpu.cfs_quota_us)和周期(cpu.cfs_period_us),cgroup中的进程在每个周期内最多使用配额指定的CPU时间。当cgroup的CPU使用量超过配额时,cgroup中的所有进程被限流(throttled),直到下一个周期开始。这种机制确保了容器或cgroup不会超过其CPU资源限制。

CFS带宽控制的实现依赖于两个关键数据结构:cfs_bandwidth和cfs_rq。cfs_bandwidth维护了cgroup的全局CPU配额和已使用时间,cfs_rq维护了每个CPU上cgroup的本地配额和已使用时间。每个周期开始时,cfs_bandwidth将配额分配给各个CPU的cfs_rq,进程运行时从本地cfs_rq扣除时间,当本地时间用完时从全局cfs_bandwidth补充。当全局配额用完时,cgroup中的所有进程被限流。

10 进程调度性能分析

调度器的性能直接影响系统的整体表现。Linux内核提供了多种工具和接口来分析和调优调度器的行为。

perf是Linux最强大的性能分析工具之一,它可以用于分析调度器的行为。perf sched命令可以记录和报告调度事件,包括进程切换、进程唤醒、调度延迟等。perf sched latency命令显示每个进程的最大调度延迟和平均调度延迟,帮助识别调度延迟过大的进程。perf sched map命令以可视化方式显示每个CPU上的调度情况,帮助发现CPU负载不均衡的问题。

/proc/sched_debug是内核提供的调度器调试接口,它输出了每个CPU运行队列的详细信息,包括当前运行的进程、运行队列中的进程列表、每个进程的vruntime、权重等。这个接口对于理解CFS的运行状态和排查调度问题非常有用。以下是一个简化的输出示例:

cfs_rq[0]:/
  .nr_running                    : 3
  .min_vruntime                  : 123456789.012345
  .load                          : 3072

task   PID   tree-key  switches  prio     exec-runtime         vruntime
R  bash   1234  123456789.0     567   120      89012.345678   123456789.012345
S  vim    5678  123456790.5     234   120      45678.901234   123456790.512345
S  gcc    9012  123456792.1     890   120     123456.789012   123456792.112345

schedstat是另一个有用的调度统计接口,通过/proc/[pid]/schedstat文件提供每个进程的调度统计信息。该文件包含三个数字:CPU时间(纳秒)、等待时间(纳秒)和时间片数量。这些数据可以用于计算进程的平均调度延迟和时间片长度。

调度器的可调参数通过sysctl接口暴露,管理员可以根据工作负载特征进行调优。主要的调度参数包括:

参数 默认值 含义
sched_latency_ns 6000000 (6ms) 调度延迟,所有进程运行一轮的目标时间
sched_min_granularity_ns 750000 (0.75ms) 最小调度粒度,进程的最小运行时间
sched_wakeup_granularity_ns 1000000 (1ms) 唤醒抢占粒度,唤醒进程的vruntime优势阈值
sched_child_runs_first 0 fork后子进程是否先于父进程运行
sched_tunable_scaling 1 调度参数是否随CPU数量自动缩放
sched_migration_cost_ns 500000 (0.5ms) 进程迁移代价估计,影响负载均衡决策

这些参数的调优需要根据具体的工作负载来进行。对于交互式桌面系统,可以减小sched_latency_ns和sched_wakeup_granularity_ns来提高响应性;对于批处理服务器,可以增大这些参数来减少上下文切换的开销。sched_child_runs_first参数在fork密集型工作负载中特别重要——如果设为1,fork后子进程先运行,可以利用COW机制减少页面复制的开销(因为子进程通常会立即调用exec,不需要复制父进程的地址空间)。

11 进程资源限制与统计

Linux内核通过rlimit(Resource Limit)机制对进程使用的系统资源进行限制,防止单个进程消耗过多资源导致系统不稳定。rlimit机制是UNIX系统的传统特性,Linux对其进行了扩展和增强。

每个进程都有一组资源限制,存储在task_struct的signal->rlim数组中。资源限制分为软限制(soft limit)和硬限制(hard limit)——软限制是当前生效的限制值,进程可以自行降低或提高软限制(但不能超过硬限制);硬限制是软限制的上限,只有特权进程(CAP_SYS_RESOURCE)才能提高硬限制。资源限制通过getrlimit()和setrlimit()系统调用读写,shell中通过ulimit命令设置。

Linux支持以下资源限制类型:

限制类型 常量名 含义 默认值
RLIMIT_CPU CPU时间限制(秒) 超过软限制发送SIGXCPU,超过硬限制发送SIGKILL RLIM_INFINITY
RLIMIT_FSIZE 文件大小限制(字节) 超过限制发送SIGXFSZ RLIM_INFINITY
RLIMIT_DATA 数据段大小限制 堆空间上限 RLIM_INFINITY
RLIMIT_STACK 栈大小限制 栈空间上限 8MB
RLIMIT_CORE 核心转储文件大小 0表示不生成core文件 0
RLIMIT_RSS 驻留集大小限制 物理内存使用上限(Linux不强制执行) RLIM_INFINITY
RLIMIT_NPROC 进程数限制 用户可创建的进程数上限 约为系统内存页数的1/2
RLIMIT_NOFILE 打开文件数限制 文件描述符上限 1024
RLIMIT_MEMLOCK 锁定内存限制 mlock锁定的内存上限 64KB
RLIMIT_AS 地址空间限制 虚拟内存总量上限 RLIM_INFINITY

RLIMIT_NOFILE是最常调整的限制之一。默认值1024对于高并发服务器来说远远不够——一个处理上万并发连接的Web服务器需要数万个文件描述符。通过ulimit -n或setrlimit()可以将限制提高到数十万甚至数百万。内核内部通过nr_open全局变量(默认1024*1024)限制文件描述符的绝对上限,可以通过/proc/sys/fs/nr_open调整。

进程的资源使用统计通过getrusage()系统调用获取,可以统计进程自身(RUSAGE_SELF)或子进程(RUSAGE_CHILDREN)的资源使用情况。统计信息包括:用户态CPU时间、内核态CPU时间、页面换入次数、页面换出次数、自愿上下文切换次数、非自愿上下文切换次数等。这些统计信息对于性能分析和优化非常有用——例如,高非自愿上下文切换次数表明进程频繁被抢占,可能需要调整调度优先级;高页面换入次数表明进程的内存使用超出了物理内存容量,需要优化内存使用或增加物理内存。

12 进程间通信机制

进程间通信(Inter-Process Communication, IPC)是操作系统中实现进程协作的核心机制。Linux内核提供了多种IPC机制,每种机制都有其特定的适用场景和性能特征。理解这些IPC机制的内核实现,对于编写高效的多进程程序和深入理解操作系统原理都至关重要。

Linux支持的IPC机制可以分为以下几大类:

IPC类型 代表机制 通信模式 内核支持 适用场景
管道 pipe, FIFO 半双工字节流 VFS inode 父子进程通信
消息队列 System V/POSIX 结构化消息 内核数据结构 异步消息传递
共享内存 System V/POSIX 共享内存区域 页表映射 大数据量高速通信
信号量 System V/POSIX 计数器 内核数据结构 同步与互斥
信号 signal 异步通知 sigaction 异步事件通知
套接字 socket 双向通信 网络协议栈 网络和本地通信

管道(Pipe)是最古老的IPC机制,它创建一个内核缓冲区作为通信通道,连接两个文件描述符——一个用于读,一个用于写。管道的本质是一个受限的文件——数据只能顺序读取,读取后即被消费(不可重复读取)。管道在内核中通过pipefs伪文件系统实现,每个管道对应一个pipefs inode,inode中包含一个环形缓冲区(默认大小为16个页面,即64KB)。当写者写入数据时,数据被复制到环形缓冲区;当读者读取数据时,数据从环形缓冲区复制到用户空间。如果缓冲区满了,写者阻塞;如果缓冲区空了,读者阻塞。这种生产者-消费者模型确保了数据的有序传输。

命名管道(FIFO)是管道的扩展,它在文件系统中有一个对应的路径名,允许无亲缘关系的进程之间通信。FIFO通过mkfifo命令或mkfifo()系统调用创建,在内核中同样使用pipefs实现。FIFO的打开语义与管道不同——默认情况下,以只读方式打开FIFO会阻塞直到有进程以写方式打开它,反之亦然。这种同步打开机制确保了通信双方都已准备好。

共享内存是最高效的IPC机制,因为它不需要数据在内核和用户空间之间复制——两个进程将同一块物理内存映射到各自的虚拟地址空间,通过直接读写内存来通信。共享内存的通信速度接近内存访问速度,是大数据量通信的首选机制。但共享内存本身不提供同步机制,进程需要使用信号量或其他同步原语来协调对共享内存的访问。

Linux中共享内存有两种实现:System V共享内存(shmget/shmat/shmdt)和POSIX共享内存(shm_open/mmap)。System V共享内存使用键值(key)标识共享内存段,通过shmget()创建或获取共享内存段,通过shmat()将共享内存段映射到进程地址空间。POSIX共享内存使用路径名标识共享内存对象,通过shm_open()创建或打开共享内存对象,通过mmap()将其映射到进程地址空间。POSIX共享内存的接口更简洁,与文件操作的语义更一致。

在内核实现层面,共享内存段由shmid_kernel结构体(System V)或shm_file_data结构体(POSIX)描述。当进程调用shmat()或mmap()映射共享内存时,内核在进程的页表中创建映射项,指向共享内存对应的物理页面。由于多个进程的页表项指向相同的物理页面,任何一个进程的修改都能立即被其他进程看到。共享内存的页面在首次访问时通过缺页中断分配,使用与普通匿名页面相同的COW机制——但共享内存的页面不会被标记为COW(因为它们本身就是共享的),所以写操作不会触发COW缺页。

信号量(Semaphore)是用于进程同步的IPC机制,它通过计数器控制对共享资源的访问。System V信号量是计数信号量,其值可以为任意非负整数,支持P(wait/proberen)和V(signal/verhogen)操作。P操作将信号量值减1,如果值变为负数则阻塞等待;V操作将信号量值加1,如果有等待的进程则唤醒一个。System V信号量支持信号量集(Semaphore Set),即一次操作多个信号量,这可以避免多信号量操作中的死锁问题。

消息队列(Message Queue)是另一种IPC机制,它允许进程以消息为单位发送和接收数据。与管道不同,消息队列保留了消息的边界——每条消息有明确的长度和类型,接收者可以按类型选择性地接收消息。System V消息队列使用msgget()创建或获取,msgsnd()发送消息,msgrctl()控制消息队列。每条消息由消息类型(long型)和消息体组成,内核将消息组织为链表。POSIX消息队列提供了更现代的接口,支持消息优先级、异步通知(通过信号或线程启动)和非阻塞操作。

信号(Signal)是Linux中唯一的异步IPC机制,它允许一个进程向另一个进程发送异步通知。信号的内核实现涉及多个数据结构:task_struct中的signal字段指向共享信号处理结构体,pending字段包含待处理信号链表,blocked字段包含信号掩码。当内核向进程发送信号时,将信号添加到待处理信号链表中,并在进程返回用户空间之前检查是否有待处理信号。如果信号的处理方式是自定义处理函数,内核在返回用户空间之前修改用户栈,插入信号处理函数的调用帧,使得进程返回用户空间后首先执行信号处理函数。

信号的处理方式有三种:默认处理(如SIGTERM的默认处理是终止进程、SIGSEGV的默认处理是产生核心转储并终止)、忽略处理(SIG_IGN)和自定义处理(用户注册的处理函数)。SIGKILL和SIGSTOP是两个特殊信号——它们不能被忽略、不能被自定义处理、不能被信号掩码阻塞,始终执行默认操作(SIGKILL终止进程,SIGSTOP停止进程)。这种设计确保了系统管理员始终能够终止或停止失控的进程。

信号的内核实现中有一个重要的优化:实时信号(SIGRTMIN-SIGRTMAX)支持排队——多个相同的实时信号会被依次排队,不会丢失。而标准信号(1-31)不支持排队——如果多个相同的标准信号在处理之前到达,只保留一个。这个区别对于实时应用很重要——如果使用信号进行事件通知,应该使用实时信号而不是标准信号。

13 进程审计与记账

进程审计(Process Auditing)和记账(Process Accounting)是Linux内核提供的系统级监控功能,它们记录进程的活动信息,用于安全审计、资源计费和性能分析。

BSD进程记账(BSD Process Accounting)是Linux内核最早提供的进程监控机制,它通过acct()系统调用启用。启用后,内核在每个进程终止时将其资源使用信息写入记账文件。记账记录包含以下信息:进程名、用户ID、组ID、CPU时间(用户态和内核态)、内存使用量、I/O操作次数、退出状态等。BSD记账的开销很低——只在进程终止时写入一条记录,不影响进程的正常执行。但BSD记账的信息粒度较粗——只有进程级别的汇总信息,没有系统调用的详细记录。

Linux审计子系统(Linux Audit Subsystem)提供了更强大的审计功能,它基于auditd守护进程和内核的audit框架实现。审计框架允许管理员定义审计规则,指定需要监控的系统调用、文件访问、权限检查等事件。当匹配的事件发生时,内核将事件信息发送到auditd守护进程,auditd将信息写入审计日志。审计日志包含事件的详细信息:时间戳、进程ID、用户ID、系统调用号、参数、返回值等。

审计规则的配置通过auditctl命令完成。常用的审计规则包括:-a exit,always -F arch=b64 -S openat(审计所有openat系统调用)、-w /etc/passwd -p wa(审计对/etc/passwd的写入和属性修改)、-a exit,always -F auid>=1000 -S execve(审计所有非系统用户的程序执行)等。审计规则支持过滤条件(如UID、PID、系统调用返回值等),可以精确控制审计的范围,减少审计日志的体积和性能开销。

eBPF是Linux审计的未来方向。相比传统的audit框架,eBPF提供了更灵活的事件过滤和处理能力——eBPF程序可以在内核中实时处理事件,只将相关信息发送到用户空间,减少了数据传输的开销。Facebook(Meta)开发的BCC工具集中的execsnoop、opensnoop、killsnoop等工具就是基于eBPF实现的轻量级审计工具,它们可以实时监控程序执行、文件打开和信号发送等事件,无需配置复杂的审计规则。

14 命名空间与容器技术

命名空间(Namespace)是Linux内核实现容器技术的核心机制,它为进程提供了隔离的系统视图——不同命名空间中的进程看到不同的系统资源。命名空间与cgroup一起构成了Linux容器(如Docker、Podman、LXC)的底层技术基础。

Linux内核目前支持以下8种命名空间:

命名空间 标志常量 隔离内容 引入版本 典型用途
Mount CLONE_NEWNS 文件系统挂载点 2.4.19 隔离文件系统视图
UTS CLONE_NEWUTS 主机名和域名 2.6.19 隔离主机标识
IPC CLONE_NEWIPC System V IPC和POSIX消息队列 2.6.19 隔离IPC资源
PID CLONE_NEWPID 进程ID 2.6.24 隔离进程ID空间
Network CLONE_NEWNET 网络协议栈 2.6.29 隔离网络资源
User CLONE_NEWUSER 用户和组ID 3.8 隔离用户权限
Cgroup CLONE_NEWCGROUP Cgroup根目录 4.6 隔离cgroup视图
Time CLONE_NEWTIME 系统时钟 5.6 隔离时间

命名空间的使用方式有两种:通过clone()系统调用创建新命名空间(在flags参数中指定CLONE_NEW*标志),或通过unshare()系统调用将当前进程移入新命名空间。setns()系统调用允许进程加入已有的命名空间。每个进程的命名空间成员关系记录在task_struct的nsproxy结构体中。

PID命名空间是最重要的命名空间之一。在PID命名空间中,进程有两个PID——全局PID(在init命名空间中的PID)和局部PID(在当前命名空间中的PID)。PID命名空间形成层次结构——子命名空间中的进程在父命名空间中可见,但父命名空间中的进程在子命名空间中不可见。PID 1在PID命名空间中有特殊含义——它是命名空间中的init进程,负责回收孤儿进程,它的终止会导致整个命名空间被销毁。

网络命名空间为进程提供了完全隔离的网络协议栈——包括网络设备、IP地址、路由表、iptables规则、端口号空间等。每个网络命名空间有自己的loopback设备,需要显式创建veth pair或macvlan设备来连接不同的网络命名空间。网络命名空间是容器网络隔离的基础,Docker等容器运行时为每个容器创建独立的网络命名空间,然后通过veth pair和bridge将容器连接到宿主机网络。

用户命名空间允许普通用户在容器内拥有root权限,而在宿主机上仍然是普通用户。这是通过UID/GID映射实现的——用户命名空间维护一个从容器内UID到宿主机UID的映射表。例如,容器内的UID 0(root)可以映射到宿主机上的UID 1000(普通用户)。用户命名空间是容器安全的重要基础,它使得容器内的进程即使获得root权限,也无法影响宿主机上的其他进程。

命名空间的组合使用实现了完整的容器隔离。Docker等容器运行时在创建容器时,会同时创建Mount、UTS、IPC、PID、Network、User等命名空间,为容器提供完整的隔离环境。但需要注意的是,命名空间只提供了隔离,不提供了资源限制——cgroup才是资源限制的机制。命名空间和cgroup的组合使用,才构成了完整的容器技术基础。

15 容器运行时与Kubernetes调度

容器运行时(Container Runtime)是管理容器生命周期的软件组件,它负责创建、启动、停止和删除容器。容器运行时分为高层运行时(High-Level Runtime)和低层运行时(Low-Level Runtime)两个层次。高层运行时(如containerd、CRI-O)负责镜像管理和容器编排,低层运行时(如runc、kata-containers)负责直接创建和运行容器进程。

runc是最常用的低层容器运行时,它是OCI(Open Container Initiative)运行时规范的参考实现。runc的工作流程如下:解析OCI运行时规范(config.json);创建命名空间(Mount、PID、Network、User等);设置cgroup资源限制;配置安全特性(seccomp、AppArmor、SELinux等);执行容器进程。runc本身是一个命令行工具,不包含守护进程——它创建容器进程后退出,容器进程由init进程(PID 1)管理。

containerd是Docker公司开发的高层容器运行时,它提供了镜像拉取、容器创建、任务管理和快照管理等功能。containerd通过gRPC API提供服务,Docker Engine和Kubernetes都可以通过containerd管理容器。containerd的架构分为三层:最底层是runc等低层运行时,中间层是containerd的task服务,最上层是containerd的API服务。

Kubernetes是目前最流行的容器编排平台,它的调度器负责将Pod(Kubernetes的最小调度单元)分配到集群中的工作节点上。Kubernetes调度器的工作流程分为两个阶段:过滤(Filter)和打分(Score)。过滤阶段排除不满足Pod调度约束的节点(如资源不足、污点/容忍不匹配、亲和性规则不满足等);打分阶段对剩余节点按优先级排序(考虑资源均衡、亲和性权重、反亲和性等因素),选择得分最高的节点。

Kubernetes的资源模型使用request和limit两个值描述容器的资源需求。request是容器保证获得的资源量(用于调度决策),limit是容器可以使用的资源上限(用于运行时限制)。CPU的request和limit以毫核(millicore)为单位,1个CPU核心=1000毫核;内存的request和limit以字节为单位。当节点的可用资源小于Pod的request时,调度器不会将Pod分配到该节点;当容器使用的资源超过limit时,CPU会被限流(throttled),内存会被OOM Kill。

例题

  1. 在Linux内核中,task_struct结构体的主要作用是:

A. 仅表示进程,线程使用独立的数据结构
B. 统一表示进程和线程,通过clone标志区分资源共享程度
C. 仅表示线程,进程使用mm_struct表示
D. 表示进程的内存布局信息

答案:B。Linux采用"统一进程/线程表示"的设计哲学,task_struct同时表示进程和线程。线程被视为与其他线程共享地址空间的进程,通过clone()系统调用的flags参数来精确控制父子进程之间共享哪些资源(如CLONE_VM共享地址空间、CLONE_FILES共享文件描述符等)。

  1. CFS调度器选择下一个运行进程的依据是:

A. 进程的静态优先级
B. 进程的虚拟运行时间(vruntime)最小值
C. 进程的实际运行时间最少
D. 进程的nice值最低

答案:B。CFS的核心调度决策是选择vruntime最小的进程运行。vruntime是考虑了进程权重的虚拟运行时间,高优先级进程的vruntime增长较慢,因此能更频繁地被调度。这确保了所有进程按照权重公平地获得CPU时间。

  1. 关于CFS中vruntime的计算,以下公式正确的是:

A. vruntime += delta_exec × weight / NICE_0_LOAD
B. vruntime += delta_exec × NICE_0_LOAD / weight
C. vruntime += delta_exec × weight
D. vruntime += delta_exec / weight

答案:B。vruntime的更新公式为vruntime += delta_exec × NICE_0_LOAD / weight。其中NICE_0_LOAD是nice值为0对应的权重(1024),weight是当前进程的权重。高优先级进程的权重较大,因此vruntime增长较慢,能获得更多CPU时间。

  1. 在Linux中,SCHED_FIFO实时调度策略的特点是:

A. 进程按时间片轮流执行
B. 进程没有时间片限制,一直运行直到主动让出或被更高优先级进程抢占
C. 进程按照截止时间调度
D. 进程按照nice值分配CPU时间

答案:B。SCHED_FIFO进程没有时间片限制,它会一直运行直到主动让出CPU(通过sched_yield()或阻塞等待I/O)或被更高优先级的实时进程抢占。SCHED_RR才是按时间片轮转的实时调度策略,SCHED_DEADLINE是按截止时间调度的策略。

  1. 关于僵尸进程(Zombie Process),以下描述正确的是:

A. 僵尸进程占用大量CPU资源
B. 僵尸进程占用大量内存资源
C. 僵尸进程保留了task_struct和少量信息,等待父进程回收
D. 杀死僵尸进程需要发送SIGKILL信号

答案:C。僵尸进程是已经调用exit()退出但父进程尚未调用wait()回收的进程。僵尸进程释放了大部分资源(内存、文件描述符等),但保留了task_struct和少量内核栈信息,以便父进程获取子进程的退出状态。僵尸进程不占用CPU和大量内存,但会占用PID和task_struct的slab缓存。发送SIGKILL信号无法杀死僵尸进程,因为僵尸进程已经终止,正确的解决方法是让父进程调用wait()或杀死父进程。

  1. CFS使用红黑树而非其他数据结构来组织可运行进程,主要原因是:

A. 红黑树的实现最简单
B. 红黑树保证了查找、插入和删除操作都是O(log n)时间复杂度,且性能稳定
C. 红黑树查找最小值的时间复杂度为O(1)
D. 红黑树占用的内存最少

答案:B。CFS选择红黑树是因为它保证了查找、插入和删除操作的时间复杂度都是O(log n),且作为自平衡树,其性能在各种操作序列下都很稳定。红黑树查找最小值需要遍历到最左节点,时间复杂度为O(log n)(虽然实际上缓存最左节点指针后为O(1)),但总体性能优于堆和跳表等其他选择。

  1. 在cgroup v2中,与cgroup v1的主要区别是:

A. cgroup v2不支持CPU限制
B. cgroup v2采用统一的层级结构,所有子系统共享同一棵cgroup树
C. cgroup v2不支持内存限制
D. cgroup v2每个子系统可以独立挂载到不同层级

答案:B。cgroup v2采用了统一的层级结构,所有子系统共享同一棵cgroup树,一个进程只能属于一个cgroup。而cgroup v1中每个子系统可以独立挂载到不同的层级上,一个进程可以同时属于多个不同层级的cgroup,导致了语义混乱和管理复杂。

  1. 关于SCHED_DEADLINE调度策略的可调度性条件,以下正确的是:

A. 所有进程的CPU利用率之和不超过2
B. 所有进程的CPU利用率之和不超过1
C. 进程数量不超过CPU核心数
D. 每个进程的runtime不超过period

答案:B。SCHED_DEADLINE基于EDF算法,其可调度性条件为所有进程的CPU利用率之和不超过1,即 U = ∑ i = 1 n runtime i period i ≤ 1 U = \sum_{i=1}^{n} \frac{\text{runtime}_i}{\text{period}_i} \leq 1 U=i=1nperiodiruntimei1。这是Liu和Layland在1973年证明的EDF可调度性充分条件。内核在设置SCHED_DEADLINE策略时会验证这个条件,如果违反则拒绝设置。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐