news 2026/9/29 5:46:50

深入理解Linux进程创建与回收:从fork到SIGCHLD与进程池

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解Linux进程创建与回收:从fork到SIGCHLD与进程池

在 Linux 下搞过服务端开发的人,基本上都会被“进程的创建与回收”这件事教育过几回。创建听着简单,不就是 fork 一下?回收听着也不难,不就是 wait?可真到了线上,进程像是野草一样疯长、ps 里冒出一堆 defunct、父进程忘了收尸结果句柄泄漏、服务跑着跑着就再也 fork 不出新进程……这些场景我一个个都踩过。这篇文章就沿着“进程创建”和“进程回收”这条主线往下走,先讲清楚 fork 底层的写时复制逻辑,再把 wait 和 waitpid 的细节磨明白,最后聊一聊工程里真正在用的 SIGCHLD 回收姿势和进程池的设计思路。不管你是刚学 Linux 编程的新手,还是已经在写服务端程序想补一补底层功课的老兵,这应该都能帮上忙。

1. 先想清楚:进程到底是什么,为什么创建和回收是配套动作

1.1 程序是一份菜谱,进程是端上桌的那道菜

很多刚接触 Linux 的朋友会把“程序”和“进程”混为一谈,但这俩关系其实很像“菜谱”和“端上桌的菜”。菜谱静静地躺在盘子里或者手机里,什么都不会发生;只有你照着菜谱动手做了,才有一道热菜端在你面前。程序就是磁盘上那个可执行文件,它是静态的;进程是程序被加载到内存里跑起来之后的动态实体,它有自己独立的 PID、独立的虚拟地址空间、独立打开的文件表,还有自己的工作目录、环境变量和信号处理函数。

开发中还有一个更准确的类比:进程其实是一套“执行现场”。CPU 执行到哪里、栈上有什么临时变量、打开了哪些文件、标准输出指向哪个终端,这些信息组成了一套上下文。Linux 内核调度一个进程的时候,就是在这些现场之间反复切换。你理解了“现场”这个概念,就会明白进程创建的本质不是“安装了一个新程序”,而是“搭了一套新的执行现场”。

1.2 创建进程等于“现场复制”,回收进程等于“事后结账”

在 Linux 上创建一个新进程,最常见的底层动作是 fork。fork 的直译是“分支”,它的行为也确实像岔路口:从一个正在运行的父进程身上,复制出一份几乎一模一样的子进程。子进程一开始的代码、数据、文件描述符都和父进程相同,但它从此有了自己独立的 PID,也和父进程走上了不同的路径。

复制只是一半,另一半收尾工作就是回收。很多新手以为进程退出之后内核就会自动把它的所有痕迹抹掉,事实并非如此。进程退出的一瞬间,它的大部分资源(内存、文件、信号处理器)确实会被内核释放掉,但内核会刻意保留一个“残骸”,里面记录着这个进程的退出状态、消耗的 CPU 时间等信息,等着父进程来认领。这个残骸状态在 ps 里显示为 Z,也就是僵尸进程(zombie)。父进程调用 wait / waitpid,本质上就是去“收尸”:取走退出状态,然后告诉内核“行了,这孩子我看完了,你可以把最后那点记录也删了”。所以创建和回收永远是配套动作,创建了多少个进程,最后就应该回收多少个,一个都不能少。

2. 进程创建:fork、vfork 与 exec 三兄弟

2.1 fork:整个 Unix 世界最经典的创建接口

在 Linux 里创建一个进程,主流办法就一个:调用 fork。它的原型特别简单:

#include <sys/types.h> #include <unistd.h> pid_t fork(void);

调用一次,却会返回两次,这也是很多初学者最懵的地方。fork 成功后,父进程收到的是子进程的 PID,子进程收到的是 0;如果失败,父进程收到的是 -1。通过这个返回值,父子进程立刻就走进了不同的分支:

pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } else if (pid == 0) { /* 子进程执行到这里 */ printf("I am child, pid=%d\n", getpid()); } else { /* 父进程执行到这里 */ printf("I am parent, child pid=%d\n", pid); }

这个“返回两次”的设计其实非常巧妙。子进程没有办法在 fork 之后立刻知道自己的 PID 是多少,除非去调 getpid,但那样还要再区分一次父子上下文;内核干脆把结果直接塞进返回值里,父进程得到比自己小的那个 PID 线索,子进程得到一个 0,两边都不需要额外系统调用就能立刻对号入座。

