coroutine
进程、线程、协程
线程是CPU调度的最小单位, 进程是资源分配的最小单位。对于线程和进程,我们可以这么理解:
- 当进程只有一个线程时,可以认为进程就等于线程。
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
- 线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。
协程作为用户态线程,也是轻量级的线程,用来解决高并发场景下线程切换的资源开销。协程跟线程是有区别的
- 线程/进程是内核进行调度,有 CPU 时间片的概念,进行 抢占式调度(有多种调度算法)
- 协程对内核是透明的,也就是系统并不知道有协程的存在,是完全由用户自己的程序进行调度的,因为是由用户程序自己控制,那么就很难像抢占式调度那样做到强制的 CPU 控制权切换到其他进程/线程,通常只能进行 协作式调度,需要协程自己主动把控制权转让出去之后,其他协程才能被执行到。
GO协程的设计与原理
GMP模型
- G(Goroutine):用户态协程,逻辑上的计算单元
- M(Machine/OS Thread):绑定操作系统线程,实际执行G
- P(Processor):调度器抽象,用于调度G给M
go func
‘’‘go func main() { for i := 0; i < 10; i++ { go func() { fmt.Print(i) }() } time.Sleep(1 * time.Second) } ’‘’ 上述代码的运行结果为:

这段代码中开启了十个线程,可以看到并不是按顺序打印且每次不一样,这是因为这十个线程的调度时间并不固定,只有等到被调度执行的时候才会实际打印。
每个我们开启的协程都是一个计算任务,这些任务会被提交给go的runtime。若有多个计算任务,这些计算任务会被先暂存起来,一般的做法是放到内存的队列中等待被执行。
消费端则是一个go runtime维护的一个调度循环。调度循环简单来说,就是不断从队列中消费计算任务并执行。这里本质上就是一个生产者-消费者模型,实现了用户任务与调度器的解耦。
调度策略
为了避免多个M访问同一个队列,我们把全局队列分为多个多个本地队列,这个本地队列由P来管理。这样一来,每个M只需要去先找到一个P结构,和P结构绑定,然后执行P本地队列里的G。

上图中各个模块的作用如下:
- 全局队列:存放等待运行G
- P的本地队列:和全局队列类似,存放的也是等待运行的G,存放数量上限256个。新建G时,G优先加入到P的本地队列,如果队列满了,则会把本地队列中的一半G移动到全局队列
- P列表:所有的P都在程序启动时创建,保存在数组中,最多有GOMAXPROCS个,可通过runtime.GOMAXPROCS(N)修改,N表示设置的个数。
- M:每个M代表一个内核线程,操作系统调度器负责把内核线程分配到CPU的核心上执行。
**个人感觉GMP中的M就像线程池中的线程,Go runtime会动态创建和复用M,避免频繁线程切换或销毁,提升并发性能。**区别在于线程池中的线程在等待IO的时候会阻塞,但协程模型中,这个线程会在调度器的调度下运行另一个任务,IO完成后再唤醒,且任务切换的开销远小于切换线程。
调度策略: 调度器核心思想是尽可能避免频繁的创建、销毁线程,对线程进行复用以提高效率。
- work stealing机制(窃取式):当本线程无G可运行时,从其他线程绑定的P窃取G,而不是直接销毁线程。
- hand off机制:当本线程M1因为G进行的系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的M0执行。
- 抢占:一个goroutine最多占用CPU10ms,防止其他goroutine等待太久得不到执行被“饿死”。
优势:
- goroutine是用户态线程,其创建和切换都在用户代码中完成而无需进入操作系统内核,所以其开销要远远小于系统线程的创建和切换;
- goroutine启动时默认栈大小只有2k,这在多数情况下已经够用了,即使不够用,goroutine的栈也会自动扩大,同时,如果栈太大了过于浪费它还能自动收缩,这样既没有栈溢出的风险,也不会造成栈内存空间的大量浪费。
rust协程:tokio
与 Go 等语言的有栈协程不同,Rust 的 async/await 实现的是无栈协程。这种设计在内存占用和任务切换开销上通常更具优势。
在 Rust 中,一个 async 代码块或 async fn 函数在编译后会生成一个实现了 Future trait 的状态机结构体。这个 Future 本身可以存在于栈上。当我们调用 tokio::spawn 将其作为一个任务交给调度器时,Tokio 会将其**装箱(Box)**并放到堆上,以便在不同线程间安全地移动和管理。随后,这个被包装的任务被推入异步任务队列,等待调度器线程执行。
协程运行机制
在rust中,async fn用来定义一个异步函数,被编译成状态机结构体Future,tokio::spawn会把Future推入到调度器的任务队列,实际线程从队列中取任务,调用Future::poll方法,返回Poll::Ready则任务完成。如果 poll 方法返回 Poll::Pending,这表明任务在某个 .await 处遇到了一个尚未完成的异步操作,此时任务需要等待。这并非传统意义上的“线程阻塞”,而是一个挂起点。
此时,任务会:
-
向执行器(Executor)注册一个 Waker。这个 Waker 知道如何唤醒当前任务。
-
任务被挂起,执行它的工作线程则会立即丢下它,去处理本地队列中的下一个就绪任务,线程本身从不阻塞。
当等待的事件完成时(例如,网络数据到达),事件源会调用之前注册的 Waker 的 wake() 方法。被唤醒的任务会被重新放回调度器的就绪队列中,等待下一次被线程轮询。 状态机的转换过程如下图:

调度器
Tokio采用多线程、work-stealing调度策略:
- 每个线程都有一个本地任务队列;
- 在 Tokio 工作线程内部调用 spawn 时,优先尝试将这个新任务放入当前工作线程自己的本地运行队列中,增强数据局部性。
- 当本地任务队列已满时或者从 Tokio 运行时外部调用 spawn 时,会尝试将任务放入全局任务队列中;
- 空闲线程可以从其他线程(包括全局队列)“偷”任务;
- 每个任务的 Future 是 Send + ‘static,所以可以在不同线程间移动执行。
适用场景
- I/O 密集型(I/O-Bound)
**Tokio 的异步模型最核心的优势在于处理 I/O 密集型任务。**例如网络请求、数据库连接、文件读写等。
原因在于:对于这类任务,程序大部分时间都在等待外部资源(如网络、磁盘)的响应。在传统的同步阻塞模型中,等待会使整个线程挂起,浪费 CPU 资源。而在 Tokio 中,当一个任务等待 I/O 时,它会主动让出(.await)CPU,使得执行线程可以去处理成百上千个其他并发任务。这种“在等待时做别的事”的模式极大地提高了单线程的利用率和整个系统的吞吐能力。
- CPU 密集型(CPU-Bound)
对于需要长时间进行密集计算的任务,不应该直接在 Tokio 的主异步上下文中运行。
原因在于:这样的任务会长时间占用线程的 CPU 时间,并且从不“让出”(因为没有 .await I/O 操作)。这会导致它所在的线程无法去轮询其他任务,造成调度器饥饿,使得该线程上的所有其他异步任务都得不到执行,严重影响响应性。
对于这类计算密集型任务,可以派发到专门的阻塞线程池中去执行。