C#多线程中对象引用偶发丢失致空引用异常排查
看起来你遇到了典型的多线程并发问题,结合你的代码和描述,我来拆解下问题根源和对应的修复方案:
核心问题原因
1. 非线程安全集合的并发访问
CalcStatistikMarkersThreads是普通的List<Thread>,而HandleCalcStatistikMarkersDone会被多个子线程同时调用(每个子线程完成后都会触发事件回调)。List<T>的Find和Remove方法都不是线程安全的——当多个线程同时修改或查询列表时,可能导致列表内部索引结构损坏,或者Find方法返回null(比如线程刚被移除、列表处于不一致状态),此时调用Remove(currentThread)就会触发NullReferenceException。
2. ManagedThreadId 复用风险
.NET 中的ManagedThreadId并非永久唯一:当一个线程结束后,CLR 可能会把这个 ID 分配给新创建的线程。如果某个子线程的事件回调延迟触发,原线程已结束、ID 被新线程占用,用ManagedThreadId查找时要么找到错误线程,要么返回null,最终引发空引用异常。
3. 事件回调的潜在无效引用
在CalcCoinStatistik方法中,你每次创建新的Strategies实例并绑定ThreadDone事件,但没有显式解绑。如果这些实例未被及时回收,可能会触发旧实例的HandleCalcStatistikMarkersDone方法,而此时旧实例的CalcStatistikMarkersThreads可能已被释放或为null,直接访问就会报错。
4. 低效且不安全的线程等待方式
你用while (CalcStatistikMarkersThreads.Count != 0) { Task.Delay(1000).Wait(); }轮询等待子线程完成,不仅效率低,还会在轮询和回调修改列表时产生并发冲突,加剧集合的不一致性。
修复与预防方案
1. 使用线程安全集合替代 List
把List<Thread>换成System.Collections.Concurrent命名空间下的线程安全集合,比如ConcurrentDictionary<Guid, Thread>(用唯一Guid标识任务,避免线程ID复用问题):
// 替换原来的List private ConcurrentDictionary<Guid, Thread> CalcStatistikMarkersThreads; // 初始化集合 if (CalcStatistikMarkersThreads == null) { CalcStatistikMarkersThreads = new ConcurrentDictionary<Guid, Thread>(); } // 创建子线程时分配唯一ID foreach (var todo in InternalToDos) { Strategies strat = new Strategies(); Guid taskId = Guid.NewGuid(); CalcStatistikMarkersParameter csp = new CalcStatistikMarkersParameter(); csp.TaskId = taskId; // 将唯一ID传入子线程参数 strat.ThreadDone += HandleCalcStatistikMarkersDone; Thread candleCalc = new Thread(new ParameterizedThreadStart(strat.CalcStatistikMarkers)); CalcStatistikMarkersThreads.TryAdd(taskId, candleCalc); candleCalc.Start(csp); }
修改回调逻辑,用唯一ID操作集合:
public void HandleCalcStatistikMarkersDone(object sender, EventArgs e) { ThreadInfoEventArgs tEv = (ThreadInfoEventArgs)e; // 直接通过唯一ID移除,无需查找 if (CalcStatistikMarkersThreads.TryRemove(tEv.TaskId, out _)) { // 可选:添加清理逻辑 } }
2. 对集合访问加锁(若坚持用 List)
如果不想更换集合,必须在所有访问CalcStatistikMarkersThreads的地方加锁,确保同一时间只有一个线程操作列表:
private readonly object _threadListLock = new object(); // 添加线程时加锁 lock(_threadListLock) { CalcStatistikMarkersThreads.Add(candleCalc); } // 回调中加锁并判空 public void HandleCalcStatistikMarkersDone(object sender, EventArgs e) { ThreadInfoEventArgs tEv = (ThreadInfoEventArgs)e; lock(_threadListLock) { if (CalcStatistikMarkersThreads == null) return; Thread currentThread = CalcStatistikMarkersThreads.Find(xy => xy.ManagedThreadId == tEv.ThreadID); if(currentThread != null) { CalcStatistikMarkersThreads.Remove(currentThread); } } }
3. 改用 Task 替代 Thread,简化线程管理
.NET 的Task API 比直接使用Thread更高效易管理,还能通过Task.WhenAll等待所有子任务完成,彻底避免维护线程列表和事件回调的麻烦:
public async void CalcCoinStatistik(object parameters) { StrategyParameter givenParms = (StrategyParameter)parameters; Pair internalPair = givenParms.pair; List<Task> tasks = new List<Task>(); foreach (var todo in InternalToDos) { CalcStatistikMarkersParameter csp = new CalcStatistikMarkersParameter(); csp.parm1 = param; // 用Task.Run创建子任务 Task task = Task.Run(() => CalcStatistikMarkers(csp)); tasks.Add(task); } // 等待所有任务完成,无需轮询 await Task.WhenAll(tasks); }
4. 完善事件回调的解绑与空值检查
如果继续使用事件,记得在子线程完成后解绑事件,避免内存泄漏和无效回调:
// 创建子线程时绑定事件 strat.ThreadDone += HandleCalcStatistikMarkersDone; // 在CalcStatistikMarkers方法末尾解绑 public void CalcStatistikMarkers(object parm) { // 业务逻辑... ThreadDone?.Invoke(this, new ThreadInfoEventArgs(...)); // 解绑事件 ((Strategies)sender).ThreadDone -= HandleCalcStatistikMarkersDone; }
同时,在HandleCalcStatistikMarkersDone中必须先检查CalcStatistikMarkersThreads是否为null,再执行后续操作。
内容的提问来源于stack exchange,提问作者Airwave

