异步开发:协程、线程、Unitask
UniTask:https://github.com/Cysharp/UniTask
Lua源码协程的实现:https://blog.csdn.net/initphp/article/details/104296906
一文让你明白CPU上下文切换:https://zhuanlan.zhihu.com/p/52845869
协程
不同语言框架的协程
Unity 迭代器协程
- 源码:
- 创建、清理协程:
MonoBehaviour.cpp
:TryCreateAndRunCoroutine
- 运行协程逻辑:
Coroutine.cpp
:Run->CallDelayed->ContinueCorutine
- 驱动协程事件:
Player.cpp
、CallDelayed.cpp
:CallDelayed
- 创建、清理协程:
- 大概逻辑,状态机,如下图:
-
-
编译时:为每个协程创建一个类,包含需要用到的变量,每个
yield
记录为一个步骤(或状态),每次执行MoveNext
就切换一次步骤。 -
运行时:
- 创建协程对象并执行
TryCreateAndRunCoroutine
,Coroutine::Run
执行。- 会记录到MonoBehaviour自己的协程列表
m_ActiveCoroutines
中,用于Stop时候清理。
- 会记录到MonoBehaviour自己的协程列表
- 执行
MoveNext
,如果返回false就结束清理协程,为true就继续。 - 判断
__current
执行的函数类型,注册到DelayCallManager
,等待下次时机再次触发。- 比如
yield return new WaitForSeconds(0.5f);
创建了一个回调,注册kRunDynamicFrameRate``|kWaitForNextFrame
类型事件,回调包含callbackCoroutine::ContinueCoroutine
(就是执行第一步的Coroutine::Run
)。 -
// 不同类型触发事件时机不一样 enum DelayedCallMode {kRunFixedFrameRate = 1 << 0,kRunDynamicFrameRate = 1 << 1,kRunStartupFrame = 1 << 2,kWaitForNextFrame = 1 << 3,kAfterLoadingCompleted = 1 << 4,kEndOfFrame = 1 << 5,kRunOnClearAll = 1 << 6, }
- 比如
- 在
Player.cpp
中,每帧update时触发GetDelayedCallmanger().Update(DelayedCallManager::``kRunDynamicFrameRate``)
,判断时间过了0.5秒后,触发回调。
- 创建协程对象并执行
-
C# Task async/await
- 源码:
- 创建Task,保存结果,处理异常:https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncTaskMethodBuilder.cs
- 处理线程调度,返回结果: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/TaskAwaiter.cs
- Task实现:https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs
- 大概逻辑也是状态机驱动
-
-
比如:Task.Delay(1000),就是注册了计时器,1000毫秒后触发回调,重新执行
MoveNext
。- 时间控制不受unity的时间影响,即使unity暂停了(
Time.timeScale = 0
),也会正常执行。
- 时间控制不受unity的时间影响,即使unity暂停了(
-
每次await也是通过注册回调,等待触发时再执行
MoveNext
推进下一步。
-
Unity UniTask
- 贴合 Unity 的
async/await
实现库。 - 相比C#原生Task,性能更佳,并扩展常见unity异步操作(
UnityWebRequest
、AssetBundleRequest
、IEnumerator
)等。 - 原理和
async/await
类似,UniTask为struct类型,Task为class类型,能做到0GC。 - UniTask.Delay 在 PlayerLoopHelper驱动,类似Unity原生协程。
Lua 协程
- 参考:https://blog.csdn.net/initphp/article/details/104296906
- 和前面编译记录变量,走迭代器不同,lua是有栈协程:记录调用栈的信息,在恢复时,指针直接指回原来的调用函数和调用栈。
- 语义像线程。
- 要保存/恢复寄存器和栈,调度比无栈慢一些。
有栈协程 VS 无栈协程
比较项 | 有栈协程 | 无栈协程 |
---|---|---|
语言 | lua | unity/c# |
可跨函数 yield | ✅ 支持 | ❌ 不支持 |
实现复杂度 | ❌ 高(需管理栈) | ✅ 简单(状态机) |
性能 | ⬆️ 稍慢 | ⬆️ 极快(切换仅跳转) |
表达能力 | ✅ 高 | ❌ 受限 |
调试体验 | ✅ 好 | ❌ 较差 |
内存占用 | ⬆️ 每个协程一个栈 | ⬇️ 极小 |
协程对比
特性/对比项 | Unity 协程(IEnumerator) | C# async/await | UniTask(第三方) | Lua 协程 |
---|---|---|---|---|
使用语言 | C# | C# | C#(需引用 UniTask) | Lua |
返回值支持 | 基本不支持返回值(靠回调) | ✅ 完整支持(Task) | ✅(支持 UniTask) | ✅(用 coroutine.yield()) |
多线程支持 | ❌ 仅限主线程 | ✅ 可用 Task.Run | ✅ UniTask.RunOnThreadPool | ❌ Lua VM 单线程 |
中断/恢复能力 | ✅ 有 yield 控制权 | ✅ 有 await 控制权 | ✅ 类似 await | ✅ 用 yield/resume 控制 |
性能开销 | 中等:GC 有开销 | 高:Task 分配多 | 低:几乎无 GC | 低:原生支持有栈协程 |
使用复杂度 | 简单 | 简单 | 简单(需额外包) | 中等(需要状态管理) |
错误处理 | 不方便,需手动处理 | ✅ 可用 try/catch | ✅ 同 async | ❌ 错误需 resume 判断 |
与 Unity 生命周期集成 | ✅(StopCoroutine, yield WaitForSeconds) | ❌ 需要手动对接 | ✅(支持 CancellationToken) | ❌ 不集成 Unity 生命周期 |
跨语言支持 | ❌ | ❌ | ❌ | ✅(可嵌入 Lua VM) |
Unity协程开发建议
- 优先考虑使用UniTask。
- 如果有需要做同步等待操作,用Task,因为Unitask不直接支持。
- 比如做Unity命令行工具,避免await直接跳出函数结束Unity进程。
线程
线程切换的完整流程
- https://zhuanlan.zhihu.com/p/52845869
- 下文大部分AI生成,谨慎观看
1. 当前线程被抢占或主动让出
- 触发时机:
- 时间片耗尽(抢占式调度)
- 主动调用
yield
/sleep
- 阻塞等待(IO、锁、事件)
- 线程优先级变化
- 手动调用
Suspend
(很少用)
2. 进入内核态,调度器开始工作
- 系统中断(如时钟中断)或系统调用触发切换逻辑
- 操作系统陷入 内核模式(Ring 0)
- CPU 暂停当前线程,进入调度逻辑
3. 保存当前线程上下文
- CPU 寄存器(如 EAX、EBX、ESP、EBP、RIP、RSP 等)
- 程序计数器(PC/RIP)
- 栈指针(ESP/RSP)
- 条件码寄存器(FLAGS)
- 协处理器状态(如浮点状态)
- SIMD 寄存器(XMM、YMM) 保存位置:线程控制块(TCB)或内核栈
4. OS 选择新的线程
操作系统根据调度算法选择下一个可运行线程:
- 时间片轮转(Round Robin)
- 多级反馈队列
- 优先级调度
- CFS(Linux 完全公平调度器)
5. 加载新线程的上下文
- 从新线程的 TCB 恢复:
- 栈指针
- 程序计数器
- 寄存器组
- SIMD 等扩展寄存器(如果用到)
- 更新页表、内存映射(如果线程属于不同进程)
6. CPU 跳转到新线程继续执行
- CPU 直接跳转到新线程的下一条指令(RIP)
- 用户感知不到切换过程(除非主动测量)
- 如果调度到的是新线程,则从入口开始执行
锁
锁机制 | 原理简述 | 应用场景 | 是否阻塞 | 是否适用于主线程 |
---|---|---|---|---|
lock(应用级) | 线程互斥执行,可能阻塞 | 多线程资源共享,如下载、日志写入等 | ✅ 是 | ❌ 主线程慎用 |
Mutex(系统级) | 跨线程/进程锁,使用操作系统内核对象 | 多进程资源访问(很少见) | ✅ 是 | ❌ |
SpinLock | 自旋锁,忙等直到获得锁 | 小粒度锁、短时间同步场景(如计数器) | ❌ 否 | ❌ |
Interlocked | 原子操作,使用 CAS 实现 | 原子加减、标志位更新 | ❌ 否 | ✅ 可用于主线程 |
ReaderWriterLockSlim | 支持读多写一的锁模型 | 缓存共享读多的结构 | ✅ 是 | ❌ |
Semaphore(Slim) | 控制并发访问数量,Slim 为用户态轻量实现 | 控制线程池并发数,加载队列等 | ✅ 是 | ❌ |
CAS(Compare And Swap)
- 原子操作,对比旧值并原子替换
bool CAS(int* addr, int expected, int newVal) {if (*addr == expected) {*addr = newVal;return true;}return false;
}
- 比如
a=1
,多线程执行a=a+1
int a = 1;
do {int oldValue = a; // 读取当前值(例如为 1)int newValue = oldValue + 1;
} while (CAS(ref a, newValue, oldValue) != oldValue);
步骤 | 线程 A | 线程 B | a 值 | 说明 |
---|---|---|---|---|
1 | 读 a = 1 | 1 | A 准备执行 | |
2 | 读 a = 1 | 1 | B 也准备执行 | |
3 | CAS(a, 2, 1) → 成功 | 2 | A 把 a 改成 2 | |
4 | CAS(a, 2, 1) → 失败 | 2 | B 发现 a ≠ 1,CAS 失败 → 重试 | |
5 | 读 a = 2 | 2 | B 再次读取 | |
6 | CAS(a, 3, 2) → 成功 | 3 | B 把 a 改成 3 |
lock
- C#最常见的锁
lock(obj) {}
,会等obj
这个锁对象用完才能继续执行。
Mutex
- 跨进程锁,理解为不同应用之间的锁,比如wps在改表加锁,游戏也要改,通过这个锁来控制。
SpinLock
- 自旋锁,通过CAS来拿到锁,再操作自己要操作的内容,用完释放锁。
Interlocked
- 无锁,直接用CAS来操作自己要操作的内容,避免了加锁导致上下文切换。
ReaderWriterLock
- 读写锁,常用于要读的频率比写的频率高很多时用
- 读的时候计数+1,大于0时,不给写,但还可以继续读,计数一直+1,读完时计数-1。
- 计数为0时,可以写。写的时候只能一个线程来写,不给别的线程读/写。
Semaphore
- 比普通互斥锁多个计数,比如最多同时3个线程下载资源。
Unity中的多线程
- Unity默认单线程开发。常见异步线程等待有请求URL、等待AB加载(解压序列化等)、IO读写等。
对比项\方式 | Thread | async/await | UniTask | Unity Job System |
---|---|---|---|---|
使用原理 | 操作系统级线程 + 栈/寄存器上下文切换,频繁切换有系统开销 | 编译器将 async 方法编译成状态机类,await 注册回调恢复状态 | 使用结构体状态机 + PlayerLoop 插入,避免GC,支持线程切换 | Job 为结构体任务描述,调度器统一派发,使用 Burst 编译器进行 SIMD 优化 |
线程模型 | ✅ 原生系统线程(Win32/pthread) | ⛔ 默认主线程,基于 SynchronizationContext 决定 | ✅ 可切换线程(主线程、线程池、任意自定义) | ✅ 多线程:由 Unity 的 Job 调度器在线程池中调度 |
是否多线程 | ✅ 是 | ⛔ 默认不是,除非配合 Task.Run 或 ThreadPool 使用 | ⛔ 默认不是,支持 RunOnThreadPool 实现并发 | ✅ 是:完全由 Unity 控制并行度 |
GC 生成情况 | 高:每个线程分配堆栈,频繁创建回收会造成 GC 和系统开销 | 中:状态机会产生堆对象,闭包捕获等也会增加 GC | 低:结构体实现状态机,无装箱;配合 Source Generator 基本为零 GC | 零 GC:Job 是结构体,使用 NativeArray 等 Native 数据结构 |
创建成本 | 高:每个线程都要向 OS 申请资源,线程数过多影响性能 | 低:状态机对象 + 回调注册 | 极低:结构体、可复用对象池 | 极低:结构体任务描述,调度器批量处理 |
线程控制粒度 | ✅ 完全控制线程生命周期、调度逻辑 | ⛔ 无法指定线程;调度逻辑受 SynchronizationContext 控制 | ✅ 通过 SwitchToXxx() 指定在哪个上下文继续执行 | ⛔ Job 完全由 Unity 控制,不可指定具体线程 |
是否能访问 Unity API | ❌ 否,Unity 只能在主线程访问其对象 | ✅ 默认支持(await 后回到主线程) | ✅ 可自由切换主线程(支持 SwitchToMainThread()) | ❌ 否:Job 不能访问任何托管对象、GameObject 等 |
依赖支持 | 标准 .NET API | 标准 C# 语言特性 | UniTask(Cysharp) | Unity DOTS 模块 |
调试难度 | 中高:需要管理线程间同步与共享数据 | 低:断点调试良好 | 低:协程式语法调试友好 | 中等:Job 排队、Burst 编译需特殊调试工具 |
适用场景 | 需要自定义线程逻辑、长时间运行任务、第三方库依赖线程 | 网络请求、IO异步、资源加载等 | Unity 环境中一切异步处理场景(替代 IEnumerator 协程) | DOTS 数据密集型处理、大量 Entity 的计算逻辑 |
- 接口区别:
功能/类别 | Thread | async/await (Task) | UniTask | Job (IJob, IJobParallelFor) |
---|---|---|---|---|
创建方式 | new Thread(Action) | async Task Func() | async UniTask Func() | MyJob : IJob { Execute() } |
启动执行 | thread.Start() | 自动执行 | 自动执行 | job.Schedule() 或 job.ScheduleParallel() |
同步等待 | thread.Join() | task.Wait() / GetResult() | ToTask().Wait() 自身不支持,要转成Task | jobHandle.Complete() |
返回值 | 无 | Task | UniTask | 通过结构体传值回主线程 |
取消支持 | 手动控制变量 | CancellationToken | CancellationToken | 不支持取消(需要用户手动控制) |
异常捕获 | 需要手动 try-catch | 支持 try-catch(自动转异常) | 同 async/await | 不支持异常传播(需在 Job 内 catch) |
调度在哪运行 | 操作系统线程池(新线程) | .NET 线程池/主线程(取决于上下文) | Unity PlayerLoop、线程池、主线程 | Unity Job System,工作线程池 |
多线程并发 | 支持 | 支持(通过 Task.Run) | 支持(通过 RunOnThreadPool) | 高效并发(推荐处理大量数据) |
回到主线程方式 | 手动调用 MainThreadDispatcher | 使用 SynchronizationContext | UniTask.SwitchToMainThread() | Complete() 后手动执行主线程逻辑 |
Unity多线程开发建议
- 用法区分:
- 如果是要长期自己维护一个线程用Thread。比如网络消息处理。
- 只是临时个别异步任务用
UniTask
,再考虑Task
,最后再考虑IEnumerator
。 - 大量计算用Job。
- 异步线程开发不要忘了
try-catch
,有遇到过子线程抛异常没日志输出的情况,需要手动try-catch
抛出。 - UnityEngine.Debug.Log 打日志不是线程安全的,遇到过编辑器子线程执行卡死,PC端执行卡死情况,安卓/iOS暂不清楚是否会有问题。
线程安全打印log
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Experimental.PlayerLoop;// 简易的线程安全打日志,通过主线程手动DoUpdate驱动刷新日志
[ExecuteAlways]
public class DebugLoggerEx : MonoBehaviour
{private struct LogEntry{public string msg;public string stack;public LogType type;}private static readonly ConcurrentQueue<LogEntry> _logQueue = new ConcurrentQueue<LogEntry>();private static bool _isInit;#if UNITY_EDITOR[UnityEditor.InitializeOnLoadMethod]private static void EditorInit(){UnityEditor.EditorApplication.update -= ProcessLogs;if (Application.isPlaying)return;UnityEditor.EditorApplication.update += ProcessLogs;_isInit = true;}
#endifprivate static void Init(){if (!_isInit){var go = new GameObject("[DebugLoggerEx]");{DontDestroyOnLoad(go);}go.AddComponent<DebugLoggerEx>();_isInit = true;}}private static void LogInternal(string str, LogType type, bool stackTrace = true){Init();var entry = new LogEntry { msg = str, type = type };if (stackTrace)entry.stack = new System.Diagnostics.StackTrace(2, true).ToString();_logQueue.Enqueue(entry);}public static void LogSimple(string str){LogInternal(str, LogType.Log, false);}public static void Log(string str){LogInternal(str, LogType.Log);}public static void LogWarning(string str){LogInternal(str, LogType.Warning);}public static void LogError(string str){LogInternal(str, LogType.Error);}public static void ProcessLogs(){while (_logQueue.TryDequeue(out var log)){string fullMsg = $"{log.msg}\n{log.stack}";switch (log.type){case LogType.Warning: Debug.LogWarning(fullMsg); break;case LogType.Error: Debug.LogError(fullMsg); break;default: Debug.Log(fullMsg); break;}}}public void Update(){ProcessLogs();}
}