2.2 fork 底层的写时复制,不影响“上手即用”

早期的 fork 实现是实打实地把父进程整个地址空间复制一份,成本高得吓人。后来 Unix 设计者引入了写时复制(Copy-On-Write,COW)机制:fork 的时候并不急忙复制物理内存,只是把父进程的页表复制给子进程,并且把这些页标记成“只读”。父子任意一方真的去写某个页时,才触发出错,内核再针对那一个页做真正的复制,然后把所有权分配给触发了写入的那一方。

这个机制带来的直接体验就是:你写一个 fork 程序感觉它好快,因为大部分情况下它根本就没怎么复制内存。但它也埋了一个坑:如果你 fork 之后马上在子进程里大量写内存,尤其是一边 fork 一边初始化大数组,那 COW 页错误会此起彼伏,性能并不比原地复制好多少。所以工程上有一个老传统——fork 之后子进程应该立刻 exec 或者退出,尽量不要在子进程里做太多“带写操作”的初始化。

2.3 vfork:一个共享地址空间的“远古特例”

除了 fork,Linux 还保留了一个 vfork 接口,它和 fork 最大的区别是:vfork 创建出的子进程完全共享父进程的地址空间,并且父进程会一直阻塞,直到子进程调用 exec 或 _exit。vfork 的设计初衷是为了配合 exec 用的,因为子进程马上要替换成全新程序,那 fork 阶段复制地址空间就纯属浪费,干脆先共享着用。

但 vfork 在工程里是个危险品。你想,父子共享同一个地址空间,而且父进程被冻结住,子进程一旦改了某个变量,等父进程恢复执行时看到的也是被改过的值。这就导致很多 vfork 程序出现“子进程悄悄改了父进程数据”的诡异 bug。我记得有一种老掉牙的 fork bomb 变种也会用 vfork 来提高炸弹效率。所以现在的实际建议是:现代 Linux 上 fork 已经支持 COW,vfork 几乎没有性能优势,还带来一堆共享地址的隐患,除非你清楚知道自己到底在干什么,否则不要用 vfork 写业务代码。

2.4 exec 族:让子进程“换一个程序跑”

fork 出来的子进程继续跑的依然是父进程那套二进制代码,大多数时候我们想要的其实是“启动一个别的程序”。于是 fork 通常和 exec 配合使用:先 fork 一个子进程,再在子进程里调用 exec 族接口,把当前进程的代码段、数据段、堆、栈整体替换成一个新的程序。

Linux 的 exec 族有六个脸:execl、execv、execle、execve、execlp、execvp,区别主要体现在两个维度:第一个是命令行参数是逐个列举还是传一个字符指针数组;第二个是可执行文件路径是直接给绝对路径/相对路径,还是依赖 PATH 环境变量去搜索。其中 execve 是真正的系统调用,其他几个都是 libc 封装。一个典型的用法:

char *args[] = {"/bin/ls", "-l", NULL}; execv(args[0], args); /* 如果 exec 成功,这里永远不会执行到 */ perror("execv failed"); exit(1);

需要注意的是 exec 成功后不会返回,只有 exec 失败才会带着 -1 回到原来的调用点。这个特性经常被粗心的同学忽略,导致 exec 后面写了大量“原本不该执行”的代码。另一个容易被忽略的点:exec 会保留进程的 PID、文件描述符表的大部分内容以及进程的工作目录,所以子进程里打开的文件、继承的 socket 在 exec 之后依然存在。

3. 创建进程的核心细节与实操要点

3.1 父进程的命令行参数与子进程的“复制品”

fork 会复制的东西比你想象的多得多:不仅仅是代码段、数据段,还包括父进程的命令行参数、环境变量、文件描述符表、信号处理器、当前工作目录、umask 等。这意味着你在父进程里已经建好的数据库连接池、日志 fd、socket 监听口,在子进程里全是“复制的”状态。

