【Linux】内核编织术:task_struct的动态网络

在上一篇文章中,我们讨论了fork()函数的返回值、写时复制机制以及进程创建过程,了解到内核通过task_struct(PCB)这个'档案袋'来管理进程的所有信息。但这仅仅相当于看到了档案袋的封面。今天,我们将打开这个档案袋,深入探究:内核如何组织进程信息,调度器如何管理这个'生命体',进程状态如何转换,以及它如何与系统进行交互。

  1. 核心问题提炼:

    • 高效组织机制:内核如何通过精巧的链表设计来管理海量task_struct结构?(引出进程链表管理)

    • 调度决策机制:内核如何基于优先级等调度参数,实现快速进程切换?(引出调度算法设计)

    • 资源管理机制:进程在阻塞时如何保存状态?内核如何维护其内存、文件等资源视图?(引出资源管理体系)

  2. 本文将深入探讨Linux内核进程管理的核心机制,不仅分析接口实现,更着重解析底层数据结构和算法设计原理。

task_struct如何被组织

由前文知,task_struct是进程在内核中的完整描述符,储存一个进程的所有信息,那么它是被怎样组织管理的呢?

进程状态

内核中有成千上万个task_struct,它们并不是随机的散落在各处,而是被精心组织在一个动态的网络中。这个网络的结构直接决定了内核管理进程的效率。要将task_struct放到这个网络中,首先就要把它们归类;进程可以被分为很多种,已经在运行的,等待调度的,还在磁盘中还没有创建PCB的。这时候就产生了进程状态这个概念,对进程分类后,对它们进行管理就方便多了。

进程的状态有很多种,在这里我们主要讨论运行,阻塞和挂起三种状态,及状态之间的切换。

进程状态切换图

运行状态

一个CPU会维护一个运行队列,多核CPU会有多个运行队列。资源准备就绪的进程的PCB会被链入runqueue,在运行队列中的进程的状态为running

运行队列图示:

cpp 复制代码
#include <linux/sched.h>

struct runqueue {
    // 1. 队列锁:保护整个运行队列的并发访问
    spinlock_t lock;

    // 2. 绑定的CPU核心ID(关键:每个runqueue对应一个CPU)
    unsigned int cpu;

    // 3. 就绪进程队列:按优先级组织的链表(核心中的核心)
    struct prio_array *active;    // 活跃队列(时间片未用完的进程)
    struct prio_array *expired;   // 过期队列(时间片用完的进程)
    struct prio_array arrays[2];  // 实际存储队列的数组(active/expired指向这里)

    // 4. 当前在该CPU上运行的进程
    struct task_struct *curr;
    struct task_struct *idle;     // 空闲进程(CPU无任务时运行)

    // 5. 调度统计/状态
    unsigned long nr_running;     // 队列中就绪进程总数
    unsigned long nr_switches;    // 上下文切换次数
    unsigned long load_weight;    // CPU负载权重(用于负载均衡)

    // 6. 负载均衡相关
    struct migration_queue migration;  // 进程迁移队列(核心间迁移任务)
    int nr_migrate;                    // 待迁移进程数
};

阻塞状态

阻塞即指进程因等待设备或资源就绪而被暂停执行的状态。操作系统通过结构化的方式(前文提到的先描述在组织)管理硬件设备:首先定义device结构体,为每个设备分配专属结构体实例,将设备相关信息存储其中,实现统一管理。

cpp 复制代码
// 简化版device结构体 - 驱动开发中最常用的字段
struct device {
    // 1. 设备树和层次结构
    struct device *parent;       // 父设备(必须设置!)
    struct device_node *of_node; // 设备树节点(重要!)
    
    // 2. 驱动模型绑定
    struct bus_type *bus;        // 所属总线(如pci_bus_type)
    struct device_driver *driver;// 绑定的驱动
    
    // 3. 识别信息
    const char *init_name;       // 设备名称
    dev_t devt;                  // 设备号(主/次设备号)
    
