
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 UniTaskstring DownloadFromFastestSourceAsync(CancellationToken externalToken) { var sources new[] { https://source1.com/file, https://source2.com/file, https://source3.com/file }; var downloadTasks new UniTaskstring[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 UniTaskstring 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 UniTaskstring 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 UniTaskstring 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 UniTaskbool 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 Dictionaryint, CancellationTokenSource _linkedCtsPool; void Awake() { _globalCts new CancellationTokenSource(); _linkedCtsPool new Dictionaryint, 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 UniTaskDictionarystring, UnityEngine.Object LoadAssetsAsync( IListLoadRequest requests, IProgressfloat progress null, CancellationToken externalToken default) { if (requests null || requests.Count 0) { return new Dictionarystring, 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 Dictionarystring, 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, IProgressfloat 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能实现更即时的取消和资源清理。通过这样的设计你的资源加载模块将变得非常可靠能够从容应对玩家中断加载、场景快速切换等复杂情况。