这里有个特别容易踩的坑:如果你 fork 之前已经建立了 TCP 连接,子进程和父进程会共享同一个 socket 的内核对象。如果父子都往这个 socket 上写数据,可能出现两个进程交错写、数据混乱的问题。同理,日志 fd 也一样——如果你 fork 之后不关 fd 就直接各写各的,日志内容会乱。所以在真实的网络服务框架里,常见套路是:先创建好监听的 socket,然后 fork,子进程只负责 accept,父进程什么也不做,或者反过来。再或者,fork 之后谁不用的 fd 就要立刻 close 掉。

3.2 标准 I/O 缓冲区会“复制出双份”

这是 fork 初学者最容易撞上的经典事故。比如:

printf("before fork"); pid_t pid = fork();

结果你发现这一行 printf 在屏幕上出现了两次,或者出现在日志文件里两次。为什么?因为 printf 这类标准库函数默认是全缓冲或行缓冲,要满足缓冲区满或者遇到换行符/程序正常退出才会刷新。fork 复制地址空间的时候,把这个还没刷新的用户态缓冲区也复制了一份,于是父子进程各自携带一份“待输出内容”,程序退出时各自刷新,自然输出两遍。

规范的做法是 fork 前记得 fflush(stdout),或者干脆在 fork 之后的子进程里立刻重新 exec,让 exec 进程自己干净地开始。如果是写日志这类场景,更好的方案是直接使用 write 这类系统调用,它对内核来说没有用户态缓冲,不会在 fork 时被复制出两段残留内容。

3.3 fork 失败与 EAGAIN:资源往往不是你想的那么无限

很多人以为 fork 只要调用就能成功,实际生产环境中 fork 失败太常见了。最常见的错误返回值是 EAGAIN,它的字面意思是“资源暂时不可用”。触发场景一般有三类:进程数量达到系统限制,比如整个系统的线程/进程数达到 pid_max;或者当前用户的进程数超过 ulimit -u;又或者当前 cgroup 里配置了 pids.max,容器环境里尤其容易触发。

遇到 fork 失败又打印不出错误码的时候,别慌,按下面顺序查:

# 查看系统最大 PID 号 cat /proc/sys/kernel/pid_max # 查看当前用户可创建的进程/线程数 ulimit -u # 查看容器或 cgroup 限制 cat /sys/fs/cgroup/pids/pids.max cat /sys/fs/cgroup/pids/pids.current

如果你的服务莫名其妙地“启动不了新线程/新进程”,而系统负载又不高,优先怀疑是不是这些限制到了天花板。长期运行的服务还要注意“进程数泄漏”——父进程不停 fork 子进程但不回收,僵尸进程也是一个一个进程,同样会占 PID 和进程表条目。

4. 进程回收:wait 与 waitpid 的学问

4.1 僵尸进程的真相:内核为什么不直接清理干净

说回收之前,必须先搞清楚僵尸进程是什么。当一个子进程结束运行,无论是正常 exit 还是被信号杀死,内核都会保留它最基本的进程描述符(task_struct),里面存放着进程的 PID、退出码、消耗的 CPU 时间、资源使用统计等。这个状态的进程就叫做僵尸进程(zombie),在 ps 里会显示状态 Z,有时也会标 [defunct]。

内核这样做的原因很实际:父进程可能需要知道“孩子是怎么死的”——是正常退出,退出码是多少;还是被发信号杀死,是哪个信号。如果内核在子进程结束的瞬间就把所有信息抹掉,父进程就永远拿不到这些答案了。因此僵尸进程不是 bug,而是内核故意的设计,问题的关键只在于:父进程什么时候来取这些信息。父进程调用 wait / waitpid 收走信息之后,僵尸进程才会真正从进程表里消失。

僵尸进程本身不再占用内存,但会占用一个 PID,如果父进程长时间不回收,大量僵尸堆积会把 PID 空间耗尽,新进程创建不出来,服务就彻底瘫痪了。而如果父进程自己先退出,孤儿进程会被系统收养,由 init(PID 1)进程负责回收,这也是为什么普通终端里写的简单程序不会残留一大堆僵尸——你退出 shell 以后,init 帮你收了尸。

4.2 wait:最基础的回收接口,但粒度太粗

最简单粗暴的回收方式就是 wait:

#include <sys/wait.h> pid_t wait(int *status);

这个调用会阻塞父进程,直到任意一个子进程退出;如果没有子进程,wait 直接返回 -1 并设置 errno 为 ECHILD。status 是一个传出参数,里面打包了子进程的退出方式等信息。

