最近在学习Golang,所以将学习过程记为笔记,以后翻阅的时候也方便,顺便也给大家做一点分享,希望能坚持下去。
免费GoLand激活码:https://web.52shizhan.cn
学习与交流:Go语言技术交流微信群


在现代后端架构中,高并发处理能力是衡量一门编程语言生态的核心指标。传统的 Java、C++ 等语言多采用内核级线程模型,其并发能力受限于操作系统线程的创建成本与上下文切换开销。
Go 语言则独辟蹊径,其核心优势在于引入了极其轻量级的 Goroutine,并在 Runtime(运行时)层自主实现了一套高效的调度器。
这种在用户态完成的线程复用技术,正是 Go 能够轻松支撑百万级并发流的核心秘密。
一、 核心基石:解耦并发的 GMP 模型
Go 调度器经历了从最初的 G-M 模型(由于全局锁竞争严重导致扩展性极差)到现代 GMP 模型的演进。
理解 GMP 分别代表的抽象实体,是通往高阶 Gopher 的必经之路。
1. 三大核心实体的职责划分
- G (Goroutine)
:代表一个 Go 协程。它不是 OS 线程,而是一个用户态的并发执行单元。每个 G 内部包含了执行的栈、程序计数器(PC)以及当前正在等待的 channel 等上下文信息。G 的初始栈仅为 2KB,并随运行需求动态扩缩容。 - M (Machine)
:代表一个真正的操作系统内核线程。它是代码最终在 CPU 上执行的宿主。M 不保存具体的 Go 代码执行状态,它只负责执行具体的指令。M 的数量通常略多于 P,以便在 M 发生阻塞(如系统调用)时,能够创建新的 M 来接管 P。 - P (Processor)
:代表逻辑处理器,是调度器中最核心的抽象。P 包含了运行 Goroutine 所需的上下文环境与本地运行队列。M 必须绑定一个 P 才能执行 G 的代码。 P 的数量决定了系统能够真正并行(Parallelism)执行的 M 的上限,默认等于 CPU 核心数,可通过环境变量 GOMAXPROCS动态调整。
2. 线程与协程的全面对比
| 内存占用 | ||
| 创建/销毁成本 | ||
| 上下文切换开销 | ||
| 调度方 |
二、 动态平衡:调度器的三大核心机制
为了在多核 CPU 上实现低延迟、高吞吐的调度表现,Go 调度器内部运转着三大精妙的动态平衡算法:
1. 工作窃取(Work Stealing)
在 GMP 模型中,每个 P 维护着一个包含最多 256 个 G 的本地运行队列(Local Queue)。当某个 P 的本地队列被全部执行完毕,且全局运行队列(Global Queue)也为空时,为了防止当前绑定的 M 进入空闲休眠状态,该 P 会启动工作窃取机制:随机挑选另一个 P,并尝试从其本地队列的尾部“窃取”一半的 G 来填补自己的队列,从而实现多核 CPU 的完美负载均衡。
2. 异步信号抢占机制(Preemptive Scheduling)
在早期的 Go 版本(1.12 之前)中,Go 采用的是协作式抢占。如果一个 G 内部是一个没有函数调用的死循环(例如 for {}),调度器将永远无法将其切走,从而导致该 M 被死死卡住。
自 Go 1.14 起,引入了基于操作系统的异步信号抢占机制。系统监控线程(sysmon)会定期巡检,一旦发现某个 G 连续运行超过 10ms,就会向该 G 所在的 M 发送一个 SIGURG 信号。
M 收到信号后会触发信号处理函数,强制保存当前 G 的上下文,并将其移回全局队列,从而彻底终结了“流氓协程”对 CPU 资源的独占。
3. 系统调用隔离(Hand Off 机制)
当 Go 程序发起一个阻塞式的内核级系统调用(如同步读取大文件、等待网络套接字等)时,执行该代码的 G 会与 M 一起陷入阻塞。
此时,调度器为了不让绑定的 P 跟着一起闲置,会实施 Hand Off(剥离分离) 策略:将 P 与当前阻塞的 M 强行解绑,然后从线程休眠队列中唤醒或重新创建一个干净的 M 来接管这个 P。
当原先的系统调用结束后,旧的 M 会尝试重新寻找一个空闲的 P,若找不到,则将 G 扔进全局队列,自己则进入休眠。
三、 深度漫游:Go Runtime 核心源码拆解
了解了理论,我们直接下潜到 Go 官方 src/runtime/proc.go 的底层源码中,一窥调度器的真实编码实现。
1. 调度引擎的起点:`schedinit`
当 Go 程序启动时,主协程尚未运行前,底层会率先调用 schedinit 函数完成运行时的基础初始化。
// src/runtime/proc.gofuncschedinit() {// 1. 获取当前主线程 M0 _g_ := getg()// 2. 确立最大的逻辑处理器 P 的数量 procs := int(ncpu)if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 { procs = n }// 3. 动态调整并初始化 P 的链表与状态if procresize(procs) != nil { throw("unknown runnable goroutine during bootstrap") }}2. 协程孵化器:`newproc`
我们在业务代码里写下的 go func(),在编译期会被替换为对 runtime.newproc 的调用,它是诞生一个 G 的主入口。
// src/runtime/proc.gofuncnewproc(siz int32, fn *funcval) { argp := add(unsafe.Pointer(&fn), sys.PtrSize) gp := getg() pc := getcallerpc()// 切换到系统栈(g0 栈)去分配和配置新的 G systemstack(func() { newg := newproc1(fn, argp, siz, gp, pc)// 获取当前 M 绑定的 P _p_ := getg().m.p.ptr()// 将全新创建的 G 放入 P 的本地运行队列(如果本地满了,会自动移入全局) runqput(_p_, newg, true)// 如果主程序已经启动,尝试唤醒或创建空闲的 P/M 来加速处理if mainStarted { wakep() } })}3. 不息的轮转:`schedule` 调度主循环
M 在启动后,会死循环地执行 schedule 函数。该函数就像一个不眠不休的传送带,源源不断地寻找可以运行的 G 并将其推上执行台。
// src/runtime/proc.gofuncschedule() { _g_ := getg()if _g_.m.locks != 0 { throw("schedule: holding locks") }// 如果当前 M 被锁死到了某个特定的 G(如通过 LockOSThread 绑定)if _g_.m.lockedg != 0 { stoplockedm() execute(_g_.m.lockedg.ptr(), false) // 永不返回 }var gp *gvar inheritTime bool// 💡 避坑及防饥饿策略:每隔 61 次调度,必须优先去全局队列里取 G// 否则如果本地队列里的 G 过于活跃,会导致全局队列里的 G 发生局部“饿死”if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 { lock(&sched.lock) gp = globrunqget(_g_.m.p.ptr(), 1) unlock(&sched.lock) }// 2. 正常逻辑:优先从 P 的本地队列获取 Gif gp == nil { gp, inheritTime = runqget(_g_.m.p.ptr()) }// 3. 全力搜寻:如果本地队列没有,则进入深度搜寻机制if gp == nil {// findrunnable 函数内部包含了:// a. 再次尝试读取全局队列// b. 尝试从网络轮询器 (NetPoller) 获取就绪的异步 I/O 协程// c. 触发 Work Stealing 机制去其他 P 的队列尾部疯狂窃取 gp, inheritTime = findrunnable() }// 找到 G 后,切换上下文并正式在当前 M 上跑起来 execute(gp, inheritTime)}4. 异步强制切线:`preemptM`
当系统触发异步抢占时,最终会下发到 preemptM,通过发送特定的系统中断信号实现无感知的内核级切线。
// src/runtime/signal_unix.gofuncpreemptM(mp *m) {// 采用 CAS 原子操作确保不重复发送抢占状态if atomic.Cas(&mp.signalPending, 0, 1) {// 向该物理线程发送指定的抢占信号(通常为物理信号 SIGURG) signalM(mp, sigPreempt) }}四、 性能表现:调度器如何变现高并发?
基于这套复杂的底层设计,Go 语言在宏观上展现出了以下堪称恐怖的性能红利:
1. 将多核 CPU 的并行潜力榨干到极致
在多核服务器中,传统的独占式全局链表会导致严重锁竞争。Go 调度器将全局队列弱化,转而将并发负载分流到与核心数严格对应的每个 P 的本地队列中。在绝大多数情况下,M 与 P 的绑定操作都是无锁原子化的,极大地释放了多核硬件的计算威力。
2. 工业级的低延迟响应能力
得益于 Go 1.14 彻底落地的基于信号的异步抢占,任何涉及密集计算(如高负载的 JSON 编解码、加密算法)的协程,都无法死死霸占线程。
在宏观上,这保证了底层网络通道与 Web 服务的响应延迟能够始终维持在极低的平稳状态。
3. 高吞吐的网络 I/O 支持
Go 调度器将网络轮询器(Net Poller)与调度循环进行了原生整合。当 Goroutine 遇到网络读写阻塞时,它会被脱离当前 P 并注册到 Net Poller 中。
P 则马上掉头去处理其他业务 G。一旦 OS 通知网络 I/O 准备就绪,Net Poller 会将该 G 重组回运行队列中。
这让开发者能够以极其简单的同步阻塞编码思维,写出性能足以媲美 epoll 异步回调的高吞吐网络应用。
五、 总结与最佳实践
Go 调度器是一个高度自动化、黑盒化的顶尖工业级工艺品。虽然绝大多数时候开发者无需关心其运转,但遵循以下两点最佳实践,可以让你的高并发代码更上一个台阶:
- 科学控制 `GOMAXPROCS`
:在现代云原生与容器化(Docker / Kubernetes)部署环境中,Go 默认读取的 CPU 核心数是宿主机的物理核。如果容器被限制了 CPU 配额(如 Limit = 2), 但 Go 误读取到了物理机 64 核,会导致创建出多余的 P 与 M,引发极度频繁且无意义的 OS 线程上下文切换。务必在微服务框架中引入组件(如 Uber 团队开源的 `automaxprocs`)来自动纠正容器限制。 - 避免在 Goroutine 中引发过长的非 I/O 密集型 CGO 调用
:由于 CGO 的执行会独立占用一个专属 M,且无法被 Go 的 sysmon轻松抢占,长时间的密集 CGO 可能会导致大量的 M 被创建,拖慢整体调度器的流转效率。
参考链接:
更多相关Go语言的技术文章或视频教程,请关注本公众号获取并查看,感谢你的支持与信任!
夜雨聆风