十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

ET 框架单线程异步原理:从回调式计时器到 await 与 ETTask 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系统讲解异步并不等于多线程这一核心概念通过一个完全运行在主线程上的计时器实现展示单线程如何完成异步调度并进一步演示如何用awaitTaskCompletionSource将回调式代码改写成同步风格。读者学完后将理解单线程异步的底层运作机制、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是否为 00 表示当前没有待处理的计时器直接返回再取当前毫秒时间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用awaitTaskCompletionSourcebool把回调串改成同步风格的顺序代码。// example2_3_2 class Program { private static int loopCount 0; private static long time; private static TaskCompletionSourcebool 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) { TaskCompletionSourcebool t new TaskCompletionSourcebool(); time DateTime.Now.Ticks / 10000 waitTime; tcs t; return t.Task; } }3.1 两种写法的对应关系回调版example2_3await 版example2_3_2说明WaitTimeAsync(int waitTime, Action a)WaitTimeAsync(int waitTime)返回Task回调参数换成TaskCompletionSourcebool记录time与action记录time与tcs返回t.Task职责完全对应CheckTimerOut到期后action.Invoke()到期后tcs.SetResult(true)触发点完全对应回调链Callback1 → Callback2 → Callback3await依次续写的三行代码逻辑顺序完全一致关键点在于TaskCompletionSourcebool它像一根遥控器.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 MultiMaplong, long timeId new(1000); public readonly Queuelong timeOutTime new(); public readonly Queuelong timeOutTimerIds new(); // 记录最小时间不用每次都去MultiMap取第一个值 public long minTime long.MaxValue; }timeIdMultiMap以到期时刻为 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回调对象、StartTime与Time持续时长注册时计算tillTime StartTime Time插入timeId见 TimerComponentSystem.cs 的AddTimer。4.2 驱动方式与示例完全同构的 Update 轮询TimerComponent 的UpdateTimerComponentSystem.cs与示例中主循环里的CheckTimerOut()结构一一对应timeId.Count 0直接返回对应time 0短路取当前时间GetNow()与minTime比较未到期返回对应if (time nowTicks) return扫描所有k timeNow的时间桶收集到timeOutTime并从timeId移除对应到期后time 0清空依次取出 timer id调用Run(timerId)分发执行对应action.Invoke()。Run内部根据TimerClass分派OnceTimer通过EventSystem.Instance.Invoke触发事件OnceWaitTimer则调用tcs.SetResult()唤醒等待者TimerComponentSystem.csRepeatedTimer会重新AddTimer自己实现周期循环。所有回调都在驱动 TimerComponent 的线程通常即逻辑主线程上完成与教程整个逻辑都在主线程中完成的设计哲学一脉相承。4.3 面向使用者的 APIWaitAsync / WaitTillAsync / 定时器基于这套机制TimerComponent 对外暴露了与教程 example2_3_2 同构但更完整的 APIWaitAsync(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.TaskTaskCompletionSource完成示例。但如 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用于传递取消令牌等上下文信息。框架层TimerComponent的WaitAsync/WaitTillAsync底层正是以ETTask tcs ETTask.Create(true)作为唤醒信号TimerComponentSystem.cs配合 ETCancellationToken 实现超时、取消等高级语义。六、总结单线程异步的三个层次回顾全篇可以提炼出理解单线程异步的三个层次概念层异步 不阻塞调用方、结果稍后送达多线程只是实现异步的一种手段而非必要条件。每个计时器一个Thread.Sleep的实现效率低下不应在生产中使用。机制层单线程异步的核心是注册到期任务 主循环轮询检查 到期执行续体。教程中的CheckTimerOut就是最精简的调度循环awaitTaskCompletionSource只是把回调链改写成顺序代码的语法糖不改变线程归属。工程层ET 框架将这一模型落地为 TimerComponentMultiMap 时间桶 minTime 加速 三种 TimerClass 分发与 ETTask无同步上下文、对象池化、支持取消使开发者可以在纯单线程的逻辑主循环上写出高性能、免锁、易读的异步代码。延伸阅读Book/2.1CSharp的协程.md异步与回调链的入门铺垫理解一串串回调就是协程Book/2.2更好的协程.mdawait 语法改写与同步上下文OneThreadSynchronizationContext的深入讨论TimerComponent.cs单线程计时器的数据结构与实体定义TimerComponentSystem.csUpdate 轮询、到期分发与 WaitAsync/WaitTillAsync 的实现ETTask.cs无同步上下文异步类型与对象池实现【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表