c中的协程(二)
栈协程(Stackful Coroutine)和无栈协程(Stackless Coroutine)的底层原理和实现方式有很大的区别,特别是在如何管理协程的执行状态、内存和栈的分配上。它们分别采用不同的策略来实现协程的暂停与恢复,以下是它们的具体实现方式、区别和底层原理的详细解析。
1. 栈协程(Stackful Coroutine)
栈协程的基本原理是为每个协程分配一个独立的栈空间。栈空间用于保存协程的局部变量、函数调用的状态(比如返回地址、参数、局部数据)以及执行的上下文。当协程暂停时,整个栈内容(包括程序计数器、寄存器等)都会被保存;当协程恢复时,这些状态会被恢复,栈继续执行。
栈协程的实现方式:
栈协程的底层实现一般需要依赖操作系统提供的线程或者使用一些低级的上下文切换机制,常见的有:
使用线程:栈协程的实现有时会通过操作系统的线程来模拟。每个协程有独立的栈和线程上下文,操作系统调度器负责协程的切换。
例子:
boost::context或libco等库,使用setjmp和longjmp来保存和恢复栈状态,或者使用线程来模拟协程。**使用
setjmp和longjmp**:setjmp:保存当前的执行状态(程序计数器、寄存器值等)。longjmp:恢复先前通过setjmp保存的执行状态,使程序回到setjmp处继续执行。
流程:
- 在创建协程时,栈协程的执行状态会被保存到一个特定的数据结构中,通常是一个上下文(包括程序计数器、寄存器等)。
- 当协程被调度时,执行状态被恢复,栈继续执行。
- 每个协程会有自己的栈空间,操作系统或用户空间的调度器负责切换。
基于操作系统线程:栈协程还可以基于操作系统线程来实现,每个协程对应一个线程。操作系统负责切换这些线程,但通常这种方式的性能较低,因为线程切换的开销较大。
栈协程的原理:
- 独立栈:每个协程拥有独立的栈空间,这使得它能够执行像普通函数那样的调用(包括递归调用、深度堆栈的函数等)。
- 上下文保存和恢复:通过
setjmp/longjmp或者其他机制保存和恢复协程的上下文信息。栈协程在暂停时保存的栈数据非常完整,恢复时直接恢复到暂停时的状态。 - 调度:协程调度通常是由用户手动控制的。可以在某个协程内通过某些机制(比如
yield或co_await)来主动让出执行权。
栈协程的优缺点:
优点:
- 支持递归和复杂的函数调用:每个协程有独立的栈,可以像线程一样支持复杂的调用栈。
- 灵活性强:能处理各种函数调用,支持较为复杂的控制流。
缺点:
- 内存开销大:每个协程都需要分配一个独立的栈,内存消耗较大,尤其是在高并发场景下。
- 上下文切换开销高:每个栈协程的上下文切换可能会涉及到完整的线程切换或较重的栈操作。
2. 无栈协程(Stackless Coroutine)
无栈协程与栈协程的区别在于,它并不为每个协程分配独立的栈空间,而是共享一个栈,或者使用堆内存来保存协程的执行状态。无栈协程通过显式的上下文切换来保存协程的状态,而不是依赖栈上的调用。
无栈协程的实现方式:
无栈协程的底层实现通常依赖于更轻量的上下文切换机制,例如:
程序计数器(PC)保存:无栈协程通常只保存程序计数器和协程的局部变量,栈的内容并不会保存。协程的状态仅限于当前函数的上下文。
协程栈共享:多个协程共享同一个栈,协程的状态仅通过寄存器、程序计数器和指令指针等上下文信息来保存。
setjmp和longjmp(或类似机制):无栈协程依赖于保存和恢复执行状态来实现上下文切换。通过setjmp保存上下文,通过longjmp恢复上下文。无栈协程通常不会在调用栈中分配空间,而是通过堆栈保存一些关键数据。手动管理堆栈:在一些轻量级的协程库(如
libco)中,协程的栈可能完全由用户管理(例如放在堆上)。协程调度器需要显式地管理每个协程的执行栈。
无栈协程的原理:
- 共享栈:多个协程共享同一个栈,栈上的数据仅在需要保存协程状态时才会保存,而非每个协程都有独立的栈。
- 上下文切换:无栈协程的上下文切换开销非常低。通过保存程序计数器(PC)、寄存器和指令指针等信息来管理协程的状态。当切换协程时,恢复这些保存的状态。
- 调度方式:协程之间的切换非常高效,一般是由用户空间的调度器控制,而不需要操作系统的线程调度。
无栈协程的优缺点:
优点:
- 内存开销小:由于没有为每个协程分配独立的栈,因此内存开销较低。
- 高效的上下文切换:上下文切换不需要保存和恢复大量的栈数据,因此切换非常高效。
- 适合大量并发:由于内存消耗较小,可以创建成千上万的协程,适用于大量轻量级任务。
缺点:
- 不支持递归:无栈协程不支持深度的函数调用栈,无法进行递归调用,因为它没有独立的栈空间。
- 编程限制:使用无栈协程时,编程模型必须遵循一定的限制,例如避免复杂的函数调用或局部变量的复杂操作。
3. 栈协程与无栈协程的底层原理对比
| 特性 | 栈协程(Stackful Coroutine) | 无栈协程(Stackless Coroutine) |
|---|---|---|
| 栈分配 | 每个协程有独立的栈 | 所有协程共享同一个栈 |
| 上下文保存 | 保存完整的栈帧、寄存器、返回地址等 | 只保存程序计数器、寄存器等少量信息 |
| 内存开销 | 高(每个协程都有独立的栈) | 低(多个协程共享栈) |
| 递归支持 | 支持递归调用 | 不支持递归调用 |
| 上下文切换开销 | 高(涉及栈的保存和恢复) | 低(只保存少量的状态) |
| 实现复杂度 | 较高(需要管理栈、上下文切换等) | 较低(只需保存寄存器和程序计数器) |
| 适用场景 | 适合递归、复杂控制流的应用 | 适合大量并发、轻量级任务的场景 |
总结
- 栈协程通过为每个协程分配独立的栈来管理其执行状态,适用于需要递归和复杂控制流的场景,但内存开销较大。
- 无栈协程共享栈并通过轻量级的上下文切换来实现高效的调度,适用于大规模并发和高效任务调度的场景,但无法处理递归调用。
然后又协程又分为独立栈和共享栈,举个例子,传统的线程模型就是独立栈,独立栈对于上下文切换是相对重量级的。一般有栈协程就是独立栈,无栈协程就是共享栈。
这里顺便说一下go中的goroutine的栈模型,一般我们说有栈协程对于高并发是相对没那么支持的,但是go中的协程进行优化,使得其性能相对优秀。
Go 语言的协程(goroutines)栈管理是其轻量级并发模型的一个重要特点。与传统线程相比,goroutines 的栈管理更为高效,能够支持数以万计的并发任务而不会导致过大的内存开销。Go 的栈是动态增长的,并且由 Go 的运行时调度器管理。
以下是 Go 中协程栈实现的详细解释:
1. Go 协程栈的基本概念
- 栈大小动态调整:每个 goroutine 一开始的栈非常小,通常只有 2 KB 左右。这使得 Go 能够高效地创建大量协程,而不会像传统线程那样每个线程需要分配几 MB 的栈空间。
- 栈的增长与收缩:goroutine 的栈是可以在运行时根据需要动态增长的。当栈的空间不足时,Go 的运行时会自动扩展栈。而如果栈有剩余空间并且某些 goroutine 被挂起较长时间,栈也会缩小,以节省内存。
2. 栈的初始分配和增长机制
初始栈大小:每个新的 goroutine 在创建时会分配一个非常小的栈,通常是 2 KB。这远小于传统线程的栈大小(通常为 1 MB 或更大)。
栈增长:当 goroutine 执行时,如果栈空间不足以存放更多的局部变量和函数调用,Go 会自动增加栈的大小。这个过程是通过 栈复制 来完成的,即将原来栈中的内容拷贝到一个更大的栈中,然后丢弃原有的栈。
- 栈扩展:当 goroutine 执行到某个点,需要更多的栈空间时,Go 会把现有栈的内容复制到一个新的更大的栈上。新的栈大小通常是原栈的 2 倍,栈的增长是指数级的。
- 栈收缩:当栈不再需要那么多空间时,Go 运行时也会尝试缩小栈的大小,以节省内存。比如如果栈的空闲空间很大,而该 goroutine 长时间没有执行任务,栈会缩小到合适的大小。
3. 栈的实现细节
栈的布局:Go 协程的栈包括栈帧(存放函数的局部变量、返回地址等)和一些额外的元数据(例如栈的起始地址、当前栈的大小等)。栈的底部通常是由运行时管理的,Go 的调度器使用这个底部的地址来管理栈。
栈分配和切换:Go 运行时将栈的分配和上下文切换(协程切换)设计得非常高效。每当协程被挂起(例如发生阻塞、等待 I/O 等),其栈内容会被保存在内存中,直到调度器决定恢复该协程的执行。
4. 栈的拷贝过程(栈增长过程)
当一个 goroutine 的栈不足以继续执行时,Go 会进行栈增长。具体过程如下:
栈复制:Go 会将当前栈的内容(栈帧、局部变量、返回地址等)复制到一个新的栈中。新的栈会比旧栈大两倍。这个复制操作是在 goroutine 执行时进行的,因此需要高效的内存管理策略。
调整栈的指针:复制栈后,运行时将 goroutine 的栈指针指向新的栈。这样,goroutine 就可以继续执行,而不需要重新分配大量的内存。
栈指针的更新:在扩展栈时,运行时会更新栈指针,使其指向新的栈空间,同时也会更新栈上的元数据,确保栈的状态正确。
5. 栈增长和调度
调度器与栈管理:Go 的调度器负责在多个 goroutine 之间进行切换,每当一个 goroutine 切换出 CPU 时,调度器会保存该 goroutine 的执行状态(包括栈指针)。当 goroutine 被切换回来时,调度器会恢复其栈,继续执行。
栈的内存布局:Go 的运行时使用 分配的栈 来实现 goroutine 上下文切换的高效性,而栈的扩展和收缩是透明的,应用程序通常不会感知到栈的变化。
6. 栈管理的高效性
栈内存回收:Go 的运行时会定期回收不再使用的栈空间。当协程被销毁或其栈不再需要时,Go 会尝试回收内存,从而避免内存泄漏。
垃圾回收与栈:Go 的垃圾回收机制(GC)与栈的管理紧密配合。当栈中有大量对象不再使用时,GC 会清理这些对象并回收内存。
7. 栈增长的实现难点
效率问题:栈的扩展和收缩是一个内存密集型的操作,需要在保证高效的同时避免性能瓶颈。Go 通过栈的指数增长(每次扩展是原栈的两倍)和局部栈压缩(减少内存浪费)来确保程序性能。
并发访问的同步:栈的扩展和收缩操作是线程安全的,Go 运行时确保多核并发环境中,多个 goroutine 访问栈时不会发生竞争。
8. 栈分配与调度的结合
Go 的调度器在运行时会根据实际情况来决定每个 goroutine 所需要的栈大小。调度器将 goroutine 的栈当作调度的一部分,随着执行的深入动态地管理栈的大小,尽量避免不必要的内存分配和过度的栈增长。
总结
- Go 协程的栈管理非常高效,初始栈小,通常为 2 KB,并且能够根据需要动态扩展,最大可以达到几 MB。
- 栈增长是指数级的,每次扩展都会翻倍,保证了内存开销的线性增长,同时避免了过度扩展的浪费。
- 栈的管理和调度是紧密结合的,Go 运行时会自动为协程管理栈的扩展与收缩,确保内存使用的高效性。
- 栈管理的高效性和调度器的优化使得 Go 可以在单机上运行数以万计的协程,而不会导致内存溢出或过多的资源消耗。
Go goroutines 的优势:
- 内存占用小:由于每个 goroutine 的栈非常小,且可以动态扩展,因此它比传统的线程模型(每个线程有独立栈空间,通常 1MB 或更大)消耗的内存要少得多。这使得在高并发场景下,Go 可以创建更多的 goroutines 而不容易导致内存耗尽。
- 高效的调度器:Go 的调度器可以非常高效地在多个 CPU 核心之间调度成千上万的 goroutines。它使用了 M:N 调度模型(多个 goroutine 映射到多个操作系统线程上),这一点使得它能更好地适应高并发环境。
Go goroutines 的劣势:
- 栈扩展的开销:尽管 Go 的栈是动态扩展的,但每当一个 goroutine 的栈需要扩展时,仍然会有一定的开销。特别是在一些栈深的操作中,扩展栈和管理栈的操作可能会影响性能,尤其是在非常高并发的场景下。
- 调度延迟:Go 的调度器虽然设计得相当高效,但在极端高并发的情况下,调度器仍然可能会成为瓶颈。尤其是当 goroutines 的数量非常庞大时(例如几百万个 goroutines),调度器的负担会增大,可能会导致一定的延迟。


