tcp和套接字之IO多路复用select poll epoll

/ 默认分类 / 0 条评论 / 820浏览

一.IO是什么

学习过操作系统都知道,io操作需要调用系统函数才能执行,io操作也就是将数据在内核空间和用户空间之间进行拷贝的过程,需要切换为内核态或用户态,其实io操作无非就是下面几种,

首先需要明确的一点,因为磁盘io操作很慢,所以导致io会发生阻塞,那么为什么磁盘io会很慢呢?
因为io操作需要操作系统将进程的执行状态从用户态切换到内核态,然后将数据从磁盘读出到内核空间,然后从内核空间拷贝到用户空间,之后才切换到用户态继续执行,磁盘又是计算机硬件中最慢的,所以整个过程造成了磁盘io操作会很慢(这里会涉及到一系列的优化方法,大名鼎鼎的零拷贝技术就是其中一个,可以参考这篇博客零拷贝)

二.TCP和Socket

  1. Socket套接字

Socket即套接字,在网络编程中表示的是两个通信双方的端信息,可以理解为网络通信双方的抽象;
下面来介绍下,客户端和服务端之间通信的详细过程

服务端:

        ServerSocket serverSocket = new ServerSocket(8080);

比如在java中,这样创建了服务端的socket,并且绑定了端口号,这里其实等于执行了socket()和bind()两个过程, 之后会执行listener(),执行完listener()之后该服务端套接字就会从CLOSE状态转为LISTENER状态,这样该套接字就可以 对外进行tcp连接了.

需要说明的一点是,上面说的这些函数基本都属于系统函数的范畴,属于操作系统内核的函数,这些函数都是直接封装在 c库中,对于上面的这行java代码,其实内部也是调用的c库,查看源码即可发现:

try {
            SecurityManager security = System.getSecurityManager();
            if (security != null)
                security.checkListen(epoint.getPort());
            getImpl().bind(epoint.getAddress(), epoint.getPort());
            getImpl().listen(backlog);
            bound = true;
        } catch(SecurityException e) {
            bound = false;
            throw e;
        } catch(IOException e) {
            bound = false;
            throw e;
        }

客户端想和服务端进行通信,也需要先创建一个socket文件描述符,然后调用connect()函数,和指定的服务端的监听socketfd进行连接,这里连接就是tcp连接, 所以现在有一个问题,服务端只生成了一次socket,不同的客户端每次重写连接都会创建一个客户端socket来和服务端简历tcp连接,那么是不是说服务端只有一个socket?

首先需要明确一点,每次tcp连接都会维护有一个socket。
然后,再来仔细了解一下listener()函数的细节,客户端socket第一次发送syn到服务端,服务端会将该socket连接放入未完成连接的队列(syn queue)中,如果服务端也确定进行连接,然后发送syn和ack到该客户端,如果之后客户端再次发送了ack到服务端(此时代表三次握手成功),这个时候服务端就会将本次socket连接移动到已完成连接的队列(accept queue/establised queue)中,但是此时还不能使用tcp传输数据,进入已完成连接的队列的socket连接会被执行的系统函数accept()消费,这个时候会先将该socket连接移出,然后就可以通过tcp连接传输数据了.

backlog其实是一个连接队列大小
1.在Linux内核2.2之前,backlog大小包括半连接状态和全连接状态两种队列大小。
半连接状态为:服务器处于Listen状态时收到客户端SYN报文时放入半连接队列中,即SYN queue(服务器端口状态为:SYN_RCVD)。
全连接状态为:TCP的连接状态从服务器(SYN+ACK)响应客户端后,到客户端的ACK报文到达服务器之前,则一直保留在半连接状态中;当服务器接收到客户端的ACK报文后,该条目将从半连接队列搬到全连接队列尾部,即 accept queue (服务器端口状态为:ESTABLISHED)。
2.在Linux内核2.2之后,分离为两个backlog来分别限制半连接(SYN_RCVD状态)队列大小和全连接(ESTABLISHED状态)队列大小。
半连接队列:SYN queue 队列长度由 /proc/sys/net/ipv4/tcp_max_syn_backlog 指定,默认为2048。
全连接队列:Accept queue 队列长度由 /proc/sys/net/core/somaxconn 和使用listen函数时传入的参数,二者取最小值。默认为128。

