计算机网络常见概念介绍 MTU EPOLL

主要介绍网络MTU MSS 与IO多路复用的SELECT POLL EPOLL IOCP

MTU MSS

  • MTU: Maxitum Transmission Unit 最大传输单元
  • MSS: Maxitum Segment Size 最大分段大小

1500字节的MTU,1460MSS和1448的真实负载:以太网Ethernet最大的数据帧是1518字节。以太网帧的帧头14字节和帧尾CRC校验4字节(共占18字节),剩下承载上层协议的地方也就是Data域最大就只剩1500字节. 这个值我们就把它称之为MTU
MSS决定TCP的单包传输量
MSS就是TCP数据包每次能够传输的最大量。为了达到最佳的传输效能,TCP协议在建立连接三次握手的时候通常要协商双方的MSS值,这个值TCP协议在实现的
时候往往用MTU值代替(需要减去IP数据包包头的大小20Bytes和TCP数据段的包头20Bytes)所以往往MSS为1460(如图1中红色方框所示的SYN包中的MSS值)。通讯双方会根据双方提供的MSS值得最小值确定为这次连接的最大MSS值
MSS为1460是由1500-20(IP头)-20(TCP头)计算出的
实际场景下,TCP包头中会带有12字节的选项→时间戳(本身10 字节就够,但是由于 TCP 头部是 4 字节对齐,因此占用 12 字节)
这样,单个TCP包实际传输的最大量就缩减为1448字节。1448=1500-20(IP头)-32(20字节TCP头和12字节TCP选项时间戳)
而在IPV6协议中一般情况下MSS的值为1440,这是因为IPv6 中的IP头的大小是40bytes

PPPoE带来了好处,也带来了一些坏处,比如:二次封装耗费资源,降低了传输效能等等,这些坏处俺也不多说了,最大的坏处就是PPPoE导致MTU变小了以太网的MTU是1500,再减去PPP的包头包尾的开销(8Bytes),就变成1492。

如果两台主机之间的某段网络使用了PPPoE那么就会导致某些不能分片的应用无法通讯。这个时候就需要我们调整一下主机的MTU,通过降低主机的MTU为1492,这样我们就能够顺利地进行通讯了。

我们回过头来看前言里面的那个问题,我们试想一下,如果我们在中间路由器上把每次TCP连接的最大MSS进行调整这样使得通过PPPoE链路的最大MSS值加上数据包头包尾不会超过PPPoE的MTU大小1492这样就不会造成无法通讯的问题。

MSS1452是由:1500-20(IP头)-20(TCP头)-8(PPP)=1452

所以上面的问题可以通过ip tcp adjust-mss 1452来解决。也可以linux系统单独修改一个route规则可以加到开机启动项/etc/rc.local中,稳定性待验证:route add -net default gw xxx.xxx.xxx.xxx dev eth0 mss 1400

当然问题也可以通过修改PC机的MTU来解决。

PMTU 黑洞路由器会给 TCP 连接带来问题.
您可以按照下面的语法使用 Ping 工具来检测 PMTU 黑洞路由器:

Pingdestination –f –l ICMPEchoPayloadSize

命令
此处的 destination 可以是一个 IP 地址,也可以是一个可解析为 IP 地址的名称
-f 选项可将 DF 标记设置为 1 不进行分段
-l 选项指定 ICMP Echo 消息的有效负载的大小
ICMPEchoPayloadSize 是 ICMP Echo 消息的有效负载的字节数

要计算 ICMPEchoPayloadSize,可用您想发送的 IP 包的大小减去 28。这是因为,IP 报头的大小为 20字节,而 ICMP Echo 消息的 ICMP 报头的大小为 8 字节。

例如,要发送长度为 1500 字节的 ICMP Echo 消息,您应使用以下命令:
ping destination –f –l 1472

