C++——计算机网络

文章目录

  • 一、TCP/IP网络模型
    • [1.1 **TCP/IP 模型的四层结构(从下到上)**](#1.1 TCP/IP 模型的四层结构(从下到上))
      • [1.1.1 网络接口层(Network Interface Layer)](#1.1.1 网络接口层(Network Interface Layer))
      • [1.1.2 网络层(Internet Layer)](#1.1.2 网络层(Internet Layer))
      • [1.1.3 传输层(Transport Layer)](#1.1.3 传输层(Transport Layer))
      • [1.1.4 应用层(Application Layer)](#1.1.4 应用层(Application Layer))
    • [1.2 TCP/IP 模型的数据传输流程(示例)](#1.2 TCP/IP 模型的数据传输流程(示例))
    • [1.3 TCP/IP 模型与 OSI 模型的对比](#1.3 TCP/IP 模型与 OSI 模型的对比)
    • [1.4 总结](#1.4 总结)
  • 二、Linux的五种IO模型
    • [2.1 什么是IO模型?](#2.1 什么是IO模型?)
    • [2.2 有几种IO?](#2.2 有几种IO?)
    • [2.3 阻塞 I/O(Blocking I/O)](#2.3 阻塞 I/O(Blocking I/O))
      • [2.3.1 工作流程](#2.3.1 工作流程)
      • [2.3.2 代码示例](#2.3.2 代码示例)
    • [2.4 非阻塞 I/O(Non-blocking I/O)](#2.4 非阻塞 I/O(Non-blocking I/O))
      • [2.4.1 工作流程](#2.4.1 工作流程)
      • [2.4.2 代码示例](#2.4.2 代码示例)
    • [2.5 I/O多路复用](#2.5 I/O多路复用)
      • [2.5.1 工作流程](#2.5.1 工作流程)
      • [2.5.2 代码示例](#2.5.2 代码示例)
    • [2.6 信号驱动 I/O(Signal-Driven I/O)](#2.6 信号驱动 I/O(Signal-Driven I/O))
      • [2.6.1 工作流程](#2.6.1 工作流程)
      • [2.6.2 代码示例](#2.6.2 代码示例)
    • [2.7 异步 I/O(Asynchronous I/O)](#2.7 异步 I/O(Asynchronous I/O))
      • [2.7.1 工作流程](#2.7.1 工作流程)
      • [2.7.2 代码示例](#2.7.2 代码示例)
    • [2.8 同步异步?阻塞非阻塞?有啥区别?](#2.8 同步异步?阻塞非阻塞?有啥区别?)
      • [2.8.1 同步 vs 异步(核心:结果通知方式)](#2.8.1 同步 vs 异步(核心:结果通知方式))
      • [2.8.2 阻塞 vs 非阻塞(核心:等待时的进程状态)](#2.8.2 阻塞 vs 非阻塞(核心:等待时的进程状态))
      • [2.8.3 组合场景(关键:四者可交叉组合)](#2.8.3 组合场景(关键:四者可交叉组合))
      • [2.8.4 通俗类比(帮助理解)](#2.8.4 通俗类比(帮助理解))
  • 三、Reactor网络编程模型
    • [3.1 **面试官问如何回答:**](#3.1 面试官问如何回答:)
    • [3.2 训练营提供的](#3.2 训练营提供的)
    • [3.3 豆包提供的](#3.3 豆包提供的)
      • [3.3.1 Reactor 模式的核心思想](#3.3.1 Reactor 模式的核心思想)
      • [3.3.2 Reactor 模式的核心组件](#3.3.2 Reactor 模式的核心组件)
      • [3.3.3 Reactor 模式的常见变体](#3.3.3 Reactor 模式的常见变体)
      • [3.3.4 Reactor 模式的优势与适用场景](#3.3.4 Reactor 模式的优势与适用场景)
      • [3.3.5 典型实现案例](#3.3.5 典型实现案例)
    • [3.4 伪代码示例](#3.4 伪代码示例)
  • 四、比较Reactor和Proactor的区别
    • [4.1 训练营](#4.1 训练营)
    • [4.2 豆包说](#4.2 豆包说)
      • [4.2.1 Proactor(前摄器) 模式的核心思想](#4.2.1 Proactor(前摄器) 模式的核心思想)
      • [4.2.2 关键组件](#4.2.2 关键组件)
      • [4.2.3 工作流程(以 "读取数据" 为例)](#4.2.3 工作流程(以 “读取数据” 为例))
      • [4.2.4 与 Reactor 模式的核心区别](#4.2.4 与 Reactor 模式的核心区别)
      • [4.2.5 适用场景与优缺点](#4.2.5 适用场景与优缺点)
      • [4.2.6 总结](#4.2.6 总结)
  • 五、介绍下网络socket是什么
    • [5.1 训练营说](#5.1 训练营说)
    • [5.2 豆包说](#5.2 豆包说)
  • 六、简述下使用TCP协议跨进程通信的主要流程
    • [6.1 训练营说](#6.1 训练营说)
    • [6.2 豆包说](#6.2 豆包说)
  • 七、简述下使用UDP协议跨进程通信的主要流程
    • [7.1 训练营说](#7.1 训练营说)
  • 八、TCP三次握手的过程介绍,以及为什么不可以是两次握手?
    • [8.1 TCP 三次握手的过程](#8.1 TCP 三次握手的过程)
    • [8.2 为什么不能是两次握手?](#8.2 为什么不能是两次握手?)
    • [8.3 标志的介绍](#8.3 标志的介绍)
  • 九、TCP四次挥手的过程介绍,以及TIME_WAIT为什么至少设置两倍的MSL时间?
    • [9.1 TCP的四次挥手](#9.1 TCP的四次挥手)
    • [9.2 TIME_WAIT 为什么至少设置两倍的 MSL 时间?](#9.2 TIME_WAIT 为什么至少设置两倍的 MSL 时间?)
  • 十、简述TCP可靠传输的实现
    • [10.1 训练营](#10.1 训练营)
    • [10.2 豆包](#10.2 豆包)
  • 十一、简述TCP流量控制的方法
    • [11.1 训练营](#11.1 训练营)
    • [11.2 豆包](#11.2 豆包)
  • 十二、什么是连接的半打开,半关闭状态?
    • [12.1 训练营](#12.1 训练营)
    • [12.2 豆包](#12.2 豆包)
  • 十三、什么是长连接、短链接
    • [13.1 短连接(Non-persistent Connection)](#13.1 短连接(Non-persistent Connection))
    • [13.2 **长连接(Persistent Connection)**](#13.2 长连接(Persistent Connection))
  • 十四、简述下Epoll是什么
    • [14.1 训练营](#14.1 训练营)
    • [14.2 传统 `select`/`poll` 在高并发下的性能瓶颈?](#14.2 传统 select/poll 在高并发下的性能瓶颈?)
      • [14.2.1 **文件描述符(FD)数量限制**](#14.2.1 文件描述符(FD)数量限制)
      • [14.2.2 **轮询机制的低效性**](#14.2.2 轮询机制的低效性)
      • [14.2.3 **用户态与内核态的数据拷贝开销**](#14.2.3 用户态与内核态的数据拷贝开销)
      • [14.2.4 重复初始化的冗余操作](#14.2.4 重复初始化的冗余操作)
      • [14.2.5 总结](#14.2.5 总结)
    • [14.3 文件描述符不是有限的吗?受啥限制?](#14.3 文件描述符不是有限的吗?受啥限制?)
      • [14.3.1 **`select`的 "硬限制" 本质是设计缺陷,而非系统 FD 总数限制**](#14.3.1 select的 “硬限制” 本质是设计缺陷,而非系统 FD 总数限制)
      • [14.3.2 **`epoll`的设计不依赖固定大小结构,仅受系统 FD 总数限制**](#14.3.2 epoll的设计不依赖固定大小结构,仅受系统 FD 总数限制)
      • [14.3.3 **`epoll`的效率不随 FD 数量增加而急剧下降**](#14.3.3 epoll的效率不随 FD 数量增加而急剧下降)
      • [14.3.4 总结](#14.3.4 总结)
  • 十五、描述下select、poll、epoll三者的区别
      • [15.1.1 核心数据结构与监控方式](#15.1.1 核心数据结构与监控方式)
      • [15.1.2 关键限制对比](#15.1.2 关键限制对比)
      • [15.1.3 性能瓶颈与效率差异](#15.1.3 性能瓶颈与效率差异)
      • [15.1.4 适用场景](#15.1.4 适用场景)
      • [15.1.5 总结](#15.1.5 总结)
  • 十六、Epoll水平触发和边缘触发的区别?
      • [16.2 在边缘触发下,一个socket已读取200,然后不在处理,是不是剩下的300就永远无法读取?](#16.2 在边缘触发下,一个socket已读取200,然后不在处理,是不是剩下的300就永远无法读取?)
  • 十七、连接断开有哪几种判定方式?
  • 十八、简述下DNS和域名
    • [18.1 DNS](#18.1 DNS)
    • [18.2 域名](#18.2 域名)
  • 十九、简述下DNS的工作流程
    • [19.1 详细步骤](#19.1 详细步骤)
    • [19.2 图解](#19.2 图解)
  • 二十、HTTP是什么?有什么特点?
  • [二十一、 简述下HTTP的请求和响应报文的格式](#二十一、 简述下HTTP的请求和响应报文的格式)
    • [21.1 HTTP 请求报文格式](#21.1 HTTP 请求报文格式)
    • [21.2 HTTP 响应报文格式](#21.2 HTTP 响应报文格式)
  • 二十二、简述HTTP方法和HTTP状态码
  • 二十三、简述HTTP的工作流程
    • [23.1 训练营](#23.1 训练营)
    • [23.2 豆包说](#23.2 豆包说)
      • [23.2.1 建立 TCP 连接](#23.2.1 建立 TCP 连接)
      • [23.2.2 客户端发送 HTTP 请求](#23.2.2 客户端发送 HTTP 请求)
      • [23.2.3 服务器处理并返回响应](#23.2.3 服务器处理并返回响应)
      • [23.2.4 关闭连接或复用连接](#23.2.4 关闭连接或复用连接)
      • [23.2.5 示例流程](#23.2.5 示例流程)

一、TCP/IP网络模型

TCP/IP 网络模型是互联网的核心通信协议体系,它定义了计算机之间如何在网络中传递数据,是现代网络通信的基础。与 OSI 七层模型不同,TCP/IP 模型将网络通信过程简化为四个层级,每层负责特定的功能,各层之间通过协议协同工作,实现数据的可靠传输。

1.1 TCP/IP 模型的四层结构(从下到上)

1.1.1 网络接口层(Network Interface Layer)

  • 作用:建立和维护物理连接,将二进制数据(比特流)转换为可在物理介质中传输的信号(如电信号、光信号、无线电波),并确保信号能被接收端正确识别(处理硬件地址(如 MAC 地址)和物理层的连接)。

  • 功能:

    • 数据帧封装与解封装
      • 接收上层(网络层)传递的数据包(如 IP 数据包),添加帧头(包含源 / 目的 MAC 地址、帧类型等信息)和帧尾(校验位),形成 "数据帧",使其符合物理网络的传输格式。
      • 接收物理网络传来的数据帧后,验证帧的完整性(通过校验位),去除帧头帧尾,将内部的数据包提取出来并提交给上层网络层。
    • 物理地址管理
      • 通过 MAC 地址(媒体访问控制地址)识别同一物理网络中的设备,确保数据帧能准确发送到目标设备(如以太网中通过 ARP 协议查询 IP 地址对应的 MAC 地址)。
    • 媒体访问控制
      • 解决多个设备共享物理传输介质(如以太网总线、无线信道)时的冲突问题,例如以太网采用 CSMA/CD(带冲突检测的载波监听多路访问)机制,Wi-Fi 采用 CSMA/CA(带冲突避免的载波监听多路访问)机制。
    • 链路差错控制
      • 通过帧尾的校验位(如 CRC 循环冗余校验)检测数据在传输过程中因噪声、干扰等导致的错误,若校验失败则丢弃该帧,避免错误数据向上层传递。
    • 与物理层交互
      • 将封装好的数据帧转换为物理信号(如电信号、光信号、无线电波),通过物理介质(网线、光纤、空气等)传输;同时接收物理层传来的信号,转换为数据帧进行处理。

    简言之,网络接口层是 TCP/IP 协议栈与物理网络的 "桥梁",负责将抽象的网络层数据转化为可在具体物理链路上传输的格式,并处理链路级的传输控制与错误管理。

  • 涉及技术:以太网、Wi-Fi、令牌环等局域网技术,以及 PPP(点对点协议)等广域网技术。

1.1.2 网络层(Internet Layer)

  • 作用:核心是实现不同网络之间的数据包路由和转发,确保数据从源主机跨越多个网络到达目标主机。它不关心数据的具体内容,只负责 "找路"。
  • 功能:
    • 为数据包分配唯一的 IP 地址(如 IPv4、IPv6),确定源地址和目标地址。
    • 通过路由选择(Routing)算法,选择最佳路径将数据包从源网络发送到目标网络(可能经过多个路由器)。
    • 处理数据包的分片与重组(当数据包超过网络的最大传输单元 MTU 时)。
  • 核心协议:
    • IP(Internet Protocol):给每个主机分配唯一的 IP 地址,定义数据包的格式(IP 报文),并通过路由算法选择传输路径。
    • ICMP(Internet Control Message Protocol) :用于在 IP 网络中传递控制信息(如 "目标不可达""超时"),常见的ping命令就基于 ICMP。
    • ARP(Address Resolution Protocol):将 IP 地址转换为物理 MAC 地址,以便在局域网内传输数据。

1.1.3 传输层(Transport Layer)

  • 功能:在源主机和目标主机的应用程序之间建立可靠的 "数据传输通道",负责数据的分段、重组、流量控制和差错校验,确保数据完整、有序地到达。
  • 核心协议:
    • TCP(Transmission Control Protocol,传输控制协议):
      • 面向连接:通信前需通过 "三次握手" 建立连接,结束后通过 "四次挥手" 断开连接。
      • 可靠传输:通过确认机制、重传机制、流量控制(滑动窗口)和拥塞控制,保证数据不丢失、不重复、按顺序到达。
      • 适用于对可靠性要求高的场景(如网页浏览、文件传输、邮件)。
    • UDP(User Datagram Protocol,用户数据报协议):
      • 无连接:直接发送数据,无需建立连接,开销小、速度快。
      • 不可靠传输:不保证数据到达,也不处理顺序和重传。
      • 适用于对实时性要求高的场景(如视频通话、在线游戏、直播)。

1.1.4 应用层(Application Layer)

  • 功能:直接为用户应用程序提供网络服务,定义应用程序之间的通信规则和数据格式。
  • 常见协议:
    • HTTP/HTTPS:用于网页传输(超文本传输协议,HTTPS 是加密版本)。
    • FTP(File Transfer Protocol):文件传输协议,用于上传和下载文件。
    • SMTP/POP3/IMAP:电子邮件相关协议,负责邮件的发送和接收。
    • DNS(Domain Name System) :将域名(如www.baidu.com)解析为 IP 地址。
    • Telnet/SSH:远程登录协议,用于远程控制主机。

1.2 TCP/IP 模型的数据传输流程(示例)

当你用浏览器访问网页时,数据的传递过程如下:

  1. 应用层:浏览器通过 HTTP 协议生成请求数据(如获取网页内容)。
  2. 传输层:HTTP 数据被封装到 TCP 段中(添加源端口和目标端口,如 80 端口),TCP 确保数据可靠传输。
  3. 网络层:TCP 段被封装到 IP 数据包中(添加源 IP 和目标 IP 地址),通过路由选择传输路径。
  4. 网络接口层:IP 数据包被封装到以太网帧中(添加源 MAC 和目标 MAC 地址),通过物理介质(如网线)发送。
  5. 目标主机接收后,从下到上逐层拆封(解封装),最终将数据传递给目标应用程序(如服务器的网页服务)。

1.3 TCP/IP 模型与 OSI 模型的对比

TCP/IP 模型(4 层) 对应 OSI 模型(7 层) 核心区别
应用层 应用层、表示层、会话层 TCP/IP 将 OSI 的上三层合并,更简洁实用
传输层 传输层 功能类似,均负责端到端传输
网络层 网络层 核心均为 IP 协议和路由功能
网络接口层 数据链路层、物理层 TCP/IP 未严格区分底层细节,更灵活

1.4 总结

TCP/IP 模型通过分层设计实现了网络通信的模块化,每层专注于特定功能,降低了整体复杂度。从物理传输到应用交互,TCP/IP 协议簇支撑了全球互联网的正常运行,是理解网络通信的基础。

二、Linux的五种IO模型

2.1 什么是IO模型?

IO 模型(Input/Output Model)是操作系统中描述进程如何与外部设备(如磁盘、网络、键盘等)进行数据交互的规则或方式。

它主要解决两个核心问题:

  1. 进程如何发起 IO 请求(如读文件、发网络数据);
  2. 进程在等待 IO 操作完成(如数据准备、数据拷贝)的过程中,处于什么状态(是阻塞等待,还是可以做其他事)。

不同 IO 模型的核心区别在于 "等待阶段的处理方式",直接影响程序的性能(如响应速度、资源利用率)。比如:

  • 有的模型会让进程 "傻傻等"(阻塞 IO);
  • 有的模型允许进程 "边等边干活"(非阻塞 IO、IO 多路复用);
  • 有的模型让内核 "全包办",完成后再通知进程(异步 IO)。

简单说,IO 模型就是进程与外部设备 "打交道" 的不同策略。

2.2 有几种IO?

Linux 的五种 IO 模型可简单分为以下几类,核心区别在于等待数据和处理数据的方式:

  1. 阻塞 IO :进程发起 IO 后一直等待,直到数据准备好并拷贝完成才返回,期间无法做其他事。
    • 像去餐厅点餐,点完后站在柜台前一直等,直到餐品做好拿到手才能离开,期间什么都做不了。
    • 类型:同步阻塞
  2. 非阻塞 IO :进程发起 IO 后立即返回,可做其他事;需不断轮询检查数据是否就绪,就绪后再拷贝数据。
    • 点餐后服务员说 "好了叫你",你可以先去旁边逛一逛,但每隔一会儿就得回来问 "我的餐好了吗",直到餐好再取。
    • 类型:同步非阻塞
  3. IO 多路复用 :通过 select/poll/epoll 等工具,一个进程可同时监控多个 IO 通道,任一通道就绪就处理,减少轮询消耗。
    • 同时在奶茶店、咖啡店、快餐店点了单,找个座位坐着,哪个店的号先叫到,就先去取哪个,不用来回跑着问。
    • 同步阻塞
  4. 信号驱动 IO :进程注册信号回调,数据就绪时内核发信号通知,进程再去处理数据拷贝,无需主动轮询。
    • 点餐后留了手机号,告诉服务员 "好了发短信",你可以自由做事,收到短信再去取餐,不用自己惦记。
    • 同步非阻塞
  5. 异步 IO :进程发起 IO 后直接返回,内核完成数据准备和拷贝后,主动通知进程结果,全程无需进程参与中间步骤。
    • 在网上下单外卖,付款后就不用管了,外卖员会把餐送到家门口再打电话通知你,全程不用你跑一趟。
    • 异步非阻塞

2.3 阻塞 I/O(Blocking I/O)

定义:最基础的 I/O 模型,当应用程序发起 I/O 操作(如读 / 写)时,会阻塞等待直到操作完成(数据就绪并拷贝到用户空间),期间进程无法执行其他任务。

2.3.1 工作流程

说法1:

  1. 发起请求:进程向内核发起 I/O 操作(如读文件、接收网络数据)。
  2. 等待就绪:内核开始准备数据(如从磁盘读取、等待网络数据到达),此时进程被挂起,处于阻塞状态,无法执行其他任务。
  3. 数据拷贝与返回:数据准备好后,内核将数据从内核空间拷贝到进程的用户空间,拷贝完成后,内核通知进程,进程解除阻塞,继续执行后续操作。

简言之:进程发起请求后 "一动不动",直到数据完全准备好并拷贝到自己的空间,才会继续干活。

说法2:

  • 应用程序调用 recvfrom 等 I/O 函数,请求读取数据。
  • 内核开始等待数据(如网络数据到达),此时应用进程进入阻塞状态(挂起)。
  • 数据到达后,内核将数据从内核缓冲区拷贝到用户缓冲区。
  • 拷贝完成后,内核唤醒进程,I/O 函数返回,进程继续处理数据。

2.3.2 代码示例

以下是使用 C 语言展示阻塞 I/O 工作流程的简单示例(以读取文件为例):

cpp 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    FILE *file;
    char buffer[1024];
    ssize_t bytes_read;

    // 1. 发起I/O请求:打开文件并尝试读取
    file = fopen("example.txt", "r");
    if (!file) {
        perror("文件打开失败");
        return 1;
    }

    printf("开始读取文件...\n");
    
    // 2. 阻塞等待:fread调用会阻塞,直到数据读取完成
    // 期间进程无法执行其他操作(如下面的printf不会被提前执行)
    bytes_read = fread(buffer, 1, sizeof(buffer), file);

    // 3. 数据返回:读取完成后才会执行到这里
    if (bytes_read > 0) {
        printf("读取到%d字节数据:%.*s\n", (int)bytes_read, (int)bytes_read, buffer);
    } else {
        printf("读取完成或文件为空\n");
    }

    fclose(file);
    return 0;
}
  1. 当调用 fread 时,进程会进入阻塞状态,暂停执行后续代码
  2. 内核会负责从磁盘读取数据,并将数据从内核空间拷贝到用户空间的 buffer
  3. 只有当数据读取和拷贝全部完成后,fread 才会返回,进程才会继续执行后续的打印操作

这个过程中,在 fread 返回之前,进程无法做任何其他事情,这就是阻塞 I/O 的典型特征。

2.4 非阻塞 I/O(Non-blocking I/O)

应用程序发起 I/O 操作后,若数据未就绪,内核会立即返回错误(如 EAGAINEWOULDBLOCK),而非阻塞等待;进程可反复轮询(poll)直到数据就绪。

2.4.1 工作流程

说法1:

非阻塞 IO 的工作流程可分为四步,核心是 "不等待,主动查":

  1. 设置非阻塞模式:进程先将 IO 操作(如套接字、文件)设置为非阻塞模式。
  2. 发起请求:进程发起 IO 调用(如读数据),内核立即返回,不管数据是否就绪。
  3. 轮询检查:
    • 若数据未就绪,返回错误(如 EAGAIN),进程可去做其他事,之后再主动轮询检查;
    • 若数据就绪,内核开始将数据从内核空间拷贝到用户空间(此阶段仍可能阻塞,因拷贝需时间)。
  4. 处理结果:拷贝完成后,IO 调用返回实际读取 / 写入的字节数,进程处理数据。

简言之:发起请求后立刻干活,时不时回头看看数据好了没,好了就处理,没好就继续干别的。

说法2:

  • 应用程序先通过 fcntl 将文件描述符(FD)设置为非阻塞模式。
  • 调用 recvfrom 时,若数据未就绪,内核立即返回错误,进程可执行其他任务。
  • 进程通过循环反复调用 recvfrom 轮询数据状态。
  • 当数据就绪后,内核将数据拷贝到用户缓冲区,函数返回成功,进程处理数据。

2.4.2 代码示例

以下是非阻塞 I/O 的代码示例(以网络套接字读取为例),展示其 "轮询检查" 的特点:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    char buffer[1024];
    ssize_t bytes_read;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("套接字创建失败");
        return 1;
    }

    // 设置套接字为非阻塞模式
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    // 连接服务器(示例地址)
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8080);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    // 非阻塞连接(可能立即返回错误,这里简化处理)
    if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("连接可能在进行中(非阻塞特性)");
    }

    // 轮询检查数据是否就绪
    while (1) {
        // 非阻塞读取:立即返回,无论数据是否就绪
        bytes_read = recv(sockfd, buffer, sizeof(buffer), 0);

        if (bytes_read > 0) {
            // 数据就绪,处理数据
            printf("读取到%d字节:%.*s\n", (int)bytes_read, (int)bytes_read, buffer);
            break;
        } else if (bytes_read == 0) {
            // 连接关闭
            printf("连接已关闭\n");
            break;
        } else {
            // 数据未就绪,可执行其他任务
            printf("暂无数据,做点别的事...\n");
            sleep(1); // 模拟其他操作
        }
    }

    close(sockfd);
    return 0;
}

代码说明:

  1. 通过 fcntl 设置套接字为 O_NONBLOCK 模式,开启非阻塞特性
  2. 调用recv时,无论数据是否就绪都会立即返回:
    • 数据就绪时,返回读取的字节数(>0)
    • 数据未就绪时,返回 -1 并设置错误码(如 EAGAINEWOULDBLOCK
  3. 进程无需一直等待,可在轮询间隙执行其他任务(如示例中的打印和休眠)

非阻塞 I/O 的核心是 "主动询问" 而非 "被动等待",但需要通过轮询不断检查状态,可能消耗额外资源。

2.5 I/O多路复用

通过一个监控进程(如内核提供的 select/poll/epoll)同时监控多个 I/O 描述符,当任一描述符的数据就绪时,通知应用程序处理,实现 "一个进程管理多个 I/O 流"。

2.5.1 工作流程

说法1:

I/O 多路复用的工作流程核心是 "一个进程监控多个 IO 通道,谁就绪就处理谁",步骤如下:

  1. 创建监控集合:进程创建一个 "监控列表",将需要关注的 IO 通道(如多个套接字)加入其中。
  2. 发起监控请求:调用 select/poll/epoll 等工具,让内核监控这个列表中的所有 IO 通道,此时进程阻塞等待。
  3. 内核监控与通知:内核持续检查列表中的 IO 通道,当任一通道数据就绪(如可读 / 可写),立即唤醒进程,并返回就绪的通道信息。
  4. 处理就绪 IO:进程只针对就绪的 IO 通道进行数据读写操作,无需轮询所有通道。
  5. 重复监控:处理完当前就绪的 IO 后,进程可再次将 IO 通道加入监控列表,重复上述过程。

简言之:用一个 "总管"(内核工具)盯着所有 IO 通道,有动静了再通知进程处理,避免了进程盲目轮询的资源浪费。

说法2:

  • 应用程序将需要监控的 FD 集合注册到 select/epoll 等系统调用中。
  • 监控进程阻塞等待,直到某个 FD 数据就绪(或超时)。
  • 内核通知应用程序哪些 FD 就绪,应用程序再针对这些 FD 发起 I/O 操作(如 recvfrom)。
  • 数据拷贝完成后,进程处理数据。

2.5.2 代码示例

下面用 C 语言的 select 函数展示 I/O 多路复用的简单示例,监控两个套接字的可读事件:

cpp 复制代码
#include <stdio.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

int main() {
    // 创建两个套接字(模拟两个客户端连接)
    int sock1 = socket(AF_INET, SOCK_STREAM, 0);
    int sock2 = socket(AF_INET, SOCK_STREAM, 0);
    if (sock1 < 0 || sock2 < 0) {
        perror("socket创建失败");
        return 1;
    }

    // 绑定端口(简化处理,实际需完整初始化地址)
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(8080);
    bind(sock1, (struct sockaddr*)&addr, sizeof(addr));
    addr.sin_port = htons(8081);
    bind(sock2, (struct sockaddr*)&addr, sizeof(addr));
    listen(sock1, 5);
    listen(sock2, 5);

    // 初始化监控集合
    fd_set read_fds;
    int max_fd = (sock1 > sock2) ? sock1 : sock2;

    while (1) {
        // 每次循环都要重新初始化集合(select会修改集合)
        FD_ZERO(&read_fds);
        FD_SET(sock1, &read_fds);  // 加入第一个套接字
        FD_SET(sock2, &read_fds);  // 加入第二个套接字

        printf("等待数据就绪...\n");
        // 监控集合,阻塞等待任一套接字就绪
        int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select失败");
            break;
        }

        // 检查哪个套接字就绪
        if (FD_ISSET(sock1, &read_fds)) {
            printf("sock1 有数据到来,开始处理...\n");
            // 实际应用中这里会调用accept/recv处理数据
        }
        if (FD_ISSET(sock2, &read_fds)) {
            printf("sock2 有数据到来,开始处理...\n");
            // 实际应用中这里会调用accept/recv处理数据
        }
    }

    close(sock1);
    close(sock2);
    return 0;
}

代码说明:

  1. 创建两个套接字并监听不同端口,模拟两个 IO 通道
  2. FD_SET 将需要监控的套接字加入集合
  3. select 函数阻塞等待,直到任一套接字有数据到来(可读)
  4. FD_ISSET 检查哪个套接字就绪,只处理就绪的 IO 操作

这个例子的核心是:一个进程同时监控多个 IO 通道,无需轮询,哪个就绪就处理哪个,比非阻塞 IO 的轮询方式更高效。

2.6 信号驱动 I/O(Signal-Driven I/O)

应用程序通过信号机制异步处理 I/O:先注册一个信号处理函数,当数据就绪时,内核发送 SIGIO 信号通知进程,进程在信号处理函数中完成数据读取。

2.6.1 工作流程

说法1:

信号驱动 I/O 的工作流程核心是 "注册信号,被动通知",步骤如下:

  1. 注册信号回调:进程向内核注册一个信号(如 SIGIO),并指定信号触发时的处理函数(回调)。
  2. 发起非阻塞请求:进程发起 IO 操作(如读数据),内核立即返回,进程可继续执行其他任务(不阻塞)。
  3. 内核准备数据:内核在后台准备数据,期间进程无需关注。
  4. 信号通知:当数据就绪后,内核主动向进程发送注册的信号。
  5. 处理数据:进程收到信号后,在回调函数中执行实际的 IO 操作(如从内核拷贝数据到用户空间)。

简言之:提前跟内核 "打个招呼",数据准备好了就 "喊我一声",我先去忙别的,听到喊声再回来处理。

说法2:

  • 应用程序通过 sigaction 注册信号处理函数(当 I/O 就绪时触发)。
  • 调用 fcntl 开启信号驱动模式,内核开始等待数据,进程不阻塞,可执行其他任务。
  • 数据就绪后,内核发送 SIGIO 信号,触发信号处理函数。
  • 进程在信号处理函数中调用 recvfrom 完成数据拷贝和处理。

2.6.2 代码示例

以下是信号驱动 I/O 的简单代码示例,使用 SIGIO 信号通知数据就绪:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <fcntl.h>
#include <unistd.h>

int fd; // 全局文件描述符,供信号处理函数使用
char buffer[1024];

// 信号处理函数:数据就绪时被调用
void sigio_handler(int signo) {
    ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
    if (bytes_read > 0) {
        printf("收到数据:%.*s\n", (int)bytes_read, buffer);
    }
}

int main() {
    // 打开文件(也可以是网络套接字)
    fd = open("input.txt", O_RDONLY);
    if (fd < 0) {
        perror("文件打开失败");
        return 1;
    }

    // 1. 注册信号处理函数(指定SIGIO信号的处理方式)
    signal(SIGIO, sigio_handler);

    // 2. 设置文件所有者,让内核知道向哪个进程发送信号
    fcntl(fd, F_SETOWN, getpid());

    // 3. 开启信号驱动模式(当数据就绪时,内核发送SIGIO信号)
    int flags = fcntl(fd, F_GETFL);
    //作用:获取当前文件描述符(fd)的状态标志(比如是否为非阻塞模式、是否可读可写等)。类比:相当于先查看 "当前开关状态"(哪些功能已开启)。
    fcntl(fd, F_SETFL, flags | O_ASYNC);
    //作用:在原有标志(flags)的基础上,添加 O_ASYNC 标志(Async,异步),然后重新设置给文件描述符。类比:把 "异步通知" 这个开关打开,同时保留之前的其他开关状态。

    // 4. 进程可以自由执行其他任务,无需阻塞或轮询
    printf("等待数据中...可以做其他事情\n");
    while (1) {
        sleep(1); // 模拟其他工作
    }

    close(fd);
    return 0;
}
  1. 进程通过 signal(SIGIO, sigio_handler) 注册信号回调,告诉内核 "数据就绪时调用这个函数"
  2. fcntl 开启 O_ASYNC 模式,激活信号驱动功能
  3. 之后进程可以正常执行其他任务(示例中用 sleep 模拟),无需等待
  4. 当文件有数据可读时,内核自动发送 SIGIO 信号,触发 sigio_handler 处理数据

这种模式的特点是:无需轮询,数据就绪时内核主动 "打断" 进程并通知,比非阻塞 I/O 更高效。

2.7 异步 I/O(Asynchronous I/O)

最彻底的异步模型,应用程序发起 I/O 操作后立即返回,内核负责全程处理 I/O 操作(包括等待数据、拷贝数据到用户空间),完成后通过信号或回调通知进程。

2.7.1 工作流程

说法1:

异步 I/O(Asynchronous I/O)的工作流程核心是 "全程不干预,完事再通知",步骤如下:

  1. 发起异步请求 :进程调用异步 IO 接口(如 aio_read),传入数据缓冲区、回调函数等参数,告诉内核 "帮我读数据,完成后通知我",然后立即返回,继续执行其他任务(全程不阻塞)。
  2. **内核全权处理:**内核在后台完成两件事:
    • 等待数据就绪(如从磁盘 / 网络获取数据);
    • 把数据从内核空间拷贝到用户空间的缓冲区。
  3. 结果通知:内核完成所有操作后,通过预设的回调函数或信号,主动通知进程 "操作已完成",并返回结果(如读取的字节数、是否出错)。
  4. 进程处理结果:进程收到通知后,直接使用已拷贝到用户空间的数据,无需再进行 IO 操作。

简言之:进程 "下单" 后就不管了,内核 "包办" 所有工作,完成后再 "通知取货",全程不耽误进程做其他事。

说法2:

  • 应用程序调用 aio_read 等异步 I/O 函数,指定数据缓冲区、回调函数,然后继续执行其他任务。
  • 内核自动等待数据就绪,并将数据从内核缓冲区拷贝到用户缓冲区。
  • 所有操作完成后,内核通过信号或回调通知进程,进程直接处理已就绪的数据。

2.7.2 代码示例

以下是使用 Linux 的 aio 系列函数实现异步 I/O 的简单示例,展示其 "全程交由内核处理,完成后回调" 的特点:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <aio.h>
#include <signal.h>
#include <unistd.h>

// 异步操作完成后的回调函数
void aio_completion_handler(int signo, siginfo_t *info, void *context) {
    struct aiocb *aio = (struct aiocb *)info->si_value.sival_ptr;
    
    // 检查操作是否成功
    if (aio->aio_errno == 0) {
        ssize_t bytes_read = aio_return(aio);
        printf("异步读取完成,读取了 %zd 字节:%.*s\n", 
               bytes_read, (int)bytes_read, (char *)aio->aio_buf);
    } else {
        printf("异步读取失败,错误码:%d\n", aio->aio_errno);
    }
}

int main() {
    int fd;
    struct aiocb aio;
    char *buffer = malloc(1024); // 数据缓冲区

    // 打开文件
    fd = open("test.txt", O_RDONLY);
    if (fd < 0) {
        perror("文件打开失败");
        return 1;
    }

    // 初始化异步I/O控制块
    memset(&aio, 0, sizeof(aio));
    aio.aio_fd = fd;               // 目标文件描述符
    aio.aio_buf = buffer;          // 数据缓冲区
    aio.aio_nbytes = 1024;         // 读取字节数
    aio.aio_offset = 0;            // 读取起始位置

    // 设置完成通知方式:通过信号回调
    struct sigaction sa;
    sa.sa_sigaction = aio_completion_handler;
    sa.sa_flags = SA_SIGINFO;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGIO, &sa, NULL);

    // 绑定信号与异步控制块
    aio.aio_sigevent.sigev_notify = SIGEV_SIGNAL;
    aio.aio_sigevent.sigev_signo = SIGIO;
    aio.aio_sigevent.sigev_value.sival_ptr = &aio;

    // 发起异步读取请求(立即返回,不阻塞)
    if (aio_read(&aio) < 0) {
        perror("异步请求失败");
        return 1;
    }

    // 发起请求后,进程可自由执行其他任务
    printf("异步请求已发起,进程继续干活...\n");
    for (int i = 0; i < 5; i++) {
        printf("做其他事情 %d...\n", i + 1);
        sleep(1);
    }

    // 清理资源
    free(buffer);
    close(fd);
    return 0;
}
  1. 通过 struct aiocb 结构体设置异步操作参数(文件描述符、缓冲区、读取长度等)
  2. 注册 SIGIO 信号的回调函数 aio_completion_handler,用于处理操作完成事件
  3. 调用 aio_read 发起异步读取请求,该函数立即返回,进程无需等待
  4. 进程在异步操作期间可正常执行其他任务(示例中用循环打印模拟)
  5. 当内核完成数据准备和拷贝后,会发送 SIGIO 信号,触发回调函数处理结果

异步 I/O 与信号驱动 I/O 的核心区别:

  • 信号驱动 I/O 仅在 "数据就绪" 时通知,还需进程自己执行拷贝
  • 异步 I/O 由内核完成全程操作(包括数据准备和拷贝),完成后才通知进程

2.8 同步异步?阻塞非阻塞?有啥区别?

同步 / 异步与阻塞 / 非阻塞是描述 I/O 操作的两组核心概念,它们从不同维度定义了操作的特性:

  • 同步 / 异步 :关注 "操作结果的通知方式"(谁来告诉进程 "操作完成了")。
  • 阻塞 / 非阻塞 :关注 "进程等待期间的状态"(进程在等的时候能不能做别的事)。

2.8.1 同步 vs 异步(核心:结果通知方式)

同步 I/O(Synchronous I/O)

进程需要主动等待或检查 I/O 操作的完成。

  • 流程:进程发起 I/O 请求 → 必须等待操作完成(或主动轮询是否完成)→ 才能继续执行后续任务。
  • 关键:"我等结果"(进程自己负责等待结果)。

异步 I/O(Asynchronous I/O)

进程无需等待,由内核主动通知操作完成。

  • 流程:进程发起 I/O 请求(同时指定 "完成后如何通知",如回调函数)→ 立即返回,继续执行其他任务 → 内核完成所有操作(包括数据准备、拷贝)后,通过预设方式(如回调、信号)主动通知进程 → 进程处理结果。
  • 关键:"结果找我"(内核负责完成操作并主动通知)。

2.8.2 阻塞 vs 非阻塞(核心:等待时的进程状态)

阻塞 I/O(Blocking I/O)

进程发起 I/O 请求后,在等待 I/O 事件(如数据就绪、拷贝完成)的过程中,会被挂起(休眠),不占用 CPU 资源,直到事件发生才被唤醒。

  • 举例:调用 read(fd, buf, n) 读取数据时,如果数据未就绪,进程会阻塞(暂停执行),直到数据就绪并拷贝到用户空间后才返回。

非阻塞 I/O(Non-blocking I/O)

进程发起 I/O 请求后,不会被挂起 ,无论 I/O 事件是否就绪,都会立即返回结果(就绪则返回数据,未就绪则返回错误码,如 EAGAIN)。

  • 举例:用 fcntl 设置文件描述符为非阻塞模式后,调用 read 时,若数据未就绪,会立即返回 -1 并设置 errno=EAGAIN,进程可继续执行其他任务,之后再定期轮询检查。

2.8.3 组合场景(关键:四者可交叉组合)

同步 / 异步与阻塞 / 非阻塞是独立维度,可形成 4 种组合(常见 3 种):

组合 特点 典型场景
同步阻塞 I/O 进程发起请求后阻塞等待,直到操作完成(最常见、最简单)。 普通文件读取、未优化的网络请求
同步非阻塞 I/O 进程发起请求后立即返回(非阻塞),但需主动轮询检查是否完成(同步)。 轮询检测设备状态(如传感器)
异步非阻塞 I/O 进程发起请求后立即返回(非阻塞),内核完成后主动通知(异步)。 高性能服务器(如异步框架)
(异步阻塞 I/O) 几乎不存在(异步的核心是 "不等待",阻塞与之矛盾)。 无典型场景

2.8.4 通俗类比(帮助理解)

  • 同步阻塞:去餐厅吃饭,点餐后坐在座位上一动不动,直到服务员把菜端上桌(等的时候啥也不干)。
  • 同步非阻塞:去餐厅吃饭,点餐后去旁边商场逛,每隔 5 分钟回来看看菜好了没(等的时候能做别的,但得自己检查)。
  • 异步非阻塞:点外卖,下单后继续工作,外卖送到后骑手打电话通知你取餐(等的时候能做别的,结果主动找你)。

三、Reactor网络编程模型

3.1 面试官问如何回答:

是什么:一种处理高并发网络请求的设计模式;它采用同步非阻塞的方式来处理IO;

本质:将对IO的处理转化为对(就绪)事件的处理;

解决的问题:将事件监听分发与事件处理进行解耦,避免为每个连接使用独立线程而造成的资源消耗。

​ IO就绪,IO操作

处理步骤:注册事件--->事件监听与分发---->事件处理;(使用io多路复用解决io处理时机的问题。)

3.2 训练营提供的

Reactor 模式是一种基于IO多路复用模型(如 select、poll、epoll、kqueue 等系统调用)的设计模式,是处理并发 I/O 比较常见的一种模式,用于同步 I/O ,中心思想是将所有要处理的 I/O 事件注册到一个中心 I/O 多路复用器上,同时主线程 / 进程阻塞在多路复用器上。一旦有 I/O 事件到来或是准备就绪 ( 文件描述符或 socket 可读、写 ) ,多路复用器返回并将事先注册的相应 I/O 事件分发到对应的处理器中。

Reactor 模型有三个重要的组件:

  • 多路复用器:由操作系统提供,在 linux 上一般是 select, poll, epoll 等系统调用。
  • 事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中。
  • 事件处理器:负责处理特定事件的处理函数。

具体流程如下:

  1. 注册读就绪事件和相应的事件处理器;
  2. 事件分离器等待事件;
  3. 事件到来,激活分离器,分离器调用事件对应的处理器;
  4. 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权

3.3 豆包提供的

在网络编程领域,"Reactor" 通常指 Reactor 模式(反应器模式),是一种高效处理多并发 I/O 操作的事件驱动编程模型。它通过将 I/O 事件的检测、分发与业务逻辑处理分离,实现对大量并发连接的高效管理,广泛应用于高性能服务器开发(如 Web 服务器、数据库服务器、消息中间件等)。

3.3.1 Reactor 模式的核心思想

Reactor 模式的核心是 "事件驱动""多路复用"

  • 事件驱动:服务器不会主动轮询每个连接的状态,而是当连接上发生特定事件(如数据可读、可写、连接建立 / 关闭等)时,由操作系统或框架通知程序处理。
  • 多路复用 :通过一个或多个 "反应器"(Reactor)统一管理多个 I/O 通道(如 socket),利用操作系统提供的 I/O 多路复用机制(如 Linux 的 epoll、Windows 的 IOCP、BSD 的 kqueue 等),批量检测多个通道的事件状态,避免对每个连接单独阻塞等待,大幅提升资源利用率。

3.3.2 Reactor 模式的核心组件

一个典型的 Reactor 模式包含以下关键组件:

  1. 事件源(Event Source)
    指产生 I/O 事件的对象,通常是网络连接(socket)、文件描述符等。
  2. 事件多路分离器(Event Demultiplexer)
    封装操作系统的 I/O 多路复用接口(如 epoll_waitselect),负责监听多个事件源,当事件发生时将其分发给 Reactor 处理。
  3. 反应器(Reactor)
    核心调度器,负责注册 / 注销事件、将事件多路分离器检测到的事件分发给对应的 "事件处理器"。
  4. 事件处理器(EventHandler)
    定义处理特定事件的接口(如 handleRead()handleWrite()handleConnect()),由具体实现类完成业务逻辑(如读取数据、解析协议、返回响应等)。
  5. 具体事件处理器(ConcreteEventHandler)
    实现 EventHandler 接口,是实际处理业务逻辑的组件。

3.3.3 Reactor 模式的常见变体

根据 "反应器" 和 "事件处理器" 的线程模型,Reactor 模式可分为以下几种典型结构:

单 Reactor 单线程模式

  • 结构:一个 Reactor 线程负责所有工作(监听连接、处理 I/O 事件、执行业务逻辑)。
  • 流程:
    • Reactor 通过事件多路分离器监听所有事件(如 ACCEPT 连接事件、READ 读事件)。
    • 当新连接到来(ACCEPT 事件),Reactor 调用连接处理器(Acceptor)创建新的 socket,并将其注册到 Reactor 中监听 READ 事件。
    • 当 socket 有数据可读(READ 事件),Reactor 调用对应的读事件处理器,完成数据读取、处理和响应。
  • 优点:实现简单,无多线程竞争问题。
  • 缺点:单线程处理所有任务,若某事件处理耗时过长(如复杂业务逻辑),会阻塞整个系统,无法充分利用多核 CPU,仅适用于并发量低、处理逻辑简单的场景(如早期的 Nginx 部分模式)。

单 Reactor 多线程模式

  • 结构:1 个 Reactor 线程负责监听事件和分发,业务逻辑由独立的线程池处理。
  • 流程:
    • Reactor 线程负责处理 ACCEPT 事件(建立连接)和 I/O 事件的检测。
    • READ 事件发生时,Reactor 将数据读取后,不直接处理业务逻辑,而是将任务提交给线程池,由线程池中的线程执行业务处理和结果返回。
  • 优点:业务逻辑处理与 I/O 事件监听分离,避免单线程阻塞,充分利用多核 CPU。
  • 缺点:Reactor 线程仍是单点,若其负载过高(如大量连接建立 / 关闭),可能成为瓶颈。

多 Reactor 多线程模式(主从 Reactor 模式)

  • 结构:多个 Reactor 分工协作,通常包含 1 个 "主 Reactor" 和多个 "从 Reactor",配合线程池处理业务。
    • 主 Reactor(Main Reactor) :仅负责监听 ACCEPT 事件(新连接建立),将建立好的 socket 分发给某个从 Reactor。
    • 从 Reactor(Sub Reactor) :每个从 Reactor 管理一部分 socket 的 I/O 事件(如 READ/WRITE),并将业务逻辑交给线程池处理。
  • 流程:
    • 主 Reactor 监听服务器监听 socket 的 ACCEPT 事件,收到新连接后,通过 Acceptor 创建 socket,并将其注册到某个从 Reactor 中。
    • 从 Reactor 监听已注册 socket 的 READ 事件,数据读取后提交线程池处理,处理完成后由从 Reactor 负责将响应写回客户端。
  • 优点 :主从 Reactor 分离连接监听和 I/O 处理,支持更高并发,充分利用多核,是高性能服务器的主流选择(如 Netty 的 NioEventLoopGroup 模型、Redis 的部分模式)。

3.3.4 Reactor 模式的优势与适用场景

  • 优势
    • 高效处理多并发 I/O:通过多路复用减少阻塞,避免为每个连接创建单独线程(减少线程上下文切换开销)。
    • 扩展性好:可通过增加 Reactor 数量和线程池大小适配更高并发。
    • 事件驱动机制灵活:便于应对复杂的 I/O 场景(如长连接、异步通信)。
  • 适用场景
    • 高并发网络服务器(如 Web 服务器、API 网关)。
    • 实时通信系统(如即时通讯、物联网数据采集)。
    • 中间件(如消息队列 Kafka、RPC 框架 Dubbo)。

3.3.5 典型实现案例

  • Netty :Java 领域最流行的 NIO 框架,基于主从 Reactor 模式实现,通过 EventLoopGroup(主从 Reactor 线程池)和 ChannelPipeline(事件处理器链)高效处理网络事件。
  • Nginx :高性能 Web 服务器,核心采用单 Reactor 多进程(或多线程)模式,通过 epoll 多路复用处理大量连接,实现高并发低延迟。
  • Redis:单线程 Reactor 模式(I/O 事件处理单线程,业务逻辑也在该线程),结合非阻塞 I/O 和高效数据结构,实现高性能内存数据库。

Reactor 模式是网络编程中处理并发 I/O 的经典思想,其核心是通过事件驱动和多路复用最大化系统资源利用率,是构建高性能网络服务的基础

3.4 伪代码示例

cpp 复制代码
#include <iostream>
#include <vector>
#include <unordered_map>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
#include <fcntl.h>

// 事件处理器基类
class Handler {
public:
    virtual ~Handler() = default;
    virtual void handleEvent(uint32_t events) = 0;
    virtual int getFd() const = 0;
};

// Reactor核心类
class Reactor {
private:
    int epollFd_;
    std::unordered_map<int, Handler*> handlers_;
    static const int MAX_EVENTS = 1024;

public:
    Reactor() {
        epollFd_ = epoll_create1(0);
        if (epollFd_ == -1) {
            perror("epoll_create1 failed");
            exit(EXIT_FAILURE);
        }
    }

    ~Reactor() {
        close(epollFd_);
    }

    // 注册事件处理器
    void registerHandler(Handler* handler, uint32_t events) {
        int fd = handler->getFd();
        handlers_[fd] = handler;

        epoll_event event;
        event.data.fd = fd;
        event.events = events;

        if (epoll_ctl(epollFd_, EPOLL_CTL_ADD, fd, &event) == -1) {
            perror("epoll_ctl add failed");
            exit(EXIT_FAILURE);
        }
    }

    // 启动事件循环
    void run() {
        epoll_event events[MAX_EVENTS];
        
        while (true) {
            // 阻塞等待事件就绪
            int nfds = epoll_wait(epollFd_, events, MAX_EVENTS, -1);
            if (nfds == -1) {
                perror("epoll_wait failed");
                exit(EXIT_FAILURE);
            }

            // 处理就绪事件
            for (int i = 0; i < nfds; ++i) {
                int fd = events[i].data.fd;
                auto it = handlers_.find(fd);
                
                if (it != handlers_.end()) {
                    it->second->handleEvent(events[i].events);
                }
            }
        }
    }
};

// 连接处理器:处理客户端通信
class ConnectionHandler : public Handler {
private:
    int clientFd_;

public:
    ConnectionHandler(int fd) : clientFd_(fd) {
        // 设置为非阻塞模式
        int flags = fcntl(clientFd_, F_GETFL, 0);
        fcntl(clientFd_, F_SETFL, flags | O_NONBLOCK);
    }

    ~ConnectionHandler() override {
        close(clientFd_);
        std::cout << "Client disconnected" << std::endl;
    }

    void handleEvent(uint32_t events) override {
        if (events & EPOLLIN) {
            char buffer[1024];
            ssize_t n = read(clientFd_, buffer, sizeof(buffer) - 1);
            
            if (n <= 0) {
                // 连接关闭或出错,销毁处理器
                delete this;
                return;
            }

            buffer[n] = '\0';
            std::cout << "Received: " << buffer << std::endl;

            // 简单回显
            std::string response = "Echo: " + std::string(buffer);
            write(clientFd_, response.c_str(), response.size());
        }
    }

    int getFd() const override {
        return clientFd_;
    }
};

// 监听处理器:处理新连接
class ListenHandler : public Handler {
private:
    int listenFd_;
    Reactor& reactor_;

public:
    ListenHandler(int fd, Reactor& reactor) : listenFd_(fd), reactor_(reactor) {}

    ~ListenHandler() override {
        close(listenFd_);
    }

    void handleEvent(uint32_t events) override {
        if (events & EPOLLIN) {
            struct sockaddr_in clientAddr;
            socklen_t clientLen = sizeof(clientAddr);
            
            int clientFd = accept(listenFd_, (struct sockaddr*)&clientAddr, &clientLen);
            if (clientFd == -1) {
                perror("accept failed");
                return;
            }

            std::cout << "New connection from " << inet_ntoa(clientAddr.sin_addr) 
                      << ":" << ntohs(clientAddr.sin_port) << std::endl;

            // 注册新连接的处理器
            Handler* connHandler = new ConnectionHandler(clientFd);
            reactor_.registerHandler(connHandler, EPOLLIN | EPOLLET);
        }
    }

    int getFd() const override {
        return listenFd_;
    }
};

// 创建监听套接字
int createListenSocket(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置端口复用
    int opt = 1;
    if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
        perror("setsockopt failed");
        exit(EXIT_FAILURE);
    }

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);

    if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) == -1) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    if (listen(fd, 10) == -1) {
        perror("listen failed");
        exit(EXIT_FAILURE);
    }

    return fd;
}

int main() {
    std::cout << "Reactor server starting on port 8080..." << std::endl;

    // 创建Reactor实例
    Reactor reactor;

    // 创建监听套接字并注册
    int listenFd = createListenSocket(8080);
    Handler* listenHandler = new ListenHandler(listenFd, reactor);
    reactor.registerHandler(listenHandler, EPOLLIN);

    // 启动事件循环
    reactor.run();

    return 0;
}
  1. 事件注册:所有需要监控的 I/O 句柄(如套接字)及其对应事件(读 / 写 / 连接)都注册到 Reactor 中
  2. 多路复用等待demultiplexer.wait() 是核心,底层调用 select/epoll 等系统调用,阻塞等待任何注册的事件就绪
  3. 事件分发:当有事件就绪(如客户端发送数据),Reactor 会找到对应的处理器(Handler)
  4. 业务处理:处理器执行具体业务逻辑(如读取数据、处理并响应)

这种模式的关键在于:通过 I/O 多路复用实现 "用一个线程监控多个 I/O 句柄",避免了传统阻塞 I/O 中 "一个连接一个线程" 的资源浪费,从而高效支持高并发场景。

四、比较Reactor和Proactor的区别

4.1 训练营

Proactor 是一种基于异步 I/O 模型的设计模式,主要用于高效处理并发 I/O 操作。它与另一种常见的异步模式 Reactor 共同构成了异步编程的两大核心范式,其核心思想是 "操作系统完成 I/O 操作后,再通知应用程序处理结果",而在Reactor模型中,内核仅通知 "数据就绪",应用程序需主动发起 I/O 调用(如 recv)完成数据拷贝。

Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。

Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。

Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。

Proactor 模式的工作流程如下:

  • Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
  • Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
  • Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
  • Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
  • Handler 完成业务处理;

4.2 豆包说

Proactor 是另一种高性能的事件驱动设计模式 ,与 Reactor 模式并列,同为处理高并发 I/O 场景的重要方案。它的核心特点是将 I/O 操作的执行过程(如读写数据)委托给内核完成 ,应用程序仅在 I/O 操作完全完成后才被通知并处理结果。

4.2.1 Proactor(前摄器) 模式的核心思想

  • "主动" 完成 I/O 操作:内核不仅负责检测 I/O 事件是否就绪,还会主动完成数据的读写操作(从内核缓冲区复制到用户缓冲区,或反之)。
  • 操作完成时通知:应用程序在发起 I/O 操作后即可返回,当内核完成所有 I/O 工作(如数据已读取到用户缓冲区),再通过事件通知应用程序进行后续处理。

4.2.2 关键组件

  1. Proactor 核心:负责管理 I/O 操作的注册、调度和结果分发。
  2. 完成事件队列:内核完成 I/O 操作后,会生成 "完成事件" 并放入队列,供 Proactor 处理。
  3. 完成处理器(Completion Handler):定义 I/O 操作完成后的业务逻辑(如处理读取到的数据)。
  4. 异步 I/O 接口 :依赖操作系统提供的异步 I/O 支持(如 Windows 的 IOCP、Linux 的 io_uring、Solaris 的 aio 等)。

4.2.3 工作流程(以 "读取数据" 为例)

  1. 应用程序通过异步接口(如 aio_read)发起读操作,指定数据缓冲区、读取长度和对应的完成处理器,并立即返回(不阻塞)。
  2. 内核负责等待数据就绪,然后将数据从内核缓冲区复制到用户指定的缓冲区(完成实际 I/O 操作)。
  3. 当读操作完全完成后,内核生成一个 "完成事件",包含读取结果(如字节数、错误码)。
  4. Proactor 从完成事件队列中取出事件,调用对应的完成处理器处理数据(如解析、业务逻辑)。

4.2.4 与 Reactor 模式的核心区别

维度 Reactor 模式 Proactor 模式
I/O 操作执行者 应用程序主动完成(内核仅通知 "就绪") 内核主动完成(从检测到数据复制全流程)
通知时机 I/O 事件 "就绪时"(如数据到达内核缓冲区) I/O 操作 "完全完成时"(如数据已到用户缓冲区)
应用程序角色 主动发起读写操作(在事件就绪后) 被动处理结果(I/O 由内核完成后)
依赖 依赖 I/O 多路复用(如 epoll、select) 依赖操作系统异步 I/O 支持(如 IOCP、io_uring)

4.2.5 适用场景与优缺点

  • 优点
    • 应用程序无需关心 I/O 操作的具体过程(如何时读、如何读),减少了用户态与内核态的交互。
    • 适合 I/O 操作耗时较长的场景(如大文件读写),内核可高效完成数据复制。
  • 缺点
    • 依赖操作系统对异步 I/O 的支持,不同平台实现差异大(如 Linux 早期缺乏高效异步 I/O,直到 io_uring 才完善)。
    • 对内存管理要求更高(需确保 I/O 操作期间缓冲区有效)。
  • 典型应用
    • Windows 平台的高性能服务器(基于 IOCP)。
    • 现代 Linux 系统(基于 io_uring)的高并发应用。
    • 某些数据库系统(如 PostgreSQL)的异步 I/O 模块。

4.2.6 总结

Proactor 模式通过将 I/O 操作的执行权完全交给内核,进一步简化了应用程序的逻辑,尤其在支持高效异步 I/O 的操作系统上,能发挥出优异的性能。但由于其对底层系统的强依赖,跨平台实现难度高于 Reactor 模式,因此在实际应用中,需根据具体平台和场景选择合适的模式。

五、介绍下网络socket是什么

5.1 训练营说

在网络编程中,Socket(套接字) 是实现不同主机(或同一主机)上进程间网络通信的核心技术,它本质上是操作系统提供的一套应用程序编程接口(API),封装了底层网络协议(如 TCP、UDP)的复杂细节,让开发者能更简单地实现数据的发送与接收。

Socket 可以理解为 "网络通信的端点":

  • 每个网络通信连接(如浏览器访问服务器、即时通讯消息发送)都需要两个端点,分别位于通信的两端(客户端和服务器)。
  • 每个端点由 "IP 地址 + 端口号" 唯一标识(类似 "家庭地址 + 房间号",IP 定位主机,端口定位主机上的具体进程)。
  • 操作系统通过 Socket 接口,让应用程序能通过这两个端点发送和接收数据,无需关心底层物理层、数据链路层、传输层的具体实现(如信号转换、差错控制、拥塞控制等)。

5.2 豆包说

网络中的 socket(套接字) 是计算机之间进行网络通信的一种抽象接口,它是操作系统提供的一种机制,让不同设备上的应用程序能够通过网络交换数据。

简单来说,socket 就像两个设备之间的 "通信端点":

  • 当一个应用程序需要与另一个设备的应用程序通信时,会创建一个 socket
  • 这个 socket 包含了目标设备的 IP 地址和端口号(用于标识具体应用程序)
  • 数据通过 socket 发送到网络,经过路由到达目标设备的对应 socket,再传递给目标应用程序

Socket 是 TCP/IP 等网络协议的编程接口,开发者不需要直接处理复杂的底层协议细节,只需通过 socket 提供的函数(如创建连接、发送数据、接收数据等)就能实现网络通信。

它支持多种通信方式:

  • 面向连接的 TCP 协议:通信前先建立连接(三次握手),确保数据可靠传输
  • 无连接的 UDP 协议:不建立连接直接发送数据,速度快但可能丢失

无论是网页浏览、即时通讯、文件传输等网络功能,底层都依赖 socket 实现应用程序之间的数据交换。

六、简述下使用TCP协议跨进程通信的主要流程

6.1 训练营说

服务器端:

  1. 使用socket()创建一个TCP类型的socket;
  2. 使用bind()将 Socket 与 "服务器 IP + 端口号" 绑定,确保客户端能通过该地址找到服务器;
  3. 使用listen()将 Socket 转为 "监听模式",允许接收客户端的连接请求;
  4. 使用accept()阻塞等待客户端的连接请求,一旦有连接,返回一个新的 Socket 描述符(专门用于与该客户端通信),原 Socket 继续监听其他请求;
  5. 使用recv/send 通过新的socket与客户端进行数据交互
  6. 通信结束后,使用close()关闭socket

客户端:

  1. 使用socket()创建一个TCP类型的socket;
  2. 使用connect()向服务器的 IP 和端口发起连接请求(触发 TCP 三次握手);
  3. 使用send/recv与服务器端交互数据
  4. 通信结束后,使用close()关闭socket

整个过程基于 TCP 的面向连接特性,确保数据传输的可靠性、有序性和完整性,适用于对数据准确性要求高的场景(如文件传输、HTTP 通信等)。

6.2 豆包说

使用 TCP 协议进行跨进程通信(可在同一台机器或不同机器上)的主要流程如下:

  1. 服务器端初始化
    • 创建 Socket(套接字)对象
    • 绑定(bind)到指定 IP 地址和端口号
    • 监听(listen)连接请求,设置最大等待队列
  2. 客户端发起连接
    • 创建 Socket 对象
    • 调用连接(connect)方法,指定服务器的 IP 和端口
    • 等待服务器响应连接请求
  3. 建立连接
    • 服务器通过 accept () 方法接收客户端连接,生成新的 Socket 专门处理该客户端
    • 完成 TCP 三次握手,建立可靠连接
  4. 数据传输
    • 客户端 / 服务器通过 send ()/write () 发送数据
    • 对方通过 recv ()/read () 接收数据
    • 数据按顺序、可靠地传输
  5. 断开连接
    • 一方调用 close () 发起断开请求
    • 完成 TCP 四次挥手,释放连接资源
    • 另一方检测到连接关闭,也关闭相应 Socket

七、简述下使用UDP协议跨进程通信的主要流程

7.1 训练营说

UDP 通信中没有严格的 "服务器" 和 "客户端" 之分,通常称接收方(需绑定端口等待数据)和发送方(主动发送数据)。

接收方:

  1. 使用socket()创建一个 UDP 类型的 Socket;
  2. 使用bind()将 Socket 与 "服务器 IP + 端口号" 绑定,确保客户端能通过该地址找到服务器;
  3. 使用recvfrom()阻塞等待数据报,同时获取发送方的地址信息;
  4. (可选)如果需要回复对方,使用sendto()并指定发送方地址。
  5. 通信结束后,使用close()关闭socket

发送方:

  1. 使用socket()创建一个 UDP 类型的 Socket;
  2. 使用sendto()直接向接收方的 IP 和端口发送数据,无需建立连接。
  3. 通信结束后,使用close()关闭socket

八、TCP三次握手的过程介绍,以及为什么不可以是两次握手?

8.1 TCP 三次握手的过程

TCP(传输控制协议)通过 "三次握手" 建立可靠连接,具体过程如下:

  1. 第一次握手(客户端 → 服务器)

    客户端发送一个带有 SYN(同步序列编号)标志的数据包,请求建立连接。

    例如:客户端发送 SYN = 1,seq = x(x 是客户端初始序列号)。

  2. 第二次握手(服务器 → 客户端)

    服务器收到请求后,确认可以连接,回复一个带有 SYN + ACK(同步 + 确认)标志的数据包:

    • ACK = 1(表示确认收到客户端的请求)
    • ack = x + 1(确认号,期望客户端下一个数据包的序列号)
    • SYN = 1,seq = y(服务器自身的初始序列号 y)。
  3. 第三次握手(客户端 → 服务器)

    客户端收到服务器的回复后,再发送一个 ACK 标志的数据包,确认已收到服务器的同步请求:

    • ACK = 1

    • ack = y + 1(期望服务器下一个数据包的序列号)。

      服务器收到此确认后,连接正式建立。

      复制代码
      +----------------+                +----------------+
      |    客户端       |                |    服务器       |
      +----------------+                +----------------+
              |                                 |
              |  SYN=1, seq=x (第一次握手)        |
              |-------------------------------->|
              |                                 |
              |  SYN=1, ACK=1, seq=y, ack=x+1   |
              |<--------------------------------|
              |       (第二次握手)                |
              |                                 |
              |  ACK=1, ack=y+1 (第三次握手)      |
              |-------------------------------->|
              |                                 |
              |         连接建立,开始传输数据       |
              |<-------------------------------->|

8.2 为什么不能是两次握手?

两次握手无法确保连接的可靠性,主要原因如下:

  1. 无法处理 "过期的连接请求"
    若客户端的第一个 SYN 数据包因网络延迟滞留,客户端会超时重发新的 SYN 并完成正常连接。但延迟的旧 SYN 可能后续到达服务器,服务器若仅通过两次握手(收到 SYN 后直接建立连接),会错误地为旧请求创建无效连接,浪费服务器资源。
    三次握手的第三次确认,能让服务器确认客户端已准备好,避免此类无效连接。
  2. 无法确保双方收发能力正常
    两次握手仅能确认 "客户端能发、服务器能收" 以及 "服务器能发",但无法确认 "客户端能收服务器的数据包"。第三次握手的 ACK 正是客户端向服务器证明 "自己能接收" 的关键,确保双方收发通道均正常。

因此,三次握手是 TCP 实现可靠连接的必要设计,通过多一次确认机制,避免资源浪费和连接状态不一致的问题。

8.3 标志的介绍

  • SYN:连接建立时的 "请求 / 同意" 标志,用来同步双方的起始序号(三次握手前两次会用到)。
  • seq:序列号,标记发送数据的第一个字节位置,保证数据按顺序传输。
  • ACK:确认标志,为 1 时表示报文里有确认信息。
  • ack:确认号,告诉对方 "我已收到到这个序号前的所有数据,下次请从这里开始发"(只有 ACK=1 时有效)。

简单说,SYN 负责建连接,seq 标顺序,ACK 和 ack 配合确认收到的数据。

九、TCP四次挥手的过程介绍,以及TIME_WAIT为什么至少设置两倍的MSL时间?

9.1 TCP的四次挥手

TCP 四次挥手是连接双方终止 TCP 连接的过程,由于 TCP 是全双工通信(双方可独立发送数据),关闭连接需双方分别确认,具体步骤如下:

  1. 第一次挥手(客户端 → 服务器)

客户端完成数据发送后,发送一个带有 FIN(结束标志)的数据包,请求关闭连接:

  • FIN = 1,seq = u(u 是客户端当前序列号)。

此时客户端进入 FIN-WAIT-1 状态。

  1. 第二次挥手(服务器 → 客户端)

服务器收到客户端的关闭请求后,回复一个带有 ACK(确认标志)的数据包:

  • ACK = 1(表示确认收到客户端的 FIN 请求)

  • ack = u + 1(期望客户端下一个数据包的序列号)

  • seq = v(服务器当前序列号 v)。

此时服务器进入 CLOSE-WAIT 状态,客户端收到后进入 FIN-WAIT-2 状态。

  1. 第三次挥手(服务器 → 客户端)

服务器完成所有数据发送后,发送一个带有 FIN 标志的数据包:

  • FIN = 1,seq = w(w 是服务器当前序列号)。

此时服务器进入 LAST-ACK 状态。

  1. 第四次挥手(客户端 → 服务器)

客户端收到服务器的 FIN 后,回复一个带有 ACK 标志的数据包:

  • ACK = 1

  • ack = w + 1(期望服务器下一个数据包的序列号)

  • seq = u + 1(客户端当前序列号)。

此时客户端进入 TIME-WAIT 状态,服务器收到后进入 CLOSED 状态,客户端等待一段时间后也进入 CLOSED 状态。

cpp 复制代码
+----------------+                +----------------+
|    客户端       |                |    服务器       |
+----------------+                +----------------+
        |                                 |
        |  FIN=1, seq=u                   |
        |  (第一次挥手:客户端请求关闭)        |
        |-------------------------------->|
        |  进入FIN-WAIT-1状态              |  接收请求
        |                                 |
        |                                 |  进入CLOSE-WAIT状态
        |  ACK=1, seq=v, ack=u+1          |
        |  (第二次挥手:服务器确认关闭请求)     |
        |<--------------------------------|
        |  进入FIN-WAIT-2状态               |  处理剩余数据
        |                                 |
        |                                 |  数据传输完毕
        |                                 |  准备关闭
        |  FIN=1, seq=w, ack=u+1          |
        |  (第三次挥手:服务器请求关闭)        |
        |<--------------------------------|
        |                                 |  进入LAST-ACK状态
        |  接收服务器关闭请求                 |
        |                                 |
        |  ACK=1, seq=u+1, ack=w+1        |
        |  (第四次挥手:客户端确认关闭)        |
        |-------------------------------->|
        |  进入TIME-WAIT状态                |  接收确认
        |  (等待2倍MSL)                     |  进入CLOSED状态
        |                                  |
        |  等待结束                          |
        |  进入CLOSED状态                    |
        |                                  |
        |         连接彻底关闭               |
        |<-------------------------------->|

客户端状态:

  1. FIN-WAIT-1
    客户端发送 FIN 报文(第一次挥手)后进入的状态,等待服务器的 ACK 确认。此时客户端已停止发送数据,但仍可接收服务器数据。
  2. FIN-WAIT-2
    客户端收到服务器对 FIN 的 ACK(第二次挥手)后进入的状态,等待服务器发送自己的 FIN 报文。此时双方已确认客户端的关闭请求,仅等待服务器完成数据传输。
  3. TIME-WAIT
    客户端发送最后一个 ACK(第四次挥手)后进入的状态,需等待 2 倍 MSL(最大报文段生存时间)。作用是确保服务器能收到最终确认,避免残留报文干扰新连接。
  4. CLOSED
    TIME-WAIT 等待结束后进入的最终状态,客户端彻底关闭连接。

服务器状态:

  1. CLOSE-WAIT
    服务器收到客户端 FIN(第一次挥手)并回复 ACK(第二次挥手)后进入的状态,此时服务器可继续发送剩余数据,直到准备好关闭。
  2. LAST-ACK
    服务器发送自己的 FIN 报文(第三次挥手)后进入的状态,等待客户端的最终 ACK 确认。
  3. CLOSED
    服务器收到客户端最后一个 ACK(第四次挥手)后进入的最终状态,服务器彻底关闭连接。

这些状态的转换确保了 TCP 连接关闭过程的可靠性,避免因报文丢失或延迟导致的连接异常。

9.2 TIME_WAIT 为什么至少设置两倍的 MSL 时间?

  • MSL(Maximum Segment Lifetime):指一个 TCP 报文段在网络中最大的存活时间(通常为 30 秒或 1 分钟)。
  • TIME-WAIT :是客户端在第四次挥手后进入的状态,目的是确保连接正常终止,其时长至少为2 倍 MSL,原因如下:
  1. 确保最后一个 ACK 报文被服务端接收
    客户端发送的第四次挥手 ACK 可能丢失,服务端未收到会重发 FIN。客户端在 2 倍 MSL 内保持 TIME-WAIT,可接收并重发 ACK,避免服务端因未收到 ACK 而一直处于 LAST-ACK 状态。
  2. 确保网络中残留的报文段失效
    连接关闭前的报文段可能因网络延迟仍在传输,2 倍 MSL 可保证这些残留报文段在客户端关闭连接前自然失效,避免其干扰新连接(若新连接使用相同的端口对)。

通过设置 2 倍 MSL,TCP 可最大程度避免连接终止时的报文丢失或残留报文干扰,保证连接关闭的可靠性。

十、简述TCP可靠传输的实现

10.1 训练营

靠以字节为单位的滑动窗口实现的。

现假定A发送,B接收。根据B发来的确认报文段的窗口20和确认号31,A构造出自己的发送窗口,后沿是31,前沿是50。在没有收到B新地确认前,A可以将发送窗口中的数据连续发送出去。这里发送窗口的大小,受网络拥塞程度的制约,但是一定不能大于B的确认报文的窗口。

这时,B的接收窗口大小是20,当B收不连续的分组后比如收到32、33,由于没有按需到达,会将这些分组存储到接收窗口中,当B收到31后,就会向A发送确认,A收到B的确认后(如果滑动窗口大小没变),就可以把发送窗口向前滑动3个序号。

通过这种机制,能保证发送方发送窗口的内容,接收方一定都能收到。

10.2 豆包

TCP(传输控制协议)的可靠传输是通过一系列机制确保数据在不可靠的网络中准确、完整地送达,核心目标是解决丢包、乱序、重复等问题。其实现机制主要包括以下几个方面:

  1. 序号与确认机制
  • 序号:TCP 为每个字节的数据分配唯一序号,确保接收方能够识别数据的顺序和完整性。例如,发送方发送一段数据时,会标记首个字节的序号,后续字节序号依次递增。
  • 确认(ACK):接收方收到数据后,会向发送方返回 "确认报文",其中包含 "期望接收的下一字节序号"(即已正确接收数据的末尾序号 + 1)。发送方通过确认报文判断数据是否已被接收。
  1. 超时重传
  • 发送方在发送数据后,会启动一个 "超时计时器"。若在计时器超时前未收到对应数据的确认报文,会认为数据已丢失,并重传该数据。
  • 超时时间的设置需动态调整(基于网络往返时间 RTT),避免因网络延迟导致误判重传。
  1. 流量控制
  • 接收方通过 "接收窗口"(TCP 报文头部的 "窗口大小" 字段)告知发送方自己的缓存空间剩余量,防止发送方发送数据过快导致接收方缓存溢出。
  • 发送方发送的数据量不得超过接收窗口的大小,若接收窗口为 0,发送方会暂停发送,直至接收方更新窗口大小。
  1. 拥塞控制
  • 当网络出现拥塞(如路由器缓存满、数据丢失)时,发送方需减少数据发送速率,避免加剧拥塞。TCP 通过以下算法实现拥塞控制:
    • 慢启动:初始发送时,发送窗口(拥塞窗口)从小到大指数增长,逐步试探网络承载能力。
    • 拥塞避免:当拥塞窗口达到阈值后,改为线性增长,避免突然增加流量。
    • 快速重传与快速恢复:若接收方连续收到 3 个重复的确认报文(暗示数据丢失),发送方会立即重传丢失数据,而非等待超时,并快速调整拥塞窗口,减少对网络的影响。
  1. 数据校验与重排
  • TCP 报文头部包含 "校验和" 字段,接收方通过校验和验证数据在传输过程中是否被篡改或损坏,若校验失败则丢弃数据(触发重传)。
  • 由于网络延迟可能导致数据乱序到达,接收方会根据序号对数据进行重排,确保按原始顺序交付给应用层。

通过以上机制的协同作用,TCP 能够在不可靠的网络环境中实现可靠传输,为 HTTP、FTP 等需要数据准确性的应用提供了基础保障。

十一、简述TCP流量控制的方法

11.1 训练营

发送方维持一个叫做拥塞窗口,并让自己的发送窗口等于拥塞窗口。

  • 由从小到大逐渐增大发送窗口,及增大拥塞窗口数值。先把初始拥塞窗口cwnd设置为1至2个发送方的最大报文段SMSS的数值。在每收到一个对新的报文段的确认后,可以把拥塞窗口增加最多一个SMSS的数值,可见拥塞窗口是成倍增加的。为了防止拥塞窗口过大,还需要设置一个慢开始门限ssthresh。如果拥塞窗口小于慢开始门限,使用上诉的慢开始算法;如果大于,使用拥塞避免算法;如果等于,两个算法都可以。
  • 拥塞避免算法:让拥塞窗口cwnd缓慢地增大,及每经过一个往返时间RTT就把发送方的拥塞窗口加1
  • 当网络中出现超时,发送方判断那为网络拥塞,于是调整门限值ssthresh = cwnd / 2, 同时设置拥塞窗口 cwnd = 1, 进入慢开始阶段。等到拥塞窗口增加到ssthresh时,又开始拥塞避免算法。
  • 采用快重传算法可以让发送方尽早直到发生了个别报文段的丢失。快重传算法首先要求接收方不要等待自己发送数据时才进行捎带确认,而是要立即发送确认,即使收到了失序的报文段也要立即发送对已收到的报文段的重复确认。发送方只要一连收到3个重复确认,就知道接收方没有收到某报文段,因而立即进行重传(只针对某一个报文段),这样就不会出现超时。
  • 发送方知道现在只是丢失了个别的报文段,于是不启动慢开始,而是执行快恢复算法。这时,发送方调整门限值 ssthresh = cwnd / 2,同时设置拥塞窗口为ssthresh,并开始执行拥塞避免算法。

11.2 豆包

TCP 的流量控制用于防止发送方发送数据的速率过快,导致接收方因缓存空间不足而无法处理,最终造成数据丢失。其核心方法是基于 "接收窗口" 的动态调整,具体实现如下:

  1. 接收窗口(Receive Window)机制
  • 窗口大小字段:TCP 报文头部包含 "窗口大小" 字段,接收方通过该字段告知发送方自己当前的接收缓存剩余容量(单位:字节)。例如,若接收方缓存还能容纳 1000 字节数据,窗口大小就设为 1000。
  • 发送方限制:发送方发送的数据量不得超过接收窗口的大小,确保接收方有足够空间缓存数据。若接收窗口为 0,发送方会暂停发送,直至接收方更新窗口大小。
  1. 窗口滑动与动态调整
  • 接收方处理完缓存中的数据后,会通过确认报文(ACK)更新窗口大小(增大窗口),允许发送方继续发送更多数据。
  • 随着数据的传输和接收,接收窗口会动态变化:当接收方缓存被占满时窗口缩小,当缓存释放后窗口扩大,形成 "滑动窗口" 的动态平衡。
  1. 持续计时器(Persistent Timer)
  • 若接收方发送的窗口大小为 0,发送方会启动持续计时器。当计时器超时后,发送方会发送一个 "窗口探查报文"(携带 1 字节数据),询问接收方是否已更新窗口。
  • 接收方收到探查报文后,会返回最新的窗口大小,避免因双方信息不同步导致数据传输长期停滞。

通过以上方法,TCP 能动态匹配发送方与接收方的处理能力,确保数据传输速率与接收方的缓存容量相适应,从而实现流量控制。

十二、什么是连接的半打开,半关闭状态?

12.1 训练营

模拟回答:

半连接:半连接发生在 TCP 三次握手中。如果 A 向 B 发起链接,B 也按照正常情况响应了,但是 A 不进行三次握手,这就是半连接。

半连接攻击:半连接,会造成 B 分配的内存资源就一直这么耗着,直到资源耗尽。(也被称为 SYN攻击)

半关闭:TCP提供了连接的一端在结束它的发送后还能接收来自另一端数据的能力,这就是TCP的半关闭。(拆除TCP连接是:你关闭你的发送通道,我关闭我的发送通道)。 半关闭的产生 1. 客户端发送FIN,另一端发送对这个FIN的ACK报文段。 此时客户端就处于半关闭。

它们都是TCP状态机变迁的正常的短暂的中间状态。如果卡在了这两个状态里,可以通过设置适当的超时时间解决。

12.2 豆包

在 TCP 连接管理中,"半打开" 和 "半关闭" 是两种特殊的连接状态,分别对应连接在异常或正常关闭过程中的不同阶段:

  1. 半打开状态(Half-Open)
  • 定义:指 TCP 连接的一端已经崩溃(或主动关闭后未通知对方),但另一端仍认为连接有效,继续发送数据的状态。
  • 产生原因:
    • 一方因网络故障、主机崩溃等异常情况断开连接,未发送 FIN 报文(终止连接请求)。
    • 另一方未检测到连接异常,仍维持连接状态并尝试发送数据。
  • 处理方式:
    • 当存活方发送数据时,若对方已崩溃,会收到 RST(重置)报文,此时存活方会意识到连接已失效,关闭连接。
    • 若未收到响应,存活方会通过超时重传机制多次尝试,最终判定连接失败并关闭。
  1. 半关闭状态(Half-Closed)
  • 定义:指 TCP 连接中,一方已完成数据发送并发送 FIN 报文,进入 "关闭等待" 状态,而另一方仍可继续发送数据的状态(即连接单向关闭)。
  • 产生原因:
    • TCP 采用 "四次挥手" 关闭连接:一方(如客户端)发送 FIN 报文,表明自己不再发送数据,进入 FIN-WAIT-1 状态。
    • 另一方(如服务器)收到 FIN 后返回 ACK,进入 CLOSE-WAIT 状态(此时服务器仍可向客户端发送数据),客户端则进入 FIN-WAIT-2 状态。此时连接处于 "半关闭":客户端→服务器的方向已关闭,服务器→客户端的方向仍可通信。
  • 结束方式:服务器发送完所有数据后,主动发送 FIN 报文,客户端返回 ACK,双方最终关闭连接,完成 "四次挥手"。

总结:半打开是异常状态(一方意外断开),半关闭是正常关闭过程中的中间状态(允许单向继续通信)。

十三、什么是长连接、短链接

在网络通信中,长连接(Persistent Connection)短连接(Non-persistent Connection) 是根据连接的存续时间和复用方式划分的两种连接模式,广泛应用于 TCP 等面向连接的协议中。

13.1 短连接(Non-persistent Connection)

  • 定义:通信双方完成一次数据交互后,立即断开连接的模式。即 "建立连接→传输数据→断开连接" 为一个完整周期,每次数据传输都需要重新建立和关闭连接。
  • 工作流程:
    1. 客户端向服务器发起连接请求(如 TCP 三次握手)。
    2. 连接建立后,双方传输数据(如一次 HTTP 请求和响应)。
    3. 数据传输完成后,立即断开连接(如 TCP 四次挥手)。
  • 特点:
    • 连接生命周期短,仅为单次数据交互服务。
    • 每次通信需重新建立连接,会产生额外的握手 / 挥手开销。
    • 资源占用动态变化,空闲时无连接占用服务器资源。
  • 适用场景:
    • 数据交互频率低、单次交互数据量小的场景,如普通 HTTP 网页浏览(早期 HTTP/1.0 默认短连接)、简单查询请求等。

13.2 长连接(Persistent Connection)

  • 定义:通信双方建立连接后,不会立即断开,而是保持连接状态,用于多次数据交互,直到超时、出错或主动关闭。
  • 工作流程:
    1. 客户端与服务器建立连接(如 TCP 三次握手)。
    2. 一次数据传输完成后,连接不关闭,等待后续数据交互(如多次 HTTP 请求复用同一连接)。
    3. 当长时间无数据传输(超过超时阈值),或双方主动发起关闭请求时,连接断开。
  • 特点:
    • 连接可复用,减少重复建立 / 关闭连接的开销(尤其适合高频交互)。
    • 需维持连接状态,可能占用服务器资源(需合理设置超时时间)。
  • 适用场景:
    • 高频数据交互场景,如即时通讯(IM)、在线游戏、实时监控、HTTP/1.1 及以上默认的长连接、数据库连接池等。

核心区别对比

维度 短连接 长连接
连接生命周期 单次交互后断开 多次交互后(超时 / 主动)断开
连接开销 每次交互需握手 / 挥手,开销高 一次握手后复用,开销低
资源占用 动态释放,空闲时占用少 长期占用连接资源
适用频率 低频率交互 高频率交互

选择长连接还是短连接,主要取决于通信的频率、数据量及对实时性的要求:高频交互优先用长连接减少开销,低频交互用短连接避免资源浪费。

十四、简述下Epoll是什么

14.1 训练营

Epoll 是 Linux 内核提供的一种高效 I/O 多路 iplexing(I/O 多路复用)机制,专为处理大量并发连接场景设计,广泛应用于高性能网络服务器(如 Nginx、Redis、Node.js 等)。它解决了传统 select/poll 在高并发下的性能瓶颈,支持高效管理成千上万个文件描述符(FD)。

I/O 多路复用的核心是同时阻塞地监控多个文件描述符(如网络套接字、管道等)的 I/O 事件(可读、可写、异常等),当某个 FD 就绪时通知应用程序处理。Epoll 相比 select/poll 的优势在于:

  • 支持大规模并发连接(无 FD 数量限制,仅受系统内存限制);
  • 事件通知方式更高效(避免轮询遍历所有 FD);
  • 低延迟、低 CPU 开销,适合高吞吐场景。

工作流程:

  1. 创建 epoll 实例:使用epoll_create()创建创建一个 epoll 实例(内核事件表);
  2. 添加 / 修改 / 删除监控事件:使用epoll_ctl()操作 epoll 实例添加 / 修改 / 删除被监控的 FD 及其事件;
  3. 等待事件就绪:使用epoll_wait()等待就绪事件,返回就绪的 FD。
  4. 处理事件:应用程序根据 epoll_wait() 返回的结果,对就绪的 FD 进行 I/O 操作(如读 / 写数据)。

工作原理:

Epoll 内核维护两个关键数据结构:

  • 红黑树(RB-Tree):存储所有被监控的 FD 及其事件(通过 epoll_ctl 添加 / 修改 / 删除),支持高效查找、插入、删除操作。
  • 就绪链表(Ready List):存储就绪的 FD(满足监控条件的事件),应用程序通过 epoll_wait 直接获取,无需遍历全部 FD。

关键优势:

  • 事件驱动:仅关注发生了 I/O 事件的 FD,而非遍历所有监控的 FD,避免无意义的轮询开销。
  • 高效数据结构:内核通过红黑树存储监控的 FD 集合,通过就绪链表存储就绪的 FD,使得添加、删除、查询 FD 的操作复杂度为 O (log n)。
  • 支持海量连接:不受 select 中 FD_SETSIZE(通常为 1024)的限制,可轻松处理数万甚至数十万连接。
  • 两种工作模式:
    • 水平触发(LT):只要 FD 还有未处理的 I/O 数据,就会持续通知(默认模式,兼容性好)。
    • 边缘触发(ET):仅在 FD 状态由 "未就绪" 变为 "就绪" 时通知一次,需一次性处理完所有数据,效率更高但编程复杂度稍高。

应用场景:

  • Epoll 是高性能网络服务器的核心技术之一,广泛应用于高并发场景,如 Nginx、Redis、Node.js 等服务器软件,尤其适合处理大量并发连接但每个连接的 I/O 操作不频繁的情况(如长连接服务)。相比 select,poll,epoll 从根本上解决了高并发下的性能瓶颈,是 Linux 系统中处理多 I/O 事件的首选方案。

14.2 传统 select/poll 在高并发下的性能瓶颈?

传统的 selectpoll 是早期 Unix/Linux 系统中用于 I/O 多路复用的机制,但其设计在高并发场景下存在明显的性能瓶颈,主要体现在以下几个方面:

14.2.1 文件描述符(FD)数量限制

  • select 的硬限制select 使用固定大小的位图(fd_set)存储监控的 FD,其最大数量由内核常量 FD_SETSIZE 决定(通常为 1024),无法直接突破,无法满足高并发场景(如需要处理数万连接)。
  • poll 的隐性限制poll 虽通过动态数组(struct pollfd)理论上支持更多 FD,但实际中受系统内存和内核参数限制,且随着 FD 数量增加,性能会急剧下降。

14.2.2 轮询机制的低效性

  • 无论是 select 还是 poll,每次调用时都需要遍历所有监控的 FD 来检查哪些处于就绪状态(即可读 / 可写)。
  • 当监控的 FD 数量庞大(如数万)时,即使只有少数 FD 就绪,也需遍历全部 FD,导致无意义的 CPU 开销,时间复杂度为 O(n),高并发下效率极低。

14.2.3 用户态与内核态的数据拷贝开销

  • selectpoll 每次调用时,都需要将整个监控的 FD 集合从用户态拷贝到内核态select 拷贝位图,poll 拷贝数组)。
  • 同时,内核处理完成后,还需将就绪的 FD 集合从内核态拷贝回用户态
  • 当 FD 数量庞大时,这种双向拷贝的开销会显著增加,成为性能瓶颈。

14.2.4 重复初始化的冗余操作

  • selectfd_set 在每次调用后会被内核修改(仅保留就绪 FD),因此下次调用前必须重新初始化整个集合,增加了代码复杂度和操作开销。
  • poll 虽无需重新初始化集合,但仍需重复传入整个数组,无法避免用户态与内核态的重复交互。

14.2.5 总结

传统 select/poll 的设计缺陷(固定 FD 上限、全量轮询、频繁数据拷贝)使其在高并发场景下性能急剧下降,无法满足现代服务器(如处理数万甚至数十万并发连接)的需求。这也催生了更高效的 I/O 多路复用机制(如 Linux 的 epoll、FreeBSD 的 kqueue 等),它们通过事件驱动、高效数据结构(红黑树)和零拷贝等优化,解决了上述瓶颈。

14.3 文件描述符不是有限的吗?受啥限制?

文件描述符(FD)的总数在系统中确实是有限的(受内核参数fs.file-max等限制),但epoll之所以被认为 "不受限制",是相对于传统select的硬限制而言的。其核心原因在于epoll对 FD 的管理方式摆脱了select的设计缺陷,能够高效支持远超select上限的 FD 数量,具体可从以下角度理解:

14.3.1 select的 "硬限制" 本质是设计缺陷,而非系统 FD 总数限制

select的最大 FD 数量限制(通常 1024)来自其内部实现 ------ 使用固定大小的位图(fd_set)存储监控的 FD,而位图的大小由内核常量FD_SETSIZE硬编码,与系统实际能支持的最大 FD 总数无关。

例如,即使系统通过ulimitfs.file-max配置了支持 10 万个 FD,select仍无法监控超过 1024 个 FD,这是设计上的人为限制

14.3.2 epoll的设计不依赖固定大小结构,仅受系统 FD 总数限制

epoll对监控的 FD 采用动态管理

  • 内核通过红黑树(一种高效的动态数据结构)存储所有被监控的 FD,红黑树的大小可随 FD 数量动态增长,无需预先分配固定大小的空间。
  • 因此,epoll能监控的 FD 数量仅受限于系统整体可打开的 FD 总数 (由fs.file-max、进程级ulimit -n等参数控制),而非自身设的硬限制。

例如,若系统配置允许进程打开 10 万个 FD,epoll就能同时监控这 10 万个 FD(只要内存足够)。

14.3.3 epoll的效率不随 FD 数量增加而急剧下降

即使系统 FD 总数有限,epoll相比select/poll的核心优势在于:

  • 无需遍历所有监控的 FD(仅处理就绪链表中的 FD),时间复杂度接近 O (1);
  • 避免了用户态与内核态之间的 FD 集合重复拷贝(仅在epoll_ctl时传递单个 FD 的信息)。

因此,即使在 FD 数量接近系统上限时,epoll仍能保持高效,而select/poll在 FD 数量稍多时就会因遍历和拷贝开销而性能崩溃。

14.3.4 总结

  • 系统 FD 总数确实有限(受内核配置和硬件资源限制),epoll也无法突破这一限制;
  • epoll的 "不受限制" 是相对selectFD_SETSIZE硬限制而言,其设计允许高效管理接近系统 FD 总数上限的连接,而不会像select/poll那样因自身机制导致性能骤降。

这也是epoll成为高并发场景(如十万级连接)首选方案的核心原因。

十五、描述下select、poll、epoll三者的区别

selectpollepoll 都是 Linux 系统中用于 I/O 多路复用的机制,用于在单个进程中同时监控多个文件描述符(FD)的就绪状态(可读、可写、异常等),但三者在设计和性能上有显著区别,以下从核心机制、限制、效率等维度详细对比:

15.1.1 核心数据结构与监控方式

机制 核心数据结构 监控方式描述
select 固定大小的位图(fd_set 通过位图标记需要监控的 FD,位图大小由 FD_SETSIZE 硬编码(默认 1024)。
poll 动态数组(struct pollfd 通过数组存储 FD 及事件类型(读 / 写 / 异常),数组大小可动态调整(无硬编码上限)。
epoll 红黑树 + 就绪链表 内核通过红黑树管理所有监控的 FD,就绪的 FD 会被加入就绪链表,无需全量遍历。

15.1.2 关键限制对比

对比项 select poll epoll
最大监控 FD 数 FD_SETSIZE 限制(默认 1024),硬限制 理论无硬限制,但受系统内存和性能限制 仅受系统最大 FD 总数(fs.file-max)限制
FD 就绪后处理 需重新初始化 fd_set(被内核修改) 无需重新初始化数组,但需重复传入 无需重新配置,就绪 FD 由内核主动通知

15.1.3 性能瓶颈与效率差异

对比项 select poll epoll
时间复杂度 O (n)(遍历所有监控的 FD) O (n)(同 select,遍历整个数组) O (1)(仅遍历就绪链表,红黑树操作是 O (log n))
用户态 - 内核态拷贝 每次调用拷贝整个 fd_set 位图 每次调用拷贝整个 pollfd 数组 仅在 epoll_ctl 时拷贝单个 FD 信息(一次拷贝)
高并发表现 超过 1024 连接无法支持,性能急剧下降 连接数增加时,遍历和拷贝开销激增 连接数增加时性能稳定,支持数万至数十万并发

15.1.4 适用场景

  • select:仅适用于连接数少(≤1024)、对性能要求不高的场景(如简单的客户端工具),目前已逐渐被淘汰。
  • poll:相比 select 支持更多连接,但本质仍是轮询机制,适用于连接数中等(数千)且短暂的场景(如某些嵌入式设备),但高并发下性能不足。
  • epoll:是高并发场景的首选(如 Web 服务器、即时通讯服务),尤其适合处理大量长连接(如数万 TCP 连接),能显著降低 CPU 开销。

15.1.5 总结

selectpoll 基于轮询机制,存在 FD 数量限制、效率随连接数下降、频繁数据拷贝等问题;而 epoll 采用事件驱动模式,通过红黑树和就绪链表实现高效管理,解决了传统机制的瓶颈,成为高并发 I/O 场景的核心技术(如 Nginx、Redis 等均基于 epoll 实现)。

十六、Epoll水平触发和边缘触发的区别?

水平触发:

只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知;当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知。epoll默认的模式是LT。

边缘触发:

当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知。

两者的区别在哪里呢?水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次。

16.2 在边缘触发下,一个socket已读取200,然后不在处理,是不是剩下的300就永远无法读取?

在边缘触发模式下,对于剩余的300,数据仍然可以读取。边缘触发模式仅表示在有新数据到达时才会通知应用程序,而不是在每次读取操作之后都会通知应用程序。

十七、连接断开有哪几种判定方式?

  1. 使用recv读取数据时,如果返回0,表明连接已经被断开
  2. 使用send发送数据时,如果返回0,表明连接已经被断开
  3. 使用Epoll监控连接状态。当接收到一个EPOLLRDHUP或EPOLLERR类型的事件时,代表网络断开。
  4. 使用getsockopt读取套接字的SO_ERROR 的值,如果值为0,表示连接仍然处于活动状态;如果SO_ERROR 的值非0,表示连接已经断开或发生了错误,通过使用strerror函数获取具体的错误信息。

十八、简述下DNS和域名

18.1 DNS

DNS(Domain Name System,域名系统)是互联网的核心基础设施之一,它的主要作用是将人类易记的域名(如www.example.com)转换为计算机可识别的 IP 地址(如192.0.2.1),被称为 "互联网的电话簿"。

DNS 既可以基于 UDP,也可以基于 TCP,具体取决于使用场景。它默认使用UDP,但在区域传输、递归查询失败时,DNS服务器可能会尝试通过TCP重试。

其核心功能是:

  • 域名解析:最核心的功能,将域名映射到对应的 IP 地址(正向解析),也可将 IP 地址反向映射到域名(反向解析)。
  • 负载均衡:通过返回多个 IP 地址,实现对同一域名下不同服务器的流量分配(如大型网站的多节点部署)。
  • 容错与冗余:通过多台 DNS 服务器存储相同记录,确保某台服务器故障时解析服务不中断。
  • 邮件路由:通过 MX 记录(邮件交换记录)指定域名对应的邮件服务器,保障电子邮件的正常投递。

18.2 域名

域名是互联网中用于标识和定位网络资源(如网站、服务器、邮件系统等)的字符串,它是人类易于记忆和识别的 "网络地址",对应着计算机可识别的 IP 地。简单来说,域名就像是互联网上的 "门牌号",帮助用户通过易记的名称找到对应的网络服务。

域名结构如下:

域名采用层次化结构,由多个部分组成,各部分用.分隔,从右到左层级逐渐降低(根域名→顶级域名→二级域名→子域名)。

示例:blog.tech.example.com

  • 根域名:最顶层的.(通常省略,如example.com实际是example.com``.)。
  • 顶级域名(TLD):根域名下的第一层,分为:
    • 通用顶级域名(gTLD):如.com(商业)、.org(组织)、.net(网络)、.info(信息)等。
    • 国家 / 地区顶级域名(ccTLD):如.cn(中国)、.``us(美国)、.jp(日本)等。
  • 二级域名:顶级域名下的自定义名称,如example.com中的example
  • 子域名:二级域名下的细分,可无限层级,如tech.example.com中的techblog.tech.example.com中的blog

十九、简述下DNS的工作流程

19.1 详细步骤

当用户在浏览器中输入www.example.com时,DNS 解析流程如下:

检查本地缓存:

  • 客户端(浏览器 / 操作系统)首先查询本地缓存(如浏览器缓存、系统 hosts 文件、本地 DNS 缓存),若存在已缓存的 IP 地址,直接返回结果,无需后续步骤。
  • hosts 文件:操作系统中的一个文本文件(如 Windows 的C:\Windows\System32\drivers\etc\hosts),可手动配置域名与 IP 的映射,优先级高于 DNS 服务器。

向本地 DNS 服务器请求解析:

若本地缓存无记录,客户端向本地 DNS 服务器(通常由 ISP 提供,如电信 / 联通的 DNS,或用户手动设置的公共 DNS 如114.114.114.1148.8.8.8)发送解析请求。

本地 DNS 服务器查询(递归查询):

本地 DNS 服务器若自身缓存无记录,则通过递归查询向上级服务器请求,直至获取结果:

  • 步骤 1:查询根域名服务器 本地 DNS 服务器向根域名服务器(全球共 13 组,由 ICANN 管理)发送请求,根服务器返回.com顶级域名服务器的 IP 地址。
  • 步骤 2:查询顶级域名服务器 本地 DNS 服务器向.com顶级域名服务器发送请求,顶级服务器返回example.com二级域名服务器的 IP 地址。
  • 步骤 3:查询权威域名服务器 本地 DNS 服务器向example.com的权威域名服务器(由域名持有者配置,如阿里云 DNS、Cloudflare 等)发送请求,权威服务器返回www.example.com对应的 IP 地址(如192.0.2.1)。

返回结果并缓存:

  • 本地 DNS 服务器将获取的 IP 地址返回给客户端,并将结果缓存一段时间(由域名的 TTL 值决定,通常为几分钟到几小时)。
  • 客户端接收到 IP 地址后,通过该 IP 与服务器建立 TCP 连接,发起 HTTP 请求。

19.2 图解

复制代码
用户设备 (浏览器/客户端)
    |
    | 1. 输入域名 (如 www.example.com) 并请求解析
    v
本地DNS缓存 (浏览器缓存/操作系统缓存)
    |
    |-- 若有缓存 → 直接返回IP (解析结束)
    |
    v 2. 无缓存,向本地DNS服务器请求
本地DNS服务器 (如运营商DNS)
    |
    | 3. 检查自身缓存
    |-- 若有缓存 → 返回IP (解析结束)
    |
    v 4. 无缓存,向根DNS服务器查询
根DNS服务器 (全球共13组)
    |
    | 5. 根服务器告知: .com 顶级域名由顶级DNS服务器负责
    v
.com 顶级DNS服务器
    |
    | 6. 告知: example.com 由其权威DNS服务器负责
    v
example.com 权威DNS服务器
    |
    | 7. 查找并返回 www.example.com 对应的IP (如 192.0.2.1)
    v
本地DNS服务器
    |
    | 8. 缓存该IP地址 (下次查询可直接使用)
    v
用户设备
    |
    | 9. 获得IP地址,建立网络连接
    v
目标服务器 (www.example.com)

二十、HTTP是什么?有什么特点?

HTTP(Hypertext Transfer Protocol,超文本传输协议)是一种用于在客户端(如浏览器)和服务器之间传输数据的应用层协议,基于TCP协议,是互联网数据通信的基础。

其核心功能是规定了客户端与服务器之间请求和响应的格式及交互规则,支持传输文本、图片、视频、超链接等多种 "超文本" 内容("超文本" 指包含跳转链接的文本,是构成网页的基础)。

其特点是:

  • **无状态:**协议本身不记录前后请求的关联,每次请求都是独立的(需通过 Cookie、Session 等机制实现状态保持);
  • **明文传输:**数据以未加密的形式发送,存在被监听或篡改的风险;
  • **基于请求 - 响应模式:**客户端发起请求(如访问网页),服务器返回响应(如返回网页内容);

一次HTTP的请求过程:

  1. 客户端向服务器发送HTTP请求,请求分为三部分,请求行 包含请求方法、协议版本等信息,请求头 包含浏览器类型、语言偏好等信息,请求体包含请求携带的数据如表单内容、上传的文件等;
  2. 服务器收到请求后,根据请求的内容执行对应操作后,返回HTTP的响应,响应也分为三个部分,状态行 包含协议版本、状态码等,响应头 包含元信息如内容类型、服务器类星星等,响应体包含实际数据如HTML代码、文本内容、视频二进制流等。

二十一、 简述下HTTP的请求和响应报文的格式

HTTP(超文本传输协议)的请求报文和响应报文均遵循特定的格式规范,由起始行首部字段空行主体四部分组成(主体可选)。以下分别简述两者的格式:

21.1 HTTP 请求报文格式

请求报文由客户端发送给服务器,用于请求资源或触发特定操作,格式如下:

  1. 请求行(起始行)

包含三个部分,以空格分隔,末尾以CRLF(回车 + 换行)结束:

plaintext 复制代码
方法  请求URI  协议版本
  • 方法 :表示请求的操作类型,如 GET(获取资源)、POST(提交数据)、PUT(更新资源)、DELETE(删除资源)等。
  • 请求 URI :指定请求的资源路径,如 /index.html 或完整 URL(如 https://example.com/api)。
  • 协议版本 :如 HTTP/1.1HTTP/2

示例:GET /index.html HTTP/1.1

  1. 请求首部字段

由多个键值对组成,每个字段格式为 字段名: 值,末尾以CRLF结束,用于描述请求的附加信息(如客户端信息、请求条件等)。常见字段包括:

  • Host:指定服务器的域名或 IP(HTTP/1.1 必需字段),如 Host: www.example.com
  • User-Agent:客户端身份标识(如浏览器型号),如 User-Agent: Mozilla/5.0
  • Accept:客户端可接受的响应数据格式,如 Accept: text/html, application/json
  • Content-Length:请求主体的长度(字节数),适用于POST等带数据的请求。
  1. 空行

一个CRLF,用于分隔首部字段和主体,是报文格式的强制要求(即使没有主体,也需保留空行)。

  1. 请求主体

可选部分,用于携带请求数据(如POST请求的表单数据、JSON 数据等),仅在需要向服务器提交数据时存在。

示例(表单数据):username=test&password=123

请求报文完整示例

plaintext 复制代码
POST /login HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

username=test&password=123

21.2 HTTP 响应报文格式

响应报文由服务器发送给客户端,用于返回请求结果或错误信息,格式如下:

  1. 状态行(起始行)

包含三个部分,以空格分隔,末尾以CRLF结束:

plaintext 复制代码
协议版本  状态码  状态描述
  • 协议版本 :与请求报文一致,如 HTTP/1.1
  • 状态码 :三位数字,表示请求处理结果,如 200(成功)、404(资源未找到)、500(服务器错误)。
  • 状态描述 :对状态码的文字解释,如 OK(200)、Not Found(404)。

示例:HTTP/1.1 200 OK

  1. 响应首部字段

同样由 字段名: 值 组成,描述响应的附加信息(如服务器信息、响应数据格式等)。常见字段包括:

  • Server:服务器软件标识,如 Server: Nginx/1.21.0`。
  • Content-Type:响应主体的数据格式,如 Content-Type: text/html; charset=utf-8(HTML 文本)、application/json(JSON 数据)。
  • Content-Length:响应主体的长度(字节数)。
  • Cache-Control:缓存控制策略,如 Cache-Control: max-age=3600(缓存 1 小时)。
  1. 空行

一个CRLF,分隔首部字段和主体(同请求报文)。

  1. 响应主体

可选部分,包含服务器返回的实际数据(如 HTML 页面、JSON 数据、图片等),具体格式由Content-Type指定。

示例(HTML 主体):<html><body><h1>Hello World</h1></body></html>

响应报文完整示例

plaintext 复制代码
HTTP/1.1 200 OK
Server: Nginx/1.21.0
Content-Type: text/html; charset=utf-8
Content-Length: 42
Cache-Control: max-age=3600

<html><body><h1>Hello World</h1></body></html>
  • 请求报文和响应报文的结构一致(起始行 + 首部 + 空行 + 主体),核心区别在起始行(请求行 vs 状态行)和首部字段的具体内容。
  • 首部字段是 HTTP 协议灵活性的关键,用于传递元数据,而主体则负责承载实际的业务数据。

二十二、简述HTTP方法和HTTP状态码

客户端发起HTTP请求时的主要方法有:

  • GET:获取资源的表示(如网页、图片、API 数据)
  • POST:向服务器提交数据(如表单提交、文件上传)
  • PUT:更新资源(通常是完整替换)
  • DELETE:删除资源
  • HEAD:获取资源的元信息(如响应头、状态码),不返回响应体
  • OPTIONS:获取服务器支持的请求方法和 CORS(跨域资源共享)配置。

服务器返回HTTP响应时的主要状态码有:

  • 1xx(信息性状态码):表示服务器已接收请求,正在处理中,需客户端继续等待。
  • 2xx(成功状态码):表示请求已被服务器成功接收、理解并处理。
  • 3xx(重定向状态码):表示请求需要客户端进一步操作才能完成(通常是跳转)。
  • 4xx(客户端错误状态码):表示请求存在错误(如语法错误、资源不存在),服务器无法处理。
  • 5xx(服务器错误状态码):表示服务器在处理请求时发生内部错误。

二十三、简述HTTP的工作流程

23.1 训练营

主要分为3部分:

  1. 客户端发送 HTTP 请求

客户端(如浏览器、APP)向服务器发起请求,请求包含三部分:

  • 请求行:明确请求方法(如GET)、目标资源路径(如/index.html)、HTTP 版本(如HTTP/1.1)。
  • 请求头:携带附加信息,如Host(服务器域名)、User-Agent(客户端类型)、Cookie(身份信息)等。
  • 请求体(可选):仅在POST/PUT等方法中携带数据(如表单内容、JSON 参数)。
  1. 服务器处理请求

服务器(如 Nginx、Apache)接收请求后,按以下步骤处理:

  • 解析请求:提取请求行、请求头和请求体,确定客户端需求(如获取 HTML 页面、提交表单)。
  • 业务处理:根据请求路径和参数执行逻辑(如查询数据库、生成动态页面、验证权限)。
  • 生成响应:根据处理结果准备响应数据,包括状态码、响应头和响应体。
  1. 服务器返回 HTTP 响应

服务器向客户端发送响应,响应包含三部分(详见前文 "响应组成"):

  • 状态行:包含 HTTP 版本、状态码(如200表示成功)、状态描述(如OK)。
  • 响应头:描述响应信息,如Content-Type(响应体格式,如text/html)、Content-Length(数据长度)、Set-Cookie(设置客户端 Cookie)等。
  • 响应体(可选):实际返回的数据(如 HTML 代码、JSON 数据、图片二进制流)。

23.2 豆包说

23.2.1 建立 TCP 连接

客户端(如浏览器)首先需要与目标服务器建立 TCP 连接(三次握手)。

  • 连接的目标地址通过DNS 解析获取(将域名转换为 IP 地址)。
  • 连接端口默认是 80(HTTP)或 443(HTTPS,加密传输)。

23.2.2 客户端发送 HTTP 请求

TCP 连接建立后,客户端向服务器发送HTTP 请求报文,包含:

  • 请求行:指定请求方法(如 GET、POST)、目标资源路径、协议版本(如 HTTP/1.1)。
  • 首部字段:附加信息(如客户端类型、可接受的数据格式等)。
  • 请求主体(可选):需提交给服务器的数据(如 POST 请求的表单数据)。

23.2.3 服务器处理并返回响应

服务器接收请求后,解析请求内容,执行对应操作(如读取文件、处理数据),然后返回HTTP 响应报文,包含:

  • 状态行:协议版本、状态码(如 200 表示成功,404 表示资源不存在)和状态描述。
  • 响应首部:服务器信息、响应数据格式、缓存策略等。
  • 响应主体(可选):返回给客户端的实际数据(如 HTML 页面、JSON 结果)。

23.2.4 关闭连接或复用连接

  • HTTP/1.0:默认一次请求 - 响应后关闭 TCP 连接(短连接)。
  • HTTP/1.1 及以上 :支持长连接Connection: keep-alive),同一连接可处理多个请求 - 响应,减少连接建立开销。
  • 客户端或服务器可主动关闭连接(四次挥手)。

23.2.5 示例流程

用户在浏览器输入https://www.example.com/index.html

  1. 浏览器通过 DNS 解析www.example.com的 IP 地址。
  2. 与该 IP 的 443 端口建立 TCP 连接(HTTPS 加密)。
  3. 发送请求:GET /index.html HTTP/1.1 及相关首部。
  4. 服务器找到index.html,返回状态码 200 和 HTML 内容。
  5. 浏览器渲染 HTML,显示页面;连接保持一段时间(长连接),等待后续请求。

简言之,HTTP 的工作流程是 "建立连接→请求→响应→关闭 / 复用连接" 的循环,核心是通过规范的报文格式实现客户端与服务器的通信。