返回文章列表

文章

从一段STM示例代码引发的思考

目录
  1. 出现这几个问题的原因是Clojure STM(Software Transactional Memory)的“重试机制”导致的结果。 虽然你只运行了 100 + 200 = 300 次逻辑转账,但 transfers 为 300 是个假象,让我们仔细看看为什么。
  2. 🧩 一、代码回顾
  3. 🧠 二、STM 的事务回滚与重试机制
  4. 这个 dosync 代码体可能被执行多次!(直到没有冲突) Clojure中有很多函数名称后面是有”!”的,例如swap! reset!等。这种命名规则表示这个函数不是事务安全的函数。我们使用的send函数用于更新agent类型的变量,send名称中并没有!,可以从侧面知道这是一个事务安全的函数,也可以从侧面看出,Clojure是一门务实的编程语言,所见即所得(前提是你对Clojure有了一定的了解)。
  5. ⚠️ 三、问题出在非事务操作(副作用)
  6. ✅ 四、正确做法:把副作用移出事务
  7. 🧮 五、验证结果
  8. 这次的 transfers 就确实表示 成功的 300 笔转账。
  9. ✳️ 总结
  10. 📎 参考文章
(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。这里包含两个问题:

  1. 为什么Transfers是300?
  2. 为什么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 计数 +1
  • transfers 异步发送 +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 中打开