← 返回首页

ARTICLE

Unity异步UI的重入陷阱:如何用队列锁解决并发问题

在Unity开发中,异步UI操作常常导致数据不一致。本文深入剖析异步重入问题的本质,并展示一种基于队列锁的优雅解决方案。 一、什么是重入问题?为什么它是灾难? 重入(Reentrancy) :同一个异步函数被多次调用,执行流程相互交错,导致状态混乱。 1.1 典型场景:电商购物车 想象一个电商应用的

在Unity开发中,异步UI操作常常导致数据不一致。本文深入剖析异步重入问题的本质,并展示一种基于队列锁的优雅解决方案。


一、什么是重入问题?为什么它是灾难?

重入(Reentrancy):同一个异步函数被多次调用,执行流程相互交错,导致状态混乱。

1.1 典型场景:电商购物车

想象一个电商应用的购物车功能。用户点击"加入购物车"按钮后:

T1: 用户第一次点击 → 发送AddToCart请求 → await 挂起
T2: 网络慢,用户以为没反应,又点一次 → 又发送请求 → await 挂起
T3: 请求1返回 itemId=1 → 触发UI刷新 → await 获取购物车列表
T4: 请求2返回 itemId=2 → 又触发UI刷新 → 又await 获取列表

T5: 列表请求2先返回 [item1, item2] → UI显示两个商品
T6: 列表请求1后返回 [item1] → 覆盖UI,只显示一个商品!

结果:用户加了两个商品,但购物车只显示一个。这不是网络问题,是并发问题。

1.2 为什么会发生?

异步操作天生就是并发的:

async void OnAddToCartClick()
{
    // 这里没有任何保护
    var result = await HttpClient.Post("/cart/add", itemData);

    // 发布事件通知UI刷新
    EventSystem.Publish(new OnCartChanged { itemId = result.id });
}

// UI刷新处理
public class OnCartChanged_RefreshUI : AUIEvent<UICartPage, OnCartChanged>
{
    protected override async UniTask Run(UICartPage ui, OnCartChanged args)
    {
        // 问题:这个函数可能被同时调用多次
        var list = await HttpClient.Get("/cart/list");
        ui.RefreshList(list);  // 先返回的会被后返回的覆盖
    }
}

核心问题:await挂起时,函数并没有结束,只是让出了线程。如果在此期间又有新的调用,两个执行流就会交错。


二、传统方案为什么治标不治本?

2.1 方案A:手动标志位(简单粗暴,但会丢数据)

private bool isRefreshing = false;

public async UniTask RefreshCartList()
{
    if (isRefreshing) return;  // 第二次调用直接丢弃

    isRefreshing = true;
    var list = await HttpClient.Get("/cart/list");
    UpdateUI(list);
    isRefreshing = false;
}

致命缺陷:

  • 丢失更新:第二次刷新被直接忽略,用户可能看不到最新数据
  • 重复代码:每个UI的每个异步方法都要写一遍这个逻辑
  • 容易遗忘:新人接手项目,很可能忘记加这个判断
  • 难以调试:UI没刷新,是因为isRefreshing=true吗?还是网络问题?

2.2 方案B:业务层维护队列(复杂易错)

private Queue<Func<UniTask>> pendingTasks = new Queue<Func<UniTask>>();
private bool isProcessing = false;

public async UniTask RefreshCart(long itemId)
{
    // 把任务加入队列
    pendingTasks.Enqueue(async () => {
        var data = await HttpClient.Get($"/cart/{itemId}");
        UpdateUI(data);
    });

    if (!isProcessing) await ProcessQueue();
}

private async UniTask ProcessQueue()
{
    isProcessing = true;
    while (pendingTasks.Count > 0)
    {
        var task = pendingTasks.Dequeue();
        await task();
    }
    isProcessing = false;
}

问题:

  • 重复代码:每个UI都要实现一遍队列逻辑
  • 容易出bug:队列处理本身就复杂,很容易写错
  • 没有超时:如果某个任务卡住,整个队列永久阻塞
  • 没有取消:UI销毁了,队列还在后台执行

三、优雅的解决方案:框架级队列锁

3.1 核心思想:单UI实例粒度的锁

设计目标:

  1. 业务代码不需要关心并发控制
  2. 每个UI实例有独立的锁,互不干扰
  3. 自动排队,不丢失任何事件
  4. 自动超时,防止死锁

实现方式:

