ASP.NET HostedService中单例List元素读写是否需要考虑线程安全
问题结论
你遇到的场景两个层面都存在线程安全问题,Foo.Value的同步和FooManager.FooList的同步不是二选一的关系,需要分别处理。
1. 关于FooManager.FooList的线程安全
你已知List本身的新增、删除、遍历操作不是线程安全的,结合你的代码场景,风险点非常明确:
FooUpdater作为后台托管服务,会永久循环遍历FooList,如果此时FooController的多线程请求对FooList执行Add/Remove操作,或者直接给FooList属性赋新实例,会直接触发InvalidOperationException(集合被修改异常),导致后台服务直接崩溃。- 即便没有触发异常,并发修改下的List遍历也可能出现元素重复读取、遗漏读取、甚至内存损坏的问题。
处理方案
- 首先修改
FooManager的FooList属性,将setter改为私有,禁止外部直接替换列表实例:
public List<Foo> FooList {get; private set;}
- 在
FooManager内部定义全局锁对象,所有对FooList的操作(新增、删除、遍历、清空)都要持有这把锁:
public class FooManager // Singleton { private readonly object _listLock = new object(); public List<Foo> FooList {get; private set;} public FooManager() { FooList = new List<Foo>(); } // 封装Add操作,内部加锁 public void AddFoo(Foo foo) { lock(_listLock) { FooList.Add(foo); } } // 封装获取遍历快照的方法,避免外部直接遍历原始集合 public List<Foo> GetFooSnapshot() { lock(_listLock) { return FooList.ToList(); } } }
- 如果你用的是.NET Core 2.0+版本,也可以直接使用内置的线程安全集合(如
ConcurrentBag<Foo>),它的遍历是快照式的,不会抛出集合修改异常,但要注意你拿到的是遍历启动那一刻的集合状态,需要符合你的业务预期。
2. 关于Foo.Value的线程安全
就算FooList的操作已经完全线程安全,Foo.Value的读写依然存在并发风险:
FooUpdater的UpdateValue方法在等待10秒后会给Value赋值,同时FooController的多线程请求可能正在读写同一个Foo实例的Value,会出现竞态条件:比如读操作拿到未完成更新的脏数据、多个写操作互相覆盖导致最终结果不符合预期。- 注意虽然
string的引用赋值本身是原子操作,但可见性无法保证(CPU缓存没有同步的话,读线程可能长时间看不到新赋值的Value),如果涉及判断后写入、字符串拼接等非原子操作,风险会更高。
处理方案
不要为了图方便直接锁整个FooList,那样会严重降低并发性能,推荐在Foo内部单独加锁保护Value的所有读写操作:
public class Foo { private readonly object _valueLock = new object(); private string _value; public string Value { get { lock(_valueLock) return _value; } set { lock(_valueLock) _value = value; } } public async Task UpdateValue(string value) { await Task.Delay(10000); Value = value; } }
所有对Value的访问都走封装好的带锁属性即可保证线程安全。
内容的提问来源于stack exchange,提问作者rosi97
相关产品推荐
相关产品推荐

