☰
language-ext 并发编程指南:Atom 无锁状态、AtomHashMap/AtomSeq 与 STM 软件事务内存
2026/9/29 8:10:26 网站建设 项目流程
  • 后端

【免费下载链接】language-ext

C# pure functional programming framework - come and get declarative!

项目地址:https://gitcode.com/gh_mirrors/la/language-ext
点击查看免费下载

函数式编程推崇不可变类型,但在真实系统中,我们偶尔仍然需要共享的可变状态。language-ext 在LanguageExt.Core/Concurrency模块中提供了一整套无需lock的并发原语:用Atom保护任意值、用AtomHashMap/AtomSeq把不可变集合包装成无锁可变结构(且快照免费),以及基于多版本并发控制(MVCC)的软件事务内存(STM)系统。读完本文,你将掌握这三种工具的使用场景、核心 API、底层实现原理,并能在自己的 C# 项目中直接替换掉传统的加锁代码。

为什么需要无锁的共享状态?

在纯函数式编程中,我们倾向于使用不可变类型,但并非所有场景都能做到完全不可变,有时候我们确实需要一些共享的可变状态。如果直接使用语言库中的不可变集合,你就需要用锁来保护每次更新:

// 一些全局状态 HashSet<int> set = HashSet(1, 2, 3); object sync = new(); lock(sync) { set = set.Add(4); }

这种写法存在诸多问题:锁的获取与释放容易出错、可能引入死锁、并带来上下文切换的开销。为此,LanguageExt.Core/Concurrency/README.md 专门定义了一个并发模块,其核心思路是用无锁的原子操作替代锁。上面的代码用AtomHashSet可以简写为:

AtomHashSet<int> set = AtomHashSet(1, 2, 3); set.Add(4);

无锁并发的完整工具集位于 LanguageExt.Core/Concurrency 目录,包含:

  • Atom—— 保护任意值的无锁容器(Atom.cs);
  • AtomHashMap / AtomSeq / AtomQue—— 对HashMap、Seq、Queue的无锁可变包装;
  • STM 系统—— 允许对多个Ref值进行事务性修改(STM.cs、Ref.cs);
  • Signals、VectorClock、VersionVector、VersionHashMap等并发辅助设施。

模块的对外入口是 Prelude.Concurrency.cs,它把Atom、Ref、swap、commute、atomic、snapshot、serial等操作以Prelude函数的形式暴露出来。

Atom:无锁保护任意值

Atom提供了一种无需锁即可管理共享、同步、独立状态的方式。你可以把不可变数据结构放进去,然后用各种Swap方法原子地更新它,或者通过Value属性读取它(详见 Atom/README.md)。

基本用法

record Person(string Name, string Surname); // 创建一个新 atom var person = Atom(new Person("Paul", "Louth")); // 原子地修改它 person.Swap(p => p with { Surname = $"{p.Name}y" }); // 获取 atom 状态的一个快照 var snapshot = person.Value;

Prelude.Atom构造函数的底层实现(见 Prelude.Concurrency.cs)提供了多种重载:

重载说明
Atom<A>(A value)创建无验证器的 Atom,任何新状态都会被接受
Atom<A>(A value, Func<A, bool> validator)创建带验证器的 Atom,返回Option<Atom<A>>;若初始值不通过验证则返回None
Atom<M, A>(M metadata, A value)带元数据的 Atom,元数据会传给验证函数
Atom<M, A>(M metadata, A value, Func<A, bool> validator)同时带元数据与验证器的 Atom

在 Atom.cs 的New内部构造中,验证器会在 Atom 创建时先对初始值执行一次校验,从而保证Atom 永远不会处于非法状态;此后每次状态变更也都会经过同一个验证器。

Swap 的底层机制:CAS + 自旋重试

Atom的核心是Swap方法,其实现(Atom.cs)遵循典型的CAS(Compare-and-Swap)自旋循环:

  1. 读取当前值current;
  2. 将当前值传给函数f,计算新值newValue;
  3. 如果验证器拒绝新值,直接返回当前值(不更新);
  4. 通过Interlocked.CompareExchange尝试把新值原子地替换进去;
  5. 如果 CAS 失败(说明有其他线程抢先修改了状态),调用SpinWait.SpinOnce()短暂自旋后重试,直到成功。

由此可以得出两个重要的使用约束(README 与源码注释都反复强调):

  • f可能被调用多次(因为并发竞争时会被重试),所以f必须是无副作用的纯函数,否则"一切皆有可能";
  • 应尽量缩短f的执行时间,以降低与其他线程竞争的窗口,减少重试次数。

