我们都知道用户程序读写socket的时候,可能阻塞当前协程,那么是不是说明Go语言采用阻塞方式调用socket相关系统调用呢?你有没有想过,Go语言又是如何实现高性能网络IO呢?有没有使用传说中的IO多路复用,如epoll呢?
探索Go语言网络IO
HTTP服务肯定涉及到socket的读写吧,而且Go语言启动一个HTTP服务还是非常简单的,几行代码就可以搞定,前面也不需要反向代理服务如Nginx,我们写一个简单的HTTP服务来测试:
package main
import (
"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 test
root 27435 0.0 0.0 219452 4636 pts/0 Sl+ 11:00 0:00 ./test
# strace -p 27435
strace: Process 27435 attached
epoll_pwait(5, [{EPOLLIN, {u32=1030856456, u64=140403511762696}}], 128, -1, NULL, 0) = 1
futex(0x9234d0, FUTEX_WAKE_PRIVATE, 1) = 1
accept4(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) = 4
epoll_ctl(5, EPOLL_CTL_ADD, 4, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1030856248, u64=140403511762488}}) = 0
getsockname(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]) = 0
setsockopt(4, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(4, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
setsockopt(4, SOL_TCP, TCP_KEEPINTVL, [180], 4) = 0
setsockopt(4, SOL_TCP, TCP_KEEPIDLE, [180], 4) = 0
futex(0xc000036848, FUTEX_WAKE_PRIVATE, 1) = 1
accept4(3, 0xc0000abac0, 0xc0000abaa4, SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
futex(0x923d48, FUTEX_WAIT_PRIVATE, 0, NULL) = 0
nanosleep({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:3039
10 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:323
1 0x000000000109e95e in internal/poll.setDeadlineImpl
at /Users/xxx/Documents/go1.18/src/internal/poll/fd_poll_runtime.go:160
2 0x000000000115a0c8 in internal/poll.(*FD).SetReadDeadline
at /Users/xxx/Documents/go1.18/src/internal/poll/fd_poll_runtime.go:137
3 0x000000000115a0c8 in net.(*netFD).SetReadDeadline
at /Users/xxx/Documents/go1.18/src/net/fd_posix.go:142
4 0x000000000115a0c8 in net.(*conn).SetReadDeadline
at /Users/xxx/Documents/go1.18/src/net/net.go:250
5 0x00000000011ea591 in net/http.(*conn).readRequest
at /Users/xxx/Documents/go1.18/src/net/http/server.go:975
6 0x00000000011ee9ab in net/http.(*conn).serve
at /Users/xxx/Documents/go1.18/src/net/http/server.go:1891
7 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读写而阻塞的协程。