ET 框架单线程异步原理:从回调式计时器到 await 与 ETTask
2026/9/16 18:59:20 网站建设 项目流程

ET 框架单线程异步原理:从回调式计时器到 await 与 ETTask

【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET

导读

本文基于 ET 框架官方教程文档 Book/2.3Single-threaded asynchronous.md 与其中文版 Book/2.3单线程异步.md,系统讲解"异步并不等于多线程"这一核心概念:通过一个完全运行在主线程上的计时器实现,展示单线程如何完成异步调度;并进一步演示如何用await+TaskCompletionSource将回调式代码改写成同步风格。读者学完后将理解单线程异步的底层运作机制、await是否开启线程的真实语义,以及 ET 框架中 TimerComponent 与 ETTask 如何把这一思想工程化落地到游戏服务器中。

一、为什么异步不只是多线程:多线程计时器的代价

在理解单线程异步之前,先回顾此前章节(Book/2.1CSharp的协程.md、Book/2.2更好的协程.md)的做法:为了实现"5 秒后打印 loopCount",我们会为每次等待都开启一个线程,线程内部Thread.Sleep(waitTime)等待后,再通过同步上下文把回调扔回主线程执行。

// example2_1 多线程计时器 private static void WaitTimeAsync(int waitTime, Action action) { Thread thread = new Thread(()=>WaitTime(waitTime, action)); thread.Start(); } private static void WaitTime(int waitTime, Action action) { Thread.Sleep(waitTime); // 将action扔回主线程执行 OneThreadSynchronizationContext.Instance.Post((o)=>action(), null); }

这种实现的效率问题非常明显:

  • 每一个计时器都独占一个线程。游戏逻辑中往往同时存在成百上千个等待(技能冷却、Buff 过期、寻路等待……),每个等待开一个线程意味着同数量级的线程同时存在。
  • 线程切换频繁。操作系统在大量可运行线程之间切换本身就有开销,线程越多,切换越频繁,CPU 时间大量浪费在上下文切换上。
  • 资源浪费严重。大部分线程只是单纯Sleep挂起等待,什么也没干。

正因如此,"每个计时器一个线程"在生产代码中几乎不会出现。一般游戏逻辑会设计一个单线程的计时器:所有计时任务注册到一个统一的调度器里,由主线程(游戏主循环)每帧检查哪些任务到期,到期则执行。这就是本篇文章要讲解的单线程异步。

二、最小单线程异步实现:主线程轮询计时器

下面这段代码来自原文档(example2_3),它完整实现了一个运行在主线程上的异步计时器,我们逐段剖析。

// example2_3 class Program { private static int loopCount = 0; private static long time; private static Action action; static void Main(string[] args) { Console.WriteLine($"主线程: {Thread.CurrentThread.ManagedThreadId}"); Crontine(); while (true) { Thread.Sleep(1); CheckTimerOut(); ++loopCount; if (loopCount % 10000 == 0) { Console.WriteLine($"loop count: {loopCount}"); } } } private static void Crontine() { WaitTimeAsync(5000, WaitTimeAsyncCallback1); } private static void WaitTimeAsyncCallback1() { Console.WriteLine($"当前线程: {Thread.CurrentThread.ManagedThreadId}, WaitTimeAsync finsih loopCount的值是: {loopCount}"); WaitTimeAsync(4000, WaitTimeAsyncCallback2); } private static void WaitTimeAsyncCallback2() { Console.WriteLine($"当前线程: {Thread.CurrentThread.ManagedThreadId}, WaitTimeAsync finsih loopCount的值是: {loopCount}"); WaitTimeAsync(3000, WaitTimeAsyncCallback3); } private static void WaitTimeAsyncCallback3() { Console.WriteLine($"当前线程: {Thread.CurrentThread.ManagedThreadId}, WaitTimeAsync finsih loopCount的值是: {loopCount}"); } private static void CheckTimerOut() { if (time == 0) { return; } long nowTicks = DateTime.Now.Ticks / 10000; if (time > nowTicks) { return; } time = 0; action.Invoke(); } private static void WaitTimeAsync(int waitTime, Action a) { time = DateTime.Now.Ticks / 10000 + waitTime; action = a; } }

