mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1994 字
6 分钟
Go 系统调用机制:从用户态到内核态的穿越
2023-08-17

Go 程序运行在用户态,但读写文件、网络通信、创建进程等操作必须请求内核服务,这就是系统调用(syscall)。Go 的系统调用机制与 C 语言截然不同:它通过汇编直接触发 syscall 指令,同时与调度器深度集成,确保阻塞在系统调用上的 M 不会浪费 CPU。

系统调用基本原理#

用户态与内核态#

graph TD subgraph "用户态(User Space)" U1["Go 程序"] U2["用户代码"] U3["runtime(大部分)"] end subgraph "内核态(Kernel Space)" K1["文件系统"] K2["网络协议栈"] K3["进程管理"] K4["内存管理"] end U1 --> |"syscall 指令"| K1 U2 --> |"syscall 指令"| K2 U3 --> |"syscall 指令"| K3 K1 --> |"返回"| U1 K2 --> |"返回"| U2 style K1 fill:#F44336,color:#fff style K2 fill:#F44336,color:#fff style K3 fill:#F44336,color:#fff style K4 fill:#F44336,color:#fff

Linux 系统调用的两种方式#

方式指令Go 使用
int 0x80软中断已弃用
syscall专用指令(x86-64)使用

Go 直接使用 SYSCALL 汇编指令,不经过 libc:

src/runtime/sys_linux_amd64.s
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)
RET

源码位置:runtime/sys_linux_amd64.s

Go 的两种系统调用模式#

Go 区分两种系统调用场景:

1. 可抢占的系统调用(entersyscall)#

用于可能短暂阻塞的系统调用(如 read/write 超时设置):

// src/runtime/proc.go (简化版)
func entersyscall() {
gp := getg()
mp := gp.m
// 保存当前 PC 和 SP(用于栈扫描)
gp.syscallpc = getcallerpc()
gp.syscallsp = getcallersp()
casgstatus(gp, _Grunning, _Gsyscall)
// 把 P 标记为 _Psyscall,但不立即 handoff
// sysmon 会在 10ms 后检查,仍未返回才触发 handoffp
pp := mp.p.ptr()
mp.p = 0
mp.oldp.set(pp)
atomic.Store(&pp.status, _Psyscall)
}
flowchart TD A["goroutine 发起 read()"] --> B["entersyscall()"] B --> C["P 状态 → _Psyscall"] C --> D["M 与 P 解绑"] D --> E["调度器:将 P 分配给空闲 M"] E --> F["其他 goroutine 继续运行"] F --> G["read() 返回"] G --> H["exitsyscall()"] H --> I{"原 P 还可用?"} I --> |"是"| J["重新绑定原 P"] I --> |"否"| K["获取空闲 P 或挂起 G"] style D fill:#FF9800,color:#fff style F fill:#4CAF50,color:#fff

2. 不可抢占的系统调用(entersyscall_block)#

用于确定会长时间阻塞的系统调用(如 futex wait):

func entersyscall_block() {
gp := getg()
mp := gp.m
// 保存状态、G 标记为 _Gsyscall(同 entersyscall)
save(mp.syscallpc, mp.syscallsp)
casgstatus(gp, _Grunning, _Gsyscall)
// 直接让出 P,立即 handoff(不等 sysmon)
pp := mp.p.ptr()
mp.p = 0
mp.oldp.set(pp)
atomic.Store(&pp.status, _Psyscall)
handoffp(pp)
}

区别:entersyscall 给调度器一个”快速路径”,如果系统调用很快返回,可以直接重新绑定 P,避免 handoff 的开销。entersyscall_block 直接交出 P,不尝试快速路径。

系统调用期间的调度器行为#

P 的状态转换#

stateDiagram-v2 [*] --> _Pidle : P 空闲 _Pidle --> _Prunning : M 绑定 P _Prunning --> _Psyscall : entersyscall _Psyscall --> _Prunning : exitsyscall(快速路径) _Psyscall --> _Pidle : sysmon 抢夺 _Pidle --> _Prunning : 另一个 M 获取 _Prunning --> _Pidle : M 让出 P

sysmon:后台监控线程#

sysmon 是一个特殊的 M(不绑定 P),负责监控和抢夺:

// src/runtime/proc.go (简化版)
func sysmon() {
delay := uint32(20) // 初始轮询间隔,约 20μs 起步
for {
usleep(delay)
// 轮询间隔会逐步增大(大致指数增长),最终收敛到 10ms 上限,
// 避免空闲时高频轮询浪费 CPU
delay = nextDelay(delay)
// 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 已被抢夺,切换到 g0 栈处理(此处简化)
mcall(exitsyscall0)
}
// exitsyscall0 在 g0 栈上执行
func exitsyscall0(gp *g) {
mp := gp.m
mp.p = 0
// 尝试获取空闲 P
pp := pidleget()
if pp != nil {
mp.p.set(pp)
casgstatus(gp, _Gsyscall, _Grunning)
gogo(&gp.sched) // 切回 G 继续执行
}
// 没有 P 可用:把 G 放入全局队列,M 进入空闲列表
casgstatus(gp, _Gsyscall, _Grunnable)
globrunqput(gp)
mput(mp) // 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:

net/fd_posix.go
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 内部的互斥锁(sync.Mutex)和条件变量都基于 Linux futex 实现。futex(Fast Userspace muTEX)的特点是:无竞争时完全在用户态完成加解锁(只需一次原子操作),只在需要等待时才陷入内核:

src/runtime/sys_linux.go
func futex(addr unsafe.Pointer, op int32, val uint32, ts, addr2 unsafe.Pointer, val3 uint32) int32 {
return sysfutex6(addr, op, val, ts, addr2, val3)
}

Go 的 runtime.lock/runtime.unlock 在无竞争时只做一次 CAS,不触发系统调用;竞争时调用 futex(FUTEX_WAIT) 将当前 M 挂起,解锁方调用 futex(FUTEX_WAKE) 唤醒等待者。

vDSO:不经过 SYSCALL 的”系统调用”#

Linux 内核将部分只读数据通过 vDSO(virtual Dynamic Shared Object)映射到每个进程的地址空间,Go 可以像调用普通函数一样读取这些数据,完全不需要 SYSCALL 指令,不需要切换到内核态。

Go 使用 vDSO 的场景#

最重要的场景是 clock_gettime。Go 的 time.Now() 底层调用 runtime.nanotime,而 nanotime 在 Linux 上优先从 vDSO 获取时间:

src/runtime/sys_linux_amd64.s
// nanotime1 调用 vDSO 中的 clock_gettime
TEXT runtime·nanotime1(SB),NOSPLIT,$0-8
MOVQ runtime·vdsoClockgettimeSym(SB), AX
CMPQ AX, $0
JEQ fallback
// 调用 vDSO 中的 clock_gettime(用户态函数调用,无 SYSCALL)
CALL AX
RET
fallback:
// 回退到系统调用
MOVQ $228, AX // SYS_clock_gettime
SYSCALL
RET

vDSO 的初始化在程序启动时完成,runtime 解析 ELF 的 auxiliary vector,找到 vDSO 的映射地址和符号表,把 clock_gettime 的地址缓存到 vdsoClockgettimeSym 变量中:

源码:runtime/vdso_linux_amd64.go

vDSO 的性能差异#

实测对比(Go 1.25,Linux amd64):

方式耗时
vDSO clock_gettime~20ns/call
SYSCALL clock_gettime~40-80ns/call
time.Now()(Go,走 vDSO)~25ns/call

vDSO 让 time.Now() 的开销从微秒级降到纳秒级,对高频时间戳采集(如 tracing、metrics)影响显著。

RawSyscall vs Syscall:三种系统调用封装#

Go 的系统调用封装有三个版本,区别在于是否与调度器协作:

封装调度器协作用途
Syscall / Syscall6entersyscall/exitsyscall大多数系统调用
RawSyscall / RawSyscall6不通知调度器极短、不会阻塞的调用
syscall 汇编直接调用不通知调度器runtime 内部极少数场景
// Syscall: 与调度器协作
TEXT ·Syscall(SB),NOSPLIT,$0-56
CALL runtime·entersyscall(SB) // 通知调度器
MOVQ trap+0(FP), AX
SYSCALL
CALL runtime·exitsyscall(SB) // 通知调度器
RET
// RawSyscall: 不通知调度器
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
MOVQ trap+0(FP), AX
SYSCALL // 直接调用,不与调度器交互
RET

RawSyscall 用于那些”确定不会阻塞”的系统调用(如 getpidsched_getaffinity),省去了 entersyscall/exitsyscall 的开销(大约 40-100ns)。但如果 RawSyscall 意外阻塞(如内核 bug),M 会被卡住,P 不会解绑,sysmon 也不会抢夺,后果比正常系统调用更严重。Go 在标准库中只在极少数确定的场景使用 RawSyscall。

cgo 调用的系统调用路径#

cgo 调用与纯 Go 系统调用有本质区别,它涉及更多上下文切换:

flowchart TD A["Go 代码调用 C 函数"] --> B["cgocall()"] B --> C["切换到 g0 栈"] C --> D["保存 goroutine 调度上下文"] D --> E["entersyscall()"] E --> F["切换到 C 栈(fnargs)"] F --> G["调用 C 函数"] G --> H["C 函数可能调用 libc syscall"] H --> I["C 函数返回"] I --> J["exitsyscall()"] J --> K["恢复 goroutine 调度上下文"] K --> L["切换回 goroutine 栈"] L --> M["Go 代码继续执行"] style C fill:#FF9800,color:#fff style G fill:#F44336,color:#fff

cgo 的开销来源:

  1. 栈切换:goroutine 栈 → g0 栈 → C 栈,两次切换
  2. 调度器交互:entersyscall/exitsyscall,P 可能被抢夺
  3. 线程切换:C 代码执行期间,当前 M 被锁定(LockOSThread),Go 调度器无法使用这个 M
  4. 参数复制:Go 和 C 之间的参数需要按 C 调用约定复制

实测 cgo 调用的基础开销约 50-100ns(空 C 函数),而纯 Go 系统调用约 20-40ns。如果 C 函数本身耗时较长,基础开销可以忽略;但如果频繁调用极短的 C 函数(如 C.abs(x)),cgo 的开销会非常显著。

常见问题 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 调用需要栈切换(goroutine 栈 → g0 栈 → C 栈),还有 entersyscall/exitsyscall 的开销,且 C 函数执行期间 M 被 LockOSThread 锁定,Go 调度器无法使用这个 M。空 C 函数调用开销约 50-100ns,纯 Go 系统调用约 20-40ns。系统调用则直接在 goroutine 栈上执行 SYSCALL 指令,只有 entersyscall/exitsyscall 的调度器开销。

Q5:如何查看 Go 程序的系统调用?#

# 使用 strace 跟踪
$ strace -c ./myprogram
# 使用 Go 的执行追踪器
$ go tool trace trace.out

小结#

Go 的系统调用设计有三个关键决策值得记住:直接 SYSCALL 而不经 libc,省去了 C 调用约定的开销,但也意味着 Go 必须自己处理所有系统调用的细节;系统调用期间 P 解绑,阻塞的 M 不会拖累整个调度器,sysmon 会在 10ms 后抢夺 P 给其他 M 用;网络 I/O 走 netpoll 而非系统调用,epoll 把”阻塞式网络 I/O”变成了”非阻塞 + goroutine 挂起/唤醒”,网络密集型程序几乎不会遇到 M 阻塞的问题。

vDSO 是一个容易被忽视但影响很大的优化:time.Now() 不走 SYSCALL 指令,而是调用内核映射在用户态的 clock_gettime,开销从微秒降到纳秒。这对高频时间戳采集(tracing、metrics)有直接的性能收益。RawSyscall 看似是”更快的系统调用”,但它跳过了调度器协作,意外阻塞时后果更严重,Go 只在极少数确定不阻塞的场景使用它。cgo 的调用路径比纯 Go 系统调用复杂得多,涉及两次栈切换和 P 的锁定,空 C 函数的调用开销约 50-100ns,频繁调用短 C 函数是性能反模式。

理解这些决策,就理解了 Go 在系统调用层面的性能哲学:能避开的阻塞就避开,避不开的就隔离,能不进内核就不进内核

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 系统调用机制:从用户态到内核态的穿越
https://blog.souloss.cn/posts/golang/go-syscall/
作者
Souloss
发布于
2023-08-17
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时