[TOC]

kernel/pid.c PID管理(PID Management) 实现PID虚拟化与生命周期控制

历史与背景

这项技术是为了解决什么特定问题而诞生的?

kernel/pid.c 中的代码及其核心数据结构 struct pid 是为了解决在现代Linux内核中PID(进程标识符)的虚拟化和生命周期管理问题而诞生的。

在早期内核中,PID仅仅是 task_struct(进程描述符)中的一个整型成员(pid_t)。这种设计简单直接,但在面临以下新需求时暴露了严重局限性:

  1. 容器化和隔离:这是最主要的驱动力。为了实现容器(如Docker、LXC),必须让容器内的进程拥有自己独立的PID空间。例如,容器内的第一个进程应该看到自己的PID是1,而不是它在主机(Host)上的某个高位PID。旧的全局唯一PID模型无法实现这一点。
  2. 检查点/恢复(Checkpoint/Restore):像CRIU这样的项目需要能够“冻结”一个正在运行的进程及其所有状态,并在稍后或另一台机器上“解冻”它。如果PID只是一个全局数字,那么恢复时很可能原来的PID已经被其他进程占用,导致恢复失败。
  3. ID类型的统一管理:一个进程不仅有进程ID(PID),还有线程组ID(TGID,即主线程的PID)、进程组ID(PGID)和会话ID(SID)。在旧模型中,这些ID需要分别管理。

为了解决这些问题,内核需要将PID这个“标识符”本身从一个简单的数值,抽象成一个独立的、可被管理的内核对象struct pidpid.c 中的管理逻辑应运而生。

它的发展经历了哪些重要的里程碑或版本迭代?

pid.c 的演进是Linux进程模型现代化的核心部分。

  • struct pid的引入:这是最根本的变革。内核不再直接在 task_struct 中存储PID数值,而是存储一个指向 struct pid 对象的指针。这个 struct pid 成为了PID的“实体”,它被独立分配、拥有自己的生命周期(通过引用计数管理),并且可以被多个进程或ID类型共享。
  • PID命名空间(PID Namespaces)的实现:在 struct pid 抽象的基础上,内核实现了PID虚拟化。struct pid 结构被扩展,使其能够存储一个PID在多个不同命名空间中的数值。一个进程因此可以同时拥有一个在主机上可见的PID和一个在容器内可见的PID。
  • 高效的PID哈希表:为了能根据一个在特定命名空间中的PID数值快速查找到对应的 task_struct(例如 kill(pid, ...) 系统调用),内核实现了一个全局的PID哈希表。pid.c 中的代码负责管理这个哈希表,实现PID的快速注册和查找。
  • 统一ID类型struct pid 的设计使其可以被PID、TGID、PGID和SID共用。task_struct 中有多个指向 struct pid 的指针,但这些指针可能指向同一个 struct pid 实例(例如,一个单线程的进程组长,其PID、TGID和PGID可能都由同一个 struct pid 对象表示),这提高了效率和代码复用。

目前该技术的社区活跃度和主流应用情况如何?

pid.c 是Linux内核进程调度和管理子系统的绝对核心,其代码非常稳定和成熟。

  • 主流应用:它是所有容器技术(Docker、Kubernetes、LXC等)能够存在的基础。没有 pid.c 实现的PID虚拟化,就没有现代容器隔离。此外,Linux系统上所有的进程创建、销毁、信号发送、进程组管理等日常操作,都无时无刻不在依赖 pid.c 提供的功能。

核心原理与设计

它的核心工作原理是什么?

