WPF 防重入,时序错乱详解

在 WPF 的异步刷新场景中(尤其是 .NET Framework 4.0),"防重入" 和**"时序错乱"**是两个必须分开处理的"致命陷阱":

加上这套逻辑后,无论用户怎么狂点按钮,或者定时器如何触发,你的 TreeView 永远显示最新且正确的数据,绝不会出现"刷新后选不中"或"数据变回旧版本"的诡异问题。 😊

  • 防重入(防止堆叠) :用户狂点按钮,或者 10 秒定时器触发时,上一次刷新还没结束。如果不加锁,会导致多个线程同时修改 TreeRoot,界面卡死或报错。

  • 时序错乱(防止覆盖)这是更隐蔽的坑。 比如第 1 次刷新 (耗时 3 秒)先发起,第 2 次刷新 (耗时 1 秒)后发起。第 2 次先完成并显示了最新数据 ,但随后第 1 次才完成,把界面强行回退成了旧数据------用户看到的数据是错的

  • 针对你的 .NET 4.0 + WPF 环境,我为你设计了一套 "三层防护网",直接照着抄就能完美解决这两个问题。

🛡️ 第一层:防重入锁(拒绝堆叠)

  • 这是最基础的,防止多个刷新任务同时执行。

    改进点 :将 bool IsRefreshing 升级为 int 类型,使用 Interlocked.CompareExchange 实现无锁线程安全,比 bool 更可靠。

cs 复制代码
private int _isRefreshing = 0; // 0=空闲, 1=刷新中

private bool TryEnterRefresh()
{
    // 原子操作:如果 _isRefreshing 为 0,则设为 1 并返回 true
    return Interlocked.CompareExchange(ref _isRefreshing, 1, 0) == 0;
}

private void ExitRefresh()
{
    Interlocked.Exchange(ref _isRefreshing, 0);
}

⏱️ 第二层:取消令牌(解决"后发先至")

  • 利用 CancellationTokenSource 取消过时的任务。当发起新刷新时,强制终止上一个还在后台运行的旧任务(前提是你的耗时任务支持取消检测)。
cs 复制代码
private CancellationTokenSource _cts;

private void RefreshTreeAsync()
{
    // 1. 取消上一个未完成的任务
    if (_cts != null)
    {
        _cts.Cancel();
        _cts.Dispose();
    }
    _cts = new CancellationTokenSource();
    var token = _cts.Token;

    // 防重入判断
    if (!TryEnterRefresh()) return;

    // ... 保存状态 ...

    Task.Factory.StartNew(() =>
    {
        // 在耗时循环中检测是否被取消
        if (token.IsCancellationRequested) return null;
        Thread.Sleep(1500); // 模拟耗时
        if (token.IsCancellationRequested) return null;
        return GenerateMockData(DateTime.Now.Second % 5 + 1);
    }, token)
    .ContinueWith(task =>
    {
        // ... 更新 UI ...
    }, TaskScheduler.Default);
}

🏆 第三层:版本号标记(解决"慢请求覆盖快请求"------终极奥义)

  • 即使你取消了旧任务,但 .NET 4.0 的 Task 取消是协作式 的(需要代码配合),如果旧任务正在 Thread.Sleep 或读取数据库卡住,无法立即停止,它依然可能在你更新完 UI 后强行覆盖。
  • 解决方案 :给每次刷新分配一个递增的"版本号",只有最新版本号的任务才能更新 UI
cs 复制代码
private int _refreshVersion = 0;

private void RefreshTreeAsync()
{
    if (!TryEnterRefresh()) return;

    // 1. 递增版本号(获取当前操作序号)
    int currentVersion = Interlocked.Increment(ref _refreshVersion);
    
    // 保存状态(UI线程)...

    Task.Factory.StartNew(() =>
    {
        // 耗时操作...
        var data = GenerateMockData(DateTime.Now.Second % 5 + 1);
        return data;
    })
    .ContinueWith(task =>
    {
        // 回到 UI 线程更新
        Application.Current.Dispatcher.Invoke((Action)(() =>
        {
            try
            {
                // 2. 【核心校验】如果不是最新版本,直接丢弃,绝不更新界面!
                if (currentVersion != _refreshVersion)
                {
                    Console.WriteLine($"丢弃过时版本 {currentVersion},当前最新为 {_refreshVersion}");
                    return;
                }

                if (task.IsFaulted) { /* 处理异常 */ return; }

                // 3. 执行真正的 UI 替换
                TreeRoot = new ObservableCollection<NodeViewModel>(BuildTree(task.Result));
                RestoreState(TreeRoot, selectedId, expandedIds);
            }
            finally
            {
                ExitRefresh();
            }
        }));
    }, TaskScheduler.Default);
}