wait 的缺点很明显:第一,它只能等“任意一个”子进程,不能指定等哪个;第二,它是阻塞式的,如果子进程很长时间不退出,父进程会被一直挂住;第三,如果父进程有多个子进程,你得循环调用 wait,而且不知道下一次回收到的到底是谁。所以工程上真正常用的是 waitpid。

4.3 waitpid:工程中的标准答案

waitpid 比 wait 灵活得多,也复杂得多:

#include <sys/wait.h> pid_t waitpid(pid_t pid, int *status, int options);

理解 pid 参数是第一步:

pid 取值含义
-1等待任意一个子进程,和 wait 等价
>0等待指定 PID 的子进程
0等待与当前进程同一个进程组的所有子进程
< -1等待指定进程组(绝对值)下的所有子进程

options 参数控制非阻塞等行为,最常用的是 WNOHANG,意思是“如果没有已退出的子进程,不要阻塞,立即返回 0”。这个值在配合信号处理或者事件循环时几乎是标配。还有一个很少用但面试偶尔会考的选项 WUNTRACED,表示也回收被暂停(stopped)的子进程;WCONTINUED 表示回收从暂停恢复继续运行的子进程。

拿到 status 之后,用一组宏来解析它:

if (WIFEXITED(status)) { int code = WEXITSTATUS(status); // 正常退出时拿到退出码 } if (WIFSIGNALED(status)) { int sig = WTERMSIG(status); // 被信号杀死时的信号编号 } if (WIFSTOPPED(status)) { int sig = WSTOPSIG(status); // 被暂停时的信号编号 }

我在工程里最常写的循环长这样,作用是把所有已退出子进程全部收干净:

int status; pid_t child_pid; while ((child_pid = waitpid(-1, &status, WNOHANG)) > 0) { if (WIFEXITED(status)) { LOG("child %d exited with code %d", child_pid, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { LOG("child %d killed by signal %d", child_pid, WTERMSIG(status)); } }

4.4 回收之后还要注意:wait 失败但 errno 不一定等于 ECHILD

用 waitpid 循环回收时,终止条件绝不能用“返回 0”,因为返回 0 只是“这次没有已退出的子进程”,不代表下次没有。如果你正在做信号驱动的回收,一定要用 WNOHANG 配合数量判断;如果你在普通流程里等所有子进程退出,那就应该用阻塞式 waitpid,循环到返回 -1 并且 errno == ECHILD,说明再没有子进程了,这时候才能安全退出。很多人图省事只 wait 一次,结果最后剩下几个子进程没人管,等父进程退出后它们才被 init 捡走,这在小脚本里看着没啥问题,但在长期运行的守护进程里就是资源泄漏。

5. 工程实践:SIGCHLD 与进程池的正确姿势

5.1 用 SIGCHLD 信号让内核来通知你收尸

当子进程退出时,内核会向父进程发送 SIGCHLD 信号。父进程可以注册一个信号处理函数,在这个处理函数里统一回收所有已退出的子进程。这是最常见的“异步回收”模式,比父进程轮询所有子进程状态要优雅得多。

先设置信号处理器:

#include <signal.h> #include <sys/wait.h> #include <unistd.h> static void sigchld_handler(int sig) { int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 记录日志、更新统计信息等 } } // 在某处注册 struct sigaction sa; sa.sa_handler = sigchld_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; // 尽量让被信号打断的系统调用自动重启 sigaction(SIGCHLD, &sa, NULL);

这里有一个非常重要的易错点:信号处理函数内部,绝对不能调用 printf、malloc、fopen 这类函数。原因很简单,信号随时可能打断主程序的任意一行代码,如果主程序正执行到 malloc 的半路,而信号处理函数里又调了一次 malloc,就可能在堆管理上重复进入非线程安全状态,导致崩溃或数据损坏。信号处理函数里能安全调用的函数必须来自 async-signal-safe 列表,直接 write 到 fd 是可行的,复杂逻辑应该抛给主循环去处理。

5.2 一批子进程同时崩溃时,SIGCHLD 可能会丢失一次

这是另一个非常隐蔽的问题。如果一批子进程几乎同时退出,Linux 会把多个 SIGCHLD 信号合并处理:同一类型的未处理信号只会排队一个,后面来的在 pending 队列里被合并。也就是说,如果你在信号处理函数里只 waitpid 一次,可能只收了一个子进程,剩下的就漏掉了。

所以标准的做法是在信号处理函数里使用 while 循环,不停地 waitpid(..., WNOHANG) 直到返回 -1 或 0:

while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 处理 pid 的退出 }

while 循环的目的就是“即便信号合并了,我也要把所有已经退出的子进程全部清空”。这个细节在面试里属于进阶题,在实际线上故障里则最容易被忽略——服务跑上一天后,ps 里慢慢又冒出一堆僵尸。

5.3 简单进程池:提前创建好,按需取用

“进程池”这个词在热词里出现频率很高,主要是因为它和“反复创建进程的性能开销”直接相关。频繁创建进程这件事,开销远不止一次 fork 系统调用:要复制页表、维护内核进程链表、分配 PID、做调度器初始化,哪怕有 COW 加持,也抵不住高频来往。更别提进程创建后还要 exec、初始化一大堆东西。

进程池的核心思路就是:提前一次性创建 N 个固定的 worker 进程,任务来了直接分给空闲 worker,而不是每个任务都临时 fork。它的好处有两个:一是省掉了反复 fork + exec 的启动开销;二是数量可控,不会因为突发事件导致进程数瞬间爆炸。

一个极简进程池框架可以这样设计:

  • 父进程启动时先 fork 出固定数量(比如 CPU 核数)的 worker;
  • worker 进程进入一个循环,等待任务队列里有任务;
  • 父进程通过管道或者队列把任务发给某个空闲 worker;
  • worker 完成一个任务后继续等下一个;
  • 父进程监控每个 worker 的健康状态,发现 worker 异常退出就立即重新 fork 一个替补。

伪代码大概长这样:

// 伪代码:创建 worker for (int i = 0; i < pool_size; i++) { pid_t pid = fork(); if (pid == 0) { worker_loop(i); // 不会返回 } workers[i] = pid; } // 伪代码:worker 主循环 void worker_loop(int idx) { while (1) { Task *task = receive_task(); // 从任务队列里取 run_task(task); } }

父进程负责补位时,最好和前面说的 SIGCHLD 回收机制配合:收到 SIGCHLD 时,除了回收退出的 worker,还要记录是第几个 worker 挂了,然后重新 fork 一个填充进去。如果手写维护 worker 的 PID 表,需要注意:不能直接用旧的 PID 去持有,因为 PID 可能被系统复用,补出来的新 worker 要重新登记。

6. 常见问题与排查技巧实录

6.1 一张表看懂常见坑

现象可能原因排查 / 解决方法
ps 里大量僵尸进程(状态 Z)父进程没有调用 wait/waitpid,或者信号处理中只回收了部分检查父进程回收逻辑;在 SIGCHLD 里用 while 循环回收,或设置 SA_NOCLDWAIT
fork 返回 -1,errno=EAGAINPID/进程数达到系统或用户限制ulimit -u、cat /proc/sys/kernel/pid_max、检查 cgroup pids.max;排查僵尸进程数量
fork 后 printf 内容输出两遍fork 复制了未刷新的用户态缓冲区fork 前 fflush(stdout),或子进程里改用 write 系统调用
父子进程同时写同一个 socket / 日志 fd,数据混乱fork 复制了 fd,共享同一内核文件对象按职责关闭不需要的 fd;或让一方持有、另一方关闭
子进程里改了变量,父进程的值也变了用了 vfork,地址空间共享改成 fork + exec;或确认自己的场景能否接受共享
wait 返回 -1,errno=ECHILD父进程已经没有可回收的子进程检查是否所有子进程都已被回收;是否设置过 SA_NOCLDWAIT
进程池 worker 挂了,服务能力下降父进程没有检测 worker 存活,或没补位结合 SIGCHLD 做 worker 补位;周期性健康检查

6.2 现场排查到底该看什么

遇到进程异常,我先教新人的一套固定排查路径:

  1. ps -ef --forest看进程树,一眼看出父子关系,尤其找状态列是 Z 的进程;
  2. top -d 1看整体负载和状态,Z 进程数量太多说明回收链路出问题了;
  3. 对具体进程,用cat /proc/<pid>/status查看 State 字段和 PPid,确认父进程是谁。State 里的 Z 表示僵尸,S 表示可中断睡眠,R 表示在运行;
  4. 如果是自己写的服务,直接strace -f -e trace=wait4,clone,fork,execve -p <pid>跟着系统调用看它到底有没有调用 wait;clone和fork在 strace 里对应的都是 clone 系统调用,父进程在这里创建子进程,wait4 就是 wait/waitpid 的底层实现;
  5. 实在看不出来,用pstree -p <pid>打印进程树,能快速定位子进程的归属。

排查工具不在多,核心逻辑是:先从进程状态和父子关系判断回收是否出了问题,再用 strace 确认系统调用行为。

6.3 我踩过的,以及你们可能也会踩的坑

第一个坑:多线程程序里 fork。如果主进程是多线程的,fork 出来的子进程只会包含调用 fork 的那个线程,其他线程全部消失。如果那个线程当时正握着某个锁,而锁在 fork 时不一定会被正确“传输”到子进程的锁状态里,子进程再去申请同一把锁,可能直接死锁。所以规范里都强调:多线程要 fork 之后马上 exec,或者不要在多线程环境里裸 fork。这个在面试里也经常被问到。

第二个坑:SIGCHLD 的设置时机。如果你在父进程已经 fork 出几个子进程之后才设置 signal handler,那之前退出的子进程产生的 SIGCHLD 可能已经被默认处理掉了,你永远收不到。所以要在 fork 之前就把 SIGCHLD handler 设置好。我有一次调了半天查不出僵尸进程为什么出现,最后发现是“先 fork 后 signal”,顺序一换问题就消失了。

第三个坑:SA_NOCLDWAIT 和显式 waitpid 不能混着来。如果信号处理里设置了 SA_NOCLDWAIT,那子进程退出后内核直接不保留僵尸状态,你的 waitpid 永远收不到子进程,因为 waitpid 会返回 -1 且 ECHILD。这在某些框架里是个“隐式禁则”,一不小心就会踩。如果你需要子进程退出状态信息,就不要用 SA_NOCLDWAIT;如果你根本不在乎退出状态,用 SA_NOCLDWAIT 反而是最省事的办法。

7. 一点个人经验

进程的创建与回收,说到底是两件事:怎么开个头,怎么收个尾。Linux 把“开头”设计得极其简单,一个 fork 就能复制出几乎一切;又把“收尾”设计得极其讲究,宁可保留一个僵尸进程也要让父进程有机会知道孩子怎么离开的。我在实际项目中见过太多只关注“怎么 fork”却不管“怎么 wait”的代码,最后线上僵尸堆积、服务失联的例子。如果你能把 SIGCHLD 回收写成 while 循环,把 fork 之前应该 flush 的缓冲区 flush 掉,把进程池的 worker 补位考虑进去,那么这块基本功就算真正落地了。最后再分享一个小技巧:遇到进程相关故障,先跑 ps -o pid,ppid,state,cmd,再辅以 strace -f 看 wait4 和 clone,八成问题都能在这两个命令里找到答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 5:46:26

在集群上独立运行 Alluxio:单 Master 部署的完整实战指南

存储分布式文件系统缓存大数据 【免费下载链接】alluxio Alluxio, data orchestration for analytics and machine learning in the cloud 项目地址&#xff1a; https://gitcode.com/gh_mirrors/al/alluxio 点击查看 免费下载 本文基于 Alluxio 官方中文文档 docs/cn/deploy/…

作者头像 李华
网站建设 2026/9/29 5:45:51

superpowers:让Codex CLI从会写代码到会做工程的技能库

从第一次在终端里敲下codex那条命令开始&#xff0c;我一直觉得这类 AI 编程助手有种"聪明但不太会用"的感觉&#xff1a;你问它一句&#xff0c;它能答得像模像样&#xff1b;但真让它独立把一个功能从规划到落地做完&#xff0c;它经常会走一步看一步&#xff0c;甚…

作者头像 李华
网站建设 2026/9/29 5:43:28

解决 Django 与 Jinja2 的兼容性问题

在 Django 项目中&#xff0c;尝试整合 Jinja2 作为模板引擎时遇到了兼容性问题。settings.py 文件中已经正确配置了 Jinja2 并保留了 Django 默认的模板设置&#xff0c;但系统报错提示未指定模板。如果移除 Django 的默认模板配置&#xff0c;错误信息变为未配置 Django 模板…

作者头像 李华