为什么 Go 适合高并发

Go 语言在云原生、微服务、网关、IM、任务调度、容器编排等领域使用非常广泛,一个重要原因就是它天然支持高并发。

Go 写并发代码非常简单:

go func() {
	// 并发执行任务
}()

但是 go func() 只是表象。

Go 真正强大的地方在于:

  • goroutine 非常轻量
  • Go runtime 自己负责调度 goroutine
  • 通过 GMP 模型实现用户态调度
  • 网络 I/O 由 runtime netpoller 高效管理
  • 调度器支持工作窃取、系统调用交接、抢占式调度

一句话理解:

Go 的高并发能力,不只是语法简单,而是 runtime 在底层帮我们做了大量调度优化。

传统线程模型的问题

在传统编程模型中,通常使用操作系统线程处理并发。

例如 Java 中常见的是线程池:

ExecutorService executor = Executors.newFixedThreadPool(100);

操作系统线程的问题:

  • 创建成本较高
  • 默认栈空间较大
  • 线程数量过多会占用大量内存
  • 上下文切换需要进入内核态,成本较高
  • 海量并发下线程调度压力大

线程和 Goroutine 对比

对比项 操作系统线程 Goroutine
创建成本
初始栈空间 通常 MB 级别 初始约 2KB
调度者 操作系统内核 Go runtime
上下文切换 成本较高 成本较低
并发数量 通常有限 可以轻松创建成千上万
编程复杂度 相对较高 go func() 即可

Go 没有让一个 goroutine 固定绑定一个线程,而是通过 GMP 模型把大量 goroutine 调度到少量操作系统线程上运行。

这就是所谓的:

M:N 调度模型

即:

M 个 Goroutine 映射到 N 个操作系统线程上执行

GMP 模型整体理解

GMP 是 Go runtime 调度器的核心模型。

三个字母分别代表:

名称 全称 含义
G Goroutine Go 协程,用户代码的执行单元
M Machine 操作系统线程,真正执行代码的线程
P Processor 逻辑处理器,负责调度 G 给 M 执行

可以简单理解为:

G = 要执行的任务
M = 真正干活的工人
P = 工人的工作台和任务队列

一个 M 必须绑定一个 P,才能执行 G。

M + P -> 执行 G

整体关系:

        全局运行队列
             ↓
    ┌────────┬────────┬────────┐
    │   P1   │   P2   │   P3   │
    │ 本地队列 │ 本地队列 │ 本地队列 │
    └───┬────┴───┬────┴───┬────┘
        │        │        │
        M1       M2       M3
        │        │        │
        G        G        G

G:Goroutine

G 是什么

G 代表 goroutine,是 Go 中最小的执行单元。

每当我们写:

go doTask()

Go runtime 就会创建一个 G。

G 里面有什么

G 中保存了 goroutine 执行所需的信息,例如:

  • 当前执行的函数
  • 程序计数器 PC
  • 栈指针 SP
  • goroutine 栈空间
  • goroutine 状态
  • 调度相关信息

G 的栈空间

Go goroutine 的初始栈空间很小,约为 2KB。

特点:

  • 初始小
  • 可以按需增长
  • 栈空间不够时会自动扩容
  • 最大可增长到较大空间

这就是 goroutine 可以大量创建的重要原因之一。

G 的常见状态

状态 含义
waiting 等待中,例如等待 channel、锁、I/O
runnable 可运行,等待被调度
running 正在运行
syscall 正在执行系统调用
dead 已结束

M:Machine

M 是什么

M 代表 Machine,可以理解为操作系统线程。

M 是真正执行机器指令的载体。

但是 M 不能单独执行 G,它必须绑定 P。

M 只有拿到 P,才有资格执行 G

M 的作用

M 负责:

  • 执行 goroutine 代码
  • 执行系统调用
  • 和 P 绑定后从队列中取 G 执行
  • 在阻塞时与 P 分离

为什么 M 不能直接执行 G

如果所有 M 都直接去抢 G,会导致调度混乱和大量锁竞争。

Go 引入 P 作为中间层,让每个 P 维护自己的本地队列,减少全局竞争。


P:Processor

P 是什么

P 代表 Processor,逻辑处理器。

它不是 CPU 核心,也不是线程,而是 Go runtime 中的调度资源。

P 的核心作用:

管理可运行的 G,并把 G 交给 M 执行

P 里面有什么

P 中维护了很多调度相关资源,最重要的是:

  • 本地运行队列 Local Run Queue
  • 可用的 G 缓存
  • 内存分配缓存
  • 调度状态