pid.c 的核心是围绕 struct pid 对象及其在命名空间中的转换和查找。

  1. 核心数据结构 struct pid

    • kref:一个引用计数器。当一个 task_struct 指向它,或者它被用于PGID等,计数就会增加。当计数归零时,该 struct pid 对象被销毁。
    • tasks:一个包含三个链表头的数组,分别用于PID、PGID和SID。这使得可以从一个 struct pid 对象反向查找到所有使用它的 task_struct
    • numbers:这是实现PID虚拟化的关键。它是一个 struct upid 数组,每个 upid 包含两部分:一个PID数值(nr)和一个指向该数值所属的命名空间(struct pid_namespace *ns)的指针。一个 struct pid 对象可以包含它在各级嵌套命名空间中的所有不同数值。
  2. PID的分配与关联

    • 当通过 fork() 创建新进程时,内核会调用 alloc_pid()
    • alloc_pid() 会在当前进程所在的PID命名空间中找到一个未使用的PID数值。
    • 然后,它会创建一个新的 struct pid 对象,用找到的PID数值和命名空间指针来初始化其 numbers 数组的第一个元素。如果存在父命名空间,也会一并填充。
    • 最后,新的 task_struct 会保存一个指向这个新创建的 struct pid 对象的指针。
  3. PID的查找与转换

    • 当用户空间程序执行如 kill(1234, SIGKILL) 的操作时,内核需要将数值 1234 翻译成一个具体的进程。
    • 内核会获取当前进程所在的PID命名空间。
    • 然后调用 find_pid_ns(),它利用PID哈希表,在指定的命名空间中查找数值为 1234struct pid 对象。
    • 一旦找到了 struct pid 对象,内核就可以通过其 tasks 链表轻松找到对应的 task_struct,然后执行发送信号的操作。

它的主要优势体现在哪些方面?

  • 隔离性:完美地实现了PID的虚拟化,是容器技术的基础。
  • 高效查找:通过哈希表,使得从PID数值到进程实体的查找操作平均时间复杂度为 O(1)。
  • 清晰的生命周期:通过引用计数,精确地管理PID对象的生命周期,避免了资源泄漏和过早释放。
  • 统一和抽象:将不同类型的ID(PID/TGID/PGID/SID)统一用 struct pid 来表示,简化了内核设计。

它存在哪些已知的劣势、局限性或在特定场景下的不适用性?

  • 复杂性增加:相比于简单的全局计数器,pid.c 的逻辑(命名空间、哈希表、引用计数)要复杂得多,给内核开发者带来了更高的理解成本。
  • 内存开销:每个PID(以及PGID/SID)都对应一个内核对象,这比只存储一个整数有更多的内存开销。但在现代系统中,这种为了功能和隔离性付出的开销是完全值得的。
  • PID耗尽:虽然 pid_max 可调,但在一个PID命名空间内,PID的数量是有限的。在创建和销毁大量短生命周期进程的容器环境中,可能会发生PID耗尽的问题。

使用场景

在哪些具体的业务或技术场景下,它是首选解决方案?

pid.c 的机制是内核内建的,不是一个可选方案,而是所有涉及进程ID操作的唯一实现方式。

  • 创建容器clone() 系统调用使用 CLONE_NEWPID 标志时,内核会创建一个新的PID命名空间,并为新进程在新空间中分配PID 1。这一切都由 pid.cnsproxy.c 等协同完成。
  • 发送信号kill() 系统调用依赖 pid.c 的查找机制,将用户传入的、在调用者命名空间中有效的PID,准确无误地定位到目标进程。
  • 进程组和会话管理setsid()setpgid() 等调用会创建或修改进程的会日志和会话ID,其底层实现涉及到创建新的 struct pid 对象或改变 task_struct 的指针。
  • 所有常规的进程创建和销毁fork()vfork()clone()exit() 都深度依赖 pid.c 的分配和释放逻辑。

是否有不推荐使用该技术的场景?为什么?

此问题不适用。因为pid.c是内核进程模型不可或缺的一部分,无法被“不使用”或“替换”。

对比分析

请将其 与 其他相似技术 进行详细对比。

最恰当的对比是将其与它所取代的旧的、扁平化的PID模型进行比较。

特性 现代 struct pid 模型 (kernel/pid.c) 旧的 pid_t in task_struct 模型
数据结构 PID是一个独立的、引用计数的内核对象 (struct pid)。 PID是 task_struct 中的一个简单整型成员。
命名空间支持 原生支持。一个struct pid可以包含在多个命名空间中的不同数值。 完全不支持。PID是全局唯一的。
查找机制 基于哈希表,在指定命名空间内快速查找。 通常是线性扫描一个全局的进程数组。
生命周期 引用计数管理,独立于task_struct task_struct 的生命周期完全绑定。
ID类型管理 统一。PID, TGID, PGID, SID 都可以由struct pid对象表示。 分散。每个ID类型都需要在task_struct中有一个独立的整型字段。
支持的场景 容器检查点/恢复 (CRIU)、复杂的进程组管理。 仅支持传统的、单一系统的进程管理。
复杂性
性能 查找,但分配/释放有对象管理开销。 查找(随进程数增加),但分配/释放只是递增计数器。

kernel/pid.c

init_pid_ns

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
* This controls the default maximum pid allocated to a process
*/
#define PID_MAX_DEFAULT (IS_ENABLED(CONFIG_BASE_SMALL) ? 0x1000 : 0x8000)

/*
* PID 映射页面开始时为 NULL,它们在首次使用时被分配,并且永远不会被释放。
* 这样,低 pid_max 值不会导致分配大量位图,但方案可以扩展到最多 400 万个 PID,运行时。
*/
struct pid_namespace init_pid_ns = {
.ns.count = REFCOUNT_INIT(2),
.idr = IDR_INIT(init_pid_ns.idr),
.pid_allocated = PIDNS_ADDING,
.level = 0,
.child_reaper = &init_task,
.user_ns = &init_user_ns,
.ns.inum = PROC_PID_INIT_INO,
#ifdef CONFIG_PID_NS
.ns.ops = &pidns_operations,
#endif
.pid_max = PID_MAX_DEFAULT,
#if defined(CONFIG_SYSCTL) && defined(CONFIG_MEMFD_CREATE)
.memfd_noexec_scope = MEMFD_NOEXEC_SCOPE_EXEC,
#endif
};
EXPORT_SYMBOL_GPL(init_pid_ns);

pid_idr_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/*
* 最多 400 万个 PID 应该足够一段时间。
* [注意:PID/TID 限制为 2^30 ~= 10 亿,见 FUTEX_TID_MASK。
*/
#define PID_MAX_LIMIT (IS_ENABLED(CONFIG_BASE_SMALL) ? PAGE_SIZE * 8 : \
(sizeof(long) > 4 ? 4 * 1024 * 1024 : PID_MAX_DEFAULT))

/*
* struct upid 用于获取 struct PID 的 ID,因为它在特定命名空间中可以看到。
* 稍后使用 int nr 和 struct pid_namespace *ns 通过 find_pid_ns() 找到结构 pid。
*/
#define RESERVED_PIDS 300

static int pid_max_min = RESERVED_PIDS + 1;
static int pid_max_max = PID_MAX_LIMIT;

void __init pid_idr_init(void)
{
/* 确认没有人做过任何愚蠢的事情: */
BUILD_BUG_ON(PID_MAX_LIMIT >= PIDNS_ADDING);

/* 基于 CPU 数量的 Bump Default 和 Minimum pid_max */
init_pid_ns.pid_max = min(pid_max_max, max_t(int, init_pid_ns.pid_max,
PIDS_PER_CPU_DEFAULT * num_possible_cpus()));
pid_max_min = max_t(int, pid_max_min,
PIDS_PER_CPU_MIN * num_possible_cpus());
pr_info("pid_max: default: %u minimum: %u\n", init_pid_ns.pid_max, pid_max_min);

/* 初始化 PID 分配器 */
idr_init(&init_pid_ns.idr);

init_pid_ns.pid_cachep = kmem_cache_create("pid",
struct_size_t(struct pid, numbers, 1),
__alignof__(struct pid),
SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT,
NULL);
}