第 7 章拆解 — 线程、进程与信号:zcron 的组装之路
线程、进程与信号:zcron 的组装之路
这一章讲什么: 单个进程之外的世界。信号怎么被「故意」用起来(不杀进程,而是查它的状态); fork 一次调用两次返回;exec 把子进程的内存整个换成另一个程序;线程是进程内共享内存的轻量执行路径。 收尾项目 zcron——一个迷你 cron——把四块积木全部用上,组装成一个能脱离终端、能热重载、不攒僵尸的常驻服务。
主线例子:zcron 的一次 tick。书上给了一组带真实时间戳的推演:now = 1741812000 时, 三个作业各走哪条路、谁被执行、谁被跳过,数字全程可对账。
1. 顶层全景:zcron 的一张架构图
信号层 SIGTERM ─→ g_running=false(优雅停机)
SIGHUP ─→ g_reload=true(热重载) ← handler 只做原子写,别的一律不干
SIGCHLD ─→ while waitpid(WNOHANG)(收尸循环)
循环层 poll(stdin, 1000ms) ← 单线程心跳:stdin 可读 → REPL;超时 → tick()
tick(): 扫一遍作业,isDue(now)? → spawnJob()
spawnJob(): fork() → 父进程立即返回,子进程 execve("/bin/sh -c 命令")
exec 失败 → _exit(127)(不冲父进程继承来的缓冲区)
结构层 schedule.zig(纯数据,isDue 逻辑,6 个单测) config.zig(配置文件→Schedule)
repl.zig(六条命令) scheduler.zig(引擎) signals.zig(三个 handler) main.zig(纯装配)
图说:每个文件只拥有一件事;schedule.zig 无 I/O 无系统调用,所有单测完全隔离地跑。
书的设计宣言:zcron 的架构偏向简单和清晰,选择「演示健壮系统编程」的模式,而不是性能。[^1]2. 核心原理
2.1 信号当作查询接口:原子标志模式
信号最常见的出场是被杀掉(SIGINT/SIGTERM),这一章先演示「故意用信号」:给进程注册 SIGUSR1/SIGUSR2 两个用户自定义信号的 handler——收到 SIGUSR1 就刷新一个随机数,收到 SIGUSR2 就报告运行时长,不重启、不重编译、不看日志,外部随时问进程内部状态1。
关键约束在 handler 里能干什么:信号 handler 可以在任意两条指令之间插入执行,POSIX 严格限制 handler 内可调用的函数——禁止 malloc、printf、绝大多数锁。所以 handler 只做一个原子写(把布尔标志置真),真正的活留给主循环:主循环每 2 秒醒一次,用 load(.seq_cst) 查标志,置真就干活、清零。共享变量一律 std.atomic.Value,handler 里 .monotonic 写、主循环里 .seq_cst 读——并发信号递送绝不产生撕裂读写2。实测:跑起来打印 PID 7206,另一个终端 kill -USR1 7206,这边输出 [SIGUSR1] Current random value: -18200547013。
2.2 fork:一次调用,两次返回
进程是独立的执行环境(自己的内存空间);线程是进程内共享内存的轻量执行单元。fork(2) 造子进程的方式是复制自己:调用一次、返回两次——父进程里返回子进程的 PID,子进程里返回 0,负数是失败4。
线程与进程的取舍,书给的经验法则值得背:要紧密共享数据、性能优先——用线程;要隔离、容错、跑不同可执行文件——用进程。进程隔离的代价是创建贵(fork 要复制页表和文件描述符表),回报是一个进程崩了不会拖死另一个;线程创建便宜(只要新栈和线程控制块),但任何一个线程空指针解引用会带崩整个进程。通信同构:线程直接共享变量(配互斥锁/原子),进程必须走 IPC——管道、套接字、信号5。
fork 之后的义务:waitpid(2) 不是可选的。不收割,内核就在进程表里留着子进程的壳——僵尸进程,ps 里显示 <defunct>;反复 fork 不收尸的长跑服务最终会耗尽进程表6。答案是 SIGCHLD handler(第 2.6 节)。
2.3 线程:spawn 与 join 的生命周期
std.Thread.spawn(.{}, func, .{args}) 三参数:配置(默认)、函数、参数元组;创建可能因系统资源限制失败,所以带 try。join() 是同步点:主线程等它等到完——没有 join,main 可能先走到头、整个进程退出,后台线程连打印的机会都没有7。
非确定性的现场教学:跑两次 nThreads -- 4,输出一次是 0 1 3 2,一次是 0 3 1 2——代码里 spawn 的顺序是确定的,但 OS 调度器把哪个线程放上 CPU 的顺序不归你管;唯一保证的是最后那行(所有 join 都返回之后才打印)永远在最后8。
std.process.run() 是第四种「造执行流」:它 spawn 子进程、等它结束、把 stdout/stderr 抓成堆上字符串,一次调用全包;返回的 term 是个联合体(.exited/.signal/.stopped/.unknown),UNIX 惯例 0 是成功、非零是错误——书上用 ls ./doesNotExist(必失败)和 uptime(必成功)对照演示两条路径9。
2.4 并发 TCP:服务器一连接一线程,客户端压测
上一章的服务器是同步的——一个连接没伺候完,新的只能排队。并发版的两处改动:accept 循环里每接受一个连接,Thread.spawn(.{ .allocator = allocator }, handleConnection, .{io, stream, thread_num}),然后 detach()——「发射后不管」:主循环不等任何工作线程,保持对新连接的响应;连接计数用全局原子计数器 fetchAdd,比互斥锁便宜。spawn 失败也有兜底:手动 close 那个客户端流再 continue——一个客户端的局部故障不许拖垮整个服务器10。
配套的并发客户端是一次现成的压测:concClient 127.0.0.1 1234 10 起 10 个线程各连一次、各发 ping from client #N、各收回声。实测输出 10 行,顺序乱(3、0、4、7、9、8、5、1、2、6),但每条消息都回到了正确的发送者手里——服务器端每连接一个独立线程 + 原子计数分号,会话互不串味11。
2.5 单线程事件循环:为什么 zcron 不用线程
设计决策一:留在前台,用 poll() 当心跳。一个朴素的双线程替代方案是「一线程睡觉发作业,一线程阻塞在 stdin.readLine()」——但两个线程立刻引入共享可变状态,每次 tick、每个列表操作、每次重载都要互斥锁保护。poll(stdin_fd, 1000ms) 把两件事合成一件:进程完全休眠,直到「stdin 来了字符」或「一秒到了」二选一,然后单线程顺序处理——同步问题整个消失。书点明这是系统编程的基石模式,Redis 和 Nginx 的事件循环就是这个形状12。
2.6 信号处理三件套:原子标志、SIGCHLD 循环、SA_RESTART
zcron 的信号层管三个信号,每个都对应一个真实事故:
- SIGTERM → 优雅停机;SIGHUP → 热重载——把 SIGHUP 分给重载是深根的 UNIX 惯例(当年表示调制解调器掉线,如今是「重读配置」的标准信号,
kill -HUP是运维的肌肉记忆)13。 - SIGCHLD → 防僵尸的收尸循环。子进程结束时内核把退出状态留在进程表里等父进程认领;handler 用
while+WNOHANG(不阻塞)把所有已结束的子进程一次收干净。循环不是装饰:POSIX 不排队信号——两个孩子同时退出,可能只递送一个 SIGCHLD,只收一次就漏一个僵尸14。 - 一个 0.16 的坑值得单独记:循环里直接调
std.c.waitpid()而不是高级封装std.posix.waitpid()——0.16 的封装把ECHILD(没有更多子进程,正是循环的终止条件)标成unreachable,在它看来「没有子进程还 wait」是逻辑错误;原始 C 函数返回 -1,可以干净地查15。 - 装 handler 时带
SA_RESTART:让被信号打断的系统调用(比如那个 poll)自动重启,保持一秒心跳不断16。
self-pipe 技巧也在这里交代了为什么不用:事件循环同时等信号和 I/O 的经典解法是在 handler 里往管道写一个字节、让 poll 因管道可读而醒来——把异步信号变成同步 I/O 事件。原子标志在 zcron 的场景已经够用,省掉一根管道的管理开销17。
2.7 fork+exec:任务的完整发射序列
调度器执行作业的两段式:
spawnJob(job):
pid = fork()
pid < 0 → 记录错误,返回 ← 父路径失败
pid != 0 → 父进程立即返回 ← 调度不等待,继续 tick
pid == 0 → execCommand(command); _exit(127)
↑
execCommand: argv = {"/bin/sh", "-c", command}
execve(SHELL, argv, CHILD_ENV) ← 成功就永不返回图说:execve 不是「启动新程序」,而是把当前子进程的整个内存映像换成新可执行文件——所以成功绝不返回;返回到 Zig 代码只可能是失败(比如 /bin/sh 不存在),这时 _exit(127) 兜底。必须用裸的 _exit 而不是高级函数:它不等标准 I/O 缓冲区 flush——那些缓冲区是从父进程 fork 来的副本,一冲就把父进程已经输出过的内容再打一遍,日志里出现重复行18。
调度不等待是关键的非阻塞架构:一个跑 10 分钟的备份脚本不能让调度器失去时间的跟踪——父进程派完作业立刻回到自己的计时循环19。走 /bin/sh -c 是为了让用户在配置里直接写重定向、管道、shell 内建命令,zcron 自己永远不用懂引号和转义20。
2.8 脱离终端:双 fork 的守护进程序列
detach() 八步,前四步是「为什么要两次 fork」的全部答案21:
第 1 次 fork:父进程立刻 exit → shell 拿回了提示符,子进程成孤儿
setsid():孤儿成为新会话、新进程组的组长 —— 与原来的控制终端永久脱钩
第 2 次 fork:会话组长再 exit → 存活的孙进程「绝不可能是会话组长」
(System V 系系统上,会话组长只要打开终端设备就可能重新获得控制终端;
不是组长,永远没这个风险 —— 这是生产级守护进程与幼稚实现的分水岭)
chdir("/"):别占着启动时所在的那个文件系统,管理员才能卸载它
dup2 ×3:stdin/stdout/stderr 全部重定向到 /dev/null
(终端已经不存在了,残留的 print 不许往一个死终端上写)图说:完成之后,进程没有终端、对 SIGHUP 这类会话信号免疫、生命周期与启动它的用户完全无关22。书也坦白省了两步硬化:umask(0) 和把 PID 写进 /var/run——真实部署前该补23。实测:ps ax 里那个进程的状态是 Ss(会话组长、睡眠中),kill -SIGTERM <pid> 是对付它的正规方式24。
2.9 主线例子:tick 的一次对账
书上给了一段 live 推演,数字全部可用25:zcron 醒来,unixNow() = 1741812000。扫描开始:
作业 0 backup: interval=60s, last_run=1741811940
1741812000 − 1741811940 = 60 → 满足阈值
→ 打日志、spawnJob(后台拉起)、last_run 更新为 1741812000
作业 1 清临时文件: interval=300s, last_run=1741811800
1741812000 − 1741811800 = 200 < 300 → 不够
→ continue,一个系统调用都不发,看下一个图说:isDue() 是全程序唯一的调度逻辑(now >= last_run + interval 的布尔比较),因为它住在无 I/O 的 schedule.zig,配 6 个完全隔离的单测。整个 zcron 的调度判断就这行减法26。
实测的 REPL 交互也值得看一眼:list 里三个作业恰好都显示 in 3s(5/10/20 秒的最小公倍数对齐的刹那);run 1 强制立即执行作业 1,last_run 当场更新,下次自然触发从那一刻起算 10 秒27。日志前 10 行里 job 0 fired 每 5 秒一行、date 每 10 秒、uptime 每 20 秒穿插——节拍全对28。
3. 加厚:zwhich 找零的 REPL 与配置链
两个容易略过的完整度细节。其一,REPL 的 quit 走 signals.requestStop()——和真实 SIGTERM 同一条原子路径置 g_running = false,不管用户敲 quit 还是外面 kill,停机语义完全一致29。其二,配置文件的查找链:命令行参数优先,其次 ~/.config/zcron/zcron.conf(XDG 规范——freedesktop 定的「用户配置属于 ~/.config/<应用名>/」),最后退回当前目录的 ./zcron.conf;main.zig 里唯一非平凡的逻辑就是用 $HOME 拼这个路径,其余全是委托30。
还有一条运转纪律值得记下:跑 zcron 时把 stderr 重定向到日志文件(zig build run 2>/tmp/zcron.log)。std.log.scoped(.scheduler) 的 info(scheduler): 前缀写往 stderr,不重定向就会和 REPL 提示符在终端上互相搅行——UNIX 服务把 stderr 接进日志而不是留在终端上,这就是具体原因之一31。
4. 作者的判断与证据
- 判断(全章落点): zcron 不是章末的新想法,是同一批想法的组合——「系统编程不是一堆不相干的原语,而是一套懂了每件就能预测地组合的工具」;调度器里每个设计决策都能在前面某个小程序里找到出处32。证据:从
sigInt.zig(一个 handler 置标志)到 zcron(几百行、六个文件)的整条链,每步都有可跑的代码。 - 判断: 单线程事件循环优于双线程分工,因为共享可变状态与互斥锁的代价高于 poll 的复杂度12。证据:Redis/Nginx 的同构先例(引证,非实测)。
- 判断: 固定栈数组(
MAX_JOBS = 128)胜过动态 ArrayList——执行路径零堆分配、无碎片、关键时刻不可能分配失败;代价是 128 个作业的上限,书明说这是为个人服务设计的取舍33。 - 坦白的缺口: 生产硬化缺
umask(0)和 PID 文件;stderr 该重定向到日志文件(不然info(scheduler):前缀会跟 REPL 提示符在终端上搅在一起)2328。
4. 边界与局限
- fork/setsid/exec 全套是 POSIX 专属,Windows 没有 fork;
builtin.os.tag的守卫在不适用的平台上会直接拒建。 - zcron 的配置上限 128 个作业、命令存
[MAX_CMD + 1]u8定长缓冲——超长命令处理是隐式截断33。 - 作业按「单进程隔离」设计,但没有超时杀任务的机制;一个挂死的作业不会阻塞调度器,但会在系统里留一个孤儿进程。
- 并发 TCP 服务器一线程一连接,没有线程池——成百上千连接时的内存与调度开销没有讨论(第 8、9 章才处理)。
- detach 示例没有处理 fork 之后、dup2 之前的失败路径清理。
5. 可带走的
- 信号 handler 只许做原子写——malloc/printf/锁全禁;真正的处理放主循环。
kill -USR1/-USR2是把信号当查询接口的现成模式:不重启就问出进程内部状态。- fork 一次调用两次返回:父得 PID、子得 0;fork 之后父进程的 wait 义务逃不掉。
- 不 wait 的后果有名字:僵尸进程;反复 fork 的服务必须配 SIGCHLD +
WNOHANG循环收尸。 - 那个循环必须是 while:POSIX 不排队信号,两个孩子同时退出可能只响一声。
- 0.16 坑:
std.posix.waitpid()把 ECHILD 当 unreachable;收尸循环用std.c.waitpid()原始版。 - exec 成功永不返回;exec 失败用
_exit(127)兜底,且必须用裸_exit防继承的缓冲区二次 flush。 - 双 fork + setsid:第一脱离终端、第二永不当会话组长(杜绝重获控制终端),然后 chdir("/")、dup2 三连。
poll(fd, 1000ms)单线程心跳:stdin 与定时器两个事件源一次等,同步问题整个消失。detach()+SA_RESTART+ 原子标志 + fork/exec + 收尸循环——这五件套就是一切「常驻小服务」的骨架。
6. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 信号作查询接口 | Using signals intentionally | text/80-fm-using-signals-intentionally.txt:5(搜「uptime」) |
| handler 的原子限制 | Using signals intentionally | text/80-fm-using-signals-intentionally.txt:72(搜「any point between instructions」) |
| 线程 vs 进程取舍 | Using signals intentionally | text/80-fm-using-signals-intentionally.txt:300(搜「consequential decisions」) |
| fork 两次返回 | Forking processes | text/81-fm-forking-processes.txt:37(搜「returns twice」) |
| 僵尸与进程表耗尽 | Forking processes | text/81-fm-forking-processes.txt:121(搜「zombie」) |
| join 是同步点 | Forking processes | text/81-fm-forking-processes.txt:266(搜「synchronization point」) |
| 非确定性双跑 | Running a variable number of threads | text/82-fm-running-a-variable-number-of-threads.txt:143(搜「0 1 3 2」) |
| run() 与 term 联合体 | Running a variable number of threads | text/82-fm-running-a-variable-number-of-threads.txt:185(搜「spawning, waiting」) |
| 一连接一线程+detach | Running a variable number of threads | text/82-fm-running-a-variable-number-of-threads.txt:567(搜「fire and forget」) |
| 10 并发客户端实录 | Writing a concurrent TCP client | text/83-fm-writing-a-concurrent-tcp-client.txt:257(搜「Client 3 received」) |
| 六文件各自的所有权 | Writing a concurrent TCP client | text/83-fm-writing-a-concurrent-tcp-client.txt:328(搜「schedule.zig」) |
| poll 单线程事件循环 | Explaining the design decisions | text/84-fm-explaining-the-design-decisions.txt:41(搜「poll(stdin_fd」) |
| self-pipe 技巧 | Explaining the design decisions | text/84-fm-explaining-the-design-decisions.txt:135(搜「asynchronous signal」) |
| SIGHUP 重载惯例 | Explaining the design decisions | text/84-fm-explaining-the-design-decisions.txt:173(搜「modem line」) |
| 双 fork 的意义 | Detaching from the terminal (fork, setsid) | text/85-fm-detaching-from-the-terminal-fork-setsid.txt:144(搜「O_NOCTTY」) |
| dup2 三连到 /dev/null | Detaching from the terminal (fork, setsid) | text/85-fm-detaching-from-the-terminal-fork-setsid.txt:91(搜「dev_null」) |
| 省掉的硬化步骤 | Detaching from the terminal (fork, setsid) | text/85-fm-detaching-from-the-terminal-fork-setsid.txt:178(搜「umask」) |
| exec 不返回与 _exit(127) | Using fork and exec to run periodic tasks | text/86-fm-using-fork-and-exec-to-run-periodic-tasks.txt:135(搜「never returns」) |
| POSIX 不排队信号 | Handling SIGTERM and SIGCHLD | text/87-fm-handling-sigterm-and-sigchld.txt:69(搜「does not queue」) |
| SA_RESTART 保心跳 | Handling SIGTERM and SIGCHLD | text/87-fm-handling-sigterm-and-sigchld.txt:77(搜「SA_RESTART」) |
| ECHILD 当 unreachable | The zcron implementation details | text/88-fm-the-zcron-implementation-details.txt:175(搜「ECHILD」) |
| tick 时间戳推演 | The zcron implementation details | text/88-fm-the-zcron-implementation-details.txt:419(搜「1741812000」) |
| 全章落点:组合 | The zcron implementation details | text/88-fm-the-zcron-implementation-details.txt:860(搜「composable tools」) |
| REPL list/run 实录 | The zcron implementation details | text/88-fm-the-zcron-implementation-details.txt:696(搜「in 3s」) |
| 日志节拍验证 | The zcron implementation details | text/88-fm-the-zcron-implementation-details.txt:799(搜「job 0 fired」) |
本章拆解依据原书第 7 章(含各小节)。每条出处可用 node scripts/book-verify.mjs systems-programming-with-zig 回核。
Footnotes
-
出处:「Using signals intentionally」第 5 段(
text/80-fm-using-signals-intentionally.txt:5,搜「uptime」)与第 9 段(text/80-fm-using-signals-intentionally.txt:9,搜「without being restarted」)。 ↩ -
出处:「Using signals intentionally」第 72 段(
text/80-fm-using-signals-intentionally.txt:72,搜「any point between instructions」)。 ↩ -
出处:「Using signals intentionally」第 239 段(
text/80-fm-using-signals-intentionally.txt:239,搜「1820054701」)。 ↩ -
出处:「Forking processes」第 37 段(
text/81-fm-forking-processes.txt:37,搜「returns twice」)。 ↩ -
出处:「Using signals intentionally」第 300 段(
text/80-fm-using-signals-intentionally.txt:300,搜「consequential decisions」)与第 306 段(text/80-fm-using-signals-intentionally.txt:306,搜「rule of thumb」)。 ↩ -
出处:「Forking processes」第 121 段(
text/81-fm-forking-processes.txt:121,搜「zombie」)。 ↩ -
出处:「Forking processes」第 266 段(
text/81-fm-forking-processes.txt:266,搜「synchronization point」)。 ↩ -
出处:「Running a variable number of threads」第 143 段(
text/82-fm-running-a-variable-number-of-threads.txt:143,搜「0 1 3 2」)。 ↩ -
出处:「Running a variable number of threads」第 185 段(
text/82-fm-running-a-variable-number-of-threads.txt:185,搜「spawning, waiting」)。 ↩ -
出处:「Running a variable number of threads」第 567 段(
text/82-fm-running-a-variable-number-of-threads.txt:567,搜「fire and forget」)。 ↩ -
出处:「Writing a concurrent TCP client」第 257 段(
text/83-fm-writing-a-concurrent-tcp-client.txt:257,搜「Client 3 received」)。 ↩ -
出处:「Explaining the design decisions」第 41 段(
text/84-fm-explaining-the-design-decisions.txt:41,搜「poll(stdin_fd」)与第 49 段(text/84-fm-explaining-the-design-decisions.txt:49,搜「Redis」)。 ↩ ↩2 -
出处:「Explaining the design decisions」第 173 段(
text/84-fm-explaining-the-design-decisions.txt:173,搜「modem line」)。 ↩ -
出处:「Handling SIGTERM and SIGCHLD」第 69 段(
text/87-fm-handling-sigterm-and-sigchld.txt:69,搜「does not queue」)。 ↩ -
出处:「The zcron implementation details」第 175 段(
text/88-fm-the-zcron-implementation-details.txt:175,搜「ECHILD」)。 ↩ -
出处:「Handling SIGTERM and SIGCHLD」第 77 段(
text/87-fm-handling-sigterm-and-sigchld.txt:77,搜「SA_RESTART」)。 ↩ -
出处:「Explaining the design decisions」第 135 段(
text/84-fm-explaining-the-design-decisions.txt:135,搜「asynchronous signal」)。 ↩ -
出处:「Using fork and exec to run periodic tasks」第 135 段(
text/86-fm-using-fork-and-exec-to-run-periodic-tasks.txt:135,搜「never returns」)与第 149 段(text/86-fm-using-fork-and-exec-to-run-periodic-tasks.txt:149,搜「without flushing」)。 ↩ -
出处:「Using fork and exec to run periodic tasks」第 35 段(
text/86-fm-using-fork-and-exec-to-run-periodic-tasks.txt:35,搜「non-blocking」)。 ↩ -
出处:「Explaining the design decisions」第 55 段(
text/84-fm-explaining-the-design-decisions.txt:55,搜「/bin/sh」)。 ↩ -
出处:「Detaching from the terminal (fork, setsid)」第 144 段(
text/85-fm-detaching-from-the-terminal-fork-setsid.txt:144,搜「O_NOCTTY」)。 ↩ -
出处:「Detaching from the terminal (fork, setsid)」第 170 段(
text/85-fm-detaching-from-the-terminal-fork-setsid.txt:170,搜「no terminal」)。 ↩ -
出处:「Detaching from the terminal (fork, setsid)」第 178 段(
text/85-fm-detaching-from-the-terminal-fork-setsid.txt:178,搜「umask」)。 ↩ ↩2 -
出处:「Detaching from the terminal (fork, setsid)」第 196 段(
text/85-fm-detaching-from-the-terminal-fork-setsid.txt:196,搜「Ss」)。 ↩ -
出处:「The zcron implementation details」第 419 段(
text/88-fm-the-zcron-implementation-details.txt:419,搜「1741812000」)。 ↩ -
出处:「The zcron implementation details」第 39 段(
text/88-fm-the-zcron-implementation-details.txt:39,搜「isDue」)。 ↩ -
出处:「The zcron implementation details」第 696 段(
text/88-fm-the-zcron-implementation-details.txt:696,搜「in 3s」)。 ↩ -
出处:「The zcron implementation details」第 799 段(
text/88-fm-the-zcron-implementation-details.txt:799,搜「job 0 fired」)。 ↩ ↩2 -
出处:「The zcron implementation details」第 295 段(
text/88-fm-the-zcron-implementation-details.txt:295,搜「same atomic path」)。 ↩ -
出处:「The zcron implementation details」第 571 段(
text/88-fm-the-zcron-implementation-details.txt:571,搜「command-line argument」)与第 609 段(text/88-fm-the-zcron-implementation-details.txt:609,搜「XDG」)。 ↩ -
出处:「The zcron implementation details」第 667 段(
text/88-fm-the-zcron-implementation-details.txt:667,搜「interleave」)。 ↩ -
出处:「The zcron implementation details」第 860 段(
text/88-fm-the-zcron-implementation-details.txt:860,搜「composable tools」)。 ↩ -
出处:「Explaining the design decisions」第 75 段(
text/84-fm-explaining-the-design-decisions.txt:75,搜「MAX_JOBS」)。 ↩ ↩2