MSS值的计算方法是:MSS=MTU-IP-TCP(如果有其他pppoe、加密报文头的话也同样减去),也就是说MSS值其实就是TCP所承载的净载荷的长度。由于AR28XX接口缺省的MTU是1500字节,故一般要求加密报文头+链路层开销+IP头(20-60字节)+TCP报文(20字节)小于1500字节,即TCP分片配置1200左右比较适合。缺省情况下,TCP报文不分片。因此TCP MSS不匹配也会引起部分应用异常。

MTU配置:

  1. 查看MTU值

    1
    # cat /sys/class/net/eth0/mtu
  2. 修改MTU值

    1
    echo "1400" > /sys/class/net/eth0/mtu

MSS配置:

MSS是协商出来的,一般是MTU-40=1500-40

linux中一般可以通过netfilter iptables设置TCP MSS来解决

1
iptables -A FORWARD -p tcp- -tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

这条规则的目的就是改变TCP MSS以适应PMTU(Path MTU)

1
iptables -A FORWARD -p tcp --tcp-flags SYN,RST SYN- j TCPMSS --set-mss 1024

设置MSS为1024

其他配置查看/proc/sys/net/ipv4/目录下的配置

select poll epoll iocp

种常见的I/O模型及其区别,如下:《Unix网络编程》

  • blocking I/O
  • nonblocking I/O 轮询,直到内核缓冲区有数据
  • I/O multiplexing (select and poll)
  • signal driven I/O (SIGIO) 免去了select的阻塞与轮询,当有活跃套接字时,由注册的handler处理
  • asynchronous I/O (the POSIX aio_functions) 很少有LInux系统支持,windows的IOCP则是此模型

总结一些重点:

只有IOCP是asynchronous I/O,其他机制或多或少都会有一点阻塞。
select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善
epoll, kqueue是Reacor模式,IOCP是Proactor模式。
Java nio 包是会自动选择poll epoll模型
Java nio2包是epoll(Linux),windows(IOCP)

C10k 问题

I/O复用成为了C10K问题的首选方案,libevent ACE ASIO

两种I/O多路复用模式:Reactor和Proactor

在高性能的I/O设计中,两个比较著名的模式Reactor(反应器模式)和Proactor模式,其中Reactor模式用于同步I/O,而Proactor运用于异步I/O操作。

在比较这两个模式之前,我们首先的搞明白几个概念,什么是阻塞和非阻塞,什么是同步和异步;

同步和异步是针对应用程序和内核的交互而言的;

同步指的是用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪,

异步是指用户进程触发IO操作以后便开始做自己的事情,而当IO操作已经完成的时候会得到IO完成的通知。

阻塞和非阻塞是针对于进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作函数的实现方式;

阻塞方式下读取或者写入函数将一直等待,

非阻塞方式下,读取或者写入函数会立即返回一个状态值。

一般来说I/O模型可以分为:同步阻塞,同步非阻塞,异步阻塞,异步非阻塞IO

同步阻塞IO:

在此种方式下,用户进程在发起一个IO操作以后,必须等待IO操作的完成,只有当真正完成了IO操作以后,用户进程才能运行。JAVA传统的IO模型属于此种方式!

同步非阻塞IO:

在此种方式下,用户进程发起一个IO操作以后边可返回做其它事情,但是用户进程需要时不时的询问IO操作是否就绪,这就要求用户进程不停的去询问,从而引入不必要的CPU资源浪费。其中目前JAVA的NIO就属于同步非阻塞IO。

异步阻塞IO:

此种方式下是指应用发起一个IO操作以后,不等待内核IO操作的完成,等内核完成IO操作以后会通知应用程序,这其实就是同步和异步最关键的区别,同步必须等待或者主动的去询问IO是否完成,那么为什么说是阻塞的呢?因为此时是通过select系统调用来完成的,而select函数本身的实现方式是阻塞的,而采用select函数有个好处就是它可以同时监听多个文件句柄,从而提高系统的并发性!

异步非阻塞IO:

在此种模式下,用户进程只需要发起一个IO操作然后立即返回,等IO操作真正的完成以后,应用程序会得到IO操作完成的通知,此时用户进程只需要对数据进行处理就好了,不需要进行实际的IO读写操作,因为真正的IO读取或者写入操作已经由内核完成了。

举个例子,将有助于理解Reactor与Proactor二者的差异,以读操作为例(类操作类似)。

在Reactor中实现读:

  • 注册读就绪事件和相应的事件处理器
  • 事件分离器等待事件
  • 事件到来,激活分离器,分离器调用事件对应的处理器。
  • 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。

在Proactor中实现读:

  • 处理器发起异步读操作(注意:操作系统必须支持异步IO)。在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。
  • 事件分离器等待操作完成事件
  • 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成。
  • 事件分离器呼唤处理器。
  • 事件处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分离器。

可以看出,两个模式的相同点,都是对某个IO事件的事件通知(即告诉某个模块,这个IO操作可以进行或已经完成)。在结构上,两者也有相同点:demultiplexor负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler;不同点在于,异步情况下(Proactor),当回调handler时,表示IO操作已经完成;同步情况下(Reactor),回调handler时,表示IO设备可以进行某个操作(can read or can write)。

综上所述,同步和异步是相对于应用和内核的交互方式而言的,同步 需要主动去询问,而异步的时候内核在IO事件发生的时候通知应用程序,而阻塞和非阻塞仅仅是系统在调用系统调用的时候函数的实现方式而已。Reactor框架中用户定义的操作是在实际操作之前调用的。比如你定义了操作是要向一个SOCKET写数据,那么当该SOCKET可以接收数据的时候,你的操作就会被调用;而Proactor框架中用户定义的操作是在实际操作之后调用的。比如你定义了一个操作要显示从SOCKET中读入的数据,那么当读操作完成以后,你的操作才会被调用。

  • 理论上iocp性能应该比epoll高但是也不一定

select poll epoll

select

select的工作流程:
单个进程就可以同时处理多个网络连接的io请求(同时阻塞多个io操作)。基本原理就是程序呼叫select,然后整个程序就阻塞了,这时候,kernel就会轮询检查所有select负责的fd,当找到一个client中的数据准备好了,select就会返回,这个时候程序就会系统调用,将数据从kernel复制到进程缓冲区。

虽然服务器进程会被select阻塞,但是select会利用内核不断轮询监听其他客户端的io操作是否完成。

Poll介绍

poll的原理与select非常相似,差别如下:

描述fd集合的方式不同,poll使用 pollfd 结构而不是select结构fd_set结构,所以poll是链式的,没有最大连接数的限制

poll有一个特点是水平触发,也就是通知程序fd就绪后,这次没有被处理,那么下次poll的时候会再次通知同个fd已经就绪。

select缺点

根据fd_size的定义,它的大小为32个整数大小(32位机器为32*32,所有共有1024bits可以记录fd),每个fd一个bit,所以最大只能同时处理1024个fd
每次要判断【有哪些event发生】这件事的成本很高,因为select(polling也是)采取主动轮询机制

  1. 每一次呼叫 select( ) 都需要先从 user space把 FD_SET复制到 kernel(约线性时间成本)
    为什么 select 不能像epoll一样,只做一次复制就好呢?
    每一次呼叫 select()前,FD_SET都可能更动,而 epoll 提供了共享记忆存储结构,所以不需要有 kernel 与 user之间的数据沟通

  2. 然后kernel还要轮询每个fd,约线性时间

假设现实中,有1百万个客户端同时与一个服务器保持着tcp连接,而每一个时刻,通常只有几百上千个tcp连接是活跃的,这时候我们仍然使用select/poll机制,kernel必须在搜寻完100万个fd之后,才能找到其中状态是active的,这样资源消耗大而且效率低下。

对于select和poll的上述缺点,就引进了一种新的技术,epoll技术

epoll 提供了三个函数:

