news 2026/11/3 23:42:34

WPF DispatcherTimer异步任务取消策略:CancellationToken与MVVM封装实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WPF DispatcherTimer异步任务取消策略:CancellationToken与MVVM封装实践

聊到 WPF 里的 DispatcherTimer,十个人里九个第一反应是“定时刷新数据的玩意儿”。这没错,但真正到了生产环境,尤其是把 DispatcherTimer 和 async/await 这种异步任务捏在一起用的时候,问题就远不是“设置个 Interval、挂个 Tick 事件”这么简单了。我在好几个项目里都见过类似的事故:轮询任务还在跑,窗口已经关了;Tick 事件里顺手 await 了一个 HTTP 请求,界面直接卡成幻灯片;更隐蔽的是,任务明明取消了,下一轮 Tick 又悄悄启动新的任务,资源被反复创建、释放、再创建,最后句柄涨成天文数字。

这篇东西想聊的,就是 DispatcherTimer 在异步任务调度场景下的取消策略。说白了就三件事:怎么让任务在用户想停的时候停下来,怎么在窗口或页面销毁时确保任务链路上的资源干干净净释放掉,以及怎么避免“取消”这个动作本身引发新的崩溃。适用人群是所有写过或正在写 WPF 轮询逻辑、大屏看板、后台扫描类功能的开发者。不管你是刚上手 WPF,还是被线上问题折磨过几轮的熟手,下面这些方案都能直接抄走。

1. 思路拆解:为什么定时器、异步任务和取消策略非要绑在一起

1.1 先搞明白 DispatcherTimer 到底帮你解决了什么

很多初学者把 DispatcherTimer 当成“能在界面线程上执行定时逻辑的 Timer”,这个理解对一半。它真正的价值在于:Timer 的 Tick 事件回调始终被投递到 Dispatcher 队列里执行。也就是说,你不需要像使用 System.Threading.Timer 那样手动去 Invoke 或处理跨线程访问控件的问题。这一点在 WPF 这种 UI 线程强约束的环境里非常省心,尤其是你要在界面上显示最新进度、刷新文本、更新数据网格的时候。

但这个设计有一个容易被忽略的副作用:Tick 事件回调本身是同步执行的。如果在 Tick 里面执行耗时的同步操作,比如访问数据库、读取大文件、执行一个三秒钟才返回的同步方法,UI 线程就被堵死了。所以当任务本身是异步的时候,正确写法应该是在 Tick 里启动异步任务,然后立即返回,让 UI 线程回到消息循环。这是很多人第一次踩坑的地方——他们把 DispatcherTimer 当成了普通的“定时执行器”,却没意识到自己塞进去的每一个耗时操作都在透支 UI 线程的响应时间。

我见过一个真实案例:某个数据采集程序,Tick 事件里直接调用了一个同步阻塞的 Modbus 通信方法,间隔设置的是 200 毫秒,但方法往往要跑 1 秒多。结果界面指针转圈,用户点任何按钮都没反应,最后只能任务管理器强杀。用 DispatcherTimer 不是错,错的是不知道它回调的本质是“排队在 UI 线程上的一段同步代码”。

1.2 异步任务调度中真正危险的部分:回调失控

当你在 Tick 里写下await _httpClient.GetStringAsync(...),这段代码的后续执行依赖于 SynchronizationContext 的调度。在 WPF 里,默认会回到 UI 线程继续执行,这很方便,但也带来了一个隐患:异步回调的“生命周期”已经完全脱离了 Timer 的控制。

什么意思?Timer 可以 Stop,可以改变 Interval,但 Stop 只会阻止下一次 Tick 的产生,对已经启动的异步任务没有任何约束力。如果你只是调用了timer.Stop(),然后窗口关闭,那这个异步任务仍然在后台飞驰。等它跑完了,await 后面的代码会尝试去更新已经销毁的 UI 元素,于是你就收获了一个 ObjectDisposedException,或者是更令人头痛的“无法访问已释放的对象”异常。

