咱们都晓得用户程序读写socket的时候,可能阻塞以后协程,那么是不是阐明Go语言采纳阻塞形式调用socket相干零碎调用呢?你有没有想过,Go语言又是如何实现高性能网络IO呢?有没有应用传说中的IO多路复用,如epoll呢?

摸索Go语言网络IO

  HTTP服务必定波及到socket的读写吧,而且Go语言启动一个HTTP服务还是非常简单的,几行代码就能够搞定,后面也不须要反向代理服务如Nginx,咱们写一个简略的HTTP服务来测试:

package mainimport (    "fmt"    "net/http")func main() {    http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {        writer.Write([]byte("hello world"))    })    server := &http.Server{        Addr: "0.0.0.0:80",    }    err := server.ListenAndServe()    fmt.Println(err)}//curl http://127.0.0.1:10086/ping//hello world

  能够临时不了解http.Server,只须要晓得这是Go语言提供的HTTP服务;咱们启动的HTTP服务监听80端口,所有申请都返回"hello world"。程序挺简略的,然而如何验证咱们提出的疑难呢?Go语言层面的socket读写,最终必定会转化为具体的零碎调用吧,有一个工具strace,能够监听过程所有的零碎调用,咱们先通过strace简略看一下。

# ps aux | grep testroot     27435  0.0  0.0 219452  4636 pts/0    Sl+  11:00   0:00 ./test# strace -p 27435strace: Process 27435 attachedepoll_pwait(5, [{EPOLLIN, {u32=1030856456, u64=140403511762696}}], 128, -1, NULL, 0) = 1futex(0x9234d0, FUTEX_WAKE_PRIVATE, 1)  = 1accept4(3, {sa_family=AF_INET6, sin6_port=htons(56447), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 4epoll_ctl(5, EPOLL_CTL_ADD, 4, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1030856248, u64=140403511762488}}) = 0getsockname(4, {sa_family=AF_INET6, sin6_port=htons(10086), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0setsockopt(4, SOL_TCP, TCP_NODELAY, [1], 4) = 0setsockopt(4, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0setsockopt(4, SOL_TCP, TCP_KEEPINTVL, [180], 4) = 0setsockopt(4, SOL_TCP, TCP_KEEPIDLE, [180], 4) = 0futex(0xc000036848, FUTEX_WAKE_PRIVATE, 1) = 1accept4(3, 0xc0000abac0, 0xc0000abaa4, SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)futex(0x923d48, FUTEX_WAIT_PRIVATE, 0, NULL) = 0nanosleep({0, 3000}, NULL)

  strace应用起来还是非常简单的,ps命令查出来过程的pid,而后strace -p pid就能够了,同时咱们手动curl申请一下。能够很分明的看到epoll_pwait,epoll_ctl,accept4等零碎调用,很显著,Go应用了IO多路复用epoll(不同零碎Linux,Windows,Mac不一样)。另外,留神第二个accept4零碎调用,返回EAGAIN,并且第四个参数蕴含标识SOCK_NONBLOCK,看到这根本也能猜到,Go语言采取的是非阻塞形式调用socket相干零碎调用。

  Linux零碎,高性能网络IO通常应用epoll,epoll能够同时监听多个socket fd是否可读或者可写(socket缓冲区有数据了就是可读,socket缓冲区有空间了就是可写)。epoll应用也比较简单,咱们不做过多介绍,读者能够本人查阅相干材料,理解下epoll基于红黑树+双向列表实现,以及程度触发边缘触发等概念。epoll只有三个API:

//创立epoll对象int epoll_create(int size)//增加/批改/删除监听的socket,包含能够设置监听socket的可读还是可写int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);//阻塞期待,直到监听的多个socket可读或者可写;events就是返回的事件列表int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

网络IO与调度器schedule

  咱们能够猜想下Go语言网络IO流程:socket读写采取的都是非阻塞式,如果不可读或者不可写,会立刻返回EAGAIN,此时Go语言会将该socket增加到epoll对象监听,同时阻塞用户协程切换到调度器schedule。而等到适合的机会,再调用epoll_wait获取可读或者可写的socket,从而复原这些因为socket读写阻塞的用户协程。

  什么时候是适合的机会呢?还记得咱们上一篇文章介绍的调度器schedule吗,调度器在获取可执行协程时,还会尝试检测一下,以后是否有协程曾经解除阻塞了,其中就包含检测监听的socket是否可读或者可写。这些逻辑都在runtime.findrunnable函数内能够看到:

