ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Linux PID 0/1/2 深度解析:内核启动与容器 PID 1

Linux PID 0/1/2 深度解析:内核启动与容器 PID 1 Linux 上敲一条ps -ef第一列数字从 1 开始排2 是kthreadd紧接着ksoftirqd、kworker、migration一串带方括号的内核线程再往后才是systemd、sshd、nginx这些熟面孔。不少人第一次认真盯着这份列表看心里都会冒出同一个疑问0 号进程跑哪儿去了内核源码里明明有创建它的代码为什么在进程列表里翻不到这个问题的答案其实就是整条 Linux 启动过程最核心的一段链路。Linux 系统里进程与 PID 的对应关系是每个从业者的基本功而 PID 0、PID 1、PID 2 这三个特殊号段刚好把从内核第一条 C 指令到第一个用户态程序的完整路径串了起来。搞清楚它们的关系你就能回答一连串平时很容易被含糊带过的问题为什么/proc/0不存在、为什么systemd一定是 1 号、为什么内核线程的父进程都是 2、容器里的 PID 1 又为什么跟宿主机的不一样。这篇文章面向三类人刚接手 Linux 服务器、想补上底层认知的运维同学准备面试、被问过init 是怎么起来的的后端开发以及需要定制启动流程、精简系统镜像的嵌入式工程师。下面按身份识别 → 源码链路 → 动手验证 → 故障排查 → 落地场景的顺序展开每个环节我都会给出可以直接复制执行的命令和关键源码位置。1. 三个特殊 PID 的身份卡与职责边界1.1 为什么 /proc 目录里死活找不到 PID 0先说结论PID 0 不是一个可以被调度、被杀死、被观察的常规进程它是内核为两个用途预留的特殊编号。第一个用途是承载那份静态定义的init_task结构体它位于内核源码init/init_task.c长这样struct task_struct init_task INIT_TASK(init_task); EXPORT_SYMBOL(init_task);这份结构体在编译期就被塞进内核数据段没有经过动态内存分配也没有走正规的 PID 分配流程。正规的 PID 分配是alloc_pid()把新进程挂进init_pid_ns的 IDR 索引结构而init_task的 pid 字段直接被初始化为 0压根没进哈希表。/proc的目录项是按 PID 命名空间里的哈希表生成的表里没有它自然也就没有/proc/0。同理find_task_by_vpid(0)返回空kill 0发的其实是给当前进程组而不是 0 号进程。第二个用途是每个 CPU 上的idle 线程。多核机器上每个逻辑核都得有一个没事干时待着的执行流用来在 CPU 空闲时执行指令让它进入低功耗状态或者直接把核让出去。这些 idle 线程在fork_idle()里创建pid 同样被写成 0命令名格式是swapper/NN 是 CPU 编号。提示top里那个id百分比列展示的就是 idle 线程在跑的时长占比但 idle 线程本身不会作为一个进程出现在top的进程列表里因为它没有/proc目录项采不到它的统计。这就解释了一个常见的困惑有人用cat /proc/0/status报No such file or directory用ls /proc | sort -n | head发现最小的数字是 1然后就怀疑是不是自己环境被裁剪过。没有任何正常的 Linux 都是这样。想看 idle 线程的踪迹去/proc/sched_debug或者top按1展开每核视图能看到swapper名字挂在各个核下面。还有个小细节值得记住init_task同时也是内核进程链表的链表头。内核里遍历所有进程用的for_each_process()起点就是init_task因为那份结构体的tasks字段在链接时被初始化成一个自环。所以内核代码里必须有一个 PID 0 存在哪怕它在用户空间完全不可见——它是整个进程数据结构世界的地基。1.2 PID 1 与 PID 2 同源分流一次内核线程创建两种人生rest_init()里有两次关键的kernel_thread()调用前后脚创建了两个进程它们分别拿到 PID 1 和 PID 2然后在极短的时间内走上完全不同的道路。拿到PID 1的那个进程函数入口是kernel_init。它最初也是一个彻头彻尾的内核线程——没有用户态地址空间跑在内核栈上。但它接下来会做一件事通过run_init_process()去execve一个用户空间的程序把整个进程的地址空间替换掉。execve不改变 PID于是 PID 1 从内核线程平滑过继成了用户态第一个进程。执行完这步之后它就是你我熟悉的systemd或者sysvinit、OpenRC、s6视发行版而定。拿到PID 2的那个进程函数入口是kthreadd。它这辈子都不会执行execve永远留在内核态任务只有一个充当所有内核线程的生产车间。内核模块、驱动、子系统需要后台线程时不直接调用底层创建接口而是把请求塞进kthread_create_list链表唤醒kthreadd由它统一在create_kthread()里 fork 出新的内核线程。所以你会看到ksoftirqd/0、kworker/0:0、kswapd0、kblockd这些家伙的父进程全是 2。对比项PID 0PID 1PID 2内核符号init_task/fork_idlekernel_initkthreadd创建方式编译期静态定义 每核fork_idlekernel_thread/user_mode_threadkernel_thread父进程无PID 0PID 0是否进入用户态否是通过execve否终身内核态/proc是否可见不可见可见可见调度优先级最低纯 idle普通普通典型命令名swapper/Nsystemd/initkthreadd这张表建议直接记住面试被问到PID 0 是什么的时候能一口气把静态定义和 idle 两个身份都说出来基本就稳了。很多人只知道第一个身份答完就卡住了。1.3 三者之间的父子与继承关系到底怎么算从血缘上看PID 1 和 PID 2 的父进程都是 0。这不是嘴上说说内核里写得明明白白——rest_init()里那段代码是在start_kernel()的调用上下文里跑的而start_kernel()的执行流最终会变成 idle 线程也就是 PID 0。换句话说先有 00 生了 1 和 2然后 0 退居幕后去当 idle。这个顺序有个硬性要求必须先创建 PID 1再创建 PID 2。原因写在内核注释里——init 进程本身后续会需要创建内核线程如果先把它调度起来而kthreadd还没就绪kthread_create()就会卡死。所以rest_init()里的写法是创建kernel_init后立刻给它打上PF_NO_SETAFFINITY标志并绑到启动核上创建kthreadd并记录kthreadd_task指针最后用complete(kthreadd_done)通知kernel_init兄弟就位了你可以继续了。你可以在自己的机器上直接验证这层关系grep -E ^(Name|Pid|PPid) /proc/1/status grep -E ^(Name|Pid|PPid) /proc/2/status两台正常的机器上这两条命令的PPid都会输出 0。这是最直接的证据——用户空间里唯一允许父进程是 0的进程就是这两个。从继承关系上看两者分叉之后又各自开枝散叶。PID 1 是所有用户态进程的祖先容器里的除外后面会讲PID 2 是所有内核线程的祖先。你随便挑一个内核线程看它的PPid基本都是 2除非它被显式改过父进程。反过来除了 PID 1 和 PID 2任何进程的PPid都不可能是 0一旦你发现有第三个进程的父进程是 0那这台机器就值得好好查一查了。2. 从内核第一条指令到第一个用户进程的完整链路2.1 上电到 start_kernel 之间机器都干了些什么要理解 0/1/2 的诞生得先把镜头往前拉一点。按下电源之后CPU 从一个固定的物理地址开始取指跑的是主板固件传统 BIOS 或现在的 UEFI。固件做完自检、枚举设备、初始化内存控制器然后按启动顺序找到可引导设备把上面的引导加载程序绝大多数发行版用 GRUB2读到内存里执行。GRUB2 的工作分两段。第一段很小塞在磁盘头部作用是把自己更大的第二段加载进来。第二段读配置文件把用户选中的内核镜像和配套的initramfs或initrd一起载入内存然后跳进内核入口。这一阶段常被忽略的一件事是内核镜像并不是裸的 ELF 可执行文件它前面套了一层自解压头启动时会先在临时缓冲区里把压缩过的内核decompress_kernel()展开屏幕上那行Decompressing Linux... Parsing ELF... done.就是它干的活。解压完成后才跳到真正的start_kernel()。这个函数在init/main.c里是整个内核在架构无关层面上的总装配线做的事包括设置启动 CPU 状态、解析架构相关的硬件信息、初始化内存管理、初始化中断和时钟、打开控制台从这里开始printk才有输出、初始化虚拟文件系统缓存、挂载最初的proc和sysfs骨架。所有这些跑完之后它做的最后一件事是调用rest_init()。这一刻进程号段 0、1、2 的分配正式拉开序幕。顺带提一句内核启动参数的传递路径GRUB 里linux那行后面跟的参数会被内核的unknown_bootoption()收集起来其中init、rdinit、initcall_debug、panic这些会被单独识别并落到对应变量里。这就给后面定制启动流程留了口子。2.2 rest_init 里的三段关键代码决定了 0/1/2 的诞生顺序rest_init()加注释也就二三十行但每一段都值得逐句看。按现代 6.x 内核的写法主干大致是这样noinline void __ref __noreturn rest_init(void) { struct task_struct *tsk; int pid; rcu_scheduler_starting(); pid user_mode_thread(kernel_init, NULL, CLONE_FS); rcu_read_lock(); tsk find_task_by_pid_ns(pid, init_pid_ns); tsk-flags | PF_NO_SETAFFINITY; set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id())); rcu_read_unlock(); numa_default_policy(); pid kernel_thread(kthreadd, NULL, NULL, CLONE_FS | CLONE_FILES); rcu_read_lock(); kthreadd_task find_task_by_pid_ns(pid, init_pid_ns); rcu_read_unlock(); system_state SYSTEM_SCHEDULING; complete(kthreadd_done); schedule_preempt_disabled(); cpu_startup_entry(CPUHP_ONLINE); }第一段创建kernel_init它拿到PID 1。注意这里用的是user_mode_thread较新内核引入早期版本就是kernel_thread差别在于前者会为新线程准备好一套能顺利切到用户态的上下文比如合适的信号处理和 TLS 状态。紧接着的PF_NO_SETAFFINITY加set_cpus_allowed_ptr组合是把 init 死死钉在启动 CPU 上——因为此时sched_init_smp()还没跑跨核迁移的逻辑不可靠让 init 乱跑会出问题。这是一个典型的启动早期只能串行、不能并行的约束。第二段创建kthreadd它拿到PID 2。PID 号的分配是单调递增的中间没有任何其他alloc_pid()调用插队所以 2 号这个位置非常稳定。创建完立刻把返回的任务结构指针存到全局变量kthreadd_task里后面所有kthread_create()都是靠这个指针对着它发唤醒。第三段是收尾。system_state SYSTEM_SCHEDULING标记调度器可用complete(kthreadd_done)解开 PID 1 那边的等待然后调用schedule_preempt_disabled()主动让出一次 CPU。这次让出的结果很关键当前执行流从正在跑 start_kernel 的上下文正式降格为 idle 线程也就是 PID 0之后它进入cpu_startup_entry(CPUHP_ONLINE)在do_idle()里循环等待。所以整个过程用一句话概括start_kernel()亲手创建了 1 号和 2 号然后自己变成了 0 号。0 号是父但它是最后一个确定身份的这个时间上的微妙之处是很多人理解错的地方——他们会以为内核先有 0 再有 1实际上代码顺序是先 fork 出 1 和 2最后才落地成 0。注意fork_idle()给其他 CPU 创建 idle 线程是在kernel_init里通过smp_init()触发的那已经是 1 号进程的工作了。也就是说0 号进程创建了 1 号1 号又为自己造了一堆兄弟 0 号这个循环关系挺有意思画进程树的时候会看到多个swapper/N。2.3 kernel_init 的长跑从内核线程到 execve 用户程序PID 1 拿到号码之后并不轻松它要干完一大堆初始化才能去执行用户程序。它在kernel_init()里做的第一件事是等wait_for_completion(kthreadd_done)确认 2 号就位。然后进入kernel_init_freeable()这是初始化工作的主战场。这一大段里有几个关键动作值得单拎出来smp_prepare_cpus()和smp_init()把其他 CPU 拉起来。每个核起来的过程中都会调fork_idle()造一个 idle 线程这就是swapper/1、swapper/2的来源。workqueue_init()初始化工作队列。内核里大量异步任务靠工作队列干活而工作队列自己也是靠内核线程驱动的所以它得等kthreadd就绪才能跑。do_basic_setup()这里面会依次跑完各级initcall也就是驱动和子系统的初始化函数。你在dmesg里看到的网卡探测到磁盘识别到基本都是这一阶段打出来的。打开/dev/console内核通过ksys_open(/dev/console, O_RDWR, 0)把标准输入输出接到控制台然后用两次ksys_dup(0)把 fd 1 和 fd 2 也指向它。这一步失败会打一行Warning: unable to open an initial console.是个值得留意的排障线索。光有日志还不够直接看代码顺序更直观。下面是这个环节的骨架static noinline void __init kernel_init_freeable(void) { gfp_allowed_mask __GFP_BITS_MASK; set_mems_allowed(node_states[N_MEMORY]); smp_prepare_cpus(setup_max_cpus); workqueue_init(); do_pre_smp_initcalls(); smp_init(); sched_init_smp(); do_basic_setup(); if (ksys_open((const char __user *) /dev/console, O_RDWR, 0) 0) pr_err(Warning: unable to open an initial console.\n); (void) ksys_dup(0); (void) ksys_dup(0); if (!ramdisk_execute_command) ramdisk_execute_command /init; /* ... */ }初始化做完了kernel_init()就要去执行用户态程序了。它会按一个明确的优先级顺序尝试一串路径rdinit指定的程序默认是/initinit内核参数指定的程序/sbin/init/etc/init/bin/init/bin/sh只要其中一个能成功execvePID 1 就切换成了用户态程序后面的全部跳过。如果全试一遍都失败内核会打出一行非常经典的报错然后 panicKernel panic - not syncing: No working init found. Try passing init option to kernel.这句几乎每个做过嵌入式或者救援修复的人都见过。遇到它基本可以断定根文件系统挂错了、init 二进制被删了、或者动态链接库缺失导致execve返回ENOENT。一个小技巧是先用init/bin/sh启动进去之后手工排查比对着黑屏猜要高效得多。这里有个细节容易被忽略run_init_process()内部调的是kernel_execve()是 exec 不是 fork。所以 PID 1 从内核线程变成用户态进程的过程中号码一直没变。如果当初写的是 fork 加 exec那用户态的 init 就会是 3 号或者更大整个系统的进程号约定就乱了。2.4 initramfs 与 switch_rootPID 1 号码为什么始终不变现在几乎没有发行版会直接从物理根文件系统启动中间都夹了一层initramfs。原因是内核要知道怎么访问真正的根设备得先有对应的驱动——比如 LVM、软 RAID、磁盘加密、NVMe 控制器——而这些驱动往往以模块形式存在模块又躺在根文件系统上鸡生蛋的问题就来了。initramfs就是用来打破这个死循环的它是一个由cpio打包、被内核解压到rootfs一个tmpfs里的小型根文件系统包含必要的驱动模块和一套脚本。所以真实流程是两段第一段内核把initramfs解开挂到临时的rootfs上PID 1 执行里面的/init由dracut或initramfs-tools生成。这个/init是个 shell 脚本负责加载模块、扫描磁盘、激活 LVM 和加密卷、找到真正的根设备并挂载到/sysroot之类的临时挂载点最后执行switch_root。第二段switch_root做的事情是把当前根目录切换成新挂载的那个真实根文件系统删掉旧的tmpfs内容腾出内存然后exec真实根上的 init 程序。同样地这里也是 exec 而不是 fork所以 PID 1 从头到尾没有换过号码。一个直接的验证方式是看/proc/1/status里的Pid从 initramfs 阶段到系统完全启动这个数字一直是 1变的只是comm字段先是init后来变成systemd。如果你想看看实际的 initramfs 里都有什么可以这样操作lsinitrd /boot/initramfs-$(uname -r).img | head -40 # RHEL/CentOS/Fedora lsinitramfs /boot/initrd.img-$(uname -r) | head -40 # Debian/Ubuntu手工解开一份出来读/init脚本是理解启动流程最快的路径之一。我第一次这么干的时候才发现里面处理加密卷、处理多路径设备的逻辑比想象中复杂得多也理解了不少启动卡住的现场到底卡在哪个环节。3. 上手验证把 PID 0/1/2 的痕迹一条条挖出来3.1 用 /proc 里的 PPid 字段反推父子关系理论讲完接下来动手。/proc/pid/status里的PPid字段是验证父子关系最直接的入口因为它是内核从任务结构里直接读出来的没法被用户态伪造除非进程主动通过prctl之类的接口改但 PID 1 和 2 不会。grep -E ^(Name|Pid|PPid|Uid) /proc/1/status /proc/2/status正常输出大致是/proc/1/status:Name: systemd /proc/1/status:Pid: 1 /proc/1/status:PPid: 0 /proc/2/status:Name: kthreadd /proc/2/status:Pid: 2 /proc/2/status:PPid: 0看到PPid: 0就对了。接着可以顺手确认一下所有内核线程的父进程都是 2ps -eo pid,ppid,comm | awk $2 2 | head -20ps的comm列对内核线程会显示成[kthreadd]、[ksoftirqd/0]、[kworker/0:0]这种带方括号的形式方括号是ps用来标记这个进程没有用户空间命令行的约定。想验证这一点直接读它的cmdlinecat /proc/2/cmdline | wc -c # 输出 0 readlink /proc/2/exe # 报错No such file or directory两个命令的结果都印证了同一个事实内核线程没有用户态可执行文件自然也就没有可执行的命令行。反过来看 PID 1cat /proc/1/cmdline | tr \0 ; echo readlink -f /proc/1/exe第一个命令一般会输出/sbin/init或者/usr/lib/systemd/systemd第二个会给出实际二进制路径。如果这里读出来的是别的东西那这台机器就值得查了。3.2 pstree 与 ps 配合看清 kthreadd 的整个家族看单点不如看全貌。pstree是展示进程父子关系最顺手的工具加-p参数把 PID 带上pstree -p 1 | head -30 pstree -p 2 | head -40第一条命令看的是用户态那棵树systemd下面挂着systemd-journald、systemd-udevd、dbus-daemon、sshd等等这些是典型的用户态服务。第二条命令看的是内核线程那棵树kthreadd下面挂着一大堆方括号名字每个都是内核某个子系统的后台工作者。对内核线程做分类整理挺有用我平时习惯用这条ps -eo pid,ppid,comm | awk $2 2 {print $3} | sort | uniq -c | sort -rn | head -20输出会告诉你哪些内核线程被创建得最多。一般kworker数量最多因为工作队列按 CPU、按类型普通、高优先级、内存回收等各开一条线程核数一多线程数就上去了。这个数字本身还是个体检指标如果kworker数量异常多可能存在阻塞型驱动或者频繁触发的定时任务。再补一条看线程和进程区别的命令。ps -eLf会把线程也展开同一个进程的多个线程 PID 相同但 LWP 不同ps -eLf | head -20内核线程其实是只有一个线程的进程的特例它们共享内核页表没有独立用户地址空间。3.3 观测启动耗时定位 init 阶段的慢点知道 PID 1 是谁之后自然会想知道它启动花了多久、慢在哪里。systemd-analyze家族是最方便的入口systemd-analyze systemd-analyze blame | head -20 systemd-analyze critical-chain第一条给出内核阶段和用户空间阶段各自的耗时第二条按耗时从长到短列出各个 unit第三条画出关键依赖链。三者配合能从不同角度定位启动慢的原因。不过systemd-analyze的前提是系统用systemd当 PID 1。如果是嵌入式环境或者容器镜像里用的是busybox init就得换个思路——加内核参数initcall_debuglinux /vmlinuz root/dev/sda2 initcall_debug ignore_loglevel重启后dmesg里会为每个initcall输出一行带耗时的记录类似calling ahci_pci_driver_init0x0/0x1b 1 initcall ahci_pci_driver_init0x0/0x1b returned 0 after 12736 usecs把所有行抓出来按耗时排序就能看出哪个驱动拖了后腿dmesg | grep initcall.*returned | sed s/.*returned // | sort -rn | head -20这套方法在排查嵌入式设备开机十几秒的问题上非常好使。常见的结果是某个存储控制器驱动在做复位等待或者某个网络驱动在等 PHY 链路单点就可能占掉几秒。3.4 容器环境下的 PID namespace那个假 PID 1容器里ps看到 1 号进程是应用自身的进程很多人在这个场景下会产生困惑——难道容器把宿主机的 init 换了没有这是PID 命名空间的效果。PID 命名空间让一组进程看到一套独立的 PID 编号。容器启动时runc之类的运行时把应用进程放进新的命名空间这个进程在容器内部看来是 1 号但在宿主机上分配的还是一个普通的、可能几千号的大数字。你可以这样验证# 在容器外 docker inspect --format {{.State.Pid}} 容器名 # 在容器内 cat /proc/1/status | grep -E Pid|NSpid容器内的NSpid字段会同时列出两套编号比如NSpid: 1234 1前面那个是宿主机视角的 PID后面那个是容器内视角的 PID。这个字段在排查容器进程和宿主机监控对应关系的时候特别有用——宿主机上top看到的某个高 CPU 进程通过NSpid就能对应回具体是哪个容器里的哪个服务。还有个必须注意的坑容器里的 1 号进程只是命名空间内的 init不具备全局 init 的特殊保护。全局 init 有SIGNAL_UNKILLABLE标志内核会拦掉大部分发给它的信号容器 init 没有这个待遇除非用--init配了 tini 或者显式注册了信号处理。这直接导致了下一章要讲的那些容器一启动就退出的经典问题。4. 围绕这三个 PID 的高频故障与排查套路4.1 PID 1 退出意味着什么以及如何避免全局 PID 1 一旦退出内核会立刻 panic报错信息是Kernel panic - not syncing: Attempted to kill init! exitcode0x00000000这不是吓唬人的措辞而是内核在do_exit()里对全局 init做的硬性判定。逻辑上也说得通init 是所有用户态进程的祖先它没了剩下的进程全成了孤儿系统的运行语义就崩了与其处于不确定状态不如直接停住。但要注意内核拦的是全局 init。在 PID 命名空间内的 init 退出不会触发 panic只会让这个命名空间里的进程收到SIGKILL然后一起死掉——容器退出的机制就是这个。所以排障时要先分清是哪种场景init 退出后的表现排查方向宿主机 / 物理机内核 panic屏幕卡死检查/sbin/init是否损坏、根文件系统是否只读、关键库是否缺失容器pid namespace容器退出退出码通常为 137 或 init 进程的退出码检查容器主进程是否正常 daemon 化、信号是否正确转发initramfs 阶段打印No working init found后 panic检查rdinit/init参数、initramfs 是否完整容器场景下最常见的两个坑第一把服务做成启动后自己 fork 到后台然后父进程退出父进程一退PID 1 没了容器直接结束第二只写了CMD /app/start.sh脚本里没做exec导致 PID 1 是 shell 而不是应用脚本收到SIGTERM后不会转发给子进程docker stop要等超时才被杀。正确做法是在脚本最后一行用exec /app/server让应用直接接管 PID 1。4.2 僵尸、孤儿与信号回收的坑父子关系带来的另一个麻烦是进程回收。子进程退出后内核不会马上把它的任务结构释放掉得等父进程调用wait()或waitpid()来读取退出状态否则它就变成僵尸Z状态占着 PID 号和一小块内核内存不放。那如果父进程先死了呢剩下的子进程就成了孤儿内核会通过find_new_reaper()给它们找一个新爸爸。默认规则是往上找最终落到全局 initPID 1头上。所以你在服务器上看到一堆PPid是 1 的进程很多时候它们原本是有爹的爹死了才被 init 收养。这条规则现在有了例外。Linux 支持PR_SET_CHILD_SUBREAPER这个 prctl 标志某个进程可以声明自己愿意当中间层收养者。systemd就是这么干的——它给每个用户会话设了 subreaper这样会话里的孤儿进程会被会话级的管理进程收走而不是全部涌向 PID 1。这个设计的实际意义在于面向用户的进程可以拿到更准确的退出通知也避免 PID 1 被大量收养和回收请求淹没。排查孤儿和僵尸的常用组合ps -eo pid,ppid,stat,comm | awk $3 ~ /^Z/ ps -eo pid,ppid,stat,comm | awk $2 1 $3 !~ /^Z/ | head -20第一条找僵尸第二条找被 init 收养的孤儿。如果僵尸数量持续增长说明有父进程从来不回收子进程这时候要么修代码要么给它设个 subreaper 兜住如果孤儿数量异常多往往意味着有服务在反复 fork 并崩溃。提示写自动化脚本的时候subprocess.Popen之后一定要配对wait()或communicate()否则在长时间运行的服务里僵尸会一点点累积最后把进程表塞满。4.3 PID 耗尽与 pid_max 的调整边界PID 是有限的。上限由/proc/sys/kernel/pid_max决定32 位系统上默认 3276864 位系统上默认也是 32768但可以调到 4194304。查看和调整cat /proc/sys/kernel/pid_max sysctl -w kernel.pid_max4194304调大之后有个副作用如果同时开着kernel.pid_max和kernel.threads-max的监控会发现内核用于索引 PID 的 IDR 结构变大占用的内存会略有增加但通常可以忽略。更需要注意的是不要在有大量短生命周期任务、又用了 IPsec 或者某些依赖高位 PID 的老代码的系统上乱调历史上有过因为 PID 超过某个阈值导致兼容性问题的案例。PID 耗尽的典型报错是-bash: fork: retry: Resource temporarily unavailable看到它先别急着调pid_max。真正的根因可能是这几种某进程在疯狂 fork比如 shell 循环出 bug、爬虫并发失控、cron 里的脚本没有互斥、僵尸进程堆积占号、ulimit -u设得太低限制了单用户进程数。按这个顺序排查ps -eo pid,user,comm | awk {print $2} | sort | uniq -c | sort -rn | head ps -eo stat | grep -c ^Z ulimit -u第一条看哪个用户在占号第二条数僵尸第三条看用户级限制。定位到源头再去改参数比盲目调大上限靠谱得多。4.4 常见疑问速查表含 PID 名称歧义说明围绕这三个编号我被问过的问题重复率很高干脆整理成表疑问结论验证方式/proc/0为什么不存在PID 0 未经过alloc_pid()不在命名空间哈希表里ls /proc | sort -n | headps为什么看不到 0 号没有/proc目录项ps数据源就是/procps -eo pid | sort -n | head每个 CPU 的 idle 线程 PID 是几都是 0命令名swapper/Ncat /proc/sched_debug | grep -i swapperkthreadd一定是 2 号吗现代内核中稳定是 2因为 PID 分配单调递增且中间无插入grep PPid /proc/2/status为什么内核线程父进程是 2所有kthread_create()请求都由kthreadd落地ps -eo pid,ppid,comm | awk $22收到kill -9 1会怎样全局 init 有SIGNAL_UNKILLABLE保护信号被忽略在测试机上用非 root 试观察返回容器里的 PID 1 是宿主机的吗不是是 PID 命名空间内的独立编号grep NSpid /proc/1/status怎么确认当前 init 是哪个程序读/proc/1/exe和/proc/1/commreadlink -f /proc/1/exe最后补一个容易造成搜索混淆的点操作系统里的 PID 是 Process ID自动化控制里的 PID 是 Proportional-Integral-Derivative。这两者除了缩写一样毫无关系。你在搜索引擎里搜pid 算法、位置式 pid、增量式 pid、pid 控制器出来的全是控制理论内容讲的是用比例、积分、微分三项去拟合误差曲线用在电机调速、温度控制、无人机姿态稳定上。而搜linux pid、进程 pid、ppid才是操作系统这一侧。写技术文档的时候最好把全称写清楚不然读者很容易被带偏我自己就曾经在一个内部 wiki 里把两边的链接混着贴过后来被同事吐槽了很久。5. 把这些知识落到实际工作里5.1 定制最小 init 与内核启动参数理解了 PID 1 的选取顺序就能反过来控制启动行为。最常用的两个参数是init和rdinit它们的优先级不一样rdinit只对 initramfs 里的/init生效如果设了它内核会优先尝试这个路径init会在 initramfs 交接完之后在真实根文件系统上执行两者都不给的时候内核按/sbin/init、/etc/init、/bin/init、/bin/sh的顺序试。在 GRUB 里临时加参数很简单选中启动项按e找到linux那行末尾追加CtrlX启动。做救援的时候我最常用的组合是init/bin/bash rw它能让你在几乎没有任何服务启动的情况下拿到一个 root shell用来改密码、修配置文件、检查磁盘。需要注意的是这种方式进来之后没有systemd帮忙挂载文件系统/proc、/sys可能是空的得手工挂mount -t proc proc /proc mount -t sysfs sysfs /sys如果是做嵌入式产品把 PID 1 换成一个自己写的极简程序也很常见。核心要求只有几条永远不要退出、正确回收孤儿进程循环waitpid(-1, ...)、把收到的信号转发给子进程、在收到SIGTERM时有序关停。这几十行代码写好了整个系统的最小化就能再往前推一步。5.2 用 PID 树排查资源异常与可疑进程线上出问题的时候进程树往往是第一手线索。CPU 或者内存突然飙高先做这三步top -o %CPU -b -n 1 | head -20 ps -eo pid,ppid,%cpu,%mem,comm --sort-%cpu | head -20 pstree -p 可疑PID前两条定位到具体进程第三条把它放回进程树里看。很多时候单个进程看起来人畜无害但往上追两层会发现它是一个失控脚本的孙子进程或者是某个已经崩溃但没退出的服务残留。这种顺藤摸瓜的排查方式比盯着单个进程看有效得多。还有一类场景是排查可疑进程。判断依据可以是进程的可执行文件路径指向/tmp或者/dev/shm正常服务不会放这儿、PPid是 1 但找不到对应的 systemd unit、进程名的拼写和正常系统进程只差一两个字符、/proc/pid/exe指向的文件已被删除readlink会带(deleted)后缀。这几条组合起来看基本能筛出绝大多数异常。ls -l /proc/*/exe 2/dev/null | grep deleted systemctl status $(cat /proc/pid/comm)5.3 容器 init 与 subreaper 的取舍建议容器场景下要不要加一个 init 进程一直有争议。我的建议是按应用类型分如果容器里跑的是单一前台进程并且它自己正确处理了SIGTERM、自己wait了子进程那就没必要加。多一层反而增加信号转发的复杂度和调试成本。前提是CMD用的是 exec 形式不要让 shell 挡在前面。如果应用会产生后台子进程、或者会在运行中 fork 出短命进程比如调用外部命令做一次任务强烈建议加。用docker run --init或者 compose 里写init: trueDocker 会注入一个极小的 init。它干的事就是回收僵尸、转发信号正好补上应用自己懒得做的那部分。如果容器是给开发人员用的交互环境比如一个基础的开发镜像那就更应该加否则 shell 里随手起的后台进程会一直堆着。这里有个容易踩的细节加了--init之后容器内的 PID 1 就不再是应用本身而是那个小 init应用的 PID 变成 2。这时候如果应用逻辑里有硬编码我是 1 号的判断例如判断收到某个信号时该怎么处理行为会变。线上切之前最好先在测试环境验证一遍信号路径。回头看这一整条链路从rest_init()里的两次kernel_thread()到kernel_init一路做完initcall再execve成systemd再到kthreadd默默孵化出几百个内核线程这三个编号其实各管一摊0 号是数据和调度的地基1 号是用户世界的入口2 号是内核后台的车间。我在实际调试中养成了一个习惯拿到一台不熟悉的机器先敲三条命令——grep PPid /proc/1/status、ps -eo pid,ppid,comm | awk $22 | wc -l、readlink -f /proc/1/exe。第一条确认父子关系没被改过第二条大致知道内核线程规模第三条确认 init 是什么。三秒钟就能对这台机器的启动形态有个基本判断比翻配置文件快得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表