coroutine协程详解

前两天阿里巴巴开源了coobjc,没几天就已经2千多star了,我也看了看源码,主要关注的是协程的实现,周末折腾了两整天参照Go的前身libtask和风神的coroutine实现了一部分,也看了一些文章,稍微整理一下。

协程

Coroutines are computer-program components that generalize subroutines for non-preemptive multitasking, by allowing multiple entry points for suspending and resuming execution at certain locations. Coroutines are well-suited for implementing familiar program components such as cooperative tasks, exceptions, event loops, iterators, infinite lists and pipes.

Process -> Thread -> Coroutine

协程(Coroutine)编译器级的,进程(Process)和线程(Thread)操作系统级的

进程(Process)和线程(Thread)是os通过调度算法,保存当前的上下文,然后从上次暂停的地方再次开始计算,重新开始的地方不可预期,每次CPU计算的指令数量和代码跑过的CPU时间是相关的,跑到os分配的cpu时间到达后就会被os强制挂起,开发者无法精确的控制它们。

协程(Coroutine)是一种轻量级的用户态线程,实现的是非抢占式的调度,即由当前协程切换到其他协程由当前协程来控制。目前的协程框架一般都是设计成 1:N 模式。所谓 1:N 就是一个线程作为一个容器里面放置多个协程。那么谁来适时的切换这些协程?答案是有协程自己主动让出 CPU,也就是每个协程池里面有一个调度器,这个调度器是被动调度的。意思就是他不会主动调度。而且当一个协程发现自己执行不下去了(比如异步等待网络的数据回来,但是当前还没有数据到),这个时候就可以由这个协程通知调度器,这个时候执行到调度器的代码,调度器根据事先设计好的调度算法找到当前最需要 CPU 的协程。切换这个协程的 CPU 上下文把 CPU 的运行权交个这个协程,直到这个协程出现执行不下去需要等等的情况,或者它调用主动让出 CPU 的 API 之类,触发下一次调度。

优缺点

优点:

  • 协程更加轻量,创建成本更小,降低了内存消耗
    协程本身可以做在用户态,每个协程的体积比线程要小得多,因此一个进程可以容纳数量相当可观的协程
  • 协作式的用户态调度器,减少了 CPU 上下文切换的开销,提高了 CPU 缓存命中率
    协作式调度相比抢占式调度的优势在于上下文切换开销更少、更容易把缓存跑热。和多线程比,线程数量越多,协程的性能优势就越明显。进程 / 线程的切换需要在内核完成,而协程不需要,协程通过用户态栈实现,更加轻量,速度更快。在重 I/O 的程序里有很大的优势。比如爬虫里,开几百个线程会明显拖慢速度,但是开协程不会。

但协程也放弃了原生线程的优先级概念,如果存在一个较长时间的计算任务,由于内核调度器总是优先 IO 任务,使之尽快得到响应,就将影响到 IO 任务的响应延时。假设这个线程中有一个协程是 CPU 密集型的他没有 IO 操作,也就是自己不会主动触发调度器调度的过程,那么就会出现其他协程得不到执行的情况,所以这种情况下需要程序员自己避免。

此外,单线程的协程方案并不能从根本上避免阻塞,比如文件操作、内存缺页,这都属于影响到延时的因素。

  • 减少同步加锁,整体上提高了性能
    协程方案基于事件循环方案,减少了同步加锁的频率。但若存在竞争,并不能保证临界区,因此该上锁的地方仍需要加上协程锁。
  • 可以按照同步思维写异步代码,即用同步的逻辑,写由协程调度的回调
    需要注意的是,协程的确可以减少 callback 的使用但是不能完全替换 callback。基于事件驱动的编程里面反而不能发挥协程的作用而用 callback 更适合。

缺点:

  • 在协程执行中不能有阻塞操作,否则整个线程被阻塞(协程是语言级别的,线程,进程属于操作系统级别)
  • 需要特别关注全局变量、对象引用的使用
  • 协程可以处理 IO 密集型程序的效率问题,但是处理 CPU 密集型不是它的长处。
    假设这个线程中有一个协程是 CPU 密集型的他没有 IO 操作,也就是自己不会主动触发调度器调度的过程,那么就会出现其他协程得不到执行的情况,所以这种情况下需要程序员自己避免。

