这篇文章主要是分析在golang里面用户态进行系统调用时候的一些原理,主要关注点将会放在system call与scheduler之间的关联。

1.入口
syscall/asm_linux_amd64.s

函数声明如下:

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.Errno)

func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno)

func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err syscall.Errno)

func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno)

这些函数的实现都是汇编,按照 linux 的 syscall 调用规范,我们只要在汇编中把参数依次传入寄存器,并调用 SYSCALL 指令即可进入内核处理逻辑,系统调用执行完毕之后,返回值放在 RAX 中:

Syscall 和 Syscall6 的区别只有传入参数不一样, 具体源码与实现请看golang的开源源码。

这里只列出Syscall和RawSyscall的源码:

//Syscall
TEXT ·Syscall(SB),NOSPLIT,$0-56
	CALL	runtime·entersyscall(SB)
	MOVQ	a1+8(FP), DI
	MOVQ	a2+16(FP), SI
	MOVQ	a3+24(FP), DX
	MOVQ	$0, R10
	MOVQ	$0, R8
	MOVQ	$0, R9
	MOVQ	trap+0(FP), AX	// syscall entry
	SYSCALL
	CMPQ	AX, $0xfffffffffffff001
	JLS	ok
	MOVQ	$-1, r1+32(FP)
	MOVQ	$0, r2+40(FP)
	NEGQ	AX
	MOVQ	AX, err+48(FP)
	CALL	runtime·exitsyscall(SB)
	RET
ok:
	MOVQ	AX, r1+32(FP)
	MOVQ	DX, r2+40(FP)
	MOVQ	$0, err+48(FP)
	CALL	runtime·exitsyscall(SB)
	RET
//RawSyscall
TEXT ·RawSyscall(SB),NOSPLIT,$0-56
	MOVQ	a1+8(FP), DI
	MOVQ	a2+16(FP), SI
	MOVQ	a3+24(FP), DX
	MOVQ	$0, R10
	MOVQ	$0, R8
	MOVQ	$0, R9
	MOVQ	trap+0(FP), AX	// syscall entry
	SYSCALL
	CMPQ	AX, $0xfffffffffffff001
	JLS	ok1
	MOVQ	$-1, r1+32(FP)
	MOVQ	$0, r2+40(FP)
	NEGQ	AX
	MOVQ	AX, err+48(FP)
	RET
ok1:
	MOVQ	AX, r1+32(FP)
	MOVQ	DX, r2+40(FP)
	MOVQ	$0, err+48(FP)
	RET

Syscall和RawSyscall的实现比较典型,可以看到这两个实现最主要的区别在于:
Syscall在进入系统调用的时候,调用了runtime·entersyscall(SB)函数,在结束系统调用的时候调用了runtime·exitsyscall(SB)。做到进入和退出syscall的时候通知runtime。

这两个函数runtime·entersyscall和runtime·exitsyscall的实现在proc.go文件里面。其实在runtime·entersyscall函数里面,通知系统调用时候,是会将g的M的P解绑,P可以去继续获取M执行其余的g,这样提升效率。

所以如果用户代码使用了 RawSyscall 来做一些阻塞的系统调用,是有可能阻塞其它的 g 的。RawSyscall 只是为了在执行那些一定不会阻塞的系统调用时,能节省两次对 runtime 的函数调用消耗。

runtime·entersyscall和runtime·exitsyscall这两个函数也是与scheduler交互的地方,后面会对源码进行分析。

2.系统调用管理
/syscall/syscall_linux.go

可以把系统调用分为三类:

  1. 阻塞系统调用
  2. 非阻塞系统调用非阻塞系统调用
  3. wrapped 系统调用

阻塞系统调用会定义成下面这样的形式:

//sys   Madvise(b []byte, advice int) (err error)

非阻塞系统调用:

//sysnb    EpollCreate(size int) (fd int, err error)

然后,根据这些注释,mksyscall.pl 脚本会生成对应的平台的具体实现。mksyscall.pl 是一段 perl 脚本,感兴趣的同学可以自行查看,这里就不再赘述了。

看看阻塞和非阻塞的系统调用的生成结果:

func Madvise(b []byte, advice int) (err error) {
    var _p0 unsafe.Pointer
    if len(b) > 0 {
        _p0 = unsafe.Pointer(&b[0])
    } else {
        _p0 = unsafe.Pointer(&_zero)
    }
    _, _, e1 := Syscall(SYS_MADVISE, uintptr(_p0), uintptr(len(b)), uintptr(advice))
    if e1 != 0 {
        err = errnoErr(e1)
    }
    return
}

func EpollCreate(size int) (fd int, err error) {
    r0, _, e1 := RawSyscall(SYS_EPOLL_CREATE, uintptr(size), 0, 0)
    fd = int(r0)
    if e1 != 0 {
        err = errnoErr(e1)
    }
    return
}

标记为 sys(阻塞)的系统调用使用的是 Syscall 或者 Syscall6,标记为 sysnb(非阻塞) 的系统调用使用的是 RawSyscall 或 RawSyscall6。

wrapped 的系统调用是怎么一回事呢?

func Rename(oldpath string, newpath string) (err error) {
    return Renameat(_AT_FDCWD, oldpath, _AT_FDCWD, newpath)
}

可能是觉得系统调用的名字不太好,或者参数太多,我们就简单包装一下。没啥特别的。

3.runtime 中的 SYSCALL

除了上面提到的阻塞非阻塞和 wrapped syscall,runtime 中还定义了一些 low-level 的 syscall,这些是不暴露给用户程序的。

提供给用户的 syscall 库,在使用时,会使 goroutine 和 p 分别进入 Gsyscall 和 Psyscall 状态。但 runtime 自己封装的这些 syscall 无论是否阻塞,都不会调用 entersyscall 和 exitsyscall。 虽说是 “low-level” 的 syscall, 不过和暴露给用户的 syscall 本质是一样的。这些代码在 runtime/sys_linux_amd64.s 中,举个具体的例子:

TEXT runtime·write(SB),NOSPLIT,$0-28
    MOVQ    fd+0(FP), DI
    MOVQ    p+8(FP), SI
    MOVL    n+16(FP), DX
    MOVL    $SYS_write, AX
    SYSCALL
    CMPQ    AX, $0xfffffffffffff001
    JLS    2(PC)
    MOVL    $-1, AX
    MOVL    AX, ret+24(FP)
    RET

TEXT runtime·read(SB),NOSPLIT,$0-28
    MOVL    fd+0(FP), DI
    MOVQ    p+8(FP), SI
    MOVL    n+16(FP), DX
    MOVL    $SYS_read, AX
    SYSCALL
    CMPQ    AX, $0xfffffffffffff001
    JLS    2(PC)
    MOVL    $-1, AX
    MOVL    AX, ret+24(FP)
    RET

这些 syscall 理论上都是不会在执行期间被调度器剥离掉 p 的,所以执行成功之后 goroutine 会继续执行,而不像用户的 goroutine 一样,若被剥离 p 会进入等待队列。

4.用户代码的系统调用和调度交互

既然要和调度交互,那就要友好地通知我要 syscall 了: entersyscall,我完事了: exitsyscall。

所以这里的交互指的是用户代码使用 syscall 库时和调度器的交互。runtime 里的 syscall 不走这套流程。

entersyscall和exitsyscall的pipeline

                      +-----------------------------------------------------+
                      |runtime.entersyscall()                               |
                      |1) save() goroutine Save on site                     |
user code             |2) casgstatus(_g_, _Grunning, _Gsyscall)             |
 syscall   ---------->|3) atomic.Store(&_g_.m.p.ptr().status, _Psyscall)    |
                      |                                                     |
                      |a) the M is blocking;                                |
                      |b) the status of P is _Psyscall, So the P can be     |
                      |schedule to execute other goroutine                  |
                      +-----------------------------------------------------+
                                                                             
                                                                             
                                                                             
                                                                             
                             +--------------------------+                    
     user code               |runtime.exitsyscall()     |                    
 syscall finished ---------->|1) disable preemption     |                    
                             |                          |                    
                             +--+--------------------+--+                    
                                |                    |                                       
                                |                    |                           
                                |                    |                       
                                v                    v                       
                        try to re-acquire   try to get any other             
                            the last P             idle P                    
                                |                    |                       
                                |                    |                       
                                |                    |                       
                      success---+--------------------+-----+                 
                          |                                |                 
                          |                              fail                
                          |                                |                 
                          v                                |                 
               +---------------------+          +----------+------>------+   
               |there is a P to run G|          |not get P               |   
               |runtime.exexute(G)   |          |1.put G into global tail|   
               |schedule loop        |          |2.idel this M           |   
               |                     |          |                        |   
               +---------------------+          +------------------------+   

