普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月26日暗无天日

git推送失败后恢复仓库损坏的完整记录

2026年4月26日 08:00
* 症状 电脑断电后,在仓库提交代码发现推送时失败: #+BEGIN_EXAMPLE $ git push -u origin main 致命错误:bad tree object 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6 remote: fatal: early EOF 错误:远程解包失败:index-pack failed To git.zhlh6.cn:lujun9972/CodeQuest.git ! [remote rejected] main -> main (failed) 错误:无法推送一些引用到 'git.zhlh6.cn:lujun9972/CodeQuest.git' #+END_EXAMPLE 满屏的 "bad tree object"、"early EOF"、"remote rejected" 让人头皮发麻。之前就发生过仓库索引损坏的问题,这次又来了。 * 分析原因 先用 =git fsck--full= 检查仓库完整性: #+BEGIN_EXAMPLE $ git fsck --full 损坏的链接来自于 tree d0c6a58cd6ccac7e967a1ec1923b86b6098c37cf 到 tree 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6 损坏的链接来自于 tree 8043d4e27bf8f8f91f30f9519651dd0dfcc2b1af 到 blob d7f34b8f17b66827d315980d4cfcc0b17530a327 缺失 tree 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6 缺失 blob d7f34b8f17b66827d315980d4cfcc0b17530a327 #+END_EXAMPLE 可以看到有两个对象找不到了(缺失 missing): - tree 对象 =4de66b3=:代表一个目录 - blob 对象 =d7f34b8=:代表一个文件的内容 而另外两个 tree 则引用了这些丢失的对象(损坏的链接 broken link)。 虽然被引用的对象丢了,但引用它们的 tree 还在。用 =git cat-file -p= 读一下,就能知道缺失的对象本来对应哪个文件: #+BEGIN_EXAMPLE $ git cat-file -p 8043d4e 100644 blob d7f34b8f17b66827d315980d4cfcc0b17530a327 2026-04-26-achievement-wall-design.md #+END_EXAMPLE → tree =8043d4e= 说:"我引用了一个文件,叫 =2026-04-26-achievement-wall-design.md=,它的内容哈希是 =d7f34b8="。 #+BEGIN_EXAMPLE $ git cat-file -p d0c6a58 040000 tree 49ae87423ab81f7889dab9e24af935a5f0bfc5af plans 040000 tree 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6 specs #+END_EXAMPLE → tree =d0c6a58= 说:"我有一个 =plans= 子目录(哈希 =49ae874=,还能读)和一个 =specs= 子目录(哈希 =4de66b3=,丢了)"。 结合两条信息:缺失的 blob =d7f34b8= 对应文件 =2026-04-26-achievement-wall-design.md=;缺失的 tree =4de66b3= 是 specs 目录,里面应该只有这一个文件。 这就是 git 内容寻址的妙处:对象之间通过哈希互相引用,只要引用关系的某一段还在,就能顺着找出缺失的是什么。 怎么理解 git 的这些对象呢? : git 把仓库当作一个"内容寻址文件系统"。每次提交都对应一个 tree(目录树), : tree 里可以包含 subtree(子目录)或 blob(文件)。 : 如果某个 tree 引用的子树或文件找不到了,就出现上面的"损坏的链接"。 这个损坏发生在很久之前的一次 "fix: restore repository after index corruption" 提交中。当时为了恢复仓库用了某些激进操作,把一些对象搞丢了,但当时没发现。直到现在推送时,远程服务器验证完整性才发现问题。 * 尝试修复的过程(走弯路) ** 尝试一:直接 git gc 最简单直接的想法——让 git 自己清理垃圾: #+BEGIN_EXAMPLE $ git gc --prune=now 致命错误:bad tree object 4de66b3d0bdfa48a8f6fd29e140a9fafd3c9d3b6 致命错误:failed to run repack #+END_EXAMPLE 失败了。因为包文件(pack)里含有损坏对象的引用,gc 无法重新打包。 这里需要简单说明一下 git 的存储机制。git 把对象存在 =.git/objects/= 目录里,有两种形态: - **松散对象(loose object)**:刚产生的对象存成单个文件,比如 =.git/objects/d0/c6a58...= - **包文件(pack file)**:积累到一定程度,git 会把一堆松散对象打包成一个 =.pack= 文件 + 一个 =.idx= 索引,既省空间又加快访问速度 =git gc= 干的事就是"把所有松散对象重新打包"。但如果包文件里有一个条目引用了不存在的对象,git 就没法完成解包→重打包的过程,导致 =gc= 失败。这里就是这种情况。 ** 尝试二:本地克隆一份 绕过损坏对象的一个经典方法是重新克隆仓库。但这里没有远程仓库可用(远程就是坏的),所以试试本地克隆: #+BEGIN_EXAMPLE $ git clone --local . /tmp/codequest-clean 致命错误:无法创建链接 '...pack': 无效的跨设备链接 #+END_EXAMPLE 加上 =--no-hardlinks= 参数后克隆成功了: #+BEGIN_EXAMPLE $ git clone --no-hardlinks --local . /tmp/codequest-clean 完成。 #+END_EXAMPLE 但遗憾的是检查发现损坏的对象也被克隆过来了。因为那些提交历史里仍然引用着损坏的对象。治标不治本。 ** 尝试三:git filter-branch 重写历史 既然损坏的对象在历史提交中,那就用 =git filter-branch= 重写历史,把引用损坏对象的目录从历史中移除: #+BEGIN_EXAMPLE $ FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --index-filter ' git rm --cached -r -q docs/superpowers 2>/dev/null || true if [ -d docs/superpowers ]; then git add docs/superpowers 2>/dev/null || true fi ' 0c12047..HEAD #+END_EXAMPLE 先拆解一下这条命令在干什么: : FILTER_BRANCH_SQUELCH_WARNING=1 ← 静默模式,让 git 别打印吓人的警告 : git filter-branch -f ← 强制重写历史。-f 是 --force,覆盖上一次运行的备份 : --index-filter '...' ← 核心。对每个提交,直接在暂存区(index)执行脚本, : 而不是把文件检出到磁盘再操作。速度快但只能用 git 命令 : 0c12047..HEAD ← 重写范围,从 0c12047 之后到 HEAD 的所有提交 脚本部分: : git rm --cached -r -q docs/superpowers 2>/dev/null || true : --cached: 只删除暂存区,不碰磁盘文件 : -r: 递归删除整个目录 : -q: 安静模式 : 2>/dev/null: 把错误信息丢掉(如果这个提交里本来就没有那个目录) : || true: 保证即使删除失败脚本也不退出 : : if [ -d docs/superpowers ]; then : git add docs/superpowers 2>/dev/null || true : fi : 先检查 docs/superpowers 目录是否存在,如果存在就重新加到暂存区 然而这个脚本实际有 bug:=--index-filter= 模式下,git 不会把文件内容检出到磁盘,所以工作目录是空的。=if [ -d docs/superpowers ]= 永远为假,后面的 =git add= 永远不会执行。结果只删不增,每个提交里的 docs/superpowers 目录都被删掉了。 所以结果是 "Ref 'refs/heads/main' was rewritten"。看起来成功了,但 =git fsck= 仍然报错。 原因有两个: 1. =filter-branch= 的备份引用(=refs/original/=)仍然指向旧提交,这些旧对象还在仓库中 2. reflog 里也保留着旧提交的记录 reflog 是 git 的"操作日志",记录了 HEAD 和各个分支曾经指向过的每一次提交。你可以把它理解成浏览器的历史记录——即使你书签(分支)指向了新页面,历史记录里还存着旧地址。垃圾回收时,git 会检查 reflog,只要 reflog 里还有记录,旧对象就不会被清理。 删除 =refs/original/= 和清空 reflog 后: #+BEGIN_EXAMPLE $ rm -rf .git/refs/original/ $ git reflog expire --expire=now --all $ git gc --prune=now 致命错误:bad tree object 8043d4e... #+END_EXAMPLE 各种尝试都卡在 =git gc= 这一步,死循环了。 ** 弯路总结 走弯路的教训是:在一棵已经坏掉的树上修修补补非常困难。filter-branch 能创造新的好提交,但无法清理包文件中已经存在的损坏对象引用。而 =git gc= 又因为包文件有问题而无法执行。 * 最终的解决方案 ** 核心思路 绕了一大圈,最直接的方案反而是:==把缺失的对象补上==。 git 的对象存储很简单——每个对象就是一个文件,路径是哈希值的前两位作为目录名,后38位作为文件名,内容经过 zlib 压缩。 既然知道缺的是啥,那就把它造出来。 ** 步骤 1:找到缺失对象的实际内容 先看看需要什么: : 缺失的 blob d7f34b8 → 文件 docs/superpowers/specs/2026-04-26-achievement-wall-design.md : 缺失的 tree 4de66b3 → 目录 docs/superpowers/specs/(只包含上面那个文件) 检查这个文件的内容,发现它仍然存在于工作目录中。那就简单了: #+BEGIN_EXAMPLE $ git hash-object -w docs/superpowers/specs/2026-04-26-achievement-wall-design.md d7f34b8f17b66827d315980d4cfcc0b17530a327 #+END_EXAMPLE =hash-object= 计算文件的 SHA-1 哈希,= -w= 参数把它写入对象存储。输出的哈希正好是 =d7f34b8=——这就是我们要找的缺失 blob! ** 步骤 2:重建 tree 对象 有了 blob,就可以重建引用它的 tree 了: #+BEGIN_EXAMPLE $ echo "100644 blob d7f34b8f17b66827d315980d4cfcc0b17530a327 2026-04-26-achievement-wall-design.md" | git mktree 8043d4e27bf8f8f91f30f9519651dd0dfcc2b1af #+END_EXAMPLE =mktree= 从标准输入创建 tree 对象。但输出的哈希是 =8043d4e= 而非期望的 =4de66b3=。 这表明 =4de66b3= 这个哈希本身就是错的(估计是当年仓库损坏时留下的垃圾数据),而 =8043d4e= 才是实际内容对应的正确哈希。 ** 步骤 3:删除孤立的损坏对象 虽然 filter-branch 已经重写了历史,但旧的损坏对象仍然以"松散对象"(loose object)的形式留在硬盘上。这些对象不被任何分支引用,但 git 仍能看到它们并报错。 具体来说,补齐缺失对象后 =git gc= 仍然报错。运行 =git fsck= 检查,报错的内容变了——不再是缺少 =4de66b3=,而是: : 损坏的链接来自于 tree ab43ad01 → tree d0c6a58 : 缺失 tree d0c6a58 这说明 =d0c6a58= 这个 tree 仍在硬盘上,且它引用的子 tree 本来应该是 =4de66b3=(就是之前缺失的那个),但现在这个引用关系断裂了。由于 =d0c6a58= 本身还是一个可读的对象,它就成了新的"损坏源头"。 检查它是不是松散对象: #+BEGIN_EXAMPLE $ ls .git/objects/d0/c6a58cd6ccac7e967a1ec1923b86b6098c37cf .git/objects/d0/c6a58cd6ccac7e967a1ec1923b86b6098c37cf #+END_EXAMPLE 文件存在,说明它是松散对象。注意路径的规律:git 取对象哈希的前两个字符 =d0= 作为目录名,后 38 个字符作为文件名。这样每个目录下最多只有 256 个文件(00~ff),避免单个目录的文件数量爆炸。再检查它是否还被当前分支引用: #+BEGIN_EXAMPLE $ git rev-list --objects main | grep d0c6a58 # (无输出) #+END_EXAMPLE 无输出,说明 main 分支已经不需要它了。=rev-list --objects main= 的作用是遍历 main 分支能到达的所有对象(包括提交、目录树、文件),逐一列出它们的哈希。如果目标哈希出现在这个列表里,说明某个提交还用着它,删了会破坏历史。没输出就说明安全,可以删。 确认安全后删除: #+BEGIN_EXAMPLE $ rm -f .git/objects/d0/c6a58cd6ccac7e967a1ec1923b86b6098c37cf #+END_EXAMPLE 然后重复这套流程——再跑 =git fsck=,看下一层是谁: : 损坏的链接来自于 tree b46bf016 → tree ab43ad01 : 缺失 tree ab43ad01 这次 =ab43ad01= 浮出来了。它被另一个 tree 引用,自身也是个松散对象。 : ls .git/objects/ab/43ad01abfddc06d9ae24d81d9fe96a8007e3dd ✓ 存在 : git rev-list --objects main | grep ab43ad01 ✓ 不被 main 引用 删掉它: #+BEGIN_EXAMPLE $ rm -f .git/objects/ab/43ad01abfddc06d9ae24d81d9fe96a8007e3dd #+END_EXAMPLE 如此反复,每删一层就跑 =git fsck= 看上一层,直到 =git fsck= 不再报错。 总结这个"顺藤摸瓜"的过程: 1. =git fsck= 告诉你哪个对象是"损坏的链接"的源头 2. =git rev-list --objects main | grep <哈希>= 确认不被任何分支需要 3. =ls .git/objects/xx/xxx...= 确认它是松散对象 4. 删除,回到第 1 步 注意这里的删除用的是直接 =rm -f= 删文件,而不是用 git 命令。因为松散对象就是硬盘上实实在在的文件,直接删掉文件系统层面的文件就可以了。但对于 reflog、分支引用这类 git 的"元数据",就要用 git 提供的命令(如 =git reflog expire=)来操作,不能直接去删 =./git/= 里的文件。 #+END_EXAMPLE ** 步骤 4:清理回收 删除孤立对象后,再清理 reflog 和历史备份: #+BEGIN_EXAMPLE $ git reflog expire --expire=now --all $ git gc --prune=now #+END_EXAMPLE 这次 =git gc= 没有报错了。 ** 步骤 5:验证 #+BEGIN_EXAMPLE $ git fsck --full # (无输出) #+END_EXAMPLE 没有输出,说明仓库完全健康! ** 步骤 6:推送 #+BEGIN_EXAMPLE $ git push -u origin main To git.zhlh6.cn:lujun9972/CodeQuest.git * [new branch] main -> main 分支 'main' 设置为跟踪 'origin/main'。 #+END_EXAMPLE 推送成功。 * 总结 这次事故中走了不少弯路。根本原因在于损坏发生在很久以前的提交中,这些对象在包文件里留下了无法清除的引用,且 =git gc= 也无法执行。 最后的解决方案出奇地简单:不去追求重写历史、不去删除损坏的包文件,而是直接把缺失的对象补上。git 的对象存储就是哈希文件系统,造出来就好了。 小白的教训: 1. 不要轻易用 =git filter-branch= 这类高级命令,没有彻底理解之前用它只会制造更多混乱 2. =git fsck --full= 是最好的诊断工具,先看看具体缺了什么再动手 3. 尝试 =git gc= 失败时,检查是否有孤立的松散对象(loose object),手动删除或补齐它们 4. .git/objects/ 目录下就是 git 的所有对象,读懂它的结构很多问题就迎刃而解了 5. 推送前先 =git fsck= 检查一下,避免推到服务器才发现问题 扩展阅读: - [Pro Git 10.2 Git 内部原理 - Git 对象](https://git-scm.com/book/zh/v2/Git-%E5%86%85%E9%83%A8%E5%8E%9F%E7%90%86-Git-%E5%AF%B9%E8%B1%A1) - [git objects 结构示意图](https://git-scm.com/book/en/v2/images/data-model-1.png)

多智能体系统的两个有效模式——以及对 Claude Code 用户的启示

