网络IO
前言:
作为服务端开发,为了提高整体服务效率,网络编程是我们必不可少的知识。本文将会从网卡接收数据流程讲起,串起cpu中断、操作系统、线程调度等知识,进一步分析select到epoll的演变过程。
1. 网卡接收数据
下边是一个典型的计算机结构图,计算机由 CPU、存储器(内存)与网络接口等部件组成。为了解网络IO,那么得先从硬件角度看计算机是怎么接收网络数据的。
下图展示了网卡接收数据的过程:
- 在 ① 阶段,网卡收到网线传来的数据;
- 经过 ② 阶段的硬件电路的传输;
- 最终 ③ 阶段将数据写入到内存中的某个地址上。
这个过程涉及到 DMA 传输、IO 通路选择等硬件有关的知识,但我们只需知道:网卡会把接收到的数据写入内存。
通过硬件传输,网卡接收的数据都存放在内存中了,操作系统就可以读取它们。
2. 如何知道接收了数据?
网卡接收到的数据放入内存后,那我们在操作系统中创建的Sokcet怎么感知有数据发送过来了呢?要理解这个问题,需要了解一个概念——中断。
计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时,它应立即去保存数据,保存数据的程序具有较高的优先级(电容可以保存少许电量,供 CPU 运行很短的一小段时间)。
一般而言,由硬件产生的信号需要 CPU 立马做出回应,不然数据可能就丢失了,所以它的优先级很高。CPU 理应中断掉正在执行的程序,去做出响应;当 CPU 完成对硬件的响应后,再重新执行用户程序。中断的过程如下图,它和函数调用差不多,只不过函数调用是事先定好位置,而中断的位置由“信号”决定。
以网卡为例:当网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。网卡中断程序:根据指定的 IP 和端口,将接收数据写入对应socket的输入缓冲区。(Socket:四元组标识唯一)
3. BIO模型
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为"运行"和"等待"等几种状态。运行状态可以获得cpu执行权,参与CPU时间分片;等待状态是阻塞状态,进程进入等待队列,不会获得cpu执行权,当被唤醒则进入运行状态。操作系统分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
BIO模型的accept、recv方法,如果没有数据则进入等待状态。
下图的计算机中运行着 A、B 与 C 三个进程,其中进程 A 执行着BIO模型的基础网络程序,一开始,这 3 个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。
3.1 创建 Socket
当进程 A 执行到创建 Socket 的语句时,操作系统会创建一个由文件系统管理的 Socket 对象(如下图)。这个 socket 对象包含了发送缓存区、接收缓冲区和等待队列等成员变量。等待队列这个变量很重要,它指向所有需要等待该 socket 事件的进程。
3.2 recv 阻塞
当程序执行到 accept、recv 时,如果接收缓存区中没有数据,则会调用 park 将线程 A 挂起加入等待队列。由于工作队列只剩下了进程 B 和 C,依据进程调度,CPU 会轮流执行这两个进程的程序,不会执行进程 A 的程序。所以进程 A 被阻塞,不会往下执行代码,也不会占用 CPU 资源。
当 socket 接收缓存区有数据后,会调用 unpark 唤醒进程 A,该进程从等待状态变成运行状态,继续执行进程 A 的代码,读取接收缓存区的数据。
3.3 唤醒进程
这一步,贯穿网卡、中断与进程调度的知识,叙述阻塞 recv 下,内核接收数据的全过程。
如下图所示:进程在 recv 阻塞期间,计算机收到对端传送的数据(步骤1),数据经由网卡传送到内存(步骤2),然后网卡通过中断信号通知 CPU 有数据到大,CPU 执行网卡中断程序(步骤3)。
网卡中断程序:
- 先将网络数据写入到对应 socket 的接收缓冲区里面(步骤5)
- 调用 unpark 唤醒进程 A,进程 A 重新进入工作队列
写入数据、唤醒进程之后,进程 A 读取接收缓冲区的数据返回给应用层,这期间还会清除 socket 的等待队列指向:
以上是BIO模型,内核接收数据全过程。
这里我们可能会思考几个问题:
-
内核调用 accept 方法生成的 socket 是什么?
accept 函数返回的新 socket 其实指代的是本次创建的连接(在linux,用文件描述符表示),而一个连接是包括两部分信息的,一个是源IP和源端口,另一个是宿IP和宿端口。所以,accept 函数可以产生多个不同的 socket,而这些 socket 里包含的宿IP和宿端口是不变的,变化的只是源IP和源端口。 -
操作系统如何知道发送网络数据对应哪个 socket 连接?
socket 连接包含有宿IP、端口和源IP、端口,Clietn 发送的数据包携带这两部分数据,数据包含有的宿IP、端口,可以让数据准确的到达 Server;而 Server 的网卡中断程序,解析数据包中的源IP、端口,就知道这个数据包应该发给哪个 socket 连接了。
4. NIO 模型
操作系统实现 NIO 模型和 BIO 模型最大的区别就是:accpet、recv 方法不是阻塞的,进程在执行 NIO 模型的 recv 方法即使没获取到数据,也会继续往下执行代码。
当网卡接收到数据,流程图如下:
应用层在实现 BIO 网络编程时,往往一个用户线程对应一个 socket,当前主流商业Java虚拟机的线程模型都是基于操作系统原生线程模型来实现,即采用1:1的线程模型,线程数设置过多,会导致频繁的上下文切换很浪费性能,所以 BIO 模型不太适合现在网络连接数多的场景。
至于 NIO 网络模型,可以一个用户线程管理多个 socket,但是用户线程不知道哪个 socket 有数据进来,在用户程序得遍历所有 socket 进行read数据,当read数据时候,会从用户态切换到内核态(系统调用),调用内核recv方法读取数据。
5. 多路复用器 select
NIO 用户线程不太好统一管理多个 Socket,操作系统就提供了多路复用器,来管理多个 socket。
我们先从不太高效的 select 来讲,最后再来分析高效的 epoll。
select 的实现思路很直接,应用层在调用 selector.select() 后,会传入注册在 selector 上的 fds(linux上的文件描述符集合),系统调用后,内核 select 模型会遍历这些 fds,根据每个 fd 找到每一个对应的 socket,查看是否接收到了数据,也就是否是就绪状态,即使没数据也不阻塞继续访问下一个 socket(这里也就解释了使用多路复用器为啥要使用 NIO 模型),最后将准备就绪的 socket fd 返回给应用层。
如下图所示,遍历了 socket1、socket2、socket3 发现没数据后,进程 A 进入等待队列,同时每个 socket 的等待队列指向进程 A:
当任何一个 socket 接收到数据后,中断程序将唤醒起进程,再次遍历所有 socket 查看哪些已经就绪了。(selector.select(timeout),当等待时间超过timeout,进程会自动唤醒)。如下图所示:
进程 A 被唤醒之后,重新加入工作队列,它知道至少有一个 socket 接收了数据。程序只需遍历一遍 socket 列表,就可以得到就绪的 socket,同时也会移除所有 socket 等待队列对进程 A 的指向。应用层再分别操作就绪的 socket 读取数据即可。
NIO 和 Select 对比:
- 两者都需要遍历所有的 socket,询问状态。但是 NIO 每遍历一次都需要经历系统调用过程,而 select 只需要一次系统调用。
- 再者 select 如果没有就绪的 socket 进程会进入阻塞状态,如果有 socket 进入就绪状态会主动唤醒。而 NIO 必须用户线程不断的轮询。
这里再说下,poll工作原理与select几乎相同,不同的是由于其 fd 集合:
- select fd 集合底层由数组实现,默认只能监视1024个文件描述符,可以修改FD_SETSIZE的值来修改文件描述符的数量限制。
- poll fd 集合由链表实现,所以其对于要处理的内核进程数量理论上是没有限制的
那么,有没有减少遍历的方法呢?这个问题 epoll 很好的解决了。
6. 多路复用器 epoll
epoll 是在 select 出现 N 多年后才被发明的,是 select 和 poll(poll 和 select 基本一样,有少量改进)的增强版本。
select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 socket,就能避免遍历。
6.1 创建 epoll 对象
如下图所示,当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(相比 select,它不会创建 select 对象,它只是提供了一些内核方法)。eventpoll 对象也是文件系统中的一员。
rdlist 就是就绪列表,就绪状态的 socket 的文件描述符都会存入这个列表。创建 epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 socket。内核通过 epoll_ctl 添加 sock1、sock2 和 sock3 的监视。
6.2 阻塞
当调用应用层调用 selector.select() 方法后,会经过系统调用内核的 epoll_wait 方法,如果这时 rdlist 有数据则直接返回,应用层代码继续往下指向。如果没有数据,进程则会进入等待状态。如下图所示:
当 socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程。
6.3 唤醒
当 socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 socket 的 fd。如下图展示的是 socket3 接收到数据后,中断程序让 rdlist 添加这个 socket 的 fd,同时唤醒进程 A,进程 A 在读取 rdlist 的数据后,返回给应用层,应用层则分别操作 socket 读取数据。
eventpoll 对象相当于 socket 和进程之间的中介,socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。也就是就绪列表的存在,进程 A 才知道哪些 socket 发生了变化。
epoll 相比 select,进程 A 被唤醒后,不需要遍历所有的 socket 访问它们的状态,只要要访问就绪队列即可知道哪些 socket 已经就绪了。