文章
从一段STM示例代码引发的思考
目录
- 出现这几个问题的原因是Clojure STM(Software Transactional Memory)的“重试机制”导致的结果。 虽然你只运行了 100 + 200 = 300 次逻辑转账,但 transfers 为 300 是个假象,让我们仔细看看为什么。
- 🧩 一、代码回顾
- 🧠 二、STM 的事务回滚与重试机制
- 这个 dosync 代码体可能被执行多次!(直到没有冲突) Clojure中有很多函数名称后面是有”!”的,例如swap! reset!等。这种命名规则表示这个函数不是事务安全的函数。我们使用的send函数用于更新agent类型的变量,send名称中并没有!,可以从侧面知道这是一个事务安全的函数,也可以从侧面看出,Clojure是一门务实的编程语言,所见即所得(前提是你对Clojure有了一定的了解)。
- ⚠️ 三、问题出在非事务操作(副作用)
- ✅ 四、正确做法:把副作用移出事务
- 🧮 五、验证结果
- 这次的 transfers 就确实表示 成功的 300 笔转账。
- ✳️ 总结
- 📎 参考文章
(ns clj-webapp.stm-test
(:require [clojure.test :refer :all]))
(def attempts (atom 0))
(def transfers (agent 0))
(defn transfer [from to amount]
(dosync
(swap! attempts inc) ;; 会产生副作用
(send transfers inc)
(alter from - amount)
(alter to + amount)
)
)
(def checking (ref 10000))
(def savings (ref 20000))
(defn stress-thread [from to iterations amount]
(Thread. #(dotimes [_ iterations] (transfer from to amount)))
)
(deftest test-transaction
(testing "test transaction"
(println "Before: Checking =" @checking " Savings =" @savings)
(let [t1 (stress-thread checking savings 100 100)
t2 (stress-thread savings checking 200 100)]
(.start t1)
(.start t2)
(.join t1)
(.join t2)
(await transfers)
)
(is (= 300 @transfers))
(is (> @attempts @transfers))
(println "Attempts: " @attempts )
(println "Transfers:" @transfers )
(println "After: Checking =" @checking " Savings =" @savings)
)
)
全部源码见参clj-webapp/test/clj_webapp/stm_test.clj at main · bruceblink/clj-webapp 执行这段代码,这是一种可能的执行结果:
Before: Checking = 10000 Savings = 20000
Attempts: 541
Transfers: 300
After: Checking = 20000 Savings = 10000
这段测试代码,每次执行Attempts的值都是大于300,Transfers的值都是300。这里包含两个问题:
- 为什么Transfers是300?
- 为什么Attempts大于Transfers?
出现这几个问题的原因是Clojure STM(Software Transactional Memory)的“重试机制”导致的结果。
虽然你只运行了 100 + 200 = 300 次逻辑转账,但 transfers 为 300 是个假象,让我们仔细看看为什么。#
🧩 一、代码回顾#
关键部分:
(defn transfer1 [from to amount]
(dosync
(swap! attempts inc) ;; ⚠️ 原子副作用
(send transfers inc)
(alter from - amount)
(alter to + amount))
)
看起来我们希望每次事务执行都:
attempts计数 +1transfers异步发送 +1- 更新两个 ref 的余额
🧠 二、STM 的事务回滚与重试机制#
在 Clojure 中,dosync 是 事务性内存块。
当你在事务中修改多个 ref 时,Clojure 会自动检测冲突:
- 如果两个事务同时修改同一个
ref,其中一个事务会自动重试 - 重试时,它会 重新执行整个
dosync块 也就是说:
(dosync
(swap! attempts inc)
(send transfers inc)
(alter from - amount)
(alter to + amount))
这个 dosync 代码体可能被执行多次!(直到没有冲突)
#
⚠️ 三、问题出在非事务操作(副作用)#
swap! 和 send 都是 副作用操作:
- 它们在 STM 冲突重试时不会回滚;
- 每次重试都会再次执行它们;
- 所以即使这次事务最后被丢弃,它们的副作用依然生效。 因此,当多个线程竞争同一个 ref(如 checking/savings)时,事务重试次数就可能超过预期。 你的例子里:
- 100 次 + 200 次理论调用 = 300 次;
- 但实际上每次冲突会触发重试;
swap!(attempts)和send(transfers)仍然执行;- 所以最终的
@transfers数量反映了重试总次数(≥ 300),不是成功提交次数。
✅ 四、正确做法:把副作用移出事务#
事务体里不该有副作用。应该这样改:
(defn transfer1 [from to amount]
(swap! attempts inc) ;; ✅ 在事务外统计尝试
(dosync
(alter from - amount)
(alter to + amount))
(send transfers inc)) ;; ✅ 事务成功后再异步记录
这样:
swap!和send只执行一次;- 不受 STM 重试机制影响;
@transfers就会精确地反映成功事务次数。
🧮 五、验证结果#
修改后再次运行:
Before: Checking = 10000 Savings = 20000
Attempts: 300
Transfers: 300
After: Checking = 10000 Savings = 20000
这次的 transfers 就确实表示 成功的 300 笔转账。#
✳️ 总结#
| 项目 | 问题原因 | 解决方法 |
|---|---|---|
@transfers 为 300+ | STM 自动重试导致副作用重复 | 把 swap! 和 send 移出 dosync |
| STM 特性 | 冲突检测自动重试 | 事务内部代码可能执行多次 |
| 原则 | dosync 内不要有副作用 | 副作用应在事务外执行 |
📎 参考文章#
未支持的 Notion 内容:bookmark 在 Notion 中打开