// 框架层封装(业务层看不到)
private async UniTask _Run(UIBase uibase, UI ui, EventTypes args)
{
    // 关键:为每个UI实例获取独立的锁
    using (await UniTaskLockComponent.Instance.Wait(
               UniTaskLockComponent.UI,    // 锁类型:UI
               uibase.InstanceId,          // 锁的key:UI实例的唯一ID
               uibase.token,               // 取消令牌
               3000                        // 超时3秒
           ))
    {
        if(ui.IsDisposed) return;

        // 业务代码在这里执行,完全串行
        await Run(ui, args);
    }
}

三个关键设计:

  1. 锁的粒度是UI实例:通过 InstanceId 作为key,UICartPage实例A和实例B的锁完全独立
  2. 自动排队:拿不到锁就进入等待队列,不会丢失
  3. 自动释放:using语句保证执行完自动释放,通知下一个

3.2 有锁情况下的完整时间轴

T1: 用户第一次点击"加入购物车" → 发送请求
T2: 用户又点一次 → 又发送请求

T3: 请求1返回 itemId=1
    → EventSystem.Publish(OnCartChanged)
    → AUIEvent 尝试获取 UICartPage(instanceId=12345) 的锁
    → 成功获取(因为是第一个)
    → 执行业务逻辑
    → await HttpClient.Get("/cart/list")
    → 挂起等待,但锁未释放! ← 关键

T4: 请求2返回 itemId=2
    → EventSystem.Publish(OnCartChanged)
    → AUIEvent 尝试获取 UICartPage(instanceId=12345) 的锁
    → 锁已被占用!加入等待队列 ← 不是丢弃,是排队

T5: 请求1的列表数据返回
    → ui.RefreshList([item1])
    → 业务逻辑执行完毕
    → using 块结束,自动释放锁
    → 通知等待队列:下一个可以执行了

T6: 请求2从队列中被唤醒
    → 获取锁
    → 执行业务逻辑
    → await HttpClient.Get("/cart/list")
    → 返回 [item1, item2]
    → ui.RefreshList([item1, item2])  ← 正确!
    → 释放锁

核心保证:请求1还在执行时(即使await挂起),锁一直被持有,请求2必须等请求1完全执行完。


四、底层实现:UniTaskLock的队列机制

4.1 数据结构:二维索引的队列

UniTaskLockComponent (全局单例)
└── list[type]  // type=0 是UI类型
    └── Dictionary<long key, UniTaskLockQueue>
        ├── key=12345 (UICartPage的InstanceId)
        │   └── Queue<UniTaskCompletionSource>
        │       ├── 事件1的等待任务
        │       └── 事件2的等待任务
        └── key=67890 (UIHomePage的InstanceId)
            └── Queue<...>  // 另一个UI的独立队列

设计亮点:

  • 按(type, key)二维索引:不同类型、不同实例的锁完全隔离
  • 队列存储等待任务:后来的事件不会丢失,而是排队
  • UniTaskCompletionSource:手动控制异步任务的完成时机

4.2 Wait() 的核心逻辑

public static async UniTask<UniTaskLock> Wait(
    this UniTaskLockComponent self,
    int type, long key, CancellationToken ct, int time = 60000)
{
    UniTaskLockQueueType queueType = self.list[type];

    // 检查这个key是否已有队列
    if(!queueType.TryGetValue(key, out UniTaskLockQueue queue))
    {
        // 第一次请求这个锁,直接创建并返回
        queueType.Add(key, self.AddChildWithId<UniTaskLockQueue>(...));
        return self.CreateUniTaskLock(type, key, time, 1);
    }

    // 已有队列,说明锁被占用,创建等待任务
    UniTaskCompletionSource<UniTaskLock> tcs = new();
    queue.Add(tcs, time);  // 加入队列

    // 等待被唤醒(这里会挂起,直到前一个任务释放锁)
    return await tcs.Task;
}

4.3 完整的解锁-上锁流程

事件1执行完成
    ↓
using 块结束(自动调用Dispose)
    ↓
UniTaskLock.Dispose() 触发销毁系统
    ↓
UniTaskLockDestroySystem.Destroy()
    ↓
RunNextUniTask() 加入"下一帧执行队列"
    ↓
【下一帧】Update() 被调用
    ↓
UniTaskLockComponentUpdateSystem.Update()
    ↓
Notify() 从队列取出事件2的 tcs
    ↓
tcs.TrySetResult(newLock) 唤醒事件2
    ↓
事件2的 await tcs.Task 返回
    ↓
事件2拿到锁,开始执行

为什么延迟到下一帧?

如果立即唤醒下一个任务:

