2

【2-7 Golang】Go并发编程—系统调用

 1 year ago
source link: https://studygolang.com/articles/35889
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【2-7 Golang】Go并发编程—系统调用

tomato01 · 大约4小时之前 · 34 次点击 · 预计阅读时间 6 分钟 · 大约8小时之前 开始浏览    

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

封装系统调用

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

2-7-1.png

  这显然是不合理的,如果真是这样,性能怎么保障?那怎么办?既然系统调用有可能会阻塞线程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。


有疑问加站长微信联系(非本文作者))

280

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK