ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux进程控制全解析:从fork到僵尸进程的完整链路

Linux进程控制全解析:从fork到僵尸进程的完整链路 1. 为什么搞懂进程控制是Linux运维和开发的第一道门槛先聊点实际的。很多人学Linux最开始的成就感来自敲命令ls、cd、ps、kill一套组合拳下来感觉已经会玩系统了。但真正上了生产环境遇到服务莫名其妙挂了、进程明明在跑却不响应、系统负载不高但新进程起不来这类问题就开始抓瞎。原因很简单——你只是学会了用命令还没理解命令背后操作系统是怎么管进程的。我这里说的进程控制不是某个具体命令而是Linux系统里围绕进程的整套机制进程怎么被创建出来fork、怎么变成另一个程序exec、怎么退出exit、退出去之后内核怎么把它的身后事处理干净wait、以及信号signal在其中扮演什么角色。这套机制是操作系统的命脉级别知识。对运维来说它是排查故障的地基对开发来说它是写出健壮后台程序的前提哪怕是嵌入式方向跑Linux的板子上进程模型出了问题照样一头雾水。这篇文章我打算把进程控制这条线完整捋一遍不讲教科书上的空泛概念而是从代码到内核行为再到真实线上故障一层层拆开。很多内容也是我在实际工作中踩坑总结出来的希望能帮你在遇到问题的时候不再是重启试试而是能直接定位到哦原来是进程控制机制在这里出了问题。2. 进程的诞生fork背后的写时复制与调度逻辑2.1 一次调用两次返回的精分现场先看最经典的代码这是理解进程创建的核心入口#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { perror(fork error); return 1; } else if (pid 0) { printf(I am child, my pid %d\n, getpid()); } else { printf(I am parent, child pid %d\n, pid); } return 0; }fork这个系统调用诡异的地方在于它只调用一次但返回两次。父进程里返回的是子进程的PID一定大于0子进程里返回的是0。子进程拿到的是父进程地址空间的一份完整拷贝——注意这里说的是逻辑上的完整拷贝内核实际用的是写时复制技术来做优化。什么叫写时复制简单说就是fork的时候父子进程先共享同一份物理内存只是把页表项标记成只读。谁先往这块内存里写数据谁就触发一次缺页异常内核才给它分配新的物理页把内容拷过去然后更新页表。这样最直观的好处是如果fork之后子进程立刻exec去跑新程序那父进程几乎不需要付出什么拷贝成本因为exec会直接丢弃旧的地址空间。这个设计让forkexec这种组合变得非常轻盈也是Linux上进程创建的效率能这么高的根本原因。这里有一个很多人初学时会踩的坑fork之后的printf问题。上面这段代码如果你把printf的字符串换成一个不带换行符的版本比如printf(before fork, pid %d, getpid()); fork();运行之后你会发现before fork这串字符打了两遍。原因在于printf这种标准库函数默认对stdout做的是全缓冲写文件时或行缓冲写终端时。不带换行符时数据还没真正flush到内核缓冲区还留在用户态的stdio缓冲区里。fork拷贝地址空间的时候把这个用户态缓冲区也一并拷贝了。于是父子进程各带着一份还没写出去的before fork等到进程退出统一flush的时候就变成了两遍。解决办法也简单fork之前要么加换行符把行缓冲刷出去要么显式调用fflush(NULL)。这算是进程控制里最经典的一道面试陷阱但在实际写多进程日志程序的时候真的会导致日志重复输出。2.2 调度器视角下的父子进程竞跑fork之后父子进程到底谁先执行这可能是初学者最关心的问题之一。答案很扫兴不确定。父子进程都处于就绪态最终谁先拿到CPU由内核的调度器按调度策略决定。绝大多数调度策略下子进程通常会先被调度因为Linux的copy_process为了保证fork的局部性倾向于让子进程先运行这样父进程有可能接下来要exec、要退出可以减少不必要的写时复制开销。但这是倾向不是保证。所以在写多进程逻辑的时候永远不要假设父子进程的执行顺序。尤其不要写出这种代码——父进程fork完马上读一个变量准备传给子进程用而子进程那边又指望父进程先把这个变量改掉。多进程之间如果需要同步必须走明确的IPC机制管道、信号、共享内存、文件锁靠我猜它应该先跑这种思路早晚出事。我之前在一个嵌入式Linux项目里就见过这样的bug主进程fork子进程去采集传感器数据采集完要写共享文件。代码里没有任何同步就是觉得父进程反正先fork出来肯定先把配置写好了。结果某次板卡启动后资源紧张调度顺序变了子进程先把空配置写进了日志整个采集流程后面全都对不上。后来改成先写配置再fork子进程才稳定下来。2.3 fork失败的几种真实场景fork并不是百分百成功的。返回-1时最常见的原因是EAGAIN意思是进程数或内存达到了系统上限。这里说的进程数上限不是单看某个用户能开多少而是同时要考虑两个关键参数一个是内核参数kernel.pid_max系统级PID号上限另一个是cgroup的pids.max容器或systemd slice内的进程数限制。我在排查线上问题的时候见过好几次这样的情况某台机器上跑着一个Java应用代码里疯狂fork外部工具去处理任务进程数量膨胀到几万结果新的fork直接返回Resource temporarily unavailable。系统负载不高CPU也不忙但就是起不了新进程。用cat /proc/sys/kernel/pid_max一看默认的3276832位系统上常见的值早就被占满了。解决办法是分两层短期调大pid_max64位系统可以调到百万级长期改代码控制并发进程数别让进程数量无限膨胀。还有一种fork失败情况是内存不足。虽然写时复制让fork变得轻量但fork还是要为子进程复制一套内核数据结构task_struct、mm_struct、文件描述符表等并且要给父进程的地址空间建立页表。如果系统真的是内存见底fork就会失败。这种情况在高并发、大内存占用的服务上并不少见。3. 进程的变身exec族系统调用与PATH查找的隐藏细节3.1 名字不同内核都背着我们干了同一件事fork创建出来的子进程一开始和父进程几乎一模一样。但我们日常看到的现象是父进程跑着一个Java程序子进程怎么就能变成nginx、变成python、变成bash这个变身动作就是exec族系统调用干的事。exec不是一个函数而是一个家族常用的有六个int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ..., char * const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execve(const char *path, char *const argv[], char *const envp[]);记忆的规律其实很简单看后缀字母就行llist参数以列表形式逐个传入最后一个用NULL结尾。vvector参数存到一个指针数组里数组里每一项是一个参数。ppath会自动去PATH环境变量指定的目录里查找可执行文件不需要写绝对路径。eenvironment可以自己指定新进程的环境变量列表而不是继承父进程的。你平时在shell里敲一个命令比如nginxshell内部做的事情本质上就两步先fork出一个子进程然后在这个子进程里调用execvp去PATH里找nginx可执行文件并加载运行。这也解释了为什么你在shell里执行命令时当前shell本身不会消失——因为实际跑命令的是子进程。这里有一个极其关键、新手最爱踩坑的点exec成功之后后续代码不会执行。因为exec成功意味着当前进程的地址空间、代码段、数据段、堆栈全被新程序替换掉了CPU从新程序的入口开始执行你原来写在exec后面的任何语句都已经毫无意义。只有exec失败比如找不到文件、没有执行权限才会返回-1而且返回之后还能继续执行后续代码。所以实践中一定要对exec的返回值做检查pid_t pid fork(); if (pid 0) { execl(/usr/sbin/nginx, nginx, -g, daemon off;, NULL); // 能走到这里说明exec失败了 perror(exec nginx failed); _exit(127); }注意这里我用了_exit(127)而不是exit(127)。两者有很大区别exit是标准库函数会做缓冲区清理、执行atexit注册的清理函数干净退出_exit是系统调用直接让内核把进程干掉。在fork出来的子进程里如果你已经决定要exec一个新程序那exec成功前子进程和父进程共享着一堆继承来的状态。此时如果用exit标准库会flush继承自父进程的用户态缓冲区这可能导致日志被重复写、文件描述符被多关一次等问题。稳妥做法是子进程里尽量用_exit绕开所有清理逻辑直接走人。3.2 PATH查找的机制与安全考量带p的exec版本execlp、execvp会自动查PATH。这里有个隐藏细节它查PATH的顺序跟你echo $PATH看到的环境变量顺序完全一致从左到右逐个目录找找到第一个匹配的就执行。这在平时用着很方便但安全隐患也在这里。举个例子如果某个服务的启动脚本里调用了execvp去执行myapp而当前环境PATH里恰好有个/home/user/bin目录排在/usr/bin前面攻击者只要在/home/user/bin里放一个同名恶意程序你的服务就变成了运行别人的代码。生产环境的服务启动脚本我强烈建议要么用带完整路径的execv/execl要么在启动脚本开头显式写死export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin把PATH固定下来。还有一个细节是带p版本的exec在查找PATH的时候如果目标文件存在但没有执行权限它不会立刻放弃而是会继续往后面目录找。找到有权限的文件才执行。找不到或者全部没权限才返回错误。这个行为很多人不知道容易在排错时产生疑惑为什么明明/usr/bin/foo存在但execvp报No such file or directory因为可能系统里/usr/local/bin/foo排在前面存在但权限不足execvp就跳过了而最终没有一个目录里的文件是可执行的。3.3 exec时文件描述符的继承与清理exec之后进程的代码和数据被替换了但文件描述符默认是保留的。这既是一个便利也是一个坑。便利在于像shell重定向cmd out.log 21的实现就是先在子进程里把标准输出的文件描述符1重定向到文件再exec cmd这样cmd的所有标准输出就自然写进文件了。坑在于如果你的服务fork子进程、exec外部程序时子进程会继承一堆与服务端socket、数据库连接、定时器fd等相关的描述符。这会造成什么后果最典型的你启动了一个监听了TCP端口的服务服务代码里fork某个子进程去执行外部工具却忘了关闭监听socket的描述符。这个子进程如果一直不退出那么即使主服务想关掉这个端口重新监听也会失败——因为还有别的进程持有这个socket的引用。这类端口被神秘占用的问题排查起来很痛苦根子就在exec继承描述符上。解决方案在工程上俗称关闭fd门在fork和exec之间遍历/proc/self/fd目录用close_range内核5.9libc支持或closefromBSD系把除了0、1、2之外的所有描述符关掉然后把要传给新程序的描述符显式保留下来。以C语言为例高版本glibc可以直接close_range(3, ~0U, 0);低版本内核没这个接口就手动遍历for (int fd 3; fd sysconf(_SC_OPEN_MAX); fd) { close(fd); }这一步是很多进程泄露文件描述符类故障的根治手段。4. 进程的归宿exit与僵尸进程的完整治理链路4.1 进程退出的不同姿势return、exit、_exit进程结束的方式有几种很多人以为都一样其实它们对进程控制的影响差别很大main函数里的return 0这其实也通过标准库的逻辑最终会调用exit标准库版本但是它在返回之前会先执行全局析构、atexit注册的清理函数然后才真正调用_exit系统调用让内核回收进程。exit(n)标准库函数做缓冲区flush、执行atexit、关闭标准IO流然后调用_exit。_exit(n)/_Exit(n)直接陷入内核不做任何用户态清理立刻终止进程。这里就回到上面提到的问题为什么在fork出来的子进程里尤其是准备exec新程序之前优先用_exit原因就是exit会动用整个标准库的清理链条而这些清理动作里包含了flush缓冲区。当这个缓冲区是继承自父进程的时候你flush的实际上是把父进程的一段脏数据再次写一遍。日志重复、文件内容错乱很多时候就是这么来的。4.2 僵尸进程是怎么产生的以及为什么它是运维噩梦进程退出后并不会立刻从进程表里消失。内核会保留它的task_struct登记它的退出状态退出码直到父进程调用wait/waitpid把这个退出状态取走它才真正被释放。处于已经退出、但状态还没被父进程收走这个阶段的过程就叫僵尸进程zombie。僵尸进程在ps里的状态标识是Z。它不占用CPU也不占用内存那些已经释放了但它占着一个PID号。而PID号是有限的受kernel.pid_max限制大量僵尸进程堆积的最终结果就是系统无法创建新进程——因为PID号被耗尽了。这就回到了我前面讲fork失败时的场景你查pid_max明明还有余量但ps aux里一列全是Z状态的进程实际可用的进程号已经没了。僵尸进程的常见产生原因说白了就是代码里fork了子进程却从不调用wait/waitpid去收尸。这种事在高频fork子进程的脚本型服务、或某些图省事的C程序里特别常见。Python里subprocess模块如果不管子进程返回状态也一样会有僵尸只是Python的垃圾回收机制可能掩盖一部分。4.3 收尸的正确姿势wait、waitpid与SIGCHLD信号回收子进程退出状态的标准接口是#include sys/wait.h pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status, int options);wait是阻塞的会一直等到有一个子进程退出才返回waitpid更灵活可以通过pid参数指定等哪个子进程通过options参数设置为非阻塞WNOHANG。在实际服务代码里最常见的处理方式是把waitpid和SIGCHLD信号配合起来。子进程退出时内核会给父进程发送SIGCHLD信号。父进程里注册一个信号处理函数在函数里循环调用waitpid(-1, status, WNOHANG)把所有已退出的子进程全部收一遍void sigchld_handler(int sig) { pid_t pid; int status; while ((pid waitpid(-1, status, WNOHANG)) 0) { // 处理每个退出的子进程 } } // main里注册 signal(SIGCHLD, sigchld_handler);注意这里一定要用WNOHANGwhile循环。为什么因为信号处理函数执行期间如果又有子进程退出SIGCHLD信号可能被pending合并标准信号不支持排队多个同类信号只算一个那么处理函数只被调用一次而此刻可能有多个子进程都已经死了。所以必须在处理函数里用while循环一口气把所有僵尸都回收掉。有一个非常经典的偷懒技巧父进程如果不关心子进程的具体退出状态可以在注册SIGCHLD处理函数的时候用signal(SIGCHLD, SIG_IGN)即显式忽略这个信号。这时候内核对SIGCHLD的处理逻辑会变成直接自动回收子进程不留僵尸。这是POSIX允许的优化行为不是未定义怪癖。很多高并发网络服务器的多进程模型里父进程就是靠这个技巧省掉一整套waitpid逻辑。但代价是你永远拿不到子进程是否异常退出、退出码是多少这些信息。如果你的业务需要知道子进程执行成败就别这么干。4.4 深入理解退出状态WIFEXITED与WIFSIGNALED用wait拿到status之后它里面存的不仅仅是退出码而是一个打包了很多信息的位段。必须用宏去解析不能直接拿int用WIFEXITED(status)判断子进程是否正常退出通过exit或return。WEXITSTATUS(status)如果正常退出取得退出码0~255。WIFSIGNALED(status)判断子进程是否被信号杀死。WTERMSIG(status)如果是被信号杀的取信号编号。WCOREDUMP(status)判断是否产生了core dump。WIFSTOPPED(status)/WSTOPSIG(status)子进程是否处于停止状态以及是哪个信号导致的停止配合waitpid的WUNTRACED选项用。这些宏是每个做进程管理的程序员都应该记熟的。我见过不少运维脚本只判断进程退出码是不是0就下结论说服务启动失败。但实际情况是子进程可能是被SIGSEGV段错误干掉的退出码是13912811也可能是被SIGKILL干掉的退出码1371289。如果你只判断不等于0就是失败那排查问题的方向可能从一开始就偏了。从status里解析出是被哪个信号杀的、当时是不是有core dump能帮你省去大量的猜测时间。5. 信号协同进程控制里最容易被低估的控制通道5.1 信号的本质与常见信号速查进程控制不只是fork、exec、wait信号signal也是不可或缺的控制手段。你可以把信号理解成内核给进程发的一个异步通知进程随时可能被信号打断转去执行信号处理函数完了再回来做原来的事。运维和开发日常打交道最多的信号我列个速查表信号编号默认行为常见触发场景SIGHUP1终止进程终端断开、nginx reloadSIGINT2终止进程键盘CtrlCSIGQUIT3终止并生成core键盘Ctrl\SIGKILL9强制终止不可捕获kill -9SIGSEGV11终止并生成core非法内存访问SIGPIPE13终止进程写管道但没有读者SIGTERM15终止进程kill 默认发送SIGCHLD17忽略子进程退出SIGSTOP19停止进程不可捕获CtrlZ这里有一个运维场景我想特别聊聊。很多人以为kill -9是万能杀进程手段但我的经验是kill -9是最后手段不是第一手段。原因很简单kill -9发送的SIGKILL信号在内核层面直接终止进程进程没有任何机会做清理——不执行atexit、不flush缓冲区、不关闭文件、不释放锁。如果你用kill -9杀数据库、杀Redis、杀消息队列这类有持久化状态的服务轻则内存里没落盘的数据丢了重则进程持有的锁文件没人清理重启后要手动修复半天。正确的操作顺序是先kill -TERM发SIGTERM给进程一个优雅退出的机会等服务自己清理完、自己退出等几秒发现还没退出再用kill -KILL兜底。很多成熟的守护进程nginx、MySQL、Redis都注册了SIGTERM处理函数收到后会把脏数据flush、关闭连接、清理锁然后才退出。你给它这个机会就是在给自己省后续的麻烦。5.2 信号处理函数里的禁区如果你自己写服务程序要处理信号有一条铁律必须记住信号处理函数里不能调用不安全的函数。什么叫不安全比如printf、malloc、free、pthread_mutex_lock这类涉及全局状态、锁、堆分配的函数都不是异步信号安全的。因为在信号打断的那一刻主程序可能正卡在malloc的内部临界区里信号处理函数再调一个malloc就可能把堆管理器的内部状态搞乱直接死锁或崩溃。信号处理函数的安全操作清单其实很短安全读写volatile sig_atomic_t类型变量、调用write注意不是printf、_exit、signal等少数系统调用。所以工程实践里标准的信号处理模式是信号处理函数里只设置一个标志位真正的收尸、清理逻辑放到主循环里去检查这个标志位再执行。也就是所谓的信号只负责叫醒不负责干活。这样写才是安全的volatile sig_atomic_t g_flag_child_exited 0; void on_sigchld(int sig) { g_flag_child_exited 1; // 只置标志 } // 主循环里 while (1) { if (g_flag_child_exited) { g_flag_child_exited 0; while (waitpid(-1, status, WNOHANG) 0) { // 真正的收尸逻辑 } } // 其他业务逻辑 }5.3 一个教学级的信号陷阱kill调用本身也会失败最后提醒一个细节kill这个系统调用本身也会返回错误。kill(pid, 0)不发送任何信号只用来检测进程是否存在用0号信号探测。kill(pid, SIGTERM)发送信号时如果目标进程不存在会返回ESRCH权限不够会返回EPERM。我在写运维脚本的时候习惯在kill之后检查返回值因为kill成功了和进程真的死了是两件事——信号发出去只代表内核接受了投递请求真正死没死还得配合后续的进程存在性检测来判断。6. 实战复盘一次PID耗尽故障的完整排查链路6.1 现象新进程起不来老进程又在跑有一回线上系统出了这么一个事某天下午监控平台报警说A服务健康检查失败。我登录上去看发现服务主进程还在ps能看到状态是S但它的子线程和子进程都异常了。尝试手动重启结果报错Resource temporarily unavailable——fork失败了。先用最基础的命令看系统整体状态ps -e -o pid,ppid,stat,comm | awk {print $3} | sort | uniq -c很快发现了关键线索Z状态的进程数量极其庞大有好几千个。这些僵尸进程的父进程PID集中在同一个编号——就是那个A服务的守护进程我们用systemd托管。换句话说systemd虽然是A服务的父进程但A服务自己fork了大量子进程这些子进程退出后A服务从没调用wait回收全堆成了僵尸。6.2 根因从不收尸的子进程模型继续深挖原因。查看A服务的日志发现在过去一段时间里它持续不断向外fork短生命周期的工作进程去处理任务但每次fork完代码里根本没有任何waitpid逻辑。子进程跑完就变成僵尸留在进程表里占位。日积月累僵尸攒到几千个把PID号消耗殆尽。内核的kernel.pid_max是默认的32768。一个机器上本来还有其他服务、系统线程PID号本来就吃紧。几千个僵尸一占剩下的PID号就不够用了于是新fork直接失败。这个故障的欺骗性在于系统CPU、内存、磁盘全都不高负载也正常乍一看一切正常但你就是起不了任何新进程。这也是为什么我强烈建议运维同学日常巡检的时候别只看top里的负载和CPU还要顺手看看进程状态分布。一条命令就能看出来ps -e -o stat | sort | uniq -c正常情况下除了少数D状态不可中断睡眠和一批S状态睡眠Z状态应该非常稀少。如果Z的数量持续增长而不是归零大概率有程序在泄漏子进程。6.3 处置先止血再治根当时的紧急处置分几步第一步重启A服务让僵尸进程的回收责任从它身上转移。僵尸进程跟随父进程消失而消失被init或systemd收养后由systemd统一wait。重启后僵尸清零PID号释放系统恢复可用。第二步在A服务的代码里加SIGCHLD处理——注册处理函数用waitpid(-1, status, WNOHANG)的while循环把所有子进程的退出状态都收掉。改动不大但彻底解决了僵尸堆积问题。第三步给系统层面加防护。把systemd服务单元里加上TasksMax限制[Service] TasksMax1000这样即使应用再次失控进程数量也不会无限膨胀到拖垮整个系统。另外还把kernel.pid_max调大到了131072给正常业务留出余量。这个案例里如果你不懂进程控制看到Resource temporarily unavailable可能会以为内存不够然后去加内存、调swap折腾半天问题复现。而当你理解fork失败的本质、理解僵尸进程吃PID号的机理整个排查链路就是顺藤摸瓜的事。这也是为什么我始终觉得进程控制不是一门理论课它是实打实的排障基本功。7. 让进程脱离终端守护进程化daemonize的正确打开方式7.1 nohup、setsid与守护进程的三类常见误区聊完上面的故障再补充一个和高可用部署强相关的知识点怎么让一个进程真正脱离终端变成后台运行的守护进程。这里最常见的是三个命令/接口很多人混着用但其实差异很大nohup cmd 作用是忽略SIGHUP信号。SIGHUP在终端关闭时发给该终端下的会话首进程然后级联发给会话里的其他进程。nohup的本质就是让进程忽略这个信号所以终端关闭时它不会被顺带杀死。setsid cmd让进程新开一个会话完全脱离原来的控制终端。在代码里调用setsid()系统调用把自己变成新会话的首进程。nohup和setsid的区别用一个场景就能讲明白你在SSH里执行nohup sleep 100 然后退出SSH进程一般能活下来因为它忽略了SIGHUP。但如果这个进程后续自己去调用了fork又或者它需要真正和终端切断关系比如不再尝试读写终端的stdin/stdoutnohup就不够用了。setsid则是从会话层面脱离——即使终端本身还在进程也不再属于那个会话终端的一切信号SIGHUP、CtrlC都影响不到它。7.2 标准守护进程化步骤与背后的为什么如果你写的是一个正式的服务程序靠nohup兜底不是长久之计最好在代码里自己做daemonize。经典的步骤是这样pid_t pid fork(); if (pid 0) { // 父进程退出让子进程被init/systemd收养 exit(0); } // 此时进程不再是会话首进程可以成功调用setsid if (setsid() 0) { perror(setsid); return -1; } // 二次fork确保进程不是会话首进程无法重新获取控制终端 pid fork(); if (pid 0) { exit(0); } // 改变工作目录到根目录避免占用挂载点 chdir(/); // 重置文件权限掩码 umask(0); // 关闭标准输入输出和错误或用/dev/null重定向 freopen(/dev/null, r, stdin); freopen(/dev/null, w, stdout); freopen(/dev/null, w, stderr);每一步都有它的用途我逐个说明一下第一次fork是为了让子进程不是会话首进程这样后续setsid()才能成功。因为setsid()要求调用者不是某个进程组的组长。父进程直接退出子进程被系统托管就满足了条件。setsid()成功之后当前进程变成了新会话的首进程也变成了新进程组的组长并且和原控制终端完全脱离。第二次fork是为了确保进程以后再也不会重新获得控制终端。因为一个会话首进程如果再次打开一个没有明确指定所属会话的终端设备内核有可能会把它分配为这个终端的新控制终端。再fork一次让进程不再是会话首进程就能彻底避免这个情况。chdir(/)的原因是如果进程当前工作目录在某个挂载点上比如某个磁盘分区父进程退出时这个目录还被进程占用那么管理员想umount这个分区都会失败提示target is busy。umask(0)是为了让子进程创建文件时的权限完全由程序自己决定不被父进程的umask干扰。把标准输入输出错误都重定向到/dev/null是因为一个后台守护进程如果还持有终端的fd那么在调用某些库函数时可能因为尝试读写终端而发生意外行为比如意外收到SIGTTOU信号导致进程被停止。7.3 现代思路别再手动daemonize了交给systemd不过说实话在现代Linux发行版上我其实不太推荐在应用代码里手动做这一大套daemonize步骤了。原因很简单systemd已经把守护进程化这件事接管了。你写一个服务单元文件[Service] ExecStart/usr/local/bin/myservice Restarton-failure Usermyservicesystemd会自己调用fork、setsid、重定向标准fd然后把进程放到一个干净的cgroup里管理。你的程序只需要老老实实以前台方式运行不需要自己fork不需要自己setsid。这样日志采集、进程监控、崩溃重启都变得非常统一不再依赖各应用自己实现的半吊子守护进程。唯独有一种情况我还保留手动daemonize那就是目标环境非常精简没有systemd连init脚本都要自己写的嵌入式场景。那种情况下知道完整的daemonize步骤确实是救命技能。所以这部分知识属于你可以不用但必须会的范畴。8. 写在最后进程控制是理解一切系统行为的地基我自己带人的时候不管对方是运维还是后端开发都会建议先把进程控制这条线彻底吃透。原因很简单——你在Linux上遇到的很多问题掰开揉碎之后最后都会落回到进程是怎么被创建、怎么执行、怎么退出、怎么回收这几个基本动作上。如果你受了这篇文章启发建议动手做几个小实验来加深理解写一个fork程序观察输出顺序写一个故意不wait的父进程看看僵尸是怎么积累的再写一个注册了SIGCHLD处理函数并用while循环收尸的版本对比前后系统里Z状态进程的数量变化。这些实验都不难短短几十行代码但带来的理解深度比纯看文档要深刻得多。还记得我前面讲的那个线上PID耗尽案例吗它的教训本质就一句话你的每一个fork将来都是要负责回收的。不回收系统就会在某一天用无法创建新进程来惩罚你。搞明白这一点进程控制这个主题你就已经掌握到能实战的程度了。剩下的就是在更多真实系统的运行里不断验证和积累自己的判断了。
返回列表