事件1完成 → 立即唤醒事件2 → 事件2完成 → 立即唤醒事件3 → ...
可能导致单帧执行大量任务 → 卡顿

延迟到下一帧的好处:

  1. 防止单帧时间过长:每帧最多处理一批任务
  2. 给其他系统机会:渲染、输入、物理等系统需要CPU时间
  3. 性能可控:如果连续处理100个事件,会打印warning

五、超时机制:防止死锁的最后一道防线

5.1 为什么需要超时?

假设某个网络请求永远不返回(服务器挂了、网络断了):

无超时机制:
  事件1 → await 卡死 → 锁永远不释放
  事件2、3、4... → 永远在队列等待
  这个UI实例 → 彻底死锁 → 用户点击任何按钮都没反应

有超时机制(3秒):
  事件1 → await 卡死
  3秒后 → UniTaskLockTimer 超时触发
  → 强制释放锁
  → 打印错误日志:Log.Error($"[UniTaskLock] timed out!")
  → 事件2继续执行
  → UI恢复响应

5.2 超时的实现

// 每个Wait调用都设置了超时时间
using (await UniTaskLockComponent.Instance.Wait(
    UniTaskLockComponent.UI,
    uibase.InstanceId,
    uibase.token,
    3000  // ← 3秒超时
))

超时检查在每帧Update中进行:

private void _TimeoutCheck(UniTaskLockComponent self)
{
    long time_now = TimeInfo.Instance.frameTime;

    // 遍历所有超时的锁
    foreach(var pair in self.timers)
    {
        if(pair.Key > time_now) break;

        // 强制释放超时的锁
        var locker = pair.Value.uni_task_lock;
        self.RunNextUniTask(locker.lock_type, locker.key, locker.level + 1);
        locker.lock_type = UniTaskLock.TYPE_NONE;
    }
}

六、实战场景:新手引导的完美保护

6.1 问题场景

新手引导需要一步步控制UI:

async UniTask ShowStep1()
{
    // 1. 打开商城页面
    var shopPage = await UIComponent.Get<UIShopPage>();

    // 2. 高亮"购买"按钮
    await shopPage.HighlightButton("BuyButton");

    // 3. 等待用户点击
    await shopPage.WaitForButtonClick("BuyButton");

    // 4. 继续下一步
    await ShowStep2();
}

无锁情况下的灾难:

T1: ShowStep1() 调用 UIComponent.Get<UIShopPage>()
    → 开始异步加载UI资源
    → await 挂起

T2: 同时,服务器推送:"限时优惠开始"
    → Publish(OnPromotionStarted)
    → AUIEvent<UIShopPage> 被触发
    → 也调用 UIComponent.Get<UIShopPage>()
    → 又加载同一个UI

T3: 第一个Get完成
    → HighlightButton("BuyButton")
    → 开始播放高亮动画

T4: 第二个Get也完成
    → 事件处理调用 shopPage.RefreshPromotionBanner()
    → 刷新页面,可能触发UI重建
    → 高亮动画被打断! ← 引导流程被破坏
    → 用户困惑:为什么高亮没了?

有锁情况下的完美保护:

T1: ShowStep1() 调用 Get<UIShopPage>()
    → 获取锁(因为是第一个)
    → 开始加载UI

T2: OnPromotionStarted 触发
    → 尝试获取同一个UI的锁
    → 锁被占用,加入队列等待 ← 不会打断引导

T3: ShowStep1() 完成加载
    → HighlightButton("BuyButton")
    → WaitForButtonClick()
    → await 挂起,但锁还在持有 ← 关键

T4: 用户点击按钮
    → ShowStep1() 继续执行
    → ShowStep2()
    → 最终执行完毕,释放锁

T5: OnPromotionStarted 从队列出来
    → 获取锁
    → 执行刷新逻辑
    → 不会干扰引导流程 ← 完美!

七、业务代码的使用:极致简洁

7.1 示例:购物车UI刷新

// 1. 定义事件结构(纯数据)
namespace EventTypes
{
    public struct OnCartItemAdded
    {
        public long itemId;
        public string itemName;
        public int quantity;
    }
}

// 2. 实现事件处理(框架自动加锁)
public class OnCartItemAdded_RefreshUI : AUIEvent<UICartPage, OnCartItemAdded>
{
    protected override async UniTask Run(UICartPage ui, OnCartItemAdded args)
    {
        // 只写业务逻辑即可,框架自动处理:
        // ✓ 生命周期检查(UI是否已销毁)
        // ✓ 自动加锁(防止重入)
        // ✓ 异常捕获(不会崩溃)
        // ✓ 超时保护(3秒)
        await ui.AddItemToList(args.itemId, args.itemName, args.quantity);
    }
}

