epoll服务器开发一

socket英文单词为插座的意思,在网络通信中代表套接字,取插座的意思代表socket需要像插头插座一样配套使用,所以socket需要指定具体的五元组才可以通信,包括源ip、源端口、目的ip、目的端口、tcp/udp协议。

在tcp/udp通信中,server端socket需要先绑定ip和端口,而client端通常不需要绑定,由内核自行选择ip和port进行绑定,如果客户端是多网卡的,数据会选择其中一个可用网卡发送数据,也可以选择自己绑定ip,端口让内核自行选择。

tcp通信时需要进行三次握手,该过程发生在listen和accept之间(不调用accept也会进行三次握手),server接收到client的SYN包并回复SYN和ACK包时,进入SYN_RECV状态,将这些数据加入半连接队列,如果收到了client的ACK包,进入ESTABLISHED状态,再将这些数据移入全连接队列,这两个队列的最大值就是listen的参数backlog。所以调用listen后,tcp服务端就开始工作了,accept只是从全连接队列中取出连接而已,这里其实是取出新的fd,不是listenfd,这个fd是内核为新的连接创建的新的clientfd。

这里有个问题,udp怎么做高并发?由上面可知,tcp服务端会为每个新的客户端分配新的clientfd,后续使用新的fd通信即可,但udp不一样,udp只有一个监听fd,后续通信也是使用这个fd,这样就无法通过多线程操作这一个fd,另外由于udp接收到的数据是无序的,没办法区分不同用户发来的数据,这里其实可以参考tcp的实现技巧,在用户态为不同的udp客户分配不同的fd即可,不同的用户使用其对应的fd进行通信即可,这样就可以实现多用户高并发通信了。

这里有必要提一下SIGIO,这个信号可以用在udp通信中,协议栈收到udp数据后会给内核发出该信号,所以应用层进程捕捉到该信号时,代表有udp数据到了,就可以在信号回调函数中调用recv读取udp数据了,注意要将udp的socket设置成异步非阻塞的,tcp没办法处理这种信号,因为tcp需要处理监听fd,信号回调里面没办法对fd做区分。

在linux中,每个进程都有自己的task_struct,在这个结构体中的signal_struct类型指针sig保存有进程的信号处理集,我们可以通过signal系统调用函数进行注册信号处理回调函数,也就是修改sig指针对应的变量里k_sigaction类型action变量对应那个信号的值,另外task_struct里面还有一个sigpending类型的变量pending,当进程收到一个信号时,会把它放入这个pending队列中,同时将task_struct里面的sigpending置为1。

kill的过程主要就是修改pending队列(修改tail指向新的sigquque)和sigpending置为1,如果进程处于睡眠状态,就唤醒它,进程在下次系统调用或者中断或者异常时,会从内核态切换到用户态,切换时就会检查sigpending是否有置位,有的话就执行内核里面的do_signal,然后这个函数的dequeue_signal函数会从该进程取出一个信号,根据sa_handler判断信号的处理方式,忽略或者按默认方式处理,如果是用户自定义信号处理,由于用户自定义在用户的程序中,需要切换到用户态处理,参考内核的handle_signal函数,这里面的setup_frame就是设置内核态返回用户态执行的第一条指令的地址,也就是eip,将该地址指向信号处理函数就好了,同时保存内核栈到用户栈,参考setup_sigcontext函数,等用户态信号处理函数执行完以后,内核还需要进行一些收尾工作,此时还需要进入内核态,这里是通过修改用户态的eip,将其指向用户栈的某个地方,在这里可以执行sigreturn系统调用,这里面主要就是从用户栈恢复内核栈,参考restore_sigcontext函数,此时再从内核态返回时就可以执行之前第一次调用系统调用或者中断或者异常的下一个用户程序执行点了,至此信号捕捉并处理完成。

对于高并发服务器开发,现在都采用io多路复用的方式,目前主要有select、poll、epoll三种系统调用,它们都可以一次性监听多个fd是否有io事件到来,select和poll每次都需要将需要监听的fd给到内核,epoll只需要一开始将需要监听的fd进行epoll_add就行,将fd给到内核就涉及用户态到内核态的数据拷贝,当内核拿到有事件的fd后,还需要将这些fd和事件传回给用户态地址,epoll也是这样的,内核用的__put_user方法,所以并不存在共享内存的说法。