P 的数量由什么决定

P 的数量由 GOMAXPROCS 决定。

查看当前值:

fmt.Println(runtime.GOMAXPROCS(0))

设置 P 的数量:

runtime.GOMAXPROCS(4)

默认情况下,Go 会根据 CPU 核心数设置 GOMAXPROCS


GMP 调度流程

当我们启动一个 goroutine:

go func() {
	fmt.Println("hello")
}()

大致流程如下:

  1. Go runtime 创建一个 G
  2. 新创建的 G 优先放入当前 P 的本地队列
  3. M 绑定 P 后,从 P 的本地队列取出 G
  4. M 执行 G 的代码
  5. G 执行完成后变成 dead 状态
  6. M 继续从 P 的本地队列取下一个 G

调度流程可以理解为:

go func()
   ↓
创建 G
   ↓
放入 P 的本地队列
   ↓
M 从 P 获取 G
   ↓
M 执行 G
   ↓
G 执行结束或被阻塞

本地队列与全局队列

本地队列

每个 P 都有自己的本地运行队列。

本地队列保存等待执行的 G。

特点:

  • 每个 P 独立维护
  • M 优先从绑定 P 的本地队列取 G
  • 减少全局锁竞争
  • 本地队列容量有限,常见说法是 256

全局队列

除了每个 P 的本地队列,Go runtime 还有一个全局运行队列。

当本地队列满了,或者某些调度场景需要时,G 可能会进入全局队列。

M 获取 G 的大致优先级:

P 的本地队列 -> 全局队列 -> Netpoller -> 从其他 P 窃取

不同版本细节可能有所调整,但整体思想是一致的:

优先本地,减少竞争;本地没有,再从其他地方找任务。

Work Stealing:工作窃取

什么是工作窃取

当某个 P 的本地队列没有 G 可以执行时,它不会马上让 M 休眠。

它会尝试从其他 P 的本地队列中偷一部分 G 来执行。

这就是 Work Stealing。

某个 P 没活干 -> 随机找其他 P -> 偷一半 G 过来执行

为什么需要工作窃取

假设有 4 个 P:

P1:100 个 G
P2:0 个 G
P3:0 个 G
P4:0 个 G

如果没有工作窃取,只有 P1 很忙,其他 P 对应的 CPU 资源就浪费了。

有了工作窃取后:

P2、P3、P4 可以从 P1 偷任务执行

这样可以提升 CPU 利用率。

工作窃取的好处

  • 避免某些 P 忙死,某些 P 空闲
  • 提升整体吞吐量
  • 减少中心化调度带来的锁竞争
  • 更适合高并发场景

Hand Off:系统调用阻塞交接

系统调用阻塞的问题

有些操作会让线程陷入系统调用阻塞。

例如:

  • 文件 I/O
  • 某些阻塞系统调用
  • CGO 调用

如果 M 带着 P 一起阻塞,会导致 P 本地队列中的其他 G 无法执行。

这会浪费 CPU 调度能力。

Hand Off 机制

当某个 G 执行阻塞系统调用时:

  1. M 带着当前 G 进入系统调用
  2. M 与 P 解绑
  3. P 被释放出来
  4. P 寻找其他空闲 M,或者创建新的 M
  5. 新的 M 绑定 P,继续执行 P 队列里的其他 G

可以理解为:

M 阻塞了,但 P 不能跟着一起浪费

图示:

阻塞前:
P1 -> M1 -> G1

G1 进入阻塞系统调用:
M1 + G1 阻塞
P1 释放

交接后:
P1 -> M2 -> 执行其他 G

Hand Off 的意义

  • 避免一个阻塞系统调用拖住整个 P
  • 提高 CPU 利用率
  • 保证其他 goroutine 可以继续执行
  • 提升高并发服务稳定性

抢占式调度

早期协作式调度的问题

早期 Go 调度主要依赖协作式调度。

也就是说,goroutine 需要在某些安全点主动让出 CPU。

如果某个 goroutine 长时间执行纯计算,没有函数调用、没有阻塞点,就可能长时间占用 M。

例如:

for {
	// 长时间 CPU 计算
}

这可能导致其他 goroutine 饥饿。

Go 1.14 后的异步抢占

从 Go 1.14 开始,Go runtime 引入了基于信号的异步抢占机制。

系统监控线程 sysmon 会定期检查运行中的 goroutine。

如果某个 G 执行时间过长,runtime 会尝试抢占它,让其他 G 有机会运行。

常见说法是超过约 10ms 可能会触发抢占检查。

