- 后端
【免费下载链接】language-ext
C# pure functional programming framework - come and get declarative!
函数式编程推崇不可变类型,但在真实系统中,我们偶尔仍然需要共享的可变状态。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)自旋循环:
- 读取当前值
current; - 将当前值传给函数
f,计算新值newValue; - 如果验证器拒绝新值,直接返回当前值(不更新);
- 通过
Interlocked.CompareExchange尝试把新值原子地替换进去; - 如果 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)的执行流程如下:
- 用当前世界状态创建一个新
Transaction,并把它放进AsyncLocal; - 执行事务操作
op; - 调用
ValidateAndCommit校验并提交:- 若隔离级别为
Serialisable,先执行ValidateReads,逐个比对读取的Ref版本号是否变化,变化则抛ConflictException; - 对每个写入,先运行验证器(不通过抛
RefValidationFailedException),再检查版本号,版本一致则Inc()版本后写入,否则抛ConflictException;
- 若隔离级别为
- 捕获
ConflictException后清空当前事务、广播变更,短暂自旋后重新执行整个事务; - 最终提交由底层
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。
使用建议与注意事项
综合文档与源码,以下几点是使用该并发模块的硬性约束:
- Swap/事务函数必须无副作用且尽量短小。无论是
Atom.Swap、AtomHashMap.Swap还是 STM 事务,都可能在冲突时被多次执行;f的耗时直接决定了竞争窗口的大小。 - 放入
Atom/Ref的值必须不可变。否则 CAS 重试与事务回滚都无从谈起;language-ext 的持久化集合(HashMap、Seq、Lst、Set等)天然适合作为这些容器的值,因为它们的"拷贝"是免费的、修改是结构共享的。 - I/O 和带副作用的操作要远离事务,因为事务随时可能回滚重试。
- 选择正确的隔离级别:单值原子更新用
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!
相关推荐
Arrow STM 并发编程:构建无锁并发系统的完整教程
Arrow STM 并发编程:构建无锁并发系统的完整教程 在现代软件开发中, 并发编程 已成为必备技能,但传统的锁机制往往带来死锁、竞态条件等复杂问题。今天,我
后端Concurrent Ruby TVar和STM:软件事务内存的Ruby实现与实战应用
Concurrent Ruby TVar和STM:软件事务内存的Ruby实现与实战应用 在现代并发编程中, 软件事务内存(Software Transactio
开发工具深入理解软件事务内存(STM)及其在JVM中的应用
深入理解软件事务内存 STM 及其在JVM中的应用 引言:并发编程的痛点与STM的诞生 在传统的多线程编程中,开发者常常陷入同步共享可变内存的困境。使用 syn
文档教程知识库
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考