
Goroutine 是 Go 语言的灵魂,也是它能够在云原生和高并发领域大杀四方的核心武器。
在 C++ 中,我们处理并发通常有两条路:要么使用 std::thread(直接映射为操作系统内核线程,1:1 模型),要么自己基于 epoll 编写复杂的 Reactor 异步事件循环(将代码割裂成大量回调)。
Goroutine 提供了一种极具颠覆性的体验:它允许你用完全同步的、线性的代码风格,写出底层其实是极高并发的、非阻塞的程序。
一、 Goroutine 为什么能开十万个?(物理特性)
在 Linux 系统中,创建一个标准的 OS 线程(如 C++ 的 std::thread)是很"昂贵"的:
-
内存开销大: 默认需要分配 2MB 到 8MB 的固定栈空间。即便你只跑一个很小的函数,这块内存也占用了。10万个线程就是 200GB 内存,直接 OOM。
-
上下文切换重: 线程切换需要陷入内核态,保存大量的寄存器状态,且会导致 CPU L1/L2 缓存失效,一次切换的耗时大约在 1~2 微秒。
Goroutine 则是极其轻量级的"用户态协程":
-
动态收缩的栈: 一个 Goroutine 初始只分配极小的 2KB 栈空间。随着你的函数调用层级加深,Go 运行时(Runtime)会自动帮它动态扩容(按需增长,最大可达 1GB)。因此,在普通服务器上轻松开启十万级 Goroutine 毫无内存压力。
-
极速切换: Goroutine 的调度和切换完全在用户态进行,不需要进内核。切换时只需保存极少数的寄存器(如 PC 指令寄存器、SP 栈指针),耗时在 200 纳秒左右,比 OS 线程快了一个数量级。
二、 核心大脑:GMP 调度模型 (M:N 模型)
Goroutine 是由 Go 语言自带的 Runtime 调度的,而不是由操作系统直接调度的。这就引出了 Go 最核心的 GMP 模型。
-
G (Goroutine): 待执行的任务。它包含了执行的函数指针、自己的栈、以及当前的状态等信息。
-
M (Machine): 操作系统底层的真实物理线程(OS Thread)。CPU 最终执行的指令都必须在 M 上运行。
-
P (Processor): 逻辑处理器。它的数量默认等于你机器的 CPU 核心数(由
GOMAXPROCS决定)。P 的本质是一个带有本地 Goroutine 队列的执行上下文。
为什么必须要有 P?
早期的 Go 只有 G 和 M,所有的 G 都在一个全局队列里。每次 M 要取任务,都必须加一把全局大锁。多核 CPU 下,极其严重的锁竞争导致性能断崖式下跌。 P 的引入,就是为了"无锁化"。每个 P 都有一个自己的"本地队列"(最多存 256 个 G)。M 只要绑定了 P,就可以直接从本地队列无锁地取出 G 来执行,极大地减少了争抢。

三、 保证高并发的两大调度机制
有了 GMP,Go Runtime 是如何应对各种突发情况,保证 CPU 永远不闲着的呢?
1. Work Stealing (工作窃取机制)
如果 M2 绑定的 P2 本地队列里的 G 全执行完了,M2 难道就闲着吗? 不会。P2 会先去全局队列里找任务;如果全局队列也是空的,P2 就会化身"小偷",悄悄去别的逻辑处理器(比如 P1)的本地队列里,直接偷走一半的 G 放到自己的队列里继续执行。这保证了多核 CPU 的负载绝对均衡。

2. Hand Off (交接机制)
如果当前正在执行的 G1 突然发起了一个阻塞的系统调用(比如读取磁盘大文件),导致底层线程 M1 被操作系统挂起了(阻塞),此时 P 队列里还有其他几十个 G 怎么办? Go Runtime 监控到这个情况后,会极其果断地将 P 与阻塞的 M 解绑 。然后唤醒一个新的休眠线程M3(或者创建一个新线程),把 P 和这个新线程绑定,继续执行队列里的后续 G。当那个阻塞的文件读写完成后,原来的 G 会被放回队列排队,原来的 M 会进入休眠池。


四、 底层揭秘:网络 I/O 为什么不阻塞 M?
在系统编程中,网络 I/O 是最容易引发阻塞的。如果在 Go 里写 conn.Read() 导致 M 阻塞进而触发上述的 Hand Off 不断创建新线程,那性能早就崩塌了。
这里就是 Go 和底层系统调用(如 epoll)完美结合的地方:Go 内部集成了一个极其隐秘的组件------Netpoller。
当你对一个网络套接字调用读写时:
-
底层并不真正做阻塞系统调用。
-
Go Runtime 会将这个文件描述符(fd)注册到它自己维护的
epoll实例中。 -
接着,把当前的 Goroutine 标记为休眠状态,并让出 M 去执行别的 G。(此时底层线程 M 没有任何阻塞!)
-
当内核通过
epoll_wait发现这个 fd 的数据准备好了,Go Runtime 会自动把刚刚那个休眠的 G 唤醒,塞回 P 的队列中继续执行后续逻辑。
总结来说: 你写在代码里的同步阻塞网络请求,在底层全被 Go Runtime 偷偷拦截,并通过 epoll 转换成了异步非阻塞的操作。这就是为什么在 Go 中,我们只需要开门见山地写 Read 和 Write,却能获得和 C++ 中编写复杂状态机驱动的 epoll 服务器一样恐怖的高并发性能。
五、运行go fun()方法背后逻辑

六、M0和G0

1、 M0:主线程 (The Main Thread)
在 C++ 中,当操作系统加载并启动一个二进制可执行文件时,会默认创建一个主线程来执行 int main()。在 Go 中,这个主线程就是 M0。
-
全局唯一: M0 是进程启动时由操作系统内核创建的第一个真实物理线程(Thread 0)。
-
生命周期: 它伴随整个 Go 进程的生命周期,M0 死了,整个进程就退出了。
-
核心职责:
-
负责执行 Go Runtime 的初始化操作(比如初始化内存分配器、垃圾回收器、调度器等)。
-
启动第一个真正的用户态 Goroutine(即负责运行你代码中
main.main()的那个 G)。 -
在完成上述"创世"工作后,M0 就会"泯然众人",和其他普通的 M 一样,绑定一个 P,开始去队列里取 G 来执行。
-
2、 G0:调度栈 (The Scheduling Stack)
这是极其关键,但也极其容易被误解的概念。 普通的 G 分配在堆上(初始 2KB),而 G0 使用的是底层操作系统线程的真实 C 语言系统栈(在 Linux 上通常是 8MB)。
-
数量: 每一个 M(包括 M0)都有一个属于自己的 G0。 如果有 10 个 M,就有 10 个 G0。
-
不执行业务代码: G0 永远不会执行你写的任何 Go 代码。它里面跑的全是 Go Runtime 的底层调度逻辑。
-
核心职责: 在 C++ 中,线程上下文切换是由操作系统内核强行中断并保存寄存器完成的。而在 Go 的用户态调度中,必须有一段代码来主动执行"保存 G1 状态 -> 选择 G2 -> 恢复 G2 状态"的操作。这段代码运行在哪里?就运行在 G0 上。
六、GMP可视化调试

