咱们都晓得用户程序读写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读写而阻塞的协程。