此外,Atom还提供SwapMaybe(f返回Option<A>,返回None时不更新)、SwapIO/SwapMaybeIO(把 swap 提升为IO单子)、以及ValueIO(可重复求值以获取最新状态的IO读取器)。每个成功更新都会触发Change事件(类型为AtomChangedEvent<A>),便于做变更通知。

AtomHashMap 与 AtomSeq:无锁的集合包装

单个Atom每次只能原子更新一个值。当你想对HashMap这样的集合做"读取-修改-写入"的复合操作时,AtomHashMap把整个HashMap包装进一个无锁结构(AtomHashMap.cs),内部持有的是一个volatile TrieMap。

构造与快照

// 空集合 var map = AtomHashMap<string, int>.Empty; // 从普通 HashMap 转换(见 AtomHashMap.Module.cs) HashMap<string, int> hm = HashMap<string, int>((("a", 1), ("b", 2))); var amap = hm.ToAtom(); // 构造后即可并发使用

关键是快照是免费的:由于底层是不可变HashMap(结构共享),读取Value/Items得到的就是当前状态的不可变快照,可以安全地随意读取而无需加锁。

Swap:整个集合的原子更新

AtomHashMap.Swap接受一个Func<TrackingHashMap<K, V>, TrackingHashMap<K, V>>,把"当前整个 map"映射为"新的 map",并原子地一次性提交(AtomHashMap.cs):

amap.Swap(m => m.Add("c", 3).Remove("a"));

这段代码把添加和删除作为一次原子操作完成,中间状态对外不可见。与Atom.Swap同理:

  • swap 函数可能被多次执行,因此必须无副作用、尽量短小;
  • 若ReferenceEquals判断新旧 map 相同(无变化),则不触发变更事件直接返回;
  • 只有通过Interlocked.CompareExchange成功提交后,才会调用AnnounceChanges广播变更。

SwapKey则用于只针对某个 key 的值做原子更新,若 key 不存在则什么都不做(AtomHashMap.cs)。AtomHashMap实现了IEnumerable<(K Key, V Value)>、IReadOnlyDictionary<K, V>等接口,因此可以像普通只读字典一样遍历与索引访问。同类工具还有AtomSeq(包装Seq)、AtomQue(包装Queue)。

STM:软件事务内存与 Ref

当需要同时、原子地修改多个状态时,单个Atom就不够用了。language-ext 的软件事务内存(STM)系统支持对多个Ref值进行事务性修改(见 Concurrency/README.md 与 STM.cs)。

Ref:被事务追踪的存储位置

Ref<A>只是包装了对一个值的访问,并让该值的状态变化可以被 STM 追踪(Ref.cs)。每个Ref在创建时被分配唯一 Id,其Value的读取(STM.Read)与写入(STM.Write)只能在事务内进行:

var x = Ref(1); var y = Ref(2);
// 在同一事务内同时修改 x 和 y atomic(() => { x.Value = x.Value + 1; y.Value = y.Value + 1; });

Ref支持Swap(在事务内把旧值替换为f的返回值)、Commute(见下文)以及ValueIO。与Atom相同,Ref的值也必须是(或被视为)不可变的——STM 事务中的所有工作都是推测性的,如果值可变,事务的回滚就无从谈起。

事务的 ACID 语义

sync事务(即atomic/snapshot/serial包装的代码块)类比数据库事务,对Ref的修改保证:

  • Atomic(原子性):事务中对所有Ref的修改要么全部发生,要么全部不发生;
  • Consistent(一致性):每个新值在提交前都要经过验证器(validator)校验;
  • Isolated(隔离性):事务运行期间看不到其他事务的任何影响。

此外,冲突的事务会被自动重试。language-ext 的 STM 使用**多版本并发控制(MVCC)**实现快照与可串行化隔离,体现在实践中就是:

  • 事务内对Ref的所有读取都会看到事务起点(read point)的一致快照,同时能看到自身做出的修改(即"in-transaction-value");
  • 事务内对Ref的所有修改看起来是在"写点"这一瞬间同时发生的;
  • 读者永不阻塞写者,写者也永不阻塞读者。

隔离级别:Snapshot 与 Serialisable

Isolation.cs 定义了两种隔离级别:

  • Snapshot(快照隔离,默认):只要求事务外部没有修改过事务内被写入的Ref。如果写冲突,事务回滚并按最新状态重试;
  • Serialisable(可串行化):要求事务外部没有修改过事务内被读取或写入的所有Ref。这是最严格的隔离级别,最容易冲突,但能防止跨读写的读-写不一致。

看一个官方文档给出的典型对比(见 Prelude.Concurrency.cs):

var x = Ref(1); var y = Ref(2); // 快照隔离:只写 x、只读 y // 即使事务中途有人写 y,也不会导致事务失败 snapshot(() => x.Value = y.Value + 1); // 可串行化隔离:读写了 x 和 y // 如果有人写 y,事务就会失败并重试 serial(() => x.Value = y.Value + 1);

