Backend

IO模型

socket和fd

IONetwork

socket和fd

在了解IO模型前,需要先了解一下,什么是socket和fd,以及网络IO的大致流程是怎样的

首先socket是由操作系统抽象出来的一层通信接口,让客户端和服务端可以用统一的方式收发网络数据。他把复杂的网络细节,比如说什么TCP/IP,重传,拥塞控制等隐藏起来,对应用程序暴露出实际可以用的读写接口。

socket解决的是“谁在跟谁对话”的问题,他不是连接本身,而是连接双方的门牌号,tcp连接一定是两个socket对出来的结果

而fd全称为file description也就是文件描述符,是操作系统用来唯一确定一个文件的标志。在Linux中一切皆文件,创建的socket自然也可以理解为一种文件,那么在创建socket 的时候就会返回其对应的fd,每当客户端发送IO请求时,就会通过携带的fd找到对应socket。

socket其实就是操作系统抽象出来的一层通讯接口,他屏蔽掉了底层的网络细节,向客户端和服务端暴露出可实际操作的读写接口。比如read()/write()/bind()/listen()/accept()/connect等方法。他解决的是“谁在和谁通话”的问题,他并不是连接本身, 而是通讯双方的端点,相当于门牌号。比如tcp连接一定是两个socket对出来的结果

创建socket

首先在客户端和服务端建立连接之初,都需要各自创建各自的socket。在 Linux 中一切都是文件,那么创建的 socket 也是文件,每个文件都有一个整型的文件描述符(fd)来唯一指代这个文件。

int socket(int domain, int type, int protocol);
  • domain:这个参数用于选择通信的协议族,比如选择 IPv4 通信,还是 IPv6 通信等等
  • type:选择套接字类型,可选字节流套接字、数据报套接字等等。
  • protocol:指定使用的协议。

这个 protocol 通常可以设为 0 ,因为由前面两个参数可以推断出所要使用的协议。

比如socket(AF_INET, SOCK_STREAM, 0);,表明使用 IPv4 ,且使用字节流套接字,可以判断使用的协议为 TCP 协议。

这个方法的返回值为 int ,其实就是创建的 socket 的 fd。

bind

服务端创建了socket后,还没有一个具体的地址来指向这个socket,所以服务端需要指明他的ip+端口号,这样客户端才能够访问。所以此时我们需要指定一个地址和端口来与这个 socket 绑定一下。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数里的 sockfd 就是我们创建的 socket 的文件描述符,执行了 bind 参数之后我们的 socket 距离可以被访问又更近了一步。

listen

执行了 socket、bind 之后,此时的 socket 还处于 closed 的状态,也就是不对外监听的,然后我们需要调用 listen 方法,让 socket 进入被动监听状态,这样的 socket 才能够监听到客户端的连接请求。

int listen(int sockfd, int backlog);

传入创建的 socket 的 fd,并且指明一下 backlog 的大小。

这个 backlog 查阅资料的时候,看到了三种解释:

  1. socket 有一个队列,同时存放已完成的连接和半连接,backlog为这个队列的大小。
  2. socket 有两个队列,分别为已完成的连接队列和半连接队列,backlog为这个两个队列的大小之和。
  3. socket 有两个队列,分别为已完成的连接队列和半连接队列,backlog仅为已完成的连接队列大小。

解释下什么叫半连接

我们都知道 TCP 建立连接需要三次握手,当接收方收到请求方的建连请求后会返回 ack,此时这个连接在接收方就处于半连接状态,当接收方再收到请求方的 ack 时,这个连接就处于已完成状态。

所以上面讨论的就是这两种状态的连接的存放问题。

查阅资料看到,基于 BSD 派生的系统的实现是使用的一个队列来同时存放这两种状态的连接, backlog 参数即为这个队列的大小。

而 Linux 则使用两个队列分别存储已完成连接和半连接,且 backlog 仅为已完成连接的队列大小

accept

现在我们已经初始化好监听套接字了,此时会有客户端连上来,然后我们需要处理这些已经完成建连的连接。