更麻烦的是并发重入。假如 Timer 的间隔是 1 秒,第一次 Tick 启动了一个网络请求,这个请求在网络抖动时可能耗时 5 秒。第二次 Tick 不会等你,它照样触发,又启动一个新的请求。任务堆叠越多,资源占用越高,最后界面卡死、内存飞涨。所以异步任务调度里,真正危险的不是定时器本身,而是“回调失控”——你无法准确预知每个异步操作何时完成、完成后会做什么、以及它是否还应该继续做。

1.3 一次真实的崩溃现场

我在维护一个车间数据看板项目时遇到过一模一样的现象。那个看板用 DispatcherTimer 每 5 秒去拉一次十几个仪表的实时数据,当时的代码大概是这个画风:

private async void OnTimerTick(object sender, EventArgs e) { var data = await _service.FetchAllAsync(); // 可能跑好几秒 UpdateBoard(data); // 更新一堆TextBlock }

看起来没什么问题对不对?但操作员切换页面时,负责当前页面的 Window 被 Close 了,timer 也被 Stop 了。问题在于,那个 FetchAllAsync 内部的 HttpClient 请求并没有取消,几秒后请求返回,UpdateBoard(data)开始访问已经 Disposed 的控件,异常在异步回调里炸出来,因为方法是 async void,异常直接炸到 Dispatcher,整个应用崩掉。

这个场面就是典型的“取消策略缺失”的代价。要解决它,需要的不是碰运气式的防止界面关闭,而是从架构层面把“定时触发”和“异步任务”两个生命周期彻底管起来。

1.4 把取消策略的目标先定清楚

结合上面的场景,我认为一套合格的取消策略至少要满足三个目标:

  • 资源管理:取消后,HttpClient 请求、文件流、数据库连接、CancellationTokenSource 本身都要能被及时释放,不能让任务在后台空跑浪费 CPU 和内存。
  • 用户控制:用户点击“停止”按钮、切换页面、关闭窗口时,系统必须在可感知的时间范围内真正停下来,而不是“看起来停了,其实后台还在跑”。
  • 应用稳定性:取消的瞬间会抛出 OperationCanceledException,如果处理不当,你会把“取消”误判为“故障”。稳定的策略应能区分用户的主动取消与真正的程序异常。

这三个目标就是下面所有方案的验收标准。脱离它们谈取消,都只是在写玩具代码。

2. 核心机制解析:CancellationTokenSource 和 DispatcherTimer 怎么协作

2.1 协作式取消的本质

.NET 的取消模型是协作式的,不是强杀式的。CancellationTokenSource 的 Cancel() 方法不会终止任何线程,它只是把一个标志位设为已取消,并触发注册过的回调。真正要停止工作的,是那些接收了 CancellationToken 的异步方法——它们有义务在合理的时机检查取消信号,并抛出 OperationCanceledException 来终止自己的执行。

这看起来绕,但其实是经过深思熟虑的设计。强制终止线程最大的问题是,你不知道线程停留在哪一行代码上,它可能持有一个锁、正在写文件、正在执行一个数据库事务。强杀等于把一个不完整的状态留在系统里,后果比慢一点更严重。协作式取消给了代码一个“优雅退场”的机会,让它在安全的位置终止,并允许清理资源。

放在 DispatcherTimer 的场景里,你真正要做的是:给每次 Tick 启动的任务创建或传递一个 CancellationToken,然后在取消点(如 UI 关闭、手动停止)调用 Cancel(),让异步方法自己感知取消并退出。

下面这段代码展示了最基础的协作取消模式:

private CancellationTokenSource _cts; private DispatcherTimer _timer; private void StartTask() { _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; _timer.Tick += OnTimerTick; _timer.Start(); } private async void OnTimerTick(object sender, EventArgs e) { // 每次Tick都用当前CTS的Token发起请求 if (_cts == null) return; var data = await _service.FetchAsync(_cts.Token); if (_cts.IsCancellationRequested) return; UpdateBoard(data); }

2.2 从“每次新建 CTS”到“统一生命周期管理”

一个常见的误区是:给每次异步任务单独 new 一个 CancellationTokenSource,然后感觉“我有取消机制了”。实际上,如果你不持有这个 CTS 的引用,等到你需要取消的时候,你根本找不到它,这等于没有取消。

比较合理的做法是:在窗口或 ViewModel 中维护一个长期存活的 CTS,它代表当前这个“会话”或“页面活动期”的取消信号。当窗口关闭或页面退出时,调用 Cancel 告诉所有在途任务“你们该结束了”。如果用户需要“手动停止再重新开始”,则把旧的 CTS Cancel 掉并 Dispose,再 new 一个新的 CTS。注意,CTS 一旦 Cancel 后不能复用,必须重建。

这里有一个细节很多人会忽略:Dispose 和 Cancel 的时机。你可以在 Cancel 之后立即 Dispose 吗?不建议这么做。Dispose 会立即释放 CTS 的资源,但注册在该 Token 上的回调可能还在执行中。稳妥做法是先 Cancel,然后让异步任务进入 using 块来管理 CTS 的生命周期,或者保留引用到页面真正卸载后再 Dispose。在 WPF 里,我一般封装一个 disposer,在 Window.Closed / UserControl.Unloaded 里同时处理 Stop 和 Dispose。

我这里提供一个写法供参考:

private CancellationTokenSource? _cts; private DispatcherTimer? _timer; private bool _disposed; public void StartPolling() { StopPolling(); // 防止重复开启 _cts = new CancellationTokenSource(); _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) }; _timer.Tick += OnTimerTick; _timer.Start(); } public void StopPolling() { _timer?.Stop(); _cts?.Cancel(); _cts?.Dispose(); _cts = null; _timer = null; }

2.3 把取消信号传进异步方法内部

如果你只是把 CancellationToken 传进_httpClient.GetAsync(url, token),那只是完成了第一层。真正的专业做法是让整个异步业务方法都能感知取消,因为异步任务的耗时可能不只在一个 HttpClient 里,还包括中间的数据处理、数据库写入、等待其他任务完成等。

举例来说,一个采集服务内部可能是这样的结构:先下载数据,再解析,再入库。如果取消发生在解析阶段,你希望它能立刻停止后面的入库操作,而不是等入库完成再退出。所以你要在方法的每一个耗时点都传入 token,在关键位置主动检查token.ThrowIfCancellationRequested()。

public async Task<BoardData> FetchAndProcessAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); var json = await _httpClient.GetStringAsync(apiUrl, token); token.ThrowIfCancellationRequested(); var raw = Parse(json); // 同步解析 await Task.Delay(100, token); // 模拟后续处理 return ConvertToBoardData(raw); }

ThrowIfCancellationRequested是一个简单粗暴且有效的检查点,它会在 token 被取消时抛出 OperationCanceledException,跳过后面的所有逻辑。它的优点是明确、直观,缺点是如果调用链里没人捕获这个异常,它会一路冒泡,最终你可能需要一个统一的异常处理点来吞掉它。这点在第四章的排查部分我会展开。

2.4 Register 回调:在取消时做额外的收尾工作

有些清理动作是异步方法内部做不了的,比如“取消之后把定时器停掉”“取消之后关闭数据库连接”“取消之后把界面的启动按钮恢复可用”。这时候可以注册 Token 的取消回调,让操作在 Cancel() 被调用的瞬间自动执行。

_cts = new CancellationTokenSource(); _cts.Token.Register(() => { _timer?.Stop(); Application.Current.Dispatcher.Invoke(() => { ToggleButton.IsEnabled = true; // 假如你在UI线程上有状态要复位 }); });

需要提醒的是:Register 的回调缺省是在调用 Cancel 的线程上同步执行的。如果 Cancel 是在 UI 线程调用,那你可以在回调里直接操作 UI;但如果是从后台线程 Cancel 的,回调里访问 UI 就得自己 Invoke。所以很多团队习惯把 Cancel 统一放到 UI 线程调用,省去一堆跨线程麻烦。这也算是一个工程上的小约定。

3. 实操:从最小可用到 MVVM 完整方案的三个版本

3.1 救火版:最小可用的 CancellationToken 接入

如果代码已经上线,现在只是要“止血”,那最少需要改三处:为 Tick 中启动的异步任务传入 token,在窗口关闭时取消,并统一捕获取消异常。我用一个极小的窗口示例来说明。

假设 XAML 里有一个文本框和一个启动按钮:

<StackPanel> <TextBox x:Name="InfoBox" Height="200" TextWrapping="Wrap"/> <Button Content="启动" Click="StartButton_Click"/> </StackPanel>

代码后置可以这样写:

private DispatcherTimer _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) }; private CancellationTokenSource? _cts; public MainWindow() { InitializeComponent(); _timer.Tick += OnTimerTickAsync; Closed += (_, _) => StopTimer(); // 窗口关闭时统一停止 } private void StartButton_Click(object sender, RoutedEventArgs e) { _cts = new CancellationTokenSource(); _timer.Start(); } private async void OnTimerTickAsync(object? sender, EventArgs e) { if (_cts == null) return; try { var data = await FetchDataAsync(_cts.Token); InfoBox.AppendText(data + Environment.NewLine); } catch (OperationCanceledException) { // 取消不算异常 } catch (Exception ex) { InfoBox.AppendText("错误: " + ex.Message + Environment.NewLine); } } private void StopTimer() { _timer.Stop(); _cts?.Cancel(); _cts?.Dispose(); _cts = null; }

救火版的要点在于:取消后不再刷新 UI,异常被分类处理。但它的缺点也很明显——如果异步任务发起后还没等 token 传入方法内层,我们无法保证深层资源的释放。所以这是临时用的,不是终极方案。

3.2 进阶版:防重入的串行轮询与窗口关闭全面清理

核心需求是避免并发重入。在不改变 DispatcherTimer 的前提下,有一个常见的“不可重入标志”写法。Timer 照常 Tick,但只有在上一个任务完成后才接受下一次任务启动。

private bool _isBusy; private CancellationTokenSource? _cts; private async void OnTimerTickAsync(object? sender, EventArgs e) { if (_isBusy) return; // 上一次还没跑完,直接跳过本次Tick _isBusy = true; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(_rootCts.Token); _cts = cts; await PollOnceAsync(cts.Token); } catch (OperationCanceledException) { // 预期中的取消,无需处理 } catch (Exception ex) { Log(ex); } finally { _isBusy = false; } }

这个方案引入了两个重要设计:

  • 全局的_rootCts:代表整个窗口或页面的声明周期,页面销毁时 Cancel 掉,所有在途任务都会被通知。
  • 每次 Tick 通过CreateLinkedTokenSource创建一个“临时子令牌”,可以把父级取消信号和临时任务本身的取消信号合并在一起。如果需要单独取消某一次的任务(比如用户点了“刷新一次”后马上取消),可以只 Cancel 子令牌。

窗口关闭时的完整清理应该覆盖三件事:停掉 Timer、取消根 CTS、释放资源。别忘了把事件订阅退订掉:

protected override void OnClosed(EventArgs e) { _timer.Stop(); _timer.Tick -= OnTimerTickAsync; _rootCts.Cancel(); _rootCts.Dispose(); base.OnClosed(e); }

我为什么强调要退订事件?因为 WPF 里的窗口和控件构成一棵活着的对象图,事件订阅会让 GC 无法正确回收。Timer 在被 Stop 之前仍持有事件的引用,而事件又持有窗口的方法引用,这可能导致窗口虽然关闭了,但内存迟迟不被释放。尤其在多窗口反复打开关闭的应用里,这是一条很重要的内存管理经验。

3.3 MVVM 版:把轮询任务封装成可复用的服务

在实际项目里,Timer 和取消逻辑散落在各个 View 的 Code-Behind 里会很痛苦。更常见的是你有多个页面都需要轮询,只是数据源不同。这时候适合把整个 DispatcherTimer + 取消 + 状态管理封装成一个后台轮询服务,ViewModel 只需要调用它与响应数据事件。

我写过一个精简版,类似这样:

public sealed class PollingService<T> : IDisposable { private readonly DispatcherTimer _timer; private readonly Func<CancellationToken, Task<T>> _pollAction; private CancellationTokenSource? _cts; public event Action<T>? DataReceived; public event Action<Exception>? ErrorOccurred; public PollingService(Func<CancellationToken, Task<T>> pollAction, TimeSpan interval) { _pollAction = pollAction; _timer = new DispatcherTimer { Interval = interval }; _timer.Tick += OnTick; } public void Start() { _cts = new CancellationTokenSource(); _timer.Start(); } public void Stop() { _timer.Stop(); _cts?.Cancel(); } private async void OnTick(object? sender, EventArgs e) { if (_cts == null) return; try { var data = await _pollAction(_cts.Token); DataReceived?.Invoke(data); } catch (OperationCanceledException) { /* 正常取消 */ } catch (Exception ex) { ErrorOccurred?.Invoke(ex); } } public void Dispose() { Stop(); _cts?.Dispose(); _timer.Tick -= OnTick; } }

调用方式就清晰多了。比如在某个 ViewModel 里:

_pollingService = new PollingService<string>( token => _httpClient.GetStringAsync(_apiUrl, token), TimeSpan.FromSeconds(5)); _pollingService.DataReceived += OnDataReceived; _pollingService.ErrorOccurred += OnError; _pollingService.Start(); // 页面退出时 public void Cleanup() { _pollingService.DataReceived -= OnDataReceived; _pollingService.ErrorOccurred -= OnError; _pollingService.Dispose(); }

这个服务的好处是:计时器、取消、异常处理都被统一收敛到一个类里,ViewModel 不需要关心 DispatcherTimer 的工作原理,只需要关心业务数据的消费。我也用过这个模式驱动多个读表页面,每一页只需要换 pollAction 和界面刷新逻辑,非常省事。

3.4 使用 CancellationTokenSource.CreateLinkedTokenSource 的理由

进阶版里我用到了 CreateLinkedTokenSource,这里多说一句。它创建的 CTS 会同时监听多个来源的取消信号:新建 CTS 本身的取消、关联的 Token 的取消。在这类轮询逻辑里,它完美对应两种取消场景:

  • 全局取消:窗口关闭或页面切换,你的根 Token 被 Cancel,于是所有进行中的 Tick 任务随之取消。
  • 局部取消:某一次初始化特别慢,用户点了“取消本次刷新”,你只需要 Cancel 掉本次临时 CTS,不影响后续轮询。

这比“只有一个全局 CTS”更灵活。很多有经验的开发者甚至会用一个 token 参数贯穿整个请求链路,然后再在外层包一层 Token 用于控制超时,形成“多层取消”。这些都建立在理解 LinkedTokenSource 的基础上。

4. 常见问题与排查技巧实录

4.1 症状:窗口关闭之后 CPU 占用依然很高

这个症状十有八九是 Timer 没有真正停掉,或者还有在途任务没有取消。排查第一步,在你的 Stop/Cleanup 方法里打断点,确认_timer.Stop()和_cts.Cancel()都被执行到了。第二步,看是不是有多个实例。很多人会在窗口构造里 new Timer,但窗口不是单例的,每次打开都 new 一个,关闭时只停掉了当前实例,旧的 Timer 还挂在老窗口的事件里跑。

我建议用一个简单的对象追踪办法:在 Timer Tick 的入口和退出处各打一条 Debug 日志,带上实例 ID 和当前时间。如果关闭窗口后日志还在持续输出,说明实例没有销毁干净。根源往往在事件订阅未退订,或者关闭逻辑只是隐藏了窗口而不是真正 Close。要记住:Visibility.Collapsed不等于关闭,Timer 不会因为窗口不可见就自动暂停。

4.2 症状:TaskCanceledException 被当成故障处理

这种问题在日志里最迷惑人。明明只是主动取消,日志系统却记了一条 Error。排查思路是检查异常捕获的顺序。OperationCanceledException 是 TaskCanceledException 的基类之一,但很多代码先写catch (Exception ex),把取消异常也吞进了错误日志。正确做法是把catch (OperationCanceledException)放在最前面,干净地吃掉取消分支。

try { await SomeWorkAsync(token); } catch (OperationCanceledException) { // 记录 Debug,不记录 Error } catch (Exception ex) { _logger.Error(ex, "任务执行失败"); }

这里还有个更隐蔽的情况:如果 HttpClient 请求超时或者网关返回错误,某些底层实现也会抛 TaskCanceledException。要区分“用户取消”和“超时取消”,可以通过检查 token 是否真的被取消了,或者 CTS 的超时时间是不是到了。比如:

catch (TaskCanceledException ex) when (ex.CancellationToken.IsCancellationRequested) { // 真的被用户/系统取消 } catch (TaskCanceledException ex) { // 大概率是对端超时,需要按失败处理 }

这个过滤器写法是我在实践中非常依赖的,建议大家一定要记住。

4.3 症状:UI 卡顿与 Tick 堆积

UI 卡顿通常有两个原因:Tick 里有同步耗时操作,或者异步任务并发重入导致 UI 线程排队更新。第一个原因好排查,直接用 dotnet-trace 录一份 CPU 分析,如果热点集中在 Timer 回调内的同步调用,那就把它也改为异步。第二个原因用日志看“Tick 进入”和“Tick 退出”的间隔就能发现,如果进入频率大于退出频率,就说明任务的执行时间已经超过 Interval,造成任务堆积。

解决办法就是我在进阶版里写的_isBusy标志。但要注意:_isBusy只是“丢掉溢出 Tick 数据”,并不是最佳业务语义。如果每次轮询的数据都很重要,一个都不能丢,那么应该考虑用队列削峰,而不是直接丢弃。当然,绝大多数看板场景并不要求每次数据都被消费,跳过上一次未完成的 Tick 是可接受的。

还有一个常常被忽视的点:如果 Tick 回调里用了 async void,并且没有用 try/finally 保护状态标志,那么在异常情况下_isBusy = false永远不会执行,Timer 从此变成“空转”。我的建议是:所有 async void 事件处理器内部,必须要用 try/catch/finally 覆盖全生命周期,这算是我自己用血泪教训换来的底线条款。

4.4 取消相关问题的排查速查表

现象大概率原因快速检查方法最优修复
窗口关闭后 CPU 仍高Timer 未 Stop / 在途任务未取消日志查看 Tick 是否持续触发统一 Cleanup 方法,调用 Stop+Cancel
大量 TaskCanceledException 错误日志取消异常被通用 catch 捕获检查 catch 块顺序OperationCanceledException 放在最前
界面点击无响应Tick 内同步耗时操作分析 Tick 执行堆栈改用异步方法 await
内存持续增长事件未退订 / CTS 未释放使用内存剖析工具查看关闭时退订事件并 Dispose
任务重复执行无防重入机制检查 Tick 进入/退出日志间隔添加 _isBusy 防重入标志
修改数据需要同步取消只有单层 token检查 token 是否传递到深层方法使用 CreateLinkedTokenSource 多层共享取消

4.5 一个让我反复吃亏的坑:Timer.Stop 不等于任务取消

我见很多朋友以为调用了timer.Stop()就算完成取消了。实际不是。Stop 只是不再触发新 Tick,已启动的异步任务会一路跑到底,直到它自己结束。如果你的任务不接收 token,那就是不可取消的;即使它接收了 token,如果不把 token 传到最底层的 IO 调用里,取消也只能延迟到下一个检查点。

我在一次设备通讯项目中遇到过:停止按钮调了timer.Stop()并立即释放了通讯端口,但上一次读取操作还在等待设备应答,等它回来时端口已经关闭,异常把整个工控界面打挂。那次事故之后,我的代码里写死了一条规则:先 Cancel,再 Close/Dispose 任何资源,再停用 UI 元素。顺序错了,永远都会有竞态。

5. 我的实操经验与建议清单

最后分享几条不写代码也能少走弯路的经验。

第一,不要把取消逻辑写成“只在正常路径里有效”。用户永远会在你意想不到的时候点击停止、切走窗口、断开设备。所以你的停止方法应该是幂等的,连续调用两次不能抛异常。实现幂等的办法很简单:Cancel 前判断_cts != null,或者用if (!_cts.IsCancellationRequested)包一层。

第二,CTS 生命周期尽量贴近其所属的 UI 生命周期。在窗口级作用域上维护一个根 CTS,比在每次 Tick 里各自管理要容易得多。一旦窗口关闭,一个 Cancel 就能覆盖所有在途任务。如果你想精确控制某一次刷新的取消,再在根 Token 之上做 LinkedTokenSource 就可以了。

第三,给每个异步轮询服务留一个统一的异常出口。无论是 OperationCanceledException 还是业务异常,都从同一个回调里出来。这样日志格式统一,排查问题的时候真的很顺手。

第四,async void 在 WPF 里不是完全不能碰,但它必须是事件处理器的末端,且被 try/catch 完整包裹。任何异步方法能通过 return Task 暴露出来的,就绝不要用 async void 包装。用了 async void,异常会直接抛到 SynchronizationContext 上,你的统一捕获机制大概率拦不住。

我在实际维护中越来越觉得,DispatcherTimer 本身只是一个触发器,整个异步任务调度体系是否稳定,取决于你有没有想清楚两个生命周期:定时器触发的是“开始信号”,而异步任务则拥有自己的“运行和结束周期”。两者必须用 CancellationToken 桥接起来,让用户停止、窗口关闭、任务取消变成同一件事。

如果你按上面的方案去重构一段现有的轮询代码,刚开始可能会觉得改动有点多,但等你真的遇到“任务卡死后台却不知道去哪找”的现场,你会感谢那次重构。这个思路也可以延伸到 WPF 之外的场景,比如 WinUI、Avalonia 里的同类问题——背后的协作式取消模型是完全一致的。下一次写调度代码之前,先把“什么时候停、停了以后谁来清理”想清楚,比多写几百行功能代码重要得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 23:41:06

SpringBoot+Vue学生课外活动管理系统全栈实战:从需求拆解到部署避坑

最近帮人做了一套基于SpringBoot的学生课外活动管理系统&#xff0c;前后端完整跑通&#xff0c;折腾了小半个月。这个项目技术栈不算复杂&#xff0c;但麻雀虽小五脏俱全——用户体系、权限控制、活动发布、报名审核、消息通知、数据统计全都要顾到。如果你正在准备JavaWeb方向…

作者头像 李华
网站建设 2026/11/3 23:40:59

基于Spring Boot和MyBatis Plus的代码作业查重系统实战解析

简介&#xff1a;这是一套基于Spring Boot和MyBatis Plus的代码作业查重系统完整源码&#xff0c;面向需要实现作业提交、自动查重与评分管理场景的各类开发者&#xff0c;适合作为毕业设计或企业级教学平台的基础。系统覆盖学生、教师、管理员三类角色&#xff0c;支持作业发布…

作者头像 李华
网站建设 2026/11/3 10:12:38

Spring Boot社交后端实战:从好友申请到WebSocket实时聊天

在夜间节目场景里&#xff0c;“交个朋友”是一个非常典型的陌生人社交话题。本文不讨论节目内容本身&#xff0c;而是从开发者的视角&#xff0c;把“黑夜排档交个朋友”这个选题落地成一个可以运行的社交后端项目&#xff1a;用户注册、兴趣标签匹配、好友申请、实时聊天、离…

作者头像 李华
网站建设 2026/10/16 4:38:41

Python坦克大战源码实战:从解压运行到改造排错

简介&#xff1a;Python坦克大战源代码压缩包&#xff0c;是针对Python初学者和游戏开发爱好者的入门级实战项目&#xff0c;通过一个经典坦克对战游戏串联多个编程知识点。资源共74个文件&#xff0c;约12.17MB&#xff0c;包含6个Python源文件&#xff08;如Game5.py主入口以…

作者头像 李华
网站建设 2026/10/14 9:15:38

SpringBoot+Vue外卖系统课设实战:从环境搭建到二次开发避坑指南

简介&#xff1a;这份资源是面向高校计算机相关专业学生的Java课程设计与期末大作业参考项目&#xff0c;采用SpringBoot后端搭配Vue前端实现完整的外卖管理系统&#xff0c;适合需要提交课程设计、毕业设计或自学前后端分离开发的学习者使用。压缩包共192个文件&#xff0c;约…

作者头像 李华