欢迎光临
我们一直在努力

Linux内核学习轨迹第四部:深入解析Linux内核进程管理与调度(第一小节)

第四部分:进程管理与调度子系统(内核的心脏)

章节开篇

对于有一定内核基础的工程师而言,进程管理与调度是穿透整个Linux内核的核心主线——从用户态程序的启动、运行到退出,所有行为都围绕「进程」这个核心抽象展开;而调度器则是内核的「大脑」,决定了CPU资源如何在海量进程之间分配,直接决定了系统的吞吐量、响应延迟、实时性与公平性。

很多工程师对这部分的认知停留在「进程是资源分配单位,线程是调度单位」「CFS用红黑树管理进程」这类表层概念,在遇到实际工程问题时依然手足无措:

  • 线上服务出现大量D状态进程,系统负载虚高但CPU空闲,却无法定位根因;
  • 多线程服务频繁出现上下文切换过载,吞吐量上不去,却不知道从哪里优化;
  • 实时进程跑满CPU导致系统完全hang住,连ssh都无法响应;
  • 容器内进程CPU限流不符合预期,却看不懂cgroup的底层调度逻辑;
  • 僵尸进程堆积耗尽PID,却不知道如何从代码层面彻底预防。
  • 本章节基于Linux 6.6 LTS内核,从设计思想、源码实现、工程实践、问题排查四个维度,完整拆解进程管理与调度子系统的全链路逻辑,纠正行业内常见的认知误区,给有基础的工程师建立体系化的内核认知,同时提供可落地的问题排查与调优方案。

    1. 进程/线程/内核线程的内核实现本质

    Linux内核的核心设计哲学之一是:没有真正意义上的「线程」,所有调度实体都是用task_struct描述的任务,进程、线程、内核线程的核心区别,仅在于资源的共享程度不同。

    1.1 进程的核心抽象:独立的资源管理单元

    在Linux中,进程是系统资源分配的最小独立单元,一个进程本质上是内核管理的一组资源的集合,包括:

  • 独立的虚拟地址空间(代码段、数据段、堆、栈、内存映射区)
  • 独立的文件描述符表(打开的文件、管道、socket等)
  • 独立的信号处理表与挂起信号队列
  • 独立的进程组、会话与终端关联
  • 独立的命名空间视图、cgroup资源配额
  • 独立的优先级与调度属性
  • 内核中,进程的所有资源都通过task_struct结构体中的指针关联,进程之间默认不共享任何资源,每个进程都有独立的资源生命周期。

    1.2 线程的内核实现:轻量级进程LWP

    Linux没有为线程设计专门的内核数据结构,用户态的pthread线程,在内核中本质是轻量级进程(Light Weight Process, LWP),和普通进程共享同一个资源集合,仅拥有独立的调度实体、栈与线程私有数据。

    线程与普通进程的核心差异,完全由clone()系统调用的CLONE_*标志位控制——fork()、vfork()、pthread_create(),最终都会落到内核的clone()系统调用,只是传入的标志位不同,决定了子任务与父任务共享哪些资源。

    1.2.1 线程创建的核心标志位组合

    用户态pthread_create()创建线程时,会传入以下核心标志位,实现资源共享:

    标志位

    核心作用

    线程创建必选

    CLONE_VM

    共享地址空间

    mm_struct

    ,父子进程使用同一个页表,写时复制COW完全失效

    CLONE_FS

    共享文件系统信息

    fs_struct

    ,包括根目录、当前工作目录、umask

    CLONE_FILES

    共享文件描述符表

    files_struct

    ,一个线程打开/关闭文件,同进程所有线程可见

    CLONE_SIGHAND

    共享信号处理表

    sighand_struct

    ,信号处理函数完全同步

    CLONE_THREAD

    加入同一个线程组,共享TGID(线程组ID),用户态

    getpid()

    返回相同值

    CLONE_SETTLS

    设置线程本地存储TLS,实现线程私有数据

    CLONE_PARENT_SETTID

    把子线程的TID写入父进程地址空间,用于父进程获取线程ID

    CLONE_CHILD_CLEARTID

    子线程退出时清空TID地址,触发futex唤醒,实现

    pthread_join()

    而传统的fork()创建普通进程时,仅传入SIGCHLD标志,不设置任何共享标志,子进程拥有完全独立的所有资源。

    1.2.2 核心认知纠正

  • PID与TGID的区别:每个线程在内核中都有独立的PID(task_struct->pid),也就是用户态gettid()的返回值;而同一个进程的所有线程共享同一个TGID(task_struct->tgid),等于主线程的PID,也就是用户态getpid()的返回值。
  • 线程的调度独立性:每个线程都是内核中独立的调度实体,直接参与CPU调度,不存在「用户态线程调度」的说法,Linux采用的是1:1线程模型,每个用户态线程对应一个内核态LWP。
  • 资源共享的边界:线程之间共享的是资源的「管理结构」,而非运行时栈——每个线程都有独立的用户栈与内核栈,线程之间的栈是完全隔离的,不会互相干扰。
  • 1.3 内核线程的本质:无用户态地址空间的内核任务

    内核线程是运行在内核态的特殊任务,没有用户态地址空间,只能执行内核代码,访问内核地址空间,是内核执行后台任务、异步操作、硬件中断底半部的核心载体。我们常见的kswapd(内存回收)、kworker(工作队列)、flush(脏页回写)、migration(进程迁移)都是内核线程。

    1.3.1 内核线程与用户进程的核心差异

    特性

    用户进程/线程

    内核线程

    地址空间

    拥有独立的用户态地址空间,

    task_struct->mm

    不为NULL

    无用户态地址空间,

    task_struct->mm = NULL

    ,仅能访问内核地址空间

    运行级别

    大部分时间运行在用户态,仅系统调用/中断时陷入内核态

    全程运行在内核态,拥有最高特权级

    资源限制

    受cgroup、ulimit资源配额限制

    不受用户态资源限制,直接访问内核所有资源

    生命周期

    由用户态程序控制,退出时需要父进程回收

    由内核创建与销毁,退出时自动释放资源,不会产生僵尸进程

    调度参与

    参与常规调度,和其他进程公平竞争CPU

    参与常规调度,可设置更高的实时优先级

    1.3.2 内核线程的核心实现细节

  • 地址空间复用机制:内核线程的mm = NULL,但运行时需要访问内核地址空间,而内核地址空间在所有进程的页表中都是完全一致的。因此内核线程运行时,会复用前一个用户进程的active_mm(task_struct->active_mm),直接使用其页表中的内核地址空间映射,避免TLB刷新,极大降低上下文切换开销。
  • 创建流程:所有内核线程都是2号进程kthreadd的子进程,创建流程为:
  • 用户调用kthread_create(),向kthreadd的工作队列添加创建任务;
  • kthreadd被唤醒后,调用kernel_thread()创建新的内核线程,共享内核地址空间;
  • 新创建的内核线程初始为停止状态,需要调用wake_up_process()唤醒后才会执行;
  • kthread_run()是kthread_create() + wake_up_process()的封装,创建并直接启动内核线程。
  • 3.退出机制:内核线程必须通过kthread_should_stop()检查停止信号,收到信号后清理资源并退出,内核会自动释放其task_struct,不会产生僵尸进程。

    1.4 工程实践与避坑指南

  • 多线程fork()的致命陷阱
  • 多线程程序中调用fork(),子进程仅会复制调用fork的线程,其他线程都会消失。如果其他线程持有锁(比如malloc的互斥锁),子进程会继承锁的「已持有」状态,后续调用malloc、printf等非异步信号安全的函数时,会直接死锁。

     最佳实践:多线程程序中,fork()之后必须立即调用execve(),中间不能执行任何非异步信号安全的函数;如果不需要exec,绝对不要在多线程程序中使用fork()。

    2.vfork()的滥用风险

    vfork()的设计目的是「fork后立即exec」的场景,通过共享地址空间避免写时复制的开销。但vfork()会让父进程完全阻塞,直到子进程调用execve()或exit(),如果子进程修改了父进程的内存、调用了其他函数、异常退出,会直接导致父进程崩溃。

    最佳实践:现代Linux的fork()已经通过COW做了极致优化,vfork()的性能收益几乎可以忽略,除非是极致性能优化的嵌入式场景,否则绝对不要使用vfork()。

    3.内核线程的开发规范

  • 内核线程的主体函数必须是循环结构,必须在循环中调用kthread_should_stop()检查停止信号,收到信号后必须清理所有资源再退出;
  • 驱动退出时,必须先调用kthread_stop()停止内核线程,再释放相关资源,绝对不能先释放资源再停止线程,否则会导致线程访问已释放内存,触发内核oops;
  • 内核线程中不能执行任何可能睡眠的操作,除非持有对应资源的锁,且必须保证睡眠能被唤醒。
  • 4.线程栈溢出的预防

    线程的用户栈默认大小为8MB,很多工程师创建大量线程时会导致虚拟内存耗尽,或者在栈上分配大数组导致栈溢出。

  • 最佳实践:创建线程时,根据业务需求用pthread_attr_setstacksize()设置合理的栈大小,避免默认8MB的内存浪费;绝对不要在栈上分配超过4KB的大数组,大内存必须用malloc在堆上分配。
  • 2. 进程描述符task_struct全字段深度解析

    task_struct是Linux内核描述一个任务(进程/线程/内核线程)的核心结构体,定义在include/linux/sched.h中,是整个内核中最复杂、字段最多的结构体之一(超过1000行代码)。它是所有进程相关子系统的核心锚点,内存管理、文件系统、信号、调度、网络、cgroup、命名空间等所有子系统,都通过task_struct关联到对应的进程。

    对于有基础的工程师,不需要死记硬背所有字段,而是要按功能模块理解字段的设计逻辑、关联的子系统、以及工程中的使用场景。本章节按功能分类,深度拆解核心字段的作用与实现细节。

    2.1 进程状态与调度核心字段

    这部分是调度器操作的核心字段,决定了进程是否能被调度、以及如何被调度。

    volatile long state; /* 进程状态,-1=不可运行,0=可运行,>0=停止 */
    int exit_state; /* 进程退出状态:EXIT_ZOMBIE/EXIT_DEAD */
    unsigned int flags; /* 进程全局标志位 */

    int prio, static_prio, normal_prio; /* 进程优先级相关 */
    unsigned int rt_priority; /* 实时优先级 */
    const struct sched_class *sched_class; /* 所属调度类 */
    struct sched_entity se; /* CFS调度实体 */
    struct sched_rt_entity rt; /* 实时调度实体 */
    struct sched_dl_entity dl; /* Deadline调度实体 */
    unsigned int policy; /* 调度策略:SCHED_NORMAL/FIFO/RR/DEADLINE */
    cpumask_t cpus_allowed; /* CPU亲和性掩码 */
    unsigned int nr_cpus_allowed; /* 允许运行的CPU数量 */

    核心字段深度解析

    1.volatile long state

    进程的当前状态,用bit位标记,是调度器判断进程是否可调度的核心依据。用volatile修饰,因为它会被多个CPU、中断上下文并发修改,必须保证每次都从内存读取,不能被编译器优化。

  • 核心状态值:TASK_RUNNING(0,就绪/运行态)、TASK_INTERRUPTIBLE(0x0001,可中断睡眠)、TASK_UNINTERRUPTIBLE(0x0002,不可中断睡眠)、__TASK_STOPPED(0x0004,停止态)、__TASK_TRACED(0x0008,跟踪态);
  • 扩展状态:TASK_KILLABLE(可终止的不可中断睡眠)、TASK_IDLE(空闲睡眠态,不计入系统负载)、TASK_NOLOAD(无负载睡眠态,不计入系统负载)。
  • 2.unsigned int flags

    进程的全局标志位,标记进程的核心属性,内核中所有子系统都会根据这个标志位做逻辑判断。常用标志:

  • PF_KTHREAD:标记这是一个内核线程;
  • PF_EXITING:标记进程正在退出;
  • PF_FORKNOEXEC:标记进程fork后还未执行execve();
  • PF_MEMALLOC:标记进程可以申请内核预留内存,不受内存水位线限制(比如kswapd内核线程);
  • PF_NOFREEZE:标记进程系统休眠时不会被冻结;
  • PF_SUPERPRIV:标记进程拥有超级用户权限。
  • 3.优先级相关字段

  • static_prio:静态优先级,由nice值决定,范围100(nice=-20)~139(nice=19),进程启动后仅能通过nice系统调用修改;
  • rt_priority:实时优先级,范围0(非实时进程)~99,数值越大优先级越高;
  • ormal_prio:常规优先级,基于static_prio和rt_priority计算得到,是进程的基础优先级;
  • prio:动态优先级,调度器实际使用的优先级,会根据进程行为、优先级继承等动态调整。
  • 4.调度相关字段

  • sched_class:指向进程所属的调度类,调度类封装了进程的调度算法,是Linux调度器模块化设计的核心;
  • se/rt/dl:不同调度类对应的调度实体,包含了调度器需要的所有信息(比如CFS的vruntime、红黑树节点),每个进程只会有一个调度实体被实际使用;
  • policy:进程的调度策略,决定了进程所属的调度类;
  • cpus_allowed:CPU亲和性掩码,定义了进程可以在哪些CPU上运行,是多核调度的核心依据。
  • 2.2 进程标识与亲缘关系字段

    这部分字段定义了进程的唯一标识、父子关系、线程组归属,是内核管理进程树的核心。

    pid_t pid; /* 进程/线程的内核唯一ID */
    pid_t tgid; /* 线程组ID,用户态getpid()的返回值 */

    struct pid *thread_pid;
    struct pid *pids[PIDTYPE_MAX]; /* 不同类型的PID结构体(PID/PGID/SID) */

    char comm[TASK_COMM_LEN]; /* 进程名,长度16字节 */

    struct task_struct __rcu *real_parent; /* 真实父进程 */
    struct task_struct __rcu *parent; /* 当前父进程,接收SIGCHLD信号 */
    struct list_head children; /* 子进程链表头 */
    struct list_head sibling; /* 兄弟进程链表节点 */
    struct task_struct *group_leader; /* 线程组组长(主线程) */

    核心字段深度解析

    1.pid与tgid

  • pid:内核中任务的唯一标识,每个进程、线程都有独立的pid,对应用户态gettid()的返回值;
  • tgid:线程组ID,同一个进程的所有线程tgid相同,等于主线程的pid,对应用户态getpid()的返回值。
  • 核心认知:用户态的「进程ID」,本质是内核的线程组ID;用户态的「线程ID」,本质是内核的任务pid。
  • 2.struct pid *pids[PIDTYPE_MAX]

    内核的PID结构体,比简单的pid_t数值更复杂,因为Linux支持PID命名空间,同一个进程在不同的命名空间中拥有不同的PID。pids数组包含了进程的PID、PGID(进程组ID)、SID(会话ID),分别对应不同的命名空间视图。

    3.亲缘关系字段

  • real_parent:指向创建该进程的真实父进程;
  • parent:指向当前接收该进程SIGCHLD信号的父进程,通常和real_parent一致,当父进程退出后,会指向1号init进程;
  • children:链表头,链接了该进程的所有子进程;
  • sibling:链表节点,用于把该进程链接到父进程的children链表中;
  • 内核通过这组链表,构建了整个系统的进程树,从1号init进程延伸到所有用户进程。
  • 2.3 内存管理相关字段

    这部分字段是内存管理子系统的核心锚点,关联了进程的地址空间、内存统计、缺页异常处理等所有内存相关操作。

    struct mm_struct *mm; /* 进程的用户态地址空间管理结构 */
    struct mm_struct *active_mm; /* 当前活跃的地址空间,内核线程复用 */

    unsigned long total_vm, locked_vm, pinned_vm, shared_vm, exec_vm; /* 内存统计 */
    unsigned long min_flt, maj_flt; /* 缺页异常统计:次缺页/主缺页 */

    struct vm_area_struct *mmap_cache; /* VMA缓存,最近访问的虚拟内存区域 */

    核心字段深度解析

    1.mm与active_mm

  • mm:指向进程的内存描述符mm_struct,管理整个用户态虚拟地址空间,包括代码段、数据段、堆、栈、内存映射区、页表等。用户进程的mm不为NULL,内核线程的mm为NULL(无用户态地址空间);
  • active_mm:指向进程当前使用的内存描述符。对于用户进程,active_mm等于mm;对于内核线程,active_mm会复用前一个用户进程的mm,避免TLB刷新,提升上下文切换效率。
  • 2.内存统计字段

  • total_vm:进程的总虚拟内存页数;
  • locked_vm:被mlock锁定的内存页数,不能被swap换出;
  • min_flt:次缺页异常次数,不需要访问磁盘(比如匿名页分配、写时复制);
  • maj_flt:主缺页异常次数,需要访问磁盘(比如文件页读取、swap换入),是内存性能瓶颈的核心指标。
  • 2.4 文件系统与资源相关字段

    这部分字段关联了进程打开的文件、文件系统视图、IO上下文等所有文件相关资源。

    struct files_struct *files; /* 文件描述符表,管理所有打开的文件 */
    struct fs_struct *fs; /* 文件系统信息:根目录、当前工作目录、umask */

    struct io_context *io_context; /* IO上下文,用于块层IO调度 */

    核心字段深度解析

    1.files_struct *files

    管理进程的文件描述符表,每个文件描述符对应一个struct file结构体,记录了文件的打开模式、偏移量、操作函数集、inode引用等信息。

  • 线程设置CLONE_FILES标志时,会共享同一个files_struct,这就是同一个进程的线程之间共享文件描述符的底层实现;
  • execve()系统调用时,会关闭所有设置了FD_CLOEXEC标志的文件描述符,这是文件描述符泄漏的核心根源。
  • 2.fs_struct *fs

    管理进程的文件系统视图,包括根目录root、当前工作目录pwd、umask值。线程设置CLONE_FS标志时,会共享同一个fs_struct,一个线程修改当前工作目录,同进程的所有线程都会受影响。

    2.5 信号处理相关字段

    这部分字段管理进程的信号处理、挂起信号、定时器等所有信号相关逻辑。

    struct signal_struct *signal; /* 进程共享的信号信息,整个线程组共享 */
    struct sighand_struct *sighand; /* 信号处理函数表,整个线程组共享 */

    sigset_t blocked; /* 信号阻塞掩码 */
    struct sigpending pending; /* 线程私有挂起信号队列 */

    unsigned long sas_ss_sp; /* 信号栈地址 */
    size_t sas_ss_size; /* 信号栈大小 */

    核心字段深度解析

    1.signal与sighand

  • signal:整个线程组共享的信号结构,包括进程退出码、资源限制、定时器、共享挂起信号队列,同一个进程的所有线程共享同一个signal_struct;
  • sighand:整个线程组共享的信号处理函数表,记录了每个信号的处理函数、掩码、标志,同一个进程的所有线程共享同一个sighand_struct。
  • 2.信号队列

  • signal->shared_pending:整个进程的共享挂起信号队列,发送给进程的信号会加入这个队列,任意一个线程都可以处理;
  • pending:线程私有挂起信号队列,发送给特定线程的信号会加入这个队列,只有该线程可以处理。
  • 2.6 命名空间与cgroup相关字段

    这部分是容器技术的底层核心,管理进程的资源隔离视图与资源配额。

    struct nsproxy *nsproxy; /* 命名空间代理,管理所有类型的命名空间 */
    struct css_set __rcu *cgroups; /* cgroup控制组集合,管理资源配额 */

    核心字段深度解析

    1.sproxy *nsproxy

    命名空间代理结构体,包含了进程的所有命名空间指针:PID、MNT、NET、UTS、IPC、USER、CGROUP、TIME命名空间。进程通过clone()设置CLONE_NEW*标志时,会创建新的命名空间,不共享父进程的命名空间视图,实现资源隔离。

    2.css_set __rcu *cgroups

    cgroup控制组集合,关联了进程所属的各个cgroup子系统(CPU、内存、IO、网络等),记录了进程的资源配额与限制,是容器资源限流的底层实现。

    2.7 架构相关与上下文字段

    这部分字段是架构相关的,用于进程上下文切换、中断返回、调试跟踪等场景。

    struct thread_struct thread; /* 架构相关的线程上下文,保存寄存器、栈等信息 */
    struct restart_block restart_block; /* 系统调用重启结构,用于被信号中断的系统调用重启 */

    核心字段深度解析

    struct thread_struct thread是架构相关的结构体,x86_64和ARM64的定义完全不同,用于保存进程切换时的硬件上下文,包括通用寄存器、浮点寄存器、栈指针、程序计数器、调试寄存器等,是上下文切换的核心数据结构。进程切换时,内核会把当前进程的寄存器状态保存到thread中,然后从下一个进程的thread中恢复寄存器状态,完成硬件上下文的切换。

    2.8 工程实践与认知纠正

    1.不要直接修改task_struct的字段
    内核提供了大量封装好的函数来操作task_struct的字段,比如set_current_state()修改进程状态、set_user_nice()修改nice值、sched_setaffinity()修改CPU亲和性,绝对不要直接修改task_struct的字段,否则会破坏内核的并发保护机制,导致死锁、数据竞争等严重问题。
    2.task_struct的分配与回收
    task_struct是从slab缓存中分配的,不是栈上的变量,内核通过RCU机制管理task_struct的生命周期,遍历进程链表时必须持有RCU读锁,否则会出现use-after-free的问题。
    3.current宏的本质
    内核中常用的current宏,指向当前CPU上正在运行的进程的task_struct,它的实现是架构相关的:x86_64通过GS寄存器获取当前进程的thread_info,再拿到task_struct;ARM64通过SP_EL1寄存器获取。current是内核中访问当前进程属性的核心入口,性能极高,没有内存访问开销。

    3. 进程生命周期与状态转换全场景

    进程的生命周期是Linux进程管理的核心主线,所有进程从创建到销毁,都遵循严格的状态转换规则。线上90%的进程相关问题——比如系统负载虚高、僵尸进程堆积、进程卡死无法kill,本质都是进程状态转换出现了异常。

    本章节我们完整拆解进程的全生命周期、状态机转换、等待队列实现、僵尸/孤儿进程处理机制,同时给出工程中高频异常问题的排查方法。

    3.1 进程的7个核心状态全解析

    Linux内核中,进程的所有状态都定义在include/linux/sched.h中,每个状态对应一个明确的内核行为规则,我们按生命周期顺序拆解每个状态的触发场景、内核行为与用户态可见性。

    状态宏定义

    状态值

    核心含义

    触发场景

    内核调度行为

    用户态可见标识

    TASK_RUNNING

    0

    就绪/运行态

    1. 进程正在CPU上执行;2. 进程已就绪,放在CPU的就绪队列中等待调度

    唯一可被调度器选中执行的状态,调度器仅从该状态的进程中选择下一个执行的进程

    R

    TASK_INTERRUPTIBLE

    0x0001

    可中断睡眠态

    进程等待资源/事件(socket数据、键盘输入、信号量、锁),可被信号唤醒

    不会被调度执行,可被信号唤醒,唤醒后进入TASK_RUNNING,也可直接处理信号

    S

    TASK_UNINTERRUPTIBLE

    0x0002

    不可中断睡眠态

    进程等待必须完成的硬件IO事件(磁盘IO、块设备读写、swap换入),不能被信号中断

    不会被调度执行,仅能被

    wake_up()

    明确唤醒,kill -9无法终止该状态的进程

    D

    __TASK_STOPPED

    0x0004

    停止态

    进程收到SIGSTOP、SIGTSTP(Ctrl+Z)、SIGTTIN、SIGTTOU信号,暂停执行

    不会被调度执行,仅收到SIGCONT信号后唤醒,进入TASK_RUNNING

    T

    __TASK_TRACED

    0x0008

    跟踪态

    进程被调试器(gdb/strace)通过ptrace跟踪,触发断点后进入该状态

    与停止态类似,仅调试器通过ptrace发送继续执行命令后才会唤醒

    t

    EXIT_ZOMBIE

    0x0020

    僵尸态

    进程已执行完exit系统调用,释放了所有用户态资源,但父进程未通过wait()回收其task_struct

    不会被调度执行,仅保留最小的进程描述符与退出信息,占用PID资源

    Z

    EXIT_DEAD

    0x0040

    死亡态

    父进程已通过wait()回收了进程的所有资源,进程即将从系统中完全销毁

    内核临时状态,用户态几乎不可见,回收完成后进程彻底移除

    X

    3.1.1 工程高频扩展状态

    内核为了解决传统状态的工程痛点,扩展了多个复合状态,是驱动开发、内核编程中必须掌握的:

  • TASK_KILLABLE:可终止的不可中断睡眠态,是TASK_UNINTERRUPTIBLE的优化版本。进程处于该状态时,不会被常规信号唤醒,但可以被致命信号(kill -9)唤醒,完美解决了传统D状态进程无法被杀死、导致系统D状态进程堆积的问题。驱动开发中,长时间的IO等待应该优先使用TASK_KILLABLE,而非TASK_UNINTERRUPTIBLE。
  • TASK_IDLE:空闲睡眠态,优先级最低的睡眠态,不会被信号唤醒,仅用于内核线程的空闲等待,不会贡献系统平均负载。
  • TASK_NOLOAD:无负载睡眠态,处于该状态的进程不会被计入系统平均负载。传统的TASK_UNINTERRUPTIBLE进程会被计入系统负载,这就是为什么很多时候CPU空闲但系统负载很高的核心原因——大量D状态进程堆积。
  • 3.2 进程生命周期的完整状态机

    Linux进程的状态转换不是任意的,内核严格限制了合法的转换路径,所有异常的状态转换都会导致系统问题。下图是进程从创建到销毁的完整状态机,以及每个转换的触发条件与内核函数:

    3.2.1 核心转换路径的内核实现细节

  • TASK_RUNNING → 睡眠态
  • 这是最常见的转换路径,进程因为等待资源主动放弃CPU,进入睡眠态。内核的标准转换流程有严格的规范,绝对不能直接修改进程状态后调用schedule(),否则会出现唤醒丢失、进程永远睡眠的bug。

    内核标准睡眠流程:

    // 1. 定义等待队列项
    DEFINE_WAIT(wait);
    // 2. 把等待队列项加入等待队列头,设置进程状态
    prepare_to_wait(&demo_waitq, &wait, TASK_INTERRUPTIBLE);
    // 3. 检查资源是否可用,不可用则主动放弃CPU
    if (!resource_available)
    schedule(); // 触发上下文切换,进程进入睡眠态
    // 4. 被唤醒后,从等待队列中移除
    finish_wait(&demo_waitq, &wait);

    核心要点:必须在设置进程状态之后、调用schedule()之前,检查资源是否可用,否则会出现「唤醒信号在设置状态之后、schedule()之前到来,导致进程永远睡眠」的竞态bug。

    1.睡眠态 → TASK_RUNNING

    触发条件只有两个:

  • 等待的资源可用,内核调用wake_up()/wake_up_interruptible()主动唤醒进程,把进程设置为TASK_RUNNING,加入对应CPU的就绪队列;
  • 可中断睡眠态的进程收到信号,被信号唤醒,进入TASK_RUNNING状态,先处理信号,再继续执行。
  • 2.TASK_RUNNING → EXIT_ZOMBIE

    进程调用exit()系统调用,或者收到致命信号触发内核强制退出,会释放所有用户态资源(地址空间、文件描述符、信号处理等),仅保留task_struct结构体与退出状态,设置为EXIT_ZOMBIE状态,同时给父进程发送SIGCHLD信号,通知父进程回收。

    3.EXIT_ZOMBIE → EXIT_DEAD

    父进程调用wait()/waitpid()系统调用,读取子进程的退出状态,释放子进程的task_struct结构体,子进程设置为EXIT_DEAD状态,彻底从系统中消失。如果父进程已经退出,子进程会被1号init进程收养,init进程会自动调用wait()回收僵尸进程。

    3.3 睡眠与唤醒的核心机制:等待队列wait_queue

    进程的睡眠与唤醒,完全基于内核的**等待队列(wait_queue)**机制实现,它是内核中最基础的异步通知机制,驱动开发、内核子系统中无处不在。很多工程师写的驱动出现死锁、唤醒丢失、惊群效应,都是因为没有理解等待队列的底层实现。

    3.3.1 等待队列的核心数据结构

    等待队列由两个核心结构体组成,定义在include/linux/wait.h中:

    1.等待队列头wait_queue_head_t

    struct wait_queue_head {

        spinlock_tlock; // 保护等待队列的自旋锁

        struct list_headhead; // 等待队列项的双向链表头

    };

    typedef struct wait_queue_head wait_queue_head_t;

    作用:管理整个等待队列,保护链表的并发访问,所有等待同一个资源的进程,都会把自己的等待队列项加入这个链表。

    初始化:内核提供DECLARE_WAIT_QUEUE_HEAD(name)宏静态初始化,或者init_waitqueue_head()动态初始化。

    2.等待队列项wait_queue_entry_t

    struct wait_queue_entry {

        unsigned intflags; // 标志位,WQ_FLAG_EXCLUSIVE表示独占等待

        void*private; // 指向等待进程的task_struct

        wait_queue_func_tfunc; // 唤醒回调函数,默认是autoremove_wake_function

        struct list_headentry; // 链表节点,加入等待队列头的链表

    };

    typedef struct wait_queue_entry wait_queue_entry_t;

    作用:每个等待资源的进程,都会创建一个等待队列项,加入等待队列头,记录等待的进程、唤醒回调函数、是否是独占等待。

    3.3.2 等待事件的标准接口

    内核封装了一系列wait_event宏,是等待资源的标准用法,绝对禁止手动修改进程状态、操作等待队列,否则会出现竞态bug

    宏定义

    核心语义

    唤醒条件

    适用场景

    wait_event(wq, condition)

    不可中断的等待

    condition为真,被

    wake_up()

    唤醒

    必须等待资源可用,不能被信号中断的场景,极少使用

    wait_event_interruptible(wq, condition)

    可中断的等待

    condition为真,或收到信号

    普通的资源等待,允许被信号中断,驱动开发最常用

    wait_event_killable(wq, condition)

    可终止的等待

    condition为真,或收到致命信号

    长时间的IO等待,允许被kill -9终止,避免D状态进程堆积

    wait_event_timeout(wq, condition, timeout)

    带超时的不可中断等待

    condition为真,或超时时间到

    有超时限制的资源等待,避免永远睡眠

    wait_event_interruptible_timeout(wq, condition, timeout)

    带超时的可中断等待

    condition为真,或收到信号,或超时

    带超时的普通资源等待,最安全的用法

    wait_event_exclusive(wq, condition)

    独占等待

    condition为真,被

    wake_up()

    唤醒

    多进程等待同一资源,避免惊群效应

    3.3.3 唤醒机制与惊群效应规避

    唤醒进程通过wake_up系列宏实现,核心是遍历等待队列头的链表,调用每个等待队列项的唤醒回调函数,把进程设置为TASK_RUNNING,加入CPU的就绪队列。

    宏定义

    唤醒范围

    对应等待接口

    wake_up(wq)

    唤醒所有非独占等待进程 + 1个独占等待进程

    wait_event

    wake_up_interruptible(wq)

    唤醒所有可中断等待的进程

    wait_event_interruptible

    wake_up_nr(wq, nr)

    唤醒指定数量的独占等待进程

    自定义唤醒场景

    wake_up_all(wq)

    唤醒等待队列中所有进程,包括所有独占等待进程

    极少使用,会导致严重的惊群效应

    惊群效应的核心问题与解决:

  • 惊群效应:当多个进程/线程等待同一个资源时,一次wake_up_all会唤醒所有等待的进程,但只有一个进程能拿到资源,其他进程会再次进入睡眠,导致大量无效的上下文切换、CPU开销,严重影响系统性能。最典型的场景就是多个进程accept同一个socket,新连接到来时,所有进程都被唤醒,但只有一个能accept成功。
  • 内核解决方案:独占等待(WQ_FLAG_EXCLUSIVE)。在等待队列项的flags中设置WQ_FLAG_EXCLUSIVE标志,wake_up只会唤醒队列中第一个设置了该标志的进程,而不是所有进程,完美解决惊群效应。
  • 工程实践:多进程/多线程等待同一个资源时,必须使用wait_event_exclusive()系列宏设置独占等待标志,避免惊群效应。
  • 3.4 僵尸进程与孤儿进程的内核处理机制

    僵尸进程和孤儿进程是Linux进程管理中最常见的问题,很多工程师只知道「僵尸进程是父进程没回收子进程」,但不理解内核的处理机制,也不知道如何彻底解决。

    3.4.1 僵尸进程的产生与危害

    1.产生原因:子进程退出时,内核会释放它的所有用户态资源(地址空间、文件描述符、内存等),但会保留它的task_struct结构体、PID、退出状态、资源使用统计信息,等待父进程通过wait()/waitpid()系统调用读取这些信息。如果父进程没有调用wait,子进程的task_struct就不会被释放,一直处于EXIT_ZOMBIE状态,成为僵尸进程。

    2.核心危害:

  • 占用PID资源,Linux系统的PID有最大限制(默认32768),大量僵尸进程会耗尽PID,导致系统无法创建新进程;
  • 占用内核内存,每个task_struct结构体大约占用2KB内存,大量僵尸进程会消耗大量内核内存;
  • 污染进程列表,影响系统监控和问题排查。
  • 3.常见误区:很多工程师用kill -9杀僵尸进程,这是完全无效的,因为僵尸进程已经退出了,只是等待父进程回收,信号对它完全不起作用。

    3.4.2 孤儿进程的内核处理机制

  • 产生原因:父进程先于子进程退出,子进程就成为了孤儿进程。
  • 内核的处理逻辑:内核会自动把孤儿进程的父进程设置为1号init进程(systemd),init进程会周期性的调用wait()系统调用,回收所有它收养的孤儿进程的资源,所以孤儿进程不会变成僵尸进程。
  • 特殊场景:如果父进程是容器内的1号进程,父进程退出后,容器内的孤儿进程会被容器的init进程收养,而不是宿主机的init进程。
  • 3.4.3 僵尸进程的彻底解决方案

    1.根本解决方案:正确处理SIGCHLD信号

    子进程退出时,内核会给父进程发送SIGCHLD信号,父进程必须注册SIGCHLD信号处理函数,在处理函数中循环调用waitpid()回收所有已经退出的子进程。

    标准实现:

    void sigchld_handler(int sig)
    {
    // 循环回收所有已退出的子进程,WNOHANG表示非阻塞,没有退出的子进程直接返回
    while (waitpid(-1, NULL, WNOHANG) > 0);
    }

    // 注册信号处理函数
    int main()
    {
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sa.sa_flags = SA_RESTART; // 自动重启被信号中断的系统调用
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);

    // 业务逻辑
    while(1) {
    // fork子进程
    }
    return 0;
    }

    核心要点:必须用while循环+WNOHANG标志,因为多个子进程同时退出时,只会发送一个SIGCHLD信号,循环可以回收所有僵尸进程;WNOHANG保证没有子进程退出时,信号处理函数不会阻塞。

    2.临时解决方案:如果父进程已经退出,僵尸进程的父进程变成了init进程,只需要重启init进程(systemctl daemon-reexec),或者直接重启系统,init进程会自动回收这些僵尸进程。

    3.预防方案:如果不需要获取子进程的退出状态,可以设置SA_NOCLDWAIT标志,告诉内核,子进程退出时,不需要保留僵尸状态,直接释放所有资源,父进程不需要调用wait。

    struct sigaction sa;
    sa.sa_handler = SIG_IGN;
    sa.sa_flags = SA_NOCLDWAIT;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);
    这个方案适用于守护进程、后台服务,不需要获取子进程退出状态的场景。

    3.5 进程状态相关的问题排查方法

    线上90%的进程相关问题,都可以通过进程状态快速定位根因,这里我总结了一线工作中最常用的排查方法和工具,都是经过无数线上故障验证的最佳实践。

    3.5.1 核心排查工具与命令

    命令

    核心作用

    常用参数

    排查场景

    ps aux

    查看系统所有进程的状态、资源占用

    ps auxf

     树形显示进程父子关系;

    ps -eL

     显示线程

    快速查看进程状态、PID、父进程、CPU/内存占用

    top/htop

    实时监控进程的状态、资源占用

    top -H

     显示线程;

    f

     选择显示字段,开启进程状态列

    实时查看进程状态变化、CPU/内存占用排序

    dstat

    系统整体状态监控

    dstat -cdlmnpsy

     全维度监控

    查看系统负载、IO、CPU、内存状态,定位负载高的原因

    pidstat

    进程级别的资源统计

    pidstat -p 1

     每秒统计进程的CPU/内存/IO/上下文切换

    定位进程CPU占用高、IO高、上下文切换频繁的问题

    wchan

    查看进程睡眠的内核函数

    ps -o pid,stat,wchan,comm

    cat /proc//wchan

    定位D状态进程到底在等待什么内核函数、什么资源

    cat /proc//stack

    查看进程的内核栈回溯

    精准定位进程睡眠、卡死的内核函数、调用链,是排查D状态进程的终极工具

    ftrace

    跟踪进程的内核态执行流程

    跟踪schedule()、wake_up()函数,查看进程的调度、唤醒流程

    定位进程唤醒丢失、调度延迟高的问题

    perf sched

    调度器行为分析

    perf sched record

     记录调度事件;

    perf sched latency

     查看进程调度延迟

    定位进程调度延迟高、上下文切换频繁的问题

    3.5.2 高频问题的排查流程

    1.系统负载高,但CPU空闲

    核心原因:大量TASK_UNINTERRUPTIBLE(D状态)进程堆积,D状态进程会被计入系统平均负载,哪怕它不占用CPU。

    排查流程:

  • 用ps aux | grep " D "找到所有D状态进程;
  • 用cat /proc//stack查看进程的内核栈,找到进程在等待的内核函数;
  • 常见根因:磁盘IO故障、NFS挂载断开、块设备驱动卡死、内存回收卡住、swap分区故障;
  • 快速定位:用echo 1 > /proc/sys/kernel/sysrq; echo w > /proc/sysrq-trigger打印所有D状态进程的栈,快速定位问题。
  • 2.僵尸进程堆积

    排查流程:

  • 用ps aux | grep " Z "找到所有僵尸进程,记录它们的PPID(父进程PID);
  • 查看父进程的状态,确认父进程是否还在运行,是否注册了SIGCHLD信号处理函数;
  • 常见根因:父进程没有处理SIGCHLD信号,没有调用wait回收子进程;父进程卡死,无法处理信号;
  • 解决方案:修复父进程的信号处理逻辑,或者重启父进程,让僵尸进程被init进程收养回收。
  • 3.进程卡死,无法被kill

    排查流程:

  • 用ps aux查看进程状态,如果是D状态,说明进程处于不可中断睡眠,无法被信号杀死;
  • 用cat /proc//stack查看内核栈,找到等待的资源;
  • 如果是等待磁盘IO,检查磁盘是否正常;如果是等待锁,检查是否死锁;如果是驱动中的无限等待,检查驱动代码;
  • 临时解决方案:如果是TASK_KILLABLE状态,可以用kill -9杀死;如果是TASK_UNINTERRUPTIBLE,只能修复底层资源故障,或者重启系统。
  • 4.进程CPU占用率100%,但业务逻辑没有执行

    排查流程:

  • 用top -H -p 找到占用CPU最高的线程;
  • 用perf top -p 查看线程的用户态/内核态函数占用,找到占用CPU的函数;
  • 常见根因:用户态死循环、内核态系统调用死循环、大量的缺页异常、频繁的上下文切换;
  • 用perf record -g -p sleep 10采集调用栈,perf report查看完整的调用链,定位死循环的位置。
  • 3.6 工程实践与避坑指南

    1.绝对禁止手动修改task_struct->state

    很多工程师为了省事,直接修改current->state = TASK_UNINTERRUPTIBLE,然后调用schedule(),这是严重的错误,会导致唤醒丢失、进程永远睡眠的bug。必须使用内核提供的wait_event系列宏,或者严格遵循prepare_to_wait() → 检查资源 → schedule() → finish_wait()的标准流程。

    2.避免滥用TASK_UNINTERRUPTIBLE

    除非是必须完成的、不能被中断的硬件IO操作,否则绝对不要使用TASK_UNINTERRUPTIBLE,优先使用TASK_INTERRUPTIBLE或者TASK_KILLABLE,避免进程无法被杀死,导致系统D状态进程堆积、负载虚高。

    3.长时间等待必须加超时机制

    所有的睡眠等待,都必须设置超时时间,哪怕是必须完成的IO操作,也要有超时退出机制,避免进程永远睡眠,导致资源泄漏、系统hang住。使用wait_event_timeout系列宏,超时后返回错误,上层逻辑可以重试或者退出。

    4.多进程等待同一资源必须使用独占等待

    多个进程/线程等待同一个资源时,必须使用wait_event_exclusive设置独占等待标志,避免wake_up唤醒所有进程,导致严重的惊群效应,影响系统性能。

    5.父进程必须处理SIGCHLD信号,回收子进程

    只要创建了子进程,就必须注册SIGCHLD信号处理函数,在处理函数中循环调用waitpid(-1, NULL, WNOHANG)回收所有退出的子进程,绝对不能忽略SIGCHLD信号,否则会导致僵尸进程堆积。

    6.禁止在中断上下文、自旋锁持有期间调用schedule()

    schedule()会触发上下文切换,中断上下文不能睡眠,自旋锁持有期间也不能睡眠,否则会导致内核死锁、崩溃。中断上下文、自旋锁持有期间,只能使用忙等待延时,不能睡眠。

    第一章节暂时更新至此!待续!!

    赞(0)
    未经允许不得转载:171主机测评 » Linux内核学习轨迹第四部:深入解析Linux内核进程管理与调度(第一小节)
    分享到: 更多 (0)

    评论 抢沙发

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