从上面的分析我们可以得知,三次握手完成后的连接会被加入到已完成连接队列中去。

这时候,我们就需要从已完成连接队列中拿到连接进行处理,这个拿取动作就由 accpet 来完成。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

这个方法返回的 int 值就是拿到的已完成连接的 socket 的文件描述符,之后操作这个 socket 就可以进行通信了。

注意:这里调用accept返回的socket和最开始调用socket方法创建的socket不是一个东西。

服务端调用socket()创建的socket主要用于监听,即监听某绑定ip和端口下是否有请求。

而accept这是从已经建立连接的队列中拿出连接创建的用于与对应客户端通信的socket。

此外accept除了返回新建socket的fd之外,还会返回对应客户端的ip+端口号

如果已完成连接队列没有连接可以取,那么调用 accept 的线程会阻塞等待

connect

在服务端经历了socket创建,bind,listen后,开始监听是否有客户端寻求连接。

此时客户端就可以调用connect方法,开始建立连接。也就是熟悉的TCP三次握手。

客户端创建完 socket 并调用 connect 之后,连接就处于 SYN_SEND 状态,当收到服务端的 SYN+ACK 之后,连接就变为 ESTABLISHED 状态,此时就代表三次握手完毕。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

调用connect需要指定远程的地址和端口进行建连,三次握手完毕之后就可以开始通信了。

客户端这边不需要调用 bind 操作,默认会选择源 IP 和随机端口。

可以看到两个阻塞点:

  • connect:需要阻塞等待三次握手的完成。
  • accept:需要等待可用的已完成的连接,如果已完成连接队列为空,则被阻塞。

read,write

连接建立成功之后,就能开始发送和接收消息了。

read 为读数据,从服务端来看就是等待客户端的请求,如果客户端不发请求,那么调用 read 会处于阻塞等待状态,没有数据可以读,这个应该很好理解。

write 为写数据,一般而言服务端接受客户端的请求之后,会进行一些逻辑处理,然后再把结果返回给客户端,这个写入也可能会被阻塞。

这里可能就会疑惑: read 读不到数据阻塞等待可以理解,write 为什么还要阻塞,有数据不就直接发了吗?

因为我们用的是 TCP 协议,TCP 协议需要保证数据可靠地、有序地传输,并且给予端与端之间的流量控制

所以说发送不是直接发出去,它有个发送缓冲区,我们需要把数据先拷贝到 TCP 的发送缓冲区,由 TCP 自行控制发送的时间和逻辑,有可能还有重传什么的。

如果发的过快,导致接收方处理不过来,那么接收方就会通过 TCP 协议告知:别发了!忙不过来了。发送缓存区是有大小限制的,由于无法发送,还不断调用 write 那么缓存区就满了,满了就不让你 write 了,所以 write 也会发生阻塞。

综上,read 和 write 都会发生阻塞。

IO模型

关于I/O模型可以分为:

  1. 同步阻塞IO,Blocking I/O
  2. 同步非阻塞IO,No Blocking I/O
  3. I/O多路复用
  4. 信号驱动式I/O
  5. 异步I/O,Asynchronous I/O

用一个例子打穿:

  1. 阻塞io:你想喝水。用烧水壶烧水,自己站在边。自己盯着。一旦开了,你把水倒到杯子里面

  2. 非阻塞io: 你烧水,一开烧。你就去打2分钟游戏。然后回来看一下开了没。没开就又去打2分钟游戏。你不在这里等着。直到有一次来它开了。你倒到杯子里面

  3. NIO多路复用: 你还是想喝水,但是你找了个专门帮人烧水的邻居。他给很多人烧水。你说我想烧水,他给你烧上,你就跑回家玩游戏了。水开了他就电话打你。你赶紧来拿。但是你要自己倒杯子里

  4. 信号量:你去烧水房。是自动的,没有人。通知灯和你家门铃是通的,你烧上,人就可以走。烧开了就自动响你门铃,但是有时候客人来了也响门铃。你就搞不清楚到底是啥情况

  5. 异步io:和3类似,还是烧水你最后还要自己倒杯子里面,吹凉了喝。这里就是他给你倒好,吹凉了。然后一喊你。你来了立马就喝上了

