PAYATHON 2026

.NET MAUI 中的死锁与 ConfigureAwait 使用问题

排障赵师傅

结论

单靠 ConfigureAwait(false) 解决不了 .NET MAUI 的 UI 卡死问题。它只控制 await 之后是否尝试恢复到原来的上下文,既不会自动把代码转到后台线程,也无法处理网络请求长期无响应、同步阻塞、重复加载或大量 UI 更新引起的卡顿。

从这段代码看,没有发现典型的同步等待死锁,因为其中没有 .Result、.Wait() 或 GetAwaiter().GetResult()。更值得检查的是以下问题:

  • UpdateDisplayedItems 在 ConfigureAwait(false) 之后直接修改绑定属性。
  • BeginInvokeOnMainThread 只排队执行,没有被等待,方法可能先于 UI 更新返回。
  • Scrolled 触发频率很高,触底条件可能连续成立。
  • AddRange 实际上逐项修改 ObservableCollection,每一项都可能触发布局和绑定刷新。
  • CollectionView 嵌套在 ScrollView 中,可能削弱虚拟化效果,增加布局成本。
  • 空的 catch (Exception) { } 会吞掉异常。网络超时、集合跨线程访问和弹窗错误最终都可能表现为“挂起”。

UI 事件、弹窗、导航和绑定集合更新应留在主线程。ConfigureAwait(false) 更适合纯数据访问、网络请求以及不依赖 UI 的库代码。

ConfigureAwait(false) 应该放在哪里

通常可以在数据访问层或服务层使用它:

public async Task<List<Good>> GetGoodsAsync(ProductsRequest request)
{
    var result = await GoodToList(request).ConfigureAwait(false);

    // 此处只能处理普通数据,不能访问控件、导航、弹窗或绑定集合。
    return result;
}

以下操作需要在主线程执行:

  • 修改 ObservableCollection
  • 给绑定属性赋值并触发 PropertyChanged
  • 页面导航
  • 显示或关闭弹窗
  • 操作 VisualElement
  • 调用动画 API

UI 事件处理程序一般不需要添加 ConfigureAwait(false):

await _viewModel.RunIsBusyTaskAsync(...);

原代码中的这句应去掉末尾的 ConfigureAwait(false):

await _viewModel.RunIsBusyTaskAsync(async () =>
{
    // ...
}).ConfigureAwait(false);

当前 await 后面虽然没有其他代码,但这个设置并无实际收益。以后如果在后面加入 UI 代码,还容易让代码跑到错误的线程上。

修正 UpdateDisplayedItems

原方法只在 IsClear == true 时切回主线程。IsClear == false 分支会在 ConfigureAwait(false) 之后直接替换 Goods 和 SelectedProduct,造成跨线程更新绑定状态。

BeginInvokeOnMainThread 还有一个问题:它只把操作加入队列,不会等待操作完成。调用方可能已经关闭加载弹窗,并将 IsBusy 重置为 false,此时集合更新才刚开始。

可以改为等待 MainThread.InvokeOnMainThreadAsync:

public async Task UpdateDisplayedItemsAsync(bool append, int start, int end)
{
    // 如果 product 是绑定集合,应在离开 UI 线程前取得快照。
    var productCodes = product
        .Select(x => x.ProductCode)
        .ToHashSet();

    var result = await GoodToList(new ProductsRequest
    {
        Start = start,
        End = end
    }).ConfigureAwait(false);

    if (result == null || result.Count == 0)
        return;

    // 普通数据计算可以留在当前线程。
    var selectedItems = result
        .Where(x => productCodes.Contains(x.ProductCode))
        .Cast<object>()
        .ToList();

    // 所有绑定状态统一在主线程修改,并等待修改完成。
    await MainThread.InvokeOnMainThreadAsync(() =>
    {
        if (append)
        {
            foreach (var item in result)
                Goods.Add(item);

            foreach (var item in selectedItems)
                SelectedProduct.Add(item);
        }
        else
        {
            Goods = new ObservableCollection<Good>(result);
            SelectedProduct = new ObservableCollection<object>(selectedItems);
        }
    });
}

如果其他线程可能修改 product,读取时也要明确切回主线程:

var productCodes = await MainThread.InvokeOnMainThreadAsync(
    () => product.Select(x => x.ProductCode).ToHashSet());

这里可以安全地使用 ConfigureAwait(false)。后续的数据筛选不访问 UI,最终更新绑定状态时,又通过 InvokeOnMainThreadAsync 明确回到了主线程。

修正忙碌状态和弹窗处理

bool IsBusy 无法可靠地充当异步互斥机制。如果以后有多个线程同时调用,检查和赋值之间可能发生竞争。可以改用 SemaphoreSlim,同时确保弹窗操作始终在主线程执行:

private readonly SemaphoreSlim _busyGate = new(1, 1);

public async Task RunIsBusyTaskAsync(Func<Task> operation)
{
    if (!await _busyGate.WaitAsync(0))
        return;

    var popupShown = false;

    try
    {
        await MainThread.InvokeOnMainThreadAsync(async () =>
        {
            await MopupService.Instance.PushAsync(loadingPopup);
            popupShown = true;
        });

        await operation();
    }
    finally
    {
        if (popupShown)
        {
            await MainThread.InvokeOnMainThreadAsync(async () =>
            {
                if (MopupService.Instance.PopupStack.Contains(loadingPopup))
                    await MopupService.Instance.PopAsync();
            });
        }

        _busyGate.Release();
    }
}

不要把异常处理留空:

catch (Exception)
{
}

这样会丢失最有用的诊断信息。更合适的处理方式是记录异常,再由 UI 层决定是否向用户提示:

