可以从 Go 源码目录结构和对应代码文件了解 Go 在不同平台下的网络 I/O 模式的实现。比如,在 Linux 系统下基于 epoll,freeBSD 系统下基于 kqueue,以及 Windows 系统下基于 iocp。
因为我们的代码都是部署在Linux上的,所以本文以epoll封装实现为例子来讲解Go语言中I/O多路复用的源码实现。
介绍 I/O多路复用所谓 I/O 多路复用指的就是 select/epoll 这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。以防很多同学对select或epoll不那么熟悉,所以下面先来讲讲这两个选择器。
首先我们先说一下什么是文件描述符(File descriptor),根据它的英文首字母也简称FD,它是一个用于表述指向文件的引用的抽象化概念。它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
select int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);writefds、readfds、和exceptfds是三个文件描述符集合。select会遍历每个集合的前nfds个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为就绪的描述符。
timeout参数表示调用select时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果timeout参数设为 NULL,会无限阻塞直到某个描述符就绪;如果timeout参数设为 0,会立即返回,不阻塞。
当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select的缺点也列举一下:
select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024;
每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
每次 kernel 都需要线性扫描整个 fd_set,所以随着监控的描述符 fd 数量增长,其 I/O 性能会线性下降;
epollepoll是selec的增强版本,避免了“性能开销大”和“文件描述符数量少”两个缺点。
为方便理解后续的内容,先看一下epoll的用法:
int listenfd = socket(AF_INET, SOCK_STREAM, 0); bind(listenfd, ...) listen(listenfd, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的fd添加到epfd中 while(1){ int n = epoll_wait(...) for(接收到数据的socket){ //处理 } }先用epoll_create创建一个epoll对象实例epfd,同时返回一个引用该实例的文件描述符,返回的文件描述符仅仅指向对应的epoll实例,并不表示真实的磁盘文件节点。
epoll实例内部存储:
监听列表:所有要监听的文件描述符,使用红黑树;
就绪列表:所有就绪的文件描述符,使用链表;
再通过epoll_ctl将需要监视的fd添加到epfd中,同时为fd设置一个回调函数,并监听事件event,并添加到监听列表中。当有事件发生时,会调用回调函数,并将fd添加到epoll实例的就绪队列上。
最后调用epoll_wait阻塞监听 epoll 实例上所有的fd的 I/O 事件。当就绪列表中已有数据,那么epoll_wait直接返回,解决了select每次都需要轮询一遍的问题。
epoll的优点:
epoll的监听列表使用红黑树存储,epoll_ctl 函数添加进来的 fd 都会被放在红黑树的某个节点内,而红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN),并且可以存储大量的的fd,避免了只能存储1024个fd的限制;
epoll_ctl 中为每个文件描述符指定了回调函数,并在就绪时将其加入到就绪列表,因此不需要像select一样遍历检测每个文件描述符,只需要判断就绪列表是否为空即可;
解析netpoll本质上是对 I/O 多路复用技术的封装,所以自然也是和epoll一样脱离不了下面几步:
netpoll创建及其初始化;
向netpoll中加入待监控的任务;
从netpoll获取触发的事件;
在go中对epoll提供的三个函数进行了封装:
func netpollinit() func netpollopen(fd uintptr, pd *pollDesc) int32 func netpoll(delay int64) gListnetpollinit函数负责初始化netpoll;
netpollopen负责监听文件描述符上的事件;
netpoll会阻塞等待返回一组已经准备就绪的 Goroutine;
下面是Go语言中编写的一个TCP server:
func main() { listen, err := net.Listen("tcp", ":8888") if err != nil { fmt.Println("listen error: ", err) return } for { conn, err := listen.Accept() if err != nil { fmt.Println("accept error: ", err) break } // 创建一个goroutine来负责处理读写任务 go HandleConn(conn) } }下面我们跟着这个TCP server的源码一起看看是在哪里使用了netpoll来完成epoll的调用。
net.Listen