1.先谈epoll
1.1 epoll定义
是I/O多路复用的一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪,就是这个文件描述符进行读写操作之前),能够通知程序进行相应的读写操作。
参考文章(我觉得写的特别好):https://zhuanlan.zhihu.com/p/63179839
Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!!!
Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!
1.2 epoll为什么高效
这里的高校是相对于select/poll。
select/poll是需要将所有用户态的fd拷贝到内核态,每次调用select都需要将线程加入到所有监视socket的等待队列,数量巨大时这个效率比较慢。当其中监控的socket有数据到达的时候,还需要将线程从这些监控的socket队列中移除。并且返回之后,还需要将内核空间的数据(包括fd)拷贝到用户空间,然后还需要将所有的fd遍历一遍,对isset的fd进行处理。
但是epoll呢,epoll_create时创建内核高速cache区:就是建立连续的物理内存页,然后在之上建立slab层,简单的说就是物理上分配好你想要的大小的内存对象,每次使用时都是使用空闲的已分配好的对象、内核cache中建立个红黑树来存储通过epoll_ctl添加进来的fd,这些fd其实已经在内核态了,当你再次调用epoll_wait时,不需要再拷贝进内核态、内核cache中再建立就绪链表存储所有就绪的fd。epoll_ctl 时将fd添加到红黑树中(若存在则不添加),当fd有数据到达的时候,然后将这个fd添加到就绪列链表中。 epoll_wait时返回就绪链表里面的数据就可以了,所以这里只需要将就绪链表的数据从内核太拷贝到用户态。
2.再谈netpoll
2.1 netpoll定义
go网络库对epoll的封装
2.2netpoll的实现
这里go的版本是go1.13.3。
我们知道epoll机制提供的接口有三个函数:创建epoll句柄int epfd = epoll_create(intsize)、将被监听的文件描述符加入创建的epoll句柄或者从epoll句柄删除或者修改被监听的文件描述符int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)、等待监听的文件描述符有事件的发生int epoll_wait(int epfd, struct epoll_event * events, intmaxevents, int timeout)。go网络库对epoll进行封装,其实就是怎么调用这个三个系统函数,实现用户态对多个socket的监听。这里讲的主要是针对单例epoll。
2.2.1go是怎么实现创建epoll实例并将fd加入到epoll列表的呢?
入口函数在 net/http server.go func ListenAndServe(addr string, handler Handler) error {}
从这个函数看下去,我们会发现三个非常关键的数据结构。其中一个是netFD
另外一个是FD
还有一个就是pollDesc(I/O poller)
截图中有对每一个数据结构进行描述,其中的包含关系就是netFD-->FD-->pollDesc。这三个数据结构都有一个init方法,但都是netFD的i nit调用的是FD的init,FD的init调用的是pollDesc的init,所以我们来看pollDesc的init做了什么。
可以看到pollDesc的init有调用runtime_pollServerInit和runtime_pollOpen,并且runtime_pollServerInit只调用了一次。接下来来看这两个函数的实现。
通过//go:linkname 可以看到runtime_pollServerInit的实现是在runtime netpoll.go下的poll_runtime_pollServerInit。
poll_runtime_pollServerInit调用了netpollinit。
netpollinit实现了系统调用epoll_create,在内核态创建了一个epoll实例,这里单个进程只能创建一个epoll实例。
接通过//go:linkname 可以看到runtime_pollOpen的实现是在runtime netpoll.go下的poll_runtime_pollOpen。
这里先分配得到一个pd *polldesc。
这里主要看rg和wg。rg,wg默认是0,rg表述读goroutine为pdReady表示读就绪,可以将协程恢复,为pdWait表示读阻塞,协程将要被挂起。wg也是如此。
然后再调用了netpollopen。
netpollopen实现了系统调用epoll_ctl。netpollopen把这个fd添加到了epoll表里(用红黑树存储所有添加进来的fd),同样pd作为event的data传入内核表,从而实现内核态和用户态数据的关联。
2.2.2go怎么实现将goroutine挂起的呢?
看go的网络库源码,我们可以看到有两种情况需要将goroutine挂起。第一种情况是accept一个新的连接的时候,如果新的连接没有数据,需要将该goroutine挂起等待新数据的到来。还有一种情况就是,对于每一个tcp连接,都会开一个goroutine去处理这个连接的数据,但是当从该连接接收不到数据的时候,我们得把goroutine挂起。这两个地方其实底层的实现都是一样的,所以我这里讲一下第一种情况go是怎么实现将goroutine挂起的呢,这里我们需要从网络库中的TCPListener数据结构的Accept方法讲起。最终的实现是在FD数据结构Accept方法。
accept接受一个新的连接,如果没有错误直接返回。当有错误EAGAIN说明当前没有连接到来,就调用waitRead等待连接。我们来看*pollDesc的waitRead。
这一层一层的调用看下去,最后调用的是gopark,此函数汇编实现,具体功能是将当前的goroutine修改为等待状态,然后执行其他的goroutine。
2.2.3go怎么实现将goroutine唤醒的呢?
当go程序启动的时候会创建一个M去跑我们的系统监测任务,它没有和任何的P(逻辑处理器)进行绑定,而是通过自身改变睡眠时间和时间间隔来一直循环下去(代码位于runtime/proc.go)。网络轮循器就在这个M中去实现了。在runtime/proc.go中findrunnable会判断是否初始化epoll,如果初始化了则调用netpoll,从而获取glist,然后traceGoUnpark激活挂起的协程。
3.go封装epoll的整体流程
Golang的Listen–> lc.Listen –> sl.listenTCP –> internetSocket流程中的internetSocket封装了创建socket-->bind这个流程,并把这个listenFd注册到了epoll对象。当listenFd accpet()的时候,listenFd 对应goroutine会阻塞,所以有新的连接到来的时候,就会唤醒listenFd对应的goroutine,这个goroutine会把新的连接的fd注册到epoll对象,并且go会开启一个新的goroutine去处理这个新连接的数据,所以当这个新的连接没有数据到来的时候对应的goroutine会挂起,有数据到来的时候对应的goroutine会被唤醒。
网友评论