适用场景

  • 高性能计算,牺牲公平性换取吞吐。协程最早来自高性能计算领域的成功案例,协作式调度相比抢占式调度而言,可以在牺牲公平性时换取吞吐
  • IO Bound 的任务
    在 IO 密集型的程序中由于 IO 操作远远小于 CPU 的操作,所以往往需要 CPU 去等 IO 操作。同步 IO 下系统需要切换线程,让操作系统可以再 IO 过程中执行其他的东西。这样虽然代码是符合人类的思维习惯但是由于大量的线程切换带来了大量的性能的浪费。

所以人们发明了异步 IO。就是当数据到达的时候触发我的回调。来减少线程切换带来性能损失。但是这样的坏处也是很大的,最大的问题就是破坏掉了人类这种线性的思维模式,你必须把一个逻辑上线性的过程切分成若干个片段,每个片段的起点和终点就是异步事件的完成和开始。固然经过一些训练你可以适应这种思维模式,但你还是要付出额外的心智负担。与人类的思维模式相对应,大多数流行的编程语言都是命令式的,程序本身呈现出一个大致的线性结构。异步回调在破坏点思维连贯性的同时也破坏掉了程序的连贯性,让你在阅读程序的时候花费更多的精力。这些因素对于一个软件项目来说都是额外的维护成本,所以大多数公司并不是很青睐 node.js 或者 RxJava 之类的异步回调框架,尽管这些框架能提升程序的并发能力。

但是协程可以很好解决这个问题。比如把一个 IO 操作 写成一个协程。当触发 IO 操作的时候就自动让出 CPU 给其他协程。要知道协程的切换很轻的。协程通过这种对异步 IO 的封装既保留了性能也保证了代码的容易编写和可读性。

  • Generator 式的流式计算
    消除 Callback Hell(回调地狱),使用同步模型降低开发成本的同时保留更灵活控制流的好处,比如同时发三个请求;这时节约地使用栈,可以充分地发挥 "轻量" 的优势。

ucontext

协程一般有两类实现,一种是 stackless,一种是 stackful。

structure

 

struct ucontext {
    /*
     * Keep the order of the first two fields. Also,
     * keep them the first two fields in the structure.
     * This way we can have a union with struct
     * sigcontext and ucontext_t. This allows us to
     * support them both at the same time.
     * note: the union is not defined, though.
     */
    sigset_t    uc_sigmask;  //这个上下文要阻塞的信号
    mcontext_tt uc_mcontextt;  //保存的上下文的特定机器表示,包括调用线程的特定寄存器等

    struct __ucontext *uc_link;  //指向当前的上下文结束时要恢复到的上下文
    stack_t     uc_stack;  //该上下文中使用的栈
    int     __spare__[8];  
};

getcontext

 

int getcontext(ucontext_t *ucp)

该函数初始化ucp所指向的结构体ucontext_t(用来保存前执行状态上下文),填充当前有效的上下文

setcontext

 

int setcontext(const ucontext_t *ucp)

函数恢复用户上下文为ucp所指向的上下文。成功调用不会返回。ucp所指向的上下文应该是getcontext()或者makecontext()产生的。

如果上下文是getcontext()产生的,切换到该上下文,程序的执行在getcontext()后继续执行。
如果上下文被makecontext()产生的,切换到该上下文,程序的执行切换到makecontext()调用所指定的第二个参数的函数上。当该函数返回时,我们继续传入makecontext()中的第一个参数的上下文中uc_link所指向的上下文。如果是NULL,程序结束。
成功时,getcontext()返回0,setcontext()不返回。错误时,都返回-1并且赋值合适的errno。

makecontext

 

void makecontext(ucontext_t *ucp, void (*func)(void), int argc, ...)

函数修改ucp所指向的上下文,ucp是被getcontext()所初始化的上下文。当这个上下文采用swapcontext()或者setcontext()被恢复,程序的执行会切换到func的调用,通过makecontext()调用的argc传递func的参数。
makecontext()产生一个调用前,应用程序必须确保上下文的栈分配已经被修改。应用程序应该确保argc的值跟传入func的一样(参数都是int值4字节);否则会发生未定义行为。
makecontext()修改过的上下文返回时,uc_link用来决定上下文是否要被恢复。应用程序需要在调用makecontext()前初始化uc_link

swapcontext

 

int swapcontext(ucontext_t *restrict oucp, const ucontext_t *restrict ucp)

函数保存当前的上下文到oucp所指向的数据结构,并且设置到ucp所指向的上下文。
成功完成,swapcontext()返回0。否则返回-1,并赋值合适的errno。
swapcontext()函数可能会因为下面的原因失败:
ENOMEM ucp参数没有足够的栈空间去完成操作

