diff options
| author | eltrac <hi@eltr.ac> | 2026-10-12 00:47:31 +0800 |
|---|---|---|
| committer | eltrac <hi@eltr.ac> | 2026-10-12 00:47:31 +0800 |
| commit | f05ee8acc0b26254e55d082ddc2211d84242aa58 (patch) | |
| tree | 640c9247ae0bf6de4bce9ae4c68927cd298119ed /content/article | |
| parent | c3d50c6b766da9c16b1e09f936f9f695e4fa41d9 (diff) | |
[edit&&draft] update draft, edit privous Coffee Break Clojure title to contain meanful words
Diffstat (limited to 'content/article')
| -rw-r--r-- | content/article/clj-license.md | 22 | ||||
| -rw-r--r-- | content/article/clj0.md | 2 | ||||
| -rw-r--r-- | content/article/clj1.md | 2 | ||||
| -rw-r--r-- | content/article/clj2.md | 2 | ||||
| -rw-r--r-- | content/article/clj3.md | 2 | ||||
| -rw-r--r-- | content/article/clj4.md | 2 | ||||
| -rw-r--r-- | content/article/clj5.md | 2 | ||||
| -rw-r--r-- | content/article/clj6.md | 2 | ||||
| -rw-r--r-- | content/article/clj7.md | 233 |
9 files changed, 260 insertions, 9 deletions
diff --git a/content/article/clj-license.md b/content/article/clj-license.md index b9657ecc..b1a182ef 100644 --- a/content/article/clj-license.md +++ b/content/article/clj-license.md @@ -2,7 +2,7 @@ title = 'Coffee Break Clojure, on License' subtitle = '谈谈开源协议' slug = 'clj-license' -tags = [] +tags = ["Clojure","开源"] categories = '' date = 2026-10-09T00:07:13+08:00 draft = true @@ -45,7 +45,25 @@ Eclipse Public License(简称 EPL)不能算常见的开源协议,[Choose a 所以,即便了解了缘由之后,我还是想感叹:*Why, Clojure? Why EPL?* -## +## Clojure 的开源生态 + +不少 Clojure 库都选用 EPL 协议,还会在文档里写这样的句子: + +> Distributed under the Eclipse Public License, ==the same as Clojure.== + +如果细数一个 Clojure 项目的依赖项(在 `deps.edn` 或 `project.clj` 等文件里),会发现直接依赖的软件库(包括 Clojure 本身)都有不少用 EPL 授权的软件包,更别说间接依赖了。GPL 协议在 Clojure 生态中比较少见(但不是没有),这与 EPL 和 GPL 的兼容性问题有关,但更多的原因,似乎是 Clojure 社区受 Clojure 的影响,把 EPL 当作了**默认**开源协议。 + +Leiningen 提供的项目模板就默认生成 EPL 协议,我对这一做法其实颇有微词,倒不是说工具本身不应该带有强烈的观点,而是说工具没有权利帮代码的版权所有者选择授权协议。不过就算 Leiningen 不这么做,许多开源开发者仍然不在乎授权协议,他们只是想把代码分享出去而已。 + +程序员可能是互联网上最热爱分享的一群人了,这很好。我以前也抱类似的态度,直到最近的[某些事件](/weekly/101/#市场真的有辨别力吗),我开始反思自己对待版权的态度是不是不够严肃。 + +认真对待授权协议,也就是认真对待自己要授予其他人的权力,一方面是为了保护自己的作品,另一方面也是为了让内容更容易传播和使用。这两者在某种程度上是矛盾的,至少具有某种张力。如果完全不允许别人使用,作品就很难传播(“流行音乐一定要流行”);如果对授予他人的权力太过慷慨,又有可能遇到糟心事。 + +Clojure 选用 EPL 协议似乎是为了找到两者的平衡点,是权衡取舍之后的决定。这个平衡点在 Clojure 的历史背景和未来打算的语境下,是有意义的,但未必放之四海皆准。我们也看到,这个选择造就了一个不小的问题:Clojure 程序员不能直接给自己用 GPL 授权自己的代码,除非对 GPL 协议做附加说明。 + +我不知道 Rich Hickey 有没有考虑过这个后果。他可能考虑到了,并且接受这个权衡之后的结果。如果是这样最好。总之,我希望自己(以及读到这篇文章的人)以后都能认真考虑此时。在越来越多人践踏著作权(copyright)、滥用著佐权(copyleft)的当今,单单提升版权意识是不够的,还应该有更深入的思考。无论选用什么授权协议,都应该是审慎思考过后的决定。 + +## Why, Eltrac. Why AGPL? [^1]: Clojure 的创始人。 diff --git a/content/article/clj0.md b/content/article/clj0.md index 7dec146b..c69cdf12 100644 --- a/content/article/clj0.md +++ b/content/article/clj0.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.0' +title = 'Coffee Break Clojure, Code as Data' subtitle = '初识 Clojure 与 Lisp' slug = 'clj0' aliases = '/article/tutorial-clj-0/' diff --git a/content/article/clj1.md b/content/article/clj1.md index b3f848d8..ac3bc928 100644 --- a/content/article/clj1.md +++ b/content/article/clj1.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.1' +title = 'Coffee Break Clojure, REPL & Paredit' subtitle = '准备工具' slug = 'clj1' aliases = ['/article/tutorial-clj-1'] diff --git a/content/article/clj2.md b/content/article/clj2.md index d47e7954..56dccc0c 100644 --- a/content/article/clj2.md +++ b/content/article/clj2.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.2' +title = 'Coffee Break Clojure, Forms' subtitle = "Forms O' Plenty" slug = 'clj2' aliases = '/article/tutorial-clj-2/' diff --git a/content/article/clj3.md b/content/article/clj3.md index 34b1d490..c1518adc 100644 --- a/content/article/clj3.md +++ b/content/article/clj3.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.3' +title = 'Coffee Break Clojure, Collections' subtitle = '只用圆括号怎么够?' slug = 'clj3' aliases = '/article/tutorial-clj-3' diff --git a/content/article/clj4.md b/content/article/clj4.md index d4f81afa..0e9b5b10 100644 --- a/content/article/clj4.md +++ b/content/article/clj4.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.4' +title = 'Coffee Break Clojure, Functional' subtitle = 'Functionally Lazy' slug = 'clj4' tags = ["Lisp","Clojure","编程"] diff --git a/content/article/clj5.md b/content/article/clj5.md index f0068678..f01a320c 100644 --- a/content/article/clj5.md +++ b/content/article/clj5.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.5' +title = 'Coffee Break Clojure, Namespace' subtitle = '管理 Clojure 项目' slug = 'clj5' tags = ["Lisp","Clojure","编程"] diff --git a/content/article/clj6.md b/content/article/clj6.md index d8980bd6..5fad887a 100644 --- a/content/article/clj6.md +++ b/content/article/clj6.md @@ -1,5 +1,5 @@ +++ -title = 'Coffee Break Clojure, Vol.6' +title = 'Coffee Break Clojure, Web Server' subtitle = 'Ring Ring Ring' slug = 'clj6' tags = ["Lisp","Clojure","编程","Web"] diff --git a/content/article/clj7.md b/content/article/clj7.md new file mode 100644 index 00000000..5d709615 --- /dev/null +++ b/content/article/clj7.md @@ -0,0 +1,233 @@ ++++ +title = 'Coffee Break Clojure, Async' +subtitle = '异步编程和并发编程' +slug = '' +tags = [] +categories = '茶歇蔻芥' +date = 2026-10-11T18:35:50+08:00 +draft = true +toc = true ++++ + +[上一篇](/article/clj6/)我提及了如何用 [Ring](https://github.com/ring-clojure/ring) 开发 HTTP 服务器,也提到 Ring 提供了同步 API 和异步 API。那么,什么是异步编程?为什么需要异步?在什么情况下需要让一个程序同时做很多事情?<!--more--> + +## 同步、异步、并发和并行 + +同步(synchronization)的中文名字可能会让人误以为是多个实体同时进行操作,实际上同步的意思是指系统中所有事件都依照相同的顺序进行协调。可以用工厂流水线举例,假如流水线中一个工人需要等上一个工人交付一部分工作之后才能干活,整个流水线都要所有人一起干活才能转动起来,这个流水线就是同步的。反之,假如某些工作可以分开做,比如一部分人负责生产 A 零件,另一部分人负责生产 B 零件,两者互不干扰,而最终把 A 和 B 组装在一起的又是另一拨人,这个系统就是异步的(asynchronous)。 + +异步(asynchrony)和并发(concurrency)很容易搞混,并不是同一个概念。异步是指系统中各个事件的状态不同步,而并发通常是指多个线程(或类似线程的东西,比如 `goroutine`)同时做类似的操作。 + +以下分别是异步和并发的例子: + +1. 小明和小华准备去桌游店玩,小明负责去买饮料和零食,而小华先去桌游店占位置。他们的状态是不同步的,小华并不需要等到小明买完东西才能去桌游店。不过,小明和小华最终会在桌游店汇合,他们一起玩桌游时,就又回到了同步状态。 + + 异步通常是程序里的一小部分,如果系统中所有任务都是异步的,那这些任务大概就不应该放在同一个系统里了,因为状态之间没有同步的必要。 +2. 某推销团队在城市某片区招募了 100 名推销员,给他们派发相同的任务:向路人介绍产品,并尽可能获客。从整个推销计划的角度来看,这是个并发度为 100 的系统,增加并发度能够提高单位时间内的销售成绩,减少并发度效果则相反。也可以说推销员之间是异步的,但系统整体是同步的,销售成绩最后都会汇总到一个地方。 + +简单来说,异步是局部状态,对系统整体影响不大,而并发则事关整个系统的执行效率,是更宏观的概念。 + +从编程实践来看,并发往往是更大更复杂的系统才考虑的(比方说网络爬虫必然不能一次只爬一个网页),而异步则是较小的程序会采用的轻量方案(比方说网页渲染中加载某些资源时为了不阻碍主线程,所以要异步加载)。 + +至于并行(parallelism),这个概念经常在**操作系统**的语境里和并发做区分。并行是指程序同时运行在多个 CPU 核心上,是物理意义上的同时发生;并发是指 CPU 调度器时进程交替执行,由于交替速度非常快,所以即便是在单核机器上,也能造成程序在同时运行的假象。 + +脱离操作系统的语境,这种区分的意义就不大了。在编程的语境下,并行是指程序在同时进行的事实(开发者很少在乎程序究竟是在多核还是在单核机器上运行的,这对编写程序而言不重要),而并发则是**组织程序**的一种方式。 + +接下来就看看如何在 Clojure 里组织异步和并发。 + +## 面向未来编程 + +前文提到,异步是局部状态,系统最终还是要同步信息的。许多编程语言里的做法是,在当前线程(thread)里启动子线程,相关的线程都持有对同一块内存区域的引用,他们在这一块内存区域里读写数据。之前我提到,Clojure 是[函数式编程语言](/article/clj4/),不允许共享**状态**,那怎么办呢? + +假设我们需要从某个 API 获取数据,但问题是网络状态不稳定,这个操作可能很费时间。与此同时,我们还要处理手头已有的数据。 + +```clojure +;; 这里用到了 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` 的线程。 + +```clojure +(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 程序为反例: + +```javascript +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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function)。 + +如果你熟悉 JavaScript 的 `async` 和 `await`,那 Clojure 的 `future` 和 `deref` 就很容易理解。 + +## 不阻塞的异步 + +尝试解引用 `future` 返回的引用可能会阻塞当前线程,如果希望当前线程不被阻塞呢?有时候我们并不需要严格同步数据,如果新值没有到达,那就用旧的值。 + +比方说我们有个计数器,每 `5` 秒给计数器增加 `1`,解引用这个计数器就拿到当前的数,而不是等五秒过后再拿新的数。 + +要这么做,可以用 `agent`: + +```clojure +(def counter (agent 0)) +;; => #'user/counter + +(deref counter) +;; => 0 + +(future + (doseq [_ (range)] + (Thread/sleep 5000) + (send counter inc))) +;; => #object[clojure.core$future_call$reify__8574 0x4abf3f0 {:status :pending, :val nil}] + +(deref counter) +;; => 0 + +;; 五秒过后 +(deref counter) +;; => 1 +``` + +`doseq` 函数接收类似 `let` 的绑定,用于遍历序列。`(doseq [item data] (f item))` 相当于 `(doall (map #(f %) data))`——`doall` 立刻执行惰性序列中所有函数的副作用,而 `doseq` 则本来就是用于处理副作用的,不创建函数。 + +上述代码中传入的是无穷的惰性序列,所以会无限循环。每次循环都让线程先睡五秒钟,然后再执行操作。 + +这里的 `send` 函数是专门和 `agent` 一起用的,作用是==把一个函数发送给 `agent`,让 `agent` 对自己保存的数据做处理==。这个过程是异步的,`send` 会立刻返回 `agent`,之后再解引用 `agent` 就能拿到 `agent` 当前的值。 + +你可能发现了,这难道不是非常**不**函数式吗?比方说,如果不把 `agent` 用于异步编程,我们完全可以违反「不允许可变状态」的规则,做这种事情: + +```clojure +(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 页面塞进了同一个向量里: + +```clojure +(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)) + +(deref crawler) +;; 会返回由当前已经爬取完成的 HTML 字符串组成的向量 +``` + +对于这种结构简单、数量确定的操作,还可以使用 `pmap`(parallel map),也就是并行版本的 `map`。下面这段代码是并行的(准确来说是并发),不需要 `agent` 也不需要 `deref`。 + +```clojure +(pmap crawl urls) +;; 会返回包含所有爬取结果的惰性序列 +``` + +`pmap` 返回的是“半”惰性序列,这些 `crawl` 其实已经执行了,只不过没有把值返回过来,只有在读取惰性序列的时候才会得到值。`pmap` 底下用的是 `future`。 + +如果要用 `map` 处理耗时较长的任务,就可以考虑换成 `pmap` 同时处理。 + +```clojure +;; 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](https://clojuredocs.org/clojure.core/pmap#example-542692cdc026201cdc326cf4)。 + +你可能好奇,如果 `pmap` 处理大量的数据,比方说一不小心传了个无穷序列进去怎么办?这会同时处理无穷个数据,把 CPU 撑爆吗?老实说,我不知道具体会发生什么,但大概率 Clojure 或 Java 会控制最大并发数,根据当前负载,把过多的请求串行化以减轻压力。 + +无论有没有这种说法,程序自己完全不控制并发数量也是不行的。无论是 `future` 还是 `agent`,貌似都没有提供很好的并发控制机制,难道只能一开始就少派发任务吗?如果不确定有多少任务要运行呢? + +## Gopher?是你吗? + +## Footnotes + +[^1]: 不过其实也可以给 `agent` 发这样的函数:`(fn [_] "new value")` |
