网络模型中的I/O多路复用

163 阅读6分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

Socket编程

什么是文件描述符(fd)?

"On Unix, Everything is a file"

这就是Unix操作系统的一个设计理念。这是什么 意思呢?

这意味着通过读取文件,可以获取操作系统的所有能够获取到的数据,而在进行部分系统调用的时候可以通过文件描述符。形象点来说,文件描述符就像是一张门票,每次你需要完成某种操作的时候,就把这张门票给操作系统,然后操作系统为你完成你想要做的。

在内容上,就是一个数字,在某个进程内唯一

程序跑起来后就是进程,进程维护着自己的文件描述符表。如下图中的File descriptors:

image.png

在Linux,进程中打开的文件描述符集可以在路径下访问/proc/PID/fd/,其中PID是进程标识符。

有几个系统定义的标准文件描述符:

  • 0:标准输入
  • 1:标准输出
  • 2:标准错误输出

Socket是什么?

根据通信域的不同可以划分成2种:Unix domain socket 和 Internet domain socket。

Unix domain socket 又叫 IPC(inter-process communication 进程间通信) Socket,用于实现同一主机上的进程间通信。

Internet domain socket用于实现不同主机上的进程间通信,大部分情况下我们所说的Socket都是指Internet domain socket

在传统计算机网络五层模型中,Socket是应用层和传输层的桥梁(一个抽象层)。

依据计算机网络中通信双方的协议对等,因此网络服务器和连接客户端都会有个socket来承接应用层和传输层。

两个进程如果需要进行通讯最基本的一个前提是能够唯一的标识一个进程,在本地进程通讯中我们可以使用PID来唯一标示一个进程,但PID只在本地唯一,网络中的两个进程PID冲突几率很大,这时候我们需要另辟它径了,我们知道IP层的ip地址可以唯一标示主机,而TCP层协议和端口号可以唯一标示主机的一个进程,这样我们可以利用IP地址+协议+端口号唯一标示网络中的一个进程。

Socket和文件描述符有什么关系?

我们进行系统调用来创建一个socket时,返回一个文件描述符(fd)读写文件(fd)的函数也可以用来操作socket。也就是说,在fd代表了Socket,想要操作socket,可以直接操作fd。

具体点的话可以看socket API的socket()函数,参数依据自己所需要的网络协议去分配就行(参数详情参考reference里面的wiki地址)。

int socket(int domain, int type, int protocol);

发生错误时返回-1;成功时返回一个数字,代表着新分配的进程文件描述符。

I/O 多路复用

I/O模型

在神作《UNIX 网络编程》里,总结归纳了 5 种 I/O 模型,包括同步和异步 I/O:

  • 阻塞 I/O (Blocking I/O)
  • 非阻塞 I/O (Nonblocking I/O)
  • I/O 多路复用 (I/O multiplexing)
  • 信号驱动 I/O (Signal driven I/O)
  • 异步 I/O (Asynchronous I/O)

操作系统上的I/O是用户空间(用户进程的内存空间)和内核空间(系统内核的内容存空间)的数据交互,因此 I/O 操作通常包含以下两个步骤:

  1. 等待网络数据到达网卡(读就绪)/等待网卡可写(写就绪) –> 读取/写入到内核缓冲区
  2. 从内核缓冲区复制数据 –> 用户空间(读)/从用户空间复制数据 -> 内核缓冲区(写)

而判定一个 I/O 模型是同步还是异步,主要看第二步:数据在用户和内核空间之间复制的时候是不是会阻塞当前进程,如果会,则是同步 I/O,否则,就是异步 I/O。基于这个原则,这 5 种 I/O 模型中只有一种异步 I/O 模型:异步I/O,其余都是同步 I/O 模型

这 5 种 I/O 模型的对比如下:

I/O 多路复用

所谓 I/O 多路复用指的就是select/poll/epoll这一系列的多路选择器:支持单一线程同时监听多个文件描述符(I/O 事件),阻塞等待,并在其中某个文件描述符可读写时收到通知。I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个控制线程能够处理多个连接(I/O 事件)

select/poll/epoll是Linux系统的I/O事件驱动技术,类似地,Windows上对应的是IOCP,MacOS对应的则是kqueue。基于不同操作系统的事件驱动模型具有差异,编程语言中会有对应不同的系统调用(如Node.js的libuv)。

非阻塞I/O

什么叫非阻塞 I/O,顾名思义就是:所有 I/O 操作都是立刻返回而不会阻塞当前用户进程。I/O 多路复用通常情况下需要和非阻塞 I/O 搭配使用,否则可能会产生意想不到的问题。

Linux 下,我们可以通过 fcntl 系统调用来设置 O_NONBLOCK 标志位,从而把 socket 设置成 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:

当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 EAGAIN error。从用户进程角度讲 ,它发起一个 read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error 时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,非阻塞I/O 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。

服务器的I/O多路复用

回到主题,socket编程是控制/管理网络连接的方法,I/O多路复用则提供了事件驱动的模型(Reactor模型)。

典型的在服务器中的例子就是Redis单线程是如何提供高性能的网络服务的。(PS: Redis 6.0后是多线程模型了)

具体单线程时的网络连接处理如下图:

image.png

上图中的File event dispatcher则是Redis定义的事件类型和获取到事件如何处理,这就和Redis本身的具体设计有关了。

Reference

Linux内核101:fd,syscall,socket

BSD_and_POSIX_sockets WiKI

手撕Linux Socket——Socket原理与实践分析

Go netpoll I/O 多路复用构建原生网络模型之源码深度解析

Redis 和 I/O 多路复用