MoreRSS

site iconEltrac | 極客死亡計劃修改

常讨论心理学和社会观察相关内容,不敢说很懂哲学;也会写笔记和生产力工具相关内容;还是学生。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Eltrac | 極客死亡計劃的 RSS 预览

爱拯救之

2026-10-21 23:52:48

极客死亡计划现在运行在安装 OpenBSD 8.0 的服务器上,不是 Linux。

我在 第一百零一期周刊 因 Linux 如今也包含 LLM 生成的代码而感到沮丧,写道:“我用 Linux,不可能不用”——结果,我还真的可以不用。Linus Torvalds 最近还在 邮件列表 里回复一条我读起来毫无恶意的邮件,用十分荒唐的逻辑称对方的想法愚蠢之极(“UTTERLY STUPID”),语气强烈。

前不久 ,他用 Claude Code 写的代码被评审者找出潜在的竞态问题(race condition)。 再后来 ,Linux 内核不再要求贡献者披露 LLM 使用情况。

这一切都给了我更多的理由抛弃 Linux,去用 BSD。我们讨论的不是可有可无的 SaaS 软件,我们说的是 Linux,全世界都依赖的操作系统。

Oh Linus, oh Linus, you let us all down.

不少 LLM 追捧者貌似只有那么一两个论点,Linus 一边骂人一边强调“LLM 是工具”,与此同时,Rich Hickey1 说: 我一辈子都在造工具和用工具,我知道工具是什么。LLM 可不是工具。 与此同时,OpenBSD 发布了一首名为《 The Simps on AI 》的歌曲,我意犹未尽地听了好几遍。与此同时,还有 一群没那么显眼的人 想要负责任地维护让人感到安心的软件系统。

说真的,我还在乎那些破事儿干嘛呢?

前几天我在英文博客发了一篇题为《 What’s next for the indie web? 》的文章,介绍了 weepinbell 的理念和架构设计,还谈了谈我认为独立网站不应该集中在 IndieWeb 以及 Aaron Parecki 提供的基础设施上(比如 webmention.io)。我的想法是,除了 IndieWeb camp,还应该有更多有活力的小社群。社群内(或社群之间)可以共享基础设施,由一两个有能力、有责任心的人维护诸如联邦宇宙实例、页面托管和 Webmention 接收器等基础设施,这样的社群应该是由相似理念和爱好的人组成。

这样大家可以一起远离充斥推荐算法和广告的商业平台,又不容易感到自己在孤军奋战,可以相互提供帮助,或者各司其职。不同的基础设施可以由社群里不同的人维护,兴许还能合作推出公开的软件或服务,不懂技术的人还能在美术等方面提供帮助。除了扁平的结构,也可以像 IndieWeb camp 那样,有一两个扛大旗的志愿者,但仍然与社群联系紧密。

这样的互联网社群已经有一些案例了,涛叔的 ZZ.AC 兴许就算其中一种,国外我比较有好感的是 omg.lol 。此外还有一些比较特立独行、小众且观点鲜明的,比方说 OpenBSD Amsterdam ——尽管看上去是云计算提供商,但社群的确很有活力,我在找 OpenBSD 的参考资料的时候也偶尔会路过他们写的页面。就算不考虑独立网站的概念,一两个有能力的人带头组建起和谐的小社群,这样的例子也有不少,氛围都要比乌烟瘴气的「大互联网」好太多。

我的确有很强烈的自我意识和难以改变的价值观(与我有邮件通信的朋友将其称作「工匠精神」,其实我有点不敢当,但我的确很在乎工艺之类的概念),以至于我时常绝望地在心里大吼,这个世界怎么变得这么坏?最近这种负面情绪变得愈发强烈,可以在《 打领带的人 》里读到。我和这个世界就是不合的,但这不代表我找不到和我有着一样信念的人。

我想我有点太关注那些与我无关的人的想法和行事方式了,有点像《地狱客栈》第二季的夏莉,那样发起疯来只会让关心自己的人感到难受。如上文所述,有很多人和我站在相似的立场上,我为什么要想象自己身处敌营中,让自己感到困顿、绝望、难以呼吸、无法逃脱?

我也要原谅自己的偏执,因为我“身处敌营”的状况其实并非只是想象:

我观察下来,这个行业里有绝大部分是不爱编程的。

我比较有限的样本里有这几种类型:除了上班不写代码,觉得有 LLM 不用还要自己手写的人是纯傻逼的;写代码是为了开发产品获得商业成功,而同时开好几个 Agent 会话能帮他们“更快达成目标”;对任何事情都没有反思,把 LLM 盛行当作天气现象一样自然的事情,就像世界上有个叫 Java 的编程语言、学校里教这个、也有公司招这个岗,然后就一直写 Java,后来也很自然地融入了 Vibe-coding 的同列;喜欢新奇概念,自称追上行业前沿的,热衷于传播「许愿式编程」之类的概念,对自己的“与时俱进”感到骄傲……

我觉得我能做的就是给工作分配尽可能少的精力,但这好像也很难,我也真的在认真考虑要不要远离这个行业……

我唯一应该责怪自己的,不是自己对世界的认知,而是我对这种认知的反应。我的反应相当情绪化,我想逃。我说我在考虑要远离这个行业是认真的,一周前我就在物色接收短篇小说投稿的杂志。《零杂志》有专门接收非文学背景投稿的栏目,可惜字数限制八千,我最近写的 小说 大多都超过一万两千字了,我也不愿意删改,而且他们的投稿竟然还要填身份证号码,我可不想自己在网络上的身份与现实身份联系起来。我还看了《花城》,据说编辑是真的会读自由来稿的,想必是如今对文学有热情的人少了,编辑的时间才能分配得过来。

不过也有很俗套甚至搞笑的杂志,《花火》据称偏向青春文学,投稿要求里写的是允许忧伤淡雅,但不允许阴郁沉闷,还严格要求禁止描写中小学生早恋,但是可以暗恋,明明是文艺而非文学。文学要的是真实,可不是什么淡雅,不是迎合你们对“青春”的恶臭想象。我想《啸风山庄》要是投稿给这个杂志,一定会被拒稿,许多文学经典按他们的标准都不合格。

明智地讲,如果我真想要从事文学,我也得先有一份能养活自己的工作,而且要想作品被刊登,就应该去研究某篇杂志过去发表过的作品的风格,依照这个风格去创作。一个新人作者是没有创作自由的,除非他不想吃这口饭,就像一个程序员只能去学行业里其他人都在用的技术,自己的技术兴趣只能当作消遣(除非你的技术兴趣是 Rust、LLM Agent 和模型训练)。

愈发觉得职业选择自由和创作自由以及各种形式的自由,在当今的市场里都是特权。

只有有丰富履历(而不一定是过硬的技术实力)的人才有谈薪酬福利的资本,有自由选择技术栈的权力;普通人只能迎合和学习这些人选择的技术,除非不想要吃饭了。

只有有过丰富作品集的小说家、画家和音乐家才有资本让出版机构来欣赏自己的作品,普通人只能迎合杂志社、出版社、唱片公司的审稿人、编辑、制作人的品味,去创作他们偏好的东西。

我没有要批判的意思,我觉得很合理,但我确实不太能够接受这个局面,不能接受个人必须要谄媚才能在世界中获得一席之地…… 我大概要花时间消化这一点。

所以说到底,我还是很难战胜这个世界的残忍。我思考问题很认真,对待自己在乎的事请也很认真,因此对不合理的现象容忍度非常低。然而我只是个学生,可以说是手无缚鸡之力,只有被管的份。哪怕我的辅导员在我的简历上批注「不知道你那个符号(GitHub Logo)是什么意思」,我也不能指责她「外行指导内行」。她也会继续认为自己反复强调面试进门的时候怎么走路和微笑,是崇高的教育之道。

哪怕我不是个学生,也免不了受人管制。恐怕我要到三四十岁的年纪,才能拥有与我的自我意识相匹配的自由。或许到那个时候也不会有,或许到那个时候,自杀的念头会又一次擒住我,而那一次,它会战胜我。我希望那种事情不要发生。

至少现在,我的理智和热情还能赋予我反叛精神,不至于麻木。

除了没用的肉体自杀和精神逃避,第三种自杀的态度是坚持奋斗,对抗人生的荒谬。

每想到类似的事情,我就会想到加缪的文字。我并没有把加缪当作偶像或导师之类的形象,他自己也不觉得自己是哲学家。的确,她不像黑格尔、海德格尔和胡塞尔那样有严谨的哲学理论,他的文字不是理性主导的。荒诞与爱是加缪作品里连续的主题,他的作品里有种朴素的真实感(尤其是他的遗作《 第一个人 》),让我感到他是个可以接近的人,他写的东西常常包含令我会心一笑的个性。

这种真实感和亲近感,或许就是我对抗荒诞的方式呢?我热爱文学,兴许是出于我骨子里对真实的向往,而获取亲近感的方式,则是和真实的人建立真实的关系,而不是 纠正互联网上那个“错的人” 。


最后,我真的有在考虑建立一个小型、独立的互联网社群,也想要帮助没有技术背景的人建立起独立的互联网身份,不被大科技公司控制。这样的社群不会以 V2EX 和 Hacker News 那样的「大广场」形式存在,更像是各自独立但彼此关联的群岛。我大概会用 weepinbell 搭建公开的 Webmention 服务,我还在考虑自己开发类似 are.na 的平台。

当然,我还在思考,也可能不做。如果你感兴趣,欢迎 给我发邮件 。

希望我们都不要认输。


  1. 编程语言 Clojure 的创造者。 ↩︎

啸风山庄

2026-10-21 01:29:39

《啸风山庄》是经典英国文学 Wuthering Heights 的新译本,译者是赖慈芸。这本在大陆更常见的译名是《呼啸山庄》,在台湾的译名是《咆哮山庄》,译者本人是台湾师范大学翻译研究所教授,可以说这个新译本统一了两岸的译名。书中还附上《啸风山庄》的翻译简史,读起来蛮有趣。曾经 Wuthering Heights 还被译作《狭路冤家》《魂归离恨天》,后者给我的感觉是把 Wuthering 理解为冤魂不散的声响(毕竟主要人物凯瑟琳和希斯克利夫死后的鬼魂似乎都在人间游荡),把 Heights 理解成了高空,也是值得品味的译法。

这个译本我通篇读下来,觉得语言很通顺,几乎没有译制腔,也没有过度本土化,注释也很完善。只不过有不少对情节进行解读的注解,我其实觉得有些影响阅读。理想国的装帧也很好看,大小合适,捧起来很舒服,我之前还在飞机的狭窄座椅上读过。在万米高空读 Wuthering Heights,也别有一番韵味了。