抢占式调度的意义

  • 避免单个 goroutine 长时间霸占 CPU
  • 减少其他 goroutine 饥饿
  • 提升调度公平性
  • 改善高并发服务的尾延迟

Netpoller:网络轮询器

Netpoller 是什么

Go runtime 内置了网络轮询器 Netpoller。

它用于高效处理网络 I/O。

底层在不同系统上使用不同机制:

系统 底层机制
Linux epoll
macOS / BSD kqueue
Windows IOCP

网络 I/O 为什么不阻塞线程

当 goroutine 执行网络读写时,例如:

conn.Read(buf)

如果数据还没准备好,Go runtime 通常不会让 M 一直阻塞在这里。

它会:

  1. 把当前 G 挂起
  2. 把网络 fd 注册到 netpoller
  3. M 继续执行其他 G
  4. 当网络事件就绪时,netpoller 唤醒对应 G
  5. G 重新进入可运行队列

Netpoller 的意义

  • 支撑大量网络连接
  • 避免一个连接占用一个线程
  • 提升 Web 服务吞吐量
  • 非常适合网关、长连接、微服务通信场景

GOMAXPROCS 的作用

GOMAXPROCS 决定同时可以执行 Go 代码的 P 的数量。

查看当前值:

runtime.GOMAXPROCS(0)

设置:

runtime.GOMAXPROCS(8)

它影响什么

GOMAXPROCS 影响的是:

同一时刻最多有多少个 P 在执行 Go 代码

通常可以近似理解为 Go 程序使用 CPU 并行能力的上限。

是否越大越好

不是。

如果设置过大,可能导致:

  • 调度开销增加
  • CPU 上下文切换增加
  • 缓存命中率下降
  • 性能不升反降

一般建议:

场景 建议
CPU 密集型 接近 CPU 核心数
I/O 密集型 默认值通常足够,压测后再调整
容器环境 注意 CPU quota,建议使用 Go 新版本或自动适配库
不确定 先使用默认值

工程中不要凭感觉修改 GOMAXPROCS,应该通过压测和监控验证。


高并发工程优化实践

理解 GMP 的目的,不是为了背概念,而是指导实际优化。

1. 减少频繁内存分配

高并发下,大量对象创建会增加 GC 压力。

不推荐:

func Handle() {
	buf := make([]byte, 0, 1024)
	_ = buf
}

如果是高频临时对象,可以使用 sync.Pool

var bufferPool = sync.Pool{
	New: func() any {
		buf := make([]byte, 0, 1024)
		return &buf
	},
}

func Handle() {
	bufPtr := bufferPool.Get().(*[]byte)
	defer bufferPool.Put(bufPtr)

	buf := (*bufPtr)[:0]
	_ = buf
}

适合使用 sync.Pool 的对象:

  • bytes.Buffer
  • 临时切片
  • 编解码临时对象
  • 高频请求中的临时结构体

注意:sync.Pool 不是缓存,也不是连接池,里面的对象可能被 GC 清理。

2. 预分配切片容量

不推荐:

var list []int
for i := 0; i < 10000; i++ {
	list = append(list, i)
}

推荐:

list := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
	list = append(list, i)
}

好处:

  • 减少扩容次数
  • 减少内存拷贝
  • 降低 GC 压力
  • 提升高并发下稳定性

3. 大结构体使用指针传递

不推荐:

func Process(order Order) {
	// Order 很大时会产生拷贝
}

推荐:

func Process(order *Order) {
	// 避免大对象拷贝
}

但也不要所有对象都用指针。

简单小对象按值传递通常更清晰,逃逸到堆上反而可能增加 GC 压力。

4. 控制 Goroutine 数量

不推荐无限制创建 goroutine:

for _, id := range ids {
	go process(id)
}

如果 ids 很大,可能瞬间创建大量 goroutine,压垮数据库、Redis 或下游服务。

推荐使用并发限制:

func BatchProcess(ids []int64) {
	var wg sync.WaitGroup
	sem := make(chan struct{}, 20)

	for _, id := range ids {
		wg.Add(1)
		sem <- struct{}{}

		go func(orderID int64) {
			defer wg.Done()
			defer func() { <-sem }()

			process(orderID)
		}(id)
	}

	wg.Wait()
}

5. 避免 Goroutine 泄漏

常见泄漏场景:

  • channel 只读不写
  • channel 只写不读
  • for range ch 但 channel 永远不关闭
  • 后台 goroutine 没有退出条件
  • 超时后子 goroutine 仍然阻塞发送结果