entersyscall

直接看源码:

// 用户代码使用 syscall 库时和调度器的交互;
// runtime本身的syscall不走这一套流程
// Standard syscall entry used by the go syscall library and normal cgo calls.
//go:nosplit
func entersyscall() {
	reentersyscall(getcallerpc(), getcallersp())
}

//go:nosplit
func reentersyscall(pc, sp uintptr) {
	_g_ := getg()

	// 需要禁止 g 的抢占
	_g_.m.locks++

	// entersyscall 中不能调用任何会导致栈增长/分裂的函数
	// (See details in comment above.)
	// Catch calls that might, by replacing the stack guard with something that
	// will trip any stack check and leaving a flag to tell newstack to die.
	_g_.stackguard0 = stackPreempt
	_g_.throwsplit = true

	// Leave SP around for GC and traceback.
	//保存现场,在 syscall 之后会依据这些数据恢复现场
	save(pc, sp)
	_g_.syscallsp = sp
	_g_.syscallpc = pc
	casgstatus(_g_, _Grunning, _Gsyscall)
	if _g_.syscallsp < _g_.stack.lo || _g_.stack.hi < _g_.syscallsp {
		systemstack(func() {
			print("entersyscall inconsistent ", hex(_g_.syscallsp), " [", hex(_g_.stack.lo), ",", hex(_g_.stack.hi), "]\n")
			throw("entersyscall")
		})
	}

	if trace.enabled {
		systemstack(traceGoSysCall)
		// systemstack itself clobbers g.sched.{pc,sp} and we might
		// need them later when the G is genuinely blocked in a
		// syscall
		save(pc, sp)
	}

	if atomic.Load(&sched.sysmonwait) != 0 {
		systemstack(entersyscall_sysmon)
		save(pc, sp)
	}

	if _g_.m.p.ptr().runSafePointFn != 0 {
		// runSafePointFn may stack split if run on this stack
		systemstack(runSafePointFn)
		save(pc, sp)
	}

	_g_.m.syscalltick = _g_.m.p.ptr().syscalltick
	_g_.sysblocktraced = true
	_g_.m.mcache = nil
	// 解绑P与M的关系
	_g_.m.p.ptr().m = 0
	atomic.Store(&_g_.m.p.ptr().status, _Psyscall)
	if sched.gcwaiting != 0 {
		systemstack(entersyscall_gcwait)
		save(pc, sp)
	}

	_g_.m.locks--
}

主要流程如下:

  1. 设置_g_.m.locks++,禁止g被强占
  2. 设置_g_.stackguard0 = stackPreempt,禁止调用任何会导致栈增长/分裂的函数
  3. 保存现场,在 syscall 之后会依据这些数据恢复现场
  4. 更新G的状态为_Gsyscall
  5. 释放局部调度器P:解绑P与M的关系;
  6. 更新P状态为_Psyscall
  7. g.m.locks–解除禁止强占。

可以看到,进入 syscall 的 G 是铁定不会被抢占的。

此外进入系统调用的goroutine会阻塞,导致内核M会阻塞。此时P会被剥离掉,所以P可以继续去获取其余的空闲M执行其余的goroutine。

exitsyscall

直接看源码

