goroutine是go运行时管理的轻量级并发单元,采用m:n调度模型,初始栈仅2kb、可动态伸缩,切换成本低至200–500ns,通过channel通信而非共享内存,无需手动管理os线程。

Go 里根本不需要你手动管理线程;go 启动的 goroutine 已经替你做了绝大多数事——不是“协程比线程好”,而是 Go 的运行时把线程调度藏起来了,你只该关心 goroutine 和 channel。
goroutine 不是线程,也不运行在独立线程上
每个 goroutine 默认不绑定专属 OS 线程。Go 运行时用 M:N 调度模型:M 个 goroutine 映射到 N 个系统线程(M > N 很常见)。当某个 goroutine 阻塞在系统调用(如文件读写、网络收发)时,Go 会把它从当前线程剥离,让其他 goroutine 继续跑——你完全感知不到。
而你用 cgo 或 runtime.LockOSThread() 强制绑定的,才是真线程,但那属于边缘场景,99% 的业务代码不该碰。
- 误以为
go f()= 新建一个 OS 线程 → 实际只分配 ~2KB 栈空间,几纳秒就启动 - 看到
top里进程线程数(LWP)很少,却跑着 10 万个goroutine→ 正常,说明调度器在高效复用 - 用
strace -f跟踪 Go 程序,发现clone()系统调用远少于goroutine数量 → 验证了 M:N 模型
栈大小和内存开销差异直接决定并发上限
goroutine 初始栈仅 2KB,按需增长(最大约 1GB),且栈空间可回收;OS 线程栈默认 2MB(Linux)或 8MB(macOS),固定分配、无法收缩。
这意味着:100 万个 goroutine 内存占用约 2–4GB;同等数量的线程,仅栈就吃掉 2TB,早 OOM 了。
- 不要用
unsafe.Sizeof或runtime.Stack估算goroutine内存 → 它们反映的是当前栈使用量,不是固定开销 - 若某
goroutine频繁触发栈扩容(比如递归过深),runtime.ReadMemStats里StackInuse会异常升高 - 用
go tool pprof查goroutine泄漏时,重点看runtime.gopark卡住的位置,而非栈大小
上下文切换成本差出一个数量级
goroutine 切换是纯用户态操作,只保存/恢复 PC、SP、DX 三个寄存器,耗时 200–500ns;OS 线程切换需陷入内核、保存 16+ 寄存器、处理 TLB flush,耗时 1000–1500ns。
这个差距在高频率协作场景(比如大量短任务、事件循环)中会被放大。但注意:它只在频繁主动让出(select、channel 收发、time.Sleep)时体现;纯计算密集型 goroutine 可能长期霸占 P,根本切不走。
- 别用
for {}空转测试切换性能 → 这会让 G 永远不被调度,测的是抢占延迟,不是切换成本 -
GOMAXPROCS设置过低(如1)时,即使有空闲 CPU,goroutine也无法并行 → 切换再快也无意义 - 真正影响响应延迟的,往往是阻塞系统调用是否被
netpoll异步化,而不是 goroutine 切换本身
通信方式不同,本质是编程范式切换
线程靠共享内存 + 锁(mutex、RWMutex)同步;goroutine 靠 channel 和 select 通信。这不是语法糖差异,而是避免竞态的设计约束。
Go 编译器甚至会对未使用的 channel 做死锁检测(fatal error: all goroutines are asleep - deadlock),但对裸指针共享毫无提示。
- 用
sync/atomic直接操作变量 → 可以,但绕过了 Go 的通信哲学,容易出错 - 多个
goroutine写同一个map不加锁 → 运行时报fatal error: concurrent map writes,不是随机崩溃 - 把
channel当队列用却忘了关闭 → 接收方永远阻塞,goroutine泄漏
真正难的不是理解“协程轻量”,而是放弃“我要控制线程”的直觉——Go 的并发模型要求你把逻辑拆成可暂停、可传递消息的小单元,而不是靠加锁保护一块内存。这点一旦没转过来,再多的 go 关键字也救不了架构。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