2.1 工作原理逐步拆解

  • 注册等待(WaitTimeAsync:调用时记录两样东西——目标唤醒时刻(DateTime.Now.Ticks / 10000 + waitTime,把 Ticks 换算成毫秒)和回调委托action。注意这里没有创建任何线程,只是两个字段赋值。
  • 主循环驱动(Main:主线程死循环,每轮Thread.Sleep(1)让出 1 毫秒,随后调用CheckTimerOut()检查计时器,再对loopCount计数并每 10000 次打印一次。
  • 到期检测(CheckTimerOut:先看time是否为 0(0 表示当前没有待处理的计时器,直接返回);再取当前毫秒时间nowTicks与目标时刻比较,未到则返回;一旦time <= nowTicks说明到期,将time清零并调用action.Invoke()执行回调。
  • 回调串联(WaitTimeAsyncCallback1/2/3:每个回调打印"当前线程 ID 与 loopCount 的值"后,继续注册下一个等待,形成 5 秒 → 4 秒 → 3 秒的链式时序。

2.2 这段代码证明了什么

  • 整个逻辑全部在主线程中完成:注册、轮询、到期判断、回调执行,没有一行代码切换到其他线程。
  • 它依然是异步的:调用WaitTimeAsync(5000, callback)后,主线程没有被阻塞,继续执行计数打印逻辑;5 秒后回调被"自动"触发。
  • 异步的本质是"不阻塞调用方、结果稍后到达",与是否使用多线程没有必然关系。这就是文档结论"异步并非多线程,单线程同样可以异步"的最直观证明。

三、用 await 改写:TaskCompletionSource 驱动的单线程异步

回调嵌套的问题是显而易见的:每插入一段逻辑就要拆开回调链重写(这正是 Book/2.1CSharp的协程.md 中演示过的痛点)。原文档给出第二种写法(example2_3_2),用await+TaskCompletionSource<bool>把回调串改成同步风格的顺序代码。

// example2_3_2 class Program { private static int loopCount = 0; private static long time; private static TaskCompletionSource<bool> tcs; static void Main(string[] args) { Console.WriteLine($"主线程: {Thread.CurrentThread.ManagedThreadId}"); Crontine(); while (true) { Thread.Sleep(1); CheckTimerOut(); ++loopCount; if (loopCount % 10000 == 0) { Console.WriteLine($"loop count: {loopCount}"); } } } private static async void Crontine() { await WaitTimeAsync(5000); Console.WriteLine($"当前线程: {Thread.CurrentThread.ManagedThreadId}, WaitTimeAsync finsih loopCount的值是: {loopCount}"); await WaitTimeAsync(4000); Console.WriteLine($"当前线程: {Thread.CurrentThread.ManagedThreadId}, WaitTimeAsync finsih loopCount的值是: {loopCount}"); await WaitTimeAsync(3000); Console.WriteLine($"当前线程: {Thread.CurrentThread.ManagedThreadId}, WaitTimeAsync finsih loopCount的值是: {loopCount}"); } private static void CheckTimerOut() { if (time == 0) { return; } long nowTicks = DateTime.Now.Ticks / 10000; if (time > nowTicks) { return; } time = 0; tcs.SetResult(true); } private static Task WaitTimeAsync(int waitTime) { TaskCompletionSource<bool> t = new TaskCompletionSource<bool>(); time = DateTime.Now.Ticks / 10000 + waitTime; tcs = t; return t.Task; } }

3.1 两种写法的对应关系

回调版(example2_3)await 版(example2_3_2)说明
WaitTimeAsync(int waitTime, Action a)WaitTimeAsync(int waitTime)返回Task回调参数换成TaskCompletionSource<bool>
记录timeaction记录timetcs,返回t.Task职责完全对应
CheckTimerOut到期后action.Invoke()到期后tcs.SetResult(true)触发点完全对应
回调链Callback1 → Callback2 → Callback3await依次续写的三行代码逻辑顺序完全一致

关键点在于TaskCompletionSource<bool>:它像一根"遥控器",.Task是被等待的一端(await 的对象),SetResult(true)是完成信号的一端。计时器到期时在主线程调用SetResult(true),await 之后的代码就会在同一个线程上继续执行——整个流程依然没有离开主线程。

3.2 为什么 await 版能保持单线程

编译器会把async方法改写成状态机:遇到await WaitTimeAsync(5000)时,如果 Task 未完成,就把"await 之后的代码"保存为续体(continuation),返回控制权给主循环;当tcs.SetResult(true)触发后,续体被调度执行,代码从下一行继续。因为SetResult是在主线程的CheckTimerOut里调用的,续体自然也在主线程执行。

这正是原文档末尾的结论:"上面这个例子所有调用全部在主线程中完成,并且使用了 await,因此 await 并不会开启多线程,await 具体用没用多线程完全取决于具体的实现。"await本身只是一个语法糖与状态机机制,线程归属完全由被 await 的那个实现决定——如果实现内部用Task.Run或新开线程去完成工作,那才会引入多线程。

四、从示例到工程:ET 框架的单线程 TimerComponent

教程中的两个示例虽然完整,但只是教学演示(全局单例计时器字段、一次只能注册一个等待)。在生产级游戏服务器中,ET 框架把同样的思想做成了通用组件:TimerComponent,位于 Packages/cn.etetet.core/Scripts/Core/Share/Timer/TimerComponent.cs。

4.1 数据结构:按到期时间分桶

从源码结构看,TimerComponent 内部的数据结构与示例有着清晰的继承关系:

public class TimerComponent: Entity, IAwake, IUpdate { /// <summary> /// key: time, value: timer id /// </summary> public readonly MultiMap<long, long> timeId = new(1000); public readonly Queue<long> timeOutTime = new(); public readonly Queue<long> timeOutTimerIds = new(); // 记录最小时间,不用每次都去MultiMap取第一个值 public long minTime = long.MaxValue; }
  • timeId(MultiMap):以"到期时刻"为 key、timer id 为 value,把同时到期的计时器聚在一起。MultiMap 有序,天然支持按时间顺序扫描,对应示例中的time字段,但容量从 1 扩展为 N。
  • minTime优化:记录当前最小的到期时刻,Update里先比较timeNow < minTime直接返回,避免每帧遍历整个表,对应示例中if (time > nowTicks) return;的提前退出逻辑。
  • timeOutTime/timeOutTimerIds队列:收集本次到期的时间桶与 timer id,逐批执行,避免在遍历过程中修改容器。

计时任务本身是一个TimerAction实体,携带TimerClass(枚举None / OnceTimer / OnceWaitTimer / RepeatedTimer)、Type(事件类型)、Object(回调对象)、StartTimeTime(持续时长),注册时计算tillTime = StartTime + Time插入timeId,见 TimerComponentSystem.cs 的AddTimer

4.2 驱动方式:与示例完全同构的 Update 轮询

TimerComponent 的Update(TimerComponentSystem.cs)与示例中主循环里的CheckTimerOut()结构一一对应:

  1. timeId.Count == 0直接返回(对应time == 0短路);
  2. 取当前时间GetNow(),与minTime比较,未到期返回(对应if (time > nowTicks) return);
  3. 扫描所有k <= timeNow的时间桶,收集到timeOutTime,并从timeId移除(对应到期后time = 0清空);
  4. 依次取出 timer id,调用Run(timerId)分发执行(对应action.Invoke())。

Run内部根据TimerClass分派:OnceTimer通过EventSystem.Instance.Invoke触发事件;OnceWaitTimer则调用tcs.SetResult()唤醒等待者(TimerComponentSystem.cs);RepeatedTimer会重新AddTimer自己实现周期循环。所有回调都在驱动 TimerComponent 的线程(通常即逻辑主线程)上完成,与教程"整个逻辑都在主线程中完成"的设计哲学一脉相承。

4.3 面向使用者的 API:WaitAsync / WaitTillAsync / 定时器

基于这套机制,TimerComponent 对外暴露了与教程 example2_3_2 同构但更完整的 API:

  • WaitAsync(time):等待若干毫秒后继续,底层创建一个OnceWaitTimer,到期后tcs.SetResult()await后的代码在原线程续跑;
  • WaitTillAsync(tillTime):等待到某个绝对时间点,若已过期立即返回;
  • WaitFrameAsync():等待一帧;
  • NewOnceTimer / NewRepeatedTimer:注册一次性或周期性的定时事件,返回 timer id,可用Remove取消。

用法示例(逻辑均运行在单线程主循环上,无需加锁):

// 5 秒后继续,语义与教程 example2_3_2 完全一致,但可注册任意多个等待 await TimerComponent.Instance.WaitAsync(5000); Log.Info($"5 秒后打印, 当前线程: {Thread.CurrentThread.ManagedThreadId}");

可以看到,教程中的单例字段time/tcs/action在工程化后被 MultiMap、TimerAction 实体、事件系统与可取消 id 取代,但"注册到期时间 → 主线程轮询 → 到期回调/唤醒"的核心模型没有变化。

五、更进一步:ETTask——游戏场景下的极简单线程异步

教程使用System.Threading.Tasks.Task+TaskCompletionSource完成示例。但如 Book/2.2更好的协程.md 所指出的,.NET 的Task默认把续体调度到同步上下文(SynchronizationContext),如果不设置同步上下文,回调可能跑到线程池线程上。在游戏开发中"逻辑全部单线程"的诉求下,每次回调都绕一圈同步上下文显得多余。

ET 框架为此提供了自己的异步类型ETTask,实现在 Packages/cn.etetet.core/Scripts/Core/Share/ETTask/ETTask.cs:

  • 无同步上下文依赖await之后的续体默认就在发起 await 的线程上继续执行,天然满足单线程逻辑的要求,代码更简洁;
  • 对象池复用ETTask.Create(fromPool: true)从池中取实例,完成后的Recycle会清空状态并归还(源码中有queue.Count > 1000的上限保护),避免高频创建异步对象带来的 GC 压力;
  • 支持取消与上下文透传TaskType枚举(Common / WithContext / ContextTask)与ETTaskExtensions中的GetContextAsync用于传递取消令牌等上下文信息。

框架层TimerComponentWaitAsync/WaitTillAsync底层正是以ETTask tcs = ETTask.Create(true)作为唤醒信号(TimerComponentSystem.cs),配合 ETCancellationToken 实现超时、取消等高级语义。

六、总结:单线程异步的三个层次

回顾全篇,可以提炼出理解单线程异步的三个层次:

  1. 概念层:异步 = 不阻塞调用方、结果稍后送达;多线程只是实现异步的一种手段,而非必要条件。每个计时器一个Thread.Sleep的实现效率低下,不应在生产中使用。
  2. 机制层:单线程异步的核心是"注册到期任务 + 主循环轮询检查 + 到期执行续体"。教程中的CheckTimerOut就是最精简的调度循环;await+TaskCompletionSource只是把回调链改写成顺序代码的语法糖,不改变线程归属。
  3. 工程层:ET 框架将这一模型落地为 TimerComponent(MultiMap 时间桶 + minTime 加速 + 三种 TimerClass 分发)与 ETTask(无同步上下文、对象池化、支持取消),使开发者可以在纯单线程的逻辑主循环上写出高性能、免锁、易读的异步代码。

延伸阅读

  • Book/2.1CSharp的协程.md:异步与回调链的入门铺垫,理解"一串串回调就是协程"
  • Book/2.2更好的协程.md:await 语法改写与同步上下文(OneThreadSynchronizationContext)的深入讨论
  • TimerComponent.cs:单线程计时器的数据结构与实体定义
  • TimerComponentSystem.cs:Update 轮询、到期分发与 WaitAsync/WaitTillAsync 的实现
  • ETTask.cs:无同步上下文异步类型与对象池实现

【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询