// g 已经退出了 syscall
// 需要准备让 g 在 cpu 上重新运行
// 不能有 write barrier,因为 P 可能已经被偷走了
//go:nosplit
//go:nowritebarrierrec
func exitsyscall() {
	_g_ := getg()
	// 禁止强占
	_g_.m.locks++ // see comment in entersyscall
	if getcallersp() > _g_.syscallsp {
		throw("exitsyscall: syscall frame is no longer valid")
	}

	_g_.waitsince = 0
	oldp := _g_.m.p.ptr()
	if exitsyscallfast() {
		if _g_.m.mcache == nil {
			throw("lost mcache")
		}
		if trace.enabled {
			if oldp != _g_.m.p.ptr() || _g_.m.syscalltick != _g_.m.p.ptr().syscalltick {
				systemstack(traceGoStart)
			}
		}
		// There's a cpu for us, so we can run.
		_g_.m.p.ptr().syscalltick++
		// We need to cas the status and scan before resuming...
		casgstatus(_g_, _Gsyscall, _Grunning)

		// Garbage collector isn't running (since we are),
		// so okay to clear syscallsp.
		_g_.syscallsp = 0
		_g_.m.locks--
		if _g_.preempt {
			// restore the preemption request in case we've cleared it in newstack
			_g_.stackguard0 = stackPreempt
		} else {
			// otherwise restore the real _StackGuard, we've spoiled it in entersyscall/entersyscallblock
			_g_.stackguard0 = _g_.stack.lo + _StackGuard
		}
		_g_.throwsplit = false
		return
	}

	_g_.sysexitticks = 0
	if trace.enabled {
		// Wait till traceGoSysBlock event is emitted.
		// This ensures consistency of the trace (the goroutine is started after it is blocked).
		for oldp != nil && oldp.syscalltick == _g_.m.syscalltick {
			osyield()
		}
		// We can't trace syscall exit right now because we don't have a P.
		// Tracing code can invoke write barriers that cannot run without a P.
		// So instead we remember the syscall exit time and emit the event
		// in execute when we have a P.
		_g_.sysexitticks = cputicks()
	}

	_g_.m.locks--

	// Call the scheduler.
	mcall(exitsyscall0)

	if _g_.m.mcache == nil {
		throw("lost mcache")
	}

	// Scheduler returned, so we're allowed to run now.
	// Delete the syscallsp information that we left for
	// the garbage collector during the system call.
	// Must wait until now because until gosched returns
	// we don't know for sure that the garbage collector
	// is not running.
	_g_.syscallsp = 0
	_g_.m.p.ptr().syscalltick++
	_g_.throwsplit = false
}

//exitsyscallfast
//go:nosplit
func exitsyscallfast() bool {
	_g_ := getg()

	// Freezetheworld sets stopwait but does not retake P's.
	if sched.stopwait == freezeStopWait {
		_g_.m.mcache = nil
		_g_.m.p = 0
		return false
	}

	// Try to re-acquire the last P.
	if _g_.m.p != 0 && _g_.m.p.ptr().status == _Psyscall && atomic.Cas(&_g_.m.p.ptr().status, _Psyscall, _Prunning) {
		// There's a cpu for us, so we can run.
		exitsyscallfast_reacquired()
		return true
	}

	// Try to get any other idle P.
	oldp := _g_.m.p.ptr()
	_g_.m.mcache = nil
	_g_.m.p = 0
	if sched.pidle != 0 {
		var ok bool
		systemstack(func() {
			ok = exitsyscallfast_pidle()
			if ok && trace.enabled {
				if oldp != nil {
					// Wait till traceGoSysBlock event is emitted.
					// This ensures consistency of the trace (the goroutine is started after it is blocked).
					for oldp.syscalltick == _g_.m.syscalltick {
						osyield()
					}
				}
				traceGoSysExit(0)
			}
		})
		if ok {
			return true
		}
	}
	return false
}

// exitsyscall0
// exitsyscall slow path on g0.
// Failed to acquire P, enqueue gp as runnable.
//
//go:nowritebarrierrec
func exitsyscall0(gp *g) {
	_g_ := getg()

	casgstatus(gp, _Gsyscall, _Grunnable)
	dropg()
	lock(&sched.lock)
	_p_ := pidleget()
	if _p_ == nil {
		globrunqput(gp)
	} else if atomic.Load(&sched.sysmonwait) != 0 {
		atomic.Store(&sched.sysmonwait, 0)
		notewakeup(&sched.sysmonnote)
	}
	unlock(&sched.lock)
	if _p_ != nil {
		acquirep(_p_)
		execute(gp, false) // Never returns.
	}
	if _g_.m.lockedg != 0 {
		// Wait until another thread schedules gp and so m again.
		stoplockedm()
		execute(gp, false) // Never returns.
	}
	stopm()
	schedule() // Never returns.
}