对于文学经典,我不好妄下评论,不过我也确实没有批评的话要讲。我很喜欢艾米莉·勃朗特对人物的塑造,只可惜她一生只出版了这一部作品,而且似乎在初次出版时并不受欢迎。她的姐姐夏洛特·勃朗特(《简·爱》的作者)在给再版的《啸风山庄》写序的时候,还为妹妹在书中描写的各种不道德的行径致歉,她没有意识到自己的妹妹写出了一篇杰作。译者导读里写道,如今文学界普遍认为《啸风山庄》的文学造诣要高于《简·爱》。

我读《简·爱》还是小学,如今已经忘干净了,只记得主角很惨,最终没能和爱人在一起,她爱的男人有一个精神病妻子。《简·爱》还被译作《孤女飘零记》,和《魂归离恨天》一样都有悲剧基调。《简·爱》的人物关系看起来不如《啸风山庄》复杂,而且道德水准有点太高了,我就喜欢看扭曲和阴暗的情节。当然《简·爱》也是地位相当的文学经典,包含超越时代的女性主义思想,“夏洛特·勃朗特被誉为在故事里最早使用私我意识的作家”1,这点我也觉得很好,稍早一些文学作品都很少描写人物的自我意识,甚至他们可能没有很强烈的自我意识,即便是《 包法利夫人 》和《 红与黑 》里关于偷情的描写,也见不到对人物复杂心理活动的直接描写。《简·爱》的文学成就有很大一部分就体现在这点上。

那么《啸风山庄》的文学成就呢?好吧,我向来不喜欢讨论文学作品的时代意义和影响,就不勉强自己了,写我自己的感想就好。

非道德性

我向来喜欢看道德处于灰色地带的作品,因为合乎道德的好人谁都写得出来,太过正直反而缺少文学所追求的那种真实——这也是我 不喜欢诺兰导演的《奥德赛》 的原因之一。我想要看到的是真实的人在糟糕的处境下做出两难(甚至三难、更多难)的抉择。道德抉择本质上是伪抉择,让角色从绝对的好和绝对的坏之间选择,完全是耍流氓,谁都知道要选第一个。只会服从定言令式的人怎么会打动人心?

《啸风山庄》的非道德性,主要围绕希斯克利夫(Heathcliff)展开。他被恩萧老爷捡回家之后,因为是黑人,还是身份不明的小孩,所以一直被啸风山庄里的其他人欺负,尤其是与辛德里·恩萧(恩萧家的少爷)结下了梁子,但是与凯瑟琳·恩萧(辛德里的妹妹)是青梅竹马。

后来,辛德里离开山庄,回来时娶了个名叫法兰西斯的女人,诞下一子名为哈里顿。辛德里是在恩萧老爷病逝后才带妻子回家的,可能是怕父亲不赞同婚事。法兰西斯的身份不明,但猜想应该是与恩萧家的乡绅身份不符。辛德里的性格不好,父亲和妻子双双死去后,他一蹶不振,恩萧家逐渐破败,啸风山庄里就出现了这么个神智不清的酒鬼。哈里顿没有受到什么好的照看,有次辛德里甚至把它从楼上丢下来,被希斯克利夫接住了。

希斯克利夫和辛德里的儿子哈里顿之间的关系很奇怪。哈里顿本是啸风山庄的女管家迪恩太太养大,后来迪恩太太跟随小姐凯瑟琳嫁到了鸫翔庄园,便离开了哈里顿。哈里顿受希斯克利夫的影响最深,这孩子性格暴戾(可以说跟他父亲也像吧),似乎是讨厌所有人,就喜欢希斯克利夫,因为希斯克利夫教他说脏话。后来辛德里死了,这孩子就一直跟着希斯克利夫,长大了也没读过书。乡绅的孩子被希斯克利夫养成了个没文化没教养的农民,这就是他报复恩萧家的方式之一。

凯瑟琳与希斯克利夫

希斯克利夫至死都一直爱着青梅竹马凯瑟琳。他们之间的感情很复杂,说是爱恨交织,有过度简化之嫌。凯瑟琳·恩萧在小时候和希斯克利夫一起去鸫翔庄园,也就是林顿一家的大庄园里玩,被林顿家的看门狗咬伤了,在庄园里修养了几个月,而希斯克利夫则被当作野孩子赶了出去。恩萧家对教育不是很看重,再加上凯瑟琳和希斯克利夫一起玩闹,显得很没教养,林顿夫人就把凯瑟琳收拾干净,教她做个淑女。回来之后,她的气质和性格都大变样,而希斯克利夫颇为不满,他感到自己被嫌弃了。这应该就是他们矛盾的开端。

其实从这里就能看出,凯瑟琳和希斯克利夫的关系之所以无法进行下去,很大程度上是受到了外界的规训。毕竟,凯瑟琳真的字面意义上地被“规训”了一番。林顿夫妇就是一个代表,他们象征着一套普遍的价值观,而这样的价值观是不允许凯瑟琳这样的小姐和希斯克利夫这样的野孩子在一起的。

凯瑟琳在要谈婚论嫁的时候也意识到了这一点,选择了和埃德格·林顿结婚,门当户对。同意结婚之后,凯瑟琳还和管家迪恩太太讨论过此事。从她的言语中可以发现,凯瑟琳最爱的是希斯克利夫:

我爱他,不是因为他好看,而是因为他比我更像我自己。

然而凯瑟琳不能做自己,而且她的自我意识也没有像现代人那样强烈到不做自己就活不下去,尽管有很多迟疑,她认为自己嫁给林顿是合理的。凯瑟琳的动机是我认为最值得品味的,凯瑟琳还说她自己就是希斯克利夫,她显然认可希斯克利夫是他的灵魂伴侣,但她非常清醒地做出了不和自己的灵魂伴侣结婚的决定,而且,她似乎并没有感到十分痛苦。

这对我而言难以理解,兴许是因为我和凯瑟琳这一人物在意识形态上的区别吧。

后来,希斯克利夫离开了啸风山庄,与凯瑟琳分道扬镳,也与恩萧家分道扬镳。回来之后,他也像凯瑟琳从林顿家回来时一样大变样,成了个成功人士,还赚了不少钱。此时凯瑟琳已经嫁入鸫翔庄园了,而希斯克利夫则向仇人辛德里在啸风山庄租了一间房。辛德里还是恨希斯克利夫,但是他更爱钱,所以收了这个房客。

此时,希斯克利夫的报复计划就正式开始了。

凯瑟琳的动机真的让我捉摸不透,她在希斯克利夫回来之后非常激动,一定要让家人与情敌来往。希斯克利夫并不打算追回凯瑟琳,他拐走了埃德格的妹妹伊莎贝拉·林顿——这应该是希斯克利夫复仇计划的开端,也是他非道德行为的开端。伊莎贝拉当时是真的被希斯克利夫的花言巧语迷惑,背着哥哥和他私奔了。

希斯克利夫当然不爱伊莎贝拉,根本不关心这个妻子。伊莎贝拉到了啸风山庄之后,连个换衣服和睡觉的地方都没有,因为主人辛德里不顾家事,山庄里已经没有几个干活的仆人了,希斯克利夫更是对伊莎贝拉毫不过问。最后她带着自己和希斯克利夫剩下的儿子,逃到了城里去住。哥哥已经不想要私奔的妹妹回家,希斯克利夫也不去找她,是她一人把林顿·希斯克利夫抚养长大。

在此期间,凯瑟琳也因为希斯克利夫的事情被搞得精神失常,得了怪病,生下女儿没多久就去世了。女儿也叫凯瑟琳,一般以小名凯茜称呼。凯瑟琳的死对希斯克利夫冲击很大,他在凯瑟琳死前的几天还在鸫翔庄园的地界躲藏,想要找机会接近凯瑟琳。不过他的注意力很快又被复仇之心转移了。

一直在城里和丈夫希斯克利夫分居的伊莎贝拉病逝,埃德格林顿本想把妹妹的孩子带回来抚养,但他当然无法和希斯克利夫这个“正当”的父亲争夺抚养权。继哈里顿之后,希斯克利夫又要残害另一个无辜的孩子了。

凯茜与林顿

接下来就是第二代的故事了。小林顿被接到鸫翔庄园暂住的时候,凯茜很喜欢这个表弟。林顿体弱多病,一直觉得不太舒服,可刚到没多久,与表姐才建立起感情,就又要被拉着去啸风山庄和希斯克利夫一起住。啸风山庄的环境和仆人的态度,他的母亲伊莎贝拉已经见识过了。总之,他过得并不好。

希斯克利夫并不想借林顿报仇。他教坏哈里顿是因为憎恶辛德里,他说,一想到辛德里的儿子比起喜欢父亲更喜欢自己,他就觉得非常舒畅。他对伊莎贝拉并没有多大的怨恨,毕竟伊莎贝拉只是他向埃德格(也就是抢走了凯瑟琳的男人)报仇下的一步棋,拐走他妹妹,让林顿一家蒙羞,就已经够了。他留下林顿,是因为埃德格也病弱,时日无多,他死后,应当由伊莎贝拉继承遗产,而伊莎贝拉也死了,所以应当由林顿继承遗产。他只要保证林顿活得比埃德格长,就能拿到遗产。

事实上他也的确做到了。辛德里死了,哈里顿忠诚于希斯克利夫,啸风山庄已经在他的掌控之下。接着他又耗死了埃德格,林顿家的财产也落入他手。这附近最大的两户人家,如今都归希斯克利夫所有了。这才有了本书第一章,洛克伍德先生从希斯克利夫那里租下鸫翔庄园的情节。

林顿死前,与凯茜来往密切,因为他们已经在短暂的相处中喜欢上了对方(是的…… 他们是表姐弟…… 凯茜是埃德格·林顿的女儿,林顿是伊莎贝拉·林顿的儿子…… 这才是本作的最大道德污点吧!)。希斯克利夫不允许林顿出门,埃德格也不允许凯茜靠近啸风山庄,因为担心遇到希斯克利夫这个灾星。

Well, it wasn’t sexy if it wasn’t forbidden…

总之,凯茜非常聪明地用书贿赂马夫,让他帮忙送信,他们两个就这么一直通信。迪恩太太发现那些情书之后,威胁要告诉她父亲,这才阻止了他们。但我说实话,这也只是让凯茜更想和林顿搞在一起了。后来迪恩太太卧病在床,凯茜就每天晚上骑马去啸风山庄找林顿。

希斯克利夫为什么不阻止这两个人呢?其实他反而在撮合这两个人,因为林顿和凯茜结婚对他有好处。埃德格的女儿若是嫁到啸风山庄来,一方面能激怒埃德格,另一方面他也更容易拿到林顿家的遗产。

