1. 项目概述:异步任务编排中的取消难题
在Unity游戏开发中,异步编程早已不是新鲜话题。从传统的协程(Coroutine)到基于Task的异步模式,开发者们一直在寻找更高效、更可控的方式来处理那些耗时的操作,比如资源加载、网络请求或复杂的AI计算。我自己在多个上线项目中,从手游到主机游戏,都深刻体会到,一个健壮的异步系统往往是项目后期稳定性的基石。而当我们开始广泛使用UniTask这个强大的库来替代原生Task和协程时,会发现其提供的WhenAny和WhenAll这两个组合器(Combinator)极大地简化了任务编排逻辑。然而,随之而来的一个高级议题便是:如何优雅且可靠地取消这些组合任务?
这不仅仅是调用一个Cancel()那么简单。想象一个场景:你的游戏需要同时预加载三个场景的资产(A、B、C),使用WhenAll来等待所有加载完成。此时玩家突然切换菜单,需要立即中断加载并清理资源。如果你只是粗暴地取消WhenAll返回的Task,很可能导致某些子任务已经完成,其资源无法被正确释放;或者某些子任务被取消,但另一些还在后台运行,造成状态不一致和内存泄漏。WhenAny的情况则更微妙,它常用于竞速或超时处理,比如同时向多个CDN源请求同一份配置文件,取最先返回的结果。当第一个任务完成,你需要取消其他仍在进行的请求,以避免不必要的网络消耗和潜在的回调冲突。
网络上关于UniTask基础使用的文章很多,但深入探讨WhenAny和WhenAll内部取消机制,尤其是如何将外部的CancellationToken正确传播到每一个子任务,并处理部分完成、部分取消的混合状态,这样的内容并不多见。很多开发者,包括我早期,都曾在这里踩过坑——要么取消不了,要么抛出意料之外的OperationCanceledException导致程序崩溃。本文将结合我多年的实战经验,拆解UniTask中这两个核心组合器的取消行为,提供从原理到实操的完整解决方案,让你能构建出真正抗干扰的异步逻辑。
2. 核心概念解析:CancellationToken与UniTask的组合器
在深入WhenAny和WhenAll之前,我们必须夯实两个基础概念:CancellationToken在异步世界中的运作方式,以及UniTask组合器的本质。
2.1 CancellationToken的传播链与协作式取消
CancellationToken是.NET和C#中实现协作式取消的标准模式。关键在于“协作式”三个字。它不是一个强制中断线程的命令,而是一个信号。持有CancellationToken的任务需要定期检查这个信号的IsCancellationRequested属性,并在适当的时候通过调用ThrowIfCancellationRequested()方法主动抛出OperationCanceledException来响应取消。
在UniTask的语境下,这个检查通常是自动的。当你将一个CancellationToken传递给一个UniTask方法(例如UniTask.Delay, 或者使用WithCancellation扩展方法),该UniTask内部会在关键节点检查令牌。然而,问题在于传播。你创建了一个父任务(比如一个WhenAll),并传入了一个CancellationToken tokenA。这个tokenA会控制父任务本身的取消,但它不会自动传递给组成WhenAll的各个子任务。每个子任务可能需要自己的取消逻辑,或者需要链接到父令牌上。
// 错误示例:token 并未传递给子任务 CancellationTokenSource cts = new CancellationTokenSource(); var task1 = LoadAssetAsync("AssetA"); // 未接收token var task2 = LoadAssetAsync("AssetB"); // 未接收token var combinedTask = UniTask.WhenAll(task1, task2).AttachExternalCancellation(cts.Token); cts.Cancel(); // 这会取消combinedTask,但task1和task2可能继续运行!正确的做法是构建一个取消令牌的传播链。通常使用CancellationTokenSource.CreateLinkedTokenSource方法,它可以创建一个新的CancellationTokenSource,这个新的源会在其父令牌被取消时自动触发取消。
// 正确示例:将父令牌链接到子任务 CancellationTokenSource parentCts = new CancellationTokenSource(); var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(parentCts.Token); var task1 = LoadAssetAsync("AssetA", linkedCts.Token); // 子任务接收链接后的token var task2 = LoadAssetAsync("AssetB", linkedCts.Token); var combinedTask = UniTask.WhenAll(task1, task2); // WhenAll本身可以不直接附加,因为子任务已受控 parentCts.Cancel(); // linkedCts也会被取消,从而task1和task2会收到取消请求注意:
AttachExternalCancellation是一个便捷方法,它为现有的UniTask附加一个外部取消令牌。但它作用于“当前”这个Task对象。对于WhenAll创建的组合任务,使用AttachExternalCancellation意味着:当令牌取消时,尝试取消这个“等待所有子任务完成”的等待操作本身,而不是去取消子任务。理解这个区别至关重要。
2.2 WhenAll 与 WhenAny 的行为本质
UniTask.WhenAll(TaskA, TaskB, ...):它返回一个新的UniTask,这个新任务等待所有传入的子任务完成。只有所有子任务都到达最终状态(完成、取消或故障),WhenAll返回的任务才会完成。它的完成状态是“聚合”的:- 如果所有子任务都成功完成,它成功完成。
- 如果任何一个子任务因异常失败,
WhenAll返回的任务会立即以该异常(或AggregateException)失败。注意,其他子任务仍在后台继续运行,除非你显式取消它们。 - 如果任何一个子任务被取消,
WhenAll返回的任务会立即以OperationCanceledException进入取消状态。同样,其他任务继续运行。
UniTask.WhenAny(TaskA, TaskB, ...):它返回一个新的UniTask,这个新任务等待任意一个传入的子任务完成。只要有一个子任务到达最终状态,WhenAny返回的任务就立即完成,并返回该完成任务的索引(或结果)。它的行为是“竞速”的:- 它只关心第一个完成的任务。其他任务的状态被忽略,但它们依然在运行。这是最容易被忽视的风险点。
理解了这两个组合器的本质,就能明白取消机制的复杂性所在:组合任务与子任务是松耦合的。取消组合任务,不等于自动取消其子任务。我们必须主动设计取消信号的流向。
3. WhenAll的取消策略:全有或全无的精细控制
WhenAll的取消需求通常很明确:要么全部成功,要么在外部干预时全部停止。根据子任务是否支持取消,我们有不同的策略。
3.1 策略一:所有子任务均支持取消令牌
这是最理想的情况。每个子任务方法都接受一个CancellationToken参数,并在内部进行协作式取消。
标准实现模式:
public async UniTaskVoid StartControlledLoading() { // 父级取消源,例如绑定到UI取消按钮 CancellationTokenSource parentCts = new CancellationTokenSource(); // 为这一组操作创建链接的取消源 using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(parentCts.Token); try { // 启动所有子任务,并传入链接的令牌 var loadTask1 = LoadSceneAssetsAsync("Scene1", linkedCts.Token); var loadTask2 = LoadCharacterModelsAsync("Heroes", linkedCts.Token); var loadTask3 = LoadAudioClipsAsync("BGM", linkedCts.Token); // 等待所有任务完成。此处WhenAll本身不额外附加令牌,因为子任务已受控。 await UniTask.WhenAll(loadTask1, loadTask2, loadTask3); Debug.Log("所有资源加载完成!"); } catch (OperationCanceledException) { // 当parentCts.Cancel()被调用时,linkedCts会触发,子任务内部取消并抛出异常, // 导致WhenAll提前抛出OperationCanceledException。 Debug.Log("资源加载被用户取消。"); // 这里可以进行统一的清理工作,但注意子任务内部的清理应在其自己的catch中完成。 } catch (Exception e) { Debug.LogError($"加载过程中发生错误: {e}"); } // using语句确保linkedCts被Dispose } // 一个支持取消的加载函数示例 private async UniTask LoadSceneAssetsAsync(string sceneName, CancellationToken ct) { // 模拟分步加载,每一步都检查取消 ct.ThrowIfCancellationRequested(); await LoadTextures(sceneName, ct); // 假设这个函数也支持取消 ct.ThrowIfCancellationRequested(); await LoadMeshes(sceneName, ct); // ... 更多步骤 }实操心得:使用using语句包裹linkedCts是确保资源释放的好习惯。即使WhenAll因异常提前退出,using也能保证linkedCts被正确销毁,避免内存泄漏。此外,在catch (OperationCanceledException)块中,通常只进行最外层的状态通知和日志记录,具体的资源回滚(如释放已加载的Asset)应该在每个子任务函数内部实现,因为只有它们知道自己加载到了哪一步。
3.2 策略二:处理不支持取消的遗留子任务
在改造旧代码或使用第三方库时,常会遇到不支持CancellationToken的异步方法。此时无法从内部中止它们,但我们可以从外部“忽略”它们的结果。
使用UniTask.SuppressCancellationThrow或AttachExternalCancellation+ 状态检查:
public async UniTaskVoid StartMixedLoading() { CancellationTokenSource cts = new CancellationTokenSource(); // 假设这个任务不支持取消 var legacyTask = LegacyLoadSystemAsync(); // 假设这个任务支持取消 var modernTask = ModernLoadSystemAsync(cts.Token); try { // 关键:为WhenAll附加取消令牌,并使用SuppressCancellationThrow来避免异常传播 var (isCanceled, _) = await UniTask.WhenAll(legacyTask, modernTask) .AttachExternalCancellation(cts.Token) .SuppressCancellationThrow(); if (isCanceled) { Debug.Log("组合任务被取消。"); // 问题:legacyTask可能还在后台运行! // 我们需要手动处理它,例如设置一个共享的“取消请求”标志位。 _shouldAbortLegacyOperation = true; // 等待它自然结束或超时,但无法强制中断。 await legacyTask; // 或者忽略它,但这可能导致状态混乱 } else { Debug.Log("所有任务完成。"); } } catch (Exception e) when (!(e is OperationCanceledException)) { Debug.LogError($"发生错误: {e}"); } }注意事项:这种策略存在显著风险。
legacyTask在取消后仍会继续消耗CPU、内存或网络资源,直到它自己结束。在设计系统时,应尽量避免混合使用支持和不支持取消的任务。如果必须使用,考虑为遗留任务增加一个超时机制(UniTask.Timeout)或提供一个可供轮询的AbortFlag。
4. WhenAny的取消策略:胜者通吃与败者清理
WhenAny的取消逻辑核心是:第一个任务完成后,立即取消所有其他仍在进行的任务。这通常用于超时控制、多源竞速下载等场景。
4.1 标准竞速模式与资源清理
public async UniTask<string> DownloadFromFastestSourceAsync(CancellationToken externalToken) { var sources = new[] { "https://source1.com/file", "https://source2.com/file", "https://source3.com/file" }; var downloadTasks = new UniTask<string>[sources.Length]; // 为这一组下载任务创建一个链接的取消源 using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); // 启动所有下载任务 for (int i = 0; i < sources.Length; i++) { downloadTasks[i] = DownloadFromSourceAsync(sources[i], linkedCts.Token); } // 等待任意一个任务完成 var (winningIndex, winningResult) = await UniTask.WhenAny(downloadTasks); Debug.Log($"最快源是: {sources[winningIndex]}"); // 立即取消其他所有下载任务 linkedCts.Cancel(); // 等待一小段时间,让其他任务处理取消逻辑(非必须,但更安全) try { // 使用WhenAll等待所有任务结束,但忽略结果和异常(除了观察)。 // 使用SuppressCancellationThrow来避免因取消而抛出异常。 await UniTask.WhenAll(downloadTasks).SuppressCancellationThrow(); } catch (Exception e) { // 这里可能会捕获到非取消异常,例如网络错误,但我们可以选择记录或忽略, // 因为我们已经有了成功的结果。 Debug.LogWarning($"其他任务在取消过程中报告了非取消异常: {e.Message}"); } // 返回获胜者的结果 return winningResult; } private async UniTask<string> DownloadFromSourceAsync(string url, CancellationToken ct) { using var unityWebRequest = UnityWebRequest.Get(url); var asyncOp = unityWebRequest.SendWebRequest(); // 将CancellationToken与UnityWebRequest的异步操作关联 // 注意:UnityWebRequest本身没有直接的CancellationToken支持,需要手动处理。 while (!asyncOp.isDone && !ct.IsCancellationRequested) { await UniTask.Yield(); } if (ct.IsCancellationRequested) { unityWebRequest.Abort(); // 主动中止请求 ct.ThrowIfCancellationRequested(); // 抛出取消异常 } if (unityWebRequest.result != UnityWebRequest.Result.Success) { throw new Exception($"下载失败: {unityWebRequest.error}"); } return unityWebRequest.downloadHandler.text; }关键点解析:
- 共享令牌:所有竞速任务共享同一个
linkedCts.Token,这是实现“一键取消所有”的基础。 WhenAny之后立即取消:一旦得到获胜者,第一时间调用linkedCts.Cancel()。这向所有其他任务发送取消信号。- 清理等待:使用
await UniTask.WhenAll(downloadTasks).SuppressCancellationThrow();等待所有任务达到最终状态(完成或取消)。这一步不是必须的,但能确保所有任务的清理逻辑(如UnityWebRequest.Abort())都有机会执行,避免资源泄露。SuppressCancellationThrow防止取消异常再次抛出,干扰主流程。 - 子任务中的取消检查:在
DownloadFromSourceAsync中,我们手动在循环中检查ct.IsCancellationRequested,并在取消时调用unityWebRequest.Abort()。这是Unity API与CancellationToken协作的典型模式。
4.2 结合超时(Timeout)的使用技巧
WhenAny的一个经典用法是实现超时控制。UniTask提供了便捷的Timeout扩展方法。
public async UniTask<string> FetchDataWithTimeoutAsync() { var longRunningTask = FetchDataFromSlowAPIAsync(); // 一个可能很慢的任务 var timeoutTask = UniTask.Delay(TimeSpan.FromSeconds(5)).SuppressCancellationThrow(); // 竞速:是慢任务先完成,还是超时先发生? var (winningIndex, _) = await UniTask.WhenAny(longRunningTask, timeoutTask); if (winningIndex == 0) // 慢任务赢了 { return await longRunningTask; // 获取结果 } else // 超时赢了 { // 取消慢任务(如果它支持取消) // 这里需要额外的CancellationTokenSource来控制慢任务,与上面的例子不同。 throw new TimeoutException("获取数据超时。"); } }更优雅的方式是直接使用Timeout方法:
public async UniTask<string> FetchDataWithTimeoutAsync(CancellationToken externalToken) { try { // Timeout方法内部就是利用WhenAny实现的 return await FetchDataFromSlowAPIAsync(externalToken) .Timeout(TimeSpan.FromSeconds(5)); } catch (TimeoutException) { // 处理超时 return "Default Data"; } }实操心得:
UniTask.Timeout本质上封装了WhenAny和Delay的逻辑,并自动处理了取消。在大多数超时场景下,直接使用Timeout方法更简洁安全。但如果你需要在超时后执行更复杂的逻辑(比如记录是哪个具体操作超时,或尝试备用方案),那么手动实现WhenAny会更灵活。
5. 高级模式与常见陷阱排查
掌握了基础策略后,我们来看一些更复杂的场景和容易踩坑的地方。
5.1 陷阱一:WhenAll中的部分成功与资源回滚
当WhenAll中的某个任务失败或被取消时,其他任务可能已经部分完成了工作。例如,同时保存玩家数据到本地和云端,云端保存失败,但本地已保存成功。这时需要事务性回滚。
解决方案:使用“补偿任务”(Compensation Task)或“两阶段提交”思想。
public async UniTask<bool> SaveGameTransactionallyAsync(CancellationToken ct) { bool localSaveSuccess = false; bool cloudSaveSuccess = false; string localBackupData = null; try { // 阶段1:准备与尝试 localBackupData = LoadLocalData(); // 备份当前本地数据 var localSaveTask = SaveToLocalAsync(currentData, ct); var cloudSaveTask = SaveToCloudAsync(currentData, ct); await UniTask.WhenAll(localSaveTask, cloudSaveTask); localSaveSuccess = true; cloudSaveSuccess = true; Debug.Log("双端保存成功!"); return true; } catch (Exception e) when (e is not OperationCanceledException) { Debug.LogError($"保存失败: {e}"); // 阶段2:补偿(回滚) if (localSaveSuccess && !cloudSaveSuccess) { // 本地成功但云端失败,需要回滚本地数据 Debug.LogWarning("云端保存失败,回滚本地数据。"); await RestoreLocalDataAsync(localBackupData, ct); } // 如果本地失败,云端成功的情况比较棘手,可能需要通知云端服务进行回滚(如果有API)。 return false; } catch (OperationCanceledException) { Debug.Log("保存操作被取消。"); // 取消时,也要根据已完成的部分进行清理 if (localSaveSuccess) { await RestoreLocalDataAsync(localBackupData, CancellationToken.None); // 使用独立token回滚 } throw; // 或者返回false } }5.2 陷阱二:未处理的子任务异常与进程崩溃
即使WhenAll因为第一个异常而快速失败,其他子任务中未观察到的异常(Unobserved Exception)在垃圾回收时可能导致进程崩溃(在部分.NET环境下)。在Unity中,虽然表现可能不同,但未处理的异常总是不安全的。
解决方案:确保观察所有子任务的结果。
public async UniTask SafeWhenAllAsync() { var task1 = DangerousOperationAsync(); var task2 = AnotherDangerousOperationAsync(); UniTask allTasks = UniTask.WhenAll(task1, task2); try { await allTasks; } catch { // WhenAll抛出了第一个异常 } // 关键:即使WhenAll异常了,也单独await每个任务以确保其异常被观察到。 // 使用SuppressCancellationThrow和SuppressException来安全地获取状态。 var result1 = await task1.SuppressCancellationThrow(); var result2 = await task2.SuppressCancellationThrow(); // 现在可以检查每个任务的具体状态 if (result1.IsFaulted) Debug.LogError($"Task1 failed: {result1.Exception}"); if (result2.IsCanceled) Debug.Log("Task2 was canceled."); }UniTask的SuppressCancellationThrow和SuppressException扩展方法非常有用,它们允许你获取一个UniTask的结果而不抛出异常,返回一个包含状态和异常信息的UniTaskResult结构体。
5.3 性能考量:CancellationTokenSource的创建与销毁
频繁创建和销毁CancellationTokenSource(尤其是LinkedTokenSource)会产生GC(垃圾回收)压力。在性能关键的循环或每帧操作中,需要谨慎。
优化模式:对象池或长期存在的CTS。
public class AsyncOperationManager : MonoBehaviour { private CancellationTokenSource _globalCts; private Dictionary<int, CancellationTokenSource> _linkedCtsPool; void Awake() { _globalCts = new CancellationTokenSource(); _linkedCtsPool = new Dictionary<int, CancellationTokenSource>(); } void OnDestroy() { _globalCts?.Cancel(); _globalCts?.Dispose(); foreach(var cts in _linkedCtsPool.Values) cts.Dispose(); } public CancellationToken GetLinkedTokenForOperation(int operationId) { if (!_linkedCtsPool.TryGetValue(operationId, out var cts)) { cts = CancellationTokenSource.CreateLinkedTokenSource(_globalCts.Token); _linkedCtsPool[operationId] = cts; } else if (cts.IsCancellationRequested) { // 如果之前的已取消,则重新创建 cts.Dispose(); cts = CancellationTokenSource.CreateLinkedTokenSource(_globalCts.Token); _linkedCtsPool[operationId] = cts; } return cts.Token; } public void CancelOperation(int operationId) { if (_linkedCtsPool.TryGetValue(operationId, out var cts)) { cts.Cancel(); // 可以选择立即Dispose并从池中移除,或者等待下次复用检查时处理。 } } }这种模式适合管理大量生命周期可预测的异步操作组。对于一次性或简单的操作,直接using创建仍是清晰且推荐的做法。
6. 实战案例:一个可取消的并行资源加载器
让我们综合运用以上知识,构建一个实战中的模块:一个支持进度报告、错误处理和外部取消的并行资源加载器。
using System; using System.Collections.Generic; using System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; public class ParallelAssetLoader : MonoBehaviour { [System.Serializable] public class LoadRequest { public string AssetPath; public System.Type AssetType; } public async UniTask<Dictionary<string, UnityEngine.Object>> LoadAssetsAsync( IList<LoadRequest> requests, IProgress<float> progress = null, CancellationToken externalToken = default) { if (requests == null || requests.Count == 0) { return new Dictionary<string, UnityEngine.Object>(); } // 创建用于这一批加载的专用取消源,链接到外部令牌。 using var batchCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); var batchToken = batchCts.Token; var loadTasks = new UniTask<(string path, UnityEngine.Object asset)>[requests.Count]; var loadedAssets = new Dictionary<string, UnityEngine.Object>(requests.Count); int completedCount = 0; // 启动所有并行加载任务 for (int i = 0; i < requests.Count; i++) { var request = requests[i]; // 每个任务都接收批次的取消令牌 loadTasks[i] = LoadSingleAssetAsync(request, batchToken, progress, () => { // 每完成一个,更新总进度 Interlocked.Increment(ref completedCount); progress?.Report((float)completedCount / requests.Count); }); } try { // 等待所有加载任务完成 var results = await UniTask.WhenAll(loadTasks); // 整理结果 foreach (var result in results) { if (result.asset != null) { loadedAssets[result.path] = result.asset; } } Debug.Log($"并行加载完成,成功加载 {loadedAssets.Count}/{requests.Count} 个资产。"); return loadedAssets; } catch (OperationCanceledException) { Debug.Log("资产加载被取消。"); // 即使取消,也需要等待所有任务达到最终状态,以便它们清理资源。 await UniTask.WhenAll(loadTasks).SuppressCancellationThrow(); // 清理可能已加载的部分资产(根据需求决定) foreach (var task in loadTasks) { // 安全地获取任务结果(如果已完成) var result = await task.SuppressCancellationThrow(); if (result.IsCompletedSuccessfully && result.Result.asset != null) { // 对于已加载的资源,是保留还是释放?这取决于业务逻辑。 // 示例中我们选择释放(如果是非托管资源需要谨慎)。 // Resources.UnloadAsset(result.Result.asset); // 仅用于Resources加载的资源 Debug.LogWarning($"已加载但被取消的资源: {result.Result.path}"); } } throw; // 重新抛出取消异常,或返回空字典/部分字典。 } catch (Exception e) { Debug.LogError($"并行加载过程中发生未预料错误: {e}"); // 同样需要等待所有任务结束 await UniTask.WhenAll(loadTasks).SuppressCancellationThrow(); throw; } } private async UniTask<(string path, UnityEngine.Object asset)> LoadSingleAssetAsync( LoadRequest request, CancellationToken ct, IProgress<float> progress, System.Action onCompleted) { ct.ThrowIfCancellationRequested(); // 模拟异步加载,例如使用Addressables或Resources // 这里以Resources.LoadAsync为例,它不支持CancellationToken,需要包装。 var resourceRequest = Resources.LoadAsync(request.AssetPath, request.AssetType); while (!resourceRequest.isDone && !ct.IsCancellationRequested) { progress?.Report(resourceRequest.progress * 0.8f); // 单个任务内部进度 await UniTask.Yield(PlayerLoopTiming.Update, ct); } if (ct.IsCancellationRequested) { // 取消时,Resources.LoadAsync无法中止,但我们可以忽略其结果。 // 更复杂的加载系统(如Addressables)支持取消。 Resources.UnloadAsset(resourceRequest.asset); // 如果已经加载了,卸载它 ct.ThrowIfCancellationRequested(); } onCompleted?.Invoke(); return (request.AssetPath, resourceRequest.asset); } }这个案例展示了如何将WhenAll、链接的CancellationTokenSource、进度报告和健壮的错误/取消处理结合在一起。关键点在于LoadAssetsAsync方法中的try-catch块,它确保了无论成功、失败还是取消,所有启动的子任务都会被妥善等待(通过SuppressCancellationThrow),避免残留任务和未观察异常。同时,在取消时,它提供了钩子让开发者决定如何处理已加载的部分资源。
在实际项目中,你可能需要替换Resources.LoadAsync为Addressables.LoadAssetAsync,后者原生支持CancellationToken,能实现更即时的取消和资源清理。通过这样的设计,你的资源加载模块将变得非常可靠,能够从容应对玩家中断加载、场景快速切换等复杂情况。