You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.29 09:06:29