多线程无保护读写对象的安全性、风险及异常捕获问题
线程安全问题分析与解答
问题描述
我了解到从不同线程访问同一对象并非线程安全,应加以保护,可通过Locking、Interlocked.Exchange、不可变对象(Immutables)或其他方式实现。这意味着以下代码因未对共享的test对象的访问加以保护,可能存在线程安全问题。我的问题如下:
- 以下代码是否安全?
- 若不安全,可能出现的最严重后果是什么?
- 若不安全,脏读或脏写是否会抛出可捕获的异常以规避最坏情况?
待分析代码
class Test { public Test() { Foo = new Random().Next(10000); } public int Foo; } internal class Program { public static async Task Main(string[] args) { var test = new Test(); var exitToken = new CancellationTokenSource(TimeSpan.FromSeconds(120)).Token; var readerTask = Task.Run(async () => { while (!exitToken.IsCancellationRequested) { Console.WriteLine("Random Foo: " + test.Foo); await Task.Delay(TimeSpan.FromSeconds(5)); } }); var writerTask = Task.Run(async () => { while (!exitToken.IsCancellationRequested) { test = new Test(); await Task.Delay(TimeSpan.FromSeconds(5)); } }); await Task.WhenAll(readerTask, writerTask); } }
问题解答
1. 代码是否安全?
不安全。核心问题在于共享的引用变量test的读写没有同步机制:
- writer线程直接对
test赋值,reader线程直接读取test.Foo; - 受CPU缓存和JIT编译器优化影响,reader线程可能无法及时感知到writer线程更新后的
test引用,存在可见性问题; - 虽然
int类型读写是原子操作,但引用变量的赋值缺乏内存屏障,存在指令重排序风险。
2. 最严重的后果是什么?
最严重的后果是reader线程长期读取过期的test引用:
- 极端情况下(比如JIT将
test缓存到寄存器),reader线程可能永远看不到writer更新后的对象,持续输出旧的Foo值; - 即使偶尔能看到更新,也会出现输出结果与预期不符的情况;
- 当前
Test构造仅初始化一个int,不会出现半初始化对象问题,但如果Test包含复杂成员(如引用类型),可能读取到未完全构造的对象,引发更诡异的行为。
3. 脏读/脏写是否会抛出可捕获的异常?
不会抛出任何可捕获的异常。线程安全问题导致的脏读、可见性问题大多是静默的逻辑错误,程序不会崩溃也不会触发异常,但输出结果不符合预期。这类问题极难排查,因为其出现依赖线程调度、CPU缓存状态等不确定因素,无法稳定重现。
修复方案
可以通过以下几种方式修复线程安全问题:
方案1:使用Interlocked类操作引用
class Test { public Test() { Foo = new Random().Next(10000); } public int Foo; } internal class Program { private static Test _test = new Test(); public static async Task Main(string[] args) { var exitToken = new CancellationTokenSource(TimeSpan.FromSeconds(120)).Token; var readerTask = Task.Run(async () => { while (!exitToken.IsCancellationRequested) { // 安全读取当前引用 var currentTest = Interlocked.CompareExchange(ref _test, null, null); Console.WriteLine("Random Foo: " + currentTest.Foo); await Task.Delay(TimeSpan.FromSeconds(5)); } }); var writerTask = Task.Run(async () => { while (!exitToken.IsCancellationRequested) { // 安全更新引用 Interlocked.Exchange(ref _test, new Test()); await Task.Delay(TimeSpan.FromSeconds(5)); } }); await Task.WhenAll(readerTask, writerTask); } }
方案2:使用lock同步读写操作
class Test { public Test() { Foo = new Random().Next(10000); } public int Foo; } internal class Program { private static Test _test = new Test(); private static readonly object _lockObj = new object(); public static async Task Main(string[] args) { var exitToken = new CancellationTokenSource(TimeSpan.FromSeconds(120)).Token; var readerTask = Task.Run(async () => { while (!exitToken.IsCancellationRequested) { lock (_lockObj) { Console.WriteLine("Random Foo: " + _test.Foo); } await Task.Delay(TimeSpan.FromSeconds(5)); } }); var writerTask = Task.Run(async () => { while (!exitToken.IsCancellationRequested) { lock (_lockObj) { _test = new Test(); } await Task.Delay(TimeSpan.FromSeconds(5)); } }); await Task.WhenAll(readerTask, writerTask); } }
方案3:使用不可变对象(Immutables)
由于Test类的Foo字段构造后不会修改,可将其声明为只读字段,确保对象不可变。此时即使引用更新无同步,reader至多读取到旧的不可变对象,不会出现不一致状态:
class Test { public Test() { Foo = new Random().Next(10000); } public readonly int Foo; // 声明为只读 }
内容的提问来源于stack exchange,提问作者rudimenter
相关产品推荐
相关产品推荐