1)同步阻塞 I/O(Blocking I/O,BIO)

线程调用 read 时,如果数据还未到来,线程会一直阻塞等待;数据从网卡到内核,再从内核拷贝到用户空间,这两个拷贝过程都为阻塞操作。

  • 优点:实现简单,逻辑直观;调用后直接等待数据就绪。
  • 缺点:每个连接都需要一个线程,即使没有数据到达,线程也会被占用,导致资源浪费,不适合高并发场景。

2)同步非阻塞 I/O(Non-blocking I/O,NIO)

在非阻塞模式下,read 调用如果没有数据就绪会立即返回错误(或特定状态),不会阻塞线程;应用程序需要不断轮询判断数据是否就绪,但当数据拷贝到用户空间时依然是阻塞的。

  • 优点:线程不会长时间阻塞,可以在无数据时执行其他任务;适用于部分实时性要求较高的场景。
  • 缺点:轮询方式会频繁进行系统调用,上下文切换开销较大,CPU 占用率较高,不适合大规模连接。

3)I/O 多路复用

通过一个线程(或少量线程)使用 selectpollepoll 等系统调用,监控多个连接的状态;只有当某个连接的数据就绪时,系统才通知应用程序,再由应用程序调用 read 进行数据读取(读取时仍为阻塞操作)。

  • 优点:大大减少了线程数量和上下文切换,能高效处理大量并发连接;资源利用率高。
  • 缺点:依赖系统内核的支持,不同的多路复用实现(如 select vs epoll)有各自局限。

4)信号驱动 I/O

由内核在数据就绪时发出信号通知应用程序,应用程序收到信号后再调用 read(依然阻塞)。

  • 优点:理论上可以避免轮询,数据就绪时由内核主动通知。
  • 缺点:对于 TCP 协议,由于同一个信号可能对应多种事件,难以精确区分(所以实际应用中使用较少)。

5)异步 I/O(Asynchronous I/O,AIO)

调用 aio_read 后,内核负责将数据从网卡拷贝到用户空间,拷贝完成后通过回调通知应用程序;整个过程用户线程没有阻塞。

  • 优点:真正实现了非阻塞,充分利用内核能力,适合高并发场景。
  • 缺点:编程模型复杂,错误处理和状态管理较难;在 Linux 下支持不完善,多数实际场景仍采用 I/O 多路复用来模拟异步效果,而 Windows 则支持真正的 AIO。

同步阻塞,BIO(Blocking I/O)

当用户程序的线程调用 read 获取网络数据的时候,首先这个数据得有,也就是网卡得先收到客户端的数据,然后这个数据有了之后需要拷贝到内核中,然后再被拷贝到用户空间内,这整一个过程用户线程都是被阻塞的。

假设没有客户端发数据过来,那么这个用户线程就会一直阻塞等着,直到有数据。即使有数据,那么两次拷贝的过程也得阻塞等着。

所以这称为同步阻塞 I/O 模型。

它的优点很明显,简单。调用 read 之后就不管了,直到数据来了且准备好了进行处理即可。

缺点也很明显,一个线程对应一个连接,一直被霸占着,即使网卡没有数据到来,也同步阻塞等着。

我们都知道线程是属于比较重资源,这就有点浪费了。

所以我们不想让它这样傻等着。

于是就有了同步非阻塞 I/O。

同步非阻塞I/O,NIO(No Blocking I/O)

在没数据的时候,用户程序可以不再阻塞等着,而是直接返回错误,告知暂无准备就绪的数据,用户程序会通过轮询操作,不断发起 read 调用,直到内核中的数据拷贝就绪,才会停止发起 read 调用,不过在数据从内核拷贝到用户空间的时候,这段时间内用户程序是出于阻塞状态的。

这个模型相比于同步阻塞 I/O 而言比较灵活,比如调用 read 如果暂无数据,则线程可以先去干干别的事情,然后再来继续调用 read 看看有没有数据。

