golang笔记——深入了解netpoller

大部分的服务都是 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 模型会使用不同的方式操作文件描述符:

  1. 阻塞I/O
  2. 非阻塞I/O
  3. I/O多路复用
  4. 信号驱动I/O
  5. 异步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_NONBLOCKfcntl 为我们提供了操作文件描述符的能力,我们可以通过它修改文件描述符的特性。当我们将文件描述符修改成非阻塞后,读写文件会经历以下流程:

第一次从文件描述符中读取数据会触发系统调用并返回 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。这种机制的使用需要 selectpollepoll来配合。

select

在select这种I/O多路复用机制下,我们需要把想监控的fd集合通过函数参数的形式告诉select,然后select会将这些文件描述符集合拷贝到内核中。

我们知道数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过1024个,同时当select返回后我们仅仅能知道有些文件描述符可以读写了,但是我们不知道是哪一个,因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。

select的缺点

  1. 能监控的文件描述符太少,通过 FD_SETSIZE 设置,默认1024个
  2. 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大(需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大)
  3. 每次调用select,都需要在内核,遍历fd集合进行无差别轮询,性能开销大(如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的)
poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。

poll的缺点

  1. 每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
  2. 对 fd集合 扫描是线性扫描,采用轮询的方法,效率较低(高并发时)
epoll

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

epoll函数接口:

#include <sys/epoll.h>

// 数据结构
// 每一个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的优点
  1. 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)
  2. 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll
  • epoll的缺点
  1. epoll只能工作在 linux 下
  • epoll LT 与 ET 模式的区别
    epoll 有 EPOLLLT 和 EPOLLET 两种触发模式,LT 是默认的模式,ET 是 “高速” 模式。
  1. LT 模式下,只要这个 fd 还有数据可读,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作;
  2. 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方式可能性能更好。

selectpoll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

selectpoll每次调用都要把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,具体的源码路径如下:

本文的解析基于 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种状态:

  1. pdReady == 1: 网络io就绪通知,goroutine消费完后应置为nil

  2. pdWait == 2: goroutine等待被挂起,后续可能有3种情况:

    • goroutine被调度器挂起,置为goroutine地址
    • 收到io通知,置为pdReady
    • 超时或者被关闭,置为nil
  3. Goroutine地址: 被挂起的goroutine的地址,当io就绪时、或者超时、被关闭时,此goroutine将被唤醒,同时将状态改为pdReady或者nil。

另外,由于wg、rg是goroutine的地址,因此当GC发生后,如果goroutine被回收(在heap区),代码就崩溃了(指针无效)。所以,进行网络IO的goroutine不能在heap区分配内存

lock锁对象保护了pollOpen, pollSetDeadline, pollUnblockdeadlineimpl操作。而这些操作又完全包含了对seq, rt, tw变量。fd在PollDesc整个生命过程中都是一个常量。处理pollReset, pollWait, pollWaitCanceledruntime.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 的状态做出以下不同的处理:

  1. 如果结构体中的计时器没有设置执行的函数时,该函数会设置计时器到期后执行的函数、传入的参数并调用 runtime.resettimer 重置计时器;
  2. 如果结构体的读截止日期已经被改变,我们会根据新的截止日期做出不同的处理:
    1. 如果新的截止日期大于 0,调用 runtime.modtimer 修改计时器;
    2. 如果新的截止日期小于 0,调用 runtime.deltimer 删除计时器;

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.netpollDeadlineruntime.netpollReadDeadlineruntime.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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,009评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,808评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,891评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,283评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,285评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,409评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,809评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,487评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,680评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,499评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,548评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,268评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,815评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,872评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,102评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,683评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,253评论 2 341

推荐阅读更多精彩内容