推荐使用 context 控制生命周期:

func worker(ctx context.Context, jobs <-chan int) {
	for {
		select {
		case job, ok := <-jobs:
			if !ok {
				return
			}
			process(job)
		case <-ctx.Done():
			return
		}
	}
}

6. I/O 操作设置超时

网络请求、数据库查询、Redis 操作都应该有超时控制。

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := db.WithContext(ctx).Where("id = ?", id).First(&order).Error

不要让一个慢请求无限占用资源。

7. Channel 用于通信,Mutex 用于保护共享数据

不要机械地认为 channel 一定比锁好。

场景 推荐
goroutine 之间传递任务 channel
goroutine 之间传递结果 channel
保护共享 map sync.RWMutexsync.Map
简单计数器 sync/atomic
复杂状态修改 sync.Mutex

核心判断:

通信优先 channel,共享状态保护优先锁。

常见问题与避坑指南

1. Goroutine 很轻量,是否可以无限创建

不可以。

goroutine 虽然轻量,但不是没有成本。

每个 goroutine 至少需要:

  • 栈空间
  • G 结构体
  • 调度开销
  • 可能占用外部资源

工程中要根据下游承载能力限制并发数量。

2. GOMAXPROCS 是不是越大越好

不是。

GOMAXPROCS 设置过大可能增加调度成本。

通常先使用默认值,再通过压测决定是否调整。

3. 为什么高并发下 GC 压力会变大

因为并发请求越多,单位时间内创建的对象越多。

对象越多,GC 扫描和回收压力越大。

优化方向:

  • 减少临时对象
  • 复用对象
  • 预分配容量
  • 避免不必要的字符串拼接
  • 减少逃逸到堆上的对象

4. 为什么 channel 会导致 goroutine 泄漏

channel 的发送和接收可能阻塞。

例如:

ch := make(chan int)

go func() {
	ch <- 1
}()

如果永远没人接收,子 goroutine 会永远阻塞。

5. CPU 密集型任务如何优化

CPU 密集型任务主要受 CPU 核心数量限制。

建议:

  • GOMAXPROCS 接近 CPU 核心数
  • 避免创建远超 CPU 核心数的大量计算 goroutine
  • 拆分任务时控制并发度
  • 使用 pprof 分析 CPU 热点

6. I/O 密集型任务如何优化

I/O 密集型任务主要瓶颈在网络、磁盘、数据库、下游服务。

建议:

  • 设置超时
  • 使用连接池
  • 控制并发数量
  • 避免慢请求堆积
  • 做限流、熔断、降级
  • 使用 pprof 和 trace 观察阻塞点

面试重点总结

1. GMP 分别是什么

G:Goroutine,协程,用户代码执行单元
M:Machine,操作系统线程,真正执行代码
P:Processor,逻辑处理器,负责调度 G

2. M 为什么必须绑定 P 才能执行 G

因为 P 维护了运行队列和调度资源。

M 只有绑定 P 后,才能从 P 的本地队列中获取 G 并执行。

3. Work Stealing 是什么

当某个 P 没有可运行的 G 时,会随机从其他 P 的本地队列中偷取一部分 G 执行。

目的是提升负载均衡和 CPU 利用率。

4. Hand Off 是什么

当 G 执行阻塞系统调用时,M 会带着 G 阻塞,同时释放 P。

P 会绑定其他 M 继续执行其他 G,避免 P 被浪费。

5. Go 如何避免某个 Goroutine 长时间霸占 CPU

Go 1.14 后引入了基于信号的异步抢占机制。

sysmon 会监控运行时间过长的 G,并尝试抢占它,让其他 G 有机会执行。

6. Netpoller 的作用是什么

Netpoller 用于管理网络 I/O。

当网络 I/O 没有准备好时,runtime 会挂起当前 G,让 M 去执行其他 G。

网络事件就绪后,再把对应 G 放回可运行队列。

7. 高并发 Go 服务怎么优化

常见方向:

  • 控制 goroutine 数量
  • 设置请求超时
  • 避免 goroutine 泄漏
  • 减少内存分配
  • 使用 sync.Pool 复用对象
  • 预分配切片容量
  • 合理使用 channel、mutex、atomic
  • 使用 pprof、trace 定位性能瓶颈

Go 的高并发能力不是魔法,而是建立在 GMP 调度模型、轻量级 goroutine、netpoller、工作窃取、系统调用交接和抢占式调度之上。写好高并发 Go 程序,不仅要会 go func(),更要理解 goroutine 的生命周期、调度机制、资源限制和工程化优化方法。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