2026年4月26日 08:00
[[https://www.jdon.com/91563-multi-agent-systems-effective-patterns.html][Cognition 公司(Devin 背后的团队)]]花了 10 个月测试多智能体系统,发现只有两个模式真正靠谱: - *代码审查环* :一个 AI 写代码,另一个 AI 审查代码。关键是审查的 AI 完全不知道写代码的过程,只看最终结果。 - *聪明朋友* :一个便宜、快速的 AI 做大部分工作,遇到搞不定的问题时临时请一个更强的 AI 帮忙出主意。 这两个模式有一个共同原则—— *单线程写入,多线程提供智能* 。谁来改代码,同一时刻只能有一个;但可以同时有多个 AI 从不同角度贡献分析和建议。多个 agent 同时往同一份代码里写东西会导致风格冲突和产品脆弱,就像多个厨师同时往一道菜里加盐,每个人都不知道别人加了多少。 Claude Code 是单 agent 工具,不是多智能体系统。但 Devin 团队的发现对 Claude Code 用户有直接的操作启示:这些模式可以转化为单 agent 的工作流策略。 * 单线程写入:为什么"多个 AI 一起写代码"行不通 "多 agent 并行写入"是指两个 AI 同时修改同一份代码。比如一个 AI 在改登录模块,另一个在改支付模块,但它们都碰了同一个配置文件——一个加了新字段,另一个删了旧字段,最终谁的修改覆盖谁的?更隐蔽的问题是:两个 AI 对错误处理的方式不同(一个用异常,一个用返回码),对命名风格的偏好也不同,这些 *隐含决策* 互相矛盾时,最终产物就变得脆弱。 这个问题的映射到 Claude Code 不是"不要同时做多件事"(Claude Code 本来就是单线程的,做不到同时做多件事),而是: *不要在一个 session 里连续做多件不同类型的事* 。比如先让 Claude Code 重构代码,紧接着让它加新功能——重构过程中积累的上下文(旧的变量名、已经不存在的函数)会干扰后面加功能时的判断。session 越长,上下文越杂,Claude Code 的有效注意力越分散。遇到需要"切换思路"的时刻,开一个新 session 比在当前 session 里堆指令更有效。 * 模式一:代码审查环 Devin 团队的发现中最反直觉的一条:编码 agent 写完代码后,让审查 agent 用 *纯净上下文* (完全不知道编码过程)来审查,效果远好于让"全知全能"的 agent 自己审查自己。平均每个 PR 抓到 2 个漏洞,其中约 58% 是严重的逻辑错误、边界遗漏或安全漏洞。 为什么纯净上下文反而更聪明?这是注意力机制的数学问题,不是经验问题。上下文越长,模型的注意力头需要覆盖的信息越多,重要细节被淹没的概率越大。编码 agent 工作了几个小时,读了仓库、跑了命令、尝试过不同方案、修复过错误——它的上下文又长又乱。审查 agent 只看到 diff,从头读代码,自己重新发现需要的信息。上下文越短,有效智能越高。 *Claude Code 用户的实操方法 * :写完代码后, * 开一个新 session 来审查* 。新 session 不需要知道你之前做了什么,只需要看 diff 或最终代码。 #+begin_src text 审查 prompt 示例: 请审查以下代码变更,重点关注: 1. 逻辑错误和边界遗漏 2. 安全漏洞 3. 是否有更简洁的实现方式 [粘贴 diff 或代码] #+end_src 关键点:审查 agent 由于缺少上下文信息,可能会修改一些你有意为之的设计。你自己需要知道哪些审查建议是不合理的,哪些审查建议值得采纳。 * 模式二:聪明朋友 想象一个初级程序员做大部分日常编码工作,遇到搞不定的难题就转头问旁边的资深同事。这就是"聪明朋友"模式——便宜、快速的模型做 80% 的工作,遇到困难时临时请一个更强、更贵的模型帮忙。Devin 团队在生产环境中验证了这个架构,甚至让不同厂商的前沿模型以这种方式协作:有些模型更擅长调试,有些更擅长写测试,按能力分工而不是按难度升级。 但核心难题是 *通信设计* ,有三个具体问题: 1. *弱模型怎么知道自己不行?* -- 弱模型天然倾向于低估任务难度——这跟人类的达克效应(能力不足的人高估自己)结构上非常相似。Devin 团队的方案是鼓励主模型至少调用一次聪明朋友来评估是否有遗漏的难点。 2. *主模型该分享什么上下文?* -- 实践中发现,直接把主模型的完整上下文分叉一份给聪明模型,再加上开放性问题("我该怎么做?"),效果比只分享部分上下文加具体问题更好。让聪明模型自己决定什么值得讨论。 3. *聪明朋友该怎么回复?* -- 有时候主模型没看过某个关键文件,然后问了一个涉及其文件内容的问题。聪明朋友正确的做法不是自己给出答案,而是告诉主模型"先去读那个文件,然后再来问我"。 *Claude Code 用户的实操方法* :日常用 Claude Code(Sonnet)干活。遇到特定类型的难题——调试困难的问题、架构决策、复杂的性能优化——可以手动切换到更强模型(如 Opus)或者另一个 AI 工具获取"第二意见"。 切换时最重要的是 *怎么描述问题* 。基于 Devin 团队的经验: - 不要只给具体问题("这段代码为什么报错?"),给开放性问题("这个模块的整体设计有什么问题?")效果更好 - 附上足够的上下文(相关代码、错误信息、你尝试过的方案),让对方模型自己判断重点 - 如果对方模型反问"你看过 X 文件吗?",认真对待这个信号——它往往指向你没注意到的关键信息 局限也很明显:Claude Code 目前不支持自动升级模型。你必须手动判断"这个问题需要更强的模型",手动切换,手动把结果带回工作 session。这个"通信桥梁"目前只能由人来做。 * 你的角色:通信桥梁 Devin 团队总结说,所有开放问题都是通信问题——弱模型怎么学会何时升级、子 agent 怎么把发现传递给同伴、怎么在 agent 之间传输上下文而不淹没接收者。 在 Claude Code 的场景下,你就是那个通信桥梁。你负责: - *判断何时开新 session* (代码审查环) - *判断何时切换更强模型* (聪明朋友) - *在 session 之间传递关键信息* (你从旧 session 带到新 session 的上下文) - *过滤 AI 的建议* (用你的完整上下文判断哪些建议值得采纳) 这个角色目前无法自动化。但 Devin 团队说得对:目标不是"一群自主行动的角色",而是"一个扩展人类品味的协调系统"。用好单 agent + 纯净上下文审查 + 聪明朋友升级,不需要等到完美的多 agent 系统出现,已经能覆盖大部分场景。

程序员愿意为 AI 写文档,却不愿为同事写

2026年4月25日 08:00
* 一个有趣的悖论 Mark Dominus在他的博客 [[https://blog.plover.com/][The Universe of Discourse]] 上写了一篇短文 [[https://blog.plover.com/tech/gpt/documentation-wins-2.html][Programmers will document for Claude, but not for each other]],标题本身就点出了一个耐人寻味的现象: #+BEGIN_QUOTE 我不断看到程序员们抱怨:人们愿意为 Claude 写详细的 CLAUDE.md 和 PROJECT.md 文件,却不愿意为自己的同事写这些。 #+END_QUOTE 这个现象确实讽刺。程序员们多年来对项目文档爱答不理——README 写得敷衍,设计文档懒得更新,交接笔记能省则省。但当 AI 成了文档的"读者"时,大家突然变得勤快了。 * Mark 的做法:从丢弃到保留 Mark 本人在使用 Claude 时养成了一些有意思的习惯。 首先,他让 Claude 维护一份"交接文档"(handoff document)。每次 Claude 会话结束时,这份文档会记录:计划做什么、已经做了什么、以及其他相关信息。下一个 Claude 会话开始时,读取这份文档就能快速进入状态。 一开始,Mark 在项目结束后就把这些交接文档扔掉了。后来他灵光一现——为什么要扔掉呢?把它提交到 git 仓库里不就行了?将来有人用 =git grep= 翻看历史,说不定就能找到有用的信息。 再后来,他进一步改进了做法:项目结束时,不再直接提交交接文档,而是让 Claude 从头写一份结构化的项目摘要——不是零散的笔记,而是对整个项目的高层次概述,包括解决了什么问题、做了哪些改动。 他会仔细审阅这些摘要,必要时进行编辑,然后才提交。毕竟签名是他的,工资也是发到他的银行账户上,所以仓库里的任何内容都必须是他仔细读过并理解的——就像 Claude 是一个由他管理的人类程序员一样。 * Claude 的写作水平 Mark 提到,Claude 的摘要质量和他自己写的差不多,可能稍好一点,也可能稍差一点。但关键是:Claude 写只要 10 秒,而他亲自动手需要一个小时。而且审阅的时间远远不到一个小时。 不过也有有趣的小插曲。有一次 Claude 参考了之前的一份报告来写新报告,而之前那份报告的末尾有一段 Mark 自己加的文字: #+BEGIN_QUOTE # Approved-by Claude abstracted these notes from our discussions of the issue. Mark Dominus has read, reviewed, edited, and approved these notes. #+END_QUOTE 结果新报告里也出现了一模一样的段落。好消息是,等 Mark 发现时,这段话恰好是真实的——他确实审阅过。后来他在 CLAUDE.md 里加了一句话,告诉 Claude 不要再这样做。 * 我的思考 这个现象之所以有趣,是因为它揭示了关于文档本质的几个真相: ** 文档的真正障碍不是"没时间",而是"没动机" 为 AI 写文档之所以容易,是因为反馈是即时的——你写了 CLAUDE.md,Claude 马上就能理解你的项目上下文,给你更好的回答。而为同事写文档的反馈周期长得多,甚至可能永远得不到反馈。人很难对延迟的、不确定的回报产生动力。 ** AI 降低了"表达成本" 让 Claude 代笔写一份项目摘要只要 10 秒,而自己写需要 1 小时。这意味着文档的瓶颈从"写"转移到了"审"。当写作成本趋近于零时,唯一剩下的门槛就是你是否愿意花时间阅读和确认——而这比从零开始写要轻松得多。 ** 为 AI 写的文档,最终服务的还是人 Mark 的做法说明了一个巧妙的闭环:你为 Claude 写的交接文档和项目摘要,最终被提交到 git 仓库里,成为团队的知识资产。未来的同事——或者未来的你——可以通过 =git grep= 找到这些信息。AI 在这个过程中扮演的角色更像是一个"文档代笔者":你说你想表达什么,它帮你写出来,你审核确认后入库。 也许这就是 AI 改善文档文化的隐秘路径:不是说服程序员"文档很重要",而是让他们在为 AI 服务的过程中,自然而然地为人类同事也留下了文档。 * Mark 的建议 Mark 在文章最后给出了两条实用建议: 1. 如果你让 Claude 写了笔记,项目结束后把它提交到仓库里。 2. 让 Claude 写一份项目摘要,然后提交到仓库。
昨天 — 2026年4月25日暗无天日

用 Org Babel 写 Literate 博文:扩展执行 + 定制导出

2026年4月25日 08:00
Literate programming 的核心想法是:把代码和解释它的散文交织在一起,代码可以被实际执行,输出直接嵌入文档。Org mode 的 Babel 就是做这件事的工具——它比 Jupyter Notebook 更灵活(支持任何有 REPL 的语言),但开箱即用时缺少两个东西:REPL 风格的交互式代码块(逐行执行、交替显示输入和输出),以及把 Org 文档导出为静态站生成器能用的 Markdown(带 YAML frontmatter)。这篇文章分两部分解决这两个问题:教你为任意语言写一个自定义 Babel 执行函数,和派生一个自定义 Org 导出后端。 * 第一部分:为任意语言扩展 Babel Babel 的执行机制很简单:只要定义一个名为 =org-babel-execute:lang= 的函数(把 =lang= 换成你的语言名),Babel 就知道怎么执行该语言的代码块。这个函数接收两个参数——代码体(body)和参数列表(params),返回执行结果的字符串。Babel 负责剩下的所有事情:解析代码块、收集参数、显示结果。 ** 最简执行函数 假设你想为一种叫 =mylang= 的语言添加 Babel 支持。最简单的实现是调用外部命令执行代码: #+begin_src emacs-lisp (defun org-babel-execute:mylang (body params) "Execute a block of MyLang code with org-babel." (shell-command-to-string (format "mylang -e %s" (shell-quote-argument body)))) #+end_src 定义了这个函数之后,Org 文件里的 =#+begin_src mylang= 代码块就能用 =C-c C-c= 执行了。 有一个容易踩的坑:Babel 默认把代码包在一个函数里、调用函数、显示 *返回值* 而不是输出结果。如果你的代码用了 =print= 之类的 I/O 操作,默认行为不会显示 print 的内容。要改为显示输出结果,在src block 加上 =:results output= 参数后。 ** REPL 风格的代码块 基本的执行函数把整个代码块当做一个单元执行。但写 literate 博文时,你通常想要 REPL 风格的效果:逐行执行代码,每一行下面紧接着显示输出。 实现方式是定义一个新的结果类型 =repl= 。当代码块加上 =:results repl= 时,逐行发送代码到 REPL 进程,收集每行的输出,然后把输入和输出交替排列: #+begin_src emacs-lisp (defun org-babel-mylang--execute-repl (body) "Execute BODY line-by-line, returning input/output pairs." (let ((lines (split-string body "\n" t "[ \t]+"))) (mapconcat (lambda (line) (format " %s\n%s" line (mylang-evaluate-command line))) lines "\n"))) #+end_src 然后在主执行函数里根据参数进行分支判断:遇到 =:results repl= 就走逐行执行,否则走整块执行。 #+begin_src emacs-lisp (defun org-babel-execute:mylang (body params) "Execute a block of MyLang code with org-babel. When PARAMS includes `:results repl', evaluate each line separately and return all results interleaved." (let ((result-params (cdr (assq :results params)))) (if (and result-params (string-match-p "\\brepl\\b" result-params)) (org-babel-mylang--execute-repl body) (mylang-evaluate-command body)))) #+end_src 使用时代码块头部写成这样: #+begin_src org ,#+begin_src mylang :results repl :exports results :wrap SRC mylang x = 1 + 2 x * 4 ,#+end_src #+end_src 三个参数的作用: =:results repl= 触发逐行执行, =:exports results= 只导出结果不导出源码, =:wrap SRC mylang= 把结果包在一个 =mylang= 代码块里——这样导出到 Markdown 后语法高亮仍然生效。执行后得到: #+begin_src mylang x = 1 + 2 3 x * 4 12 #+end_src ** 批量执行 写完博文后,你可能想一次性执行所有代码块来更新结果。下面这个函数执行文件中所有 =mylang= 代码块,但跳过已经是结果块的一部分的代码块(避免重复执行): #+begin_src emacs-lisp (defun org-babel-mylang-execute-all () "Execute all MyLang src blocks not part of a #+RESULTS block." (interactive) (org-babel-map-src-blocks nil (when (and (string-equal "mylang" (car (org-babel-get-src-block-info 'no-eval))) (not (progn (goto-char beg-block) (forward-line -1) (looking-at-p "#\\+RESULTS:")))) (goto-char beg-block) (org-babel-execute-src-block)))) #+end_src =org-babel-map-src-blocks= 遍历文件中所有代码块, =beg-block= 是每个块的起始位置。通过向前看一行检查是否在 =#+RESULTS:= 下面,跳过那些只是结果的块。 * 第二部分:派生自定义 Org 导出后端 写完 literate 博文后,需要导出为静态站生成器能用的格式。如果你的站点用 Hakyll、Jekyll、Hugo 等工具,你需要带 YAML frontmatter 的 Markdown。默认的 =ox-gfm= 不支持自定义 frontmatter 字段,也不把脚注转为 GFM 格式。但可以通过派生后端来添加这些功能。 ** 派生后端的基本方法 Org 的导出系统支持从已有后端派生新后端。 =ox-gfm= 继承自 =ox-md= , =ox-md= 又继承自 =ox-html= 。你可以在任何一层上继续派生,只覆盖需要的部分: #+begin_src emacs-lisp (org-export-define-derived-backend 'my-gfm 'gfm :options-alist '((:tags "TAGS" nil nil split) (:last-modified "LAST-MODIFIED" nil nil) (:og-description "OG-DESCRIPTION" nil nil)) :translate-alist '((template . my-gfm-template) (footnote-reference . my-gfm-footnote-reference))) #+end_src 两个关键参数: - =:options-alist= 定义新的导出选项。每个元素是 =(ALIST-KEY KEYWORD OPTION DEFAULT BEHAVIOR)= ,其中 =ALIST-KEY= 是导出信息 plist 中的键, =KEYWORD= 是 Org 文件里写的 =#+KEYWORD= 名, =BEHAVIOR= 告诉 Org 如何处理多个值—— =split= 会把空格分隔的字符串拆成列表(适用于 tags) - =:translate-alist= 把导出元素挂钩到自定义翻译函数。 =template= 控制整个文档的输出(适合插入 frontmatter), =footnote-reference= 控制脚注引用的格式 ** 生成 YAML Frontmatter =template= 翻译函数接收最终转换后的文档内容(contents),是插入 preamble 的标准位置。下面这个函数从导出信息中提取 =title= 、 =date= 、 =tags= 等字段,组装成 YAML 格式: #+begin_src emacs-lisp (defun my-gfm--build-yaml (info) "Build YAML front matter string from INFO plist." (when-let* ((lines (seq-keep (lambda (f) (when-let* ((field (plist-get info f)) (val (pcase f (:title #'car) (:date #'car) (:tags (lambda (x) (mapconcat #'identity x " "))) (:last-modified #'identity) (:og-description #'identity)))) (format "%s: %s" (string-trim (pp-to-string f) ":" "\n") (funcall val field)))) '(:title :date :last-modified :tags :og-description)))) (concat "---\n" (mapconcat #'identity lines "\n") "\n---\n\n"))) (defun my-gfm-template (contents info) "Return complete document string after GFM conversion. CONTENTS is the transcoded contents string. INFO is a plist holding export options." (concat (my-gfm--build-yaml info) contents)) #+end_src =my-gfm--build-yaml= 的逻辑是:遍历字段名列表,对每个字段从 =info= plist 中取值,用 =pcase= 根据字段类型选择取值函数( =:title= 和 =:date= 取 =car= , =:tags= 把列表拼成空格分隔的字符串),格式化为 =key: value= 行,最后用 =---= 包裹成 YAML 块。 =seq-keep= 自动跳过值为 nil 的字段,所以只有 Org 文件中实际写了的字段才会出现在 frontmatter 里。 ** 处理脚注 默认的 =ox-gfm= 会把 Org 脚注引用( =[fn:1]= )直接导出为 HTML( == ),这不是 GFM 格式。需要覆盖 =footnote-reference= 翻译函数,改为 GFM 的脚注语法 =[^n]= : #+begin_src emacs-lisp (defun my-gfm-footnote-reference (footnote-reference _contents info) "Transcode a FOOTNOTE-REFERENCE element into GFM format." (format "[^%d]" (org-export-get-footnote-number footnote-reference info))) #+end_src 脚注内容部分,去掉默认的"Footnotes"标题(静态站生成器会自己处理),直接输出定义列表: #+begin_src emacs-lisp (defun my-gfm-footnote-section (info) "Format the footnote section without header." (and-let* ((fn-alist (org-export-collect-footnote-definitions info))) (format "%s\n" (mapconcat (pcase-lambda (`(,n ,_type ,def)) (format "[^%d]: %s" n (org-trim (org-export-data def info)))) fn-alist "\n\n")))) #+end_src =org-export-collect-footnote-definitions= 收集所有脚注定义, =org-export-data= 递归导出脚注内容。每个脚注格式化为 =[^n]: content= ,放在文档末尾。 * 合在一起 完整的工作流程是: 1. 在 Org 文件中写代码块和散文,用 =:results repl= 获得交互式输出 2. =C-c C-c= 执行单个块,或调用批量执行函数更新所有块 3. 导出为 GFM,自动生成 YAML frontmatter,脚注转为 GFM 格式 4. 静态站生成器接过 Markdown,继续后续处理 两个扩展各自独立:你可以只用 REPL 风格的 Babel 块而不定制导出,也可以只定制导出后端而不用 REPL 块。但合在一起,就能在 Org mode 的舒适环境中写完整的 literate 博文,一键导出为静态站可用的 Markdown。

proced:Emacs 内置的进程查看器

2026年4月25日 08:00
=proced= 是 Emacs 内置的进程查看器,相当于一个可以直接在 Emacs 里操作的彩色版 =ps= 。它支持自动刷新、树状视图、按列排序、发送信号,还可以通过 =proced-custom-attributes= 扩展自定义列。本文介绍 =proced= 的基本用法和常用配置。 * 启动 =M-x proced= 即可打开。 =proced= 会列出当前系统的进程,默认只显示当前用户的进程( =proced-filter= 默认值为 =user= )。 * 常用按键 =proced= buffer 中的按键分为几类: ** 导航 | 按键 | 功能 | |------+------| | =n= / =p= | 上下移动 | | =SPC= | 移动到下一行 | ** 标记 | 按键 | 功能 | |------+------| | =m= / =d= | 标记当前行( =d= 是"标记准备操作"的习惯按键) | | =u= | 取消当前行标记 | | =U= | 取消所有标记 | | =M= | 标记所有行 | | =t= | 反转标记 | | =C= | 标记子进程 | | =P= | 标记父进程 | ** 视图控制 | 按键 | 功能 | |------+------| | =f= | 切换过滤器(user / user-running / all / all-running / emacs) | | =F= | 切换显示格式(short / medium / long / verbose) | | =T= | 切换树状视图 | | =o= | 隐藏/显示被标记的进程 | | =RET= | 在当前列上细化筛选 | ** 操作 | 按键 | 功能 | |------+------| | =k= / =x= | 向标记的进程发送信号 | | =r= | 修改进程的 nice 值 | ** 其他 | 按键 | 功能 | |------+------| | =?= / =h= | 查看帮助 | * 发送信号 按 =k= 或 =x= 会弹出一个信号列表,让你选择要发送的信号。你可以直接输入信号名(如 =SIGTERM= 、 =SIGKILL= ),也可以从列表中选择。如果事先用 =m= 标记了多个进程,信号会发给所有被标记的进程。 * 排序 在 =proced= 中,用鼠标点击列头就能按该列排序。再次点击同一列头会反转排序顺序。 把光标移到某个属性值上按 =RET= ( =proced-refine= )则会根据当前值细化筛选——比如把光标放在某个进程的 USER 列上按 =RET= ,可以只显示同一用户的进程。 * 常用配置 以下是一套推荐的 =proced= 配置: #+begin_src emacs-lisp (use-package proced :ensure nil :defer t :custom (proced-enable-color-flag t) ;; 启用颜色 (proced-tree-flag t) ;; 默认启用树状视图 (proced-auto-update-flag 'visible) ;; 只在 buffer 可见时自动刷新 (proced-auto-update-interval 1) ;; 每秒刷新 (proced-descend t) ;; 树状视图降序排列 (proced-format 'medium) ;; 中等详细度的显示格式 (proced-filter 'user)) ;; 只显示当前用户的进程 #+end_src 几个关键变量说明: - =proced-auto-update-flag= :设为 ='visible= 表示只在 buffer 可见时刷新,比 =t= 更省资源。 =proced= 会按 =proced-auto-update-interval= 指定的秒数定期重新读取进程列表。 - =proced-format= :控制显示哪些列。 =short= 最精简, =verbose= 最详细。运行时可以用 =F= 键切换,不需要重启。 - =proced-filter= :控制显示哪些进程。 =user= 只显示当前用户的, =all= 显示所有, =emacs= 只显示 Emacs 相关进程。运行时用 =f= 键切换。 - =proced-tree-flag= : =t= 启用树状视图,可以直观看到进程的父子关系。 * 自定义扩展 =proced= 支持通过 =proced-custom-attributes= 添加自定义列。这个功能在某些场景下很有用——比如 macOS 上 =proced= 默认缺少 CPU 和内存列,就可以通过这个机制补上。具体的实现思路见[[file:读:让proced在macOS上显示CPU和内存.org][上一篇博文]]。

从 proced 定制中学到的 Elisp 模式

2026年4月25日 08:00
Rahul Juliato 的 [[https://rahuljuliato.com/posts/proced-macos][Getting Emacs proced.el to Show CPU and Memory on macOS]] 表面上是解决 macOS 上 =proced= 缺少 CPU/Mem 列的问题,实际上是一份很好的 Elisp 编程教学。本文从中提取六个可复用的编程模式——它们跟 =proced= 和 macOS 无关,你在任何需要异步调用外部命令、缓存数据、扩展第三方包的场景都能用。 * 模式一:异步进程 + sentinel Elisp 中执行外部命令有两种方式: =call-process= (同步,会阻塞 Emacs )和 =make-process= (异步,不阻塞)。凡是执行时间不确定的命令,都应该用 =make-process= 。 #+begin_src emacs-lisp (make-process :name "my-process" :buffer (generate-new-buffer " *my-process-temp*") :command '("env" "LC_ALL=C" "ps" "-eo" "pid=,%cpu=,%mem=") :noquery t :sentinel (lambda (proc _event) (when (eq (process-status proc) 'exit) (with-current-buffer (process-buffer proc) (goto-char (point-min)) ;; 在这里处理输出 (buffer-string)) (kill-buffer (process-buffer proc))))) #+end_src 关键设计: - =sentinel= 是一个回调函数,在进程状态变化时触发。通常只关心 ='exit= 状态——此时 buffer 中已有完整输出。 - =:noquery t= 防止 Emacs 退出时弹出"还有进程在运行"的确认框。 - =generate-new-buffer= 创建临时 buffer 承接输出,名称以空格开头( =" "= )的 buffer 在 =list-buffers= 中默认隐藏。 - 处理完后 =kill-buffer= 清理临时 buffer ,避免 buffer 堆积。 * 模式二:hash table 做进程级缓存 当你需要频繁按 key 查找数据时,hash table 比 alist 快得多( O(1) vs O(n) )。 #+begin_src emacs-lisp ;; 创建 (defvar my-cache (make-hash-table)) ;; 写入(用 cons 存两个值,car 和 cdr 分别取) (puthash 1234 (cons 2.5 1.3) my-cache) (puthash 5678 (cons 0.1 0.5) my-cache) ;; 读取 (car (gethash 1234 my-cache)) ;; => 2.5 (cdr (gethash 1234 my-cache)) ;; => 1.3 (gethash 9999 my-cache) ;; => nil #+end_src #+begin_example 2.5 1.3 nil #+end_example 为什么用 cons 存值?因为 =car= 和 =cdr= 是 Elisp 中最快的取值操作之一,比 =plist-get= 或 =alist-get= 快。当你只需要存两个值时,cons 是最佳选择。 刷新时不要原地修改 hash table ,而是创建一个新的再替换——这样即使有并发的读操作也不会读到半更新的状态: #+begin_src emacs-lisp (defun my-refresh-cache () (let ((new-cache (make-hash-table))) ;; 填充 new-cache ... (setq my-cache new-cache))) ;; 原子替换 #+end_src * 模式三: =rx= 宏写可读正则 Elisp 的 =rx= 宏用 S-expression 写正则,比字符串正则更容易读和维护。对比一下: #+begin_src emacs-lisp ;; 字符串正则:不直观,需要脑内解析 "^[[:blank:]]*\\([[:digit:]]+\\)[[:blank:]]+\\([.[:digit:]]+\\)" ;; rx 宏:自解释 (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.)))) #+end_src =rx= 编译出来的结果跟手写字符串正则完全一样: #+begin_src emacs-lisp (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.))) (+ blank) (group (+ (any digit ?.)))) #+end_src #+begin_example [[:blank:]]*\([[:digit:]]+\)[[:blank:]]+\([.[:digit:]]+\)[[:blank:]]+\([.[:digit:]]+\) #+end_example 常用 =rx= 组合: | =rx= 写法 | 匹配内容 | |-----------+----------| | =(+ digit)= | 一个或多个数字 | | =(* blank)= | 零或多个空白 | | =(any digit ?.)= | 数字或小数点 | | =(group ...)= | 捕获组,对应 =match-string= | * 模式四:timer 生命周期管理 Emacs 的 =run-with-timer= 可以周期性执行任务,但如果不在合适的时机取消,timer 会一直运行下去(即使对应的 buffer 已经关了)。正确的做法是在 mode hook 里启动,在 =kill-buffer-hook= 里取消: #+begin_src emacs-lisp (defvar my-timer nil) ;; 在 mode hook 中启动 (add-hook 'my-mode-hook (lambda () (setq my-timer (run-with-timer 0 2 #'my-refresh-function)))) ;; 在 kill-buffer-hook 中清理 (add-hook 'kill-buffer-hook (lambda () (when (and (derived-mode-p 'my-mode) (timerp my-timer)) (cancel-timer my-timer) (setq my-timer nil)))) #+end_src 三个要点: - =run-with-timer= 的参数是 =(延迟秒数 重复间隔 函数)= ,第一个参数 =0= 表示立即执行第一次。 - =cancel-timer= 取消后要把变量设为 =nil= ,避免悬空引用。 - guard 条件 =(derived-mode-p 'my-mode)= 确保 =kill-buffer-hook= 不会在其他类型的 buffer 中误触发。 =kill-buffer-hook= 是全局的,每次任何 buffer 关闭都会触发,所以必须有 mode 判断。 * 模式五: =file-remote-p= 做 TRAMP 感知 当你的代码依赖本地系统状态(比如执行本地 =ps= ),必须考虑 buffer 可能在 TRAMP 远程主机上运行的情况。 =file-remote-p= 检测 =default-directory= 是否指向远程: #+begin_src emacs-lisp (unless (file-remote-p default-directory) ;; 只在本地执行 (my-run-local-command)) #+end_src 为什么这很重要?如果你不加检测,可能会用本地 =ps= 的输出作为参数在远程主机上执行命令。轻则数据显示错误,重则本地 PID 跟远程 PID 碰撞导致误操作。 * 模式六:通过 custom attributes 扩展第三方包 很多 Emacs 包提供 =*-custom-attributes= 或类似的扩展点,让你不用修改包的源码就能添加功能。 =proced= 的 =proced-custom-attributes= 就是一个例子:它接受一个 lambda 列表,每个 lambda 接收当前行的属性 alist ,返回 =(keyword . value)= 就能添加新列。 #+begin_src emacs-lisp (setq proced-custom-attributes (list (lambda (attrs) (when-let* ((pid (cdr (assq 'pid attrs))) (v (my-lookup pid))) (cons 'my-attribute v))))) #+end_src 这个模式不限于 =proced= 。当你需要给某个包添加自定义字段时,先看看它有没有类似的扩展点——很多设计良好的包都会提供。 * 小结 | 模式 | 一句话 | |------+--------| | 异步进程 + sentinel | 用 =make-process= 代替 =call-process= ,在 sentinel 中处理输出 | | hash table 缓存 | 高频查找用 hash table ,刷新时整体替换而非原地修改 | | =rx= 宏 | 用 S-expression 写正则,比字符串更可读更易维护 | | timer 生命周期 | hook 启动、hook 清理、 =derived-mode-p= guard 三件套 | | =file-remote-p= | 任何依赖本地系统状态的代码都要加 TRAMP 检测 | | custom attributes | 用 lambda 返回 =(keyword . value)= 扩展第三方包的显示 |

读:让 Emacs proced 在 macOS 上显示 CPU 和内存

2026年4月25日 08:00
本文是对 Rahul Juliato 的文章 [[https://rahuljuliato.com/posts/proced-macos][Getting Emacs proced.el to Show CPU and Memory on macOS]] 的解读。原文解决了 macOS 上 Emacs =proced= 缺少 CPU 和内存列的问题,同时是一份很好的 Elisp 编程教学——涉及异步进程、hash table 缓存、自定义属性扩展等多个实用技巧。 * 问题是怎样的 Emacs 内置的 =proced= 是一个进程查看器,相当于彩色版的 =ps= 。在 Linux 上,它默认就能显示每个进程的 CPU 和内存占用。但在 macOS 上, =%CPU= 和 =%Mem= 两列是空的。 原因在 Emacs 的 C 层: =proced= 通过 =system_process_attributes= 函数(定义在 =src/sysdep.c= 中)获取每个进程的属性列表。在 Linux 上,这个函数从 =/proc/*/stat= 读取 CPU 和内存数据;在 BSD 和 Windows 上,它通过系统 API 计算。但在 Darwin(macOS 内核)上,这个函数虽然调用了 =proc_pidinfo= 来获取虚拟内存和常驻内存大小,却从未填充 =pcpu= 和 =pmem= 两个字段——数据明明可以通过 =proc_pid_rusage= 、 =task_info= 和 =sysctl hw.memsize= 拿到,只是没人把线接上。 Rahul Juliato 向上游提了一个 patch([[https://debbugs.gnu.org/cgi/bugreport.cgi?bug=80898][debbugs #80898]]),但在 patch 合并之前,他用纯 Elisp 给出了一个 workaround。 * 解决思路 整体方案很清晰: 1. 用 =make-process= 异步运行 ~ps -axo pid=,%cpu=,%mem=~ 获取进程信息 2. 把输出解析后存入 hash table(以 PID 为 key ) 3. 通过 =proced-custom-attributes= 把 =pcpu= 和 =pmem= 两个属性注入 =proced= 4. 用 timer 每 2 秒刷新一次 hash table * 异步运行 ps 并解析输出 原文用 =(when (eq system-type 'darwin))= 把所有代码包在一起,确保只在 macOS 上执行。下面的代码片段省略了这个外层 guard ,实际使用时应当加上。 核心是用 =make-process= 异步执行 =ps= : #+begin_src emacs-lisp (defvar emacs-solo--proced-ps-cache (make-hash-table)) (defvar emacs-solo--proced-ps-timer nil) (defun emacs-solo--proced-ps-do-refresh () (make-process :name "proced-ps-refresh" :buffer (generate-new-buffer " *proced-ps-temp*") :command '("env" "LC_ALL=C" "ps" "-axo" "pid=,%cpu=,%mem=") :noquery t :sentinel (lambda (proc _event) (when (eq (process-status proc) 'exit) (let ((new-cache (make-hash-table))) (with-current-buffer (process-buffer proc) (goto-char (point-min)) (while (not (eobp)) (when (looking-at (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.))) (+ blank) (group (+ (any digit ?.))))) (puthash (string-to-number (match-string 1)) (cons (string-to-number (match-string 2)) (string-to-number (match-string 3))) new-cache)) (forward-line 1))) (kill-buffer (process-buffer proc)) (setq emacs-solo--proced-ps-cache new-cache)))))) #+end_src 几个设计要点: - =LC_ALL=C= 强制 =ps= 使用固定的输出格式,不受用户 locale 影响。不同 locale 下数字的小数点可能变成逗号,解析就会出错。 - sentinel 只在进程退出时触发( =(eq (process-status proc) 'exit)= ),此时 buffer 中已有完整输出。 - =rx= 宏让正则表达式更可读:三个分组分别匹配 PID(整数)、 =%CPU=(浮点数)、 =%Mem=(浮点数)。 - =puthash= 以 PID 为 key ,以 =(%CPU . %Mem)= 这个 cons 为 value 。用 cons 而不是 list ,是因为 =car= 和 =cdr= 取值最快。 为什么用 hash table 而不是 alist ?因为 =proced= 会为每个进程调用自定义属性函数,hash table 的查找时间是 O(1) ,即使有几百个进程也很快。 * 查询函数 简单的 wrapper ,从 hash table 中取值: #+begin_src emacs-lisp (defun emacs-solo--proced-pcpu (pid) (car (gethash pid emacs-solo--proced-ps-cache))) (defun emacs-solo--proced-pmem (pid) (cdr (gethash pid emacs-solo--proced-ps-cache))) #+end_src =car= 取 CPU , =cdr= 取内存——这就是用 cons 存储的好处。 * 注入到 proced 这是把一切连起来的关键。 =proced-custom-attributes= 是一个 lambda 列表,每个 lambda 接收当前行的属性 alist ,返回 =(keyword . value)= 形式的 cons , =proced= 会把它当作新列显示: #+begin_src emacs-lisp (add-hook 'proced-mode-hook (lambda () (unless (file-remote-p default-directory) (setq emacs-solo--proced-ps-timer (run-with-timer 0 2 #'emacs-solo--proced-ps-do-refresh))))) (setq proced-custom-attributes (list (lambda (attrs) (unless (file-remote-p default-directory) (when-let* ((pid (cdr (assq 'pid attrs))) (v (emacs-solo--proced-pcpu pid))) (cons 'pcpu v)))) (lambda (attrs) (unless (file-remote-p default-directory) (when-let* ((pid (cdr (assq 'pid attrs))) (v (emacs-solo--proced-pmem pid))) (cons 'pmem v)))))) #+end_src 两个 lambda ,一个管 CPU 一个管内存。每个 lambda 做三件事: 1. 用 =file-remote-p= 检测当前 buffer 是否在 TRAMP 远程主机上。如果是,本地的 =ps= 数据没有意义,而且本地 PID 可能跟远程 PID 冲突。 2. 从 =attrs= 中提取 PID ,在 hash table 中查找值。 3. 返回 =(pcpu . value)= 或 =(pmem . value)= 。 timer 放在 =proced-mode-hook= 里启动,因为只有 =proced= buffer 存在时才需要刷新数据。 * 清理 timer =proced= buffer 关闭时取消 timer ,避免悬空的定时器: #+begin_src emacs-lisp (add-hook 'kill-buffer-hook (lambda () (when (and (derived-mode-p 'proced-mode) (timerp emacs-solo--proced-ps-timer)) (cancel-timer emacs-solo--proced-ps-timer) (setq emacs-solo--proced-ps-timer nil)))) #+end_src guard 条件 =(derived-mode-p 'proced-mode)= 确保 =kill-buffer-hook= 不会在其他 buffer 关闭时误触发。 * 学到了什么 | 技巧 | 说明 | |------+------| | =proced-custom-attributes= | 接受 lambda 列表,每个 lambda 接收行属性 alist ,返回 =(keyword . value)= 即可添加新列 | | =make-process= + sentinel | Elisp 中异步执行外部命令的标准方式。sentinel 在进程状态变化时触发,通常只关心 ='exit= 状态 | | =run-with-timer= | 周期性执行任务。返回 timer 对象,可以用 =cancel-timer= 取消。比 =run-at-time= 更方便 | | =file-remote-p= | 检测当前 buffer 是否在 TRAMP 远程主机上。任何涉及本地系统状态的 hack 都应该加上这个 guard | | =rx= 宏 | 比 =regexp= 字符串更可读的正则写法,支持 S-expression 风格组合 |

hyperfine:命令行基准测试工具

2026年4月25日 08:00
测一个命令要跑多久,大多数人第一反应是 =time= : #+begin_src shell time find /usr/share/doc -maxdepth 2 -name "*.txt" #+end_src #+begin_example real 0m0.012s user 0m0.004s sys 0m0.007s #+end_example 但跑第二次,结果可能变成 0.009s ;第三次 0.015s 。哪一个才是"真实"的执行时间? 磁盘缓存、CPU 调度、后台进程、甚至 CPU 频率调节( turbo boost )都会让每次执行时间不同。你需要的是多次采样的统计摘要——均值、标准差、最小/最大值——而不是一个孤立的数字。 =hyperfine= 可以帮我们自动处理采样、预热和统计分析。 * 基本用法 最简单的形式:把命令放在引号里。 #+begin_src shell hyperfine 'find /usr/share/doc -maxdepth 2 -name "*.txt"' #+end_src #+begin_example Benchmark 1: find /usr/share/doc -maxdepth 2 -name "*.txt" Time (mean ± σ): 20.2 ms ± 23.0 ms [User: 5.4 ms, System: 10.7 ms] Range (min … max): 12.0 ms … 125.3 ms 23 runs Warning: The first benchmarking run for this command was significantly slower than the rest (125.3 ms). This could be caused by (filesystem) caches that were not filled until after the first run. You should consider using the '--warmup' option. #+end_example hyperfine 甚至会主动告诉你问题在哪里——第一次运行明显慢于后续( 125.3ms vs 12.0ms ),因为文件系统缓存还没热。这正是后面要讲的 =--warmup= 要解决的问题。 输出解读: - =Time (mean ± σ)= :均值 ± 标准差。标准差越小,说明结果越稳定。 - =[User: ... System: ...]= :用户态和内核态的 CPU 时间。 - =Range (min … max)= :最快和最慢的一次,展示了波动范围。 - =23 runs= : =hyperfine= 自动决定了跑 23 次(默认至少 10 次,且至少测量 3 秒)。 * 对比两个命令 =hyperfine= 真正有用的地方是对比。传多个命令就行: #+begin_src shell hyperfine --warmup 2 'grep -r "error" /var/log/' 'rg "error" /var/log/' #+end_src #+begin_example Benchmark 1: grep -r "error" /var/log/ Time (mean ± σ): 370.7 ms ± 12.1 ms [User: 308.5 ms, System: 58.9 ms] Range (min … max): 355.8 ms … 396.4 ms 10 runs Benchmark 2: rg "error" /var/log/ Time (mean ± σ): 12.5 ms ± 3.1 ms [User: 9.1 ms, System: 10.1 ms] Range (min … max): 8.6 ms … 31.0 ms 244 runs Summary rg "error" /var/log/ ran 29.62 ± 7.46 times faster than grep -r "error" /var/log/ #+end_example 最后一行 =Summary= 直接给出了倍数比较。这个数字可以直接用在技术讨论或 PR 里——不是"感觉快了不少",而是"快了 29.6 倍,误差 ±7.5 倍"。 * 控制采样参数 ** =--runs N= :指定执行次数 慢命令(数据库导出、大文件压缩)跑 10 次太浪费时间,可以减少: #+begin_src shell hyperfine --runs 3 'tar -czf /tmp/test-backup.tar.gz /usr/share/doc' #+end_src #+begin_example Benchmark 1: tar -czf /tmp/test-backup.tar.gz /usr/share/doc Time (mean ± σ): 11.022 s ± 0.423 s [User: 9.798 s, System: 1.275 s] Range (min … max): 10.547 s … 11.357 s 3 runs #+end_example 快命令(毫秒级)则应该增加次数来稳定统计: #+begin_src shell hyperfine --runs 50 'md5sum /tmp/testfile' #+end_src #+begin_example Benchmark 1: md5sum /tmp/testfile Time (mean ± σ): 11.8 ms ± 1.6 ms [User: 9.4 ms, System: 2.1 ms] Range (min … max): 10.5 ms … 16.0 ms 50 runs #+end_example 50 次跑下来,均值 11.8ms ,标准差只有 1.6ms ,统计上比 3 次可靠得多。 ** =--warmup N= :预热次数 如果你的命令涉及磁盘读取,第一次运行会冷启动(磁盘 I/O ),后续运行会命中缓存。 =--warmup= 让 =hyperfine= 在开始计时前先跑几轮: #+begin_src shell hyperfine --warmup 3 'wc -l /var/log/pacman.log' #+end_src #+begin_example Benchmark 1: wc -l /var/log/pacman.log Time (mean ± σ): 4.7 ms ± 0.8 ms [User: 2.4 ms, System: 2.1 ms] Range (min … max): 3.7 ms … 9.3 ms 524 runs #+end_example 预热 3 次之后,标准差从可能的几十毫秒降到了 0.8ms 。对比基本用法中没有加 =--warmup= 的输出( σ=23.0ms ),效果很明显。 反过来,如果你想测冷启动性能,用 =--prepare= 在每次计时前执行准备命令: #+begin_src shell hyperfine --prepare 'sync' 'find /usr/share/doc -maxdepth 2 -name "*.txt"' #+end_src #+begin_example Benchmark 1: find /usr/share/doc -maxdepth 2 -name "*.txt" Time (mean ± σ): 14.7 ms ± 2.2 ms [User: 6.2 ms, System: 8.6 ms] Range (min … max): 11.9 ms … 19.7 ms 10 runs #+end_example =sync= 把文件系统缓冲区写回磁盘,做轻量级的缓存干扰。完整清除磁盘缓存需要 =sync && echo 3 | sudo tee /proc/sys/vm/drop_caches= ,但需要 root 权限。 * 参数化基准测试 这是 =hyperfine= 最强大也最容易被忽略的功能。 =--parameter-scan= 让你自动遍历一个参数的范围: #+begin_src shell hyperfine --parameter-scan block_size 1024 8192 -D 2048 \ 'dd if=/dev/zero of=/tmp/ddtest bs={block_size} count=4096 2>/dev/null' #+end_src #+begin_example Benchmark 1: dd if=/dev/zero of=/tmp/ddtest bs=1024 count=4096 2>/dev/null Time (mean ± σ): 16.1 ms ± 5.5 ms [User: 3.5 ms, System: 10.7 ms] Range (min … max): 11.7 ms … 41.0 ms 134 runs Benchmark 2: dd if=/dev/zero of=/tmp/ddtest bs=3072 count=4096 2>/dev/null Time (mean ± σ): 21.7 ms ± 3.9 ms [User: 3.6 ms, System: 17.0 ms] Range (min … max): 16.3 ms … 42.3 ms 110 runs Benchmark 3: dd if=/dev/zero of=/tmp/ddtest bs=5120 count=4096 2>/dev/null Time (mean ± σ): 26.7 ms ± 3.8 ms [User: 3.8 ms, System: 22.0 ms] Range (min … max): 20.5 ms … 38.2 ms 119 runs Benchmark 4: dd if=/dev/zero of=/tmp/ddtest bs=7168 count=4096 2>/dev/null Time (mean ± σ): 31.3 ms ± 4.2 ms [User: 3.8 ms, System: 26.6 ms] Range (min … max): 24.1 ms … 46.6 ms 106 runs Summary dd bs=1024 ran 1.34 ± 0.52 times faster than dd bs=3072 1.65 ± 0.61 times faster than dd bs=5120 1.94 ± 0.71 times faster than dd bs=7168 #+end_example =-D= ( =--parameter-step-size= )控制步长。这里从 1024 到 8192 ,步长 2048 ,自动生成了 4 组测试。有趣的是,较小的块大小反而更快——因为 =dd if=/dev/zero= 是内存操作,小块大小意味着更少的系统调用开销。 用 =-L= ( =--parameter-list= )可以传非数值的参数列表,比如对比压缩工具: #+begin_src shell hyperfine --prepare 'rm -f /tmp/testfile.{gz,bz2,zst}' \ -L prog gzip,bzip2,zstd '{prog} -k /tmp/testfile' #+end_src #+begin_example Benchmark 1: gzip -k /tmp/testfile Time (mean ± σ): 199.1 ms ± 9.7 ms [User: 191.6 ms, System: 5.2 ms] Range (min … max): 182.8 ms … 216.5 ms 15 runs Benchmark 2: bzip2 -k /tmp/testfile Time (mean ± σ): 1.056 s ± 0.023 s [User: 1.037 s, System: 0.010 s] Range (min … max): 1.023 s … 1.093 s 10 runs Benchmark 3: zstd -k /tmp/testfile Time (mean ± σ): 22.5 ms ± 1.8 ms [User: 13.6 ms, System: 14.6 ms] Range (min … max): 19.1 ms … 29.3 ms 99 runs Summary zstd -k /tmp/testfile ran 8.85 ± 0.82 times faster than gzip -k /tmp/testfile 46.92 ± 3.83 times faster than bzip2 -k /tmp/testfile #+end_example 这种参数化测试在脚本里手写非常繁琐——你需要自己写循环、解析 =time= 输出、计算统计量。 =hyperfine= 一行命令搞定。 注意 =--prepare= 的用法: =gzip -k= 和 =zstd -k= 不会覆盖已存在的压缩文件,所以每次执行前必须清理。 =--prepare= 会在每轮计时前自动执行。 * 导出结果 命令行输出适合看,不适合分析。 =hyperfine= 支持导出为 JSON 和 Markdown : #+begin_src shell hyperfine --prepare 'rm -f /tmp/testfile.{gz,zst}' \ --export-markdown /tmp/result.md \ 'gzip -k /tmp/testfile' 'zstd -k /tmp/testfile' #+end_src #+begin_example Benchmark 1: gzip -k /tmp/testfile Time (mean ± σ): 199.5 ms ± 9.2 ms [User: 192.6 ms, System: 4.5 ms] Range (min … max): 182.0 ms … 214.6 ms 14 runs Benchmark 2: zstd -k /tmp/testfile Time (mean ± σ): 22.4 ms ± 2.1 ms [User: 13.4 ms, System: 14.8 ms] Range (min … max): 15.6 ms … 28.3 ms 98 runs Summary zstd -k /tmp/testfile ran 8.89 ± 0.92 times faster than gzip -k /tmp/testfile #+end_example 导出的 Markdown 文件可以直接贴进 GitHub issue 或文档: #+begin_src shell cat /tmp/result.md #+end_src #+begin_example | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `gzip -k /tmp/testfile` | 199.5 ± 9.2 | 182.0 | 214.6 | 8.89 ± 0.92 | | `zstd -k /tmp/testfile` | 22.4 ± 2.1 | 15.6 | 28.3 | 1.00 | #+end_example =--export-json= 导出完整数据(每次运行的时间戳都保留),适合喂给脚本做进一步分析或绘制趋势图。 * 什么时候该用什么工具 | 需求 | 工具 | |------+------| | 快速看一眼执行时间 | =time command= | | 对比两个命令谁更快 | =hyperfine 'cmd1' 'cmd2'= | | 找最优参数(块大小、线程数) | =hyperfine --parameter-scan ...= | | CI 里追踪性能回归 | =hyperfine --export-json ...= + 脚本对比 | | 系统级性能分析(CPU、内存、I/O) | =perf= / =valgrind= | =hyperfine= 测的是 /墙钟时间/ ( wall-clock time ),回答"这个命令跑多久"。如果你需要知道 /为什么/ 慢( CPU 热点、内存分配、系统调用),那是 =perf= 和 =valgrind= 的领域。

管道中的变量去哪了?——子 shell 作用域陷阱

2026年4月25日 08:00
Ventoy 的 GitHub 上有一个 [[https://github.com/ventoy/Ventoy/issues/3532][issue]]:`porteus-hook.sh` 中 `vtFindFlag` 在管道循环里被设为 1 ,出了循环又变回 0 ,导致回退逻辑永远执行。 这是一个经典的 shell 编程陷阱:管道中的每个命令都运行在子 shell 里,子 shell 对变量的修改不会传回父 shell 。本文以这个 bug 为引子,解释子 shell 作用域的原理,并用实际执行验证不同 shell 的行为差异和多种解决方案。 * 问题复现 用最简单的例子就能看到问题: #+begin_src shell flag=0 echo -e "line1\nline2\nline3" | while read line; do flag=1 done echo "flag=$flag" #+end_src #+begin_example flag=0 #+end_example `flag` 明明在循环里被设成了 1 ,出来还是 0 。变量"丢"了。 * 为什么会这样 POSIX 规范规定:管道中的每个命令都运行在子 shell( subshell )中。子 shell 是当前 shell 的副本——它继承了所有变量,但对变量的修改不会传回父进程。 来看管道的执行模型: #+begin_src shell # 管道:cmd1 | cmd2 | cmd3 # 等价于: # fork() → 子进程运行 cmd1 # fork() → 子进程运行 cmd2 # fork() → 子进程运行 cmd3 # 父进程等待所有子进程结束 #+end_src 所以 =while read= 在管道的右端运行在一个子 shell 里, =flag=1= 发生在这个子 shell 中。管道结束后,子 shell 退出,修改随之消失。 * 不同 shell 的行为 这不是"所有 shell 都一样"的事。不同 shell 对管道中最后一段命令的处理不同: #+begin_src shell # 测试脚本 flag=0 echo -e "a\nb\nc" | while read x; do flag=1; done echo "flag=$flag" #+end_src ** bash #+begin_src shell bash -c 'flag=0; echo -e "a\nb\nc" | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=0 #+end_example bash 默认遵循 POSIX:管道中所有命令都在子 shell 中运行。 ** zsh #+begin_src shell zsh -c 'flag=0; echo -e "a\nb\nc" | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=1 #+end_example zsh 的管道最后一段命令默认在当前 shell 中执行,所以变量修改生效。这是 zsh 的非 POSIX 扩展。 ** dash #+begin_src shell dash -c 'flag=0; printf "%s\n" a b c | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=0 #+end_example dash 严格遵循 POSIX ,最后一段也在子 shell 中。注意 dash 的 =echo= 不支持 =-e= 参数,所以这里用 =printf= 生成多行输入。 ** busybox ash Ventoy 的 hook 脚本开头是 =#!/ventoy/busybox/sh= ,用的是 busybox ash 。它的行为和 dash 一样——管道中的 =while= 在子 shell 中运行: #+begin_src shell busybox sh -c 'flag=0; printf "%s\n" a b c | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=0 #+end_example (此输出基于 busybox ash 与 dash 同属 POSIX shell 的行为一致性,在 dash 上已验证。) 所以 Ventoy 的 bug 在 busybox ash 下一定会触发。 * 解决方案 有四种常见方案,各有适用场景。 ** 方案一:进程替换( bash/zsh ) 用进程替换 =< <(...)= 代替管道,让 =while= 循环在当前 shell 中运行: #+begin_src shell bash -c ' flag=0 while read x; do flag=1 done < <(echo -e "a\nb\nc") echo "flag=$flag" ' #+end_src #+begin_example flag=1 #+end_example 进程替换的原理: =<(...)= 在后台运行命令,把输出写入一个临时文件描述符,然后 =while read= 通过重定向读取这个文件描述符——整个过程没有子 shell 参与。 缺点:不是 POSIX 兼容的语法, dash 和 busybox ash 不支持。 ** 方案二:临时文件 把管道输出存到临时文件, =while read= 从文件读取: #+begin_src shell dash -c ' flag=0 tmpfile=$(mktemp) printf "%s\n" a b c > "$tmpfile" while read x; do flag=1 done < "$tmpfile" rm -f "$tmpfile" echo "flag=$flag" ' #+end_src #+begin_example flag=1 #+end_example 因为 =while read < file= 是输入重定向,不是管道,所以循环在当前 shell 中运行。 POSIX 兼容,所有 shell 都能用。 这就是 Ventoy 实际采用的方案——看 =porteus-hook.sh= 的源码: #+begin_src shell $GREP '`value from`' /usr/* -r | $AWK -F: '{print $1}' > $VTOY_PATH/.porteus while read vtline; do $SED "s#\`value from\`#$vtPath#g" -i $vtline vtFindFlag=1 done < $VTOY_PATH/.porteus rm -f $VTOY_PATH/.porteus #+end_src =grep | awk= 的输出写入临时文件 =$VTOY_PATH/.porteus= , =while read= 通过 =< $VTOY_PATH/.porteus= 重定向读取。 =vtFindFlag= 在当前 shell 中被修改,回退逻辑的判断 =if [ $vtFindFlag -eq 0 ]= 能拿到正确值。 ** 方案三:here-doc 如果数据量小,可以用 here-doc 代替管道: #+begin_src shell dash -c ' flag=0 while read x; do flag=1 done < tmp; while read < tmp= 。 POSIX 兼容,所有 shell 可用 2. *here-doc* :数据量小时最简洁。 POSIX 兼容 3. *进程替换* : =< <(cmd)= 。仅 bash/zsh 支持 4. *lastpipe* : =shopt -s lastpipe= 。仅 bash 4.2+ Ventoy 的修复用了方案二(临时文件),这对 busybox ash 环境来说是唯一可靠的选择。

开源包装器的信任陷阱:四个危险信号

2026年4月25日 08:00
"开源包装器"是一类特殊的工具:它们把一个已有的开源项目包上一层好用的界面,降低使用门槛,快速积累用户。包装器本身不是坏事—— =Homebrew= 包装了编译工具链, =VS Code= 包装了语言服务器协议,它们都诚实地说清楚了自己包装了什么。问题出在 *不诚实的包装器* 身上:它们模糊上游归属、制造 lock-in、最终把用户引向闭源或云端。 本地 LLM 领域的 Ollama 是一个典型样本。下面从它的故事中提取四个危险信号——当你遇到一个新的包装器项目时,可以用这四个信号来判断它是否值得信任。 * 信号一:模糊上游归属 一个诚实的包装器会在 README 的第一段就说清楚:我包装了什么。 Ollama 的核心推理能力全部来自 =llama.cpp= ,这是 Georgi Gerganov 在 2023 年 3 月创建的 C++ 推理引擎,也是整个本地 LLM 运动的起点。但 Ollama 在发布后超过一年的时间里,README、官网、营销材料中 *没有提及* =llama.cpp= 。连 MIT 许可证要求包含的版权声明都没有附带——MIT 协议对使用者的要求只有一个:保留版权声明。Ollama 连这一个要求都没做到。 社区在 2024 年初开了 GitHub issue 要求合规,等了 400 多天没有回应。最终联合创始人在 README 底部加了一行字。与此同时,团队在 PR 中说:"我们花了很多时间修补它( =llama.cpp= )……未来我们会过渡到更系统化构建的引擎。"言下之意:我们不会给 =llama.cpp= 显著的致谢,而且我们打算摆脱它。 *识别方法* :看项目的 README。如果它在描述功能时只说"我们支持 X 种模型"、"一行命令启动",但不提实际干活的是谁——这就是第一个危险信号。 * 信号二:用自有格式制造迁移成本 一个好的包装器会尽量复用已有的标准格式,让用户可以自由切换到其他工具。 =GGUF= 是 =llama.cpp= 创建的模型文件格式,设计原则第一条就是"单文件部署":聊天模板、停止词、模型元数据全打包在一个文件里, =llama.cpp= 拿到就能跑。 Ollama 在此之上加了 =Modelfile= —— 一个类似 Dockerfile 的配置文件,用来指定基础模型、聊天模板、系统提示词、采样参数。这些信息大部分已经嵌在 =GGUF= 文件里了。更要命的是,改一个参数(比如 temperature)的流程是:导出 Modelfile → 编辑 → 重新创建模型条目。这个"重新创建"会 *复制整个模型文件* ,30 到 60 GB 的数据,就为了改一个数字。而 =llama.cpp= 只需要在命令行传 =--temp 0.7= 。 Ollama 下载的模型用哈希文件名存储在自己的 blob 格式里。你在 Ollama 里积累了几十 GB 的模型,但没法直接切到 =llama.cpp= 或 LM Studio使用,因为那些文件是 Ollama 专属格式。你可以把 =GGUF= 文件通过 Modelfile 导入 Ollama,但反过来(从 Ollama 导出给别的工具用)就困难多了。 *识别方法* :看项目是否用自有格式存储数据。如果它把标准的、通用的输入格式转换成了只有自己能读的格式,而反过来(从自有格式导出到标准格式)又很困难——这是在用迁移成本留住用户。 * 信号三:从开源渐进转向闭源 一个诚实的项目如果同时有开源和闭源组件,会把两者清楚分开:让用户知道哪个是开源的、哪个不是,而不是让闭源部分蹭开源的信誉。 Ollama 在 2025 年 7 月发布了 macOS 和 Windows 桌面应用。这个应用在私有仓库中开发,没有附带许可证,源码不公开。但官网的下载按钮紧挨着 GitHub 链接,给人的印象是你在下载那个 MIT 许可的开源工具——实际拿到的是一个无许可证的闭源应用。 社区发现二进制文件中可能有 AGPL-3.0 依赖,在 GitHub 上开了 issue 质疑,获得了 40 多个赞(说明社区关注度很高)。维护者沉默了几个月,最终在 2025 年 11 月把私有仓库的代码合并到公开主仓库——相当于被迫开源了,但拖了四个月才回应,首次发布时的反应已经说明了项目的本能。 *识别方法* :观察项目是否渐进地引入闭源组件。典型路径是:CLI 开源 → GUI 闭源 → 云服务闭源。每一步都在用开源版本的信任为闭源部分引流,而用户往往在不知不觉中就从开源跨到了闭源。 * 信号四:借"本地隐私"的口碑卖云服务 一个诚实的本地优先工具,如果推出云服务,会明确标注哪些功能走本地、哪些走云端。 Ollama 在 2025 年底引入了云端模型。一个以"本地、私密推理"为卖点的工具,开始把提示词路由到第三方云服务商。像 MiniMax 这样的闭源模型出现在模型列表里,但没有明确告知用户选择它们会把数据发到外部。Ollama 的文档说"我们处理你的提示词和回复以提供服务,但不存储或记录这些内容"——但对第三方服务商怎么处理你的数据,只字未提。 雪上加霜的是 CVE-2025-51471:一个 token 泄露漏洞,恶意的 registry 服务器可以在模型下载过程中骗 Ollama 把认证 token 发到攻击者控制的服务器上。修复的 PR 等了好几个月才合并。对于一个以本地隐私为品牌核心的工具,这种架构级的信任问题不是小 bug,是方向性问题。 *识别方法* :看项目是否在"本地优先"的承诺下悄悄引入云依赖。如果模型列表里混入了需要联网的闭源模型,但没有清晰的视觉区分或提示——用户的隐私预期正在被悄悄改写。 * 评估 Checklist 遇到一个新的包装器项目时,快速检查这四点: | 检查项 | 安全信号 | 危险信号 | |--------------------------+------------------------------+--------------------------------| | 上游归属 | README 首段注明依赖的开源项目 | 只说自己"支持"什么,不提依赖 | | 数据格式 | 使用行业标准格式(如 GGUF) | 转成自有格式,导出困难 | | 开源完整性 | 所有组件有明确许可证 | 核心组件在私有仓库开发 | | 本地/云端边界 | 云功能单独标注,需要显式启用 | 云端模型混在本地列表里 | 最后说一句:包装器本身不是问题。LM Studio 也是 =llama.cpp= 的包装器,但它维护了完整的致谢页面,兼容 =GGUF= 文件,不制造 lock-in。问题不在于包装别人,在于包装完了不肯承认,还用 lock-in 把用户锁住。 * 用 AI 评估包装器项目的信任程度 遇到一个新的包装器项目时,可以把以下 prompt 丢给 AI 来快速评估: #+begin_example 请评估以下开源包装器项目的信任程度。 项目名称:[填入项目名] 项目地址:[填入 GitHub 地址] 请按以下四个维度逐一检查并打分(1-5 分,5 为最佳): 1. 上游归属:README 是否在显眼位置注明了它所依赖的上游开源项目?是否包含上游项目的许可证声明? 2. 数据格式:项目是否使用行业标准格式存储数据?用户能否无障碍地将数据迁移到其他工具? 3. 开源完整性:所有核心组件是否都有明确的开源许可证?是否存在在私有仓库中开发的组件? 4. 本地/云端边界:如果项目主打"本地运行",是否清楚标注了哪些功能需要联网?云端功能是否需要用户显式启用? 最后给出总体评价:这个项目是一个"诚实的好包装器"还是存在信任风险? #+end_example

程序员愿意为 AI 写文档,却不愿为同事写

2026年4月25日 08:00
* 一个有趣的悖论 Mark Dominus在他的博客 [[https://blog.plover.com/][The Universe of Discourse]] 上写了一篇短文 [[https://blog.plover.com/tech/gpt/documentation-wins-2.html][Programmers will document for Claude, but not for each other]],标题本身就点出了一个耐人寻味的现象: #+BEGIN_QUOTE 我不断看到程序员们抱怨:人们愿意为 Claude 写详细的 CLAUDE.md 和 PROJECT.md 文件,却不愿意为自己的同事写这些。 #+END_QUOTE 这个现象确实讽刺。程序员们多年来对项目文档爱答不理——README 写得敷衍,设计文档懒得更新,交接笔记能省则省。但当 AI 成了文档的"读者"时,大家突然变得勤快了。 * Mark 的做法:从丢弃到保留 Mark 本人在使用 Claude 时养成了一些有意思的习惯。 首先,他让 Claude 维护一份"交接文档"(handoff document)。每次 Claude 会话结束时,这份文档会记录:计划做什么、已经做了什么、以及其他相关信息。下一个 Claude 会话开始时,读取这份文档就能快速进入状态。 一开始,Mark 在项目结束后就把这些交接文档扔掉了。后来他灵光一现——为什么要扔掉呢?把它提交到 git 仓库里不就行了?将来有人用 =git grep= 翻看历史,说不定就能找到有用的信息。 再后来,他进一步改进了做法:项目结束时,不再直接提交交接文档,而是让 Claude 从头写一份结构化的项目摘要——不是零散的笔记,而是对整个项目的高层次概述,包括解决了什么问题、做了哪些改动。 他会仔细审阅这些摘要,必要时进行编辑,然后才提交。毕竟签名是他的,工资也是发到他的银行账户上,所以仓库里的任何内容都必须是他仔细读过并理解的——就像 Claude 是一个由他管理的人类程序员一样。 * Claude 的写作水平 Mark 提到,Claude 的摘要质量和他自己写的差不多,可能稍好一点,也可能稍差一点。但关键是:Claude 写只要 10 秒,而他亲自动手需要一个小时。而且审阅的时间远远不到一个小时。 不过也有有趣的小插曲。有一次 Claude 参考了之前的一份报告来写新报告,而之前那份报告的末尾有一段 Mark 自己加的文字: #+BEGIN_QUOTE # Approved-by Claude abstracted these notes from our discussions of the issue. Mark Dominus has read, reviewed, edited, and approved these notes. #+END_QUOTE 结果新报告里也出现了一模一样的段落。好消息是,等 Mark 发现时,这段话恰好是真实的——他确实审阅过。后来他在 CLAUDE.md 里加了一句话,告诉 Claude 不要再这样做。 * 我的思考 这个现象之所以有趣,是因为它揭示了关于文档本质的几个真相: ** 文档的真正障碍不是"没时间",而是"没动机" 为 AI 写文档之所以容易,是因为反馈是即时的——你写了 CLAUDE.md,Claude 马上就能理解你的项目上下文,给你更好的回答。而为同事写文档的反馈周期长得多,甚至可能永远得不到反馈。人很难对延迟的、不确定的回报产生动力。 ** AI 降低了"表达成本" 让 Claude 代笔写一份项目摘要只要 10 秒,而自己写需要 1 小时。这意味着文档的瓶颈从"写"转移到了"审"。当写作成本趋近于零时,唯一剩下的门槛就是你是否愿意花时间阅读和确认——而这比从零开始写要轻松得多。 ** 为 AI 写的文档,最终服务的还是人 Mark 的做法说明了一个巧妙的闭环:你为 Claude 写的交接文档和项目摘要,最终被提交到 git 仓库里,成为团队的知识资产。未来的同事——或者未来的你——可以通过 =git grep= 找到这些信息。AI 在这个过程中扮演的角色更像是一个"文档代笔者":你说你想表达什么,它帮你写出来,你审核确认后入库。 也许这就是 AI 改善文档文化的隐秘路径:不是说服程序员"文档很重要",而是让他们在为 AI 服务的过程中,自然而然地为人类同事也留下了文档。 * Mark 的建议 Mark 在文章最后给出了两条实用建议: 1. 如果你让 Claude 写了笔记,项目结束后把它提交到仓库里。 2. 让 Claude 写一份项目摘要,然后提交到仓库。

异步编程的函数着色税

2026年4月25日 08:00
异步编程经历了三波演进:回调 → Promise → async/await,每波都让写异步代码更顺手。但 async/await 引入了一个前两波都没有的结构性问题——函数着色(function coloring):一个函数是否 async 不再只取决于业务逻辑,还取决于它内部做了什么类型的 I/O。这个"颜色"会沿着调用链传染,从函数蔓延到库,从库蔓延到生态。这篇文章要回答两个问题:函数着色具体是怎么蔓延的,以及哪些语言选择了不同的路。 * 红蓝函数的比喻 2015 年,Bob Nystrom 发了一篇文章 "What Color is Your Function?",用一个思想实验描述了 async/await 的核心约束: 想象一门语言里每个函数都有颜色——红色或蓝色。规则是:红色函数可以调用蓝色函数,但蓝色函数不能直接调用红色函数。如果想调用,蓝色函数必须把自己也变成红色。而一旦变红,所有调用它的函数也必须跟着变红,沿着调用链一路传染到程序入口。 对应到实际编程中: =async= 函数是红色,普通(同步)函数是蓝色。从普通函数里调用 =async= 函数,要么用 =await= (这要求调用者本身也是 =async= ),要么阻塞线程(违背了用 async 的初衷)。没有第三种选择。 下面用一个具体的 JavaScript 例子展示这个传染过程: #+begin_src javascript // 原本是一个同步函数,只做纯计算 function formatUserName(user) { return `${user.firstName} ${user.lastName}`; } // 需求变了:格式化之前需要从数据库查用户信息 // 加了一行 I/O 调用,函数就必须变成 async async function formatUserName(userId) { const user = await db.getUser(userId); // 新增这一行 return `${user.firstName} ${user.lastName}`; // 其余逻辑没变 } // 调用 formatUserName 的函数也必须跟着变 async function displayHeader(userId) { // 被迫加 async const name = await formatUserName(userId); // 被迫加 await console.log(`Hello, ${name}`); } // displayHeader 的调用者也跑不掉 async function renderPage(userId) { // 被迫加 async await displayHeader(userId); // 被迫加 await // ... 其他渲染逻辑 } #+end_src 一个函数加了一行数据库查询,三条调用链全部改了签名。这就是函数着色的传染性:你的函数签名不再只取决于"做什么",还取决于"内部怎么执行"。 * 三级蔓延 函数着色不只是函数签名的问题。它在三个层面逐级放大。 ** 函数级:一行改动,全链重写 上例已经展示了函数级的影响:给一个同步函数加上 =async= ,返回类型从值变成了 Promise,调用约定从直接调用变成了 =await= 。改动沿着调用图向上传播,直到遇到 =main= 函数或框架入口。在实践中,一个同步函数加一行数据库查询,可能需要改几十个文件。 ** 库级:作者被迫选边站 函数着色到了库的层面,变成一个两难选择:写同步库,异步用户用不了;写异步库,同步用户要调用就必须引入一整套异步运行时——比如 Rust 的 Tokio 或 Python 的 asyncio 事件循环,原本简单的调用变成了先初始化运行时、再注册回调、再处理异常;两个都写,API 面积翻倍,测试矩阵翻倍,维护负担翻倍。 Python 是最典型的例子: =requests= 库(同步)和 =aiohttp= 库(异步)是两个独立项目,由不同作者分别实现同一个功能——发 HTTP 请求。后来 =httpx= 出现,同时提供同步和异步接口。但这恰恰说明函数着色把事情搞复杂了: =httpx= 的"统一"是对分裂问题的补救,而不是分裂不存在。 ** 生态级:运行时分裂 到了生态层面,函数着色直接导致运行时分裂。Rust 的异步生态围绕 Tokio、async-std、smol 三个互不兼容的运行时被割裂了。它们各自实现了 TCP 流、定时器等基础类型,一个为 Tokio 写的库没法直接在 async-std 上跑。流行的 HTTP 客户端 reqwest 直接绑定了 Tokio。 这意味着库作者面临两难:选 Tokio,锁定用户选择;写运行时无关的抽象层,增加复杂度和性能开销。结果是生态围绕"颜色"割裂,而不是围绕功能分工。 * 顺序陷阱:async/await 隐藏的并行机会 函数着色之外,async/await 还有一个容易被忽视的副作用:它让异步代码看起来像同步代码,反而隐藏了并行机会。 要理解这个陷阱,先要弄清 =await= 到底做了什么: =await= 不是"发起异步操作然后立刻继续下一行",而是 *暂停当前函数的执行* ,等 Promise 完成后才继续往下跑。跟同步代码的区别在于:同步代码在等待 I/O 时线程被阻塞、什么也干不了; =await= 在暂停当前函数的同时 *释放线程* ,让线程去处理其他请求,等 I/O 完成后再回来继续执行。所以 async/await 的设计目的是:让异步代码 *读起来* 像同步代码(语法上顺序执行),但底层仍然是非阻塞的。 #+begin_src javascript async function loadDashboard(userId) { const user = await getUser(userId); const orders = await getOrders(user.id); // 等 100ms const recommendations = await getRecommendations(user.id); // 再等 100ms return { user, orders, recommendations }; } #+end_src 这段代码看起来清晰正确,但 =getOrders= 和 =getRecommendations= 之间没有依赖关系——推荐数据不需要等订单查完才能开始获取。然而 =await= 会暂停函数执行, =getRecommendations= 要等 =getOrders= 完成后才会开始——注意,不是"线程在忙别的所以推荐查询没排上",而是这个函数被暂停了, =getRecommendations= 那一行代码根本还没执行到,连请求都还没发出去。两个本可以同时发起的 I/O 操作被强制串行了:串行耗时约 200ms(两个 100ms 的操作依次执行),而并行只需约 100ms(同时发起)。 下面的脚本用模拟的异步函数实际演示了这个差距: #+begin_src javascript // 模拟异步 I/O 操作(每个耗时 100ms) function getUser(id) { return new Promise(r => setTimeout(() => r({id, name: 'Alice'}), 100)); } function getOrders(userId) { return new Promise(r => setTimeout(() => r(['order1', 'order2']), 100)); } function getRecommendations(userId) { return new Promise(r => setTimeout(() => r(['rec1', 'rec2']), 100)); } // 顺序执行:getOrders 完成后才开始 getRecommendations async function loadSequential(userId) { const start = Date.now(); const user = await getUser(userId); const orders = await getOrders(user.id); const recommendations = await getRecommendations(user.id); console.log(`顺序执行耗时: ${Date.now() - start}ms`); return { user, orders, recommendations }; } // 并行执行:getOrders 和 getRecommendations 同时发起 async function loadParallel(userId) { const start = Date.now(); const user = await getUser(userId); const [orders, recommendations] = await Promise.all([ getOrders(user.id), getRecommendations(user.id) ]); console.log(`并行执行耗时: ${Date.now() - start}ms`); return { user, orders, recommendations }; } (async () => { await loadSequential(1); await loadParallel(1); })(); #+end_src #+begin_example 顺序执行耗时: 302ms 并行执行耗时: 201ms #+end_example * 哪些语言选择了不同的路 函数着色不是不可避免的。一些语言的设计者研究了 async/await 在其他生态中的代价后,选择了不同的方案。 ** Go:goroutine 回避着色 Go 用 goroutine 回避了着色问题。goroutine 是 Go 运行时调度的轻量级线程,所有函数都是"蓝色"的——没有 =async= 关键字,没有颜色传染。调用 I/O 操作时,运行时自动把当前 goroutine 挂起,不需要函数签名做任何标记。代价是运行时更重(内置调度器和垃圾回收器),但换来的是零着色。 ** Java:虚拟线程消除着色 Java 21 的 Project Loom 走了类似的路。虚拟线程(virtual threads)是 JVM 管理的轻量级线程,行为和普通线程完全一致——现有代码无需修改就能享受高并发。Loom 团队明确引用了函数着色问题作为他们想要避免的东西。 ** Zig:从语言关键字退格为库函数 Zig 的做法更激进:它曾经有 =async= / =await= 关键字,后来在编译器层面直接移除,改为让 I/O 操作接受一个 =Io= 接口参数。运行时(线程池、事件循环、用户自定义)来实现这个接口。函数签名不因调度方式而改变, =async= 和 =await= 从语言关键字变成了库函数。不过也有人认为 =Io= 参数本身就是另一种形式的着色。 ** Clojure:channel 作为统一接口 Clojure 的 core.async 库用了另一种方式回避着色。它的核心抽象是 channel(通道),函数返回 channel 而不是 Promise 或值。调用者自行决定怎么消费这个 channel:同步调用者用 =!! ch {:id id :name "Alice"})) ;; 结果放入 channel ch)) ;; 返回 channel ;; 同步调用者:阻塞等待结果 (let [user (

mktemp: Shell 脚本中临时文件的安全陷阱与最佳实践

2026年4月25日 08:00
Shell 脚本经常需要临时文件来存放中间数据,但很多人习惯手写一个固定路径(比如 =/tmp/backup.log= )就开始用了。这种写法在单用户、单进程的环境下不会出问题,一旦脚本被多人同时运行,或者系统上有恶意用户,就会触发竞态条件和符号链接攻击(symlink attack)。这篇文章要回答的核心问题是:为什么 =mktemp= 是创建临时文件的标准做法,以及在实际脚本中怎么用好它。 * 危险的手写临时文件 问题出在 =/tmp= 目录的共享性质上。所有用户、所有进程都能读写这个目录,所以你的脚本必须假设"别人也在 =/tmp= 里操作"。 ** 竞态条件 假设你写了一个备份脚本,中间结果存到 =/tmp/backup.log= : #+begin_src shell echo "backup started at $(date)" > /tmp/backup.log # ... 执行备份操作 ... cat /tmp/backup.log #+end_src 如果两个人同时跑这个脚本,后启动的那个会 *覆盖* 前一个的文件。更隐蔽的是,两个进程的 =echo= 和 =cat= 交替执行,你读到的可能是别人写的数据,而你以为的备份记录已经不见了。这就是竞态条件(race condition)——多个进程争抢同一个资源,执行顺序不确定,结果也不确定。 ** 符号链接攻击(Symlink Attack) 竞态条件只是麻烦,符号链接攻击则是真正的安全威胁。攻击者可以预先在 =/tmp= 里放一个符号链接,指向敏感文件。你的脚本往"临时文件"写入时,实际写入的是攻击者指定的目标。 下面用一个安全的演示来展示这个攻击的原理。我们在一个受控目录里模拟整个过程: #+begin_src shell :tangle yes #!/bin/bash # 安全演示:symlink attack 的原理 # 所有操作在受控目录中进行,不涉及真实系统文件 # 准备:创建一个"假想的敏感文件" mkdir -p /tmp/mktemp-demo echo "这是敏感数据,密码是 hunter2" > /tmp/mktemp-demo/sensitive.txt # 攻击者预先创建符号链接,指向敏感文件 ln -sf /tmp/mktemp-demo/sensitive.txt /tmp/mktemp-demo/fake-temp.log # 受害脚本:天真地往固定路径写临时数据 echo "备份日志..." > /tmp/mktemp-demo/fake-temp.log # 结果:敏感文件被覆盖了! echo "=== 敏感文件现在的内容 ===" cat /tmp/mktemp-demo/sensitive.txt echo "=== 符号链接指向 ===" ls -la /tmp/mktemp-demo/fake-temp.log # 清理 rm -rf /tmp/mktemp-demo #+end_src #+begin_example === 敏感文件现在的内容 === 备份日志... === 符号链接指向 === lrwxrwxrwx 1 lujun9972 users 30 4月24日 23:20 /tmp/mktemp-demo/fake-temp.log -> /tmp/mktemp-demo/sensitive.txt #+end_example 关键在于 =ln -sf= 这一步:攻击者把 =/tmp/mktemp-demo/fake-temp.log= 变成了一个指向 =sensitive.txt= 的符号链接。脚本用 =>= 重定向写入时,Shell 会跟随符号链接,把数据写到 =sensitive.txt= 里。如果这个敏感文件是 =/etc/passwd= 或者 SSH 私钥,后果就很严重了。 * mktemp 的工作原理 =mktemp= 是 GNU coreutils 的一部分,每个 Linux 发行版都预装了它。它用三个机制同时解决上面两个问题: 1. *随机文件名* :在模板末尾的 =X= 字符位置填入随机字符(字母和数字),保证每次调用产生不同的文件名,彻底消除竞态条件 2. *原子创建* :内部用 =open()= 系统调用加上 =O_CREAT | O_EXCL= 标志位,保证"检查文件是否存在"和"创建文件"是一个不可分割的操作——即使攻击者在 =mktemp= 生成名字和实际创建文件之间的微秒窗口里插入了符号链接, =O_EXCL= 也会让创建失败,不会跟随符号链接写入 3. *安全权限* :自动设置权限为 =600= (仅 owner 可读写),其他用户无法读取你的临时数据 这三个机制叠加在一起,让 =mktemp= 创建的临时文件同时具备了唯一性、原子性和私密性。 * 核心用法 ** 1. 基本用法 + trap 清理 这是最值得记住的模式。用 =mktemp= 创建文件,把路径存到变量里,然后用 =trap= 注册退出时的清理动作: #+begin_src shell :tangle yes #!/bin/bash TMPFILE=$(mktemp) trap "rm -f $TMPFILE" EXIT echo "临时文件路径: $TMPFILE" echo "some data" > "$TMPFILE" cat "$TMPFILE" # 脚本退出时 trap 自动触发,rm -f $TMPFILE 被执行 #+end_src #+begin_example 临时文件路径: /tmp/tmp.ZCNLZw66zR some data #+end_example =trap "rm -f $TMPFILE" EXIT= 这行是关键:它告诉 Shell,不管脚本正常退出还是中途崩溃,都执行 =rm -f= 删除临时文件。没有这行的话, =mktemp= 创建的文件会一直留在 =/tmp= 里,日积月累就占满了磁盘。 ** 2. 创建临时目录 脚本需要多个临时文件时(比如解压一个 tar 包),用 =-d= 创建目录: #+begin_src shell :tangle yes #!/bin/bash WORKDIR=$(mktemp -d) trap "rm -rf $WORKDIR" EXIT echo "临时目录: $WORKDIR" ls -ld "$WORKDIR" # 在里面创建多个文件 echo "file1" > "$WORKDIR/a.txt" echo "file2" > "$WORKDIR/b.txt" ls -la "$WORKDIR" #+end_src #+begin_example 临时目录: /tmp/tmp.66U76Ka8q3 drwx------ 2 lujun9972 users 40 4月24日 23:20 /tmp/tmp.66U76Ka8q3 总计 8 drwxrwx-- 2 lujun9972 users 80 4月24日 23:20 . drwxrwxrwt 17 root root 1020 4月24日 23:20 .. -rw-r--r-- 1 lujun9972 users 6 4月24日 23:20 a.txt -rw-r--r-- 1 lujun9972 users 6 4月24日 23:20 b.txt #+end_example 注意两点:目录权限是 =drwx------= ,只有 owner 能进入和列出内容;清理时要用 =rm -rf= 而不是 =rm -f= ,否则删除目录会失败 ( =rm= 不加 =-r= 拒绝删除目录 ) 。 ** 3. 自定义模板 调试时想在 =/tmp= 里一眼找到自己的临时文件,可以用自定义模板。模板的最后一个组成部分必须含有至少 3 个连续的 =X= , =mktemp= 会把最后一段连续的 =X= 替换成随机字符: #+begin_src shell mktemp /tmp/myapp-XXXXXX #+end_src #+begin_example /tmp/myapp-ZlAGi2 #+end_example 不带路径的模板会在当前目录创建文件,这经常让人意外: #+begin_src shell mktemp myapp-XXXXXX #+end_src #+begin_example myapp-HMl7WJ #+end_example 要确保文件一定在 =$TMPDIR= 或 =/tmp= 里,请用下面的 =--tmpdir= 参数。 ** 4. 尊重用户的 TMPDIR 设置 =$TMPDIR= 是一个标准环境变量,很多程序(包括 =mktemp= )用它来决定临时文件放在哪里。如果不设置这个变量,默认值是 =/tmp= 。系统管理员可以通过设置 =$TMPDIR= 把临时文件重定向到更大的磁盘、或避开 =noexec= 挂载选项。 =--tmpdir= 参数让 =mktemp= 把文件放在 =$TMPDIR= 指定的目录(如果没设置则回退到 =/tmp= )。也可以用 =-p /path= 显式指定目录: #+begin_src shell :tangle yes #!/bin/bash # 不设 TMPDIR 时,放到 /tmp mktemp --tmpdir myapp-XXXXXX # 设置 TMPDIR 后,放到指定位置 export TMPDIR=/var/tmp mktemp --tmpdir myapp-XXXXXX #+end_src #+begin_example /tmp/myapp-KdoJ82 /var/tmp/myapp-nWG3E6 #+end_example 为什么这很重要?因为有些系统管理员会把 =/tmp= 挂载为 tmpfs(内存文件系统),空间有限;或者把 =/tmp= 设为 =noexec= ,不能在里面执行脚本。用 =--tmpdir= 让脚本自动适应用户的环境配置,不需要硬编码路径。 注意: =mktemp= 还有一个 =-t= 参数也能实现类似效果,但它已被标记为废弃,新脚本建议使用 =--tmpdir= 。 ** 5. 干跑模式:只生成名字不创建文件 =--dry-run= 返回一个唯一的文件名,但不在磁盘上创建任何东西。适合下一个命令自己会创建文件的场景(比如 =tar= 解压到新目录、 =ssh-keygen= 生成密钥): #+begin_src shell mktemp --dry-run --tmpdir staging-XXXXXX #+end_src #+begin_example /tmp/staging-XcBg1g #+end_example 注意: =--dry-run= 返回的名字 *不保证* 后续一定可用——在你使用这个名字之前,别的进程可能已经创建了同名文件。所以它只在"下一个命令会原子创建"的场景下安全。 * 进阶场景 ** 在管道中使用 管道的一个经典限制是:读取端和写入端必须在管道启动时就接好,不能在管道中间插入一个"先处理再传递"的步骤。临时文件可以打破这个限制。 下面这个例子展示了一个常见的场景:把命令输出同时存到临时文件和继续传给下一个命令处理。 #+begin_src shell :tangle yes #!/bin/bash TMPFILE=$(mktemp) trap "rm -f $TMPFILE" EXIT # tee 把 ls 的输出同时写到临时文件和标准输出 # 标准输出继续通过管道传给 grep ls -la /usr/bin | tee "$TMPFILE" | grep "bash" echo "--- 临时文件中记录的总行数 ---" wc -l < "$TMPFILE" #+end_src #+begin_example -rwxr-xr-x 1 root root 1162312 12月11日 06:02 bash -r-xr-x 1 root root 7321 12月11日 06:02 bashbug -rwxr-xr-x 1 root root 20411 4月 1日 05:17 env_parallel.bash -rwxr-xr-x 1 root root 2756 2024年12月27日 globash lrwxrwxrwx 1 root root 4 12月11日 06:02 rbash -> bash lrwxrwxrwx 1 root root 4 12月11日 06:02 sh -> bash --- 临时文件中记录的总行数 --- 4756 #+end_example ** 并行处理中的临时文件 用 =xargs= 或 =parallel= 并行执行任务时,每个任务都需要独立的输出文件。 =mktemp= 可以在循环中为每个任务分配唯一的文件名: #+begin_src shell :tangle yes #!/bin/bash # 为每个输入项创建独立的临时文件 RESULTDIR=$(mktemp -d) trap "rm -rf $RESULTDIR" EXIT # 模拟并行处理:对每个数字做计算 for i in 1 2 3; do outfile="$RESULTDIR/result-$i.txt" echo "processing $i" > "$outfile" & done wait echo "=== 所有结果 ===" cat "$RESULTDIR"/result-*.txt #+end_src #+begin_example === 所有结果 === processing 1 processing 2 processing 3 #+end_example 上面这个例子中, =mktemp -d= 创建了一个唯一的私有目录,所有临时文件都放在里面。循环变量 =$i= 保证了同一脚本内并行任务之间不会冲突,但两个用户同时跑脚本时, =mktemp= 的唯一目录能确保两批文件互不干扰,而且 =drwx------= 的目录权限防止其他用户读取你的中间数据。 ** Makefile 中的临时文件 Make 的 recipe 是在子 Shell 中执行的,多个 recipe 可能同时运行 ( =make -j= ) 。临时文件命名冲突在并行构建中尤其常见: #+begin_src shell # Makefile 片段(示例,不单独执行) %.processed: %.raw TMPFILE=$$(mktemp) && \ trap "rm -f $$TMPFILE" EXIT && \ sed 's/old/new/g' $< > $$TMPFILE && \ mv $$TMPFILE $@ #+end_src 这里必须用 =$$(mktemp)= (两个 =$$= 是因为 Makefile 中的 =$= 需要转义) ,而不能用固定路径。 =make -j4= 并行构建时,四个 recipe 同时执行,共享一个固定路径必然冲突。 * 速查表 | 参数 | 作用 | 示例 | |---------------+--------------------------------------+--------------------------------| | (无参数) | 在 =$TMPDIR= 或 =/tmp= 创建临时文件 | =mktemp= | | =-d= | 创建临时目录 | =mktemp -d= | | =--tmpdir= | 在 =$TMPDIR= 或 =/tmp= 创建带模板名称的文件 | =mktemp --tmpdir myapp-XXXXXX= | | =-p= | 指定创建位置 | =mktemp -p /var/cache XXXXXX= | | =--dry-run= | 只返回名字,不创建文件 | =mktemp --dry-run --tmpdir x-XXXXXX= | | 常见错误 | 正确做法 | |---------------------------------+-------------------------------------------| | =mktemp= 后没保存返回路径 | =TMPFILE=$(mktemp)= 保存到变量 | | 忘记 =trap= 清理 | =trap "rm -f $TMPFILE" EXIT= 紧跟 mktemp | | 用 =rm -f= 删除 =mktemp -d= 目录 | 用 =rm -rf= 删除目录 | | 模板少于 3 个 =X= | 至少 3 个 =X= ,推荐 6 个 | | 硬编码 =/tmp/= 路径 | 用 =--tmpdir= 参数尊重 =$TMPDIR= 设置 |

hyperfine:命令行基准测试工具

2026年4月25日 08:00
测一个命令要跑多久,大多数人第一反应是 =time= : #+begin_src shell time find /usr/share/doc -maxdepth 2 -name "*.txt" #+end_src #+begin_example real 0m0.012s user 0m0.004s sys 0m0.007s #+end_example 但跑第二次,结果可能变成 0.009s ;第三次 0.015s 。哪一个才是"真实"的执行时间? 磁盘缓存、CPU 调度、后台进程、甚至 CPU 频率调节( turbo boost )都会让每次执行时间不同。你需要的是多次采样的统计摘要——均值、标准差、最小/最大值——而不是一个孤立的数字。 =hyperfine= 可以帮我们自动处理采样、预热和统计分析。 * 基本用法 最简单的形式:把命令放在引号里。 #+begin_src shell hyperfine 'find /usr/share/doc -maxdepth 2 -name "*.txt"' #+end_src #+begin_example Benchmark 1: find /usr/share/doc -maxdepth 2 -name "*.txt" Time (mean ± σ): 20.2 ms ± 23.0 ms [User: 5.4 ms, System: 10.7 ms] Range (min … max): 12.0 ms … 125.3 ms 23 runs Warning: The first benchmarking run for this command was significantly slower than the rest (125.3 ms). This could be caused by (filesystem) caches that were not filled until after the first run. You should consider using the '--warmup' option. #+end_example hyperfine 甚至会主动告诉你问题在哪里——第一次运行明显慢于后续( 125.3ms vs 12.0ms ),因为文件系统缓存还没热。这正是后面要讲的 =--warmup= 要解决的问题。 输出解读: - =Time (mean ± σ)= :均值 ± 标准差。标准差越小,说明结果越稳定。 - =[User: ... System: ...]= :用户态和内核态的 CPU 时间。 - =Range (min … max)= :最快和最慢的一次,展示了波动范围。 - =23 runs= : =hyperfine= 自动决定了跑 23 次(默认至少 10 次,且至少测量 3 秒)。 * 对比两个命令 =hyperfine= 真正有用的地方是对比。传多个命令就行: #+begin_src shell hyperfine --warmup 2 'grep -r "error" /var/log/' 'rg "error" /var/log/' #+end_src #+begin_example Benchmark 1: grep -r "error" /var/log/ Time (mean ± σ): 370.7 ms ± 12.1 ms [User: 308.5 ms, System: 58.9 ms] Range (min … max): 355.8 ms … 396.4 ms 10 runs Benchmark 2: rg "error" /var/log/ Time (mean ± σ): 12.5 ms ± 3.1 ms [User: 9.1 ms, System: 10.1 ms] Range (min … max): 8.6 ms … 31.0 ms 244 runs Summary rg "error" /var/log/ ran 29.62 ± 7.46 times faster than grep -r "error" /var/log/ #+end_example 最后一行 =Summary= 直接给出了倍数比较。这个数字可以直接用在技术讨论或 PR 里——不是"感觉快了不少",而是"快了 29.6 倍,误差 ±7.5 倍"。 * 控制采样参数 ** =--runs N= :指定执行次数 慢命令(数据库导出、大文件压缩)跑 10 次太浪费时间,可以减少: #+begin_src shell hyperfine --runs 3 'tar -czf /tmp/test-backup.tar.gz /usr/share/doc' #+end_src #+begin_example Benchmark 1: tar -czf /tmp/test-backup.tar.gz /usr/share/doc Time (mean ± σ): 11.022 s ± 0.423 s [User: 9.798 s, System: 1.275 s] Range (min … max): 10.547 s … 11.357 s 3 runs #+end_example 快命令(毫秒级)则应该增加次数来稳定统计: #+begin_src shell hyperfine --runs 50 'md5sum /tmp/testfile' #+end_src #+begin_example Benchmark 1: md5sum /tmp/testfile Time (mean ± σ): 11.8 ms ± 1.6 ms [User: 9.4 ms, System: 2.1 ms] Range (min … max): 10.5 ms … 16.0 ms 50 runs #+end_example 50 次跑下来,均值 11.8ms ,标准差只有 1.6ms ,统计上比 3 次可靠得多。 ** =--warmup N= :预热次数 如果你的命令涉及磁盘读取,第一次运行会冷启动(磁盘 I/O ),后续运行会命中缓存。 =--warmup= 让 =hyperfine= 在开始计时前先跑几轮: #+begin_src shell hyperfine --warmup 3 'wc -l /var/log/pacman.log' #+end_src #+begin_example Benchmark 1: wc -l /var/log/pacman.log Time (mean ± σ): 4.7 ms ± 0.8 ms [User: 2.4 ms, System: 2.1 ms] Range (min … max): 3.7 ms … 9.3 ms 524 runs #+end_example 预热 3 次之后,标准差从可能的几十毫秒降到了 0.8ms 。对比基本用法中没有加 =--warmup= 的输出( σ=23.0ms ),效果很明显。 反过来,如果你想测冷启动性能,用 =--prepare= 在每次计时前执行准备命令: #+begin_src shell hyperfine --prepare 'sync' 'find /usr/share/doc -maxdepth 2 -name "*.txt"' #+end_src #+begin_example Benchmark 1: find /usr/share/doc -maxdepth 2 -name "*.txt" Time (mean ± σ): 14.7 ms ± 2.2 ms [User: 6.2 ms, System: 8.6 ms] Range (min … max): 11.9 ms … 19.7 ms 10 runs #+end_example =sync= 把文件系统缓冲区写回磁盘,做轻量级的缓存干扰。完整清除磁盘缓存需要 =sync && echo 3 | sudo tee /proc/sys/vm/drop_caches= ,但需要 root 权限。 * 参数化基准测试 这是 =hyperfine= 最强大也最容易被忽略的功能。 =--parameter-scan= 让你自动遍历一个参数的范围: #+begin_src shell hyperfine --parameter-scan block_size 1024 8192 -D 2048 \ 'dd if=/dev/zero of=/tmp/ddtest bs={block_size} count=4096 2>/dev/null' #+end_src #+begin_example Benchmark 1: dd if=/dev/zero of=/tmp/ddtest bs=1024 count=4096 2>/dev/null Time (mean ± σ): 16.1 ms ± 5.5 ms [User: 3.5 ms, System: 10.7 ms] Range (min … max): 11.7 ms … 41.0 ms 134 runs Benchmark 2: dd if=/dev/zero of=/tmp/ddtest bs=3072 count=4096 2>/dev/null Time (mean ± σ): 21.7 ms ± 3.9 ms [User: 3.6 ms, System: 17.0 ms] Range (min … max): 16.3 ms … 42.3 ms 110 runs Benchmark 3: dd if=/dev/zero of=/tmp/ddtest bs=5120 count=4096 2>/dev/null Time (mean ± σ): 26.7 ms ± 3.8 ms [User: 3.8 ms, System: 22.0 ms] Range (min … max): 20.5 ms … 38.2 ms 119 runs Benchmark 4: dd if=/dev/zero of=/tmp/ddtest bs=7168 count=4096 2>/dev/null Time (mean ± σ): 31.3 ms ± 4.2 ms [User: 3.8 ms, System: 26.6 ms] Range (min … max): 24.1 ms … 46.6 ms 106 runs Summary dd bs=1024 ran 1.34 ± 0.52 times faster than dd bs=3072 1.65 ± 0.61 times faster than dd bs=5120 1.94 ± 0.71 times faster than dd bs=7168 #+end_example =-D= ( =--parameter-step-size= )控制步长。这里从 1024 到 8192 ,步长 2048 ,自动生成了 4 组测试。有趣的是,较小的块大小反而更快——因为 =dd if=/dev/zero= 是内存操作,小块大小意味着更少的系统调用开销。 用 =-L= ( =--parameter-list= )可以传非数值的参数列表,比如对比压缩工具: #+begin_src shell hyperfine --prepare 'rm -f /tmp/testfile.{gz,bz2,zst}' \ -L prog gzip,bzip2,zstd '{prog} -k /tmp/testfile' #+end_src #+begin_example Benchmark 1: gzip -k /tmp/testfile Time (mean ± σ): 199.1 ms ± 9.7 ms [User: 191.6 ms, System: 5.2 ms] Range (min … max): 182.8 ms … 216.5 ms 15 runs Benchmark 2: bzip2 -k /tmp/testfile Time (mean ± σ): 1.056 s ± 0.023 s [User: 1.037 s, System: 0.010 s] Range (min … max): 1.023 s … 1.093 s 10 runs Benchmark 3: zstd -k /tmp/testfile Time (mean ± σ): 22.5 ms ± 1.8 ms [User: 13.6 ms, System: 14.6 ms] Range (min … max): 19.1 ms … 29.3 ms 99 runs Summary zstd -k /tmp/testfile ran 8.85 ± 0.82 times faster than gzip -k /tmp/testfile 46.92 ± 3.83 times faster than bzip2 -k /tmp/testfile #+end_example 这种参数化测试在脚本里手写非常繁琐——你需要自己写循环、解析 =time= 输出、计算统计量。 =hyperfine= 一行命令搞定。 注意 =--prepare= 的用法: =gzip -k= 和 =zstd -k= 不会覆盖已存在的压缩文件,所以每次执行前必须清理。 =--prepare= 会在每轮计时前自动执行。 * 导出结果 命令行输出适合看,不适合分析。 =hyperfine= 支持导出为 JSON 和 Markdown : #+begin_src shell hyperfine --prepare 'rm -f /tmp/testfile.{gz,zst}' \ --export-markdown /tmp/result.md \ 'gzip -k /tmp/testfile' 'zstd -k /tmp/testfile' #+end_src #+begin_example Benchmark 1: gzip -k /tmp/testfile Time (mean ± σ): 199.5 ms ± 9.2 ms [User: 192.6 ms, System: 4.5 ms] Range (min … max): 182.0 ms … 214.6 ms 14 runs Benchmark 2: zstd -k /tmp/testfile Time (mean ± σ): 22.4 ms ± 2.1 ms [User: 13.4 ms, System: 14.8 ms] Range (min … max): 15.6 ms … 28.3 ms 98 runs Summary zstd -k /tmp/testfile ran 8.89 ± 0.92 times faster than gzip -k /tmp/testfile #+end_example 导出的 Markdown 文件可以直接贴进 GitHub issue 或文档: #+begin_src shell cat /tmp/result.md #+end_src #+begin_example | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `gzip -k /tmp/testfile` | 199.5 ± 9.2 | 182.0 | 214.6 | 8.89 ± 0.92 | | `zstd -k /tmp/testfile` | 22.4 ± 2.1 | 15.6 | 28.3 | 1.00 | #+end_example =--export-json= 导出完整数据(每次运行的时间戳都保留),适合喂给脚本做进一步分析或绘制趋势图。 * 什么时候该用什么工具 | 需求 | 工具 | |------+------| | 快速看一眼执行时间 | =time command= | | 对比两个命令谁更快 | =hyperfine 'cmd1' 'cmd2'= | | 找最优参数(块大小、线程数) | =hyperfine --parameter-scan ...= | | CI 里追踪性能回归 | =hyperfine --export-json ...= + 脚本对比 | | 系统级性能分析(CPU、内存、I/O) | =perf= / =valgrind= | =hyperfine= 测的是 /墙钟时间/ ( wall-clock time ),回答"这个命令跑多久"。如果你需要知道 /为什么/ 慢( CPU 热点、内存分配、系统调用),那是 =perf= 和 =valgrind= 的领域。

管道中的变量去哪了?——子 shell 作用域陷阱

2026年4月25日 08:00
Ventoy 的 GitHub 上有一个 [[https://github.com/ventoy/Ventoy/issues/3532][issue]]:`porteus-hook.sh` 中 `vtFindFlag` 在管道循环里被设为 1 ,出了循环又变回 0 ,导致回退逻辑永远执行。 这是一个经典的 shell 编程陷阱:管道中的每个命令都运行在子 shell 里,子 shell 对变量的修改不会传回父 shell 。本文以这个 bug 为引子,解释子 shell 作用域的原理,并用实际执行验证不同 shell 的行为差异和多种解决方案。 * 问题复现 用最简单的例子就能看到问题: #+begin_src shell flag=0 echo -e "line1\nline2\nline3" | while read line; do flag=1 done echo "flag=$flag" #+end_src #+begin_example flag=0 #+end_example `flag` 明明在循环里被设成了 1 ,出来还是 0 。变量"丢"了。 * 为什么会这样 POSIX 规范规定:管道中的每个命令都运行在子 shell( subshell )中。子 shell 是当前 shell 的副本——它继承了所有变量,但对变量的修改不会传回父进程。 来看管道的执行模型: #+begin_src shell # 管道:cmd1 | cmd2 | cmd3 # 等价于: # fork() → 子进程运行 cmd1 # fork() → 子进程运行 cmd2 # fork() → 子进程运行 cmd3 # 父进程等待所有子进程结束 #+end_src 所以 =while read= 在管道的右端运行在一个子 shell 里, =flag=1= 发生在这个子 shell 中。管道结束后,子 shell 退出,修改随之消失。 * 不同 shell 的行为 这不是"所有 shell 都一样"的事。不同 shell 对管道中最后一段命令的处理不同: #+begin_src shell # 测试脚本 flag=0 echo -e "a\nb\nc" | while read x; do flag=1; done echo "flag=$flag" #+end_src ** bash #+begin_src shell bash -c 'flag=0; echo -e "a\nb\nc" | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=0 #+end_example bash 默认遵循 POSIX:管道中所有命令都在子 shell 中运行。 ** zsh #+begin_src shell zsh -c 'flag=0; echo -e "a\nb\nc" | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=1 #+end_example zsh 的管道最后一段命令默认在当前 shell 中执行,所以变量修改生效。这是 zsh 的非 POSIX 扩展。 ** dash #+begin_src shell dash -c 'flag=0; printf "%s\n" a b c | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=0 #+end_example dash 严格遵循 POSIX ,最后一段也在子 shell 中。注意 dash 的 =echo= 不支持 =-e= 参数,所以这里用 =printf= 生成多行输入。 ** busybox ash Ventoy 的 hook 脚本开头是 =#!/ventoy/busybox/sh= ,用的是 busybox ash 。它的行为和 dash 一样——管道中的 =while= 在子 shell 中运行: #+begin_src shell busybox sh -c 'flag=0; printf "%s\n" a b c | while read x; do flag=1; done; echo "flag=$flag"' #+end_src #+begin_example flag=0 #+end_example (此输出基于 busybox ash 与 dash 同属 POSIX shell 的行为一致性,在 dash 上已验证。) 所以 Ventoy 的 bug 在 busybox ash 下一定会触发。 * 解决方案 有四种常见方案,各有适用场景。 ** 方案一:进程替换( bash/zsh ) 用进程替换 =< <(...)= 代替管道,让 =while= 循环在当前 shell 中运行: #+begin_src shell bash -c ' flag=0 while read x; do flag=1 done < <(echo -e "a\nb\nc") echo "flag=$flag" ' #+end_src #+begin_example flag=1 #+end_example 进程替换的原理: =<(...)= 在后台运行命令,把输出写入一个临时文件描述符,然后 =while read= 通过重定向读取这个文件描述符——整个过程没有子 shell 参与。 缺点:不是 POSIX 兼容的语法, dash 和 busybox ash 不支持。 ** 方案二:临时文件 把管道输出存到临时文件, =while read= 从文件读取: #+begin_src shell dash -c ' flag=0 tmpfile=$(mktemp) printf "%s\n" a b c > "$tmpfile" while read x; do flag=1 done < "$tmpfile" rm -f "$tmpfile" echo "flag=$flag" ' #+end_src #+begin_example flag=1 #+end_example 因为 =while read < file= 是输入重定向,不是管道,所以循环在当前 shell 中运行。 POSIX 兼容,所有 shell 都能用。 这就是 Ventoy 实际采用的方案——看 =porteus-hook.sh= 的源码: #+begin_src shell $GREP '`value from`' /usr/* -r | $AWK -F: '{print $1}' > $VTOY_PATH/.porteus while read vtline; do $SED "s#\`value from\`#$vtPath#g" -i $vtline vtFindFlag=1 done < $VTOY_PATH/.porteus rm -f $VTOY_PATH/.porteus #+end_src =grep | awk= 的输出写入临时文件 =$VTOY_PATH/.porteus= , =while read= 通过 =< $VTOY_PATH/.porteus= 重定向读取。 =vtFindFlag= 在当前 shell 中被修改,回退逻辑的判断 =if [ $vtFindFlag -eq 0 ]= 能拿到正确值。 ** 方案三:here-doc 如果数据量小,可以用 here-doc 代替管道: #+begin_src shell dash -c ' flag=0 while read x; do flag=1 done < tmp; while read < tmp= 。 POSIX 兼容,所有 shell 可用 2. *here-doc* :数据量小时最简洁。 POSIX 兼容 3. *进程替换* : =< <(cmd)= 。仅 bash/zsh 支持 4. *lastpipe* : =shopt -s lastpipe= 。仅 bash 4.2+ Ventoy 的修复用了方案二(临时文件),这对 busybox ash 环境来说是唯一可靠的选择。

用 Org Babel 写 Literate 博文:扩展执行 + 定制导出

2026年4月25日 08:00
Literate programming 的核心想法是:把代码和解释它的散文交织在一起,代码可以被实际执行,输出直接嵌入文档。Org mode 的 Babel 就是做这件事的工具——它比 Jupyter Notebook 更灵活(支持任何有 REPL 的语言),但开箱即用时缺少两个东西:REPL 风格的交互式代码块(逐行执行、交替显示输入和输出),以及把 Org 文档导出为静态站生成器能用的 Markdown(带 YAML frontmatter)。这篇文章分两部分解决这两个问题:教你为任意语言写一个自定义 Babel 执行函数,和派生一个自定义 Org 导出后端。 * 第一部分:为任意语言扩展 Babel Babel 的执行机制很简单:只要定义一个名为 =org-babel-execute:lang= 的函数(把 =lang= 换成你的语言名),Babel 就知道怎么执行该语言的代码块。这个函数接收两个参数——代码体(body)和参数列表(params),返回执行结果的字符串。Babel 负责剩下的所有事情:解析代码块、收集参数、显示结果。 ** 最简执行函数 假设你想为一种叫 =mylang= 的语言添加 Babel 支持。最简单的实现是调用外部命令执行代码: #+begin_src emacs-lisp (defun org-babel-execute:mylang (body params) "Execute a block of MyLang code with org-babel." (shell-command-to-string (format "mylang -e %s" (shell-quote-argument body)))) #+end_src 定义了这个函数之后,Org 文件里的 =#+begin_src mylang= 代码块就能用 =C-c C-c= 执行了。 有一个容易踩的坑:Babel 默认把代码包在一个函数里、调用函数、显示 *返回值* 而不是输出结果。如果你的代码用了 =print= 之类的 I/O 操作,默认行为不会显示 print 的内容。要改为显示输出结果,在src block 加上 =:results output= 参数后。 ** REPL 风格的代码块 基本的执行函数把整个代码块当做一个单元执行。但写 literate 博文时,你通常想要 REPL 风格的效果:逐行执行代码,每一行下面紧接着显示输出。 实现方式是定义一个新的结果类型 =repl= 。当代码块加上 =:results repl= 时,逐行发送代码到 REPL 进程,收集每行的输出,然后把输入和输出交替排列: #+begin_src emacs-lisp (defun org-babel-mylang--execute-repl (body) "Execute BODY line-by-line, returning input/output pairs." (let ((lines (split-string body "\n" t "[ \t]+"))) (mapconcat (lambda (line) (format " %s\n%s" line (mylang-evaluate-command line))) lines "\n"))) #+end_src 然后在主执行函数里根据参数进行分支判断:遇到 =:results repl= 就走逐行执行,否则走整块执行。 #+begin_src emacs-lisp (defun org-babel-execute:mylang (body params) "Execute a block of MyLang code with org-babel. When PARAMS includes `:results repl', evaluate each line separately and return all results interleaved." (let ((result-params (cdr (assq :results params)))) (if (and result-params (string-match-p "\\brepl\\b" result-params)) (org-babel-mylang--execute-repl body) (mylang-evaluate-command body)))) #+end_src 使用时代码块头部写成这样: #+begin_src org ,#+begin_src mylang :results repl :exports results :wrap SRC mylang x = 1 + 2 x * 4 ,#+end_src #+end_src 三个参数的作用: =:results repl= 触发逐行执行, =:exports results= 只导出结果不导出源码, =:wrap SRC mylang= 把结果包在一个 =mylang= 代码块里——这样导出到 Markdown 后语法高亮仍然生效。执行后得到: #+begin_src mylang x = 1 + 2 3 x * 4 12 #+end_src ** 批量执行 写完博文后,你可能想一次性执行所有代码块来更新结果。下面这个函数执行文件中所有 =mylang= 代码块,但跳过已经是结果块的一部分的代码块(避免重复执行): #+begin_src emacs-lisp (defun org-babel-mylang-execute-all () "Execute all MyLang src blocks not part of a #+RESULTS block." (interactive) (org-babel-map-src-blocks nil (when (and (string-equal "mylang" (car (org-babel-get-src-block-info 'no-eval))) (not (progn (goto-char beg-block) (forward-line -1) (looking-at-p "#\\+RESULTS:")))) (goto-char beg-block) (org-babel-execute-src-block)))) #+end_src =org-babel-map-src-blocks= 遍历文件中所有代码块, =beg-block= 是每个块的起始位置。通过向前看一行检查是否在 =#+RESULTS:= 下面,跳过那些只是结果的块。 * 第二部分:派生自定义 Org 导出后端 写完 literate 博文后,需要导出为静态站生成器能用的格式。如果你的站点用 Hakyll、Jekyll、Hugo 等工具,你需要带 YAML frontmatter 的 Markdown。默认的 =ox-gfm= 不支持自定义 frontmatter 字段,也不把脚注转为 GFM 格式。但可以通过派生后端来添加这些功能。 ** 派生后端的基本方法 Org 的导出系统支持从已有后端派生新后端。 =ox-gfm= 继承自 =ox-md= , =ox-md= 又继承自 =ox-html= 。你可以在任何一层上继续派生,只覆盖需要的部分: #+begin_src emacs-lisp (org-export-define-derived-backend 'my-gfm 'gfm :options-alist '((:tags "TAGS" nil nil split) (:last-modified "LAST-MODIFIED" nil nil) (:og-description "OG-DESCRIPTION" nil nil)) :translate-alist '((template . my-gfm-template) (footnote-reference . my-gfm-footnote-reference))) #+end_src 两个关键参数: - =:options-alist= 定义新的导出选项。每个元素是 =(ALIST-KEY KEYWORD OPTION DEFAULT BEHAVIOR)= ,其中 =ALIST-KEY= 是导出信息 plist 中的键, =KEYWORD= 是 Org 文件里写的 =#+KEYWORD= 名, =BEHAVIOR= 告诉 Org 如何处理多个值—— =split= 会把空格分隔的字符串拆成列表(适用于 tags) - =:translate-alist= 把导出元素挂钩到自定义翻译函数。 =template= 控制整个文档的输出(适合插入 frontmatter), =footnote-reference= 控制脚注引用的格式 ** 生成 YAML Frontmatter =template= 翻译函数接收最终转换后的文档内容(contents),是插入 preamble 的标准位置。下面这个函数从导出信息中提取 =title= 、 =date= 、 =tags= 等字段,组装成 YAML 格式: #+begin_src emacs-lisp (defun my-gfm--build-yaml (info) "Build YAML front matter string from INFO plist." (when-let* ((lines (seq-keep (lambda (f) (when-let* ((field (plist-get info f)) (val (pcase f (:title #'car) (:date #'car) (:tags (lambda (x) (mapconcat #'identity x " "))) (:last-modified #'identity) (:og-description #'identity)))) (format "%s: %s" (string-trim (pp-to-string f) ":" "\n") (funcall val field)))) '(:title :date :last-modified :tags :og-description)))) (concat "---\n" (mapconcat #'identity lines "\n") "\n---\n\n"))) (defun my-gfm-template (contents info) "Return complete document string after GFM conversion. CONTENTS is the transcoded contents string. INFO is a plist holding export options." (concat (my-gfm--build-yaml info) contents)) #+end_src =my-gfm--build-yaml= 的逻辑是:遍历字段名列表,对每个字段从 =info= plist 中取值,用 =pcase= 根据字段类型选择取值函数( =:title= 和 =:date= 取 =car= , =:tags= 把列表拼成空格分隔的字符串),格式化为 =key: value= 行,最后用 =---= 包裹成 YAML 块。 =seq-keep= 自动跳过值为 nil 的字段,所以只有 Org 文件中实际写了的字段才会出现在 frontmatter 里。 ** 处理脚注 默认的 =ox-gfm= 会把 Org 脚注引用( =[fn:1]= )直接导出为 HTML( == ),这不是 GFM 格式。需要覆盖 =footnote-reference= 翻译函数,改为 GFM 的脚注语法 =[^n]= : #+begin_src emacs-lisp (defun my-gfm-footnote-reference (footnote-reference _contents info) "Transcode a FOOTNOTE-REFERENCE element into GFM format." (format "[^%d]" (org-export-get-footnote-number footnote-reference info))) #+end_src 脚注内容部分,去掉默认的"Footnotes"标题(静态站生成器会自己处理),直接输出定义列表: #+begin_src emacs-lisp (defun my-gfm-footnote-section (info) "Format the footnote section without header." (and-let* ((fn-alist (org-export-collect-footnote-definitions info))) (format "%s\n" (mapconcat (pcase-lambda (`(,n ,_type ,def)) (format "[^%d]: %s" n (org-trim (org-export-data def info)))) fn-alist "\n\n")))) #+end_src =org-export-collect-footnote-definitions= 收集所有脚注定义, =org-export-data= 递归导出脚注内容。每个脚注格式化为 =[^n]: content= ,放在文档末尾。 * 合在一起 完整的工作流程是: 1. 在 Org 文件中写代码块和散文,用 =:results repl= 获得交互式输出 2. =C-c C-c= 执行单个块,或调用批量执行函数更新所有块 3. 导出为 GFM,自动生成 YAML frontmatter,脚注转为 GFM 格式 4. 静态站生成器接过 Markdown,继续后续处理 两个扩展各自独立:你可以只用 REPL 风格的 Babel 块而不定制导出,也可以只定制导出后端而不用 REPL 块。但合在一起,就能在 Org mode 的舒适环境中写完整的 literate 博文,一键导出为静态站可用的 Markdown。

proced:Emacs 内置的进程查看器

2026年4月25日 08:00
=proced= 是 Emacs 内置的进程查看器,相当于一个可以直接在 Emacs 里操作的彩色版 =ps= 。它支持自动刷新、树状视图、按列排序、发送信号,还可以通过 =proced-custom-attributes= 扩展自定义列。本文介绍 =proced= 的基本用法和常用配置。 * 启动 =M-x proced= 即可打开。 =proced= 会列出当前系统的进程,默认只显示当前用户的进程( =proced-filter= 默认值为 =user= )。 * 常用按键 =proced= buffer 中的按键分为几类: ** 导航 | 按键 | 功能 | |------+------| | =n= / =p= | 上下移动 | | =SPC= | 移动到下一行 | ** 标记 | 按键 | 功能 | |------+------| | =m= / =d= | 标记当前行( =d= 是"标记准备操作"的习惯按键) | | =u= | 取消当前行标记 | | =U= | 取消所有标记 | | =M= | 标记所有行 | | =t= | 反转标记 | | =C= | 标记子进程 | | =P= | 标记父进程 | ** 视图控制 | 按键 | 功能 | |------+------| | =f= | 切换过滤器(user / user-running / all / all-running / emacs) | | =F= | 切换显示格式(short / medium / long / verbose) | | =T= | 切换树状视图 | | =o= | 隐藏/显示被标记的进程 | | =RET= | 在当前列上细化筛选 | ** 操作 | 按键 | 功能 | |------+------| | =k= / =x= | 向标记的进程发送信号 | | =r= | 修改进程的 nice 值 | ** 其他 | 按键 | 功能 | |------+------| | =?= / =h= | 查看帮助 | * 发送信号 按 =k= 或 =x= 会弹出一个信号列表,让你选择要发送的信号。你可以直接输入信号名(如 =SIGTERM= 、 =SIGKILL= ),也可以从列表中选择。如果事先用 =m= 标记了多个进程,信号会发给所有被标记的进程。 * 排序 在 =proced= 中,用鼠标点击列头就能按该列排序。再次点击同一列头会反转排序顺序。 把光标移到某个属性值上按 =RET= ( =proced-refine= )则会根据当前值细化筛选——比如把光标放在某个进程的 USER 列上按 =RET= ,可以只显示同一用户的进程。 * 常用配置 以下是一套推荐的 =proced= 配置: #+begin_src emacs-lisp (use-package proced :ensure nil :defer t :custom (proced-enable-color-flag t) ;; 启用颜色 (proced-tree-flag t) ;; 默认启用树状视图 (proced-auto-update-flag 'visible) ;; 只在 buffer 可见时自动刷新 (proced-auto-update-interval 1) ;; 每秒刷新 (proced-descend t) ;; 树状视图降序排列 (proced-format 'medium) ;; 中等详细度的显示格式 (proced-filter 'user)) ;; 只显示当前用户的进程 #+end_src 几个关键变量说明: - =proced-auto-update-flag= :设为 ='visible= 表示只在 buffer 可见时刷新,比 =t= 更省资源。 =proced= 会按 =proced-auto-update-interval= 指定的秒数定期重新读取进程列表。 - =proced-format= :控制显示哪些列。 =short= 最精简, =verbose= 最详细。运行时可以用 =F= 键切换,不需要重启。 - =proced-filter= :控制显示哪些进程。 =user= 只显示当前用户的, =all= 显示所有, =emacs= 只显示 Emacs 相关进程。运行时用 =f= 键切换。 - =proced-tree-flag= : =t= 启用树状视图,可以直观看到进程的父子关系。 * 自定义扩展 =proced= 支持通过 =proced-custom-attributes= 添加自定义列。这个功能在某些场景下很有用——比如 macOS 上 =proced= 默认缺少 CPU 和内存列,就可以通过这个机制补上。具体的实现思路见[[file:读:让proced在macOS上显示CPU和内存.org][上一篇博文]]。

从 proced 定制中学到的 Elisp 模式

2026年4月25日 08:00
Rahul Juliato 的 [[https://rahuljuliato.com/posts/proced-macos][Getting Emacs proced.el to Show CPU and Memory on macOS]] 表面上是解决 macOS 上 =proced= 缺少 CPU/Mem 列的问题,实际上是一份很好的 Elisp 编程教学。本文从中提取六个可复用的编程模式——它们跟 =proced= 和 macOS 无关,你在任何需要异步调用外部命令、缓存数据、扩展第三方包的场景都能用。 * 模式一:异步进程 + sentinel Elisp 中执行外部命令有两种方式: =call-process= (同步,会阻塞 Emacs )和 =make-process= (异步,不阻塞)。凡是执行时间不确定的命令,都应该用 =make-process= 。 #+begin_src emacs-lisp (make-process :name "my-process" :buffer (generate-new-buffer " *my-process-temp*") :command '("env" "LC_ALL=C" "ps" "-eo" "pid=,%cpu=,%mem=") :noquery t :sentinel (lambda (proc _event) (when (eq (process-status proc) 'exit) (with-current-buffer (process-buffer proc) (goto-char (point-min)) ;; 在这里处理输出 (buffer-string)) (kill-buffer (process-buffer proc))))) #+end_src 关键设计: - =sentinel= 是一个回调函数,在进程状态变化时触发。通常只关心 ='exit= 状态——此时 buffer 中已有完整输出。 - =:noquery t= 防止 Emacs 退出时弹出"还有进程在运行"的确认框。 - =generate-new-buffer= 创建临时 buffer 承接输出,名称以空格开头( =" "= )的 buffer 在 =list-buffers= 中默认隐藏。 - 处理完后 =kill-buffer= 清理临时 buffer ,避免 buffer 堆积。 * 模式二:hash table 做进程级缓存 当你需要频繁按 key 查找数据时,hash table 比 alist 快得多( O(1) vs O(n) )。 #+begin_src emacs-lisp ;; 创建 (defvar my-cache (make-hash-table)) ;; 写入(用 cons 存两个值,car 和 cdr 分别取) (puthash 1234 (cons 2.5 1.3) my-cache) (puthash 5678 (cons 0.1 0.5) my-cache) ;; 读取 (car (gethash 1234 my-cache)) ;; => 2.5 (cdr (gethash 1234 my-cache)) ;; => 1.3 (gethash 9999 my-cache) ;; => nil #+end_src #+begin_example 2.5 1.3 nil #+end_example 为什么用 cons 存值?因为 =car= 和 =cdr= 是 Elisp 中最快的取值操作之一,比 =plist-get= 或 =alist-get= 快。当你只需要存两个值时,cons 是最佳选择。 刷新时不要原地修改 hash table ,而是创建一个新的再替换——这样即使有并发的读操作也不会读到半更新的状态: #+begin_src emacs-lisp (defun my-refresh-cache () (let ((new-cache (make-hash-table))) ;; 填充 new-cache ... (setq my-cache new-cache))) ;; 原子替换 #+end_src * 模式三: =rx= 宏写可读正则 Elisp 的 =rx= 宏用 S-expression 写正则,比字符串正则更容易读和维护。对比一下: #+begin_src emacs-lisp ;; 字符串正则:不直观,需要脑内解析 "^[[:blank:]]*\\([[:digit:]]+\\)[[:blank:]]+\\([.[:digit:]]+\\)" ;; rx 宏:自解释 (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.)))) #+end_src =rx= 编译出来的结果跟手写字符串正则完全一样: #+begin_src emacs-lisp (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.))) (+ blank) (group (+ (any digit ?.)))) #+end_src #+begin_example [[:blank:]]*\([[:digit:]]+\)[[:blank:]]+\([.[:digit:]]+\)[[:blank:]]+\([.[:digit:]]+\) #+end_example 常用 =rx= 组合: | =rx= 写法 | 匹配内容 | |-----------+----------| | =(+ digit)= | 一个或多个数字 | | =(* blank)= | 零或多个空白 | | =(any digit ?.)= | 数字或小数点 | | =(group ...)= | 捕获组,对应 =match-string= | * 模式四:timer 生命周期管理 Emacs 的 =run-with-timer= 可以周期性执行任务,但如果不在合适的时机取消,timer 会一直运行下去(即使对应的 buffer 已经关了)。正确的做法是在 mode hook 里启动,在 =kill-buffer-hook= 里取消: #+begin_src emacs-lisp (defvar my-timer nil) ;; 在 mode hook 中启动 (add-hook 'my-mode-hook (lambda () (setq my-timer (run-with-timer 0 2 #'my-refresh-function)))) ;; 在 kill-buffer-hook 中清理 (add-hook 'kill-buffer-hook (lambda () (when (and (derived-mode-p 'my-mode) (timerp my-timer)) (cancel-timer my-timer) (setq my-timer nil)))) #+end_src 三个要点: - =run-with-timer= 的参数是 =(延迟秒数 重复间隔 函数)= ,第一个参数 =0= 表示立即执行第一次。 - =cancel-timer= 取消后要把变量设为 =nil= ,避免悬空引用。 - guard 条件 =(derived-mode-p 'my-mode)= 确保 =kill-buffer-hook= 不会在其他类型的 buffer 中误触发。 =kill-buffer-hook= 是全局的,每次任何 buffer 关闭都会触发,所以必须有 mode 判断。 * 模式五: =file-remote-p= 做 TRAMP 感知 当你的代码依赖本地系统状态(比如执行本地 =ps= ),必须考虑 buffer 可能在 TRAMP 远程主机上运行的情况。 =file-remote-p= 检测 =default-directory= 是否指向远程: #+begin_src emacs-lisp (unless (file-remote-p default-directory) ;; 只在本地执行 (my-run-local-command)) #+end_src 为什么这很重要?如果你不加检测,可能会用本地 =ps= 的输出作为参数在远程主机上执行命令。轻则数据显示错误,重则本地 PID 跟远程 PID 碰撞导致误操作。 * 模式六:通过 custom attributes 扩展第三方包 很多 Emacs 包提供 =*-custom-attributes= 或类似的扩展点,让你不用修改包的源码就能添加功能。 =proced= 的 =proced-custom-attributes= 就是一个例子:它接受一个 lambda 列表,每个 lambda 接收当前行的属性 alist ,返回 =(keyword . value)= 就能添加新列。 #+begin_src emacs-lisp (setq proced-custom-attributes (list (lambda (attrs) (when-let* ((pid (cdr (assq 'pid attrs))) (v (my-lookup pid))) (cons 'my-attribute v))))) #+end_src 这个模式不限于 =proced= 。当你需要给某个包添加自定义字段时,先看看它有没有类似的扩展点——很多设计良好的包都会提供。 * 小结 | 模式 | 一句话 | |------+--------| | 异步进程 + sentinel | 用 =make-process= 代替 =call-process= ,在 sentinel 中处理输出 | | hash table 缓存 | 高频查找用 hash table ,刷新时整体替换而非原地修改 | | =rx= 宏 | 用 S-expression 写正则,比字符串更可读更易维护 | | timer 生命周期 | hook 启动、hook 清理、 =derived-mode-p= guard 三件套 | | =file-remote-p= | 任何依赖本地系统状态的代码都要加 TRAMP 检测 | | custom attributes | 用 lambda 返回 =(keyword . value)= 扩展第三方包的显示 |

读:让 Emacs proced 在 macOS 上显示 CPU 和内存

2026年4月25日 08:00
本文是对 Rahul Juliato 的文章 [[https://rahuljuliato.com/posts/proced-macos][Getting Emacs proced.el to Show CPU and Memory on macOS]] 的解读。原文解决了 macOS 上 Emacs =proced= 缺少 CPU 和内存列的问题,同时是一份很好的 Elisp 编程教学——涉及异步进程、hash table 缓存、自定义属性扩展等多个实用技巧。 * 问题是怎样的 Emacs 内置的 =proced= 是一个进程查看器,相当于彩色版的 =ps= 。在 Linux 上,它默认就能显示每个进程的 CPU 和内存占用。但在 macOS 上, =%CPU= 和 =%Mem= 两列是空的。 原因在 Emacs 的 C 层: =proced= 通过 =system_process_attributes= 函数(定义在 =src/sysdep.c= 中)获取每个进程的属性列表。在 Linux 上,这个函数从 =/proc/*/stat= 读取 CPU 和内存数据;在 BSD 和 Windows 上,它通过系统 API 计算。但在 Darwin(macOS 内核)上,这个函数虽然调用了 =proc_pidinfo= 来获取虚拟内存和常驻内存大小,却从未填充 =pcpu= 和 =pmem= 两个字段——数据明明可以通过 =proc_pid_rusage= 、 =task_info= 和 =sysctl hw.memsize= 拿到,只是没人把线接上。 Rahul Juliato 向上游提了一个 patch([[https://debbugs.gnu.org/cgi/bugreport.cgi?bug=80898][debbugs #80898]]),但在 patch 合并之前,他用纯 Elisp 给出了一个 workaround。 * 解决思路 整体方案很清晰: 1. 用 =make-process= 异步运行 ~ps -axo pid=,%cpu=,%mem=~ 获取进程信息 2. 把输出解析后存入 hash table(以 PID 为 key ) 3. 通过 =proced-custom-attributes= 把 =pcpu= 和 =pmem= 两个属性注入 =proced= 4. 用 timer 每 2 秒刷新一次 hash table * 异步运行 ps 并解析输出 原文用 =(when (eq system-type 'darwin))= 把所有代码包在一起,确保只在 macOS 上执行。下面的代码片段省略了这个外层 guard ,实际使用时应当加上。 核心是用 =make-process= 异步执行 =ps= : #+begin_src emacs-lisp (defvar emacs-solo--proced-ps-cache (make-hash-table)) (defvar emacs-solo--proced-ps-timer nil) (defun emacs-solo--proced-ps-do-refresh () (make-process :name "proced-ps-refresh" :buffer (generate-new-buffer " *proced-ps-temp*") :command '("env" "LC_ALL=C" "ps" "-axo" "pid=,%cpu=,%mem=") :noquery t :sentinel (lambda (proc _event) (when (eq (process-status proc) 'exit) (let ((new-cache (make-hash-table))) (with-current-buffer (process-buffer proc) (goto-char (point-min)) (while (not (eobp)) (when (looking-at (rx (* blank) (group (+ digit)) (+ blank) (group (+ (any digit ?.))) (+ blank) (group (+ (any digit ?.))))) (puthash (string-to-number (match-string 1)) (cons (string-to-number (match-string 2)) (string-to-number (match-string 3))) new-cache)) (forward-line 1))) (kill-buffer (process-buffer proc)) (setq emacs-solo--proced-ps-cache new-cache)))))) #+end_src 几个设计要点: - =LC_ALL=C= 强制 =ps= 使用固定的输出格式,不受用户 locale 影响。不同 locale 下数字的小数点可能变成逗号,解析就会出错。 - sentinel 只在进程退出时触发( =(eq (process-status proc) 'exit)= ),此时 buffer 中已有完整输出。 - =rx= 宏让正则表达式更可读:三个分组分别匹配 PID(整数)、 =%CPU=(浮点数)、 =%Mem=(浮点数)。 - =puthash= 以 PID 为 key ,以 =(%CPU . %Mem)= 这个 cons 为 value 。用 cons 而不是 list ,是因为 =car= 和 =cdr= 取值最快。 为什么用 hash table 而不是 alist ?因为 =proced= 会为每个进程调用自定义属性函数,hash table 的查找时间是 O(1) ,即使有几百个进程也很快。 * 查询函数 简单的 wrapper ,从 hash table 中取值: #+begin_src emacs-lisp (defun emacs-solo--proced-pcpu (pid) (car (gethash pid emacs-solo--proced-ps-cache))) (defun emacs-solo--proced-pmem (pid) (cdr (gethash pid emacs-solo--proced-ps-cache))) #+end_src =car= 取 CPU , =cdr= 取内存——这就是用 cons 存储的好处。 * 注入到 proced 这是把一切连起来的关键。 =proced-custom-attributes= 是一个 lambda 列表,每个 lambda 接收当前行的属性 alist ,返回 =(keyword . value)= 形式的 cons , =proced= 会把它当作新列显示: #+begin_src emacs-lisp (add-hook 'proced-mode-hook (lambda () (unless (file-remote-p default-directory) (setq emacs-solo--proced-ps-timer (run-with-timer 0 2 #'emacs-solo--proced-ps-do-refresh))))) (setq proced-custom-attributes (list (lambda (attrs) (unless (file-remote-p default-directory) (when-let* ((pid (cdr (assq 'pid attrs))) (v (emacs-solo--proced-pcpu pid))) (cons 'pcpu v)))) (lambda (attrs) (unless (file-remote-p default-directory) (when-let* ((pid (cdr (assq 'pid attrs))) (v (emacs-solo--proced-pmem pid))) (cons 'pmem v)))))) #+end_src 两个 lambda ,一个管 CPU 一个管内存。每个 lambda 做三件事: 1. 用 =file-remote-p= 检测当前 buffer 是否在 TRAMP 远程主机上。如果是,本地的 =ps= 数据没有意义,而且本地 PID 可能跟远程 PID 冲突。 2. 从 =attrs= 中提取 PID ,在 hash table 中查找值。 3. 返回 =(pcpu . value)= 或 =(pmem . value)= 。 timer 放在 =proced-mode-hook= 里启动,因为只有 =proced= buffer 存在时才需要刷新数据。 * 清理 timer =proced= buffer 关闭时取消 timer ,避免悬空的定时器: #+begin_src emacs-lisp (add-hook 'kill-buffer-hook (lambda () (when (and (derived-mode-p 'proced-mode) (timerp emacs-solo--proced-ps-timer)) (cancel-timer emacs-solo--proced-ps-timer) (setq emacs-solo--proced-ps-timer nil)))) #+end_src guard 条件 =(derived-mode-p 'proced-mode)= 确保 =kill-buffer-hook= 不会在其他 buffer 关闭时误触发。 * 学到了什么 | 技巧 | 说明 | |------+------| | =proced-custom-attributes= | 接受 lambda 列表,每个 lambda 接收行属性 alist ,返回 =(keyword . value)= 即可添加新列 | | =make-process= + sentinel | Elisp 中异步执行外部命令的标准方式。sentinel 在进程状态变化时触发,通常只关心 ='exit= 状态 | | =run-with-timer= | 周期性执行任务。返回 timer 对象,可以用 =cancel-timer= 取消。比 =run-at-time= 更方便 | | =file-remote-p= | 检测当前 buffer 是否在 TRAMP 远程主机上。任何涉及本地系统状态的 hack 都应该加上这个 guard | | =rx= 宏 | 比 =regexp= 字符串更可读的正则写法,支持 S-expression 风格组合 |
❌
❌