大部分的服务都是 I/O 密集型的,应用程序会花费大量时间等待 I/O 操作的完成。网络轮询器(netpoller)是 Go 语言运行时用来处理 I/O 操作的关键组件,它使用了操作系统提供的 I/O 多路复用机制增强程序的并发处理能力。本文会详细介绍I/O模型相关知识,并深入分析 Go 语言网络轮询器的设计与实现原理。
I/O 相关基础概念
文件
在Linux世界中文件是一个很简单的概念,作为程序员我们只需要将其理解为一个N byte的序列就可以了。
实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件,Everything is File,磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。
所有的I/O操作也都可以通过文件读写来实现,这一非常优雅的抽象可以让程序员使用一套接口就能对所有外设I/O操作。
常用的I/O操作接口一般有以下几类:
• 打开文件,open
• 改变读写位置,seek
• 文件读写,read、write
• 关闭文件,close
程序员通过这几个接口几乎可以实现所有I/O操作,这就是文件这个概念的强大之处。
文件描述符
要想进行I/O读操作,像磁盘数据,我们需要指定一个buff用来装入数据,一般都是这样写的
read(buff)
虽然我们指定了往哪里写数据,但是我们该从哪里读数据呢?我们无法确定哪个文件是我们需要去读取的。Linux为了高效管理已被打开的文件,于是引入了索引:文件描述符(file descriptor)。fd
用于指代被打开的文件,对文件所有 I/O 操作相关的系统调用都需要通过fd
。
有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。
因此我们来完善上述程序:
int fd = open(file_name); // 获取文件描述符read(fd, buff);
read(fd, buff);
I/O模型
目前Linux系统中提供了以下5种IO处理模型,不同的 I/O 模型会使用不同的方式操作文件描述符:
- 阻塞I/O
- 非阻塞I/O
- I/O多路复用
- 信号驱动I/O
- 异步I/O
阻塞I/O(Blocking I/O)
阻塞 I/O 是最常见的 I/O 模型,在默认情况下,当我们通过 read 或者 write 等系统调用读写文件或者网络时,应用程序会被阻塞。
如下图所示,当我们执行 recvfrom 系统调用时,应用程序会从用户态陷入内核态,内核会检查文件描述符是否就绪;当文件描述符中存在数据时,操作系统内核会将准备好的数据拷贝给应用程序并交回控制权。线程会阻塞在这里,然后挂起(挂起的时候,cpu回去处理其他任务),等待队列不为空。
非阻塞I/O(NoneBlocking I/O)
当进程把一个文件描述符设置成非阻塞时,执行 read 和 write 等 I/O 操作会立刻返回。在 C 语言中,我们可以使用如下所示的代码片段将一个文件描述符设置成非阻塞的:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
在上述代码中,最关键的就是系统调用 fcntl
和参数 O_NONBLOCK
,fcntl
为我们提供了操作文件描述符的能力,我们可以通过它修改文件描述符的特性。当我们将文件描述符修改成非阻塞后,读写文件会经历以下流程:
第一次从文件描述符中读取数据会触发系统调用并返回 EAGAIN
错误,EAGAIN
意味着该文件描述符还在等待缓冲区中的数据;随后,应用程序会不断轮询调用 read
直到它的返回值大于 0,这时应用程序就可以对读取操作系统缓冲区中的数据并进行操作。进程使用非阻塞的 I/O 操作时,可以在等待过程中执行其他任务,提高 CPU 的利用率。
I/O多路复用(I/O multiplexing)
原本是一个 I/O对应一个进程,这样的话如果有1000个i/o 就需要启动1000个进程,这对于一个16核,8核的cpu来说,需要大量性能损耗在进程间的切换上。所以优化了一种方案是N个i/o只对应一个进程来处理。这个就是I/O 多路复用。
I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符;一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作;没有文件句柄就绪就会阻塞应用程序,交出CPU。这种机制的使用需要 select
、 poll
、 epoll
来配合。
select
在select这种I/O多路复用机制下,我们需要把想监控的fd
集合通过函数参数的形式告诉select,然后select会将这些文件描述符集合拷贝到内核中。
select的缺点:
- 能监控的文件描述符太少,通过 FD_SETSIZE 设置,默认1024个
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大(需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大)
- 每次调用select,都需要在内核,遍历fd集合进行无差别轮询,性能开销大(如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的)
poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。
poll的缺点:
- 每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
- 对 fd集合 扫描是线性扫描,采用轮询的方法,效率较低(高并发时)
epoll
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
epoll函数接口:
#include
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
};
// API
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 fd 都放到 ep 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 fd 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 负责检测可读队列,没有可读 fd 则阻塞
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是logn,其中n为红黑树元素个数)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
从上面的讲解可知:通过于红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
讲解完了Epoll的机理,我们便能很容易掌握epoll的用法了。一句话描述就是:三步曲。
第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。创建一个epoll句柄,实际上是在内核空间,建立一个root根节点,这个根节点的关系与epfd相对应。
第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。创建的该用户态事件,绑定到某个fd上,然后添加到内核中的epoll红黑树中。
第三步:epoll_wait()系统调用。通过此调用收集在epoll监控中已经发生的事件。如果内核检测到IO的读写响应,会抛给上层的epoll_wait, 返回给用户态一个已经触发的事件队列,同时阻塞返回。开发者可以从队列中取出事件来处理,其中事件里就有绑定的对应fd是哪个(之前添加epoll事件的时候已经绑定)。
- epoll的优点
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
- 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll
- epoll的缺点
- epoll只能工作在 linux 下
- epoll LT 与 ET 模式的区别
epoll 有 EPOLLLT 和 EPOLLET 两种触发模式,LT 是默认的模式,ET 是 “高速” 模式。
- LT 模式下,只要这个 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作;
- ET 模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论 fd 中是否还有数据可读。所以在 ET 模式下,read 一个 fd 的时候一定要把它的 buffer 读完,或者遇到 EAGIN 错误。
epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
select/poll/epoll之间的区别
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。
select | poll | epoll | |
---|---|---|---|
获取可用的fd | 遍历 | 遍历 | 回调 |
存储fd的数据结构 | bitmap | 数组 | 红黑树 |
最大连接数 | 1024(x86)或 2048(x64) | 无上限 | 无上限 |
最大支持fd数量 | 一般有最大值限制 | 65535 | 65535 |
fd拷贝 | 每次调用select,都需要把fd集合从用户态拷贝到内核态 | 每次调用poll,都需要把fd集合从用户态拷贝到内核态 | fd首次调用epoll_ctl拷贝,每次调用epoll_wait不拷贝 |
工作模式 | LT | LT | 支持lT默认模式及ET高效模式 |
工作效率 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1) |
epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。
select
,poll
实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll
其实也需要调用epoll_wait
不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
select
,poll
每次调用都要把fd
集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次。而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次*(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
信号驱动I/O
在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。这个一般用于UDP中,对TCP套接字几乎没用,原因是该信号产生得过于频繁,并且该信号的出现并没有告诉我们发生了什么请求。
用户进程可以使用信号方式,当系统内核描述符就绪时将会发送SIGNO给到用户空间,这个时候再发起recvfrom的系统调用等待返回成功提示,流程如下:
- 先开启套接字的信号IO启动功能,并通过一个内置安装信号处理函数的signaction系统调用,当发起调用之后会直接返回;
- 其次,等待内核从网络中接收数据报之后,向用户空间发送当前数据可达的信号给信号处理函数;
- 信号处理函数接收到信息就发起recvfrom系统调用等待内核数据复制数据报到用户空间的缓冲区;
- 接收到复制完成的返回成功提示之后,应用进程就可以开始从网络中读取数据。
异步I/O
前面四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。
- 由POSIX规范定义,告知系统内核启动某个操作,并让内核在整个操作包含数据等待以及数据复制过程的完成之后通知用户进程数据已经准备完成,可以进行读取数据;
- 与上述的信号IO模型区分在于异步是通知我们何时IO操作完成,而信号IO是通知我们何时可以启动一个IO操作
同步IO/异步IO/阻塞IO/非阻塞IO(基于POSIX规范)
- 同步IO: 表示应用进程发起真实的IO操作请求(recvfrom)导致进程一直处于等待状态,这时候进程被阻塞,直到IO操作完成返回成功提示
- 异步IO: 表示应用进程发起真实的IO操作请求(recvfrom)导致进程将直接返回一个错误信息,“相当于告诉进程还没有处理好,好了会通知你”
- 阻塞IO: 主要是体现发起IO操作请求通知内核并且内核接收到信号之后如果让进程等待,那么就是阻塞
- 非阻塞IO: 发起IO操作请求的时候不论结果直接告诉进程“不用等待,晚点再来”,那就是非阻塞
IO模型对比
除了真正的异步I/O模型以外,其他几种模型,最后一阶段的处理都是相同的——阻塞于recvfrom调用,将数据从内核拷贝到应用缓冲区。
同步与异步针对通信机制,阻塞与非阻塞针对程序调用等待结果的状态
netpoller
netpoller基本原理
在Go的实现中,所有IO都是阻塞调用的,Go的设计思想是程序员使用阻塞式的接口来编写程序,然后通过goroutine+channel来处理并发。因此所有的IO逻辑都是直来直去的,先xx,再xx, 你不再需要回调,不再需要future,要的仅仅是step by step。这对于代码的可读性是很有帮助的。
netpoller的工作就是成为同步(阻塞)IO调用和异步(非阻塞)IO调用之间的桥梁。
Go netpoller 通过在底层对 epoll/kqueue/iocp 的封装,从而实现了使用同步编程模式达到异步执行的效果。总结来说,所有的网络操作都以网络描述符 netFD 为中心实现。netFD 与底层 PollDesc 结构绑定,当在一个 netFD 上读写遇到 EAGAIN 错误时,就将当前 goroutine 存储到这个 netFD 对应的 PollDesc 中,同时调用 gopark 把当前 goroutine 给 park 住,直到这个 netFD 上再次发生读写事件,才将此 goroutine 给 ready 激活重新运行。显然,在底层通知 goroutine 再次发生读写等事件的方式就是 epoll/kqueue/iocp 等事件驱动机制。
Go 是一门跨平台的编程语言,而不同平台针对特定的功能有不用的实现,这当然也包括了 I/O 多路复用技术,比如 Linux 里的 I/O 多路复用有 select、poll 和 epoll,而 freeBSD 或者 MacOS 里则是 kqueue,而 Windows 里则是基于异步 I/O 实现的 iocp,等等;因此,Go 为了实现底层 I/O 多路复用的跨平台,分别基于上述的这些不同平台的系统调用实现了多版本的 netpollers,具体的源码路径如下:
- src/runtime/netpoll_epoll.go
- src/runtime/netpoll_kqueue.go
- src/runtime/netpoll_solaris.go
- src/runtime/netpoll_windows.go
- src/runtime/netpoll_aix.go
- src/runtime/netpoll_fake.go
本文的解析基于 epoll 版本,如果读者对其他平台的 netpoller 底层实现感兴趣,可以在阅读完本文后自行翻阅其他 netpoller 源码,所有实现版本的机制和原理基本类似。
netpoller代码结构概览
实际的实现(epoll/kqueue)必须定义以下函数:
func netpollinit() // 初始化轮询器
func netpollopen(fd uintptr, pd *pollDesc) int32 // 为fd和pd启动边缘触发通知
当一个goroutine进行io阻塞时,会去被放到等待队列。这里面就关键的就是建立起文件描述符和goroutine之间的关联。 pollDesc结构体就是完成这个任务的。代码参见src/runtime/netpoll.go
type pollDesc struct { // Poller对象
link *pollDesc // 链表
lock mutex // 保护下面字段
fd uintptr // fd是底层网络io文件描述符,整个生命期内,不能改变值
closing bool
seq uintptr // protect from stale(过时) timers and ready notifications
rg uintptr // reader goroutine addr
rt timer
rd int64
wg uintptr // writer goroutine addr
wt timer
wd int64
user int32 // user-set cookie用户自定义数据
}
type pollCache struct { // 全局Poller链表
lock mutex // 保护Poller链表
first *pollDesc
}
// 调用netpollinit()
func poll_runtime_pollServerInit() {}
// 调用netpollopen()
func poll_runtime_pollOpen() {}
// 调用netpollclose()
func poll_runtime_pollClose() {}
// 先check(netpollcheckerr(pd, mode))是否有err发生,没有的话重置pd对应字段
func poll_runtime_pollReset(pd, mode) {}
// 先chekerr,再调用netpollblock(pd, mode, false) {}
func poll_runtime_pollWait(pd, mode) {}
// windows下专用
func poll_runtime_pollWaitCanceled(pd, mode) {}
func poll_runtime_pollSetDeadline(pd, deadline, mode) {}
//1. 重置定时器,并seq++
//2. 设置超时函数netpollDeadline(或者netpollReadDeadline、netpollWriteDeadline)
//3. 如果已经过期,调用netpollunblock和netpollgoready
// netpollUnblock、netpollgoready
func poll_runtime_pollUnblock(pd) {}
/*------------------部分实现------------------*/
// 检查是否超时或正在关闭
func netpollcheckerr(pd, mode) {}
func netpollblockcommit(gp *g, gpp unsafe.Pointer) {}
// 调用netpollunblock,更新g的
func netpollready(gpp *guintptr, pd, mode) schedlink {}
// 更新统计数据,调用goready --- 通知调度器协程g从parked变为ready
func netpollgoready(gp *g, traceskip) {}
// Set rg/wg = pdWait,调用gopark挂起pd对应的g。
func netpollblock(pd, mode, waitio) {}
func netpollunblock(pd, mode, ioready) {}
func netpoll(Write/Read)Deadline(arg, seq) {}
pollCache
是pollDesc链表入口,加锁保护链表安全。
pollDesc
中,rg、wg有些特殊,它可能有如下3种状态:
pdReady == 1
: 网络io就绪通知,goroutine消费完后应置为nil-
pdWait == 2
: goroutine等待被挂起,后续可能有3种情况:- goroutine被调度器挂起,置为goroutine地址
- 收到io通知,置为pdReady
- 超时或者被关闭,置为nil
Goroutine地址: 被挂起的goroutine的地址,当io就绪时、或者超时、被关闭时,此goroutine将被唤醒,同时将状态改为pdReady或者nil。
另外,由于wg、rg是goroutine的地址,因此当GC发生后,如果goroutine被回收(在heap区),代码就崩溃了(指针无效)。所以,进行网络IO的goroutine不能在heap区分配内存。
lock锁对象保护了
pollOpen
,pollSetDeadline
,pollUnblock
和deadlineimpl
操作。而这些操作又完全包含了对seq, rt, tw变量。fd在PollDesc
整个生命过程中都是一个常量。处理pollReset
,pollWait
,pollWaitCanceled
和runtime.netpollready
(IO就绪通知)不需要用到锁,所以closing, rg, rd, wg和wd的所有操作都是一个无锁的操作。
netpoller多路复用三部曲
初始化PollServer
初始化在下面注册fd监听时顺便处理了,调用runtime_pollServerInit()
,并使用sync.Once()
机制保证只会被初始化一次。全局使用同一个EpollServer
(同一个Epfd
)。
func poll_runtime_pollServerInit() {
netpollGenericInit()
}
func netpollGenericInit() {
if atomic.Load(&netpollInited) == 0 {
lockInit(&netpollInitLock, lockRankNetpollInit)
lock(&netpollInitLock)
if netpollInited == 0 {
netpollinit() // 具现化到Linux下,调用epoll_create
atomic.Store(&netpollInited, 1)
}
unlock(&netpollInitLock)
}
}
func netpollinit() {
epfd = epollcreate1(_EPOLL_CLOEXEC)
if epfd < 0 {
epfd = epollcreate(1024)
if epfd < 0 {
println("runtime: epollcreate failed with", -epfd)
throw("runtime: netpollinit failed")
}
closeonexec(epfd)
}
r, w, errno := nonblockingPipe()
if errno != 0 {
println("runtime: pipe failed with", -errno)
throw("runtime: pipe failed")
}
ev := epollevent{
events: _EPOLLIN,
}
*(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd
errno = epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)
if errno != 0 {
println("runtime: epollctl failed with", -errno)
throw("runtime: epollctl failed")
}
netpollBreakRd = uintptr(r)
netpollBreakWr = uintptr(w)
}
注册监听fd
所有Unix文件在初始化时,如果支持Poll,都会加入到PollServer的监听中。
/*****************internal/poll/fd_unix.go*******************/
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex
// System file descriptor. Immutable until Close.
Sysfd int
// I/O poller.
pd pollDesc
...
}
func(fd *FD) Init(net string, pollable bool) error {
...
err := fd.pd.init(fd) // 初始化pd
...
}
...
/*****************internal/poll/fd_poll_runtime.go*****************/
type pollDesc struct {
runtimeCtx uintptr
}
func (pd *pollDesc) init(fd *FD) error {
serverInit.Do(runtime_pollServerInit) // 初始化PollServer(sync.Once)
ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
...
runtimeCtx = ctx
return nil
}
...
/*****************runtime/netpoll.go*****************/
func poll_runtime_pollOpen(fd uintptr) (*epDesc, int32) {
...
errno := netpollopen(fd, pd) // 具现化到Linux下,调用epoll_ctl
...
}
取消fd的监听与此流程类似,最终调用epoll_ctl
.
定期Poll
结合上述实现,必然有处逻辑定期执行epoll_wait
来检测fd
状态。在代码中搜索下netpoll
,即可发现是在sysmon、startTheWorldWithSema、pollWork、findrunnable
中调用的,以sysmon
为例:
// runtime/proc.go
...
lastpoll := int64(atomic.Load64(&sched.lastpoll))
now := nanotime()
// 如果10ms内没有poll过,则poll。(1ms=1000000ns)
if lastpoll != 0 && lastpoll+10*1000*1000 < now {
atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
gp := netpoll(false) // netpoll在Linux具现为epoll_wait
if gp != nil {
injectglist(gp) //把g放到sched中去执行,底层仍然是调用的之前在goroutine里面提到的startm函数。
}
}
...
goroutine的I/O读取流程
当goroutine发起一个同步调用,经过一系列的调用,最后会进入gopark函数,gopark将当前正在执行的goroutine状态保存起来,然后切换到新的堆栈上执行新的goroutine。由于当前goroutine状态是被保存起来的,因此后面可以被恢复。这样调用Read的goroutine以为一直同步阻塞到现在,其实内部是异步完成的。
1. 加入监听
golang中客户端与服务端进行通讯时,常用如下方法:
conn, err := net.Dial("tcp", "localhost:1208")
从net.Dial看进去,最终会调用net/net_posix.go中的socket函数,大致流程如下:
func socket(...) ... {
/*
1. 调用sysSocket创建原生socket
2. 调用同名包下netFd(),初始化网络文件描述符netFd
3. 调用fd.dial(),其中最终有调用poll_runtime_pollOpen()加入监听列表
*/
}
runtime.poll_runtime_pollOpen
重置轮询信息 runtime.pollDesc
并调用 runtime.netpollopen
初始化轮询事件:
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
pd := pollcache.alloc()
lock(&pd.lock)
if pd.wg != 0 && pd.wg != pdReady {
throw("runtime: blocked write on free polldesc")
}
...
pd.fd = fd
pd.closing = false
pd.everr = false
...
pd.wseq++
pd.wg = 0
pd.wd = 0
unlock(&pd.lock)
var errno int32
// 初始化轮询事件
errno = netpollopen(fd, pd)
return pd, int(errno)
}
runtime.netpollopen
的实现非常简单,它会调用 epollctl
向全局的轮询文件描述符 epfd
中加入新的轮询事件监听文件描述符的可读和可写状态:
func netpollopen(fd uintptr, pd *pollDesc) int32 {
var ev epollevent
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}
从全局的 epfd
中删除待监听的文件描述符可以使用 runtime.netpollclose
,因为该函数的实现与 runtime.netpollopen
比较相似,所以这里不展开分析了。
2. 读等待
主要是挂起goroutine,并建立gorotine和fd之间的关联。
当从netFd读取数据时,调用system call,循环从fd.sysfd读取数据:
func (fd *FD) Read(p []byte) (int, error) {
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return 0, err
}
if fd.IsStream && len(p) > maxRW {
p = p[:maxRW]
}
for {
n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
}
err = fd.eofError(n, err)
return n, err
}
}
读取的时候只处理EAGAIN
类型的错误,其他错误一律返回给调用者,因为对于非阻塞的网络连接的文件描述符,如果错误是EAGAIN
,说明Socket的缓冲区为空,未读取到任何数据,则调用fd.pd.WaitRead
:
func (pd *pollDesc) waitRead(isFile bool) error {
return pd.wait('r', isFile)
}
func (pd *pollDesc) wait(mode int, isFile bool) error {
if pd.runtimeCtx == 0 {
return errors.New("waiting for unsupported file type")
}
res := runtime_pollWait(pd.runtimeCtx, mode)
return convertErr(res, isFile)
}
res是runtime_pollWait函数返回的结果,由conevertErr函数包装后返回:
func convertErr(res int, isFile bool) error {
switch res {
case 0:
return nil
case 1:
return errClosing(isFile)
case 2:
return ErrTimeout
}
println("unreachable: ", res)
panic("unreachable")
}
其中0表示io已经准备好了,1表示链接意见关闭,2表示io超时。再来看看pollWait的实现:
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
err := netpollcheckerr(pd, int32(mode))
if err != 0 {
return err
}
for !netpollblock(pd, int32(mode), false) {
err = netpollcheckerr(pd, int32(mode))
if err != 0 {
return err
}
}
return 0
}
调用netpollblock来判断IO是否准备好了:
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
gpp := &pd.rg
if mode == 'w' {
gpp = &pd.wg
}
for {
old := *gpp
if old == pdReady {
*gpp = 0
return true
}
if old != 0 {
throw("runtime: double wait")
}
if atomic.Casuintptr(gpp, 0, pdWait) {
break
}
}
if waitio || netpollcheckerr(pd, mode) == 0 {
gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5)
}
old := atomic.Xchguintptr(gpp, 0)
if old > pdWait {
throw("runtime: corrupted polldesc")
}
return old == pdReady
}
返回true说明IO已经准备好,返回false说明IO操作已经超时或者已经关闭。否则当waitio为false, 且io不出现错误或者超时才会挂起当前goroutine。
最后的gopark函数,就是将当前的goroutine(调用者)设置为waiting状态:
// Puts the current goroutine into a waiting state and calls unlockf.
// If unlockf returns false, the goroutine is resumed.
// unlockf must not access this G's stack, as it may be moved between
// the call to gopark and the call to unlockf.
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason string, traceEv byte, traceskip int) {
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = *(*unsafe.Pointer)(unsafe.Pointer(&unlockf))
gp.waitreason = reason
mp.waittraceev = traceEv
mp.waittraceskip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)
}
mcall(park_m)
将会挂起当前与g绑定的m:
func park_m(gp *g) {
_g_ := getg()
if trace.enabled {
traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
}
casgstatus(gp, _Grunning, _Gwaiting)
dropg()
if _g_.m.waitunlockf != nil {
fn := *(*func(*g, unsafe.Pointer) bool)(unsafe.Pointer(&_g_.m.waitunlockf))
ok := fn(gp, _g_.m.waitlock)
_g_.m.waitunlockf = nil
_g_.m.waitlock = nil
if !ok {
if trace.enabled {
traceGoUnpark(gp, 2)
}
casgstatus(gp, _Gwaiting, _Grunnable)
execute(gp, true) // Schedule it back, never returns.
}
}
schedule()
}
3. 就绪唤醒
那什么时候goroutine被唤醒并调度回来呢?运行时在执行schedule()
方法时,会通过findrunnable()
,调用netpoll()
检查文件描述符状态。
schedule() -> findrunnable() -> netpoll()
调用netpoll()
寻找到IO就绪的socket文件描述符,并找到这些socket文件描述符对应的轮询器中附带的信息,根据这些信息将之前等待这些socket文件描述符就绪的goroutine状态修改为Grunnable。执行完netpoll之后,会找到一个就绪的goroutine列表,接下来将就绪的goroutine加入到调度队列中,等待调度运行。
下面我们看下netpoll()
的源码实现:
// polls for ready network connections
// returns list of goroutines that become runnable
func netpoll(block bool) *g {
if epfd == -1 {
return gList{}
}
var waitms int32
// 根据传入的 delay 计算 epoll 系统调用需要等待的时间
if delay < 0 {
waitms = -1
} else if delay == 0 {
waitms = 0
} else if delay < 1e6 {
waitms = 1
} else if delay < 1e15 {
waitms = int32(delay / 1e6)
} else {
waitms = 1e9
}
var events [128]epollevent
retry:
// 调用 epollwait 等待可读或者可写事件的发生
n := epollwait(epfd, &events[0], int32(len(events)), waitms)
if n < 0 {
if n != -_EINTR {
println("runtime: epollwait on fd", epfd, "failed with", -n)
throw("runtime: netpoll failed")
}
goto retry
}
var gp guintptr
// 在循环中依次处理 epollevent 事件
for i := int32(0); i < n; i++ {
ev := &events[i]
if ev.events == 0 {
continue
}
var mode int32
if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
mode += 'r'
}
if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 {
mode += 'w'
}
if mode != 0 {
pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
// 文件描述符的正常读写事件,对于这些事件,我们会交给netpollready处理
netpollready(&gp, pd, mode)
}
}
if block && gp == 0 {
goto retry
}
return gp.ptr()
}
当netpoll()
调用epollwait()
获取到被监控的文件描述符出现了待处理的事件,就会在循环中依次调用netpollready()
处理这些事件。
func netpollready(toRun *gList, pd *pollDesc, mode int32) {
var rg, wg *g
if mode == 'r' || mode == 'r'+'w' {
rg = netpollunblock(pd, 'r', true)
}
if mode == 'w' || mode == 'r'+'w' {
wg = netpollunblock(pd, 'w', true)
}
if rg != nil {
toRun.push(rg)
}
if wg != nil {
toRun.push(wg)
}
}
runtime.netpollunblock
会在读写事件发生时,将 runtime.pollDesc
中的读或者写信号量转换成 pdReady
并返回其中存储的 goroutine;如果返回的 Goroutine 不会为空,那么运行时会将该 goroutine 会加入 toRun
列表,并将列表中的全部 goroutine 加入运行队列。
当goroutine 加入运行队列后,在某一次调度goroutine的过程中,处于就绪状态的FD对应的goroutine就会被调度回来。
netpoller超时控制
网络轮询器和计时器的关系非常紧密,这不仅仅是因为网络轮询器负责计时器的唤醒,还因为文件和网络 I/O 的截止日期也由网络轮询器负责处理。截止日期在 I/O 操作中,尤其是网络调用中很关键,网络请求存在很高的不确定因素,我们需要设置一个截止日期保证程序的正常运行,这时需要用到网络轮询器中的 runtime.poll_runtime_pollSetDeadline
:
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
rd0, wd0 := pd.rd, pd.wd
if d > 0 {
d += nanotime()
}
pd.rd = d
...
if pd.rt.f == nil {
if pd.rd > 0 {
pd.rt.f = netpollReadDeadline
pd.rt.arg = pd
pd.rt.seq = pd.rseq
resettimer(&pd.rt, pd.rd)
}
} else if pd.rd != rd0 {
pd.rseq++
if pd.rd > 0 {
modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq)
} else {
deltimer(&pd.rt)
pd.rt.f = nil
}
}
该函数会先使用截止日期计算出过期的时间点,然后根据 runtime.pollDesc
的状态做出以下不同的处理:
- 如果结构体中的计时器没有设置执行的函数时,该函数会设置计时器到期后执行的函数、传入的参数并调用
runtime.resettimer
重置计时器; - 如果结构体的读截止日期已经被改变,我们会根据新的截止日期做出不同的处理:
- 如果新的截止日期大于 0,调用
runtime.modtimer
修改计时器; - 如果新的截止日期小于 0,调用
runtime.deltimer
删除计时器;
- 如果新的截止日期大于 0,调用
在 runtime.poll_runtime_pollSetDeadline
的最后,会重新检查轮询信息中存储的截止日期:
var rg *g
if pd.rd < 0 {
if pd.rd < 0 {
rg = netpollunblock(pd, 'r', false)
}
...
}
if rg != nil {
netpollgoready(rg, 3)
}
...
}
如果截止日期小于 0,上述代码会调用 runtime.netpollgoready
直接唤醒对应的 Goroutine。
在 runtime.poll_runtime_pollSetDeadline
中直接调用 runtime.netpollgoready
是相对比较特殊的情况。在正常情况下,运行时都会在计时器到期时调用 runtime.netpollDeadline
、runtime.netpollReadDeadline
和 runtime.netpollWriteDeadline
三个函数:
上述三个函数都会通过
runtime.netpolldeadlineimpl
调用 runtime.netpollgoready
直接唤醒相应的 Goroutine:
func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {
currentSeq := pd.rseq
if !read {
currentSeq = pd.wseq
}
if seq != currentSeq {
return
}
var rg *g
if read {
pd.rd = -1
atomic.StorepNoWB(unsafe.Pointer(&pd.rt.f), nil)
rg = netpollunblock(pd, 'r', false)
}
...
if rg != nil {
netpollgoready(rg, 0)
}
...
}
Goroutine 在被唤醒之后会意识到当前的 I/O 操作已经超时,可以根据需要选择重试请求或者中止调用。
总结
总的来说,netpoller的最终的效果就是用户层阻塞,底层非阻塞。当goroutine读或写阻塞时会被放到等待队列,这个goroutine失去了运行权,但并不是真正的整个系统“阻塞”于系统调用。而通过后台的poller不停地poll,所有的文件描述符都被添加到了这个poller中的,当某个时刻一个文件描述符准备好了,poller就会唤醒之前因它而阻塞的goroutine,于是goroutine重新运行起来。
和使用Unix系统中的select或是poll方法不同地是,Golang的netpoller查询的是能被调度的goroutine而不是那些函数指针、包含了各种状态变量的struct等,这样你就不用管理这些状态,也不用重新检查函数指针等,这些都是你在传统Unix网络I/O需要操心的问题。
References:
https://zhuanlan.zhihu.com/p/143847169
https://developer.aliyun.com/article/893401
https://zhuanlan.zhihu.com/p/159457916
https://www.yuque.com/aceld/golang/sdgfgu
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-netpoller
https://strikefreedom.top/archives/go-netpoll-io-multiplexing-reactor
https://juejin.cn/post/6882984260672847879
https://mp.weixin.qq.com/s/T-hP3wt4whtvVh1H1LBU3w
https://yizhi.ren/2019/06/08/gonetpoller/
https://www.cnblogs.com/luozhiyun/p/14390824.html
https://cloud.tencent.com/developer/article/1234360
https://learnku.com/articles/59847