UniTask:https://github.com/Cysharp/UniTask

Lua源码协程的实现:https://blog.csdn.net/initphp/article/details/104296906

一文让你明白CPU上下文切换:https://zhuanlan.zhihu.com/p/52845869

协程

不同语言框架的协程

Unity 迭代器协程

  • 源码:
    • 创建、清理协程:MonoBehaviour.cppTryCreateAndRunCoroutine
    • 运行协程逻辑:Coroutine.cppRun->CallDelayed->ContinueCorutine
    • 驱动协程事件:Player.cppCallDelayed.cppCallDelayed
  • 大概逻辑,状态机,如下图:
    • 在这里插入图片描述
      在这里插入图片描述

    • 编译时:为每个协程创建一个类,包含需要用到的变量,每个yield记录为一个步骤(或状态),每次执行MoveNext就切换一次步骤。

    • 运行时:

      • 创建协程对象并执行TryCreateAndRunCoroutineCoroutine::Run执行。
        1. 会记录到MonoBehaviour自己的协程列表m_ActiveCoroutines中,用于Stop时候清理。
      • 执行MoveNext,如果返回false就结束清理协程,为true就继续。
      • 判断__current执行的函数类型,注册到DelayCallManager,等待下次时机再次触发。
        1. 比如yield return new WaitForSeconds(0.5f); 创建了一个回调,注册 kRunDynamicFrameRate``|kWaitForNextFrame类型事件,回调包含callbackCoroutine::ContinueCoroutine(就是执行第一步的Coroutine::Run)。
        2. // 不同类型触发事件时机不一样
          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),也会正常执行。
    • 每次await也是通过注册回调,等待触发时再执行MoveNext推进下一步。

Unity UniTask

  • 贴合 Unity 的 async/await 实现库。
  • 相比C#原生Task,性能更佳,并扩展常见unity异步操作(UnityWebRequestAssetBundleRequestIEnumerator )等。
  • 原理和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多线程开发建议

  1. 用法区分:
    1. 如果是要长期自己维护一个线程用Thread。比如网络消息处理。
    2. 只是临时个别异步任务用UniTask,再考虑Task,最后再考虑IEnumerator
    3. 大量计算用Job。
  2. 异步线程开发不要忘了try-catch,有遇到过子线程抛异常没日志输出的情况,需要手动try-catch抛出。
  3. 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;
    }
#endif

    private 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();
    }
}
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