ucontext协程的实际使用

将getcontext,makecontext,swapcontext封装成一个类似于lua的协同式协程,需要代码中主动yield释放出CPU。
协程的栈采用malloc进行堆分配,分配后的空间在64位系统中和栈的使用一致,地址递减使用,uc_stack.uc_size设置的大小好像并没有多少实际作用,使用中一旦超过已分配的堆大小,会继续向地址小的方向的堆去使用,这个时候就会造成堆内存的越界使用,更改之前在堆上分配的数据,造成各种不可预测的行为,coredump后也找不到实际原因。
对使用协程函数的栈大小的预估,协程函数中调用其他所有的api的中的局部变量的开销都会分配到申请给协程使用的内存上,会有一些不可预知的变量,比如调用第三方API,第三方API中有非常大的变量,实际使用过程中开始时可以采用mmap分配内存,对分配的内存设置GUARD_PAGE进行mprotect保护,对于内存溢出,准确判断位置,适当调整需要分配的栈大小。

ucontext簇函数学习_实际使用.png

风神的coroutine

风神的coroutine是基于ucontext封装的

schedule 调度器

 

struct schedule {
    char stack[STACK_SIZE]; // 原来schedule里面就已经存有了stack
    ucontext_t main; // ucontext_t你可以看做是记录上下文信息的一个结构
    int nco; // 协程的数目
    int cap; // 容量
    int running; // 正在运行的coroutine的id
    struct coroutine **co; // 这里是一个二维的指针
};

coroutine

 

struct coroutine {
    coroutine_func func; // 运行的函数
    void *ud; // 参数
    ucontext_t ctx; // 用于记录上下文信息的一个结构
    struct schedule * sch; // 指向schedule
    ptrdiff_t cap; // 堆栈的容量
    ptrdiff_t size; // 用于表示堆栈的大小
    int status;
    char *stack; // 指向栈地址么?
};

coroutine_new

 

int coroutine_new(struct schedule *S, coroutine_func func, void *ud)

创建一个协程,该协程的会加入到schedule的协程序列中,func为其执行的函数,ud为func的执行函数。返回创建的线程在schedule中的编号

coroutine_yield

 

void coroutine_yield(struct schedule * S)

挂起调度器schedule中当前正在执行的协程,切换到主函数。

coroutine_resume

 