select就像酒店的服务员,挨桌去问有没有要点菜的,有没有要点酒的,以及有没有要结账的,它这三个小本本还是有大小限制的,也就是fd_set的大小,在内核对应kernel_fd_set结构体,里面是一个fds_bits的数组,最大只能表示FD_SETSIZE即1024位,所以select最多只能监听1024个fd,但并不是最大只能监听到1024的fd,因为0-1024中可能还有一些没有需要监听的。具体实现可以参考内核代码select.c中的do_select函数,它会对0-n范围需要监听的fd进行轮询poll,如果有事件就其记录下来,写会用户态的三个fd_set小本本上,如果没有就会进入睡眠状态,设备驱动检测到信号后会调用poll_wait唤醒等待队列,然后select醒后会再次对所有需要监听的fd进行poll询问,将结果记录下来,传回给用户态的地址,如果还没有用完设定的超时时间就检测到了事件,还会将剩余的时间也传回给用户态地址,所以这里也有个坑,select的入参也是出参,下次使用的时候需要重新赋值。当然在select睡眠等待期间,如果有pending信号,等待队列异常,或者超时都会离开返回的。可以看出这个过程中就是需要轮询所有需要监听的fd,底层调用的vfs_poll,所以时间复杂度是O(n)。

对于poll系统调用,相比select确实做了一些优化,比如不再有最大只能监听1024个fd的限制,内部是使用poll_list链表实现,poll_list里面的entries成员是pollfd类型的链表,所以只有rlimit(RLIMIT_NOFILE)的限制,这个是系统级别的,可以修改的比较大,另有一个优化点就是它不像select一样将返回的fd直接覆盖输入参数,poll是将pollfd里面的events成员作为,revents作为出参,这样用户态程序下次监听的时候就不要重写events了。poll也是酒店的一名服务员,但是她不需要三个本子来记录了,只需要一个就行了,其他方面还是和select差不多,底层还是参与vfs_poll轮询,有事件被唤醒后还是需要轮询所有需要监听的fd,所以时间复杂度也是O(n),而且也涉及用户态和内核态的数据拷入拷出。它和select在内核中是放在同一个文件select.c中实现的。

epoll重点来了,它的实现相对比较复杂,是在内核eventpoll.c中实现的,但是原理也还好,功能上确是有了质的突破。它内部是采用的红黑树rb_root_cached rbr,可以进行find、insert、remove操作,可能在多线程环境运行,访问需要加锁,往树上添加子节点的方式来监听fd,所以可以监听的fd也非常多,关于最大值,内核是对epoll_watches做了限制,最大可以监听/proc/sys/fs/epoll/max_user_watches,一般可以达到几十w了,而且这是可以同时监听的数量,所以一旦有一些fd不再需要监听了,最好将它调用EPOLL_CTL_DEL进行删除。

epoll最大的优点是基于事件通知的,select和poll其实也有事件通知,但是它的事件通知处理函数调用的是默认的default_wake_function函数,只是唤醒正在睡眠的函数进行下一步的poll轮询而已,而epoll不同,它有实现自己的ep_poll_callback回调函数,当驱动层有事件触发时会调用poll_wait,进而调到这个回调函数来,在这个函数里,它会将通知的epi事件加到rdllist里面,如果rdllist正在使用(epoll_wait正在读取事件数据),会将epi临时加入到ovflist中,如果epoll_wait正在睡眠,就唤醒它,这个时候它就可以直接从rdllist里面取事件数据了,取的时候新到来的事件只能放在ovflist中,从rdllist取出的数据也是通过vfs_poll查看具体的事件值,会直接通过__put_user发送到用户态地址,取完后如发现ovflist也有数据了,会将其转移到rdllist中,此时如果有另外的线程在调用epoll_wait,也会唤醒它。如果没有另外的线程,那么自己下次调epoll_wait的时候就可以直接从rdllist里面取值了。当然如果调用epoll_wait的时候,没有可用的rdllist数据,eventpoll也会进入睡眠等待状态,等待超时或者有数据来时触发。这里面还有一个惊群问题,如果一个fd被多个epoll监听,有事件到来时,会触发多个epoll进行处理,这是有问题的,可以在events里面加上EPOLLEXCLUSIVE标志,这样有事件来时,就只会触发一个epoll响应了。对于非EPOLLET模式,还会将取出来的epi->rdllink插回到rdllist中,下次epoll_wait还会从rdllist中取出进行vfs_poll看有没有事件。

可以看出,epoll_wait只是从已经就绪的rdllist链表取值进行vfs_poll询问即可,不再向select和poll那样询问所以待监听的fd,对于需要监听上10w连接的服务器来说,是一个很大的性能优化了,因为大部分时候只有少部分的连接有事件发生,如果每个都去轮询一般,就需要额外的cpu消耗。另外,epoll增加了et模式,对于小文件类型的fd,触发一次后,用户层自己去循环处理就行了,不再需要内核层不断的vfs_poll询问,减少了不少用户态和内核态的切换。这两个点就是epoll最大的优点了,现在大型的服务器都会选择epoll来实现。

扒了内核代码,终于对select、poll、epoll有了比较深刻的理解了,只有网上说的epoll采用了共享内存所以快,5.12.7的内核源码并没有体现,还是需要调用__put_user向用户层拷贝数据的,完全是以讹传讹,误导大家,所以还是需要自己多了解,抱着怀疑的态度学习。

nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!