💡 不同场景下的组合策略

你的需求场景 推荐组合方案
操作频率低(用户点按钮) 第一层(防重入锁) 即可。
定时器刷新(10秒)+ 手动按钮 第一层(防重入锁) + 第三层(版本号)。用户点击时,旧定时任务的数据来了直接丢弃,保证永远显示最新点击的数据。
耗时任务可中断(如支持取消的HttpClient) 第一层 + 第二层(取消令牌),能节省后台资源(CPU/网络)。
耗时任务不可中断(如底层数据库驱动不支持Cancel) 第一层 + 第三层(版本号) 。这是最稳妥的终极保命方案,强烈推荐你直接上这个组合
  • 结合上面三层防护的完整最终版模板
cs 复制代码
private int _isRefreshing = 0;
private int _refreshVersion = 0;

private void RefreshTreeAsync()
{
    if (Interlocked.CompareExchange(ref _isRefreshing, 1, 0) != 0) return;

    int currentVersion = Interlocked.Increment(ref _refreshVersion);
    int selectedId = -1;
    var expandedIds = new HashSet<int>();

    Application.Current.Dispatcher.Invoke((Action)(() =>
    {
        var selected = FindSelectedNode(TreeRoot);
        if (selected != null) selectedId = selected.Id;
        CollectExpandedIds(TreeRoot, expandedIds);
    }));

    Task.Factory.StartNew(() =>
    {
        Thread.Sleep(1500);
        return GenerateMockData(DateTime.Now.Second % 5 + 1);
    })
    .ContinueWith(task =>
    {
        Application.Current.Dispatcher.Invoke((Action)(() =>
        {
            try
            {
                // 【灵魂逻辑】丢弃过时的刷新结果
                if (currentVersion != _refreshVersion) return;

                if (task.IsFaulted) throw task.Exception;
                
                TreeRoot = new ObservableCollection<NodeViewModel>(BuildTree(task.Result));
                RestoreState(TreeRoot, selectedId, expandedIds);
            }
            catch (Exception ex)
            {
                MessageBox.Show("刷新出错:" + ex.Message);
            }
            finally
            {
                Interlocked.Exchange(ref _isRefreshing, 0);
            }
        }));
    }, TaskScheduler.Default);
}

🎯 总结

  • 防重入Interlocked.CompareExchange,保证同一时间只有一个任务在跑。

  • 时序错乱currentVersion != _refreshVersion,保证只有最新的数据能画到界面上。

cs 复制代码
/// <summary>
/// 异步请求时序错乱防护器(版本号方案,.NET4.0 可用,无依赖)
/// 适用场景:选中项切换、Tab切换、分页、树节点加载明细
/// </summary>
public class AsyncRaceGuard
{
    private long _version;

    /// <summary>
    /// 开启一次新请求,获取本次请求版本标记
    /// </summary>
    public long BeginNewRequest()
    {
        return Interlocked.Increment(ref _version);
    }

    /// <summary>
    /// 校验本次请求是否已经过期
    /// </summary>
    public bool IsExpired(long requestToken)
    {
        // 当前全局版本 > 请求发起时的token → 已有新请求发起,旧结果丢弃
        return _version > requestToken;
    }

    /// <summary>
    /// 重置(清空所有历史请求,适合切换大页面)
    /// </summary>
    public void Reset()
    {
        Interlocked.Exchange(ref _version, 0);
    }
}
相关推荐
心平气和量大福大13 小时前
C#-WPF-控件-LiveChart图表-线性2(LineSeries)-数据绑定
开发语言·c#·wpf
whn19772 天前
oracle的节点2无法启动asm实例 提示PMON terminating the instance due to error 481
数据库·oracle·wpf
完美火龙篇 四月的友2 天前
WPF 笔迹延迟优化从硬件到软件的全链路分析
wpf
dalong102 天前
WPF:3D立方体
3d·wpf
Xin_ye100863 天前
第三章:内存泄漏的常见“案发现场”
c#·wpf
心平气和量大福大3 天前
C#-WPF-UserControl-生命周期(加载 退出)
开发语言·c#·wpf
czhc11400756634 天前
7.23:Claude code:注册->封表
wpf
心平气和量大福大4 天前
C#-WPF-控件-TextBox 数据绑定
开发语言·c#·wpf
listening7775 天前
HarmonyOS 6.1 全场景协同实战:从“单机”到“超级终端”的分布式重构
wpf