accept消费了一个已完成连接之后会生成一个新的socket文件描述符,此后客户端和服务端的数据传输都是走的该socket,这两个socket使用的是同一个tcp连接 ,accept之后这个socket是不一样的,之前的socket是用来收发tcp建立和销毁这个过程中的数据包的,accept之后的这个socket是用来客户端和服务端之间传输真实的数据的,例如服务端响应的data和客户端发起的http请求;

如果已完成队列中没有任何数据了,那么服务端就会阻塞,这个时候就是可以使用io多路复用技术,如使用select或者poll等来等待已完成队列的可读事件

ps:现在有一个问题了,服务端只创建了一个socket,为什么不同的客户端和服务端之间交互,tcp两端却都维持的是两个socket;

按照前面介绍的,服务端执行完listen()调用之后,会生成一个listen_socket_fd,改fd负责监听新客户端的连接,也就是负责三次握手的过程,之后如果accpet执行后开始消费已完成队列中的连接,那么就会生成一个新的socket,叫做connect_socket_fd,该fd就单独负责本次客户端和服务端之间的数据传输交互,也就是说三次握手之后进行数据传输使用的是新的connect_socket_fd,之所以这样做,就是为了让每个成功链接的客户端可以有独立的socket进行通信,提高效率

即tcp洪水攻击,就是当攻击者伪造了很多客户端,不断地发送syn来connect服务端地socket,服务端也会有很多地socket与之对应(tcp建立地两端都会维持一个socket),然后服务端为每一个客户端地socket发送syn+ack,之后这里服务端监听可能使用select不断轮询监测是否有状态改变的服务端监听socket(即接受到客户端socket的数据包),但是因为攻击者伪造的客户端,所以不会对本次服务端的响应做出ack回应,也就是不会握手成功,这样,服务端始终得不到ack,就会重新发送syn+ack到客户端,这样会导致一直得不到回应,但是又发送数据包,也就是需要将数据从内核空间拷贝到发送缓冲区,然后通过dma拷贝到网卡发送,并且select都发现没有改变状态的socket,导致一直超时返回,以上这些操作除了dma拷贝不需要占用cpu,其他都需要,如果攻击者客户端很多,会导致服务端会发送很多很多的syn+ack,导致服务端监听崩溃,网卡卡顿,syn queue爆满,导致正常的客户端连接也进不来;

ps:SYN Flood是当前最流行的DoS(拒绝服务攻击)与DDoS(分布式拒绝服务攻击)的方式之一,这是一种利用TCP协议缺陷,发送大量伪造的TCP连接请求,常用假冒的IP或IP号段发来海量的请求连接的第一个握手包(SYN包),被攻击服务器回应第二个握手包(SYN+ACK包),因为对方是假冒IP,对方永远收不到包且不会回应第三个握手包。导致被攻击服务器保持大量SYN_RECV状态的“半连接”,并且会重试默认5次回应第二个握手包,塞满TCP等待连接队列,资源耗尽(CPU满负荷或内存不足),让正常的业务请求连接不进来。

三.IO模型

首先IO的本质就是将磁盘的数据,或者网络接受到的数据,从内核空间拷贝到用户空间

io模型主要分为下面几种:

下面来分别解释一下:

  1. 阻塞IO

用户进程调用读取网络io传输过来的数据,但是监听socket一直没有发生状态改变,即没有数据,此时recv不会返回,直到有客户端数据过来,这个 过程中用户进程是阻塞的,有数据过来后,recv返回,将内核空间数据拷贝到用户空间

  1. 非阻塞io

用户进程从内核的socket缓冲区读取数据,发现没有数据准备好,但是不等待,也就是不阻塞,而是直接返回error状态,所以用户进程可以一直不断 地调用read读取数据,直到有数据被读取到,所以可以看出,这个过程,对于用户进程来说是同步地,但这样的read是非阻塞的,因为内核在没有读取到数据的时候不是一直不返回给用户进程,而是直接返回;

  1. IO多路复用