1
2
3
4
5
6
int epoll_create(int size);
建立一個 epoll 对象,并传回它的id
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
事件注册函数,将需要监听的事件和需要监听的fd交给epoll对象
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待注册的事件被触发或者timeout发生

epoll解决的问题:

  • epoll没有fd数量限制
  • epoll没有这个限制,我们知道每个epoll监听一个fd,所以最大数量与能打开的fd数量有关,一个g的内存的机器上,能打开10万个左右
  • epoll不需要每次都从user space 将fd set复制到内核kernel
  • epoll在用epoll_ctl函数进行事件注册的时候,已经将fd复制到内核中,所以不需要每次都重新复制一次
  • select 和 poll 都是主动轮询机制,需要拜访每一個 FD;
  • epoll是被动触发方式,给fd注册了相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd加入一个就绪的队列中,epoll_wait的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。

虽然epoll。poll。epoll都需要查看是否有fd就绪,但是epoll之所以是被动触发,就在于它只要去查找就绪队列中有没有fd,就绪的fd是主动加到队列中,epoll不需要一个个轮询确认。换一句话讲,就是select和poll只能通知有fd已经就绪了,但不能知道究竟是哪个fd就绪,所以select和poll就要去主动轮询一遍找到就绪的fd。而epoll则是不但可以知道有fd可以就绪,而且还具体可以知道就绪fd的编号,所以直接找到就可以,不用轮询。

时间复杂度

  1. select==>时间复杂度O(n)

它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  1. poll==>时间复杂度O(n)

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

  1. epoll==>时间复杂度O(1)

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现

更详细介绍

select:

select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:

  1. 单个进程可监视的fd数量被限制,即能监听端口的大小有限。

    一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.

  2. 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:

    当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
    
  3. 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大

poll:

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:

  1. 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。

  2. poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。

epoll:

epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者遇到EAGAIN错误。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

epoll为什么要有EPOLLET触发模式?

如果采用EPOLL LT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。而采用EPOLL ET这种边沿触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符

epoll的优点:

  • 1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
  • 2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
    即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
  • 3、 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

select、poll、epoll 区别总结:

1、支持一个进程所能打开的最大连接数

select

单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。

poll

poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的

epoll

虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接

2、FD剧增后带来的IO效率问题

select

因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。

poll

同上

epoll

因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。

3、 消息传递方式

select

内核需要将消息传递到用户空间,都需要内核拷贝动作

poll

同上

epoll

epoll通过内核和用户空间共享一块内存来实现的。

总结:

综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。

1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善

对这三种IO多路复用进行对比,参考网上和书上面的资料,整理如下:

  1. select实现

select的调用过程如下所示:

  • 使用copy_from_user从用户空间拷贝fd_set到内核空间
  • 注册回调函数__pollwait
  • 遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
  • 以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
  • __pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
  • poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
  • 如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
  • 把fd_set从内核空间拷贝到用户空间。

总结:

select的几大缺点:

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小了,默认是1024
  1. poll实现

  poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

  1. epoll

  epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。

  对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。

  对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。

  对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

总结:

