关于go:27-GolangGo并发编程系统调用

5次阅读

共计 4324 个字符,预计需要花费 11 分钟才能阅读完成。

  还记得 GMP 协程调度模型吗?M 是线程,G 是协程,P 是逻辑处理器,线程 M 只有绑定 P 之后能力调度并执行协程 G。那如果用户协程中执行了零碎调用呢?咱们都晓得执行零碎调用会产生用户态到内核态切换,而且零碎调用也有可能会阻塞线程 M。M 阻塞了还怎么调度协程呢?万一所有的线程 M 都因零碎调用阻塞了呢?阻塞期间谁来调度并执行协程呢?还是说就这么阻塞着呢?

封装零碎调用

  在解说零碎调用实现原理之前,先回顾下 GMP 协程调度模型,如下图所示。个别 P 的数目与 CPU 核数相等,也就是说,对于 8 核处理器,Go 过程会创立 8 个逻辑处理器 P,对应的,也就最多有 8 个线程 M 可能绑定 P,从而调度并执行用户协程。这样的话,一旦有线程 M 因零碎调用阻塞了,就会少一个调度线程,极其状况下,所有的线程 M 都被阻塞了,即所有的用户协程短时间内都得不到调度执行。

  这显然是不合理的,如果真是这样,性能怎么保障?那怎么办?既然零碎调用有可能会阻塞线程 M 这一事实无奈扭转,那么在执行可能阻塞的零碎调用之前,开释掉其绑定的 P 就行了呗,以便其余线程(能够新创建)能从新绑定这个逻辑处理器 P,从而不耽搁用户协程的调度执行。

  然而,每次执行零碎调用,都须要开释绑定的 P,启动新的调度线程,效率还是过于低下。毕竟,零碎调用只是有可能会阻塞线程 M,也有可能很快就返回了。那怎么办?其实只须要进入零碎调用之前,标记一下以后线程 M 正在执行零碎调用,同时定时检测,如果零碎调用很快返回,那么不须要额定进行任何操作;如果检测到线程 M 长时间阻塞,那么此时再剥离该线程 M 与 P 的绑定关系,并启动新的调度线程也是能够承受的。

  Go 语言函数 syscall.Syscall/Syscall6 封装了底层零碎调用,以 write 零碎调用为例,参考文件 syscall/zsyscall_linux_amd64.go:

// 只是参数数目不同
func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)

// 定义 linux 零碎调用 write 编号
const SYS_WRITE = 1

func write(fd int, p []byte) (n int, err error) {r0, _, e1 := Syscall(SYS_WRITE, uintptr(fd), uintptr(_p0), uintptr(len(p)))
    n = int(r0)
    if e1 != 0 {err = errnoErr(e1)
    }
    return
}

  syscall.Syscall 函数在进入零碎调用之前,以及零碎调用完结,都会执行对应的 hook 函数,留神这一逻辑间接应用汇编语言实现(参考文件 syscall/asm_linux_amd64.s)

TEXT ·Syscall(SB),NOSPLIT,$0-56
    // 进入零碎调用前的筹备工作
    CALL    runtime·entersyscall(SB)
    //trap 就是零碎调用编号
    MOVQ    trap+0(FP), AX    // syscall entry
    SYSCALL
    // 零碎调用执行结束后的收尾工作
    CALL    runtime·exitsyscall(SB)
    RET

  当然,并不是所有零碎调用都有可能阻塞线程,有些零碎调用就能够立刻返回,不会阻塞线程 (如 socket,epoll_create 等),对于这类零碎调用,也就不须要所谓的 entersyscall/exitsyscall。这类零碎调用封装为 syscall.RawSyscall 函数,raw 即原始的,在进入零碎调用以及零碎调用完结之后,不须要执行任何 hook 函数。

// 只是参数数目不同
func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)
func RawSyscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err Errno)

  最初,咱们以 fmt.Println 函数为例,在函数 syscall.Syscall 打断点,看一下整个调用过程:

0  0x0000000000494a30 in syscall.Syscall
   at /go1.12.4/src/syscall/asm_linux_amd64.s:18
1  0x0000000000496e13 in internal/syscall/unix.IsNonblock
   at /go1.12.4/src/internal/syscall/unix/nonblocking.go:12
2  0x00000000004979dc in os.NewFile
   at /go1.12.4/src/os/file_unix.go:84
3  0x000000000049868a in os.init.ializers
   at /go1.12.4/src/os/file.go:59
4  0x0000000000498890 in os.init
   at <autogenerated>:1
5  0x00000000004a2a1c in fmt.init
   at <autogenerated>:1
6  0x00000000004a2d6d in main.init
   at <autogenerated>:1
7  0x000000000042ddab in runtime.main
   at /go1.12.4/src/runtime/proc.go:188