对于上面两种情况,如果服务端有维持多个网络socket,那么就需要为每一个socket开启一个进程(线程)来监控socket的状态,检测是否有数据准备好, 而使用io多路复用技术就可以做到,只使用一个进程就能监听多个socket状态,具体的实现如下:

IO多路复用模型的实现方式有select,poll,epoll,当用户线程调用select之后,就会发生阻塞,然后select函数会一直轮询检测是否有状态改变的socket,如果有就返回1,这样用户进程再次调用read读取数据。

(1)先来看下select函数:

int select(int nfds, fd_set *readfds, fd_set *writefds,
            fd_set *exceptfds, struct timeval *timeout);

nfds: 监听的socket文件描述符的个数
readfds: 传入一个socket文件描述符集合,表示监听这些socket的可读状态变化 writefds: 传入一个socket文件描述符集合,表示监听这些socket的可写状态变化
exceptfds: 监视集合中fd的异常情况
timeout: timeout会有几种情况

如果在执行select期间发生异常,则返回负数

select内部存放fd集合的数据结构是数组(bitmap),所以肯定有数量的限制,在默认状态下大小为1024,并且select检测到有状态修改的fd之后,只会告诉用户进程现在有fd状态改变了,于是用户进程还会再次遍历一下fd集合获取到变化的fd

总结下select有一下缺点:

ps:这里解释下参数中的三个fds,其实这三个集合就是bitmap位图,当执行完函数select之后,如果某个fd的状态变化,那么相应位上的值就会变化,所以当执行完后 要想知道哪些变化了就还需要重新再次遍历一遍

(2)poll函数:

poll函数是select的改进,使用的是自定义fd结构体,然后使用数组进行存储,这样可以不受fd数量的限制,数组的大小是调用方传入的

#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long n, int timeout);
//成功个数,错误-1,超时0

struct pollfd{
  int fd;
  short events;
  short revents;
};

(3)epoll函数:

首先,在select和poll中,会有下面几个弊端:

面对并发越来越多的情况,epoll出现了,epoll包括下面三个系统调用:

在epoll早期的实现中,对于监控文件描述符的组织并不是使用红黑树,而是hash表。这里的size实际上已经没有意义。

EPOLL_CTL_ADD    //注册新的fd到epfd中;
EPOLL_CTL_MOD    //修改已经注册的fd的监听事件;
EPOLL_CTL_DEL    //从epfd中删除一个fd;

fd表示当前操作的fd,event表示需要注册的回调事件,回调事件类型包括下面:

EPOLLIN     //表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT    //表示对应的文件描述符可以写;
EPOLLPRI    //表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR    //表示对应的文件描述符发生错误;
EPOLLHUP    //表示对应的文件描述符被挂断;
EPOLLET     //将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT//只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

所以,执行该操作后,等于插入了一个fd节点到红黑树中,这样该fd就会和相应的程序完成事件回调的绑定

返回已经触发回调的fd的数目,并且会直接执行回调(epoll返回的只是fd状态发生变化的列表数量)

综上,epoll效率会很高,因为其不需要每次都将fd列表在用户空间和内核空间进行copy,并且每次有状态改变的fd出现后会主动调用回调函数,这样就不需要 整体进行轮询,假设现在有1k的fd,但是其中只有极少数的fd是可读的,那么也不需要轮询遍历整个fd列表,因为fd可读状态之后,相应的fd上的绑定的回调函数会自动执行,所以epoll的高效率可以不受当前连接数的影响,因为不需要完整地遍历整个fd列表.并且epoll也不需要进行大量的socketfd的复制,其使用了mmap内存映射技术,不需要在用户空间和内核空间之间复制数据.

Q&A

  1. 综上,我可以发现,使用io多路复用模型可以仅使用一个进程(线程)即可处理多个客户端连接
  2. IO多路复用模型,其本质还是同步阻塞模型,因为在执行select,poll,epoll的时候需要等待,只有fd状态修改后才会返回