void coroutine_resume(struct schedule * S, int id) { 

恢复运行调度器schedule中编号为id的协程

coroutine_close

 

void coroutine_close(struct schedule *S)

关闭schedule中所有的协程



作者:小凉介
链接:https://www.jianshu.com/p/2782f8c49b2a
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

<think>嗯,用户之前问过.NETMono的关系,现在又转向Unity中的协程。可能他们正在学习Unity开发,遇到了协程的概念,需要详细解释。之前的问题涉及技术框架的比较,这次则是具体的Unity功能,所以需要深入讲解协程的实现、用法以及常见问题。 首先,用户可能已经了解C#的基础,但协程在Unity中的工作方式与普通多线程不同,需要解释清楚Unity的协程是基于迭代器时间控制的。要强调协程不是多线程,而是在主线程上分时执行,避免误解。 用户可能需要知道协程的适用场景,比如处理异步操作、延时执行、分帧加载等。需要举一些例子,比如等待几秒后执行,或者分步骤处理生成敌人。同时,要提醒注意事项,比如避免长时间阻塞,正确停止协程,以及生命周期管理的问题。 另外,用户可能会混淆协程异步async/await的区别,需要比较两者的使用场景优缺点。比如协程更适合与Unity的Yield指令配合,而async/await在不需要Unity特定等待时可能更高效。 还要考虑用户可能的误区,比如在禁用对象或场景切换时协程停止,以及性能影响,比如过多的协程可能影响帧率。需要给出最佳实践,比如使用对象池管理协程,或者结合UniTask等优化方案。 最后,提供示例代码帮助理解,比如StartCoroutine的用法,yield return不同的指令,以及如何停止协程。确保用户能够实际应用这些知识,解决开发中的问题。</think>好的!以下是关于 **Unity 中协程Coroutine)** 的详细解析,包括原理、使用场景、实现机制注意事项。 --- ### **1. 协程的核心概念** - **定义**:协程是一种**非抢占式的多任务处理机制**,允许在单个线程内通过“分时”执行多个任务,实现**代码的暂停与恢复**。 - **与线程的区别**: - 协程运行在**主线程**上,不涉及多线程的上下文切换或锁机制。 - 协程通过 `yield` 主动让出执行权,由 Unity 引擎调度恢复时机,**不阻塞主线程**。 --- ### **2. 协程的核心用途** - **延迟执行**:等待若干秒后执行代码(如倒计时)。 - **分帧处理**:将耗时操作分散到多帧完成(如分帧加载资源)。 - **异步等待**:等待条件满足(如网络请求返回、动画播放完毕)。 - **复杂流程控制**:实现状态机或分步骤逻辑(如 RPG 对话系统)。 --- ### **3. 协程的实现机制** #### **(1)语法基础:C# 迭代器(IEnumerator)** 协程基于 C# 的 `IEnumerator` 接口实现,通过 `yield return` 暂停执行并返回控制权。 ```csharp IEnumerator MyCoroutine() { yield return null; // 等待下一帧 yield return new WaitForSeconds(2); // 等待2秒 Debug.Log("协程恢复执行!"); } ``` #### **(2)Unity 的协程调度** - **启动协程**:通过 `StartCoroutine()` 方法。 ```csharp void Start() { StartCoroutine(MyCoroutine()); } ``` - **停止协程**: - `StopCoroutine()`:停止指定协程。 - `StopAllCoroutines()`:停止所有协程。 #### **(3)常见 Yield 指令** | Yield 指令 | 行为 | |-------------------------------|---------------------------------------| | `yield return null` | 等待下一帧 | | `yield return new WaitForSeconds(t)` | 等待 `t` 秒(受 Time.timeScale 影响) | | `yield return new WaitForFixedUpdate()` | 等待 FixedUpdate 执行 | | `yield return new WaitUntil(() => condition)` | 等待条件为 `true` | | `yield return new WaitWhile(() => condition)` | 等待条件为 `false` | | `yield return StartCoroutine(otherCoroutine)` | 嵌套执行其他协程 | --- ### **4. 协程的底层原理** - **状态机模型**:Unity 将协程编译为一个状态机类,通过 `MoveNext()` `Current` 控制执行进度。 - **生命周期绑定**:协程依赖于 MonoBehaviour 对象,若对象被销毁(`Destroy(gameObject)`)或脚本禁用(`enabled = false`),协程会自动终止。 - **性能开销**:协程的调度由 Unity 主循环驱动,每帧检查恢复条件,**轻量但需避免滥用**(如同时运行数百个协程)。 --- ### **5. 使用示例** #### **(1)延迟生成敌人** ```csharp IEnumerator SpawnEnemies() { for (int i = 0; i < 5; i++) { Instantiate(enemyPrefab, transform.position, Quaternion.identity); yield return new WaitForSeconds(1); // 每隔1秒生成一个敌人 } } void Start() { StartCoroutine(SpawnEnemies()); } ``` #### **(2)分帧加载避免卡顿** ```csharp IEnumerator LoadLargeData() { for (int i = 0; i < 1000; i++) { ProcessDataChunk(i); // 处理数据块 if (i % 10 == 0) { yield return null; // 每处理10个数据块等待一帧 } } } ``` --- ### **6. 注意事项与最佳实践** - **避免长时间阻塞**:协程中不要使用 `while(true)` 或同步耗时操作,否则会冻结主线程。 - **资源释放**:若协程引用对象,需在 `OnDestroy()` 中停止协程,防止内存泄漏。 - **替代方案**: - **UniTask**:第三方库,提供更高效的异步编程支持(支持 `async/await`)。 - **C# Job System**:对多线程并行计算友好,但需处理线程安全。 - **调试技巧**:使用 `UnityEditor.EditorApplication.isPaused` 或断点调试协程执行流程。 --- ### **7. 协程 vs Async/Await** | 特性 | 协程Coroutine) | C# Async/Await | |---------------------|---------------------------------------|------------------------------------| | **依赖引擎** | 必须运行在 Unity 主线程 | 不依赖引擎,可在任意线程使用 | | **Yield 指令** | 支持 Unity 特定指令(如 WaitForSeconds)| 依赖 `Task` 或自定义等待对象 | | **性能** | 轻量,适合小规模任务 | 更高效,适合复杂异步逻辑 | | **兼容性** | 需 MonoBehaviour 支持 | 纯 C# 特性,无 Unity 依赖 | --- ### **总结** - **协程是 Unity 异步编程的核心工具**,适合处理简单的延时、分帧或流程控制。 - **理解 `yield` 指令生命周期管理**是避免 Bug 的关键。 - **复杂场景可结合 UniTask 或 Job System** 提升性能代码可维护性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值