这篇是 GameFramework 学习笔记的第四篇,把任务池(Task Pool)单独拎出来聊。搜索“GameFramwork + Task Pool”的人通常刚上手框架,想搞明白下载模块、资源加载队列这类异步任务是怎么被调度起来的。任务池这个名字乍一听像是个队列容器,实际上它是整个游戏框架异步任务调度的中枢:一方面把“要做什么”和“谁来做”拆成两套对象,另一方面用对象池的思路把任务对象也一并管理起来,避免频繁创建销毁导致 GC 抖动。不管你是想往框架里加自定义下载模块,还是想让自己手写的一堆异步逻辑变得规整可控,先把任务池吃透,后面可以少走很多弯路。
这篇内容会侧重于任务池的设计思路、核心类的职责、调度流程的完整推演,以及我在项目中实际使用它时踩过的坑。代码部分基于个人阅读的 GameFramework 源码版本做拆解,具体字段名或方法名在不同版本里可能略有差异,但核心流程是稳定的,理解了之后换成哪个版本都能顺下来。
1. 为什么需要任务池:先搞清楚它解决什么问题
1.1 没有任务池时,项目里是怎么管理异步逻辑的
在做框架之前,项目里管理异步任务最常见的方式就是“一个列表 + 一个 Update 循环”。比如要处理头像下载、序列帧播放、存档上传,就在 LoaderManager 里维护一个 List<Task>,每帧遍历所有任务,根据任务类型去推进状态,做完就从列表移除。
任务数量少的时候这套写法没问题,一旦任务多起来,问题就非常明显。最痛的是状态逻辑散落四处:判断“这个下载是否超时”“那个动画该不该继续”全都挤在一个巨长的 Update 方法里,每个新任务类型都要往 Update 里塞一段 if。第二个痛点是任务对象本身无法复用,每次请求都 new 一个对象,移动端上高频异步操作很快就能让 GC 飙起来。第三个痛点是缺乏统一的调度机制,优先级插队、任务取消、失败重试这些需求几乎都要自己从零造轮子,写出来的代码还很难复用。
任务池这个模块就是把这几件事统一收口:谁来管理任务的生命周期、谁来执行任务的实际逻辑、任务被回收后如何复用,全部由框架给出标准答案。
1.2 任务池的设计破局点:任务与代理分离
任务池在设计上做了一个非常重要的拆分:任务(TaskBase)和代理(ITaskAgent)是两类完全不同的对象。
任务对象只描述“要做什么、现在处于什么状态”。比如“下载这张头像”,任务对象持有下载地址、保存路径、当前状态、优先级这些数据。它本身不负责真正去发网络请求,也不会去解析返回内容。
代理对象则是“真正干活的工人”。每个代理对应一个具体的执行者,它接收一个任务,然后用自己的逻辑把任务跑完。比如 WebRequestTaskAgent 内部持有 UnityWebRequest,负责发起请求、每帧检查是否完成、把结果回调给上层。
这个设计与现实中的“待办清单 + 员工”非常像。任务清单上写着“今天把方案文档写出来”,这是任务;真正坐在工位上敲键盘的是员工,这是代理。如果方案写不完要换个方式做,你换的是执行策略,而不是把“写方案”这件事本身改掉。
这样的拆分解耦让框架可以自由增加任意类型的任务,只要实现对应的代理,任务池本身完全不需要改动。这也解释了为什么后面我们要自定义任务时,通常只需要新写两个类。
1.3 任务本身也是池化的:与 ReferencePool 配合
任务池的名字里带“池”字,很多新手以为它只维护了一个代理池,实际上它还隐式依赖了对象池(ReferencePool)的机制。
在 GameFramework 中,TaskBase 类实现了 IReference 接口,意味着所有任务对象都是从引用池里通过 ReferencePool.Acquire<T>() 拿出来的,任务完成后又通过 ReferencePool.Release() 归还。这样做的好处很明显:高频创建任务对象时,内存分配和回收的代价被摊薄,GC 压力大幅降低。
所以聊 Task Pool 时要区分两个池子:代理池由 TaskPool 自己维护,控制的是“有多少工人在干活”;任务对象池由 ReferencePool 统一管理,控制的是“任务对象的复用”。两者配合,才完整构成了任务池模块的复用机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 任务池核心类逐个拆解
2.1 TaskBase:任务对象的公共底座
TaskBase 是任务池里所有任务的基类,它解决的问题是给所有任务提供一个统一的公共外壳。在这个类里,框架定义了几个关键字段。
SerialId 是每个任务在池中的唯一标识,由 TaskPool 在 AddTask 时自动分配,用来定位和移除任务。Priority 是任务的优先级,决定了等待队列里的排序。Status 是任务的当前状态,用 ETaskStatus 枚举描述。Description 是任务的备注说明,用来辅助排查。
除了字段,TaskBase 还定义了一组虚方法,子类按需重写:OnStart 在任务被代理接收时调用,OnUpdate 在任务执行期间每帧被轮询,OnComplete、OnFailure、OnCancel 分别是三种结局的回调。TaskBase 还实现了 IReference 的 Clear 方法,要求子类把引用类型字段、缓存数据全部清空,这是对象池复用的关键。
csharp复制public abstract class TaskBase : IReference
{
public int SerialId { get; protected set; }
public int Priority { get; protected set; }
public ETaskStatus Status { get; protected set; }
public string Description { get; protected set; }
public bool Done
{
get
{
return Status == ETaskStatus.Done ||
Status == ETaskStatus.Failure ||
Status == ETaskStatus.Canceled;
}
}
public abstract void Clear();
public virtual void OnStart() { }
public virtual void OnUpdate(float elapseSeconds, float realElapseSeconds) { }
public virtual void OnComplete() { }
public virtual void OnFailure() { }
public virtual void OnCancel() { }
}
2.2 ITaskAgent:真正干活的工人
ITaskAgent 是接口类型,泛型参数 T 约束为 TaskBase 的子类。它定义了一个工人需要具备的能力:初始化、每帧更新、启动任务、停止任务、重置状态。
其中最关键的是 Start 和 Update。Start 接收一个任务对象,代理内部把它存下来,并开始真正的执行逻辑;Update 是每帧推进的入口,代理在这里不断检查执行进度,比如下载是否完成、动画是否播完。Stop 的作用是把当前任务标记为完成并停止执行,准备回归空闲状态。Reset 则用来在回到代理池之前清理临时状态。
接口设计而不是基类设计,是因为任务执行业务千差万别。下载代理需要持有网络请求对象,动画代理需要持有播放器组件,如果强制用基类继承,会把很多不相干的东西堆在一起,接口让每个代理可以自由组合自己需要的内部依赖。
csharp复制public interface ITaskAgent<T> where T : TaskBase
{
T Task { get; }
void Initialize();
void Update(float elapseSeconds, float realElapseSeconds);
void Shutdown();
void Start(T task);
void Stop();
void Reset();
}
2.3 TaskPool:调度中枢的数据结构
TaskPool 是整个模块的调度中枢。它内部维护了几个集合:一个栈用来存空闲代理,一个链表用来存正在工作的代理,还有两个链表用来存等待中的任务以及全部任务列表。
为什么空闲代理用 Stack,工作代理用 LinkedList?这两个选择都有讲究。空闲代理的存取是高频操作,Stack 的压栈和弹栈都是 O(1),而且后进先出的特性有利于缓存局部性,最近用过的那批代理更容易命中热数据。工作代理需要在循环中频繁插入和移除,LinkedList 的节点增删是 O(1),而且工作代理的顺序本身不重要,用链表很合适。
TaskPool 的构造函数接收一个代理数组,创建时会逐个调用代理的 Initialize。对外暴露了几个方法:AddTask 添加任务、RemoveTask 按序列号移除任务、RemoveAllTasks 清空全部任务、Shutdown 终止池内所有代理。同时提供 FreeAgentCount、WorkingAgentCount、WaitingTaskCount 等属性,方便外部监控调度状态。
2.4 ETaskStatus:任务状态机
任务状态是整个任务池里最容易被忽略但又最重要的部分。ETaskStatus 枚举定义了五个状态:Todo(待执行)、Doing(执行中)、Done(已完成)、Failure(失败)、Canceled(已取消)。
状态的流转方向很明确:新任务刚 AddTask 时是 Todo,被代理 Start 后变为 Doing,执行结束后根据结果进入 Done、Failure、Canceled 三个终态之一。一旦进入终态,任务就从执行流程中移除,TaskBase 的 Done 属性会在这三个状态时返回 true。
这里有一个容易踩坑的点:Done 不单单代表成功完成,Failure 和 Canceled 在框架内部也被视为 Done。也就是说任务池判断“这个任务是否还需要被代理继续刷”时,只要 Status 进入任一终态就会回收代理。如果自定义任务里把 Canceled 和正常完成的处理逻辑写混了,很容易出现“用户点了取消,结果走了 OnComplete 回调”的诡异问题。
3. 任务池的调度流程:一个任务从生到死
3.1 任务入池:AddTask 内部发生了什么
当我们调用 TaskPool.AddTask 时,框架会给任务设置一个自增的 SerialId,然后根据任务优先级把它插入到等待任务链表的合适位置。这里不是简单的尾插,而是按优先级从高到低排序后的插入,所以高优先级任务能够排在低优先级任务前面。
但需要注意一个细节:新任务只会被插入等待队列,如果当前有足够的空闲代理,TaskPool 会在下一帧 Update 时立刻分配给空闲代理执行。也就是说 AddTask 之后任务不一定立即开始跑,要等主循环驱动。
正是因为存在“等一帧”的机制,我们在性能关键路径上不能假设 AddTask 后任务状态马上变成 Doing。如果框架定义的任务不是每帧都允许立即分配,这种延迟分配在一些极端时序场景下会引起误解。
3.2 每帧的 Update:分配与轮询
TaskPool.Update 是任务池的心脏,它每帧做三件事:先看等待队列里有没有任务、空闲代理栈里有没有工人,如果有就取出一个空闲代理,给代理分配队首的任务,然后把代理挪到工作链表中;接着遍历工作链表,逐个调用代理的 Update 推进任务执行;最后检查每个工作代理持有的任务状态,如果任务已进入终态,就把代理从工作链表移除,重置后压回空闲栈。
把这三件事理清楚,就理解了任务池的调度节拍。分配是“尽可能”,只要有空闲代理就分配;轮询是“都要看”,所有执行中的任务每帧都会被询问进度;回收是“有终则收”,三个终态都会被回收。
code复制
// 核心流程示意
public void Update(float elapseSeconds, float realElapseSeconds)
{
// 1. 给空闲代理分配等待任务
while (m_WaitingTasks.Count > 0 && m_FreeAgents.Count > 0)
{
ITaskAgent<T> agent = m_FreeAgents.Pop();
T task = m_WaitingTasks.First.Value;
m_WaitingTasks.RemoveFirst();
m_WorkingAgents.AddLast(agent);
agent.Start(task);
}
// 2. 轮询所有在工作中的代理
LinkedListNode<ITaskAgent<T>> current = m_WorkingAgents.First;
while (current != null)
{
ITaskAgent<T> agent = current.Value;
agent.Update(elapseSeconds, realElapseSeconds);
current = current.Next;
}
// 3. 回收已完成任务的代理
// 遍历工作链表,把 Task.Done 为 true 的代理移除并压回空闲栈
...
}
3.3 完成、失败、取消:三种结局各走各的回调
任务执行到终态时,状态切换的时机非常关键。一般情况下,判断任务是否完成、是否失败的逻辑在代理内部实现。比如下载代理在 Update 里发现 UnityWebRequest 的 isDone 为 true,就检查它的 error 字段,根据结果调用任务对应的 OnComplete 或 OnFailure,然后把任务状态改为对应的终态,再调用 Stop 让代理准备回归。
任务池本身不会主动去调用 OnComplete 或 OnFailure,它只负责观察状态。一旦发现任务已经 Done,就会做回收动作。所以自定义任务时,状态切换和回调触发必须由代理负责,这是整个模块最容易写错的地方。
取消的场景更特殊。当外部调用 RemoveTask 移除一个正在执行的任务时,框架会先把任务状态置为 Canceled,然后通知代理停止。此时代理的 Update 会因为任务已取消而停止推进业务逻辑。这块逻辑如果不仔细看源码,很容易理解成“RemoveTask 只是从列表里移除”,实际上它确实会作用于正在执行中的任务。
3.4 优先级到底怎么生效
优先级在实际使用中经常被高估。任务池的优先级只能影响等待队列中的排序,不会打断正在执行的任务。也就是说,如果当前有两个空闲代理,来了一个高优先级任务,它会排到等待队列最前面并被优先分配;但如果一个低优先级任务已经在代理上跑了一半,这时添加一个高优先级任务,它只能等低优先级任务跑完或者被手动取消。
这个特性跟操作系统的抢占式调度完全不同,更像是非抢占式的优先级队列。理解这一点后,设计业务逻辑时就不会对“插队”有错误预期。如果业务上真的需要“高优先级任务立刻打断低优先级任务”,只能在代理层自己实现,比如在任务对象上加一个 CancellationToken 之类的标记,在 Update 里检测到高优先级任务到达时主动取消当前任务。
4. 实操:在项目里写一个自己的任务和代理
4.1 先定义一个足够简单的 DelayTask
理论看十遍,不如写一个最小的例子跑通流程。我建议从一个 DelayTask 开始,它的需求很简单:延迟指定秒数后执行一个回调。
继承 TaskBase,我们只需要增加两个字段:DelayTime 和 Callback。DelayTime 是 float 类型,Callback 是 Action 类型。重写 OnStart 时可以把开始时间记录下来,不过实际计时放在代理里更合适,因为任务对象本身最好只描述数据和状态。Clear 方法里一定要把 Callback 置为 null,否则对象池复用时旧回调会被带到新任务里,这种 bug 非常隐蔽。
csharp复制public sealed class DelayTask : TaskBase
{
public float DelayTime { get; private set; }
public Action Callback { get; private set; }
public void Init(float delayTime, Action callback)
{
DelayTime = delayTime;
Callback = callback;
}
public override void Clear()
{
base.Clear();
DelayTime = 0f;
Callback = null;
}
public override void OnComplete()
{
if (Callback != null)
{
Callback.Invoke();
}
}
}
4.2 再写一个 DelayTaskAgent
代理类实现 ITaskAgent
这里有一个细节要注意:agent.Start 被调用时,要把代理持有的 Task 属性设置成传入的任务,同时重置剩余时间;agent.Reset 被调用时,要把 Task 置为 null,清理所有临时状态。这样才能保证代理从空闲栈里再次被取出时是干净的。
csharp复制public sealed class DelayTaskAgent : ITaskAgent<DelayTask>
{
public DelayTask Task { get; private set; }
private float m_RemainingTime;
public void Initialize() { }
public void Start(DelayTask task)
{
Task = task;
Task.OnStart();
m_RemainingTime = task.DelayTime;
}
public void Update(float elapseSeconds, float realElapseSeconds)
{
if (Task == null)
{
return;
}
m_RemainingTime -= elapseSeconds;
if (m_RemainingTime <= 0f)
{
Task.Status = ETaskStatus.Done;
Task.OnComplete();
Stop();
}
}
public void Stop()
{
Task = null;
}
public void Reset()
{
Task = null;
m_RemainingTime = 0f;
}
public void Shutdown() { }
}
个人实践中建议尽量让代理来控制状态和回调时机,任务对象保持被动。这样整个调度流程会非常统一,后续排查问题的时候只需要盯住代理的 Update 逻辑。
4.3 把任务池接入 Unity 主循环
有了任务和代理,接下来要把任务池挂载到 Unity 的 MonoBehaviour 生命周期里。最简单的方式是写一个 TaskComponent 组件,在 Awake 中初始化代理数组并构造 TaskPool,在 Update 中调用任务池的 Update。
csharp复制public class TaskComponent : MonoBehaviour
{
private TaskPool<DelayTask> m_TaskPool;
private void Awake()
{
DelayTaskAgent[] agents = new DelayTaskAgent[4];
for (int i = 0; i < agents.Length; i++)
{
agents[i] = new DelayTaskAgent();
}
m_TaskPool = new TaskPool<DelayTask>(agents);
}
private void Update()
{
m_TaskPool.Update(Time.deltaTime, Time.unscaledDeltaTime);
}
}
添加任务时要通过 ReferencePool 获取任务对象:
csharp复制DelayTask task = ReferencePool.Acquire<DelayTask>();
task.Init(2f, () => Debug.Log("2秒后触发"));
m_TaskPool.AddTask(task);
这个最小示例跑通后,任务池的基础流程就掌握了。之后无论是下载任务、加载任务还是业务异步任务,套路都一样:定义任务数据、定义代理执行逻辑、把任务交给 TaskPool。
4.4 从演练到实战:扩展一个 WebRequest 下载任务
把 DelayTask 换成 WebRequestTask 的思路一模一样。任务对象需要持有 URL、保存路径、优先级等数据;代理内部持有 UnityWebRequest,在 Start 时创建请求,在 Update 中检查 isDone,完成后保存文件并回调,同时把任务状态置为 Done。
差异主要体现在代理的 Update 不能只靠简单倒计时,而要处理网络请求的中间状态、错误码、超时判断等。任务池负责调度,具体业务逻辑全在代理层,这也是分层设计的好处。
实现下载任务时一个很重要的点是回调时机。UnityWebRequest 在 isDone 为 true 时,可能实际上是联网失败,HTTP 状态码也不是 200。代理需要在这些判断之后才决定是 OnComplete 还是 OnFailure,并且要在调用 Stop 之前确保状态正确。否则任务池会认为任务已完成而回收代理,但业务方可能完全没有收到任何回调。
5. 任务池的常见问题与排查技巧
5.1 回调不触发,先查任务状态而不是查回调
遇到“任务应该完成但回调没执行”的问题,我的排查习惯是先在代理 Update 里加一条日志,打印任务状态和剩余时间或进度。回调不触发往往不是因为回调本身写错,而是任务状态根本没走到 Done。状态没走到 Done,代理就不会调用 Stop,任务池也不会回收代理,后续逻辑自然全部卡住。
比如一个人判断完成条件时用了 < 0 而实际进度是 0,永远触发不了;或者是把状态赋值写成了 Task.Status == ETaskStatus.Done,比较而不是赋值。这种一眼看不出的低级错误,靠状态日志很快就能暴露。
5.2 任务不执行,很可能没有空闲代理
任务添加后既不执行也没有任何报错,最典型的原因是代理全部被占满。TaskPool 的分配逻辑是“有等待任务且有空闲代理才分配”,两者缺一不可。如果所有代理都卡在某个长期任务上,后续任务会一直安静地排在上面。
查这个问题就用 TaskPool 提供的计数器,打印 FreeAgentCount、WorkingAgentCount、WaitingTaskCount 三组数据放到界面上,运行时一眼就能看出是分配不出来还是执行太慢。如果是代理数量太少的问题,增大构造时的数组长度即可;如果某个代理长期霸占不释放,则需要检查代理的 Stop 逻辑是否被正确调用。
5.3 任务对象忘了 Release 会怎样
任务对象从 ReferencePool 获取,但如果不主动 Release,它只会被 GC 回收,不会回到对象池。对于低频任务影响不大,高频场景下会频繁分配内存,GC 压力逐步上升,游戏表现为卡顿增多。
正确释放时机是在任务完成后。最常见的做法是把 Release 写在任务对象的 OnComplete、OnFailure、OnCancel 里,或者在代理完成状态切换后统一调用 ReferencePool.Release。但要注意不能重复释放,同一个对象 Release 两次会破坏对象池内部结构,产生非常难查的异常。个人习惯是只在任务对象的终态回调里做一次 Release。
5.4 优先级不生效的典型误用
把高优先级任务添加到一个已经在低优先级任务执行中的池子里,发现低优先级任务没有被插队,然后就怀疑优先级功能是不是有 bug。其实这属于预期错误,前面已经分析过,TaskPool 的优先级不是抢占式的。
如果业务确实需要抢占式调度,我的做法是在代理层做协作式取消。比如给任务加一个 Cancelled 标记,高优先级任务入池时,通过 TaskPool 的遍历接口找到正在执行的同类型低优先级任务,把它的取消标记置为 true,代理在 Update 里检测到标记后主动结束当前任务,让代理回到空闲池,这样高优先级任务才能被尽快分配。
5.5 代理卡死与缺少超时机制
TaskPool 本身不做超时管理。一个代理只要不主动结束任务,它可以一直占着这个代理不释放。这既是灵活性也是风险。比如下载任务在网络异常时一直不完成,代理就永远卡在那里,后面排队的任务全部停滞。
解决方案是在代理层增加超时字段。比如下载代理的 Start 里记录开始时间,Update 里检查当前时间是否超过超时阈值,超时就按失败处理。这个超时字段可以从任务对象传入,不同任务可以配置不同超时时间,保持灵活性。
5.6 问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 回调不触发 | 任务状态未走到终态 | 怎么看代理里的状态日志 |
| 任务排队不动 | 空闲代理数为 0 | 检查长期占用的代理,确认 Stop 时机 |
| 任务完成但 GC 升高 | 任务对象未 Release | 检查终态回调是否调了 Release |
| 高优先级任务没有插队 | 优先级只影响等待队列 | 确认当前执行任务是否已经启动 |
| 任务卡住不再推进 | 缺超时机制 | 代理层加超时判断并走失败流程 |
| 状态错乱执行到错误回调 | Done 判定包含三种终态 | 确认 Canceled 与 Failure 分支是否正确 |
6. 把任务池用好的几个心得
6.1 任务池不是万能队列
任务池擅长管理有明确终态的异步任务,但不适合做无休止的服务常驻循环。如果有一个任务需要持续运行直到游戏结束,比如每帧扫描怪物并更新路径,强行塞进任务池会让一个代理被永久占用,白白减少可用工人数量。这种情况下应该使用 GameFramework 的 System 或 Component 机制,让长驻逻辑独立于任务池存在。
6.2 扩展任务代理时,注意 Reset 的彻底性
这是对象池复用里最容易被忽视的坑。代理从工作链表回收到空闲栈之前,会调用 Reset 清理状态。如果 Reset 漏掉某些引用型字段,下一个任务复用它时就会残留上次任务的执行数据。轻则多跑一段旧逻辑,重则状态错乱导致对象池对象交叉引用,排查起来非常崩溃。
我在每次写完一个新的 ITaskAgent 后,都会重新审视一遍 Reset 方法,逐个字段确认是否清空。尤其是网络请求、协程、回调引用这些重量级字段,绝对不能留。
6.3 和框架其他模块的组合用法
任务池的价值在组合使用时才真正放大。比如资源加载模块可以把每个加载请求封装成一个任务对象,用优先级控制 UI 加载先于场景资源加载;网络模块可以把请求协议按优先级排队,把断线重试的偏移延迟也放进任务逻辑里。任务池此时提供的不只是调度能力,还可以和白名单、队列统计、调试面板整合,让每个异步请求都有迹可循。
从实际项目来看,任务池适合做所有“调度 + 复用”场景的底座。无论是下载、寻路、AI 行为、技能演出,只要是想让多个同类异步请求被统一管理,都可以在这个基础上扩展。它本身不提供业务逻辑,但让业务逻辑变得更容易被管理。
从我个人的实践来看,任务池最值得花时间的地方不是源码逐行读多少遍,而是亲手写一个自定义任务和代理,把“任务被分配、执行、完成、回收”的全过程在日志里完整看到一遍。这个过程做完,对 GameFramework 整个异步调度体系的理解会上一个台阶,再去看 WebRequest、ObjectPool 等模块,会发现它们的设计语言是高度一致的。