也正是他的撮合,让凯茜和林顿的关系变得非常扭曲。林顿本身体弱多病,严重的时候甚至连走动都非常痛苦,再加上没有受到好的教育,也难怪他性格很不好。林顿在病重的时候,依然想要凯茜到啸风山庄来陪他,但两人陪伴的过程中,林顿什么都不想做,因为凯茜想做的事情对他来说都太累了,林顿又常常耍脾气,他们的相处其实相当不愉快。

但希斯克利夫不能容许两人不和,他在私下对林顿又打又骂,折磨这个本身就被病痛困扰的孩子,要他在凯茜面前表现好点。有好几次,林顿和凯茜见面时,明明因为身体不好不想做任何事情,连话都不想说,嫌凯茜和一同前往的迪恩太太太吵,却还是执拗地不要两个人走,因为希斯克利夫在远处看着,要是没把凯茜哄好,他就会被收拾。

再后来,希斯克利夫用林顿把凯茜和迪恩太太引诱进了啸风山庄大门,然后把二人软禁了起来。迪恩太太被关起来五天之久,在此期间,希斯克利夫已经逼着凯茜和林顿成婚了。

被放出来之后,林顿对迪恩太太说的话简直令我后背发凉:

“你们不放她走?你这小笨蛋!”我叫起来,“立刻跟我2说她在哪一件,否则我让你哭出来。

“要是你想去找她,爸爸才会让你哭出来呢。”他3说,“爸爸说,我不应该对凯瑟琳心软:她是我的妻子,她想离开我是不对的。爸爸说她恨我,希望我死掉,这样我的钱就会变成她的;可是她不应该拥有我的钱,她也不应该回家!她永远拿不到我的钱!无轮她怎么哭,哭到生病也一样!”

说完他又继续吃糖,闭上眼睛,好像他快睡着了。

林顿是坏人吗?他说话的语气和方式的确令人气恼,但实际上他也是受害者,是个可怜的孩子,失去了母亲,而父亲被复仇冲昏头脑,不干人事。疾病和精神的双重折磨让一个人的人生就这样被毁掉了。不过更让我感到发毛的是,有人能如此理直气壮地说出这样邪恶且毫无逻辑的话,而我们竟然无法谴责他。

一对冤魂

希斯克利夫的死很蹊跷。故事发生在最后的一两章,此时房客洛克伍德已经离开接近半年,再次返回鸫翔庄园时发现迪恩太太4已经不在,便到啸风山庄寻找。他发现啸风山庄比以前看起来怡人不少,这命途多舛的一群人的生活也有所提升。走近厨房见到迪恩太太,他才得知希斯克利夫已经死了。

于是迪恩太太继续给洛克伍德讲故事。林顿病死后,凯茜和哈里顿慢慢地走近了(凯茜是有什么奇怪的情节吗?哈里顿也是你表哥啊!凯茜是凯瑟琳·恩萧的女儿,哈里顿是辛德里·恩萧的儿子!)。抛去道德不谈的话,这一对其实还挺可爱的,比凯茜和林顿的安坏关系(大概可以这么说吧……)要好太多。

不知道为什么,哈里顿在后面几章的形象其实很戳我 XP还挺可爱的。在此之前凯茜和林顿一直嘲笑他没文化,不识字,是个文盲。尤其是林顿,常常在凯茜面前开哈里顿的玩笑,讲话很过分。哈里顿其实是想读书的,但被凯茜嘲讽,气得一个劲儿地骂人,竟然还直接把书烧了。后来在迪恩太太的教育下,凯茜才改变了行为。她其实没有恶意,只是有些娇生惯养,不习惯和没受过教育的人讲话。后来这两个人就开始了非常青春浪漫的校园式恋爱,凯茜教哈里顿识字读书,哈里顿也听得很认真。

嗯?你问我哈里顿可爱的点在哪里?呃呃,难道不觉得有些笨笨的男人想要变好,即害羞又好强地被逼急得涨红了脸的形象很可爱吗?让人很想要戏弄一下他呢。

与此同时,希斯克利夫已经不管这两个人了。他们在不在一起,对他来说都不重要。此时的他仍然身强体壮,但精神已经失常了,常常在幻觉里看到凯瑟琳。他去世的前几天,感到莫名其妙的高兴和兴奋,晚上不睡觉,出门走路,白天才回来,谁也劝不了他。

离世后,有小孩说在路上看到希斯克利夫,他不敢过去。这一描述暗示希斯克利夫的亡魂还留在附近,结合洛克伍德先生刚到啸风山庄时梦到的凯瑟琳的冤魂,可以想象这两个人终于以鬼魂的形式重聚了。他们的爱情并不被活人的世界允许,直到死后才能如愿。这或许也是为什么希斯克利夫会对死亡的临近感到激动吧。

叙述方式

我并不喜欢研究叙事策略和形式,但这部经典作品的叙述方式的确有很鲜明的特色,我这个门外汉也能读出些门道来。

首先是叙述者的设置,以及叙述者本人的时间与故事时间的交织。本书可以当作是洛克伍德先生的日记,他记录了进入鸫翔庄园之后的见闻,一直到他最后一次回到鸫翔庄园。恩萧和林顿一家的故事,是洛克伍德先生在鸫翔庄园入住时,从迪恩太太口中听来的。从到庄园、听故事、离开庄园、再次返回又听故事,这就是洛克伍德先生本人的时间线,中间他还几次去啸风山庄拜访房东希斯克利夫先生,每次都有不同的见闻,也与故事本身形成呼应。

迪恩太太口中的故事(以及亲自拜访时的见闻侧面反映出的故事)本身也有自己的一条时间线。洛克伍德的主观体验,和恩萧林顿家发生过的客观事实,以日记和口述的形式交织在了一起,是相当有趣的阅读体验。如何有趣,大概要亲自读过才能体会。

其次是类似戏剧的结构。这条我是从我读的译本附上的作品解读里读来的,我自己对戏剧结构倒是没有什么认识,不过可以看出,《啸风山庄》很明显地被分成了上下两幕,甚至还有幕间休息。第一幕是凯瑟琳与希斯克利夫的爱恨情仇,是第一代的故事。第一幕结束之后,洛克伍德还去啸风山庄拜访,之后才回来继续听故事,这便是幕间休息。第二代的故事自然就是第二幕了,以凯茜和林顿的关系为主,以希斯克利夫的死和凯茜与哈里顿的关系收尾。

另外,我也很难不被迪恩太太这个小角色打动。可能是被她对凯瑟琳、凯茜和两家人的忠心打动,也可能是被她的正义感和是非观念打动,而且整个故事几乎都是从她的口中说出,我想很多读者都会对这个讲故事的人产生好感。这种叙事方式除了形式新奇,结构精巧,还能让读者爱上故事里的角色,怎么不好呢?

本来说不纠结故事情节的,结果还是忍不住写了这么多。时间已经不早了,那就先写到这里吧。


  1. 参见: 简·爱 - Wikipedia  ↩︎

  2. 这里的「我」是迪恩太太。 ↩︎

  3. 这里的「他」是林顿·希斯克利夫。 ↩︎

  4. 洛克伍德先生入住鸫翔庄园时,迪恩太太还在那里做管家。所有关于恩萧和林顿两家的故事,都是迪恩太太口述给洛克伍德听的。 ↩︎

What’s next for the indie web?

2026-10-18 17:31:05

I have been blogging in English for some time, but this is my first time participating in an indie web conversation. So I think I should introduce myself first.

I’m Eltrac. I’m mainly a web developer but I like all kinds of thing about programming. I code in Go, Clojure and Lua, and I explore new tech all the time. I usually blog more than three times a week, but most of my posts are written in Chinese. I recently became an active member of Codeberg e.V. and have been working on my own FOSS project weepinbell for a while. It’s a new Webmention receiver written in Clojure.

In this post, I will talk about why I developed weepinbell, how I designed its architecture, what I think of the IndieWeb movement and many things.

Why weepinbell?

I wrote to Brennan recently and he was really nice to mention weepinbell on his blog . In that email, I talked about how I set a new goal for weepinbell (i.e. an alternative to webmention.io) because of Aaron Parecki rewriting webmention.io with Claude. Later, in Aaron’s response to Brennan’s new post , he mentioned weepinbell as “a direct response to my rewrite”.

I would like to make things clear. I didn’t start developing weepinbell because of Aaron’s rewrite. I started early in this May to learn Clojure. I’ve been blogging (in Chinese) for years and of course I love the concept of IndieWeb. Naturally, my practice project for learning a new language involves IndieWeb technologies.

The motive is that I want to get my hands dirty with the Webmention standard. I read through the W3C specification document and started putting things in parentheses. You can still find snippets from that document in my code as comments. I bascially code against the specification. It was a fun experience for me.

 (if-let [error-msg (check-request source target allowlist)]
 ;; respond if request verification failed
 (bad-request {:status 400
 :message error-msg})
 ;; proceed if request is ok
 (do
 ;; The receiver SHOULD check that target is a valid
 ;; resource for which it can accept Webmentions.
 ;; This check SHOULD happen synchronously to reject
 ;; invalid Webmentions before more in-depth verification begins.
 ;;
 ;; Webmention verification SHOULD be handled asynchronously
 ;; to prevent DoS (Denial of Service) attacks.
 (put! wmc new-wm)
 ;; respond with 201 when Webmention passed checks and
 ;; was sent into queue, include a Location header
 (let [wm-id (digest (str source target))
 wm-url (str (:base-url config) "/api/mention/" wm-id)
 response {:status 201
 :message "Webmention is accepted and pending for verification."
 :url wm-url}]
 (created wm-url response))))))

This snippet is from receiver.clj .

After the initial quick and rough implementation, I stopped working on weepinbell for a few months. And it had been receiving Webmentions for my blog during that time. Everything worked and I was content. It was bascially a personal tool.

Then the webmention.io rewrite came to my attention.

Why does it bother me? I don’t even use webmention.io any more. I see some of my friends seeking alternatives, and… there aren’t many to choose from! There is webmentiond by Horst Gutmann and some other implementations here and there. My friend Chlorine developed CircleAt in Rust. And I’m sure there are many more. But frankly, none of them truly count as “alternative to webmention.io”.

Don’t get me wrong. I think many of them are great for their purpose, i.e. small service one can self-host. However, they can be quite difficult for the non-tech indie web folks and those who simply do not own a server. They need a public instance.

Refactoring for scalability

I decided to continue development of weepinbell and make it “the next webmention.io”, at least that’s what I’m going for.

So I took a closer look at my code. They’re not that messy, well partitioned (for the most part) and maintainable. I can swap some of the parts easily because I used hook functions and vtables.1 The architecture is flexible enough, and the Lisp power also makes modifying software really easy.