func findrunnable() (gp *g, inheritTime bool) {    //本地队列    if gp, inheritTime := runqget(_p_); gp != nil {        return gp, inheritTime    }    //全局队列    if sched.runqsize != 0 {        lock(&sched.lock)        gp := globrunqget(_p_, 0)        unlock(&sched.lock)        if gp != nil {            return gp, false        }    }    //检测是否有socket可读或者可写    if netpollinited() && atomic.Load(&netpollWaiters) > 0 && atomic.Load64(&sched.lastpoll) != 0 {        if list := netpoll(0); !list.empty() { // non-blocking            gp := list.pop()            injectglist(&list)            casgstatus(gp, _Gwaiting, _Grunnable)            return gp, false        }    }}

  netpoll对应的就是咱们提到的epoll_wait,留神这里输出参数是0(超时工夫为0,不会阻塞),即不论是否存在socket可读或者可写,都立刻返回,而且返回的就是gList,解除阻塞的协程列表。injectglist函数将协程增加到全局队列,或者是P的本地队列。

  然而咱们也能够看到,什么时候检测是否有socket可读或者可写呢?在查找以后P的本地队列,以及查找全局队列之后。那问题来了,如果这两个队列始终有协程怎么办?是不是就始终不会检测socket了状态了,也就是说这些协程会始终这么阻塞了。这必定不行啊,那怎么办?别忘了咱们还有一个辅助线程sysmon,这个函数也会以10ms周期检测的.

func sysmon() {    delay = 10 * 1000  // up to 10ms        for {        usleep(delay)        lastpoll := int64(atomic.Load64(&sched.lastpoll))        if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {            atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))            list := netpoll(0) // non-blocking - returns list of goroutines            if !list.empty() {                incidlelocked(-1)                injectglist(&list)                incidlelocked(1)            }        }    }}

  与调度器schedule相似,同样超时工夫为0,不会阻塞;同样的将解除阻塞的协程增加到全局队列,或者是P的本地队列。

  接下来该钻研socket读写操作的流程了,当然必定与咱们的猜想相似,非阻塞读写,如果不可读或者不可写,立刻返回EAGAIN,于是将该socket增加到epoll对象监听,并且阻塞以后协程,并切换到调度器schedule。一方面,咱们能够从上往下,如从server.ListenAndServe往底层逐层去摸索,钻研socket读写的实现;另一方面,咱们曾经晓得底层肯定会走到epoll_ctl,只是咱们不晓得Go语言对立封装的办法名称,简略浏览下runtime包下的文件,能够找到runtime/netpoll_epoll.go,依据名称根本就能判断这是对epoll的封装,这下简略了,关上调试模式(Goland、dlv都能够调试),打断点,再查看调用栈,socket读写操作的调用链霎时就分明了。

0  0x00000000010304ea in runtime.netpollopen    at /Users/xxx/Documents/go1.18/src/runtime/netpoll_epoll.go:64 1  0x000000000105cdf4 in internal/poll.runtime_pollOpen    at /Users/xxx/Documents/go1.18/src/runtime/netpoll.go:239 2  0x000000000109e32d in internal/poll.(*pollDesc).init    at /Users/xxx/Documents/go1.18/src/internal/poll/fd_poll_runtime.go:39 3  0x000000000109eca6 in internal/poll.(*FD).Init    at /Users/xxx/Documents/go1.18/src/internal/poll/fd_unix.go:63 4  0x0000000001150078 in net.(*netFD).init    at /Users/xxx/Documents/go1.18/src/net/fd_unix.go:41 5  0x0000000001150078 in net.(*netFD).accept    at /Users/xxx/Documents/go1.18/src/net/fd_unix.go:184 6  0x000000000115f5a8 in net.(*TCPListener).accept    at /Users/xxx/Documents/go1.18/src/net/tcpsock_posix.go:139 7  0x000000000115e91d in net.(*TCPListener).Accept    at /Users/xxx/Documents/go1.18/src/net/tcpsock.go:288 8  0x00000000011ff56a in net/http.(*onceCloseListener).Accept    at <autogenerated>:1 9  0x00000000011f3145 in net/http.(*Server).Serve    at /Users/xxx/Documents/go1.18/src/net/http/server.go:303910  0x00000000011f2d7d in net/http.(*Server).ListenAndServe    at /Users/xxx/Documents/go1.18/src/net/http/server.go:2968

  有了这个调用栈,socket读写操作的整个流程基本上没有太大问题了,这里就不再赘述了。咱们能够简略看一下Accept的逻辑,是不是之前咱们说的,非阻塞读写,如果不可读或者不可写,立刻返回EAGAIN,同时将该socket增加到epoll对象监听,以及阻塞以后协程,并切换到调度器schedule。

func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {    if err := fd.readLock(); err != nil {        return -1, nil, "", err    }    defer fd.readUnlock()    if err := fd.pd.prepareRead(fd.isFile); err != nil {        return -1, nil, "", err    }    for {        s, rsa, errcall, err := accept(fd.Sysfd)        if err == nil {            return s, rsa, "", err        }        switch err {        case syscall.EINTR:            continue        case syscall.EAGAIN:            if fd.pd.pollable() {                if err = fd.pd.waitRead(fd.isFile); err == nil {                    continue                }            }        case syscall.ECONNABORTED:            // This means that a socket on the listen            // queue was closed before we Accept()ed it;            // it's a silly error, so try again.            continue        }        return -1, nil, errcall, err    }}

  for循环始终尝试执行accept,如果返回EAGAIN;函数waitRead底层就是监听读socket事件,并且阻塞协程以及切换到调度器schedule。

读写超时

  下面咱们简略介绍了socket读写操作根本流程,调度器Schedule以及辅助线程sysmon检测socket根本流程。还有两个问题,咱们没有提到:1)socket可读或者可写时,如何关联到协程呢?怎么晓得哪些协程因为这个socket阻塞了呢?2)高性能服务,socket读写操作必定是须要设置正当的超时工夫的,不然如果依赖服务变慢,用户协程也会跟着长时间阻塞。socket读写超时,怎么实现呢?

  咱们先答复第一个问题,在回顾下epoll的三个API,其中波及到一个构造体epoll_event,不仅蕴含了socket fd,还蕴含一个void类型指针,通常指向用户自定义数据。Go语言也是这么做的,自定义了构造runtime.pollDesc:

type pollDesc struct {    fd   uintptr   // constant for pollDesc usage lifetime        //指向读socket阻塞的协程    rg atomic.Uintptr // pdReady, pdWait, G waiting for read or nil    //指向写socket阻塞的协程    wg atomic.Uintptr // pdReady, pdWait, G waiting for write or nil    //读超时定时器    rt      timer     // read deadline timer (set if rt.f != nil)    rd      int64     // read deadline (a nanotime in the future, -1 when expired)    //写超时定时器    wt      timer     // write deadline timer    wd      int64     // write deadline (a nanotime in the future, -1 when expired)}

  pollDesc构造蕴含了读写socket阻塞的协程指针,这样一来,在通过epoll_ctl监听socket时,使得epoll_event指向pollDesc构造就行了,epoll_wait返回事件列表之后,就能解析进去构造pollDesc,从而解除对应协程的阻塞。

  另外,咱们也能看到pollDesc构造还蕴含了设置的读写超时工夫,以及超时定时器。通过这定义也根本能确定,socket超时是基于定时器实现的。如果你认真钻研过上一大节介绍的socket读写操作流程,应该就能在internal/poll/fd_poll_runtime.go发现还有其余一些函数申明,包含runtime_pollSetDeadline,设置超时工夫。Go语言解决HTTP申请时,默认是有读写超时工夫的,同样的,咱们能够输入该流程的调用栈:

0  0x000000000105d0ef in internal/poll.runtime_pollSetDeadline   at /Users/xxx/Documents/go1.18/src/runtime/netpoll.go:3231  0x000000000109e95e in internal/poll.setDeadlineImpl   at /Users/xxx/Documents/go1.18/src/internal/poll/fd_poll_runtime.go:1602  0x000000000115a0c8 in internal/poll.(*FD).SetReadDeadline   at /Users/xxx/Documents/go1.18/src/internal/poll/fd_poll_runtime.go:1373  0x000000000115a0c8 in net.(*netFD).SetReadDeadline   at /Users/xxx/Documents/go1.18/src/net/fd_posix.go:1424  0x000000000115a0c8 in net.(*conn).SetReadDeadline   at /Users/xxx/Documents/go1.18/src/net/net.go:2505  0x00000000011ea591 in net/http.(*conn).readRequest   at /Users/xxx/Documents/go1.18/src/net/http/server.go:9756  0x00000000011ee9ab in net/http.(*conn).serve   at /Users/xxx/Documents/go1.18/src/net/http/server.go:18917  0x00000000011f352e in net/http.(*Server).Serve.func3   at /Users/xxx/Documents/go1.18/src/net/http/server.go:3071

  咱们曾经晓得,超时工夫是通过定时器实现的,所以函数poll_runtime_pollSetDeadline最终其实也是增加了定时器而已(定时器将在下一篇文章介绍),而定时器的处理函数为netpollReadDeadline或netpollDeadline或netpollWriteDeadline(依据读写操作不同)。超时了怎么办?一来必定是设置超时标识,二来如果以后有协程因为socket阻塞还需唤醒该协程。

总结

  Go语言高性能网络IO其实还是基于IO多路复用技术(如epoll)实现的,读写socket都是非阻塞操作,如果不可读或者不可写,则将该socket增加到epoll对象监听。而调度器schedule,以及辅助线程sysmon也会不定时检测是否有socket又扭转状态可读或者可写了,从而复原这些因为socket读写而阻塞的协程。