C#中INotifyPropertyChanged引发的竞态条件问题咨询
解决C#中INotifyPropertyChanged引发的ComboBox绑定竞态条件问题
从你描述的场景来看,这个竞态条件的核心原因很明确:当用户快速切换SelectedCostcentre时,前一次异步加载员工的任务还没完成,新的任务已经启动,如果旧任务反而晚于新任务完成,就会把员工列表覆盖成旧成本中心的数据,导致UI显示和当前选中的成本中心不匹配。
下面给你两种实用的解决方案,你可以根据自己的场景选择:
方案1:使用CancellationTokenSource取消旧任务(推荐)
这是最直接且资源友好的方式,通过取消未完成的异步任务,从根源上避免无效的结果覆盖。
修改你的ViewModel代码如下:
public abstract class SelectEmployeeViewModel : ViewModelBase { protected readonly Plant plant; private CancellationTokenSource _employeeLoadCts; private Costcentre _selectedCostcentre; private ObservableCollection<Employee> _employees = new ObservableCollection<Employee>(); public ObservableCollection<Costcentre> Costcentres { get; protected set; } public ObservableCollection<Employee> Employees { get => _employees; protected set => SetProperty(ref _employees, value); } public Costcentre SelectedCostcentre { get => _selectedCostcentre; set { if (SetProperty(ref _selectedCostcentre, value)) { // 取消并清理之前的加载任务 _employeeLoadCts?.Cancel(); _employeeLoadCts?.Dispose(); _employeeLoadCts = new CancellationTokenSource(); // 异步加载员工,传入取消令牌 _ = LoadEmployeesAsync(value, _employeeLoadCts.Token); } } } private async Task LoadEmployeesAsync(Costcentre selectedCostcentre, CancellationToken token) { try { Employees.Clear(); if (selectedCostcentre == null) return; // 执行实际的员工检索操作,传入取消令牌 var fetchedEmployees = await plant.GetEmployeesForCostcentreAsync(selectedCostcentre.Id, token); // 检查令牌是否已取消,避免后续无效操作 token.ThrowIfCancellationRequested(); // 在UI线程更新集合(如果ViewModel不在UI线程上下文) await Application.Current.Dispatcher.InvokeAsync(() => { foreach (var emp in fetchedEmployees) { Employees.Add(emp); } }); } catch (OperationCanceledException) { // 任务被取消是预期行为,无需额外处理 } catch (Exception ex) { // 这里可以添加日志记录或错误提示 Console.WriteLine($"加载员工失败: {ex.Message}"); } finally { // 清理当前的令牌源(确保只清理当前任务对应的实例) if (_employeeLoadCts?.Token == token) { _employeeLoadCts.Dispose(); _employeeLoadCts = null; } } } // 构造函数等其他初始化代码... protected SelectEmployeeViewModel(Plant plant) { this.plant = plant; Costcentres = new ObservableCollection<Costcentre>(); } }
关键要点:
- 每次变更
SelectedCostcentre时,先取消并销毁旧的CancellationTokenSource,再创建新的 - 异步方法中传入
CancellationToken,并在获取结果后检查令牌状态,避免无效的UI更新 - 捕获
OperationCanceledException,这是正常取消场景,不用抛出错误 - 使用
Dispatcher.InvokeAsync确保集合更新在UI线程(WPF中ObservableCollection只能在创建它的线程更新)
方案2:"最新请求获胜"机制(备选)
如果你的异步检索操作不支持取消令牌(比如调用第三方API),可以用这个方法:通过维护一个请求ID,只保留最新请求的结果。
示例代码:
public abstract class SelectEmployeeViewModel : ViewModelBase { protected readonly Plant plant; private int _currentRequestId; private Costcentre _selectedCostcentre; private ObservableCollection<Employee> _employees = new ObservableCollection<Employee>(); public ObservableCollection<Costcentre> Costcentres { get; protected set; } public ObservableCollection<Employee> Employees { get => _employees; protected set => SetProperty(ref _employees, value); } public Costcentre SelectedCostcentre { get => _selectedCostcentre; set { if (SetProperty(ref _selectedCostcentre, value)) { _ = LoadEmployeesAsync(value); } } } private async Task LoadEmployeesAsync(Costcentre selectedCostcentre) { // 线程安全地生成唯一请求ID var requestId = Interlocked.Increment(ref _currentRequestId); try { Employees.Clear(); if (selectedCostcentre == null) return; var fetchedEmployees = await plant.GetEmployeesForCostcentreAsync(selectedCostcentre.Id); // 检查当前请求是否是最新的,不是则忽略结果 if (requestId != _currentRequestId) return; await Application.Current.Dispatcher.InvokeAsync(() => { foreach (var emp in fetchedEmployees) { Employees.Add(emp); } }); } catch (Exception ex) { Console.WriteLine($"加载员工失败: {ex.Message}"); } } // 构造函数等其他代码... }
关键要点:
- 用
Interlocked.Increment保证多线程下请求ID的唯一性 - 异步任务完成后,对比请求ID和当前最新ID,不一致则放弃更新UI
- 这种方法不会取消旧任务,但能避免旧结果覆盖新结果
两种方案里,优先推荐方案1,因为它能真正终止不必要的异步操作,节省服务器资源和客户端性能;方案2适合无法取消异步操作的场景。
内容的提问来源于stack exchange,提问作者Tommehh
相关产品推荐
相关产品推荐

