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

tomato01 · · 1210 次点击 · · 开始浏览    

&emsp;&emsp;还记得GMP协程调度模型吗?M是线程,G是协程,P是逻辑处理器,线程M只有绑定P之后才能调度并执行协程G。那如果用户协程中执行了系统调用呢?我们都知道执行系统调用会发生用户态到内核态切换,而且系统调用也有可能会阻塞线程M。M阻塞了还怎么调度协程呢?万一所有的线程M都因系统调用阻塞了呢?阻塞期间谁来调度并执行协程呢?还是说就这么阻塞着呢? ## 封装系统调用 &emsp;&emsp;在讲解系统调用实现原理之前,先回顾下GMP协程调度模型,如下图所示。一般P的数目与CPU核数相等,也就是说,对于8核处理器,Go进程会创建8个逻辑处理器P,对应的,也就最多有8个线程M能够绑定P,从而调度并执行用户协程。这样的话,一旦有线程M因系统调用阻塞了,就会少一个调度线程,极端情况下,所有的线程M都被阻塞了,即所有的用户协程短时间内都得不到调度执行。 ![2-7-1.png](https://static.golangjob.cn/220930/887c369f296d04210f1b69feb7a31fa3.png) &emsp;&emsp;这显然是不合理的,如果真是这样,性能怎么保障?那怎么办?既然系统调用有可能会阻塞线程M这一事实无法改变,那么在执行可能阻塞的系统调用之前,释放掉其绑定的P就行了呗,以便其他线程(可以新创建)能重新绑定这个逻辑处理器P,从而不耽误用户协程的调度执行。 &emsp;&emsp;但是,每次执行系统调用,都需要释放绑定的P,启动新的调度线程,效率还是过于低下。毕竟,系统调用只是有可能会阻塞线程M,也有可能很快就返回了。那怎么办?其实只需要进入系统调用之前,标记一下当前线程M正在执行系统调用,同时定时检测,如果系统调用很快返回,那么不需要额外进行任何操作;如果检测到线程M长时间阻塞,那么此时再剥离该线程M与P的绑定关系,并启动新的调度线程也是可以接受的。 &emsp;&emsp;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 } ``` &emsp;&emsp;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 ``` &emsp;&emsp;当然,并不是所有系统调用都有可能阻塞线程,有些系统调用就可以立即返回,不会阻塞线程(如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) ``` &emsp;&emsp;最后,我们以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 ``` &emsp;&emsp;至于系统调用封装为Syscall还是RawSyscall,读者可以参考文件syscall/zsyscall_linux_amd64.go ## 系统调用与调度器schedule &emsp;&emsp;至此我们了解到Go语言对底层系统调用进行了封装,syscall.Syscall函数封装的是执行时间可能比较长(长时间阻塞线程)的系统调用,syscall.RawSyscall封装的是可以立即返回的系统调用。函数syscall.Syscall在进入系统调用之前,以及系统调用结束后,分别执行函数entersyscall/exitsyscall,那么这两个函数到底做了什么呢?另外,我们提到还需要定时检测,检测线程是否长时间阻塞在系统调用,那么由谁来检测,以及如何检测呢?检测到长时间阻塞怎么处理呢? &emsp;&emsp;我们先简单看看函数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) } ``` &emsp;&emsp;在执行系统调用之前,entersyscall函数会更改当前执行协程G以及关联P的状态,标记为系统调用中,同时解绑了M与P的关联关系,但是m.oldp字段又存储了P的引用。在系统调用结束之后,exitsyscall首选尝试获取m.oldp,此时该逻辑处理器P可能还是处于_Psyscall状态,那么直接绑定即可;也有可能已经被其他线程M绑定了,那么就只能尝试再去寻找其他空闲状态的P了;最后,如果线程M没有成功绑定P,则只能陷入休眠,等待被唤醒。 &emsp;&emsp;不是说线程M只能查找绑定处于空闲状态的P吗?进入系统调用的时候,P的状态不是_Psyscall吗,为什么又说,还有可能已经被其他线程M绑定呢?这就不得不提检测线程了。想想假如线程M一直由于系统调用而阻塞,难道P就只能一直处于_Psyscall状态,不能被任何M绑定了吗? &emsp;&emsp;还记得在讲解协作式调度时,提到的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_) } } } } ``` &emsp;&emsp;这下逻辑都串起来了,执行系统调用前后的辅助函数entersyscall/exitsyscall用于标记正系统调用;而辅助线程sysmon用于协助检测并处理长时间阻塞的线程M,并标记P为空闲_Pidle,同时启动新的线程M,开启新的调度循环schedule。 ## 总结 &emsp;&emsp;至此我们终于弄明白了,syscall.Syscall函数封装的是执行时间可能比较长(长时间阻塞线程)的系统调用,syscall.RawSyscall封装的是可以立即返回的系统调用;辅助线程sysmon用于协助检测并处理长时间阻塞的线程M。

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

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

1210 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传