The following part will be somewhat technical. Feel free to jump to the next section if you don’t want to read about system design. The bottom line is: weepinbell would be lightweight enough for people to self-host; it would also be scalable enough to support large traffic.

I’m a Go programmer by the living.2 And Clojure’s core.async library greatly resembles Go’s concurrey programming style, which means my skills of building scalable applications in Go translates.

Webmention is not big data. Storage is definitely not the bottleneck. In fact, up until a few commits ago , weepinbell does not even support any type of database. It unloaded everything in a single file and then loaded it back when needed. And it just… worked, for months.

Of course this is neither performant nor safe. If I want to build something reliable, I need a database. If you have taken a look at that commit I linked, you might notice that the business logic is untouched. All that’s added is a new storage implementation, a new config option, and a few lines in the main component (core.clj).

I’ll probably elaborate on architecture flexibility in another post. That’s something I’m really proud of. The gist is that using a new type of database and ORM is easy, and will be just as easy if I ever decided to redo the storage layer again.

;; all I need to make sure is that the new storage implementation
;; returns a valid storage vtable, like this:
(defn use-store [db-file]
 (let [ds (make-datasource db-file)]
 (create-tables ds)
 {:append (partial insert-webmention ds)
 :update (partial update-webmention ds)
 :delete (partial delete-webmention ds)
 :get (partial get-webmention ds)
 :get-by-domain (partial get-webmentions-by-domain ds)}))

Beside a new SQLite implementation, I added a storage worker that handles I/O asynchronously. So when processing a new webmention, the processor won’t have to wait for DB transaction (or appending to a file; it does not care about what’s going on underneath) to end. It simply delegates to the storage worker and keeps processing the next webmention.

The processor delegates by putting an operation object into a channel. It’s similar to what I already have for receiver. When a new webmention request is sent to the web server, receiver does some basic checks (e.g. does source and target look like real URLs?) and then put the request into the webmention queue. A processor will pick it up and make sure source has truly mentioned target, parse microformats, wrap data into a valid record, and hand it over to storage worker.

Reading from a channel is thread-safe. And since I/O is handled internally by storage worker, it can also be thread-safe, without the need for a mutex lock. So we can add as many processors as we want to handle webmentions concurrently.

The overall architecture looks like this:

Architecture of the receiver part of weepinbell, drawn using Excalidraw

That’s horizontal scaling. For self-hosters, simply set pipeline-concurrency to 1, and there will be only one processor handling webmentions, using a reasonable amount of RAM and CPU.

For large groups or someone who wants to host a public instance, change a number in config and you’re good to go.

All of that is compiled to a single .jar.3

The microformats nightmare

Before I move on to a non-tech topic, there’s one thing I have to complain: Using microformats markup is easy. Parsing it is really not.

Here is an example snippet from microformats.io .

<div class="h-card">
 <a class="p-name u-url" href="http://blog.lizardwrangler.com/">Mitchell Baker</a>
 (<a class="p-org h-card" href="http://mozilla.org/">Mozilla Foundation</a>)
</div>

It’s just regular HTML with standardized class names like h-card and p-name. It’s easy to add a few classes to your HTML. However, it’s still hard to parse structural data from HTML with just a few more classes.

Good thing there are already some parsers available… right?

Production ready parsers are available for: Go, Node, PHP, Python, Ruby.

Right… I’m using Clojure…

Clojure can use Java code, since it runs on top of it. I found mf2j , an “In-development Java parser for microformats2”. It hasn’t been updated since… 10 years ago.

There is a Clojure implementation . It’s archived, with its last update committed 11 years ago.

Do I implement a new one myself? Do I pick up where they left off? How do I make sure I don’t burn out and abandon it as well? After all, Clojure is a niche language and IndieWeb is also kind of niche. I can’t imagine there being another Clojurist building something that relies on microformats. At least, I don’t know anyone like that.

I briefly considered running a micro-service that wraps the Go parser, and weepinbell can talk to this service using RESTful API or gRPC. But it feels heavy and everything just won’t fit in a single .jar.

So how can Clojure run programs written in a non-JVM language? What else runs on JVM? Scala, Groovy… Wait, will WebAssembly work?

Yes! WebAssembly runs on JVM. There is a native JVM WebAssembly runtime called chicory .

I spent an afternoon studying Extism and chirocy. Then I wrote a tiny little Go wrapper of Will Norris’ parser library .

package main

import (
	"bytes"

	"github.com/extism/go-pdk"
	"willnorris.com/go/microformats"
)

//go:wasmexport parse
func parse() int32 {
	input := pdk.Input()
	r := bytes.NewReader(input)
	data := microformats.Parse(r, nil)

	pdk.OutputJSON(data)
	return 0
}

Then I compiled it to .wasm using tinygo compiler, bundled the .wasm inside .jar, and called the Extism plugin from Clojure using chicory. Voilà! Here’s mf2clj , a Clojure wrapper of WebAssembly compiled from a Go wrapper of an existing microformats parser.

By the way, I made a little logo just for fun.

A combination of Clojure logo and the microformats logo

You use it just like a normal library in Clojure. And it’s surprisingly fast.

(require '[mf2clj.core :as mf2clj])
(mf2clj/parse html-string)

More surprisingly, bundling a .wasm binary and the WebAssembly runtime inside the .jar is not as heavy as I thougt it would be. The .jar only grew 2 MB in size. Am I really running Java?

Nevertheless, the nightmare isn’t over yet. In the begining, I settled for the incomplete mf2j library and used it for a while. At that time, I called the JSON output “data structure from Hell”. I still do —— Everything is an array! Everything is deep inside something’s properties or children! Nothing is deterministic! Every website’s format is different!

{
 "items": [{
 "type": ["h-card"],
 "properties": {
 "url": ["http://blog.lizardwrangler.com/"],
 "name": ["Mitchell Baker"],
 "org": [{
 "value": "Mozilla Foundation",
 "type": ["h-card"],
 "properties": {
 "name": ["Mozilla Foundation"],
 "url": ["http://mozilla.org/"]
 }
 }]
 }
 }],
 "rels": {},
 "rel-urls": {}
}

In the end, I had to write a smaller parser to parse output a the original parser. I called this feature microformats reduction. I remember webmention.io having something similar.

"microformats": {
 "author": {
 "name": "Mitchell Baker",
 "url": "http://blog.lizardwrangler.com/",
 "photo": "https://blog.lizardwrangler.com/photo.png"
 },
 "type": "mention",
 "published": "2026-07-25T00:00:00-07:00",
 "content": {
 "html": "..."
 "text": "..."
 }
}

If something does not exists, it’ll just be null. The format is not final yet, I will make some adjustments. This can be turned off by ?microformats=raw when querying webmentions.

Also, I think more than half of the bugs I found in weepinbell have something to do with microformats-related features. Maybe we should switch to Open Graph and JSON-LD, folks?

Anyway, I’m just glad it’s finally working.

Slowing down

At this point, I’ve almost finished everything I have planned for the v1.0.0 release: a proper database, horizontal scalability, up-to-date microformats parser. As for hosting a public instance, that’ll involve authenticating users and making a dashboard for them to manage webmentions. That would be a separate project.

I plan to develop a service that sits in front of weepinbell, handling logging in and stuffs. I mentioned the idea in the project’s PHILOSOPHY.md .

I want to keep the core of weepinbell minimal. Those who want to be their own sysadmins don’t have to care about authentication. They don’t even need a frontend. They only need to self-host one .jar and use API to query webmentions, use RSS to receive notifications and use webhook to automate stuff. These are the built-ins.

A platform like webmention.io needs more than that. If I want to keep weepinbell “for both individuals and large groups”, I have to separate things. Also, this makes everything modular and swappable, a pretty good architecture decision.

My writing to Brennan was an attempt to get more Clojure developers on the team. Well, that didn’t go well. For the new platform, which will be named Ringabell4, I want to use more mainstream stack, probably Go, so that it’s easier for people to get involved.

For now, I’m still working on weepinbell’s test suite. The current test coverage is more than 83%, but there are still some edge cases that haven’t been covered by tests. I had to test a lot of paths by hand. Also it’ll take some time for the new SQLite implementation to be stable.

I don’t think we need to or should move fast when it comes to developing reliable software, with or without LLMs.

Independence, and now decentralization

In reply to Brennan’s recent question, “What do we do now?” , I think it’s finanlly time to get more decentralized.

Decentralization is never about moving away from the corporate web (that’s independence). It’s about not relying on a single point , no matter what the point is and who it involves.

I agree with Brennan on not conflating IndieWeb and the lower-case indie web. The IndieWeb camp is a nice community and a great introduction to the indie web. But I think we should form more indie web communities other than the IndieWeb camp.

omg.lol is an example. It’s a fun web community by Neatnik . They provide infrastructure like a simple profile page, Fediverse instance and email forwarding. Brennan is also building folk.zone , which hosts a fork of webmention.io and many more.

Those two may not be exactly alternatives to the IndieWeb camp, but they can be. And there can be more for people to choose from.

Tech folks can self-host their own infrastructure if they want. For the non-tech folks, there should be more alternatives to what the IndieWeb camp has to offer. Poeple who are capable can host public services and take a small community.

After all, communities are what brings the indie web together. We can and should build more communities (this is probably more like polycentricity rather than decentralization). We can have the LispyWeb for lisp programmers, ArtsyWeb for artists, and Indie Indie Literature Club!5 for book lovers. Those are just some random names I came up with.

Perhaps the upcoming Ringabell might be the next cozy small community. Who knows? To everyone who’s nice enough to finish reading this, I hope we can all find our people.


  1. A vtable in Clojure is basically a hashmap whose values are functions. It’s a technique mentioned in one of Uncle Bob’s books, Functional Design. ↩︎

  2. Actually I’m at the very begining of my career. Don’t think of me as an expert of anything. XD ↩︎

  3. Clojure runs on JVM, so it compiles to JVM bytecode, just like Java. ↩︎

  4. Meaning “ring a bell”. ↩︎

  5. The referrence is a galgame called “ Doki Doki Literature Club ”. :3 ↩︎

Coffee Break Clojure, Transducer

2026-10-18 12:26:01

在第三期《 只用圆括号怎么够? 》里,我讲到了 reduce。

先来简单复习一下:reduce 最少接收两个参数,形如 (reduce f coll),其中 f 是接收两个参数的函数,形如 (f x y);reduce 遍历 coll 中的每个元素,先对前两个元素求 (f a1 a2),1然后求 (f (f a1 a2) a3),以此类推。

(f x y) 这种函数叫作归约函数(reducing function)。

归约、Fold、高阶函数

之前我们用作示例的代码如下:

(def numbers '(1 43 231 23 99))

(reduce + numbers)
;; => 397

+ 可以视作归约函数,因为他可以这样调用:(+ x y)。当我们对上述代码进行求值时,主要执行了以下步骤:

  1. (+ 1 43) 得到 44
  2. (+ 44 231) 得到 275
  3. (+ 275 23) 得到 298
  4. (+ 298 99) 得到 397

使用 + 作为归约函数,对 numbers 进行归约(reduce),就实现了累加。

在函数式编程里,归约(reduce)也叫作累积(accumulate)、聚集(aggregate)、压缩(compress)和注入(inject),不过更常见的名字是折叠(fold)。折叠分左折叠(foldl)和右折叠(foldr)。

还记得第三篇讲过的 Cons 和 Lisp 的列表构造方式吗?Fold 的结构就像是函数版本的 Cons。

(reduce f [1 2 3 4 5])
;; 等价于
(f (f (f (f 1 2) 3) 4) 5)
(list 1 2 3 4 5)
;; 等价于
(cons 1 (cons 2 (cons 3 (cons 4 (cons 5 nil)))))

Clojure 的 reduce 默认是左折叠,即 foldl:

左折叠示意图;图源: 维基百科

要在 Clojure 里实现右折叠,只能把待操作的列表倒序:

(reduce f (reverse [1 2 3 4 5]))
;; 等价于
(f (f (f (f 5 4) 3) 2) 1) ; 实际上还是左折叠
;; 如果 f 和 + 一样满足交换律,就等价于
(f 1 (f 2 (f 3 (f 4 5))))) ; 等价于右折叠

折叠中的 f 本身也可以是折叠。还记得 into 吗?

(into [1 2 3] [4 5])
;; => [1 2 3 4 5]

;; 等价于
(reduce conj [1 2 3] [4 5]) ;; 这里的 reduce 以 [1 2 3] 为初始值
 ;; 如果没有初始值,就会用第一个元素做初始值
;; 等价于
(conj (conj (conj [1 2 3]) 4) 5)

如果要打平一个向量,比如把 [[1] [2 3] [4 5]] 变成 [1 2 3 4 5],就可以用 into 作为归约函数(reducing function),也就是把 (reduce conj ...) 作为 reduce 的 f。

(reduce into [[1] [2 3] [4 5]])
;; => [1 2 3 4 5]

;; 等价于
(reduce #(reduce conj %1 %2) [[1] [2 3] [4 5]])
;; 等价于
(reduce conj (reduce conj [1] [2 3]) [4 5])
;; 等价于
(conj (conj (conj (conj [1] 2) 3) 4) 5)

在 Clojure 中,能够被放在 reduce 里使用的函数就叫作归约函数(reducing function)。这个概念与函数式编程和 Lambda 演算的高阶函数(Higher-order function)有关,据说微积分中的导数也算一种高阶函数,但我这篇文章不是讲数学的(我也不会数学),所以就点到为止。

改造归约函数

总结一下,归约函数就是这样的一类函数:

(f result input) -> new result

这样的函数可以相互嵌套使用,不过一般传给 reduce 用。

接下来要引入一种新的函数,我没有找到中文译名,暂且称作变约函数(transducer)。它长这样:

(t (f result input)) -> (new-f result input)

Transducer 接收一个归约函数,返回新的归约函数。

返回函数的函数可能有些抽象,但是在 Clojure 中很常见。我举几个例子解释 Transducer 的用法。

假设我们要对向量自增,然后再累加,就要这样写:

;; 下面这个函数先是自增 [1 2 3 4] 得到 [2 3 4 5]
;; 然后再对 [2 3 4 5] 进行累加
(reduce + 0 (map inc [1 2 3 4]))
;; => 14

能不能让 + 这个归约函数,在把之前的结果和下一个元素累加起来之前,先给要累加的元素 + 1?也就是说,我们不用遍历一遍列表,生成新的、自增后的列表,然后再累加这个列表,而是在累加的过程中就把自增的操作也做了。

我们可以这样写:

;; 下面这个函数用 (map inc) 这个 Transducer
;; 把 + 这个归约函数,转换成了新的归约函数
;; 这个新的归约函数的作用是:先给单个元素 +1,然后再累加
(reduce ((map inc) +) 0 [1 2 3 4])
;; => 14

这里我们看到了 Clojure 1.7 引入的 map 的新用法。一般而言,(map f coll) 的作用是把 f 作用在 coll 中的每一个元素上,然后生成新的元素。

而 (map inc) 则是生成了一个 Transducer,我们把 + 作为这个 Transducer 的参数传入,得到了新的归约函数。也就是说,我们将 (map inc) 和 + 组合,构造了新的数据转换算法。 对集合中的元素 inc,然后再折叠 + 实现累加。

这种转换与具体类型无关,与惰性无关,与执行顺序也无关,只是纯粹的算法。非常便于复用。

将具体类型与转换算法解耦

Clojure 的哲学是简洁,这点并不只是体现在语法(syntax)上,还体现在语义(semantics)和语言的设计上。Rich Hickey 在《 Simple Made Easy 》里讲到,简单(simple)的东西就是不和其他东西交织(complect)在一起的东西。

Java 类不简单,因为它既是命名空间,又是数据结构,还定义了数据上的操作——类把三个东西交织在了一起。反观 Clojure,命名空间是单独的概念。

标照 Rich 本人对简单的定义,标准库里的 map、filter、reduce 等操作序列的函数,都不够简单,因为他们不仅涉及做什么操作(what to do),还涉及了怎么做(how to do it)——预设了惰性,预设了数据操作的顺序(序列都是从前往后线性遍历的),预设了数据的表示方式(返回的是序列)。

如果 map、filter、mapcat 等函数,能够与数据类型(序列)解耦呢?也就是说,如果 map 只定义做什么操作(what to do) ,把怎么做(how to do it)交给其他组件呢?

比方说,map 可以这样实现:

(defn map [f]
 (fn [f1]
 (fn [result input]
 (f1 result (f input)))))

这个 map 是构造 Transducer 的函数,它接收函数 f,返回了一个返回函数的函数。我们之前看到的 (map inc) 就返回了一个 Transducer。

这就是对做什么(what to do)的定义。

reduce 则定义了怎么做(how to do it),因为 reduce 是对序列的操作,而且是具有惰性的,返回值是惰性序列。所以把 reduce 的怎么做(惰性地、顺序地、当作序列执行左折叠)与 ((map inc) +) 的做什么(“元素自增后再累加”)组合,就得到了完整的数据操作。

如果不想要折叠呢?我能不能直接用 (map inc) 累加元素?

将 sequence(输入和输出数据为序列、惰性、顺序操作)与 (map inc)(“自增集合里所有的元素”)这些要素组合起来,才能得到完整的数据操作:“自增序列里所有的元素逐一自增,返回惰性序列。”

(sequence (map inc) [1 2 3])
;; => (2 3 4)

如果不想用序列和惰性序列,可以换成向量或者集合:

(into [] (map inc) [1 2 3])
;; => [2 3 4]

(into #{} (map inc) [1 2 3])
;; => #{4 3 2}

如果没有用 Transducer,我们就必须先把向量变成序列,然后再把序列变回向量。因为 map 和许多函数都只操作序列和惰性序列,和具体的数据类型耦合。

(map inc [1 2 3])
;; => (2 3 4) ; <- 这里得到的是序列,而非向量

(vec (map inc [1 2 3])) ; <- 必须手动转换成向量!
;; => [2 3 4]

(into [] (map inc) [1 2 3]) ; <- Transducer 不关心数据类型
;; => [2 3 4]

上一篇《 异步编程和并发控制 》提到的 channel 也可以接收 Transducer,携带 Transuder 的 channel 会对经过它的所有数据做变换。

(def growing-channel (chan 1 (map inc)))
;; => #'user/growing-channel
(put! growing-channel 1)
;; => true
(poll! growing-channel)
;; => 2

Transducer 的好处就是,由于他只定义了抽象的「做什么」,而不关系具体的「怎么做」,所以可以非常轻易地用在任何地方,不像 map 和 filter 只能作用于序列(或者要把向量、集等其他数据类型转换为序列)。

组合 Transducer

一般来说,要做 Clojure 里做一系列数据操作,会组合使用很多序列函数。

(->> [1 2 3 4 5 6 7 8]
 (filter odd?) ; 1. 过滤,只留奇数
 (map #(* 2 %)) ; 2. 全部乘 2
 (reduce +)) ; 3. 累加
;; => 32

有了 Transducer,我们可以把这一系列操作抽象出来,定义成一个独立的、可复用的操作。

(def double-odd-numbers
 (comp 
 (filter odd?)
 (map #(* 2 %))))

; 可以用来累加
(reduce (double-odd-numbers +) 0 [1 2 3 4 5 6 7 8])
;; => 32
; 等价于
(transduce double-odd-numbers + [1 2 3 4 5 6 7 8])
;; => 32

; 可以不加,返回集合
(into #{} double-odd-numbers [1 2 3 4 5 6 7 8])
;; => #{6 2 14 10}

; 如果你想,还能交给 channel
(def double-odd-chan (chan 1 double-odd-numbers))

comp 的作用是把函数组合起来。(comp f g) 就相当于 (f (g ...))。除了用来组合 Transducer,还可以组合普通的函数:

(filter #(not (zero? %)) [1 2 0 3 0 2 0])
;; => (1 2 3 2)

; 等价于
(filter (comp not zero?) [1 2 0 3 0 2 0])
;; => (1 2 3 2)

不妨做个小练习,定义一个 pipe 函数,作用是接收可变数量的函数2,将它们构造成类似 -> 箭头宏的流水线结构,但是不执行这个结构,而是返回函数。比如 (pipe f1 f2 f3) 就返回按顺序执行 f1、f2、f3 的函数,(pipe f1 f2 f3) 相当于 (f3 (f2 (f1 data)))。

参考答案
(defn pipe [& functions]
 (apply comp (reverse functions)))

或者:

(defn pipe [& functions]
 (fn [value]
 (reduce (fn [result f] (f result)) value functions)))

要注意 Transducer 和一般函数的区别。

(comp
 #(map (fn [x] (* 2 x)) %)
 #(filter odd?))
; comp 的结果是 (map fn (filter odd? ...)),很符合直觉
; 返回的函数用来直接操作序列

(comp
 (filter odd?)
 (map (fn [x] (* 2 x))))
; comp 的结果看似是反过来的 (filter odd? (map fn ...)),其实不然
; comp 并不是 -> 宏,而是函数,会先对参数求值
; filter 和 map 求值之后得到了 Transducer
; comp 是把两个 Transducer 组合起来了,而不是把 filter 和 map 组合起来
; 返回的函数用来和 reduce, into, sequence, chan 等一起使用

最后

这篇的内容可能略显抽象,因为涉及了很多对函数的操作,而不是对数据的操作。如果没理解也没有关系。Transducer 能让数据类型与算法解耦;能轻易地组合复用;给了 Clojure 程序员更多的控制权,所以能借助它来写出性能更好的代码——这些都是好处,但没有这些好处也照样能写 Clojure 代码。

那么,回见!


  1. 也可以指定初始值,(reduce f init coll) 会先求 (f init a1),再求 (f (f init a1) a2) ↩︎

  2. 用 & 表示参数数量可变,(fn [& args]) 中的 args 就是包含可变参数的序列。 ↩︎

Coffee Break Clojure, Async

2026-10-12 23:48:37

上一篇 我提及了如何用 Ring 开发 HTTP 服务器,也提到 Ring 提供了同步 API 和异步 API。那么,什么是异步编程?为什么需要异步?在什么情况下需要让一个程序同时做很多事情?

同步、异步、并发和并行

同步(synchronization)的中文名字可能会让人误以为是多个实体同时进行操作,实际上同步的意思是指系统中所有事件都依照相同的顺序进行协调。可以用工厂流水线举例,假如流水线中一个工人需要等上一个工人交付一部分工作之后才能干活,整个流水线都要所有人一起干活才能转动起来,这个流水线就是同步的。反之,假如某些工作可以分开做,比如一部分人负责生产 A 零件,另一部分人负责生产 B 零件,两者互不干扰,而最终把 A 和 B 组装在一起的又是另一拨人,这个系统就是异步的(asynchronous)。

异步(asynchrony)和并发(concurrency)很容易搞混,并不是同一个概念。异步是指系统中各个事件的状态不同步,而并发通常是指多个线程(或类似线程的东西)同时做类似的操作。

以下分别是异步和并发的例子:

  1. 小明和小华准备去桌游店玩,小明负责去买饮料和零食,而小华先去桌游店占位置。他们的状态是不同步的,小华并不需要等到小明买完东西才能去桌游店。不过,小明和小华最终会在桌游店汇合,他们一起玩桌游时,就又回到了同步状态。

    异步通常是程序里的一小部分,如果系统中所有任务都是异步的,那这些任务大概就不应该放在同一个系统里了,因为状态之间没有同步的必要。

  2. 某推销团队在城市某片区招募了 100 名推销员,给他们派发相同的任务:向路人介绍产品,并尽可能获客。从整个推销计划的角度来看,这是个并发度为 100 的系统,增加并发度能够提高单位时间内的销售成绩,减少并发度效果则相反。也可以说推销员之间是异步的,但系统整体是同步的,销售成绩最后都会汇总到一个地方。

简单来说,异步是局部状态,对系统整体影响不大,而并发则事关整个系统的执行效率,是更宏观的概念。

从编程实践来看,并发往往是更大更复杂的系统才考虑的(比方说网络爬虫必然不能一次只爬一个网页),而异步则是较小的程序会采用的轻量方案(比方说网页渲染中加载某些资源时为了不阻碍主线程,所以要异步加载)。

至于并行(parallelism),这个概念经常在操作系统的语境里和并发做区分。并行是指程序同时运行在多个 CPU 核心上,是物理意义上的同时发生;并发是指 CPU 调度器时进程交替执行,由于交替速度非常快,所以即便是在单核机器上,也能造成程序在同时运行的假象。

脱离操作系统的语境,这种区分的意义就不大了。在编程的语境下,并行是指程序在同时进行的事实(开发者很少在乎程序究竟是在多核还是在单核机器上运行的,这对编写程序而言不重要),而并发则是组织程序的一种方式。

接下来就看看如何在 Clojure 里组织异步和并发。

面向未来编程

前文提到,异步是局部状态,系统最终还是要同步信息的。许多编程语言里的做法是,在当前线程(thread)里启动子线程,相关的线程都持有对同一块内存区域的引用,他们在这一块内存区域里读写数据。之前我提到,Clojure 是 函数式编程语言 ,不允许共享状态,那怎么办呢?

假设我们需要从某个 API 获取数据,但问题是网络状态不稳定,这个操作可能很费时间。与此同时,我们还要处理手头已有的数据。

;; 这里用到了 https://github.com/dakrone/clj-http
(require '[clj-http.client :as client])
(let [response (client/get "https://example.com")
 result1 (process-data (:body response))
 result2 (process-data some-data-we-already-have)]
 (merge result1 result2)))

上面这段代码会在求值 client/get 这个形式的时候被阻塞,直到收到 HTTP 响应体。假设 process-data 操作也要费时间,这个程序的效率就很低,因为在等待网络传输的时间里,程序本身什么都没做。

如果不能提前定义 result1 变量,然后再异步执行完 client/get 之后写入 result1 变量,主线程怎么拿到数据呢?

这时可以用到 future 和 deref。future 宏接收一系列表达式,然后立刻返回一个 ref(引用),在之后,我们可以用 deref 尝试获取 ref 指向的值。如果 ref 指向的值还不能获取,就阻塞调用 deref 的线程。

(require '[clj-http.client :as client])
(let [response (future (client/get "https://example.com"))
 result2 (process-data some-data-we-already-have)
 result1 (process-data (:body (deref response)))]
 (merge result1 result2)))

把 client/get 包裹在 future 宏里之后,我们立刻得到了 response,这个 response 并不是实际的 HTTP 响应,而是对未来能够获取到的数据的引用。

拿到这个引用之后,我们立刻处理手头已有的数据。等这些数据处理完成之后,尝试 deref(解引用)一开始拿到的引用,此时 HTTP 请求已经发出有一段时间了,我们有可能直接就能拿到数据,然后就可以开始处理了;如果没拿到,也只需要再等一会儿,比一开始就等着什么也不做强多了。

持有对未来可用数据的引用,和持有对某一块内存区域的“引用”,有什么区别呢?

可以发现上述程序没有状态变更,future 返回了一个值,deref 不过以 future 的返回值作为输入,返回了新的值,数据流很清晰,很函数式。此外,它还很优雅地把程序从异步状态恢复到了同步状态,不需要我们自己写同步逻辑。

以这个 Go 程序为反例:

package main

import (
	"fmt"
	"time"
)

func main() {
 // 定义一个变量用来存放数据
 result := ""
 // 异步执行函数,两秒后给 result 赋值为 "OK"
 go (func() {
 time.Sleep(2 * time.Second)
 result = "OK"
 })()
 // 轮询,直到 result 不是空字符串才退出循环
 for {
 if result != "" {
 fmt.Println(result)
 break
 }
 }
}

这段程序既要管理状态,又要轮询状态,非常难读。

Go 语言也有自己的异步编程范式,实际上不必这么写。之后我们就会看到 Go 的异步编程,因为 Clojure 也借鉴了 Go 语言。

其实我本来想用 JavaScript 写,但我在浏览器里执行的时候,我的页面竟然卡死了。后来我才知道浏览器的 JS 运行时是单线程的,用于轮询的无限循环把主线程卡死了,JS 根本没时间管子线程,于是赋值语句就一直没执行。JavaScript 也有类似于 future 的 async 关键词,async 函数会返回 Promise,类似于 ref,而 deref 对应的是 await,详见 MDN: async function 。

如果你熟悉 JavaScript 的 async 和 await,那 Clojure 的 future 和 deref 就很容易理解。

此外,(deref ref) 可以简写为 @ref。不过用 @ 语法糖的话就用不了 deref 的超时控制。(deref ref) 默认会一直等待,如果 ref 的值一直没来,程序就会卡死;可以用 (deref ref timeout fallback) 做超时控制,如果经过了 timeout 毫秒还没能解引用成功,就用 fallback 值。

在继续之前我还要提醒对异步编程不那么熟悉的读者,无论是哪种异步机制,无论怎么实现并发,线程的执行顺序都是不固定的。

(dotimes [number 10] (future (println number)))
;; 05
;; 423
;;
;; 8
;; 9
;; nil1
;; 7
;;
;;
;; 6

上面的输出很乱,原因在于 println 的换行符何时打印也不能确定,dotimes 本身的返回值 nil 也混在这些换行得乱七八糟的数字当中。

有办法能控制并发的执行顺序,但要保证顺序必然要有等待、排队的过程,速度就有损失,甚至有可能变成串行的。如果要严格同步,那就不应该异步——是的,这是句废话。总之,需要严格保证执行顺序的操作不该异步。

不阻塞的异步

尝试解引用 future 返回的引用可能会阻塞当前线程,如果希望当前线程不被阻塞呢?有时候我们并不需要严格同步数据,如果新值没有到达,那就用旧的值。

比方说我们有个计数器,每 5 秒给计数器增加 1,解引用这个计数器就拿到当前的数,而不是等五秒过后再拿新的数。

要这么做,可以用 agent:

(def counter (agent 0))
;; => #'user/counter

@counter
;; => 0 

(future 
 (doseq [_ (range)] 
 (Thread/sleep 5000) 
 (send counter inc)))
;; => #object[clojure.core$future_call$reify__8574 0x4abf3f0 {:status :pending, :val nil}]

@counter
;; => 0 

;; 五秒过后 
@counter
;; => 1

doseq 宏接收类似 let 的绑定,用于遍历序列。(doseq [item data] (f item)) 相当于 (doall (map #(f %) data))——如果不需要操作集合数据,比起 map 和 doall,doseq 宏看起来更干净。

上述代码中传入的是无穷的惰性序列,所以会无限循环。每次循环都让线程先睡五秒钟,然后再执行操作。放在 future 里是为了不阻塞主线程。

这里的 send 函数是专门和 agent 一起用的,作用是把一个函数发送给 agent,让 agent 对自己保存的数据做处理。这个过程是异步的,send 会立刻返回 agent,之后再解引用 agent 就能拿到 agent 当前的值。

agent 是线程安全的,不必手动加锁。

你可能发现了,这难道不是非常不函数式吗?比方说,如果不把 agent 用于异步编程,我们完全可以违反「不允许可变状态」的规则,做这种事情:

(def counter (agent 0))
;; => #'user/counter

(deref counter)
;; => 0 

(send counter inc)
;; => #object[clojure.lang.Agent 0x2f6bbeb0 {:status :ready, :val 1}]

(deref counter)
;; => 1

(send counter inc)
;; => #object[clojure.lang.Agent 0x2f6bbeb0 {:status :ready, :val 1}]

(deref counter)
;; => 2

这就要靠程序员的自觉了。不过,agent 其实没那么好用,因为只能发送函数,而不能替换值本身1,用途很有限。

agent 可以存任何类型的值,也可以存集合类型,比方说我们可以一次性给 agent 发多个函数,而每个函数的返回值都是由旧集合拼接成的新集合,就可以并发地处理多个数据。比方说,下面是一个简单的并发爬虫,把数个 HTML 页面塞进了同一个向量里:

(defn crawl [url] 
 (:body (client/get url)))

(defn crawl-append [old url] 
 (conj old (crawl url)))

(def crawler (agent []))

(def urls ["https://chlo.is"
 "https://www.geedea.pro"
 "https://blog.cytrogen.icu/"])

(doseq [url urls]
 ;; crawler agent 里面已经有的值会作为 crawl-append 的第一个参数 
 ;; 后续传入的可变数量的参数(如这里的 url),会变成 crawl-append 剩下的参数
 (send crawler crawl-append url))

@crawler
;; 会返回由当前已经爬取完成的 HTML 字符串组成的向量

对于这种结构简单、数量确定的操作,还可以使用 pmap(parallel map),也就是并行版本的 map。下面这段代码是并行的(准确来说是并发),不需要 agent 也不需要 deref。

(pmap crawl urls)
;; 会返回包含所有爬取结果的惰性序列

pmap 返回的是“半”惰性序列,这些 crawl 其实已经执行了,只不过没有把值返回过来,只有在读取惰性序列的时候才会得到值。pmap 底下用的是 future。

如果要用 map 处理耗时较长的任务,就可以考虑换成 pmap 同时处理。

;; A function that simulates a long-running process by calling Thread/sleep:
(defn long-running-job [n]
 (Thread/sleep 3000) ; wait for 3 seconds
 (+ n 10))

;; Use `doall` to eagerly evaluate `map`, which evaluates lazily by default.

;; With `map`, the total elapsed time is just under 4 * 3 seconds:
user=> (time (doall (map long-running-job (range 4))))
"Elapsed time: 11999.235098 msecs"
(10 11 12 13)

;; With `pmap`, the total elapsed time is just over 3 seconds:
user=> (time (doall (pmap long-running-job (range 4))))
"Elapsed time: 3200.001117 msecs"
(10 11 12 13)

上面这个例子来自 ClojureDocs 。

你可能好奇,如果 pmap 处理大量的数据,比方说一不小心传了个无穷序列进去怎么办?这会同时处理无穷个数据,把 CPU 撑爆吗?老实说,我不知道具体会发生什么,但大概率 Clojure 或 Java 会控制最大并发数,根据当前负载,把过多的请求串行化以减轻压力。

无论有没有这种说法,程序自己完全不控制并发数量也是不行的。无论是 future 还是 agent,貌似都没有提供很好的并发控制机制,难道只能一开始就少派发任务吗?如果不确定有多少任务要运行呢?

Gopher?是你吗?

命名空间 clojure.core.async 提供了另外一种异步编程的方式,但没有放在标准库里,必须要添加依赖才能使用。

;; deps.edn 
org.clojure/core.async {:mvn/version "1.9.865"}
;; leiningen
[org.clojure/core.async "1.9.865"]

core.async 几乎和 Go 语言异步编程的要素一模一样,尤其是 go block——把语句放在 go block 里面,语句就会异步执行。把消息队列 chan 当作一等公民的做法,也如出一辙。根据 Clojure 官方的博客文章 ,这种异步编程风格其实有一段历史了,Go 也是借鉴并现代化了这种做法。

The roots of this style go back at least as far as Hoare’s Communicating Sequential Processes (CSP), followed by realizations and extensions in e.g. occam, Java CSP and the Go programming language.

无论如何,让我们来看看怎么用 go 和 chan 实现异步编程吧。如果你已经会写 Go,那么接下来的代码会很熟悉。

(require '[clojure.core.async :refer [go chan >! <!]])

(def queue (chan))

(go 
 (>! queue "Hello?"))

(go 
 (println (<! queue)))

上述例子中,我们启动了两个 go 线程,一个线程把 "Hello" 放进 queue 里,另一个线程把 "Hello" 取出来。

chan 函数用于创建 channel(频道),可以理解为消息队列。对于 channel,有放(put)和拿(take)的操作,分别用 >!(放)和 <!(拿)表示。这里的 ! 类似于 ?,是 Clojure 的命名习惯,用于标识会对状态进行变更的操作。说到底,我们的确对状态进行了变更,上述语句缺乏引用透明性,不够函数式。

chan 可以设置缓冲区大小,(chan 5) 就是缓冲区大小为 5 的队列,一旦队列满了,再试图往里面放消息,放消息的线程就会被阻塞。

(def only-put-once (chan 1))

(go (>! only-put-once 1)
 ;; 下面这个操作会阻塞这个 go 线程
 (>! only-put-once 1))

反之,如果队列空了还试图从里面拿东西,就会阻塞拿消息的线程。

(def empty-chan (chan))

(go (<! empty-chan)
 ;; 除非有人往 empty-chan 里放东西
 ;; 否则下面这句话不会被打印
 (println "Help! I'm blocked!"))

go 在 Clojure 里是个宏,在 Go 里是个放在函数调用之前的关键字,效果都是让语句异步执行。不过 Clojure 中的 go 会返回一个 channel,go block 里的语句执行完成后,会把返回值通过 channel 传递回来。2

放和拿的操作可以阻塞线程,因此可以用作并发控制。

学过操作系统的读者可能知道信号量(semaphore),我们可以用 chan 实现简单的信号量。比方说,我们先给一个 chan 存入 50 个信号,然后启动不定数量的 go 线程,但在启动线程之前,派发器必须要拿到信号量才行。如果信号量不够(尝试从空队列里取数据),就会阻塞派发器所在的线程,直到有信号量被回收。

(require '[clojure.core.async :refer [go chan put! <!! >!]])
;; 定义信号量
(def semaphore (chan))
;; 将 50 个空 map 放入信号量中
(dotimes [_ 50]
 (put! semaphore {}))
;; 启动一个无限循环派发器
(doseq [number (range)]
 ;; 尝试获取信号量,如果已经消耗完 50 个,阻塞派发器
 (<!! semaphore)
 ;; 获取到信号量时,启动 go 线程
 (go (Thread/sleep (rand-int 10000))
 (println number)
 ;; 线程工作完成后,把一个信号放入 semaphore
 (>! semaphore {})))

上述程序永远不会同时运行超过 50 个 go 线程。也可以反着实现,定义大小为 50 的 channel,每派发一个线程之前都要放个东西到 channel 里,比如 {}3,放满了话的话就阻塞。不妨试着在 REPL 里写一写试试?

需要注意的是放和拿的操作有很多变体,在不同场景下使用。之前提到的 >! 和 <! 只能放在 go block 中,放在其他地方会报错。

在 go block 外,可以用 >!! 和 <!!,一般不建议用,因为会阻塞主线程。更好的选择是 put!(放)和 take!(拿),这两个操作是异步的,无论放没放进去、拿没拿出来,都不会阻塞当前的线程(但是会阻塞异步的子线程)。

由于 take! 是异步的,不会立刻返回值,所以得传一个回调函数给它,这样在能拿到值的时候,就会调用回调函数。下面是 ClojureDocs 的示例:

user=> (def c (chan 1))
#'user/c

user=> (take! c
 (fn [x]
 (println "Clojure callback value " x)))
nil

user=> (put! c "XYZ")
Clojure callback value XYZ
true

user=> (put! c "XYZ")
true

此外还有 offer!(放;给)和 poll!(拿;查),这两个操作是同步的,但也不会阻塞线程。offer! 会检查 channel 的缓冲区是不是满的,如果满了就返回 nil,如果能放进去就返回 true;poll! 也类似,能拿就拿,不能拿就返回 nil。

像 Go 程序员一样写 Clojure

Go 语言程序经常用 goroutine4 和 channel 实现各种并发处理管线。我刚才已经演示了两个线程(协程)用 channel 异步通信,演示了派发器和并发控制。我们还可以把更多 Go 语言的编程模式搬到 Clojure 里来。

我前面提到,core.async 提供的这套异步编程模式很不函数式,因为 channel 就是存放状态的队列,与之相关的放和拿的操作也不具有纯函数应有的引用透明性。

不过这是否值得呢?接下来我要用 Go By Example5 网站上的 Stateful Goroutines 的例子回答这个问题。

 reads := make(chan readOp)
 writes := make(chan writeOp)

 go func() {
 var state = make(map[int]int)
 for {
 select {
 case read := <-reads:
 read.resp <- state[read.key]
 case write := <-writes:
 state[write.key] = write.val
 write.resp <- true
 }
 }
 }()

select 经常和 chan 一起用,放在 select 的哪个 case 中的 chan 先来消息,就先执行哪一个 case,也就是说可以同时等待多个 chan。

上述代码中的 goroutine 管理了一个状态 state,即 stateful goroutine。它接收其他 goroutine 通过 channel 传递过来的消息,然后通过消息里携带的 resp(也是个 channel)把处理结果写回那些 goroutine。

简单来说,这个 stateful goroutine 接收读操作和写操作。读者和写者不持有状态,只是发消息给 stateful goroutine,让它帮忙管理状态,然后给它一个 channel,稍后把处理结果发送回去。

 for range 100 {
 go func() {
 for {
 read := readOp{
 key: rand.Intn(5),
 resp: make(chan int)}
 reads <- read
 <-read.resp
 atomic.AddUint64(&readOps, 1)
 time.Sleep(time.Millisecond)
 }
 }()
 }

上述代码派发了 100 个读者 goroutine,每个 goroutine 都把封装好的读操作写入 reads channel,然后等待 resp 被发回来。相对应地,还有写者 goroutine,这里就略过了。

不知道你有没有注意到几件事:

  1. 管理状态的 goroutine、读者和写者之间,是解耦的,我们只需要有一个中间人(比方说 main 组件)把 channel 发给他们,他们就可以在没有代码依赖的情况下相互通信
    • 这很方便测试,假设我要测试 Stateful goroutine 是否工作正常,我不需要启动读者和写者,我只需要把测试数据写入 reads 和 writes。
    • 这让架构变得灵活,我可以随意重构三个组件,完全不会互相影响,不会出现某个组件的函数签名改了,另一个组件的函数调用也要改的情况。
  2. 我们把状态管理集中到了一个组件里,读者和写者可以是无状态的。
    • 函数式编程的重点就是要管理状态和副作用,用 goroutines 和 channel 把状态管理集中到一个组件,也算是殊途同归了。
  3. 三个组件在共享数据,但是完全不需要加锁!

要在 Clojure 里实现类似 select 的结构,可以用到 alt!(alternative)。

;; (go-loop ...) = (go (loop ...))
(go-loop [state {:key "val"}]
 (alt!
 reads ([read-op] 
 (>! (:chan read-op) ((:key read-op) state))
 (recur state))
 writes ([write-op]
 (recur (assoc state (:key write-op) (:val write-op))))))

不过,如你所见,除了 >!,这比 Go 语言的版本要更函数式一些,因为我们没有给 state 赋值,而是在 recur 一次又一次的递归调用中,不断给 go-loop 传入了不同的参数而已。

我不久前就在工作中解决了一个不大不小的性能问题:系统中有大量 goroutine 等待 SQLite 的锁,导致计算资源闲置,而要写入 SQLite 的数据不过是些日志,并不着急同步。于是,我的解决方式是引入一个写者,所有写入数据库的操作都通过 channel 传递给这个写者,由写者这一个实体写数据,这样就不需要锁了。其他 goroutine 把写操作的情况放入 channel 之后就可以继续做自己的事情,不必等着把日志写进数据库。6

把副作用、状态管理和 I/O 操作集中在系统的一个组件,通过 channel 解耦其他组件与状态管理组件,能很好地降低系统的复杂性。

我应该用什么?

future 和 deref 很简单,agent 和 send 似乎也不错,go 和 chan 更是能很好地管控系统的复杂性,我究竟应该选哪个?

老实说,我觉得这是程序员需要多写代码才能建立起来的认识。读者应该在实际的场合下权衡取舍,而不是选定一个万金油方案。这些方案都有利弊。

future 和 deref 基本对应 JavaScript 的 async 和 await——轻量,易用,非常函数式。可如果要构建复杂系统,严格控制并发,甚至根据系统负载动态调整并发度,这种轻量的方案就不太适用了。

agent 和 send 的模型更关注数据处理,毕竟 agent 在创建时就需要指定初始值,而 send 的作用是给 agent 发送一个操作,对其中的数据做操作。再者,agent 天然就是并发安全的,可以减少自己加锁的麻烦。处理数量可控且相对轻量的数据时,就可以用 pmap。

go 和 chan 就跟它们在 Go 语言里的地位一样,如果要开发高并发的复杂系统,或者管理系统的复杂性,将组件解耦,就可以抱着抛弃函数式风格的代价使用。core.async 其实遭到了 批评 ,而且的确有一些问题。Stack Overflow 上的 aeuhuea 就举了个很典型的例子:

(defn take-and-print [c]
 (println (<! c)))

(def ch (chan 1))
(>!! ch 123)

(go (take-and-print ch))
;; Assert failed: <! used not in (go ...) block

上面的代码报错了,因为 <! 和 >! 如果被放在函数里面,就会被认定没有按规定放在 go block 里,哪怕函数就是在 go 里面调用的。我一般的解决方法是把 <! 取出的值作为参数传递给函数,或者用宏。

最后

异步编程很难不考虑状态,我还真的有点好奇 Haskell 作为纯函数式编程语言,不像 Clojure 这样容忍一部分的可变状态和副作用,是怎么实现异步的。我得找时间去研究研究。

这些概念可能不太好理解,我希望我讲清楚了。如果你有任何问题,或者发现了我有疏漏的地方,欢迎给我发邮件。那么,回见!


  1. 其实可以给 agent 发这样的函数:(fn [_] "new value") ↩︎

  2. 说实话,很少用 go 返回的 channel 取返回值,因为不如 future 和 deref 方便。 ↩︎

  3. 在 Go 语言里,空结构体 struct{} 不占空间,所以经常用来做信号量放进 channel 里。Clojure 的空映射 {} 底层是个 Java 类,不可能不占空间,这里只是我自己的习惯,你可以换成 true、:signal 之类的,但是不能放 nil,会报错,因为 nil 在某些操作里表示 channel 关闭了或者空了。 ↩︎

  4. 即协程,是 Go 语言建立在线程之上的用户级、轻量的并发单元,用 go 启动。Clojure 里的 go 仅仅是借其形式,不见得和 goroutine 一样轻量(我们可是在 Java 虚拟机上啊!)。 ↩︎

  5. 这个例子由 Mark McGranaghan 和 Eli Bendersky 编写,用 CC BY 3.0 协议 授权。我只截取了其中的一部分。 ↩︎

  6. 唯一的缺陷是,如果程序意外退出,而 channel 里还有些日志没写入数据库,这些数据就丢失了。 ↩︎

打领带的人

2026-10-08 00:52:21

最近在关注 Golda ,从 ch0ccyra1n 写的 Issues 来看,她近期并不打算推进太多功能开发,更多是在做各个平台的发包和新 Fork 落地的工作,还有清理 Hugo 的某些遗留问题(比如 Hugo 的传递依赖太多导致不太容易打包到 Guix 的 问题 )。我在帮忙移除了 hugo deploy 之后也找不到什么能帮忙的事情了。

不过我在尝试把博客从 Hugo 迁移到 Golda 的时候,发现我依赖的某些较新的 API 没有被 Golda 实现。我去翻了 Hugo 的更新日志和相关的 commit,发现这些功能有不少是用 Claude 写的,不能拿来用。Hugo 也废弃了某些 API。这样下去,Golda 的 API 会和 Hugo 的变得越来越不容易兼容,但一直盯着 Hugo 更新,等人重新实现他们用大模型生成的功能,也不现实。关于此事,我提了 Issue ,与维护者基本达成一致。不过,这也意味着我要为了消除 LLM 写的代码,耗费好多精力修改我原来的代码。

事情进行到这里的时候我已经觉得有点心累,一方面是因为我在 一百零一期周刊 里提到的道德矛盾感,另一方面是我愈来愈清晰地意识到,要远离自己讨厌的技术已经变得完全不可能。很快,矛头转向了我自己。我知道这种自我攻击不健康,但若是不表达出来,伤害恐怕会更大。 既然感受是真实的 ,那就写吧。


难道你要事无巨细地检查你每个项目里的每一个依赖项,以及这些依赖项的依赖项,追溯整个供应链,确保里面没有模型生成的代码吗?你在想什么?光是找到符合你条件的桌面端应用都很费力了,你现在用来写文的 Neovim 也不全是人类所写,而据我所知,你还不想进入 Emacs 世界。你之前提到你对不开源的专有软件还有些包容度,那你怎么知道那背后的程序员是怎么工作的?你自己工作也一直用 Agent,你对待那些提交到公司内网的代码,也不如你对待提交到 Codeberg 和 Hydra 的用心。你也非常清楚,早在 LLM 之前,就已经有数不清的讨厌写代码(或者只是讨厌上班)的程序员在写他们不喜欢的软件了。你到底在要求什么?

你说你能理解那些人。你理解有的人想要脱离职场的苦海,求助于这生来就不洁的技术,抱着投机的心态创业,指望做出爆火的产品解救自己的生活。你说你看见了他们的挣扎,却还是在读到他们用 Codex 和 Claude Code 时心头一震。你能要求他们去看看背后发生了什么吗?你说你理解有的人带着一种愚昧无知的上进心,理解他们关注“时代发展方向”,使用这项技术不过是为了自己的职业生涯考虑,是在适应变化。你自己也在做这样的事情,你深知你不在简历上写自己的 Agent 使用经验和上下文管理技巧就会被市场淘汰,你也在适应变化,你也是车轮下的蚂蚁。你与他们唯一的不同是,你非常清楚自己在车轮之下,而不是在风浪之上,你非常清楚自己随时会被轧死,而这不会让你变得更容易在车轮下生存,只会让你更容易陷入绝望。

职场前辈对你随口说的某句话你难以忘却。那天上午你没有工作,给 Golda 提交了代码之后,午餐时聊起此事,但避开了与大模型有关的词句。你说你给开源项目提 PR,前辈说挺好,“可以丰富自己的简历”。你总觉得自己没有背叛那个每天下午坐在电脑面前发现一个个奇迹的孩子,可你总归还是背叛了。那个孩子从来不会想到,追随自己的好奇心和他逐渐锻炼起来的品味,最终导向的仅仅是简历上的一行字,甚至写不进简历,因为没有人在乎你用 SSG 做网站、用 Lisp 写代码、用终端还是 IDE 做开发(神奇的是,他们很在乎你用不用大模型写代码,有没有跟上趋势)。

你说你清楚,你知道,人们只在乎成果。你也认可,你也不在乎某个数学家在证明某个定理时是否感到心潮澎湃。你知道你只需要交付可靠的软件(有很多时候,他们只要求你快速交付能符合短期要求的软件),没人在乎你用的是不是你喜欢的语言,没人在乎你用了什么设计模式,没人在乎你的代码整不整洁(“以前古法编程确实这有这个说法,现在都用 AI 写了啊”)。如果可以,他们真想完全除掉代码,而且他们已经这么干了。如果可以,你也想除掉数学、除掉昆虫、除掉令人大脑萎缩的短视频,而你也已经这么干了。你致力除掉你讨厌的东西,他们致力除掉他们讨厌的东西;你朝你的目标前进,他们朝他们的目标前进。

你多想声称,这个世界太多的问题,都源于人们非要做自己不喜欢做的事情。有人不喜欢讨论软件架构、代码整洁、新的编程语言、系统的可维护性,所以他们渴求从未存在也不会存在的 银弹 ,他们用卑劣的技术除掉那些他们从来就不想要考虑的事情。有人不喜欢打领带,但非要系领带,所以发明了可笑的免打领带。可真是侮辱了领带这精巧漂亮的配饰呢。“这么方便,为什么不用?”“忙得很呢,哪有时间留给你打领带?”“这都不用。傻逼。”于是,你看着街上所有的人都戴着扣上的或套上的领带,假笑着称赞对方的领带有多么紧随潮流。人们走向你,问你的领带是扣上的还是套上的,你说你是手打的,他们开玩笑说这是古法系领带,你不解,古时候人们不系领带。又有人走向你,问你是要出席什么正式场合,你说你想在城市里散步,去看花、喝咖啡,你说你只是喜欢打领带。他们又困惑了。你说你喜欢用红色的领带配白衬衫和牛仔裤,喜欢用橙色的衬衫和绿色的领带扮演橘子,你说你尝试过薰衣草色的衬衫和紫色的领带。他们不懂为什么要在乎这些,把他们海军蓝色、没有花纹的领带扣在了衬衫上,转身走进了摩天大楼,以为遇到个怪人。

有时候你真不想理解其他人,你多想自己的世界再小一点,跟你的父亲一样坐井观天,做个烦人但自洽的幸福人。或许你可以做 Stallman 那样的人,抱有强烈的观点且决不退让,哪怕因为太冒犯人,被人写 长文 谴责,也总归还会是幸福的。可惜,你我都很清楚,你做不了那样的人,那样的人让你恶心想吐。你也融入不了大众,更成不了精英,哪个阵营都让你恶心想吐。你多想引用陈词滥调,散发着爹味对自己说:“这世界这么大,总归有你的容身之处。”可你不会信的。