主要的pipeline如下:

  1. 设置 g.m.locks++ 禁止强占
  2. 调用 exitsyscallfast() 快速退出系统调用
    2.1. Try to re-acquire the last P,如果成功就直接接return;
    2.2. Try to get any other idle P from allIdleP list;
    2.3. 没有获取到空闲的P
  3. 如果快速获取到了P:
    3.1. 更新G 的状态是_Grunning
    3.2. 与G绑定的M会在退出系统调用之后继续执行
  4. 没有获取到空闲的P:
    4.1. 调用mcall()函数切换到g0的栈空间;
    4.2. 调用exitsyscall0函数:
    4.2.1. 更新G 的状态是_Grunning
    4.2.2. 调用dropg():解除当前g与M的绑定关系;
    4.2.3. 调用globrunqput将G插入global queue的队尾,
    4.2.4. 调用stopm()释放M,将M加入全局的idel M列表,这个调用会阻塞,知道获取到可用的P。
    4.2.5. 如果4.2.4中阻塞结束,M获取到了可用的P,会调用schedule()函数,执行一次新的调度。

需要注意的是,调用 exitsyscall0 时,会切换到 g0 栈。

entersyscallblock

用户代码进行系统调用时候,知道自己会 block,直接就把 p 交出来了。

代码实现和 entersyscall 一样,就是会直接把 P 给交出去,因为知道自己是会阻塞的。

// 和 entersyscall 一样,就是会直接把 P 给交出去,因为知道自己是会阻塞的
//go:nosplit
func entersyscallblock(dummy int32) {
    _g_ := getg()

    _g_.m.locks++ // see comment in entersyscall
    _g_.throwsplit = true
    _g_.stackguard0 = stackPreempt // see comment in entersyscall
    _g_.m.syscalltick = _g_.m.p.ptr().syscalltick
    _g_.sysblocktraced = true
    _g_.m.p.ptr().syscalltick++

    // Leave SP around for GC and traceback.
    pc := getcallerpc()
    sp := getcallersp(unsafe.Pointer(&dummy))
    save(pc, sp)
    _g_.syscallsp = _g_.sched.sp
    _g_.syscallpc = _g_.sched.pc
    if _g_.syscallsp < _g_.stack.lo || _g_.stack.hi < _g_.syscallsp {
        sp1 := sp
        sp2 := _g_.sched.sp
        sp3 := _g_.syscallsp
        systemstack(func() {
            print("entersyscallblock inconsistent ", hex(sp1), " ", hex(sp2), " ", hex(sp3), " [", hex(_g_.stack.lo), ",", hex(_g_.stack.hi), "]\n")
            throw("entersyscallblock")
        })
    }
    casgstatus(_g_, _Grunning, _Gsyscall)
    if _g_.syscallsp < _g_.stack.lo || _g_.stack.hi < _g_.syscallsp {
        systemstack(func() {
            print("entersyscallblock inconsistent ", hex(sp), " ", hex(_g_.sched.sp), " ", hex(_g_.syscallsp), " [", hex(_g_.stack.lo), ",", hex(_g_.stack.hi), "]\n")
            throw("entersyscallblock")
        })
    }

    // 直接调用 entersyscallblock_handoff 把 p 交出来了
    systemstack(entersyscallblock_handoff)

    // Resave for traceback during blocked call.
    save(getcallerpc(), getcallersp(unsafe.Pointer(&dummy)))

    _g_.m.locks--
}

这个函数只有一个调用方 notesleepg,这里就不再赘述了。

func entersyscallblock_handoff() {
    handoffp(releasep())
}
5. 总结

提供给用户使用的系统调用,基本都会通知 runtime,以 entersyscall,exitsyscall 的形式来告诉 runtime,在这个 syscall 阻塞的时候,由 runtime 判断是否把 P 腾出来给其它的 M 用。解绑定指的是把 M 和 P 之间解绑,如果绑定被解除,在 syscall 返回时,这个 g 会被放入全局执行队列 global runq 中。

同时 runtime 又保留了自己的特权,在执行自己的逻辑的时候,我的 P 不会被调走,这样保证了在 Go 自己“底层”使用的这些 syscall 返回之后都能被立刻处理。

所以同样是 epollwait,runtime 用的是不能被别人打断的,你用的 syscall.EpollWait 那显然是没有这种特权的。