8  0x0000000000458a61 in runtime.goexit
   at /go1.12.4/src/runtime/asm_amd64.s:1337

  至于零碎调用封装为 Syscall 还是 RawSyscall,读者能够参考文件 syscall/zsyscall_linux_amd64.go

零碎调用与调度器 schedule

  至此咱们理解到 Go 语言对底层零碎调用进行了封装,syscall.Syscall 函数封装的是执行工夫可能比拟长(长时间阻塞线程)的零碎调用,syscall.RawSyscall 封装的是能够立刻返回的零碎调用。函数 syscall.Syscall 在进入零碎调用之前,以及零碎调用完结后,别离执行函数 entersyscall/exitsyscall,那么这两个函数到底做了什么呢?另外,咱们提到还须要定时检测,检测线程是否长时间阻塞在零碎调用,那么由谁来检测,以及如何检测呢?检测到长时间阻塞怎么解决呢?

  咱们先简略看看函数 entersyscall/exitsyscall 的次要逻辑:

func entersyscall() {
    // 保留栈上下文:PC 以及 SP 寄存器
    save(pc, sp)
    // 更改协程状态
    casgstatus(_g_, _Grunning, _Gsyscall)

    // 更改 M 与 P 的关系
    _g_.m.oldp.set(pp)
    pp.m = 0
    _g_.m.p = 0
    // 更改 P 的状态
    atomic.Store(&pp.status, _Psyscall)
}

func exitsyscall() {
    // 尝试关联 M 与 P
    oldp := _g_.m.oldp.ptr()
    //1)如果 P 状态还是_Psyscall,则间接获取该 P;2)如果 P 曾经被其余 M 绑定,尝试从全局 sched.pidle 队列获取闲暇的 P
    if exitsyscallfast(oldp) {
        // 获取到 P
        //syscalltick 零碎调用计数器,每调度一次加 1
        _g_.m.p.ptr().syscalltick++

        // 更改协程状态为运行中
        casgstatus(_g_, _Gsyscall, _Grunning)
        return
    }

    //1)协程 M 休眠,期待被唤醒;2)当有闲暇 P 时,会唤醒该 M,并进入调度循环 schedule
    mcall(exitsyscall0)
}

  在执行零碎调用之前,entersyscall 函数会更改以后执行协程 G 以及关联 P 的状态,标记为零碎调用中,同时解绑了 M 与 P 的关联关系,然而 m.oldp 字段又存储了 P 的援用。在零碎调用完结之后,exitsyscall 首选尝试获取 m.oldp,此时该逻辑处理器 P 可能还是处于_Psyscall 状态,那么间接绑定即可;也有可能曾经被其余线程 M 绑定了,那么就只能尝试再去寻找其余闲暇状态的 P 了;最初,如果线程 M 没有胜利绑定 P,则只能陷入休眠,期待被唤醒。

  不是说线程 M 只能查找绑定处于闲暇状态的 P 吗?进入零碎调用的时候,P 的状态不是_Psyscall 吗,为什么又说,还有可能曾经被其余线程 M 绑定呢?这就不得不提检测线程了。想想如果线程 M 始终因为零碎调用而阻塞,难道 P 就只能始终处于_Psyscall 状态,不能被任何 M 绑定了吗?

  还记得在解说合作式调度时,提到的 sysmon 线程吗?就是这个线程定时检测,如果 P 长时间处于_Psyscall 状态,则更改 P 的状态为_Pidle,同时启动新的线程 M:

// 创立新线程,主函数 sysmon
newm(sysmon, nil)

func sysmon() {
    delay = 10 * 1000   // up to 10ms
    usleep(delay)

    for {
        //preempt long running G's
        retake(nanotime())
    }
}

func retake(now int64) uint32 {
    // 遍历所有的 P
    for i := 0; i < len(allp); i++ {
        if s == _Psyscall {
            // 如果不等于,阐明系统调度已完结
            t := int64(_p_.syscalltick)
            if !sysretake && int64(pd.syscalltick) != t {pd.syscalltick = uint32(t)
                pd.syscallwhen = now
                continue
            }
            
            // 更改 P 的状态
            if atomic.Cas(&_p_.status, s, _Pidle) {
                // 启动新的线程 M,以执行调度循环 schedule
                handoffp(_p_)
            }
        }
    }
}

  这下逻辑都串起来了,执行零碎调用前后的辅助函数 entersyscall/exitsyscall 用于标记正零碎调用;而辅助线程 sysmon 用于帮助检测并解决长时间阻塞的线程 M,并标记 P 为闲暇_Pidle,同时启动新的线程 M,开启新的调度循环 schedule。

总结

  至此咱们终于弄明确了,syscall.Syscall 函数封装的是执行工夫可能比拟长(长时间阻塞线程)的零碎调用,syscall.RawSyscall 封装的是能够立刻返回的零碎调用;辅助线程 sysmon 用于帮助检测并解决长时间阻塞的线程 M。

正文完
 0