仅更新字典值场景下Dictionary/ConcurrentDictionary/FASTER KV选型
场景说明
当前需要处理股票实时行情流传输业务,业务规则如下:
- 每只股票以无符号整型作为键,对应存储的值为
struct类型Tick(非class类型) - 初始采用
Dictionary<uint, Tick>作为存储结构,该字典在初始化阶段一次性写入全部共5000个键,初始化完成后不再执行新增、删除键操作 - 仅由WebSocket回调随机更新已有键对应的Tick值,回调触发频率约为每毫秒1次
- 另有两个独立后台线程遍历该字典读取最新值用于行情分析:其中一个线程每20毫秒完成一轮遍历后休眠,另一个线程持续无休眠遍历
已知普通Dictionary不是线程安全类型,ConcurrentDictionary存在一定性能开销,核心疑问为该场景下是否更适合选用Microsoft FASTER KV,同时附带两个具体问题:
- 若不对字典执行新增/删除键操作,仅由单线程更新已有键的值、其他线程执行读取/遍历操作,普通
Dictionary是否具备线程安全性? - 下方代码块中标记的
Point A和Point B位置获取到的值是否一致?
/// <summary> /// this struct from 3rdpary library /// </summary> public struct Tick { public DateTime DateTime { get; set; } public uint InstrumentToken { get; set; } public decimal LastPrice { get; set; } } class TickDataProcessor { private Dictionary<UInt32, Tick> AllStocksTickData { get; set; } private Dictionary<UInt32, Tick> AllPreviousStocksTickData { get; set; } private void Initialize() { AllPreviousStocksTickData = new Dictionary<uint, Tick>(capacity: 5000); AllPreviousStocksTickData = new Dictionary<uint, Tick>(capacity: 5000); // initialize dict with all empty values, after this no more additions UInt32 key = 1000001; for (int i = 0; i < 5000; i++) { AllStocksTickData[key] = new Tick(); AllPreviousStocksTickData[key] = new Tick(); key++; } } /// <summary> ///real time streaming of stock data from stock broker /// this method is call back of websocket, websocket callback comes every millisecond for random key /// it will update the value of that key. /// </summary> /// <param name="tickData"></param> public void OnNewTickData(Tick tickData) { AllStocksTickData[tickData.InstrumentToken] = tickData; } public void Start() { var ts = new ThreadStart(OneBackgroundMethodStockPriceAnalyzer); var backgroundThread = new Thread(ts); backgroundThread.Start(); var ts2 = new ThreadStart(TwoBackgroundMethodStockPriceAnalyzer); var backgroundThread2 = new Thread(ts2); backgroundThread2.Start(); } private void TwoBackgroundMethodStockPriceAnalyzer() { while (true) { foreach (var entry in AllStocksTickData) { // second thread } } } private void OneBackgroundMethodStockPriceAnalyzer() { while (true) { foreach (var entry in AllStocksTickData) { var key = entry.Key; var tickData = entry.Value; // Point A /* * process tickdata * */ if (AllPreviousStocksTickData[key].LastPrice >= tickData.LastPrice) { // calculate % of change // other calculations... } AllPreviousStocksTickData[key] = entry.Value; // Point B } Thread.Sleep(20); } } } class Program { public static void Main(string[] args) { var tickDataProcessor = new TickDataProcessor(); tickDataProcessor.Start(); Console.ReadLine(); } }
问题解答
疑问1:固定键的单写多读场景下普通Dictionary的线程安全性
不具备线程安全性。
- 哪怕没有新增/删除键操作,
Dictionary<TKey, TValue>的已有键更新和遍历/读取操作没有做任何内存屏障、读写同步保证:- 值类型更新时如果大小超过CPU字长(本场景中的
Tick包含DateTime、uint、decimal,大小远大于8字节),读线程可能读到撕裂值——也就是写线程只更新了一半字段时,读线程就把半新半旧的值读出来,拿到完全无效的行情数据 - 即使初始化时给定了足够容量、不会触发扩容,JIT和CPU仍可能对字典条目的读写指令做重排,读线程遍历的时候可能读到条目状态不一致的情况,极端场景下甚至会触发遍历异常、死循环
- 值类型更新时如果大小超过CPU字长(本场景中的
- .NET官方文档明确标注
Dictionary所有实例成员都不保证线程安全,并发读写场景下出现任何未定义行为都属于预期内结果,不存在“测试没出问题就可以用”的侥幸空间。
疑问2:Point A和Point B的值一致性
完全不保证一致。
Point A的tickData是遍历KeyValuePair时拿到的Tick结构体副本,赋值完成后这个值就存储在当前线程的栈上,和原字典里存储的值已经没有关联- 从
Point A到Point B的执行间隙,WebSocket回调线程完全可能把同一个键对应的Tick更新成新值,此时entry.Value会读取到更新后的新副本,和之前栈上存储的tickData不是同一个值 - 额外说明:示例代码本身存在初始化bug,
Initialize方法中两次实例化赋值给AllPreviousStocksTickData,AllStocksTickData从未被实例化,运行时会直接抛出空引用异常;且AllPreviousStocksTickData被后台线程遍历的同时执行写入操作,本身也存在严重的线程安全问题。
高性能场景下的存储方案选型
首先明确结论:该场景完全不适合选用Microsoft FASTER KV,属于典型的过度选型。
FASTER KV的核心优势是处理远超内存容量的超大规模数据、支持持久化、高吞吐的混合读写工作负载,本场景总共只有5000个固定键,全量数据加起来仅几MB,FASTER的日志架构、异步持久化、设备IO层等核心能力完全用不上,反而会引入额外的序列化、异步提交开销,性能远低于纯内存级方案。
也不建议直接使用默认配置的ConcurrentDictionary:默认的ConcurrentDictionary更新操作会采取细粒度锁,遍历的时候会走版本校验,对固定键、单写多读的场景来说仍有不必要的开销。
按性能从高到低排序,适合本场景的方案如下:
- 最优方案:数组存储+
Volatile读写无锁同步
由于业务中的股票键是连续从1000001开始的连续值,可直接将键做偏移映射到数组下标,数组内存连续,遍历性能比字典高一个量级,访问开销几乎为0。更新值时用Volatile.Write写入整个Tick结构体,读取时用Volatile.Read拿到完整的值副本,全程无锁,不会出现撕裂值问题,性能比ConcurrentDictionary高3~5倍,完全能支撑当前的更新、遍历频率。 - 次优方案:普通
Dictionary+ReaderWriterLockSlim
如果不想做数组映射改造,可给普通Dictionary搭配读写锁:写操作拿写锁,读/遍历操作拿读锁。5000个键的遍历加读锁的开销几乎可以忽略,写锁的持有时间仅几百纳秒,完全能支撑每毫秒1次的更新频率,性能优于FASTER和默认配置的ConcurrentDictionary。 - 兜底方案:预初始化容量的
ConcurrentDictionary
如果一定要用现成的并发集合,初始化时将ConcurrentDictionary容量设为5000避免扩容,更新值时使用TryUpdate方法而非索引器直接赋值,可将开销压到最低,但性能仍弱于前两种方案。
内容的提问来源于stack exchange,提问作者Venkat B
相关产品推荐
相关产品推荐

