Go语言中的协程
Zhongjun Qiu 元婴开发者

Go 语言中的协程(goroutine)是一种轻量级的线程实现,允许在单个操作系统线程上运行成千上万个并发任务,以提高程序的并发性和资源利用率。


进程和线程

进程(Process)

  • 程序的一次执行实例,是 资源分配的最小单位
  • 拥有独立的内存空间(代码段、数据段、堆、栈)
  • 进程之间相互隔离,通信需要 IPC(管道、消息队列、共享内存等)

线程(Thread)

  • CPU 调度的最小单位
  • 线程属于进程,一个进程至少包含一个线程
  • 线程共享进程的资源(堆、数据段、打开的文件描述符),但有独立的栈和寄存器

用户线程(User Thread, ULT)

  • 用户态 实现,内核对它们不可见
  • 调度由用户级线程库完成(如 GNU Pthreads 的用户态实现)
  • 优点:切换快,不需要内核参与
  • 缺点:一旦某个线程阻塞,整个进程都阻塞(用户线程对内核不可见,一旦阻塞,内核认为整个进程阻塞

内核线程(Kernel Thread, KLT)

  • 操作系统内核 创建与调度
  • 内核知道每个线程的存在,可以独立调度
  • 优点:线程阻塞时不会影响同进程中的其他线程
  • 缺点:切换需要陷入内核态,开销较大

常见线程模型

一对一模型(1:1)

  • 定义:每个用户线程对应一个内核线程
  • 例子:Linux 的 pthread,Windows 的线程模型

✅ 优势:

  • 阻塞粒度小,一个线程阻塞不会影响其他线程
  • 可充分利用多核 CPU,实现真正的并行

❌ 劣势:

  • 内核线程开销大,线程数量有限(创建/销毁需要系统调用)
  • 内核调度开销比用户态大

多对多模型(M:N)

  • 定义:n 个用户线程映射到 m 个内核线程(n > m)
  • 例子:Go 的 goroutine 模型,Java 早期的绿色线程

✅ 优势:

  • 可以支持海量用户线程(轻量级)
  • 遇到阻塞时,可以切换到其他内核线程继续执行
  • 结合了用户态调度(快)+ 内核态调度(安全)

❌ 劣势:

  • 实现复杂,需要运行时(runtime)或库的支持
  • 调度策略可能不如内核调度精确

协程

协程可以理解为用户态的轻量级线程。它和普通的线程相比:

  • 创建和切换的开销要小得多,因为它完全由用户态的运行时系统(runtime)来管理,不需要每次切换都陷入内核。

  • 协程本身不直接对应内核线程,而是运行在进程分配到的内核线程之上。

之所以需要协程,是因为传统的线程模型(无论是一对一还是多对多)在面对大规模并发时都会遇到严重的开销问题。

线程数量一旦过大,就会导致内存占用增加(栈空间消耗)、线程上下文切换成本变高,以及操作系统调度器负担过重。这使得在高并发的场景下,线程模型难以支撑。

Java 早期也有类似协程的实现,即所谓的绿色线程(Green Thread)。绿色线程是一种多对多的模型,多个用户线程映射到较少数量的内核线程,由虚拟机在用户态调度。

但绿色线程遇到的根本问题在于阻塞调用。由于内核并不知道绿色线程的存在,当其中一个绿色线程执行阻塞 IO 时,对应的整个内核线程都会被挂起,从而导致所有映射到这个内核线程的绿色线程都无法执行。这就是为什么绿色线程在遇到大量 IO 阻塞场景时表现很差,最终在现代 JVM 中被放弃的原因。

解决这个问题的关键在于让阻塞操作不再真正阻塞内核线程。换句话说,阻塞调用需要转化为非阻塞 IO,加上事件驱动机制来管理。当协程遇到一个 IO 请求时,运行时会把它挂起并让出执行权,底层通过 epoll/kqueue 之类的多路复用接口等待事件完成。一旦内核通知 IO 就绪,运行时再恢复协程的执行。这就避免了“一个协程阻塞拖垮整个线程”的问题。

阻塞调用可以理解为,用户态线程发起systemcall后,会一直等待内核的响应。这时候,内核调度器就会认为这个线程完全阻塞了,所以会将该线程对应的内核线程放入等待队列,浪费了一个线程。CPU被用于运行其他线程。

非阻塞调用的思路不同。当用户态线程发起 system call 时,调用会立刻返回,而不会等待内核把结果准备好。返回时可能会给出一个状态值,比如“资源暂时不可用”,调用方需要自行决定下一步做什么。这样,线程就不会白白阻塞住,可以立马切换任务,即对应的内核线程被释放去干其他的活。与此同时,可以通过轮询或事件通知机制,来获知这个 IO 何时真正完成。

其实所谓“阻塞”,严格来说是针对 线程 而不是 CPU 的。当一个线程发起阻塞操作(比如阻塞式 IO)时,这个线程就会被内核挂起,无法继续执行代码。CPU 本身并不会停下来,它仍然可以调度其他线程运行。但在高并发场景下,如果大多数线程都因为等待 IO 而阻塞,那么可运行的线程就很少,CPU 就无法充分利用,导致整体性能下降。

协程的解决思路正是针对这一点。协程在用户态实现轻量级调度,当一个协程遇到阻塞操作时,runtime 会把它挂起,同时把底层的内核线程释放去运行其他可运行的协程。这样,即便有大量协程在等待 IO,也不会占用内核线程,从而保持 CPU 的高利用率。换句话说,协程让阻塞操作不再“绑死”线程,从而提升并发能力和资源利用效率。

Go 的协程(goroutine)就是这种机制的高效实现。Go runtime 在用户态维护了一个调度器,负责把海量的 goroutine 映射到有限的内核线程上执行。它使用 M:N 模型,即 M 个内核线程运行 N 个 goroutine。当 goroutine 发起阻塞操作时,runtime 并不会让内核线程真的阻塞,而是通过非阻塞 IO 和网络轮询器来把这个 goroutine 挂起,调度器立即把这个内核线程分配给其他 goroutine 使用。这样,内核线程几乎不会被无谓地浪费,CPU 资源能够被最大化利用。同时,goroutine 本身占用内存极小(初始栈空间只有几 KB,且能动态扩展),因此 Go 可以轻松支持成千上万甚至百万级的并发。

 REWARD AUTHOR
 Comments
Comment plugin failed to load
Loading comment plugin