// 3. 在业务代码中发布事件
async UniTask AddToCart(ItemData data)
{
    var result = await HttpClient.Post("/cart/add", data);

    // 发布事件,所有监听者都会收到
    EventSystem.Instance.Publish(new OnCartItemAdded
    {
        itemId = result.id,
        itemName = data.name,
        quantity = data.quantity
    });
}

你不需要关心:

  • 场景中有几个UICartPage实例
  • 它们是否处于激活状态
  • 会不会发生重入
  • 需不需要手动加锁

框架全帮你处理了。

7.2 数据层事件:不需要锁

// 数据层事件用 AEvent(不是 AUIEvent)
public class OnLogout_ClearCache : AEvent<OnLogout>
{
    protected override async UniTask Run(OnLogout args)
    {
        // 清理缓存,不涉及UI
        CacheManager.Instance?.ClearAll();
        await UniTask.CompletedTask;
    }
}

为什么不用 AUIEvent?

  • 不需要遍历UI实例
  • 不需要加锁(数据清理是单线程操作)
  • 不需要生命周期检查

八、架构设计:分层与职责分离

graph TD
    subgraph BusinessLayer ["业务层 (User Code)"]
        A["实现 Run(UI ui, Event args)"]
        B["执行业务逻辑"]
    end

    subgraph WrapperLayer ["AUIEvent 封装层"]
        C["遍历 UI 实例"]
        D["生命周期检查 (IsDisposed)"]
        E["自动加锁 (Locking)"]
        F["异常捕获 (Try-Catch)"]
    end

    subgraph LockLayer ["UniTaskLockComponent 锁管理层"]
        G["任务队列管理"]
        H["超时检查 (Timeout)"]
        I["取消处理 (Cancellation)"]
    end

    subgraph DispatchLayer ["EventSystem 事件调度层"]
        J["反射加载 (Reflective Loading)"]
        K["事件注册 (Subscription)"]
        L["事件分发 (Dispatching)"]
    end

    %% 流程连接
    A --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> H
    H --> I
    I --> L
    L -.-> K
    K -.-> J

    %% 样式美化
    style BusinessLayer fill:#e1f5fe,stroke:#01579b
    style WrapperLayer fill:#fff3e0,stroke:#e65100
    style LockLayer fill:#f3e5f5,stroke:#4a148c
    style DispatchLayer fill:#e8f5e9,stroke:#1b5e20

每层只做自己的事:

  • 业务层:不知道锁的存在,专注业务逻辑
  • 封装层:处理UI相关的通用逻辑(遍历、检查、加锁)
  • 锁管理层:处理并发控制(队列、超时、取消)
  • 调度层:处理事件路由(注册、分发)

这就是优秀框架设计的精髓:让复杂的事情在框架层解决,让业务代码保持简洁。


九、方案对比:为什么框架方案更优?

特性 手动标志位 业务层队列 框架队列锁
是否丢失事件 ✗ 丢失 ✓ 不丢失 ✓ 不丢失
代码重复度 ✗ 每个UI都要写 ✗ 每个UI都要写 ✓ 框架统一处理
超时保护 ✗ 无 ✗ 需自己实现 ✓ 自动3秒超时
取消机制 ✗ 无 ✗ 需自己实现 ✓ CancellationToken
生命周期感知 ✗ 需手动检查 ✗ 需手动检查 ✓ 自动检查
业务代码复杂度 ✗ 高 ✗ 高 ✓ 低(只写核心逻辑)
维护成本 ✗ 高 ✗ 高 ✓ 低(框架统一维护)

十、总结:这个设计解决了什么?

10.1 核心价值

  1. 异步重入问题:通过队列锁,保证UI事件串行执行
  2. 状态一致性:避免多个异步操作交错导致数据混乱
  3. 新手引导可靠性:引导流程不会被其他事件打断
  4. 开发体验:业务代码无需关心并发控制
  5. 可维护性:统一的锁机制,易于调试和优化
  6. 防御性:超时、取消、异常捕获,防止系统崩溃

10.2 设计哲学

  • UI刷新数据不一致
  • 新手引导被意外事件打断
  • 异步操作死锁
  • UI销毁后事件还在执行导致空指针

好的框架设计,就是让正确的事情变得容易,让错误的事情变得困难。


本文基于Unity项目的实际架构总结,采用ECS + 事件驱动的设计模式,适用于中大型Unity项目。