
IO(input output)主要指:文件IO,网络IO。今天我们重点讨论的是网络IO
首先我们要对这两种IO操作有个宏观上的认识:


上图描述的是文件IO的大致耗时,查阅资料可知:网络IO的耗时也是在微秒级别,跟磁盘IO差不多。
从中我们能得出一些结论:CPU处理数据的速度远远大于IO准备数据的速度。因此网络IO性能优化是工程师们一直在努力的方向。
Socket是什么呢?
可能很多人对socket的认识是:通过socket可以实现一个简单的网络通信,并不知道它的设计思路和使用它时到底干了啥。
socket是在应用层和传输层中间的抽象层,它把传输层(TCP/UDP)的复杂操作抽象成一些简单的接口,供应用层调用实现进程在网络中的通信。Socket起源于UNIX,在Unix一切皆文件的思想下,进程间通信就被冠名为文件描述符(file desciptor),Socket是一种“打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个“文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。

举个具体的列子:socket一般被翻译为“套接字”,其实它在英文的含义为“插座”。
Socket就像一个电话插座,负责连通两端的电话,进行点对点通信,让电话可以进行通信,端口就像插座上的孔,端口不能同时被其他进程占用。而我们建立连接就像把插头插在这个插座上,创建一个Socket实例开始监听后,这个电话插座就时刻监听着消息的传入,谁拨通我这个“IP地址和端口”,我就接通谁。
Socket通信过程
TCP通信时序图

总结:
一次完整的网络通信会经过多层的传输,需要经过物理传输层的网线和网卡,网络传输层的IP协议,经过这两层之后网络数据通过Ip地址可以知道传输到那台计算机了,传输到目标计算机后,操作系统内核通过网卡读取网络数据,将网络数据存储在内存中。计算机中会运行不同的网络程序,他们可能对应于系统中的不同进程,那要如何把网卡中的网络数据识别出来是给哪个进程的,要如何持续和稳定地给到对应的应用进程呢?我想这也就是socket设计的目的和想要解决的问题了,提供一些API接口来实现应用层通过操作系统内核读取网卡数据,并且将网络数据正确地分发到对应的应用层程序。
面试官为啥这么喜欢问IO模型?
服务端处理网络请求的大致流程:

核心过程:
(1)成功建立连接。
(2)内核等待网卡数据到位。(这里涉及DMA技术)(这一步控制IO是否阻塞)
(2)内核缓冲区数据拷贝到用户空间(这里涉及mmap内存映射技术)。(这一步控制IO是否同步)
大致解释下DMA和mmap:网卡和磁盘数据拷贝到内存这个过程,如果需要CPU参与的话会占用大量的CPU运行时间,因为IO操作相对耗时非常高。而且CPU主要适用于进行运算,磁盘数据拷贝到内存这个过程并不涉及到运算操作而且流程固定,因此设计了DMA来专门进行上述拷贝操作,相当于在磁盘嵌入了一个DMA芯片(类似简易版的IO cpu)让它来专门负责上述拷贝操作,从而使得CPU不参与上述拷贝操作,使之专注于运算操作。
mmap的设计理念是:用户空间和内核空间映射同一块内存空间,从而达到省略将数据从内核缓冲区拷贝到用户空间的操作,用户空间通过映射直接操作内核缓冲区的数据。
同步阻塞IO

调用recv()后进程一直阻塞,等待内核数据到位后,进程继续阻塞,直到内核数据拷贝到用户空间。缺点:高并发时,服务端与客户端对等连接,线程多带来的问题:
- CPU资源浪费,上下文切换。
- 内存成本几何上升,JVM一个线程的成本约1MB。
同步非阻塞IO

应用进程一直轮训调用recv()查看内核缓冲区的数据是否准备好,内核立即给予答复,如果回复结果通知数据还未准备好,则接着轮训进行询问。缺点:当进程有1000fds,代表用户进程轮询发生系统调用1000次kernel,来回的用户态和内核态的切换,成本几何上升。
I/O 多路复用 - IO multiplexing

