白话 Golang - Golang 能够创建百万协程?

23 阅读3分钟

前言

一些理论,说 Golang 能够轻松创建百万协程。这准确吗?

G的特点

  • 初始化资源占用小。G 描述符大约 500 字节,加上2K(基于go1.22)的栈,就算是2.5K,百万G在仅仅计算初始空间的情况下,顶多占用 3G内存。
  • 依据 GMP 模型,必须挂在在 M 上才能被执行。同一时刻的并发度取决于 P 的数量。
  • 基于信号的调度机制,每隔10ms就会被调度一次。
  • 只有 IO 密集型才能发挥威力。CPU 密集型开多协程/线程没有意义。

这存在哪些问题呢?

调度延迟问题

  • 这是一个非常核心的问题。Go 调度器为了公平性,会对运行超过 10ms 的协程进行抢占。但在百万级别下,这种“轮询”一遍的周期会显著拉长。可以进行假设,百万 G,10 P 的情况下,一个 G 最坏的情况下,就是被 1000s 才能被调度到一次。进一步的分析,假如 90% 的 G都在阻塞队列中,只剩下 10% 在 Runable队列,那么一个 G 仅仅是等待调度时间就需要 10S。这几乎是不能接受的延迟了。

  • 这其实是 GMP 模型里面一个很重要的设计,就是没有计算 G 调度队列的公平性。仅仅是按照队列的顺序执行,如果需要计算队列 G 的公平性,那么 CPU 的消耗会更高,这也是比 线程 高效很重要的一点。

  • 此时,就会看到 系统本身在不断运行,但是某些 G 的 Lantency 很长的情况。

内存占用问题

  • 如果 G 的栈出现了扩容的情况,那么内存占用就会直接上涨,GC 的压力很大。要知道,GC 的 Mark 阶段,是要扫描栈空间的,也就是最少会扫描 3 个 G 的内存。虽然 GC 的 CPU 限制在了 25%,或者有普通 G 辅助标记,但是在百万 G 的情况下,还要分出 CPU 来执行 GC,这本身就会导致 G 的调度延迟的进一步恶化。

  • GC 的 第一次STW 阶段,会有复制 G snapshot 的过程。此时虽然不会复制 G 的栈,但是仍然会拷贝百万个 G 描述符。G 的描述符的大小约 500 个字节,那么 100 万 G 的话,就会占用 500M 的内存。此时,STW 的时间就会与拷贝 500M 内存,会出现一个很明显个的卡顿。

使用场景问题

  • 这个问题没有必要多说。极简的 G,在理论上都已经出现了性能问题,那么在生产环境处理各项业务的情况下,哪能不出问题呢?

结论

我的看法是,百万 G 是 Golang 有这样的能力,但是不实用,所以这是伪命题。 要知道,Golang 的高并发,所谓的“高”,仅仅是说比进多进程、多线程占用的资源少些,调度的逻辑简单些,所以能创建的多些,但不是”无限”,也不是说开了高并发性能上仍然有保证。因为硬件的性能池是有限的。

不过 G 保持在 万 级别,是可以的。

那么一个运行环境,运行多少 G 比较合适呢?可以这样估算一下: (1S /一个 G 的 CPU 运行时间) * 10 * P 的个数。

基本就是一个理论上合理的预估了。

注意: 这并不是说使用Go就毫无意义。因为在同样数量的协程、线程的下,协程消耗的资源更少。如果业务体量足够,那么哪怕节省 10% 的资源,也是一笔巨大的财富了。