catch (Exception ex)
{
    logger.LogError(ex, "加载商品失败");
    throw;
}

如果当前类没有注入日志组件,调试期间至少要保留断点或接入现有日志设施。只调用 Debug.WriteLine,随后继续把操作当作成功处理,也会掩盖问题。

原代码只检查 PopupStack.Any(),可能会关闭其他操作打开的弹窗。应确认栈中存在当前 loadingPopup,或者让弹窗调用方明确记录所有权。具体检查方式取决于项目使用的 Mopups 版本和 API。

限制滚动事件的重复加载

一次滚动过程中,Scrolled 会触发多次。到达底部后,如果内容高度和滚动位置没有及时变化,触底条件可能连续成立。

UI 事件处理程序可以使用 async void,但要捕获异常:

private async void ScrollView_Scrolled(object sender, ScrolledEventArgs e)
{
    if (!onNavigated || sender is not ScrollView scrollView)
        return;

    var reachedBottom =
        scrollView.ScrollY >= scrollView.ContentSize.Height - scrollView.Height;

    if (!reachedBottom)
        return;

    try
    {
        await _viewModel.RunIsBusyTaskAsync(async () =>
        {
            var nextStart = start + 49;
            var nextEnd = end + 49;

            await _viewModel.UpdateDisplayedItemsAsync(
                append: true,
                nextStart,
                nextEnd);

            // 请求成功后再提交分页位置,避免失败时跳过一页。
            start = nextStart;
            end = nextEnd;
        });
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "滚动加载商品失败");
    }
}

还需要记录服务端是否已经没有更多数据。否则,用户停留在底部时,应用可能反复发送没有结果的分页请求。分页状态最好集中交给 ViewModel 管理,不要分散在页面的 start、end 字段中。

如果只需要纵向无限滚动,可以优先使用 CollectionView 自带的增量加载能力:

<CollectionView
    ItemsSource="{Binding Goods}"
    RemainingItemsThreshold="5"
    RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}" />

命令内部仍要防止重复进入,并维护 HasMoreItems。这种方式与 CollectionView 的虚拟化和加载模型更一致,也比手动监听 ScrollView.Scrolled 更合适。

避免 ScrollView 嵌套 CollectionView

当前布局把 CollectionView 放在 ScrollView 内。外层滚动容器可能要求内部列表按完整内容测量,导致大量项目同时参与创建、测量和布局。数据变多后,UI 线程可能因布局压力而无响应,并不一定发生了死锁。

如果布局允许,应由 CollectionView 自己负责纵向滚动。横向表格可以考虑固定列布局、支持横向滚动的行模板或专门的数据表格控件。选用哪种方式取决于页面是否必须同时支持水平和垂直滚动,但应尽量避免让外层 ScrollView 接管 CollectionView 的纵向滚动。

减少集合更新造成的卡顿

当前 AddRange 只是循环调用 Add:

foreach (var item in items)
{
    collection.Add(item);
}

每次 Add 都可能引发集合通知、绑定刷新和布局。一次追加几十条数据,就可能让主线程出现明显停顿。

可以根据现有绑定方式选择以下做法:

  • 使用支持批量通知的集合类型,一次发送范围变更通知。
  • 在后台构造完整列表,再到主线程一次性替换集合。
  • 分批追加数据,并在每批之间让出 UI 线程。
  • 精简项目模板中的控件和布局。
  • 无限滚动时在列表底部显示轻量加载指示器,避免每次分页都弹出模态窗口。

具体采用哪种集合实现,需要结合当前绑定逻辑和目标平台验证。不能直接假定任意 ObservableCollection 扩展方法都能真正合并通知。

典型死锁仍需排查

典型的 UI 异步死锁通常从同步等待开始:

// UI 线程
var result = SomeAsyncMethod().Result;

与此同时,异步方法默认尝试恢复到 UI 上下文:

public async Task<Data> SomeAsyncMethod()
{
    return await LoadAsync();
}

UI 线程同步等待结果,LoadAsync 的延续又等待 UI 线程,于是双方互相等待。解决办法是让整条调用链从入口到末端都保持异步:

var result = await SomeAsyncMethod();

应在整个项目中搜索这些调用:

.Result
.Wait()
.WaitAll()
.WaitAny()
GetAwaiter().GetResult()

此外,还要检查锁内是否执行了 await,导航回调是否同步等待 Task,以及第三方组件是否规定只能从主线程调用。

区分“挂起”和“卡顿”

界面无响应不等于发生死锁,可以按以下顺序排查:

  1. 在调试器中启用 CLR 异常的“引发时中断”,避免异常被吞掉。
  2. 在 GoodToList 调用前后记录时间、分页参数、线程 ID 和异常。
  3. 界面卡住时暂停调试器,查看 UI 线程调用栈。如果线程正在处理布局、集合通知或模板创建,通常是 UI 饥饿或渲染开销导致的。
  4. 如果 UI 线程停在 .Wait()、.Result、锁或信号量等待上,再沿调用链查找同步等待。
  5. 为网络请求设置合理的超时,并在页面离开时取消尚未完成的请求,前提是底层请求 API 支持 CancellationToken。
  6. 检查每次滚动是否都创建了新请求、重复显示弹窗或加载同一页。
  7. 使用平台性能工具查看主线程占用、布局次数和网络等待。Android 还可以结合 ANR 线程栈,确认 UI 线程具体停在什么位置。

ConfigureAwait(false) 能减少不必要的上下文恢复,但通常带来的性能收益很小。在这个场景中,应优先处理滚动事件防重入、集合批量更新、列表虚拟化和请求取消,同时保留完整的异常信息。

备注:内容仅供参考。