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

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

本文要点#

  • 系统调用的基本原理:用户态 → 内核态 → 用户态
  • Go 的两种系统调用:entersyscall 和 entersyscall_block
  • 系统调用期间的调度器行为
  • sysmon:后台监控线程
  • 与 P 的解绑与重绑
  • 常见系统调用的实现
  • cgo 与系统调用的关系

系统调用基本原理#

用户态与内核态#

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()
// 将 P 设置为 _Psyscall 状态
mp.p.ptr().status = _Psyscall
// 让出 P,允许其他 M 使用
handoffp(mp.p.ptr())
}
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
// 直接让出 P
mp.p.ptr().status = _Psyscall
handoffp(mp.p.ptr()) // 立即交出 P
}

区别: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() {
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:

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 内部使用 Linux futex 实现互斥锁和条件变量:

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)
}

常见问题 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

小结#

  1. Go 直接使用 SYSCALL 指令,不经过 libc
  2. 两种系统调用模式:可抢占(entersyscall)和不可抢占(entersyscall_block)
  3. 系统调用期间 P 解绑,允许其他 M 继续运行 goroutine
  4. sysmon 后台监控:超过 10ms 的系统调用会被抢夺 P
  5. exitsyscall 快速路径:如果原 P 还在 _Psyscall 状态,直接重新绑定
  6. 网络 I/O 使用非阻塞 + netpoll,不会阻塞 M

参考资料#

支持与分享

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

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

部分信息可能已经过时