互联网协议的多路复用、Linux系统的I/O模式

目录

[1. 互联网协议栈-多路复用](#1. 互联网协议栈-多路复用)

[1.1. 应用层的多路复用](#1.1. 应用层的多路复用)

[2.2. 传输层的多路复用](#2.2. 传输层的多路复用)

[3.3. 网络层的多路复用](#3.3. 网络层的多路复用)

[2. Linux系统的I/O模式](#2. Linux系统的I/O模式)

[2.1. I/O](#2.1. I/O)

[2.2. Socket](#2.2. Socket)

[2.3. 从网卡到操作系统](#2.3. 从网卡到操作系统)

[2.4. Socket 编程模型](#2.4. Socket 编程模型)

[2.5. I/O多路复用](#2.5. I/O多路复用)

[2.6. 阻塞/非阻塞、同步/异步](#2.6. 阻塞/非阻塞、同步/异步)

[2.7. Question](#2.7. Question)


1. 互联网协议栈-多路复用

在上述的分层模型当中,一台机器上的应用可以有很多。但是实际的出口设备,比如说网卡、网线通常只有一份。因此这里需要用到一个叫作多路复用(Multiplex)的技术。多路复用,就是多个信号,复用一个信道。

多路复用的意义

多路复用让多个信号(例如:请求/返回等)共用一个信道(例如:一个 TCP 连接),那么在这个信道上,信息密度就会增加。在密度增加的同时,通过并行发送信号的方式,可以减少阻塞。

比如说应用层的 HTTP 协议,浏览器打开的时候就会往服务器发送很多个请求,多个请求混合在一起,复用相同连接,数据紧密且互相隔离(不互相阻塞)。同理,服务之间的远程调用、消息队列,这些也经常需要多路复用。

1.1. 应用层的多路复用

应用层机制

HTTP/2 在 单个 TCP 连接 上通过 流(Stream) 和 帧(Frame) 的机制,实现多个 HTTP 请求和响应的并行传输。

流(Stream):每个请求/响应分配一个唯一的流 ID,标识独立的逻辑通道。

帧(Frame):数据被拆分为更小的帧,不同流的帧可以穿插发送,接收端按流 ID 重组。

核心目标

解决 HTTP/1.1 的队头阻塞(Head-of-Line Blocking)问题,提升传输效率。

减少 TCP 连接数(从多个连接变为单连接),降低握手和资源开销。

示例:

浏览器通过一个 TCP 连接同时加载网页中的 HTML、CSS、JS 和图片资源,无需按顺序等待。

HTTP/2 引入多路复用机制:

  • 单连接多流(Streams):每个请求/响应分配一个唯一的流 ID,在同一个 TCP 连接上并行传输。
  • 帧(Frame)机制:将数据拆分为更小的帧,不同流的帧可穿插发送,接收方按流 ID 重组。
  • 优先级与依赖:可设置请求优先级(如优先加载 CSS/JS),优化资源加载顺序。

优势:

  • 消除队头阻塞:单个流的阻塞不影响其他流(但 TCP 层丢包仍可能导致 HOL)。
  • 减少连接数:无需多个 TCP 连接,降低延迟和资源消耗。
  • 头部压缩(HPACK):进一步减少冗余数据传输。

HTTP/3 的进一步优化:

HTTP/3 基于 QUIC 协议(运行在 UDP 上),彻底解决 TCP 层队头阻塞:

  • 独立流的多路复用:每个流的传输独立于其他流,即使 UDP 包丢失,只影响当前流。
  • 0-RTT 连接:减少握手延迟,提升性能。

多路复用和 HTTP 并发连接都用于提升 HTTP 请求的并发处理能力,但实现方式不同:

多路复用(如 HTTP/2):

在 单 TCP 连接 上并行传输多个请求/响应,通过流(Stream)和帧(Frame)机制实现。

优势:减少连接开销,解决队头阻塞,高效利用带宽。

HTTP 并发连接(如 HTTP/1.1):

通过 多个 TCP 连接 并行发送请求,绕过单连接的顺序限制。

缺点:资源消耗大,无法根治队头阻塞。

核心区别:

多路复用是协议层的优化,而并发连接是客户端的补救措施。HTTP/2/3 的多路复用显著降低了延迟和资源消耗,成为现代 Web 性能的核心机制。

2.2. 传输层的多路复用

传输层机制

通过 端口号(Port) 区分不同应用或服务的数据流。

TCP/UDP 端口:确保数据包被正确路由到目标应用程序(如 HTTP 用 80 端口,DNS 用 53 端口)。

IP 协议号:在 IP 层区分传输层协议类型(如 TCP=6,UDP=17)。

核心目标

在设备间实现多应用共享网络资源,确保数据包正确分发。

示例:

一台服务器同时运行 Web 服务(80 端口)和 FTP 服务(21 端口),TCP 层通过端口号将数据分发给对应的服务。

HTTP/2 多路复用是应用层优化:在单 TCP 连接上实现逻辑并发的 HTTP 请求/响应。

传输层多路复用是基础设施:通过端口号和协议号确保数据正确路由到应用。

两者协作关系:

HTTP/2 依赖传输层的 TCP 连接(单连接),并在应用层实现更高效率的并发处理。

传输层多路复用则是 HTTP/2 多路复用的基础(通过端口号绑定服务)。

关键记忆点:

HTTP/2 多路复用是"单车道上的多辆车交替通行"(应用层逻辑并发)。

传输层多路复用是"不同车道对应不同目的地"(物理端口区分应用)。

3.3. 网络层的多路复用

传输层是一个虚拟的概念,但是网络层是实实在在的。两个应用之间的传输,可以建立无穷多个传输层连接,前提是你的资源足够。但是两个应用之间的线路、设备,需要跨越的网络往往是固定的。在我们的互联网上,每时每刻都有大量的应用在互发消息。而这些应用要复用同样的基础建设------网线、路由器、网关、基站等。

应用层的多路复用,如多个请求使用同一个信道并行的传输,实际上是传输层提供的多路复用能力。传输层的多路复用,比如多个 TCP 连接复用一条线路,实际上是网络层在提供多路复用能力。

网络层没有连接这个概念。你可以把网络层理解成是一个巨大的物流公司。不断从传输层接收数据,然后进行打包,每一个包是一个 IP 封包。然后这个物流公司,负责 IP 封包的收发。所以,是很多很多的传输层在共用底下同一个网络层,这就是网络层的多路复用。

2. Linux系统的I/O模式

2.1. I/O

I/O的定义:

I/O(Input/Output,输入/输出) 指的是计算机系统中数据在内存与外部设备(或网络)之间的传输操作。

I/O的常见类型:

1. 网络 I/O

输入(Input):从网络接收数据(如客户端请求、文件下载)。

例如:服务器读取客户端发送的 HTTP 请求。

输出(Output):向网络发送数据(如响应结果、文件上传)。

例如:服务器向客户端返回 HTML 页面。

2. 磁盘 I/O

输入(Input):从硬盘读取数据到内存(如加载配置文件)。

输出(Output):将内存数据写入硬盘(如保存日志文件)。

3. 用户交互 I/O

输入:用户通过键盘、鼠标输入指令。

输出:程序向屏幕、打印机输出结果。

在 网络编程 中讨论 I/O 多路复用时,I/O 特指网络 I/O,即:

输入:从 Socket 接收客户端发送的数据。

输出:通过 Socket 向客户端返回数据。

2.2. Socket

Socket(套接字)是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口,供应用层调用实现进程在网络中的通信。

2.3. 从网卡到操作系统

网络数据到达网卡之后,首先需要把数据拷贝到内存。拷贝到内存的工作往往不需要消耗 CPU 资源,而是通过 DMA 模块直接进行内存映射。

Linux 中用一个双向链表作为缓冲区,下图中的 Buffer看上去像一个有很多个凹槽的线性结构,每个凹槽(节点)可以存储一个封包,这个封包可以从网络层看(IP 封包),也可以从传输层看(TCP 封包)。操作系统不断地从 Buffer 中取出数据,数据通过一个协议栈,你可以把它理解成很多个协议的集合。协议栈中数据封包找到对应的协议程序处理完之后,就会形成 Socket 文件

如果高并发的请求量级实在太大,有可能把 Buffer 占满,此时,操作系统就会拒绝服务。网络上有一种著名的攻击叫作拒绝服务攻击 ,就是利用的这个原理。操作系统拒绝服务,实际上是一种保护策略。通过拒绝服务,避免系统内部应用因为并发量太大而雪崩

操作系统和网络接口交互:

如上图所示,传入网卡的数据被称为 Frames。一个 Frame 是数据链路层的传输单位(或封包)。现代的网卡通常使用 DMA 技术,将 Frame 写入缓冲区(Buffer),然后在触发 CPU 中断交给操作系统处理。操作系统从缓冲区中不断取出 Frame,通过协进栈(具体的协议)进行还原。

在 UNIX 系的操作系统中,一个 Socket 文件内部类似一个双向的管道。因此,非常适用于进程间通信。在网络当中,本质上并没有发生变化。网络中的 Socket 一端连接 Buffer, 一端连接应用------也就是进程。网卡的数据会进入 Buffer,Buffer 经过协议栈的处理形成 Socket 结构。通过这样的设计,进程读取 Socket 文件,可以从 Buffer 中对应节点读走数据。

对于 TCP 协议,Socket 文件可以用源端口、目标端口、源 IP、目标 IP 进行区别。不同的 Socket 文件,对应着 Buffer 中的不同节点。进程们读取数据的时候从 Buffer 中读取,写入数据的时候向 Buffer 中写入。通过这样一种结构,无论是读和写,进程都可以快速定位到自己对应的节点。

2.4. Socket 编程模型

对于进程而言,Socket 更多是一种编程的模型。

Socket 连接了应用和协议,如果应用层的程序想要传输数据,就创建一个 Socket。应用向 Socket 中写入数据,相当于将数据发送给了另一个应用。应用从 Socket 中读取数据,相当于接收另一个应用发送的数据。而具体的操作就是由 Socket 进行封装。具体来说,对于 UNIX 系的操作系统,是利用 Socket 文件系统,Socket 是一种特殊的文件------每个都是一个双向的管道。一端是应用,一端是缓冲区

如上图所示,当有客户端连接服务端时,服务端 Socket 文件中会写入这个客户端 Socket 的文件描述符(FD)。进程可以通过 accept() 方法,从服务端 Socket 文件中读出客户端的 Socket 文件描述符,从而拿到客户端的 Socket 文件。

程序员实现一个网络服务器的时候,会先手动去创建一个服务端 Socket 文件。服务端的 Socket 文件依然会存在操作系统内核之中,并且会绑定到某个 IP 地址和端口上。以后凡是发送到这台机器、目标 IP 地址和端口号的连接请求,在形成了客户端 Socket 文件之后,文件的文件描述符都会被写入到服务端的 Socket 文件中。应用只要调用 accept 方法,就可以拿到这些客户端的 Socket 文件描述符,这样服务端的应用就可以方便地知道有哪些客户端连接了进来。

**每个客户端对这个应用而言,都是一个Socket文件描述符。**如果需要读取某个客户端的数据,就读取这个客户端对应的 Socket 文件。如果要向某个特定的客户端发送数据,就写入这个客户端的 Socket 文件。

2.5. I/O多路复用

服务端Socket可以让进程拿到了它关注的所有 Socket,也称作关注的集合(Intersting Set),相当于进程从所有的 Socket 中,筛选出了自己关注的一个子集。

进程如何监听关注集合的状态变化,比如说在有数据进来,如何通知到这个进程?

一个线程需要处理所有关注的 Socket 产生的变化,或者说消息。实际上一个线程要处理很多个文件的 I/O。所有关注的 Socket 状态发生了变化,都由一个线程去处理,构成了I/O 的多路复用问题。

单线程管理多个 Socket概念图:

服务器应用(1个程序)

|

+-- 监听端口(比如 8080)

|

+-- 单线程管理:

├── socket_fd1(对应客户端 A)

├── socket_fd2(对应客户端 B)

├── socket_fd3(对应客户端 C)

└── ......更多

处理 I/O 多路复用的问题,需要操作系统提供内核级别的支持。Linux 下有三种提供 I/O 多路复用的 API,分别是:

  1. select
  2. poll
  3. epoll

内核知道某个 Socket 文件状态发生了变化。但是内核如何知道该把哪个消息给哪个进程呢?

一个 Socket 文件,可以由多个进程使用;而一个进程,也可以使用多个 Socket 文件。进程和 Socket 之间是多对多的关系。另一方面,一个 Socket 也会有不同的事件类型。

这样在进程内部就需要一个数据结构来描述自己会关注哪些 Socket 文件的哪些事件(读、写、异常等) 。通常有两种考虑方向,一种是利用线性结构 ,比如说数组、链表等,这类结构的查询需要遍历。每次内核产生一种消息,就遍历这个线性结构。看看这个消息是不是进程关注的?另一种是索引结构,内核发生了消息可以通过索引结构马上知道这个消息进程关不关注。

1. select()

select 和 poll 都采用线性结构,select 允许用户传入 3 个集合。

**每次 select 操作会阻塞当前线程,在阻塞期间所有操作系统产生的每个消息,都会通过遍历的手段查看是否在 3 个集合当中。**上面程序read_fd_set中放入的是当数据可以读取时进程关心的 Socket;write_fd_set是当数据可以写入时进程关心的 Socket;error_fd_set是当发生异常时进程关心的 Socket。

用户程序可以根据不同集合中是否有某个 Socket 判断发生的消息类型。

复制代码
fd_set read_fd_set, write_fd_set, error_fd_set;

while(true) {

    select(..., &read_fd_set, &write_fd_set, &error_fd_set); 

    for (i = 0; i < FD_SETSIZE; ++i)

    if (FD_ISSET (i, &read_fd_set)){

        // Socket可以读取

    } else if(FD_ISSET(i, &write_fd_set)) {

        // Socket可以写入

    } else if(FD_ISSET(i, &error_fd_set)) {

        // Socket发生错误

    } 

}

上面程序中的 FD_SETSIZE 是一个系统的默认设置,通常是 1024。可以看出,select 模式能够一次处理的文件描述符是有上限的,也就是 FD_SETSIZE。当并发请求过多的时候, select 就无能为力了。但是对单台机器而言,1024 个并发已经是一个非常大的流量了。

2. poll()

从写程序的角度来看,select 并不是一个很好的编程模型。一个好的编程模型应该直达本质,当网络请求发生状态变化的时候,核心是会发生事件。一个好的编程模型应该是直接抽象成消息:用户不需要用 select 来设置自己的集合,而是可以通过系统的 API 直接拿到对应的消息,从而处理对应的文件描述符。

  • poll 是一个阻塞调用,它将某段时间内操作系统内发生的且进程关注的消息告知用户程序;

  • 用户程序通过直接调用 poll 函数拿到消息;

  • poll 函数的第一个参数告知内核 poll 关注哪些 Socket 及消息类型;

  • poll 调用后,经过一段时间的等待(阻塞),就拿到了是一个消息的数组;

  • 通过遍历这个数组中的消息,能够知道关联的文件描述符和消息的类型;

  • 通过消息类型判断接下来该进行读取还是写入操作;

  • 通过文件描述符,可以进行实际地读、写、错误处理。

    while(true) {

    复制代码
    events = poll(fds, ...)
    
    for(evt in events) {
    
      fd = evt.fd;
    
      type = evt.revents;
    
      if(type & POLLIN ) {
    
         // 有数据需要读,读取fd中的数据
    
      } else if(type & POLLOUT) {
    
         // 可以写入数据
    
      } 
    
      else ...
    
    }

    }

poll 虽然优化了编程模型,但是从性能角度分析,它和 select 差距不大。因为内核在产生一个消息之后,依然需要遍历 poll 关注的所有文件描述符来确定这条消息是否跟用户程序相关。

3. epoll

为了解决上述问题,**epoll 通过更好的方案实现了从操作系统订阅消息。epoll 将进程关注的文件描述符存入一棵二叉搜索树,通常是红黑树的实现。**在这棵红黑树当中,Key 是 Socket 的编号,值是这个 Socket 关注的消息。因此,当内核发生了一个事件:比如 Socket 编号 1000 可以读取。这个时候,可以马上从红黑树中找到进程是否关注这个事件。

**另外当有关注的事件发生时,epoll 会先放到一个队列当中。**当用户调用epoll_wait时候,就会从队列中返回一个消息。epoll 函数本身是一个构造函数,只用来创建红黑树和队列结构。epoll_wait调用后,如果队列中没有消息,也可以马上返回。因此epoll是一个非阻塞模型。

总结一下,**select/poll 是阻塞模型,epoll 是非阻塞模型。**当然,并不是说非阻塞模型性能就更好。在多数情况下,epoll 性能更好是因为内部有红黑树的实现。

非阻塞模型的核心价值,并不是性能更好。当真的高并发来临的时候,所有的 CPU 资源,所有的网络资源可能都会被用完。这个时候无论是阻塞还是非阻塞,结果都不会相差太大。(前提是程序没有写错)。

epoll有 2 个最大的优势:

  1. 内部使用红黑树减少了内核的比较操作;
  2. 对于程序员而言,非阻塞的模型更容易处理各种各样的情况。程序员习惯了写出每一条语句就可以马上得到结果,这样不容易出 Bug。

2.6. 阻塞/非阻塞、同步/异步

select/poll 是阻塞(Blocking)模型,epoll 是非阻塞(Non-Blocking)模型。阻塞和非阻塞强调的是线程的状态,所以阻塞就是触发了线程的阻塞状态,线程阻塞了就停止执行,并且切换到其他线程去执行,直到触发中断再回来。

还有一组概念是同步(Synchrounous)和异步(Asynchrounous),select/poll/epoll 三者都是同步调用。

select/poll 是同步模型,epoll 是异步模型。

同步强调的是顺序,所谓同步调用,就是可以确定程序执行的顺序的调用。比如说执行一个调用,知道调用返回之前下一行代码不会执行。这种顺序是确定的情况,就是同步。

而异步调用则恰恰相反,异步调用不明确执行顺序。比如说一个回调函数,不知道何时会回来。异步调用会加大程序员的负担,因为我们习惯顺序地思考程序。因此,我们还会发明像协程的 yield 、迭代器等将异步程序转为同步程序。

由此可见,非阻塞不一定是异步,阻塞也未必就是同步。

阻塞 vs 同步:同步通常是阻塞的,但同步本身并不一定意味着阻塞。

非阻塞 vs 异步:非阻塞和异步都能使程序继续执行其他任务,但异步通常有回调机制或某种形式的通知,非阻塞只关注操作本身不会停止程序执行。

2.7. Question

select/poll/epoll 有什么区别?

回答:

这三者都是处理 I/O 多路复用的编程手段。select/poll 模型是一种阻塞模型,epoll 是非阻塞模型。select/poll 内部使用线性结构存储进程关注的 Socket 集合,因此每次内核要判断某个消息是否发送给 select/poll 需要遍历进程关注的 Socket 集合。

而 epoll 不同,epoll 内部使用二叉搜索树(红黑树),用 Socket 编号作为索引,用关注的事件类型作为值,这样内核可以在非常快的速度下就判断某个消息是否需要发送给使用 epoll 的线程。

相关推荐
愚润求学15 分钟前
【Linux】Ext系列文件系统
linux·运维·服务器·笔记
小阳睡不醒28 分钟前
vim 练习题
linux·编辑器·vim
胖大和尚1 小时前
完整的 CentOS 6.10 虚拟机安装启动脚本
linux·运维·centos
兴达易控2 小时前
Profibus DP主站转Modbus TCP网关接E+H流量计通讯案例
网络
熙曦Sakura2 小时前
【Linux网络】TCP全连接队列
linux·网络·tcp/ip
江边垂钓者2 小时前
HTTP、HTTPS、SSH区别以及如何使用ssh-keygen生成密钥对
http·https·ssh
脚比路长2 小时前
win11 安装 wsl ubuntu 18.04后换源失败!
linux
菜鸟康3 小时前
Linux——CMake的快速入门上手和保姆级使用介绍、一键执行shell脚本
linux·运维·服务器
星星点点洲3 小时前
【网络协议】TCP、HTTP、MQTT 和 WebSocket 对比
网络协议·tcp/ip·http
卷卷的小趴菜学编程3 小时前
Linux系统之----基础IO
linux·运维·服务器·文件·fopen·文件操作符·位图传递标志位