进程、线程和协程(在 Go 语言中称为 goroutine)都是实现程序并发执行的基本单元,但它们在内存隔离、调度方式、切换成本和使用场景等方面有明显区别。这里做一个综合的总结和对比。

进程

进程是程序运行时在操作系统中创建的一个独立实体,每个进程拥有独立的内存地址空间(包括独立的堆、栈、全局变量等),资源由操作系统分配。

  • 隔离性好:各进程之间完全独立,一个进程崩溃不会直接影响其它进程。
  • 切换成本高:由于切换时需要保存和恢复大量上下文(如内存映射、文件句柄等),进程切换开销较大。
  • 通信方式:通常需要通过进程间通信(IPC)方式,如管道、信号、共享内存等。

线程

线程是进程内的一个执行流,同一进程的多个线程共享该进程的大部分资源(如堆、全局数据、打开文件等),但每个线程有独立的栈和寄存器。

  • 共享资源:线程共享进程的内存,有利于数据共享和通信,但也需要同步机制(如锁)防止竞争。
  • 切换成本较低:相比进程,线程切换所需保存的上下文更少,但仍需要进入内核态进行调度,因此开销低于进程切换但比用户态切换略高。
  • 调度:线程由操作系统内核调度,可以利用多核实现真正的并行执行。

协程(goroutine)

协程是一种用户态的轻量级“线程”,由程序自己管理调度,而不依赖操作系统内核。goroutine 可以看作是 Go 语言中实现并发的“协程”,它们非常轻量且易于创建。使用 go 关键字启动一个函数调用就会生成一个新的 goroutine。由于它们由 Go 运行时管理,创建和销毁 goroutine 的成本非常低,这使得在同一程序中可以同时存在成千上万个 goroutine。

  • 轻量级:每个 goroutine 默认占用内存非常少(大约 2KB),但这块栈是动态增长的。当 goroutine 调用函数时,运行时会自动扩展栈的大小。并允许创建成千上万个并发执行单元。
  • 调度在用户态:goroutine 的切换由 Go runtime 管理,不需要进入内核态,因此上下文切换非常快,开销极低。
  • 协作式或半抢占式:虽然 Go runtime 实现了部分抢占,但大部分切换是协作式的(需要在适当时机主动让出控制权),使得调度更高效。
  • 通信方便:Go 语言内建通道(channel)机制,使得 goroutine 间的通信和同步变得简单。

调度模型:GMP 模型

G:goroutine
每个 goroutine 在内部表示为一个结构体,包含了它的栈、程序计数器、状态信息等。

P:processor
processor 保存了调度 goroutine 所需的运行队列及相关上下文,负责将 goroutine 分配给 OS 线程执行。

M:OS 线程
M 表示机器 Machine,即操作系统层面的线程。Go 运行时将多个 goroutine 复用到较少的 OS 线程上执行,这样可以充分利用多核 CPU,同时减少线程创建和上下文切换的开销。

调度器采用了工作窃取(work stealing)等技术,在各个 P 之间平衡负载,并且引入了抢占机制,以确保长时间运行的 goroutine 也能被适时抢占,从而提高整体响应性。

总结

特性 进程 线程 协程(goroutine)
资源隔离 各自独立,资源完全隔离 同一进程内共享大部分资源 与线程类似,共享进程资源,但更轻量
调度方式 由操作系统内核调度 由操作系统内核调度 由用户态 runtime 调度(GMP 模型)
切换开销 高(保存大量上下文、内存映射、系统调用) 较低(切换栈和寄存器,但需内核态切换) 极低(仅保存少量上下文,在用户态切换)
内存占用 较高(每个进程独立内存空间) 中等(每个线程有独立栈,其他共享) 极低(默认仅几 KB,可动态扩展)
适用场景 稳定性要求高、资源隔离需求强 多核并行处理、计算密集型任务 高并发、I/O 密集型场景、需要轻量并发控制
  1. 进程是操作系统资源分配的最小单元而线程是操作系统 CPU 调度的最小单元,这两个概念是操作系统的基本概念,而协程是编程语言层面的概念,是对线程的封装和抽象;
  2. 资源隔离:每个进程是拥有独立的内存空间,所以内存的代码段(Text Segment)、数据段(Data Segment)、堆栈段(Stack Segment)都是独立的,但线程是共享进程的内存空间的,所以线程的代码段(Text Segment)和数据段(Data Segment)是共享的,而堆(Heap)其实也是共享的,因为堆主要用于动态内存分配和存储共享数据。当程序在运行过程中需要申请内存(例如通过 malloccallocnew 等操作)时,所申请的内存空间就来自堆。而同一个进程的不同线程显然是可以访问同一个全局变量的,所以同一个进程的不同线程之间是共享堆的。但是栈(Stack)是独立的,因为栈主要用于存储局部变量和函数调用,线程在执行过程中,显然会有独立的调用路径和局部变量,如果线程之间共享栈的话,就可能会导致数据调用混乱、局部变量相互覆盖等问题,所以线程之间是独立的栈。至于协程,资源隔离与线程类似,也是有独立的栈(Stack)并且共享进程资源,但是它是在用户态的程序库中实现的,所以它的资源隔离是由用户态的程序库来达到一种更加轻量的实现;
  3. 调度方式:进程和线程都是操作系统根据不同系统内核特性和不同调度策略来调度的。而协程是由用户态的程序库来调度的,而不是由操作系统来控制的,所以它的切换非常快,而且内存占用也更小。并且Go运行的时候包含一个自己的调度器, 这个调度器使用一个称为一个 M:N 调度技术, mgoroutinenOS 线程 (可以用 GOMAXPROCS 来控制 n 的数量) , Go 的调度器不是由硬件时钟来定期触发的, 而是由特定的 go func(){...}() 语言结构来触发的, 调度的时候不需要在内核态和用户态中反复切换, 所以调度一个 goroutine 比调度一个线程的成本低很多;
  4. 内存占用:进程无疑是占用最大的,而线程和协程虽然都需要独立的栈,但是协程的栈更加灵活。在操作系统中,每一个线程都会有一个固定的栈大小,一般是 2MB,而协程由于其本身用于更加轻量的场景所以用不到这么大的栈,故而 Go 语言的协程默认的栈大小是 2KB,当然这个大小是可以动态扩展的。