使用Golang可以轻松地为每一个TCP连接创建一个协程去服务而不用担心性能问题,这是因为Go内部使用goroutine结合IO多路复用实现了一个“异步”的IO模型,这使得开发者不用过多的关注底层,而只需要按照需求编写上层业务逻辑。这种异步的IO是如何实现的呢?下面我会针对Linux系统进行分析。

在Unix/Linux系统下,一切皆文件,每条TCP连接对应了一个socket句柄,这个句柄也可以看做是一个文件,在socket上收发数据,相当于对一个文件进行读写,所以一个socket句柄,通常也用表示文件描述符fd来表示。可以进入/proc/PID/fd/查看进程占用的fd。

系统内核会为每个socket句柄分配一个读(接收)缓冲区和一个写(发送)缓冲区,发送数据就是在这个fd对应的写缓冲区上写数据,而接收数据就是在读缓冲区上读数据,当程序调用write或者send时,并不代表数据发送出去,仅仅是把数据拷贝到了写缓冲区,在时机恰当时候(积累到一定数量),会将数据发送到目的端。

Golang runtime还是需要频繁去检查是否有fd就绪的,严格说并不算真正的异步,算是一种非阻塞IO复用。

IO模型

借用教科书中几张图

阻塞式IO

程序想在缓冲区读数据时,缓冲区并不一定会有数据,这会造成陷入系统调用,只能等待数据可以读取,没有数据读取时则会阻塞住进程,这就是阻塞式IO。当需要为多个客户端提供服务时,可以使用线程方式,每个socket句柄使用一个线程来服务,这样阻塞住的则是某个线程。虽然如此可以解决进程阻塞,但是还是会有相当一部分CPU资源浪费在了等待数据上,同时,使用线程来服务fd有些浪费资源,因为如果要处理的fd较多,则又是一笔资源开销。



非阻塞式IO

与之对应的是非阻塞IO,当程序想要读取数据时,如果缓冲区不存在,则直接返回给用户程序,但是需要用户程序去频繁检查,直到有数据准备好。这同样也会造成空耗CPU。



IO多路复用

而IO多路复用则不同,他会使用一个线程去管理多个fd,可以将多个fd加入IO多路复用函数中,每次调用该函数,传入要检查的fd,如果有就绪的fd,直接返回就绪的fd,再启动线程处理或者顺序处理就绪的fd。这达到了一个线程管理多个fd任务,相对来说较为高效。常见的IO多路复用函数有select,poll,epoll。select与poll的最大缺点是每次调用时都需要传入所有要监听的fd集合,内核再遍历这个传入的fd集合,当并发量大时候,用户态与内核态之间的数据拷贝以及内核轮询fd又要浪费一波系统资源(关于select与poll这里不展开)。



epoll介绍

接下来介绍一下epoll系统调用

epoll相比于select与poll相比要灵活且高效,他提供给用户三个系统调用函数。Golang底层就是通过这三个系统调用结合goroutine完成的“异步”IO。

  • 调用epoll_create会在内核创建一个eventpoll对象,这个对象会维护一个epitem集合,可简单理解为fd集合。
  • 调用epoll_ctl函数用于将fd封装成epitem加入这个eventpoll对象,并给这个epitem加了一个回调函数注册到内核,会在这个fd状态改变时候触发,使得该epitem加入eventpoll的就绪列表rdlist。
  • 当相应数据到来,触发中断响应程序,将数据拷贝到fd的socket缓冲区,fd缓冲区状态发生变化,回调函数将fd对应的epitem加入rdlist就绪队列中。
  • 调用epoll_wait时无需遍历,只是返回了这个就绪的rdlist队列,如果rdlist队列为空,则阻塞等待或等待超时时间的到来。

大致工作原理如图



异步IO

当用户程序想要读取fd数据时,系统调用直接通知到内核并返回处理其他的事情,内核将数据准备好之后,通知用户程序,用户程序再处理这个fd上的事件。



Golang异步IO实现思路

我们都知道,协程的资源占有量很小,而且协程也拥有多种状态如阻塞,就绪,运行等,可以使用一个协程服务一个fd不用担心资源问题。将监听fd的事件交由runtime来管理,实现协程调度与依赖fd的事件。当要协程读取fd数据但是没有数据时,park住该协程(改为Gwaiting),调度其他协程执行。

在执行协程调度时候,去检查fd是否就绪,如果就绪时,调度器再通知该park住的协程fd可以处理了(改为Grunnable并加入执行队列),该协程处理fd数据,这样既减少了CPU的空耗,也实现了消息的通知,用户层面上看实现了一个异步的IO模型。



Golang netpoll的大致思想就是这样,接下来看一下具体代码实现,本文基于go1.14。

具体实现

接下来看下Golang netpoll对其的使用。

实验案例

跟随一个很简单的demo探索一下。

net.Listen的内部调用

net.Listen依次调用lc.Listen->sl.listenTCP->internetSocket->socket到fd.listenStream函数创建了一个监听9009的tcp连接的socket接口,也就是创建了socket fd,

接下来为了监听该socket对象就需要把这个socket fd加入到eventpoll中了。

查看runtime_pollServerInit,是对epoll_create的封装。

查看一下runtime_pollOpen方法,将当前监听的socket fd加入eventpoll对象中。实际上是对epoll_ctl的封装。

Accept的内部调用

接下来返回到主函数。

到此时,accept函数就被阻塞住了,系统会在这个监听的socket fd事件(0.0.0.0:9009的这个fd)的状态发生变化时候(也就是有新的客户端请求连接的时候),将该park住的goroutine给ready。

唤醒park住的协程

go会在调度goroutine时候执行epoll_wait系统调用,检查是否有状态发生改变的fd,有的话就把他取出,唤醒对应的goroutine去处理。该部分对应了runtime中的netpoll方法。

源码调用runtime中的schedule() -> findrunnable() -> netpoll()

conn.Read的内部调用

回到主函数,我们使用go func形式使用一个协程去处理一个tcp连接,每个协程里面会有conn.Read,该函数在读取时候如果缓冲区不可读,该goroutine也会陪park住,等待socket fd可读,调度器通过netpoll函数调度它。

后面会等待缓冲区可读写,shchedule函数调用netpoll并进一步调用epoll_wait检测到并唤醒该goroutine。可以查看上面netpoll,这里不做重复工作了。

Golang也提供了对于epoll item节点的删除操作,具体封装函数poll_runtime_pollClose

部分系统调用

抓了一部分系统调用分析一下上述程序与内核交互的大致过程。

部分系统调用函数如下。

参考资料

  • 《后台开发核心技术与应用实践》第七章:网络IO模型
  • 《Unix环境高级编程》第十四章:高级IO
  • 《Go netpoller 原生网络模型之源码全面揭秘》https://mp.weixin.qq.com/s/3kqVry3uV6BeMei8WjGN4g