(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

我们都知道unix(Linux)世界里,一切皆文件,而文件是什么呢?文件就是一串二进制流而已,不管socket,还是FIFO、管道、终端,对我们来说,一切都是文件,一切都是流。在信息 交换的过程中,我们都是对这些流进行数据的收发操作,简称为I/O操作(input and output),往流中读出数据,系统调用read,写入数据,系统调用write。不过话说回来了 ,计算机里有这么多的流,我怎么知道要操作哪个流呢?对,就是文件描述符,即通常所说的fd,一个fd就是一个整数,所以,对这个整数的操作,就是对这个文件(流)的操作。我们创建一个socket,通过系统调用会返回一个文件描述符,那么剩下对socket的操作就会转化为对这个描述符的操作。不能不说这又是一种分层和抽象的思想。

IO操作具体又分两部分:
第一部分:等待数据的就绪,也就是文件描述符上有事件就绪,我们才可以对其进行IO操作
第二部分:数据搬迁,说白了就是将一个文件中的数据搬到另一个文件中

什么是IO多路复用/转接
I/O多路复用实际上就是用select, poll, epoll监听多个io对象,当io对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个socket。

IO多路复用可以大大的降低等待事件就绪的时间,从而有效的提高IO效率。

下面介绍三种IO复用的接口实现,原理解释以及三种方式的对比,如果想结合对应的具体代码了解,请戳这里->select、poll、epoll服务器编写

select详解

具体介绍和实现

1
2
3
4
5
6
7
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
//参数nfds是需要监听的最大的文件描述符值+1
//rdset,wrset,exset分别对应需要检测的可读文件描述符的集合,
//可写文件描述符的集合集异常文件描述符的集合
//参数timoout为结构timeval,用来设置select()的等待时间

用户将自己所关心的文件描述符添加进描述符集中,并且明确关心的是读,写,还是异常事件
select通过轮询的方式不断扫描所有被关心的文件描述符,具体时间由参数timeout决定
执行成功则返回文件描述符状态已改变的个数
具体哪一个或哪几个文件描述符就绪,则需要文件描述符集传出,它既是输入型参数,又是输出型参数
fd_set是用位图存储文件描述符的,因为文件描述符是唯一且递增的整数
操作fd_set的一组接口

1
2
3
4
void FD_CLR(int fd, fd_set *set);//清除set中相关fd的位,即将其置为0
void FD_ISSET(int fd, fd_set *set);//测试set中相关fd是否存在,即该位是否被置1
void FD_SET(int fd, fd_set *set);//设置set中相关fd的位,即将其置1
void FD_ZERO(fd_set *set);//清空set中所有的位,即全置为0

特点

可关心的文件描述符数量是有上限的,取决于fd_set的大小
每次调用select前,都要把文件描述符重新添加进去fd_set中,因为fd_set也是输出型参数,在函数返回后,fd_set中只有就绪的文件描述符
通常我们要关心的文件描述符不止一个,所以首先用数组保存文件描述符,每次调用select前再通过遍历数组逐个添加进去
缺点

每次调用select都需要手动设置fd_set集合
每次调用select需要遍历fd_set集合,而且要将fd_set集合从用户态拷贝到内核态,如果fd很多时,开销会很大
select支持的文件描述符数量太少

poll详解

具体介绍和实现

1
2
3
4
5
6
7
8
9
10
11
#include <sys/poll.h>
int poll(struct pollfd* fds, nfds_t nfds, int timeout);

//pollfd结构
struct pollfd{
int fd;
short events;
short revents;
};
//events 是我们要关心的事件,revents是调用后操作系统设置的参数,
//也就是表明该文件描述符是否就绪

首先创建一个pollfd结构体变量的数组fd_list,然后将我们关心的fd放置在数组中的结构体变量中,并添加我们所关心的事件,调用poll函数,函数返回后我们再通过遍历的方式去查看数组中那些文件描述符上的事件就绪了。

特点(相对与select来说)

每次调用poll之前不需要手动设置文件描述符集
poll将用户关心的事件和发生的事件进行了分离
支持的文件描述符数量理论上是无上限的,其实也有,因为一个进程能打开的文件数量是有上限的 ulimit -n 查看进程可打开的最大文件数
缺点

poll返回后,也需要轮询pollfd来获取就绪的描述符
同时连接的大量客户端可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降

epoll详解

具体介绍和实现
接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <sys/epoll.h>
int epoll_create(int size);
//epoll_create的作用是创建一个epoll模型,该模型在底层建立了->
//**红黑树,就绪队列,回调机制**
//size可以被忽略,不做解释

int epoll_ctl(int epfd, int op, int fd, struct epoll_events *event);
//epfd:epoll_create()的返回值(epoll的句柄,本质上也是一个文件描述符)
//op:表示动作,用三个宏来表示
// EPOLL_CTL_ADD:注册新的fd到epfd中
// EPOLL_CTL_MOD:修改已经注册的fd的监听事件
// EPOLL_CTL_DEL:从epfd中删除一个事件
//fd:需要监听的文件描述符
//event:具体需要在该文件描述符上监听的事件

int epoll_wait(int epfd, struct epoll_event * events, int maxevents,
int timeout);
//函数调用成功,返回文件描述符就绪的个数,也就是就绪队列中文件描述符的个数,
//返回0表示超时,小于0表示出错

//epoll_event结构体
struct epoll_event{
uint32_t events; /* Epoll events */
epoll_data_t data;/* User data variable */
}__EPOLL_PACKED;
//events可以是一堆宏的集合,这里介绍几个常用的
// EPOLLIN:表示对应的文件描述符可以读(包括对端socket正常关闭)
// EPOLLOUT:表示对应的文件描述符可以写
// EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,
// 默认情况下epoll为水平触发(Level Triggered)模式
typedef union epoll_data{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
//联合体里通常只需要填充fd就OK了,其他参数暂时可以不予理会

工作原理

创建一个epoll对象,向epoll对象中添加文件描述符以及我们所关心的在该文件描述符上发生的事件

通过epoll_ctl向我们需要关心的文件描述符中注册事件(读,写,异常等),操作系统将该事件和对应的文件描述符作为一个节点插入到底层建立的红黑树

添加到文件描述符上的事件都会与网卡建立回调机制,也就是事件发生时会自主调用一个回调方法,将事件所在的文件描述符插入到就绪队列中

应用程序调用epoll_wait就可以直接从就绪队列中将所有就绪的文件描述符拿到,可以说时间复杂度是O(1)

水平触发工作方式(LT)

处理socket时,即使一次没将数据读完,下次调用epoll_wait时该文件描述符也会就绪,可以继续读取数据

边沿触发工作方式(ET)

处理socket时没有一次将数据读完,那么下次再调用epoll_wait该文件描述符将不再显示就绪,除非有新数据写入

在该工作方式下,当一个文件描述符就绪时,我们要一次性的将数据读完

隐患问题

当我们调用read读取缓冲区数据时,如果已经读取完了,对端没有关闭写端,read就会阻塞,影响后序逻辑

解决方式就是将文件描述符设置成非阻塞的,当没有数据的时候,read也不会被阻塞,可以处理后续逻辑(读取其他的fd或者继续wait)

ET的性能要好于LT,因为epoll_wait返回的次数比较少,Nginx中默认采用ET模式使用epoll

特点

采用了回调机制,与轮询区别看待
底层采用红黑树结构管理已经注册的文件描述符
采用就绪队列保存已经就绪的文件描述符
优点

文件描述符数目无上限:通过epoll_ctl注册一个文件描述符后,底层采用红黑树结构来管理所有需要监控的文件描述符
基于事件的就绪通知方式:每当有文件描述符就绪时,该响应事件会调用回调方法将该文件描述符插入到就绪队列中,不需要内核每次去轮询式的查看每个被关心的文件描述符
维护就绪队列:当文件描述符就绪的时候,就会被放到内核中的一个就绪队列中,调用epoll_wait可以直接从就绪队列中获取就绪的文件描述符,时间复杂度是O(1)

惊群问题

当有多个子进程在阻塞等待同一个文件描述符上的某事件时,如果该事件发生,那么所有的子进程都会被唤醒
进程被唤醒,需要进行内核重新调度,这样每个进程同时去响应这一个事件,而最终只有一个进程能处理事件成功,其他的进程在处理该事件失败后重新休眠或其他。会造成性能浪费
早期版本中,Linux中采用全部唤醒的机制,极大浪费性能,目前版本中,采用的是只唤醒等待队列上的第一个进程或线程,但是selec、poll和epoll中还存在部分的惊群问题

惊群问题解决方法
Nginx中使用mutex互斥锁解决这个问题,具体措施有使用全局互斥锁,每个子进程在epoll_wait()之前先去申请锁,申请到则继续处理,获取不到则等待,并设置了一个负载均衡的算法来均衡各个进程的任务量。

来说说后台服务模型:

迭代服务器

简单说,就是在while中循环accept, 然后处理。显然, 这种服务是没有并发功能的, 后一个请求必须等前一个请求处理完毕, 才会被处理。

多线程模型

简单说, 在while循环中循环accept, 然后开启线程来应对每一个请求, 而非在主线程执行阻塞处理操作, 让后一个请求苦苦等待。此时,服务有了并发处理能力。 (实际中的服务, 肯定不会为每个请求都搞一个线程的)

多进程模型

简单说, 就是在父进程中循环accept, 然后开启子进程来应对每一个请求, 而非在主进程执行阻塞处理操作,让后一个请求苦苦等待。此时,服务有了并发处理能力。(实际中的服务, 肯定不会为每个请求都搞一个进程的)

IO多路复用select/poll/epoll

确实有很多地方说select/poll/epoll并发, 这是多么扯淡啊, 它们不过是多路复用, 而已。 很多网上程序给出的epoll代码实现的服务器, 其实是没有并发能力的, 也仅仅是迭代服务器。

select/poll/epoll的作用是IO复用, 要实现并发, 还是需要交个其他线程/进程去处理。 业界很多成熟的服务组件, 就是这么玩的, 如nginx.

所以,再说一次, select/poll/epoll没有并发能力, 仅仅是IO多路复用而已。

那么,IO复用, 复用的是什么呢? 复用的是, 同一个线程!

对于Java NIO 是同步还是异步非阻塞的看法

有人说是JAVA NIO是同步非阻塞,但为什么又说是异步的呢?

其实这个是看问题的角度不同而已,我们可以通过缓存层实现linux内核与程序模型的隔离。这样即使linux内核是阻塞的,程序也是可以继续往下走的。所以这里说的同步是说linux内核实现是阻塞的,会再read、write方法阻塞。但是通过缓存层java程序是可以不等待的继续往下走,后面再来访问缓存。JDK7中的NIO2.0实现的异步非阻塞其实也是java层面实现的,linux内核是不变的还是epoll。

epoll可以将哪个流发生了什么事件主动通知,而不需要轮询。那么到底是如何实现通知的呢?

这就和操作系统的原理相关,在内核的最底层是中断,类似系统回调的机制。网卡设备对应一个中断号, 当网卡收到网络端的消息的时候会向CPU发起中断请求, 然后CPU处理该请求. 通过驱动程序 进而操作系统得到通知, 系统然后通知epoll, epoll通知用户代码。

java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static SelectorProvider create() {
PrivilegedAction pa = new GetPropertyAction("os.name");
String osname = (String) AccessController.doPrivileged(pa);
if ("SunOS".equals(osname)) {
return new sun.nio.ch.DevPollSelectorProvider();
}

// use EPollSelectorProvider for Linux kernels >= 2.6
if ("Linux".equals(osname)) {
pa = new GetPropertyAction("os.version");
String osversion = (String) AccessController.doPrivileged(pa);
String[] vers = osversion.split("\\.", 0);
if (vers.length >= 2) {
try {
int major = Integer.parseInt(vers[0]);
int minor = Integer.parseInt(vers[1]);
if (major > 2 || (major == 2 && minor >= 6)) {
return new sun.nio.ch.EPollSelectorProvider();
}
} catch (NumberFormatException x) {
// format not recognized
}
}
}

return new sun.nio.ch.PollSelectorProvider();
}

可以看到在Linux下,内核版本大于2.6时使用epoll,小于2.6时使用poll

IO流程图

fork进程

fork进程

new thread

new thread

epoll

epoll模型红黑树

epoll2

epoll实现2

select

select

bio

bio

nio

nio

multi

multi

aio

aio

epoll部分图

epoll实现

epoll部分图

epoll实现