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 可以轻松支持成千上万甚至百万级的并发。