事务入口:atomic / snapshot / serial

Prelude提供三个事务入口,均转发到STM.DoTransaction(Prelude.Concurrency.cs):

函数隔离级别说明
atomic(op)/atomic(op, Isolation)可指定,默认 Snapshot通用事务入口
snapshot(op)Snapshot快照隔离事务
serial(op)Serialisable可串行化事务

每个入口都有Func<R>与Action两种重载(返回R或Unit),还支持ValueTask与Eff异步/效果版本。关键特性是:如果已经处于一个事务中,嵌套调用会成为父事务的一部分,而不是另起新事务(见 STM.cs 的DoTransaction实现)。

事务的提交与冲突重试

STM.RunTransaction(STM.cs)的执行流程如下:

  1. 用当前世界状态创建一个新Transaction,并把它放进AsyncLocal;
  2. 执行事务操作op;
  3. 调用ValidateAndCommit校验并提交:
    • 若隔离级别为Serialisable,先执行ValidateReads,逐个比对读取的Ref版本号是否变化,变化则抛ConflictException;
    • 对每个写入,先运行验证器(不通过抛RefValidationFailedException),再检查版本号,版本一致则Inc()版本后写入,否则抛ConflictException;
  4. 捕获ConflictException后清空当前事务、广播变更,短暂自旋后重新执行整个事务;
  5. 最终提交由底层AtomHashMap的原子 swap 完成,保证多个Ref的修改一次性生效。

Commute:更高并发度的提交

对于可交换的更新操作,可以使用commute(Prelude.Concurrency.cs):

commute(x, v => v + 1);

Commute允许在提交时再次以最新已提交值执行f,因此事务之间可以重叠而不必互相阻塞,比直接设置Ref值允许更高的并发。注意f必须是可交换的(commutative),否则就要接受"最后写入者胜出"(last-one-in-wins)的行为;CommuteRef类型与atomic的重载(STM.cs)专门处理这类提交。在Prelude.Concurrency.cs中,swap(r, f)对应Ref.Swap(必须运行在事务内),commute(r, f)对应Ref.Commute。

使用建议与注意事项

综合文档与源码,以下几点是使用该并发模块的硬性约束:

  1. Swap/事务函数必须无副作用且尽量短小。无论是Atom.Swap、AtomHashMap.Swap还是 STM 事务,都可能在冲突时被多次执行;f的耗时直接决定了竞争窗口的大小。
  2. 放入Atom/Ref的值必须不可变。否则 CAS 重试与事务回滚都无从谈起;language-ext 的持久化集合(HashMap、Seq、Lst、Set等)天然适合作为这些容器的值,因为它们的"拷贝"是免费的、修改是结构共享的。
  3. I/O 和带副作用的操作要远离事务,因为事务随时可能回滚重试。
  4. 选择正确的隔离级别:单值原子更新用Atom;单集合复合操作用AtomHashMap.Swap;多值事务更新用snapshot(默认)或serial(需要防读写不一致时)。

验证与测试

并发模块的正确性在仓库测试中有充分覆盖,可以按图索骥深入验证:

  • AtomHashMapTests.cs 与 AtomHashMapEqTests.cs 覆盖了无锁哈希表的构造、更新与相等性;
  • AtomTests.cs 覆盖Atom的创建、Swap与快照语义;
  • VersionHashMapTests.cs 与 VectorClockTests.cs 验证分布式并发辅助结构;
  • Samples/TestBed/AtomHashMapTests.cs 提供了可运行的实测样例。

小结

language-ext 的Concurrency模块为 C# 开发者提供了三条递进的并发工具链:单值无锁原子更新用Atom;集合级无锁复合操作用AtomHashMap/AtomSeq;多值事务性修改用基于 MVCC 的 STM(Ref+atomic/snapshot/serial)。三者共享同一设计哲学——以不可变数据 + CAS 自旋重试替代加锁,在保证线程安全的同时消除死锁风险,并让快照读取变得零成本。在需要共享可变状态的场景中,这套无锁工具集是比lock更符合函数式风格的选择。

  • 后端

【免费下载链接】language-ext

C# pure functional programming framework - come and get declarative!

项目地址:https://gitcode.com/gh_mirrors/la/language-ext
点击查看免费下载

相关推荐

上一篇:OpenNHP 零信任网络隐藏协议架构详解:NHP-Agent / NHP-Server / NHP-AC 核心组件与访问工作流
下一篇:明日方舟桌宠 Ark-Pets 快速上手指南:让干员住进你的桌面,五分钟完成安装与配置

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询