服务器中的epool
当前网络服务器开发中,只要涉及到并发IO,那么epool是逃不掉的话题。本文从网络通信的基本原理出发,慢慢引申出如何将一对一的通信,转化为IO复用,最后引出select/epool机制。
epool允许在多个非阻塞的socket描述符上等待可读、可写事件,本质是一个事件驱动模型。简单来说,假设我们当前的 server 有 10 万个 TCP 连接,在这些 TCP 连接这种,能够读/写数据的连接并不是 10 万,可 能只有 5000,或者更少,这是因为用户不可能实时活跃。
如果说我们能够直接找出这 5000 个活跃的连接进行处理的话,那么系统效率将得到巨大的提升,epoll 的本质作用就是在 这 10 万个连接中找到这 5000 个活跃连接
基于socket编程流程
socket这个单词,在英文里面是插座的意思。为何跟插座联系在一起呢?发挥你的想象力吧!当我们将一个插头插入到一个插座上的时候,这个场景,跟网络通信有啥广义的相似性呢?
socket可以看做一个文件描述符(具备IO的属性),也可以认为是网络通信的端点。
服务端的TCP socket编程套路如下:
- socket();创建一个socket
- bind();绑定socket到IP/port
- listen(); 监听连接
- accept();从客户端接收连接
- recv()/send();数据传输
- close();关闭socket
注意几点:
- 客户端在执行connect的时候,如果已经返回,则三次握手已经完成,跟服务端的accept没有任何关系。即时在accept()之前建立的三次握手。
- 内核为每一个listen状态的套接字设置两个队列,未完成连接队列和已完成连接队列,这两个队列共用listen设置的连接长度。
- 当客户端发送syn报文后,服务器收到这个syn报文并检测连接队列是否已满,如果满了就丢弃这个syn,如果没满就把这个链接放入未完成队列,并发送ack和syn报文,待客户端收到ack和syn之后,会发送ack报文到服务端,且从connect函数返回,服务器收到这个ack后把连接从未完成队列中取出放入已完成队列,等待accept把这个链接取走,此时三次握手已经全部完成。
- 没有特殊情况下,很快就会从未完成队列中拿走。如果服务端在发送ack和syn后,超过一定时间(75秒)内没有收到ack,那么服务器会将这个链接丢掉。
- accept()仅仅是将已完成队列中的链接取出来,如果没有链接,则会阻塞
通过epool将一大串知识点联系在一起
需要从线程阻塞、中断优化、网卡处理数据过程等角度,去剖析epool是怎么被发明出来的,epool内在的本质究竟是什么。而不是仅仅背诵一些生硬的概念,有些情况下,知道了系统是怎么设计的以及为何这么设计,就从更深层次理解了系统。
阻塞和非阻塞
linux下所有的IO模型都是阻塞的,这是收发数据的基本原理导致的。阻塞不是问题,因为阻塞不会消耗cpu,IO多路复用也不是减少了阻塞,而是减少了运行。其实IO多路复用,通过引入新的处理方式,有效地减少了上下文切换。
当进程被阻塞/挂起时候,其实是不占用cpu资源的。“可运行状态”会占用cpu资源,创建和销毁进程会占用cpu资源。
为了支持多任务,Linux实现了进程调度的功能,即cpu时间片的调度。
为了方便时间片的调度,所有可运行状态的进程,会组成一个队列,叫工作队列。内核可以根据数据包中的端口号,很容易地找到对应的socket,将对应的进程状态修改为可运行的状态,即加入到工作队列,此时用户可以处理数据。
同步和异步
Linux下所有的IO模型都是同步的,BIO是同步的,select是同步的,pool是同步的,epool仍旧是同步的。
java提供的AIO,或许被称作异步的,但是jvm是运行在用户态的,Linux没有提供任何的异步支持。所以jvm提供的异步支持,是自己封装的。
所谓的同步和异步,只是两种事件分发器,英文是event dispatcher。
select和epool的形象区别
select需要O(n)查找的时间复杂度,而epool的时间查找复杂度是O(1),即epool返回的全部都是就绪的待执行socket,而select还需要遍历一遍才知道是不是就绪的socket。
select需要傻傻地遍历一遍所有的关心的socket,而epool只需要处理关心且活跃的socket即可,回调函数已经帮自己完成的筛选的操作。越是socket数量越大,而活跃的socket相对越少,越能体现epool的优势。
比如有一万人站在你面前,你要跟其中活跃的10个人进行通话/通信/讲话,但是select需要莎莎地问一下这一万人中的每一人,你是不是需要通话的那个人。而对于epool而言,直接相当于将这10个人领出来了,你只需要分别跟着10人一个一个地聊天即可,省去了从10万人中将这10个人筛选出来的成本。
select对应的示范代码
int fds[] = ... //socket数组
fd_set read_fds, temp_read_fds;
FD_ZERO(&read_fds);
for(int i=0; i<fds.count; i++){
FD_SET(fds[i], &read_fds);
}
while(1){
temp_read_fds = read_fds;
//这个就是监视器,会阻塞进程
//temp_read_fds既作为入参,又作为出参
//将全部符合条件的socket又填充到temp_read_fds作为出参
int n = select(..., &temp_read_fds, ...);
for(int i=0; i<fds.count; i++){
if(FD_ISSET(fds[i], &temp_read_fds)){
//梳理业务逻辑
FD_CLR(fds[i], &read_fds);
}
}
}
epoll对应的示范代码
int fds[] = ...//socket数组
int efd = epoll_create(..)//在内核空间,创建epoll实例,红黑树+就绪链表
for(int i=0; i<fds.count; i++){
epoll_ctl(efd, ..., fds[i], ...);
}
struct epoll_event[MAX_EVENTS];//用户空间分配内存,用来出参
while true{
//这个是监视器,会阻塞进程
//内核会利用红黑树,快速查找select需要的socket,放入就绪列表
//将就绪列表中,取一定数量到event,这里events作为出参
int n = epoll_wait(efd, &events, ...);
for(int i=0; i<n; i++){
//取出待处理的socket
events[i].data.fd;
}
}
大脑要能够想象出,用户空间跟内核空间交互的时候,这些辅助数据结构是如何发挥作用的。
可以看到,系统设计到最后,可以归为是用什么样的数据结构+处理逻辑进行排列组合,以降低整体的时间复杂度和空间复杂度。所以通过各种场景对自己进行数据结构算法的训练,是非常非常重要的。
数据结构算法通过各种载体来训练
将基础的程序组件,都当做数据结构算法的练习题目即可。比如数据库,其实本质就是数据结构算法的一种运用而已,而所谓的架构设计,也不过是配列组合资源,从时间空间复杂度角度去思考解决方案。再往上抽象其实锤炼的是思维能力,而设计程序仅仅是训练思维能力的一种载体。
通过诸多练习题目来锤炼思维能力
那么思维能力又包括很多种,逻辑思维,发散思维,演绎思维,抽象思维,形象思维,灵感思维等等,最后转化为将培育思维能力,作为重中之重。