C#单写多读场景下Decimal线程安全性及锁使用问题
单写多读场景下decimal字段无锁访问风险与方案选型
场景前提
仅存在一个线程循环更新decimal类型的TotalValue属性,其余任意数量线程仅对该属性执行读操作,无锁状态下读线程可能在写线程执行TotalValue = totalCopy赋值的中途访问属性。写线程核心逻辑如下:
class Thread1 { public decimal TotalValue {get; private set;} private decimal StockAmount; private decimal OldPrice; public async Task GetStockPrices(string fictionalAsset) { while (true) { decimal totalCopy = TotalValue; totalCopy -= (StockAmount * OldPrice); OldPrice = StockPrices.GetValue(fictionalAsset); totalCopy += (StockAmount * OldPrice); TotalValue = totalCopy; } } }
你原本考虑的普通锁写法如下,担心读多写少场景下锁阻塞带来过高开销:
lock (TotalLock) { TotalValue = totalCopy; }
问题1:赋值中途读访问的实际后果
当前无锁实现会触发严重业务错误,绝非仅读到旧值这么简单:
decimal是16字节的值类型,主流32位、64位CPU的单次原子写入宽度分别为4字节、8字节,因此对decimal的赋值会被拆分为2~4次独立的内存写入操作。如果读线程刚好卡在这几次写入的间隙访问TotalValue,会读到撕裂值(torn read):即一部分字节来自旧值、一部分字节来自新值的拼接结果,这个值完全没有业务意义,可能是0、也可能是和新旧值都无关的随机极大/极小值,属于直接导致计算错误的严重故障,不属于“读到可接受旧版本”的范畴。- .NET运行时仅对长度不超过当前平台指针宽度的变量保证原子读写(比如32位环境下的
int、64位环境下的long),decimal长度远超这个阈值,不存在天然的无锁安全特性。
问题2:无锁实现的额外缺陷与性能对比
除了上述撕裂读问题,当前无锁实现还存在跨线程可见性缺陷:
- C#内存模型允许编译器、CPU对没有内存屏障的内存访问做指令重排,也允许线程将变量缓存到CPU私有寄存器或核心私有缓存中,没有同步原语的前提下,读线程可能长时间读不到写线程更新的最新值,甚至永远读到第一次加载的旧值。
- “无锁性能优于锁”的判断仅在无锁实现本身正确的前提下成立,当前存在正确性问题的无锁实现没有讨论性能的价值。同时你不需要在“错误的无锁实现”和“全互斥的普通lock”之间二选一,读多写少场景下有更适配的高性能方案:
- 追求极致性能的场景下,可以用
Interlocked系列原子方法操作decimal(.NET Core 2.1及以上版本原生支持decimal的Interlocked原子读写),整个读写过程基于CPU级别的原子指令实现,无锁开销,既不会出现撕裂读,也自带全内存屏障保证所有线程能及时读到最新值,读操作完全没有阻塞。 - 如果需要兼容旧版本.NET,可以使用
ReaderWriterLockSlim,该锁实现了读写分离:多个读线程并发访问时完全不会互斥,只有写线程进入时才会短暂阻塞读,性能比读操作也需要抢锁的普通lock高一个数量级以上,不会出现“读访问被持续阻塞”的问题。
- 追求极致性能的场景下,可以用
问题3:锁的调度规则与写优先级实现
普通lock关键字基于Monitor类实现,调度逻辑如下:
- 它的等待队列不保证先进先出:整体调度近似公平,但系统允许就绪优先级更高的线程插队,也存在线程被唤醒后锁刚好被其他线程抢占的情况,绝对不要依赖lock的调度顺序实现业务逻辑。
- 普通lock没有内置的写优先级机制,非常不推荐自己实现“检测锁状态再等待写入”的逻辑——这类自定义自旋等待很容易引发活锁、优先级反转、CPU空转等问题。如果需要写线程优先的调度,直接使用前面提到的
ReaderWriterLockSlim即可,它原生支持写优先级策略:当有写线程处于等待队列时,后续新进入的读线程请求会直接排队,优先将锁分配给等待的写线程,完全匹配你的需求。
内容的提问来源于stack exchange,提问作者julian bechtold
相关产品推荐
相关产品推荐

