网络模型
每层都有啥
TCP
- 在IP协议上,建立端口到端口的可靠连接。报文有序
建立连接:3次握手
- 客户端发送报文:SYN是同步标记位=1,seq=x
- 服务器监听端口,获得报文,回复:SYN=1,ACK=1,seq=y,ack=x+1
- 客户端接收报文后返回:ACK=1,seq=x+1,ack=y+1
- 这样通过客户端和服务端通过随机数+1就确认了双方状态
断开连接:4次挥手
- 客户端端主动关闭连接(服务端也可以主动关闭连接),发送报文:FIN=1,seq=u
- 服务端接受到报文后,
- 回复ACK=1,seq=v,ack=u+1,表示已经收到 请求连接断开
- 等待把数据全部发送完成后
- 再发送一个FIN=1,ACK=1,seq=w,ack=u+1
- 客户端接受到报文后,回复 ACK=1,seq=u+1,ack=w+1,表示已收到断开连接,等待2MSL后,关闭连接
- 服务器接受到后关闭连接
常见问题
- 为什么建立连接3次,而断开连接4次
- 通过3次即可确认双方的收发都是好的
- seq和ack保证是一个报文的回复,
- 一问一答保证单向通路
- 将中间的答问合并成一个报文,省了一次传输
- 断开连接也是一样
- 4次传输,保证双向通路都是好的。
- 不能将中间的问答合并,是因为需要等服务端将所有的信息都发送完,才能断开连接
- 通过3次即可确认双方的收发都是好的
- 2MSL, 若服务器未收到返回报文,则可以重发报文
传输
- 如何在不可靠的网络上建立可靠(报文内容不错,不多,不少,顺序)传输通道, 只是网络层的可靠,没有持久化啥的,和数据可靠性不能混
- 切分应用报文顺序收发,有编号,有确认
- 超时重发,丢弃重复报文
- 校验和: TCP 将保持它⾸部和数据的检验和。这是⼀个端到端的检验和,⽬的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错, TCP 将丢弃这个报⽂段和不确认收到此报⽂段。
- 批量发送:
- 滑动窗口概念
- 每次发送一条,等待确认,网络传输耗时长,浪费时间
- 一次发送一批,即无需等待确认的报文的数量。
- 流量控制协议
- 发送方不要过快,要让接收方可以接受,TCP 连接的每⼀⽅都有固定⼤⼩的缓冲空间
- 就是利用可变大小的滑动窗口实现
- 连续ARQ协议
- 拥塞控制协议
- 批量发送不是越大越好,当网络拥堵后,继续增加批次反而造成速度下降
- 拥塞窗口,和滑动窗口类似,发送时取两者小的。通过动态调节拥塞窗口的大小,有效利用网络带宽
- 算法
- 慢开始
- 指数增长
- 拥塞避免
- 慢开始的阈值,线性增长
- 判断网络拥塞
* 少量的丢包, 我们仅仅是触发超时重传;
* 大量的丢包, 我们就认为是网络拥塞; - 快重传:发送方尽快重传未确认的报文。收到3个确认报文,即重传。而不是等到超时再重传
- 快恢复:
- 快恢复之前都是慢开始,效率低。
- 快恢复是乘法减少,而不是直接慢开始
- 慢开始
- 滑动窗口概念
HTTP
- DNS解析
- TCP连接
- 发送HTTP请求
- 服务器处理请求并返回HTTP报⽂
- 浏览器解析渲染⻚⾯
- 连接结束
HTTP2与HTTP1.1
- 传输与连接
- HTTP1.1 长链接,指的是TCP长链接,省去一个HTTP建立关闭一个TCP的开销。
- 单个 TCP 连接在同一时刻只能处理一个请求,HTTP请求不能乱序,队头阻塞
- 一般采用多个TCP
- 可以使用流水线,多个请求放到一个tcp连接中,但是服务器需要顺序发送响应。也有队头阻塞。用的不多
- HTTP2.0 多路复用,在一个TCP连接同时发送多个请求,可以乱序。甚至可以在一个TCP报文中发送多个HTTP报文。
- HTTP1.1 长链接,指的是TCP长链接,省去一个HTTP建立关闭一个TCP的开销。
- 报文
- HTTP1.1 报文体压缩
- HTTP2.0 二进制报文体积小,报文头压缩,cook。。。。
- HTTP2.0主动推送
socket
- 网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
- 重点是应用通信,ip+端口,是个API/接口。
- 是对TCP/IP协议的进一步封装。
linux5中IO模型
重点在于两个阶段:
- 准备数据,对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来
- kernel中拷贝到用户内存
阻塞和非阻塞说的是等待数据阶段,用户线程是否阻塞
同步和异步说的是在kernel中拷贝到用户内存是否阻塞的
IO多路复用模型
- https://zhuanlan.zhihu.com/p/179071801
- https://zhuanlan.zhihu.com/p/382157527
- 总结
- 为啥性能高,对比为每个连接都使用单独的线程处理
- 从机制上说,监控多个socket连接,只用一个IO线程处理
- 本质上将多个socket连接批量处理,一波可能处理多个,
- 比每个连接都使用单独的线程处理(一个连接只能处理一个),减少的线程切换
- 一个线程比多个线程减少内存等系统资源的占用
- 代价
- 响应时间增加
- 适用情况:
- 大量连接
- 少量活跃
- 处理逻辑开销小
- 并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好
- 为啥性能高,对比为每个连接都使用单独的线程处理
- select
- 优点:跨平台,大部分操作系统支持
- 缺点:
- select支持的文件描述符数量太小了,默认是1024
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- poll
- 数组改链表,解决了文件描述符小的问题
- epoll
- 无需每次copy
- 返回的就是就绪的fd
- ET,LT两种触发方式
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。
优点
跨平台,大部分操作系统支持
缺点
- select支持的文件描述符数量太小了,默认是1024
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
poll
int poll (struct pollfd fds, unsigned int nfds, int timeout);
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。
struct pollfd {
int fd; / file descriptor /
short events; / requested events to watch /
short revents; / returned events witnessed */
};
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
优点
- poll支持的文件描述符数量没有限制
缺点
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll LT 与 ET模式的区别
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。
LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作
ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读完,或者遇到EAGAIN错误
优点
- 采用回调机制代替轮询,效率提升
- epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
总结
https://mp.weixin.qq.com/s/PzOF9lFYVacPIRx0JakTjA
应用
- Redis
- Nginx
- Netty
- java nio
博客
https://www.cnblogs.com/Anker/p/3265058.html
epoll源码解析翻译------说使用了mmap的都是骗子:https://www.cnblogs.com/l2017/p/10830391.html
linux socket
- UNIX/Linux 中的一切都是文件:在 UNIX/Linux 系统中,为了统一对硬件的操作,简化接口,不同的硬件设备都被看成一个文件。对这些文件的操作,就等同于对磁盘上普通文件的操作。
以linux系统TCP协议为例,socket通信步骤 - 系统分配资源,服务端开启Socket进程,对特定端口号进行监听
- 客户端针对服务端IP进行特定端口的连接
- 连接建立,开始通信
- 通信完成,关闭连接
细节见:https://blog.csdn.net/vipshop_fin_dev/article/details/102966081
java scoket
java socket是封装了操作系统的socket api。
以linux为例:
java 伪异步IO
伪异步IO的概念完全来源于实践,在JDK NIO编程没有流行之前,为了解决Tomcat通信线程同步IO导致业务线程被挂住的问题,大家想到了一个办法,就是在通信线程和业务线程之间做个缓冲区,这个缓冲区用于隔离IO线程和业务线程间的直接访问,这样业务线程就不会被IO线程阻塞,对于后端的业务侧来说,将消息或者Task放到线程池后就返回了,它不再直接访问IO线程或者进行IO读写,这样也就不会被同步阻塞。类似这样的设计还包括前端启动一组线程,将接收的客户端封装成Task,放到后端的线程池执行,用于解决一连接一线程问题,类似这样通过线程池做缓冲区的做法,我们习惯称它为伪异步IO,官方并没有伪异步IO这种说法。
参考:https://ifeve.com/netty-2-5/
java nio
源码解析:https://www.cnblogs.com/jackion5/p/13547040.html
java aio
BIO和NIO适用场景
BIO少量连接重操作(瓶颈在业务操作上)
NIO大量连接少量活跃连接轻操作(瓶颈不在业务操作上)
netty
问题
netty为啥不直接使用java nio
- Java nio 空轮询bug,到JDK8也存在,之后的版本不知道。https://www.cnblogs.com/luoxn28/p/11872858.html
- Java nio 封装的是epoll LT模式,而epoll ET模式效率更高,netty采用epoll ET
netty和nio都使用了IO多路复用模型-epoll,为啥性能高
- netty不是对java nio的封装,而是自己使用JNI调用c。
- 零拷贝;https://www.cnblogs.com/jackion5/p/13604331.html
netty为啥放弃了aio
Netty作者在这个问题上的原话
- Not faster than NIO(epoll) on unix systems (which is true) 译:在UNIX系统上不比NIO快
- There is no daragram support 译:不支持数据报
- Unnecessary threading model(too much abstraction without usage) 译:不必要的线程模型
但是对windows的开发者来说,是有用的。
所以我们可以总结出以下几点:
- NIO中将多路请求注册到多路选择复用器上,线程轮询请求状态,发现线程完毕则分配线程进行处理业务逻辑;而AIO是每个请求从一开始就分配一个线程,实现了真正的异步处理。但是这也会带来一个问题,线程池中线程的数量不是无限的,如果每个请求从一开始就分配线程进行处理,很快就会用完线程。
- 在LINUX系统上,AIO底层实现仍使用Epoll,没有很好的实现AIO,因此性能上没有明显优势,而且被JDK封装了一层不容易优化
- Windows的AIO底层实现良好,但是Netty开发人员并没有把Windows作为主要使用平台考虑
- linux上aio不够成熟,处理回调的结果速度跟不到处理需求,供不应求,造成处理速度有瓶颈
- aio还有个缺点是接收数据需要预先分配缓冲区,而不是NIO那种需要接收时才需要分配缓存,所以对连接数量非常大但流量小的情况,内存浪费很多
- Netty整体架构是基本reactor模型,而aio是proactor模型,混合在一起会比较混乱
IO演进
java BIO,阻塞。一个连接配一个线程处理(网络IO和业务处理)。一般java项目中线程上限较小,几百到几千。一个java线程默认1M内存,1000个线程就是1G。无力处理上万连接。
一般采用线程池复用线程,这样一个线程如果处理多个连接时,一旦阻塞,也就处理不了其他的连接了。
一个想法就是如果IO不阻塞,就可以一个线程处理多个请求,不必频繁切换线程上下文。需要os/硬件支持。
主要解决两个问题:
大量连接处理:BIO,阻塞。一般java项目中线程上限较小,几百到几千。一个java线程默认1M内存,1000个线程就是1G。无力处理上万连接。
网络速度和业务处理速度不匹配的问题。假设处理网络耗时100ms,其中大部分时间都是等待,而业务处理10ms,大量的时间浪费在等待中(对于线程来说),对于cpu来说,则是在等待网络时,会切换线程,让出cpu给其他线程使用,但是,切换是有代价的大概会1000个时钟周期。如果使用一个线程处理多个请求,则可以省下一部分线程切换的时间。
以http请求为例,一个客户登录网站10分钟,总共和服务器交互60次,大概率就是一个TCP连接。10分钟内可能有1W人同时在线。1分钟需要处理6W请求,QPS为1000。假设一个请求20ms,单个线程QPS为50,则需20个线程。但是需要建立1W个连接,BIO就是1W线程,肯定是不行的。
rpc也是长连接,内部服务器相互通信倒是不会有大量的连接,有100台服务器,最多和99台建立连接,即99个连接就可以。
nio比bio减少了IO线程,主要是IO线程的内存占用和上下文的切换,用户态和内核态的相互转换。
tamcat bio
https://zhuanlan.zhihu.com/p/298022145
https://www.jianshu.com/p/857baa251902
https://blog.csdn.net/hixiaoxiaoniao/article/details/80820932
epoll
- 转自:https://juejin.cn/post/6940453515353391140
简介
Epoll 是个很老的知识点,是后端工程师的经典必修课。这种知识具备的特点就是研究的人多,所以研究的趋势就会越来越深。当然分享的人也多,由于分享者水平参差不齐,也产生的大量错误理解。
今天我再次分享 epoll,肯定不会列个表格,对比一下差异,那就太无聊了。我将从线程阻塞的原理,中断优化,网卡处理数据过程出发,深入的介绍 epoll 背后的原理,最后还会 diss 一些流行的观点。相信无论你是否已经熟悉 epoll,本文都会对你有价值。
引言
正文开始前,先问大家几个问题。
- epoll 性能到底有多高。很多文章介绍 epoll 可以轻松处理几十万个连接。而传统 IO 只能处理几百个连接 是不是说 epoll 的性能就是传统 IO 的千倍呢?
- 很多文章把网络 IO 划分为阻塞,非阻塞,同步,异步。并表示:非阻塞的性能比阻塞性能好,异步的性能比同步性能好。
- 如果说阻塞导致性能低,那传统 IO 为什么要阻塞呢?
- epoll 是否需要阻塞呢?
- Java 的 NIO 和 AIO 底层都是 epoll 实现的,这又怎么理解同步和异步的区别?
- 都是 IO 多路复用。
- 既生瑜何生亮,为什么会有 select,poll 和 epoll 呢?
- 为什么 epoll 比 select 性能高?
PS:
本文共包含三大部分:初识 epoll、epoll 背后的原理 、Diss 环节。
本文的重点是介绍原理,建议读者的关注点尽量放在:“为什么”。
Linux 下进程和线程的区别其实并不大,尤其是在讨论原理和性能问题时,因此本文中“进程”和“线程”两个词是混用的。
初识 epoll
epoll 是 Linux 内核的可扩展 I/O 事件通知机制,其最大的特点就是性能优异。下图是 libevent(一个知名的异步事件处理软件库)对 select,poll,epoll ,kqueue 这几个 I/O 多路复用技术做的性能测试。
很多文章在描述 epoll 性能时都引用了这个基准测试,但少有文章能够清晰的解释这个测试结果。
这是一个限制了100个活跃连接的基准测试,每个连接发生1000次读写操作为止。纵轴是请求的响应时间,横轴是持有的 socket 句柄数量。随着句柄数量的增加,epoll 和 kqueue 响应时间几乎无变化,而 poll 和 select 的响应时间却增长了非常多。
可以看出来,epoll 性能是很高的,并且随着监听的文件描述符的增加,epoll 的优势更加明显。
不过,这里限制的100个连接很重要。epoll 在应对大量网络连接时,只有活跃连接很少的情况下才能表现的性能优异。换句话说,epoll 在处理大量非活跃的连接时性能才会表现的优异。如果15000个 socket 都是活跃的,epoll 和 select 其实差不了太多。
为什么 epoll 的高性能有这样的局限性?
问题好像越来越多了,看来我们需要更深入的研究了。
epoll背后的原理
阻塞
为什么阻塞
我们以网卡接收数据举例,回顾一下之前我分享过的网卡接收数据的过程。
为了方便理解,我尽量简化技术细节,可以把接收数据的过程分为4步:
- NIC(网卡) 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff)。
- NIC 发出中断请求(IRQ),告诉内核有新的数据过来了。
- Linux 内核响应中断,系统切换为内核态,处理 Interrupt Handler,从RingBuffer 拿出一个 Packet, 并处理协议栈,填充Socket 并交给用户进程。
- 系统切换为用户态,用户进程处理数据内容。
网卡何时接收到数据是依赖发送方和传输路径的,这个延迟通常都很高,是毫秒(ms)级别的。而应用程序处理数据是纳秒(ns)级别的。也就是说整个过程中,内核态等待数据,处理协议栈是个相对很慢的过程。这么长的时间里,用户态的进程是无事可做的,因此用到了“阻塞(挂起)”。
阻塞不占用 cpu
阻塞是进程调度的关键一环,指的是进程在等待某事件发生之前的等待状态。请看下表,在 Linux 中,进程状态大致有7种(在 include/linux/sched.h 中有更多状态):
从说明中其实就可以发现,“可运行状态”会占用 CPU 资源,另外创建和销毁进程也需要占用 CPU 资源(内核)。重点是,当进程被"阻塞/挂起"时,是不会占用 CPU 资源的。
换个角度来讲。为了支持多任务,Linux 实现了进程调度的功能(CPU 时间片的调度)。而这个时间片的切换,只会在“可运行状态”的进程间进行。因此“阻塞/挂起”的进程是不占用 CPU 资源的。
另外讲个知识点,为了方便时间片的调度,所有“可运行状态”状态的进程,会组成一个队列,就叫**“工作队列”**。
阻塞的恢复
内核当然可以很容易的修改一个进程的状态,问题是网络 IO 中,内核该修改那个进程的状态。
socket 结构体,包含了两个重要数据:进程 ID 和端口号。进程 ID 存放的就是执行 connect,send,read 函数,被挂起的进程。在 socket 创建之初,端口号就被确定了下来,操作系统会维护一个端口号到 socket 的数据结构。
当网卡接收到数据时,数据中一定会带着端口号,内核就可以找到对应的 socket,并从中取得“挂起”进程的 ID。将进程的状态修改为“可运行状态”(加入到工作队列)。此时内核代码执行完毕,将控制权交还给用户态。通过正常的“CPU 时间片的调度”,用户进程得以处理数据。
进程模型
上面介绍的整个过程,基本就是 BIO(阻塞 IO)的基本原理了。用户进程都是独立的处理自己的业务,这其实是一种符合进程模型的处理方式。
上下文切换的优化
上面介绍的过程中,有两个地方会造成频繁的上下文切换,效率可能会很低。
- 如果频繁的收到数据包,NIC 可能频繁发出中断请求(IRQ)。CPU 也许在用户态,也许在内核态,也许还在处理上一条数据的协议栈。但无论如何,CPU 都要尽快的响应中断。这么做实际上非常低效,造成了大量的上下文切换,也可能导致用户进程长时间无法获得数据。(即使是多核,每次协议栈都没有处理完,自然无法交给用户进程)
- 每个 Packet 对应一个 socket,每个 socket 对应一个用户态的进程。这些用户态进程转为“可运行状态”,必然要引起进程间的上下文切换。
网卡驱动的 NAPI 机制
在 NIC 上,解决频繁 IRQ 的技术叫做 New API(NAPI) 。原理其实特别简单,把 Interrupt Handler 分为两部分。
- 函数名为 napi_schedule,专门快速响应 IRQ,只记录必要信息,并在合适的时机发出软中断 softirq。
- 函数名为 netrxaction,在另一个进程中执行,专门响应 napi_schedule 发出的软中断,批量的处理 RingBuffer 中的数据。
所以使用了 NAPI 的驱动,接收数据过程可以简化描述为:
- NIC 接收到数据,通过 DMA 方式写入内存(Ring Buffer 和 sk_buff)。
- NIC 发出中断请求(IRQ),告诉内核有新的数据过来了。
- driver 的 napi_schedule 函数响应 IRQ,并在合适的时机发出软中断(NET_RX_SOFTIRQ)
- driver 的 net_rx_action 函数响应软中断,从 Ring Buffer 中批量拉取收到的数据。并处理协议栈,填充 Socket 并交给用户进程。
- 系统切换为用户态,多个用户进程切换为“可运行状态”,按 CPU 时间片调度,处理数据内容。
一句话概括就是:等着收到一批数据,再一次批量的处理数据。
单线程的 IO 多路复用
内核优化“进程间上下文切换”的技术叫的“IO 多路复用”,思路和 NAPI 是很接近的。
每个 socket 不再阻塞读写它的进程,而是用一个专门的线程,批量的处理用户态数据,这样就减少了线程间的上下文切换。
作为 IO 多路复用的一个实现,select 的原理也很简单。所有的 socket 统一保存执行 select 函数的(监视进程)进程 ID。任何一个 socket 接收了数据,都会唤醒“监视进程”。内核只要告诉“监视进程”,那些 socket 已经就绪,监视进程就可以批量处理了。
IO 多路复用的进化
对比 epoll 与 select
select,poll 和 epoll 都是“IO 多路复用”,那为什么还会有性能差距呢?篇幅限制,这里我们只简单对比 select 和 epoll 的基本原理差异。
对于内核,同时处理的 socket 可能有很多,监视进程也可能有多个。所以监视进程每次“批量处理数据”,都需要告诉内核它“关心的 socket”。内核在唤醒监视进程时,就可以把“关心的 socket”中,就绪的 socket 传给监视进程。
换句话说,在执行系统调用 select 或 epoll_create 时,入参是“关心的 socket”,出参是“就绪的 socket”。
而 select 与 epoll 的区别在于:
- select (一次O(n)查找)
每次传给内核一个用户空间分配的 fd_set 用于表示“关心的 socket”。其结构(相当于 bitset)限制了只能保存1024个 socket。
每次 socket 状态变化,内核利用 fd_set 查询O(1),就能知道监视进程是否关心这个 socket。
内核是复用了 fd_set 作为出参,返还给监视进程(所以每次 select 入参需要重置)。
然而监视进程必须遍历一遍 socket 数组O(n),才知道哪些 socket 就绪了。 - epoll (全是O(1)查找)
每次传给内核一个实例句柄。这个句柄是在内核分配的红黑树 rbr+双向链表 rdllist。只要句柄不变,内核就能复用上次计算的结果。
每次 socket 状态变化,内核就可以快速从 rbr 查询O(1),监视进程是否关心这个 socket。同时修改 rdllist,所以 rdllist 实际上是“就绪的 socket”的一个缓存。
内核复制 rdllist 的一部分或者全部(LT 和 ET),到专门的 epoll_event 作为出参。
所以监视进程,可以直接一个个处理数据,无需再遍历确认。
- select 使用
main()
{
int sock;
FILE *fp;
struct fd_set fds;
struct timeval timeout={3,0}; //select等待3秒,3秒轮询,要非阻塞就置0
char buffer[256]={0}; //256字节的接收缓冲区
/* 假定已经建立UDP连接,具体过程不写,简单,当然TCP也同理,主机ip和port都已经给定,要写的文件已经打开
sock=socket(...);
bind(...);
fp=fopen(...); */
while(1)
{
FD_ZERO(&fds); //每次循环都要清空集合,否则不能检测描述符变化
FD_SET(sock,&fds); //添加描述符
FD_SET(fp,&fds); //同上
maxfdp=sock>fp?sock+1:fp+1; //描述符最大值加1
switch(select(maxfdp,&fds,&fds,NULL,&timeout)) //select使用
{
case -1: exit(-1);break; //select错误,退出程序
case 0:break; //再次轮询
default:
if(FD_ISSET(sock,&fds)) //测试sock是否可读,即是否网络上有数据
{
recvfrom(sock,buffer,256,.....);//接受网络数据
if(FD_ISSET(fp,&fds)) //测试文件是否可写
fwrite(fp,buffer...);//写入文件
buffer清空;
}// end if break;
}// end switch
}//end while
}//end main
另外,epoll_create 底层实现,到底是不是红黑树,其实也不太重要(完全可以换成 hashtable)。重要的是 efd 是个指针,其数据结构完全可以对外透明的修改成任意其他数据结构。
API 发布的时间线
另外,我们再来看看网络 IO 中,各个 api 的发布时间线。就可以得到两个有意思的结论。
1983,socket 发布在 Unix(4.2 BSD)
1983,select 发布在 Unix(4.2 BSD)
1994,Linux的1.0,已经支持socket和select
1997,poll 发布在 Linux 2.1.23
2002,epoll发布在 Linux 2.5.44
1、socket 和 select 是同时发布的。这说明了,select 不是用来代替传统 IO 的。这是两种不同的用法(或模型),适用于不同的场景。
2、select、poll 和 epoll,这三个“IO 多路复用 API”是相继发布的。这说明了,它们是 IO 多路复用的3个进化版本。因为 API 设计缺陷,无法在不改变 API 的前提下优化内部逻辑。所以用 poll 替代 select,再用 epoll 替代 poll。
总结
我们花了三个章节,阐述 Epoll 背后的原理,现在用三句话总结一下。
基于数据收发的基本原理,系统利用阻塞提高了 CPU 利用率。
为了优化上线文切换,设计了“IO 多路复用”(和 NAPI)。
为了优化“内核与监视进程的交互”,设计了三个版本的 API(select,poll,epoll)。
Diss 环节
讲完“Epoll 背后的原理”,已经可以回答最初的几个问题。这已经是一个完整的文章,很多人劝我删掉下面的 diss 环节。
我的观点是:学习就是个研究+理解的过程。上面是研究,下面再讲一下我的个人“理解”,欢迎指正。
关于 IO 模型的分类
关于阻塞,非阻塞,同步,异步的分类,这么分自然有其道理。但是在操作系统的角度来看**“这样分类,容易产生误解,并不好”**。
阻塞和非阻塞
Linux 下所有的 IO 模型都是阻塞的,这是收发数据的基本原理导致的。阻塞用户线程是一种高效的方式。
你当然可以写一个程序,socket 设置成非阻塞模式,在不使用监视器的情况下,依靠死循环完成一次 IO 操作。但是这样做的效率实在是太低了,完全没有实际意义。
换句话说,阻塞不是问题,运行才是问题,运行才会消耗 CPU。IO 多路复用不是减少了阻塞,是减少了运行。上下文切换才是问题,IO 多路复用,通过减少运行的进程,有效的减少了上下文切换。
同步和异步
Linux 下所有的 IO 模型都是同步的。BIO 是同步的,select 同步的,poll 同步的,epoll 还是同步的。
Java 提供的 AIO,也许可以称作“异步”的。但是 JVM 是运行在用户态的,Linux 没有提供任何的异步支持。因此 JVM 提供的异步支持,和你自己封装成“异步”的框架是没有本质区别的(你完全可以使用 BIO 封装成异步框架)。
所谓的“同步“和”异步”只是两种事件分发器(event dispatcher)或者说是两个设计模式(Reactor 和 Proactor)。都是运行在用户态的,两个设计模式能有多少性能差异呢?
Reactor 对应 java 的 NIO,也就是 Channel,Buffer 和 Selector 构成的核心的 API。
Proactor对应 java 的 AIO,也就是 Async 组件和 Future 或 Callback 构成的核心的 API。
我的分类
我认为 IO 模型只分两类:
更加符合程序员理解和使用的,进程模型;
更加符合操作系统处理逻辑的,IO 多路复用模型。
对于“IO多路复用”的事件分发,又分为两类:Reactor 和 Proactor。
关于 mmap
epoll 到底用没用到 mmap?
答案:没有!
这是个以讹传讹的谣言。其实很容易证明的,用 epoll 写个 demo。strace 一下就清楚了。