Go 程序运行在用户态,但读写文件、网络通信、创建进程等操作必须请求内核服务——这就是系统调用(syscall)。Go 的系统调用机制与 C 语言截然不同:它不是简单地调用 libc,而是通过汇编直接触发 syscall 指令,同时与调度器深度集成,确保阻塞在系统调用上的 M 不会浪费 CPU。
本文要点
- 系统调用的基本原理:用户态 → 内核态 → 用户态
- Go 的两种系统调用:entersyscall 和 entersyscall_block
- 系统调用期间的调度器行为
- sysmon:后台监控线程
- 与 P 的解绑与重绑
- 常见系统调用的实现
- cgo 与系统调用的关系
系统调用基本原理
用户态与内核态
Linux 系统调用的两种方式
| 方式 | 指令 | Go 使用 |
|---|---|---|
int 0x80 | 软中断 | 已弃用 |
syscall | 专用指令(x86-64) | 使用 |
Go 直接使用 SYSCALL 汇编指令,不经过 libc:
TEXT runtime·sysRawSyscall(SB),NOSPLIT,$0-56 MOVQ a1+8(FP), DI MOVQ a2+16(FP), SI MOVQ a3+24(FP), DX MOVQ trap+0(FP), AX // 系统调用号 SYSCALL // 触发系统调用 MOVQ AX, r1+32(FP) MOVQ $0, r2+40(FP) RETGo 的两种系统调用模式
Go 区分两种系统调用场景:
1. 可抢占的系统调用(entersyscall)
用于可能短暂阻塞的系统调用(如 read/write 超时设置):
// src/runtime/proc.go (简化版)func entersyscall() { gp := getg() mp := gp.m
// 保存当前 PC 和 SP(用于栈扫描) gp.syscallpc = getcallerpc() gp.syscallsp = getcallersp()
// 将 P 设置为 _Psyscall 状态 mp.p.ptr().status = _Psyscall
// 让出 P,允许其他 M 使用 handoffp(mp.p.ptr())}2. 不可抢占的系统调用(entersyscall_block)
用于确定会长时间阻塞的系统调用(如 futex wait):
func entersyscall_block() { gp := getg() mp := gp.m
// 直接让出 P mp.p.ptr().status = _Psyscall handoffp(mp.p.ptr()) // 立即交出 P}区别:entersyscall 给调度器一个”快速路径”——如果系统调用很快返回,可以直接重新绑定 P,避免 handoff 的开销。entersyscall_block 直接交出 P,不尝试快速路径。
系统调用期间的调度器行为
P 的状态转换
sysmon:后台监控线程
sysmon 是一个特殊的 M(不绑定 P),负责监控和抢夺:
// src/runtime/proc.go (简化版)func sysmon() { for { usleep(100) // 初始检查间隔 100μs
// 1. 抢夺长时间阻塞在系统调用的 P retake(now)
// 2. 强制 GC(如果超过 2 分钟没 GC) forcegcperiod = 2 * 60 * 1e9 if lastgc + forcegcperiod < now { forcegchelper() }
// 3. 网络轮询(netpoll) list := netpoll(0) injectglist(list) }}retake:抢夺 P
func retake(now int64) uint32 { for i := 0; i < len(allp); i++ { pp := allp[i]
switch pp.status { case _Psyscall: // 如果 P 在系统调用中超过 10ms,抢夺 if pp.syscalltick == pp.schedtick && now-pp.syscallwhen > 10*1000*1000 { handoffp(pp) }
case _Prunning: // 如果 P 运行超过 10ms,发送抢占信号 if pp.schedwhen+10*1000*1000 < now { preemptone(pp) } } }}exitsyscall:从系统调用返回
func exitsyscall() { gp := getg() mp := gp.m
// 快速路径:尝试重新绑定原 P pp := mp.p.ptr() if pp.status == _Psyscall && atomic.Cas(&pp.status, _Psyscall, _Prunning) { // 成功!无需 handoff return }
// 慢速路径:原 P 已被抢夺 // 尝试获取空闲 P pp = pidleget() if pp != nil { mp.p = pp pp.status = _Prunning return }
// 没有 P 可用,将 G 放入全局队列 gp.m.p = 0 gp.m.mput() // M 休眠 schedule() // 重新调度}常见系统调用的实现
文件 I/O
// os/file.go → syscall.Read()func Read(fd int, p []byte) (n int, err error) { n, err = read(fd, p) return}
// 底层调用 runtime.entersyscall → SYS_read → runtime.exitsyscall网络 I/O(非阻塞)
Go 的网络 I/O 使用非阻塞系统调用 + netpoll,不会阻塞 M:
func (fd *netFD) Read(p []byte) (n int, err error) { // 设置非阻塞 syscall.SetNonblock(fd.Sysfd, true)
// 尝试读取 n, err = syscall.Read(fd.Sysfd, p) if err == syscall.EAGAIN { // 没有数据,将 goroutine 挂起 fd.pd.WaitRead() // netpoll 唤醒后重试 }}futex:Go 的主要同步原语
Go runtime 内部使用 Linux futex 实现互斥锁和条件变量:
func futex(addr unsafe.Pointer, op int32, val uint32, ts, addr2 unsafe.Pointer, val3 uint32) int32 { return sysfutex6(addr, op, val, ts, addr2, val3)}常见问题 FAQ
Q1:Go 为什么不用 libc 的 syscall 包装?
三个原因:(1) 避免依赖 libc(Go 静态链接);(2) libc 的包装有额外开销(errno 处理、信号检查);(3) Go 需要与调度器深度集成(entersyscall/exitsyscall)。
Q2:系统调用会阻塞整个线程吗?
会阻塞 M(操作系统线程),但不会阻塞 P(逻辑处理器)。entersyscall 将 P 解绑,允许其他 M 使用这个 P 运行 goroutine。
Q3:sysmon 的抢夺阈值为什么是 10ms?
10ms 是经验值。太短会导致频繁抢夺(增加开销),太长会导致系统调用阻塞期间 P 空闲。10ms 在大多数场景下是合理的平衡点。
Q4:cgo 调用和系统调用有什么区别?
cgo 调用会切换到 C 栈,有额外的栈切换开销。cgo 调用期间,M 的 g0 栈被使用,当前 goroutine 被挂起。系统调用则直接在 goroutine 栈上执行。
Q5:如何查看 Go 程序的系统调用?
# 使用 strace 跟踪$ strace -c ./myprogram
# 使用 Go 的执行追踪器$ go tool trace trace.out小结
- Go 直接使用 SYSCALL 指令,不经过 libc
- 两种系统调用模式:可抢占(entersyscall)和不可抢占(entersyscall_block)
- 系统调用期间 P 解绑,允许其他 M 继续运行 goroutine
- sysmon 后台监控:超过 10ms 的系统调用会被抢夺 P
- exitsyscall 快速路径:如果原 P 还在 _Psyscall 状态,直接重新绑定
- 网络 I/O 使用非阻塞 + netpoll,不会阻塞 M
参考资料
- Go Runtime Source: proc.go — entersyscall/exitsyscall/retake
- Go Runtime Source: sys_linux_amd64.s — 系统调用汇编
- Go Runtime Source: os_linux.go — sysmon
- Linux syscalls(2) — 系统调用列表
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