    // 4. 私有数据指针
    void *platform_data;         // 平台特定数据
    void *driver_data;           // 驱动私有数据
    
    // 5. 设备类的kobject(sysfs基础)
    struct kobject kobj;
    
    // 6. 关键回调函数
    void (*release)(struct device *dev); // 释放设备时调用
};

同时在Linux中,每个设备通常会维护自己的等待队列(wait queue),用于管理等待该设备特定事件的进程。这个队列叫等待队列,在这个队列中的进程,状态为阻塞状态

eg:一个进程中有scanf需要等待键盘输入,cpu不可能一直让这个进程干等着,空耗资源,这时OS会将这个进程从运行队列中移出,链入等待队列;OS是硬件的管理者,键盘有响应了,资源就绪了又会将此进程链入运行队列并执行。

具体步骤:

  1. 内核会将此进程加入键盘设备驱动的等待队列

  2. 将进程状态设置为阻塞状态

  3. 从运行队列中移出

  4. CPU可以调度其他进程执行

当键盘有输入时,键盘驱动会:

  1. 接收硬件中断

  2. 从键盘设备的等待队列中唤醒等待的进程

  3. 将进程状态改回就绪状态

  4. 重新加入运行队列等待调度

挂起状态

挂起状态一般出现在一些资源告急的情况,挂起状态分为运行挂起和阻塞挂起,我们先来谈阻塞挂起。

阻塞挂起

阻塞挂起,我初学听这个名词和运行挂起一下子就被吓到了,怕分不清。阻塞挂起,有一个前提是阻塞,当内存中资源告警时,等待队列在内存中,暂时可能用不是,OS作为管理者,会将暂时不那么紧急的进程移到磁盘(外设)中,方便其它急需内存资源的进程。这个将进程从等待队列中删除,保存到磁盘中的这个过程叫做唤出。被唤出到的这个磁盘区域叫swap交换分区(Swap Partition :交换分区是硬盘(磁盘)上专门划分出来的一块区域,用于扩展内存。当物理内存(RAM)不足时,操作系统会将暂时不用的内存数据移出(换出) 到交换分区,从而释放RAM给急需的进程使用)。当进程等待外设或资源就绪时,进程又会被链入到运行队列中执行。从swap交换区到运行队列的这个过程叫唤入。

运行挂起

理解了阻塞挂起,运行挂起手到擒来,当内存紧缺到极致时,在运行队列靠后的部分进程也会被唤出到swap交换区中。当进程等待外设或资源就绪时,进程又会被链入到运行队列中执行。

从上面内容基本可以看出:所谓的进程状态的切换就是进程PCB在不同队列中切换

内核链表

之前我们讨论过运行队列和等待队列,这两个数据结构都以进程的PCB作为节点。那么PCB如何能够同时存在于多个数据结构中呢?这就要借助内核链表机制来实现。

内核链表的优势

当然不使用list_head我们也能在功能上实现,将PCB放进多个数据结构。

cpp 复制代码
// 灾难版本:每个队列都需要独立实现
struct task_struct {
    // 1. 运行队列需要的指针
    struct task_struct *run_next;
    struct task_struct *run_prev;
    
    // 2. 等待队列需要的指针  
    struct task_struct *wait_next;
    struct task_struct *wait_prev;
    
    // 3. 所有进程链表需要的指针
    struct task_struct *tasks_next;
    struct task_struct *tasks_prev;
    
    // 4. 进程组链表需要的指针
    struct task_struct *pgrp_next;
    struct task_struct *pgrp_prev;
    
    // 进程实际数据
    int pid;
    // ... 还有几十个字段
};

// 每个队列都需要独立的操作函数!

// 运行队列操作
void run_queue_add(struct task_struct *new, struct task_struct *head) {
    // 专门操作run_next/run_prev
    new->run_next = head->run_next;
    new->run_prev = head;
    head->run_next->run_prev = new;
    head->run_next = new;
}