但是如果你的线程就是取数据然后处理数据,不干别的逻辑,那这个模型又有点问题了。

等于你不断地进行系统调用,如果你的服务器需要处理海量的连接,那么就需要有海量的线程不断调用,上下文切换频繁,CPU 也会忙死,做无用功而忙死。

那怎么办?

于是就有了I/O 多路复用。

I/O 多路复用

从图上来看,看似和同步非阻塞 I/O 一样,但实际上线程模型不同。

同步非阻塞 I/O 频繁调用一直轮询的话是比较消耗 CPU 资源的。为了解决这个问题,操作系统使用了 IO 多路复用模型,只用一个线程查看多个连接是否有数据已准备就绪

仅需往 select(一次select只能监听1024个fd) 注册需要被监听的连接,由 select 来监控它所管理的连接是否有数据已就绪,如果有则可以通知别的线程来 read 读取数据,这个 read 和之前的一样,还是会阻塞用户线程。

这样一来就可以用少量的线程去监控多条连接,减少了线程的数量,降低了内存的消耗且减少了上下文切换的次数。

所谓的多路指的是多条连接,复用指的是用一个线程就可以监控这么多条连接。

多路复用,实际上就是强化版的同步阻塞模型,用户态只需要一个线程就能够同时查看多个fd的状态,只要有一个fd准备就绪就被唤醒并返回。

信号驱动式I/O

I/O 多路复用的 select 虽然不阻塞了,但是它还需要循环调用 select 去阻塞等待看看是否有数据已经准备就绪,那是不是可以省略循环调用 select 阻塞等待这一步,直接让内核告诉我们数据到了而不是用户线程一直去轮询呢?

信号驱动 I/O 就能实现这个功能,由内核告知数据已准备就绪,然后用户线程再去 read(还是会阻塞)。

听起来是不是比 I/O 多路复用好呀?那为什么好像很少听到信号驱动 I/O?

因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 socket 可以产生信号事件有七种。

也就是说不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,即内核通常只通过一个 SIGIO以 进程级 的方式通知用户态:“有 IO 事件了”。所以我们的应用程序无从区分到底是什么事件产生的这个信号。

再则这个信号不携带具体上下文信息,比如是哪个文件描述符、发生了什么事件、发生了几次,应用程序只能在收到信号后遍历所有 socket 进行试探。

所以我们的应用基本上用不了信号驱动 I/O。它仅适合「资源极受限的嵌入式场景」,比如只有 1 个 UDP fd。

所以信号驱动 I/O 也不太行。

异步 I/O,AIO(Asynchronous I/O)

信号驱动 I/O 虽然对 TCP 不太友好,但是这个思路对的:往异步发展,但是它并没有完全异步,因为其后面那段 read 还是会阻塞用户线程,所以它算是半异步。

因此,我们得想下如何弄成全异步的,也就是把 read 那步阻塞也省了。

其实思路很清晰:让内核直接把数据拷贝到用户空间之后再告知用户线程,来实现真正的非阻塞I/O!

所以异步 I/O 其实就是用户线程调用 aio_read ,然后包括将数据从内核拷贝到用户空间那步,所有操作都由内核完成,当内核操作完毕之后,再调用之前设置的回调,此时用户线程就拿着已经拷贝到用户控件的数据可以继续执行后续操作。

在整个过程中,用户线程没有任何阻塞点,这才是真正的非阻塞I/O。

那为什么常用的还是I/O多路复用,而不是异步I/O?

因为 Linux 对异步 I/O 的支持不足,你可以认为还未完全实现,所以用不了异步 I/O。

这里可能有人会说不对呀,像 Tomcat 都实现了 AIO 的实现类,其实像这些组件或者你使用的一些类库看起来支持了 AIO(异步I/O),实际上底层实现是用 epoll 模拟实现的

而 Windows 是实现了真正的 AIO,不过我们的服务器一般都是部署在 Linux 上的,所以主流还是 I/O 多路复用。

post.comments