单个线程就可以同时处理多个网络连接。内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。多路复用在Linux内核代码迭代过程中依次支持了三种调用,即SELECT、POLL、EPOLL三种多路复用的网络I/O模型。
select,poll,epoll有啥不同?epoll高效的原因是什么?
在深入了解epoll之前,我们先来了解几个前置知识点
网卡数据接收大致流程:网线->网卡->I/O南桥芯片->内存
cpu如何知道接收了网络数据?
当网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据
进程阻塞为什么不占用 CPU 资源?
阻塞是进程调度的关键一环,指的是进程在等待某个事件发生前的等待状态,recv,select,epoll都是阻塞方法。
//创建socket
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)这是一段基础的网络编程代码,先创建socket对象,依次绑定ip,端口和接受连接。当执行recv()时,进程会被阻塞,直到接收到数据才往下走。
工作队列和等待队列
进程A执行recv()前:

进程A执行recv()后:

cpu会不断地切换调度工作队列里的进程,进程A调用recv()方法后,会从工作队列移除到等待队列,之后CPU只会调度进程B和进程C,因此进程A不再占用CPU资源。
内核接收数据全过程:
如上图所示,进程在 Recv 阻塞期间:
- 计算机收到了对端传送的数据(步骤 ①)
- 数据经由网卡传送到内存(步骤 ②)
- 然后网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤 ③)
此处的中断程序主要有两项功能,先将网络数据写入到对应 Socket 的接收缓冲区里面(步骤 ④),再唤醒进程 A(步骤 ⑤),重新将进程 A 放入工作队列中。
唤醒进程的过程如下图所示:

通过上述流程的分析,我们能引申出来两个问题:
(1)如何知道接收的网络数据时属于哪个socket?
(2)如何同时监控多个socket?
socket数据包格式(源ip,源端口,协议,目的ip,目的端口),一般通过这几个信息就可以识别出来接收到的网络数据属于哪个socket。
有一种情况引发了我的思考:某个进程只启动了一个socket服务端,该服务端只监听了8080端口,有多个客户端连接这一个服务端时,内核这里是否会对应多个socket?各个socket的目的端口是否都为8080?
其实这也是我们最常见的NIO网络编程方式,多线程socket编程。其实多个客户端与同一个服务端建立了连接,这个时候内核就会有多个socket,并且为它们分配多个fd文件描述符。它们收到网络数据后无法通过目的端口来直接匹配socket,还需要再通过源ip和端口来确定属于哪个socket。
如何同时监控多个socket就是epoll核心想解决的问题。
举个例子来说明epoll到时是干什么的?为了解决什么问题?
回到开始把socket通信描述为电话机插入插座之后,双方建立了连接通路,紧接着我们要怎么知道对方打电话给我们了呢?如果有很多个电话机并排在一起(可以想象成大学时的公共电话间),要怎么准确的接听所有的来电呢?
方案1:所有可能接收来电的人,坐在电话间,等待着来电的到来。(电话间坐了一堆的人等在那里,大家不能离开去做别的事)
方案2:分配一个电话间管理员,监听所有电话。有来电时,电话机会响(相当于epoll的回调机制,后面会再介绍下),管理员听到响声进行接听,并且通过对方告知是打给谁的,紧接着去宿舍告诉王某某(你妈妈给你打来了一个电话,你得过来接一下)。(由管理员来统一监听来电,并且将来电通知正确的接电话者。这个模式就有点像epoll,来电接收者无需在电话间一直等待,有来电时统一有管理员进行接听和分配。)
同时监听多个socket的简单方法(select实现)
服务端需要管理多个客户端连接,而 Recv 只能监视单个 Socket,这种矛盾下,人们开始寻找监视多个 Socket 的方法。Epoll 的要义就是高效地监视多个 Socket。
预先传入一个 Socket 列表,如果列表中的 Socket 都没有数据,挂起进程,直到有一个 Socket 收到数据,唤醒进程。这种方法很直接,也是 Select 的设计思想。
select实现流程
Select 的实现思路很直接,假如程序同时监视如下图的 Sock1、Sock2 和 Sock3 三个 Socket,那么在调用 Select 之后,操作系统把进程 A 分别加入这三个 Socket 的等待队列中。操作系统把进程 A 分别加入这三个 Socket 的等待队列中,当任何一个 Socket 收到数据后,中断程序将唤起进程。下图展示了 Sock2 接收到了数据的处理流程:


所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面,如上图所示:
将进程 A 从所有等待队列中移除,再加入到工作队列里面。
经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket。
这种简单方式行之有效,在几乎所有操作系统都有对应的实现。但是简单的方法往往有缺点,主要是:
(1)每次select都需要将进程加入到监视socket的等待队列,每次唤醒都要将进程从socket等待队列移除。这里涉及两次遍历操作,而且每次都要将FDS列表传递给内核,有一定的开销。
(2)进程被唤醒后,只能知道有socket接收到了数据,无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。
那么,有没有减少遍历的方法?有没有保存就绪 Socket 的方法?这两个问题便是 Epoll 技术要解决的。
epoll的设计思路
措施一:功能分离
select的添加等待队列和阻塞进程是合并在一起的,每次调用select()操作时都得执行一遍这两个操作,从而导致每次都要将fd[]传递到内核空间,并且遍历fd[]的每个fd的等待队列,将进程放入各个fd的等待队列中。
epoll优化的方案是:将添加等待队列和阻塞进程拆分成两个独立的操作,不用每次都去重新维护等待队列,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升。
措施二:就绪列表
Select 低效的另一个原因在于程序不知道哪些 Socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 Socket,就能避免遍历。
select会把整个fd[]返回给用户程序,让用户程序自己去遍历哪个fd有接收到网络数据。epoll只会把接收到网络数据的fd[]返回给用户程序,用户程序不用自己去进行遍历查询。
epoll实现代码大致结构
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}epoll的原理与工作流程
创建Epoll对象
调用epoll_create方式时,会创建一个eventpoll对象(),eventpoll 对象也是文件系统中的一员,和 Socket 一样,它也会有等待队列。
创建一个代表该 Epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。

维护监控对象
创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket。eventpoll会通过一个红黑树来存储所有被监视的socket对象,实现快速查找,删除和添加。

接收数据
当 Socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 Socket 引用。

给就绪列表添加引用
如上图展示的是 Sock2 和 Sock3 收到数据后,中断程序让 Rdlist 引用这两个 Socket。
eventpoll 对象相当于 Socket 和进程之间的中介,Socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。
当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。

当 Socket 接收到数据,中断程序一方面修改 Rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下

也因为 Rdlist 的存在,进程 A 可以知道哪些 Socket 发生了变化。
总结:
select为什么在socket连接很多的情况下性能不佳?
将维护socket监控列表和阻塞进程的操作合并在了一起,每次select()调用都会触发这两个操作,从而导致每次调用select()都需要把全量的fd[]列表从用户空间传递到内核空间,内核线程在阻塞进程前需要遍历fd[]将待阻塞的进程放入到每个fd的等待队列里(第一次遍历)。当有网络数据到来时,并不知道网络数据属于具体哪个socket,只知道收到过网络数据,因此需要遍历fd[]唤醒等待队列里的阻塞进程(第二次遍历),并且把fd[]从内核空间拷贝到用户空间,让用户程序自己去遍历fd[]判别哪个socket收到了网络数据(第三次遍历,发生在用户空间)。fd[]比较大的情况,大量的遍历操作会导致性能急剧下降,所以select会默认限制最大文件句柄数为1024,间接控制fd[]最大为1024。
poll其实内部实现基本跟select一样,区别在于它们底层组织fd[]的数据结构不太一样,从而实现了poll的最大文件句柄数量限制去除了
epoll是怎么优化select上述那些问题?
引入了eventpoll这个中间结构,它通过红黑树(rbr)来组织所有待监控的socket对象,实现高效的查找,删除和添加。当收到网络数据时,会触发对应的fd的回调函数,这时不是去遍历各个fd的等待队列进行唤醒进程的操作了,而是把收到数据的socket加入到就绪列表(底层是一个双向链表)。eventpoll有个单独的等待队列来维护待唤醒的进程,避免了像select那样每次需要遍历fd[]来查找各个fd的等待队列的进程。
epoll有这几个核心的数据结构:
红黑树:存放所有待监听的socket
双向链表:存放收到网络数据的socket
等待队列:待唤醒的等待线程

理解网络IO模型:从同步阻塞到epoll
本文深入探讨了网络IO,特别是socket的运作机制和优化。介绍了CPU处理速度远超IO速度的问题,阐述了socket作为应用层与传输层之间的抽象层的角色。文章详细讲解了TCP通信时序图,分析了同步阻塞、非阻塞IO及I/O多路复用(select、poll、epoll)的区别。重点讨论了epoll的高效性和优化措施,如功能分离和就绪列表,以解决大量socket连接时的性能问题。

被折叠的 条评论
为什么被折叠?