// 等待队列操作(几乎相同的代码!)
void wait_queue_add(struct task_struct *new, struct task_struct *head) {
    // 专门操作wait_next/wait_prev  
    new->wait_next = head->wait_next;
    new->wait_prev = head;
    head->wait_next->wait_prev = new;
    head->wait_next = new;
}

// 重复 × N 次!

这样有什么问题?

代码行数:每个队列一套代码 × N个队列

维护成本:修改链表算法要改N个地方

内存浪费:每个指针8字节 × 8个指针 = 64字节

容易出错:操作run队列时误用wait指针。(容易记混指针的名字)

我们的知道这样的代码在行业中是无法忍受的,是没有水平的,我们要遵循高内聚,低耦合的设计原则。很多PCB需要在多个数据结构中被串起来,我们将这个粘合PCB的功能独立为一个模块,这个模块就是list_head。

cpp 复制代码
// 通用链表节点(包含在数据结构中)
struct list_head {
    struct list_head *next, *prev;
};

// 内嵌在具体结构中
struct task_struct {
    // ... 进程数据
    struct list_head tasks;     // 所有进程链表
    struct list_head children;  // 子进程链表
    struct list_head sibling;   // 兄弟链表
    // 一个结构可以有多个链表!
};

struct inode {
    // ... inode数据
    struct list_head i_list;    // inode链表
    struct list_head i_sb_list; // 超级块链表
};

区分内核链表和普通链表

cpp 复制代码
	struct Node
	{
		int data;

		struct Node* next;
		struct Node* prev;
	};

平时我们使用的双链表是这样的,这样不论prev还是next都指向的是一个节点开头的地址。

list_head的实现却和它们有所不同,每一个内核链表的next直接指向下一个节点的next,prev指向下一个节点的prev。

cpp 复制代码
struct list_head
{
	struct list_head* next, *prev;
};
struct task_struct
{
	int x;
	int y;
	list_head links;
};

此时我们需要思考:既然使用指针的最终目的是解引用访问指向的资源,那么在内核链表设计中,指针指向的是结构体成员而非整个结构体,如何通过这个成员指针访问task_struct中的其他资源呢?

偏移量

父结构地址 = 成员地址 - 成员偏移量

&((struct task_struct*)0->links)假设结构体地址从零开始,这一样就可以求出links这个成员相对0的偏移量,在取next指针的减去偏移量,就是节点开头的地址。通过偏移量就可以在不同队列直接切换。

用数学公式表示:

设:

A = struct task_struct 的地址(我们想求的)

M = &a.links(已知的成员地址)

offset = offsetof(struct task_struct, links)

则:

A = M - offset

相关推荐
Danileaf_Guo2 小时前
OSPF路由引入的陷阱:为何Ubuntu上静态路由神秘消失?深挖FRR路由分类机制
linux·运维·网络·ubuntu·智能路由器
张某人的胡思乱想2 小时前
windows远程ubuntu
linux·运维·ubuntu
QT 小鲜肉2 小时前
【Linux命令大全】001.文件管理之mtoolstest命令(实操篇)
linux·运维·前端·笔记·microsoft
iconball2 小时前
个人用云计算学习笔记 --30 华为云存储云服务
运维·笔记·学习·华为云·云计算
catchadmin2 小时前
前后端分离框架 CatchAdmin V5 beta.2 发布 插件化与开发效率的进一步提升
运维·服务器
ocean21032 小时前
Linux面试题图解
linux·运维·服务器·面试·职场和发展
winfreedoms2 小时前
wsl ubuntu的基础配置
linux·ubuntu·wsl·基础配置
怎么没有名字注册了啊2 小时前
Debian 纯命令行 安装 VMware Tools 完整无坑手册(含全报错解决 + 无版本号 / 无成功提示终极修复)
linux·运维·debian·vmwaretools
最后一个bug2 小时前
浅显易懂的讲解MMU是如何使用4级页表把虚拟地址转化为物理地址的~
linux·服务器·开发语言·系统架构·计算机外设