go 的 http.server 主要逻辑:net.listen 会创建一个 TCPListener 去监听一个端口,listener.Accept 生成 Conn 链接,然后启动 goroutine 执行 conn.serve 函数。这里面有几个关键的问题
- TCPListener 就只是简单的套接字监听端口吗?
- Conn 到底是什么?怎么实现的读写套接字?
- 阻塞怎么处理唤醒?监听套接字的时候可能会遇到没有新的请求而阻塞的问题,Conn 读写套接字也会
解决了上面的几个问题,http.server 高性能的秘密也就浮出水面了。
下面说明中会用到 epoll ,所以先把 epoll 的三个函数贴在这里
#include <sys/epoll.h>
int epoll_create(int size); // int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
TCPListener 监听的逻辑
net.listen 函数创建一个非阻塞的套接字(http.server 中涉及到的套接字都是非阻塞的),拿到套接字的文件描述符包装成 go netFD (网络文件描述符),之后完成套接字的 bind 和 listen 操作。假如是常规的套接字操作,到这里之后就是套接字的 accept 操作了。但是 go 在上面的操作完成之后,会创建 epoll,并且把上面的套接字的系统文件描述符注册到 epoll 的事件监听列表中,然再执行套接字的 accept 操作。
我们知道 go 的基本运行单元是 goroutine,现在只把文件描述符注册到 epoll 事件监听列表中,即使调用 epoll_wait 获取到的也只是文件描述符,文件描述符没法运行。所以 go 在把文件描述符注册到 epoll 事件监听列表中的时候,会创建一个 pollDesc 结构体,而这个 pollDesc 结构体正是上面说的 netFD 的属性。go 在调用 epoll_ctl 方法时,把 event.data 指向 pollDesc,而在添加完成时,会返回 pollDesc ,此时 netFD -> pollDesc->文件描述符就关联上了(其实返回的 pollDesc 被赋值给 netFD.pollDesc.runtimeCtx 属性了)
现在 pollDesc 作为 evnet.data 参数和系统描述符产生了关系,而 pollDesc 又有 rg 和 wg 两个属性,存储等待读取和等待写入的 goroutine,所以在调用 epoll_wait 获取文件描述符和 event 参数之后,可以根据 event 的类型是读取还是写入,分别取 pollDesc 的 rg 和 wg 放到全局的调度队列里,或者调度执行。
epoll 的整体流程感觉已经通了,只是还有一个小问题,pollDesc 的 rg 和 wg 怎么赋值的,解决这个问题就要回到最初的 netFD 身上了。我们知道在上面一系列的 bind 和 listen 操作之后,会返回一个网络文件描述符 netFD,这个结构体里既包含系统文件描述符也包含 pollDesc。而 TCPListener 只是简单的包装了一下 netFD,本质上就是 netFD 。
TCPListener.Accept 方法会通过 netFD 调用套接字的 accept 方法,假如当前没有需要处理的请求,会把当前 g 赋值给 pollDesc.rg,并且调用 gopark 把当前 g park 住。一直到 epoll 收到可读的事件消息,并且调用 epoll_wait 方法把文件描述符和 event 取出来,继而取出 pollDes.rg 放到调度里。
Conn
conn 是什么创建出来的呢?是 TCPListener.Accept 创建出来的。上面的已经说了 accept 时没有请求的情况,下面说一下有请求的情况。
假如当前存在请求,此时会新建一个非阻塞的套接字,用 netFD 包装套接字的文件描述符,同样会把文件描述符加入到 epoll 中,并且依然会把 event.data 指向 pollDesc,和上面 listener 的套接字加入到 epoll 的流程一模一样。而 Conn 也和 TCPListener 一样,也是包装了 netFD。
所以 Conn 的读写,本质上也是 netFD 的读写,是套接字的读写,假如不能进行读写时,也会把当前 g 赋值给 pollDescription 的 rg 或者 wg,并且 park 住当前 g。因为获取到 Conn 之后,是 go c.serve(ctx) 执行读写操作,所以每一个请求都会关联一个 g 处理。
调度 epoll_wait
上面说了一大堆 Conn 和 TCPListener 阻塞的逻辑,下面来聊聊唤醒操作,也就是聊一聊什么时候调用 epoll_wait 。关于 epoll_wait 的调用主要在两个地方,一个是在调度中获取可运行的 g 来执行时,会调用 epoll_wait,看一下有没有激活的事件,有的话,会执行第一个 g,并把其他的 g 放到全局队列里去。
第二个地方是在 sysmon 调度监控的时候,假如发现 netpoll 超过 10 ms 没有被轮训过,则会调用 epoll_wait,把所有激动的事件对应的 g 放到全局队列中去。
epoll_wait 和调度程序耦合在一起,保证了请求能被很好的调度处理
总结一波
http.server 使用了 epoll 来监听和处理套接字对应的文件描述符,并且每一个套接字都对应一个 g,当套接字不能进行读写的时候对应的 g 就会被 park 中,而调度中耦合 epoll_wait 则保证了请求能被即时的处理,也就是 park 中的 g 被及时唤醒。由于套接字对应的 g 被放到 go 调度中执行,所以 g 会被挂到不同的 p 上被不同的 m 处理,和 go 调度完全融为一体。