编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 计算机原理

  • 网络协议

    • README
    • 通过看新闻熟悉网络
    • 通过购物熟悉加密
    • 从0到1部书电商网站
    • 请求网络的通用流程
    • 网络编程模型的概念
    • 传输协议TCP和UDP
    • Socket的发展和设计
      • 01.工作案例引入
        • 1.1 聊天服务的七次崩溃
        • 1.2 崩溃背后的Socket知识图谱
      • 02.通信基础概念
        • 2.1 Socket基础理解
        • 2.2 TCP/IP简介
        • 2.3 Socket与Http对比
        • 2.4 Socket使用类型
        • 2.5 Socket和ServerSocket
        • 2.6 一些问题思考
      • 03.TCP协议Socket深度解析
        • 3.1 理解四元组
        • 3.2 TCP Socket的特点
        • 3.3 TCP通信的完整流程
        • 3.4 TCP Socket的缓冲区
      • 04.UDP协议Socket
        • 4.1 UDP Socket的特点
        • 4.2 UDP通信流程
        • 4.3 TCP与UDP Socket对比
      • 05.Socket操作步骤与实践
        • 5.1 基础实践步骤
        • 5.2 Socket连接
        • 5.3 Socket中TLS连接
        • 5.4 Socket数据读写
        • 5.5 Socket断开连接
        • 5.6 Socket完整案例
      • 06.IO多路复用与C10K
        • 6.1 为何需要IO多路复用
        • 6.2 三种并发模型对比
        • 6.3 select/poll/epoll演进
        • 6.4 Socket在内核中的数据结构
      • 07.Socket选项与参数
        • 7.1 Socket选项的设计
        • 7.2 地址复用选项
        • 7.3 缓冲区大小调优
        • 7.4 超时与保活选项
        • 7.5 Nagle算法控制
      • 08.Socket编程常见问题
        • 8.1 粘包和拆包问题
        • 8.2 半关闭状态处理
        • 8.3 TIME_WAIT问题
        • 8.4 异常断线检测
      • 09.Socket的演进与未来
        • 9.1 Socket API的历史
        • 9.2 从BIO到NIO到AIO
        • 9.3 协程与Socket
        • 9.4 QUIC对Socket的影响
      • 10.综合案例:聊天服务的四次进化
        • 10.1 案例背景与目标
        • 10.2 第一代:BIO单线程——阻塞的噩梦
        • 10.3 第二代:多线程版本——线程的陷阱
        • 10.4 第三代:NIO+Selector——非阻塞的曙光
        • 10.5 第四代:epoll事件驱动——C10K的答案
        • 10.6 四种方案横向对比
        • 10.7 从案例看Netty设计
        • 10.8 全文知识图谱回顾
      • 11.思考题与作业
        • 11.1 基础思考题
        • 11.2 进阶思考题
        • 11.3 动手作业
    • 传输数据的设计思想
    • 网络域名解析的流程
    • HTTP服务设计流程
    • HTTP协议设计思想
    • HTTPS协议设计策略
    • HTTP连接和跳转
    • HTTP代理和缓存设计
    • 如何去排查网络故障
    • WebSocket实时通信
    • HTTP3与QUIC协议
  • 操作系统

  • 数据库原理

  • 计算机
  • 网络协议
杨充
2022-04-28
目录

Socket的发展和设计

# 07.Socket的发展和设计

# 目录介绍

  • 01.工作案例引入
    • 1.1 聊天服务的七次崩溃
    • 1.2 崩溃背后的Socket知识图谱
  • 02.通信基础概念
    • 2.1 Socket基础理解
    • 2.2 TCP/IP简介
    • 2.3 Socket与Http对比
    • 2.4 Socket使用类型
    • 2.5 Socket和ServerSocket
    • 2.6 一些问题思考
  • 03.TCP协议Socket深度解析
    • 3.1 理解四元组
    • 3.2 TCP Socket的特点
    • 3.3 TCP通信的完整流程
    • 3.4 TCP Socket的缓冲区
  • 04.UDP协议Socket
    • 4.1 UDP Socket的特点
    • 4.2 UDP通信流程
    • 4.3 TCP与UDP Socket对比
  • 05.Socket操作步骤与实践
    • 5.1 基础实践步骤
    • 5.2 Socket连接
    • 5.3 Socket中TLS连接
    • 5.4 Socket数据读写
    • 5.5 Socket断开连接
    • 5.6 Socket完整案例
  • 06.IO多路复用与C10K
    • 6.1 为何需要IO多路复用
    • 6.2 三种并发模型对比
    • 6.3 select/poll/epoll演进
    • 6.4 Socket在内核中的数据结构
  • 07.Socket选项与参数
    • 7.1 Socket选项的设计
    • 7.2 地址复用选项
    • 7.3 缓冲区大小调优
    • 7.4 超时与保活选项
    • 7.5 Nagle算法控制
  • 08.Socket编程常见问题
    • 8.1 粘包和拆包问题
    • 8.2 半关闭状态处理
    • 8.3 TIME_WAIT问题
    • 8.4 异常断线检测
  • 09.Socket的演进与未来
    • 9.1 Socket API的历史
    • 9.2 从BIO到NIO到AIO
    • 9.3 协程与Socket
    • 9.4 QUIC对Socket的影响
  • 10.综合案例:聊天服务的四次进化
    • 10.1 案例背景与目标
    • 10.2 第一代:BIO单线程(阻塞的噩梦)
    • 10.3 第二代:多线程版本(线程的陷阱)
    • 10.4 第三代:NIO+Selector(非阻塞的曙光)
    • 10.5 第四代:epoll事件驱动(C10K的答案)
    • 10.6 四种方案横向对比
    • 10.7 从案例看Netty设计
    • 10.8 全文知识图谱回顾
  • 11.思考题与作业
    • 11.1 基础思考题
    • 11.2 进阶思考题
    • 11.3 动手作业

# 01.工作案例引入

# 1.1 聊天服务的七次崩溃

场景:小赵是一名后端工程师,负责公司 App 的实时消息推送服务。需求很简单:服务端和客户端保持长连接,有新订单时实时通知用户。小赵用 Java Socket 很快写了一个原型——本地测试一切正常。

上线第一天的凌晨两点,告警响了。

崩溃① —— 重启就报错:运维发现 CPU 偏高,决定重启服务。结果重启失败,日志打印 bind failed: Address already in use。小赵懵了——明明已经把进程杀了,端口怎么还被占着?

崩溃② —— 消息串了:修好重启问题后,测试反馈"有时会收到两条消息粘在一起,JSON 解析直接炸了"。小赵检查代码,明明是每次 write() 发一条完整 JSON,为什么客户端收到的是一坨?

崩溃③ —— 800 人就扛不住:产品说要做压测,模拟 2000 个用户同时在线。才跑到 800 个连接,CPU 直接飙到 100%,新的客户端全部 connect timeout。日志显示 accept() 之后就没动静了。

崩溃④ —— 诡异 40ms 延迟:好不容易加上了线程池撑到 2000 连接,又出现新问题——偶尔一条消息从发出到客户端收到,刚好延迟 40ms,像被掐了表一样精准。而且只在某些场景下出现。

崩溃⑤ —— 客户端悄悄失联:运维发现线上有 200 多个"僵尸连接"——客户端网络断了(比如切了 Wi-Fi),但服务端完全不知道,还占着连接不释放。内存越吃越多。

崩溃⑥ —— "Too many open files":小赵学聪明了,切换成 NIO 模型,能撑到 5000 连接了。但压测跑到 5500 时,服务进程直接报 java.io.IOException: Too many open files 崩溃。

崩溃⑦ —— 负载均衡健康检查失败:一切似乎正常了,但部署到 K8s 集群后,健康检查探针偶尔失败导致 Pod 被重启。排查发现高并发时 accept() 有几十个连接排队,健康检查的 TCP 连接被丢弃了。

疑惑链条:

  • "Address already in use 是怎么来的?" → TCP 关闭后进入 TIME_WAIT 状态,持续 2MSL(约 60 秒),这段时间端口仍被占用
  • "消息为什么会粘在一起?" → TCP 是字节流协议,没有消息边界。连续两个 write() 的数据可能被合并到同一个 TCP 段发送
  • "为什么 800 连接就撑不住了?" → 每个连接一个线程,800 个线程的上下文切换吃光了 CPU
  • "那 40ms 精准延迟是谁干的?" → Nagle 算法 + Delayed ACK 的死锁——双方都在等对方先行动
  • "僵尸连接怎么检测?" → 应用层心跳 + TCP Keep-Alive 双保险
  • "Too many open files 怎么破?" → 每个 Socket 就是一个文件描述符,需要调大系统 ulimit 和优化 epoll 模型
  • "accept 排队是什么?" → listen() 的 backlog 参数控制的是已完成三次握手但尚未被 accept 的连接队列长度

小赵这一串问题,本质都是在问:Socket 到底是怎么工作的?TCP 在内核里发生了什么?如何在"高并发、低延迟、稳定可靠"之间找到平衡?——这正是"Socket 的发展和设计"要回答的。

# 1.2 崩溃背后的Socket知识图谱

把这次事故翻译成 Socket 知识语言:

小赵的聊天服务在 Socket 知识体系中的位置:

客户端 connect()
    ↓
服务端 listen() + accept()     ← 三次握手,连接建立(崩溃①⑦的根因)
    ↓
客户端 write() → TCP发送缓冲区  ← Nagle 算法在这里生效(崩溃④)
    ↓
网络传输(TCP段)
    ↓
服务端 TCP接收缓冲区 → read()   ← 字节流,无边界(崩溃②)
    ↓
业务线程处理                    ← 阻塞 vs 非阻塞(崩溃③⑤)
    ↓
连接管理(关闭/心跳/复用)       ← 文件描述符、保活(崩溃⑤⑥)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

七次崩溃与后续章节的映射关系:

崩溃 症状 根因所在的知识点 对应章节
① bind failed TIME_WAIT → SO_REUSEADDR 07.Socket选项、08.TIME_WAIT
② 消息串了 TCP字节流 → 粘包/拆包 08.粘包拆包问题
③ 800连接极限 阻塞模型 → IO多路复用 06.IO多路复用
④ 40ms精准延迟 Nagle + Delayed ACK 07.Nagle算法
⑤ 僵尸连接 半关闭 → 心跳保活 07.保活、08.异常断线
⑥ 文件描述符耗尽 Socket=文件 → epoll+ulimit 06.Socket内核结构
⑦ backlog丢连接 listen队列 → backlog参数 03.TCP连接流程

本章的主线就是沿着这七次崩溃,一层一层下钻到 Socket 的底层机制。读完之后,你不仅能解决这七个问题,还能理解为什么 Netty 这样设计 Reactor、为什么 Redis 用单线程 epoll 能跑到 10 万 QPS、为什么 QUIC 要把可靠传输从内核搬到用户态。

# 02.通信基础概念

# 2.1 Socket基础理解

Socket定义:即套接字,是应用层与 TCP/IP 协议族通信的中间软件抽象层,表现为一个封装了 TCP / IP 协议族的编程接口(API)。

核心要点:

  • Socket不是一种协议,而是一个编程调用接口(API),属于传输层(主要解决数据如何在网络中传输)
  • 通过Socket,我们才能在 Android 平台上通过 TCP/IP 协议进行开发;对用户来说,只需调用 Socket 去组织数据,以符合指定的协议,即可通信。

疑惑:Socket 跟"端口号"是一回事吗?

答疑:不是。Socket 是一个编程对象(文件描述符),端口号只是 Socket 绑定的一个标识。一个网络连接由四元组 {本机IP, 本机端口, 对端IP, 对端端口} 唯一标识。同一个端口可以对应多个 Socket(比如服务端 accept 后产生的每个客户端 Socket 都有自己的四元组)。

# 2.2 TCP/IP简介

IP 协议提供了主机和主机间的通信。为了完成不同主机的通信,我们需要某种方式来唯一标识一台主机,这个标识,就是著名的 IP 地址。通过 IP 地址,IP 协议就能够帮我们把一个数据包发送给对方。

TCP 协议在 IP 协议提供的主机间通信功能的基础上,完成这两个主机上进程对进程的通信。

疑惑:既然 IP 已经能"找到"对方主机了,为什么还需要 TCP?

答疑:IP 只管"送到",不管"送对"。它不保证:

  • 数据是否真的到达(可能丢包)
  • 数据到达的顺序是否正确(可能乱序)
  • 数据有没有被篡改(可能出错)

TCP 在 IP 之上补充了序列号、确认应答、超时重传、校验和等机制,把这些"不可靠"变成了"可靠"。

Port,为了标识数据属于哪个进程,我们给需要进行 TCP 通信的进程分配一个唯一的数字来标识它。这个数字,就是我们常说的端口号。

# 2.3 Socket与Http对比

Socket与Http对比,不属于同一层面:

  • Socket属于传输层,因为 TCP / IP 协议属于传输层,解决的是数据如何在网络中传输的问题
  • HTTP协议属于应用层,解决的是如何包装数据

工作方式的不同:

  • Http:采用请求—响应方式。可理解为:是客户端有需要才进行通信;
  • Socket:采用服务器主动发送数据的方式。可理解为:是服务器端有需要才进行通信

疑惑:很多项目里说"用 Socket 代替 HTTP",为什么?能更快吗?

答疑:分两点理解:

  1. 不是代替,是下沉一层。HTTP 本身运行在 Socket(TCP)之上,所以"用 Socket"实际上是把应用层协议从 HTTP 换成了自定义的二进制协议或 WebSocket。
  2. 快在哪里:快不在于 TCP 比 HTTP 底层的网络更快(都一样走 TCP),而在于——省掉了 HTTP 的头部开销、省掉了每次请求的建连/拆连开销(长连接)、服务端可以主动推送。

# 2.4 Socket使用类型

  • Socket的使用类型主要有两种:
    • 流套接字(streamsocket):基于 TCP 协议,采用流的方式提供可靠的字节流服务
    • 数据报套接字(datagramsocket):基于 UDP 协议,采用数据报文提供数据打包发送的服务

# 2.5 Socket和ServerSocket

  • Socket 和 ServerSocket 的区别是什么
    • 在 Java 的 SDK 中,socket 的共有两个接口:用于监听客户连接的 ServerSocket 和用于通信的 Socket。
  • 那各自的使用场景是什么样的
    • Socket类代表一个客户端套接字,即任何时候连接到一个远程服务器应用时构建所需的 socket。
    • ServerSocket,要实现一个服务器应用,需要不同的做法。服务器需随时待命,因为不知道客户端什么时候会发来请求,此时,需要使用 ServerSocket。
    • ServerSocket与Socket不同,ServerSocket是等待客户端的请求,一旦获得一个连接请求,就创建一个Socket示例来与客户端进行通信。

# 2.6 一些问题思考

  • Socket概念:Socket是如何通信的?跟Http有何区别?数据传递性能如何?是否具有安全性?
  • Socket实践:Socket是如何使用的的?如何创建连接,读数据(接受)和写数据(发送)分别是怎么设计的?
  • Socket实践:读数据的时候,如何将io字节流转化为特定的tcp数据,拿到tcp数据后如何解析数据(解析成对应实体bean)?
  • Socket长链接:如何设置socket保持长链接?如何保持轮训心跳包稳定性并且不会阻塞主线程?如何理解心跳包?
  • Socket读写:如何理解Socket读写数据?如何处理读写异常逻辑?异常之后如何设计重新连接?
  • Socket数据:TcpPacket是如何设计的?消息的长度是不确定的,并且每条消息都有它的边界。我们如何来处理这个边界?
  • Socket数据:如何保证数据有序性?一个任务队列,执行任务,如何保证先取出的任务,执行结果需要先放入结果队列?

# 03.TCP协议Socket深度解析

# 3.1 理解四元组

四元组是指在网络通信中,用于唯一标识一个网络连接的四个要素,包括源IP地址、源端口号、目标IP地址和目标端口号。这四个要素共同构成了一个网络连接的唯一标识。

四元组示例:
  {192.168.1.5, 52341, 10.0.0.1, 80}

  客户端侧:                    服务端侧:
  IP: 192.168.1.5             IP: 10.0.0.1
  Port: 52341 (系统随机分配)   Port: 80 (监听端口)

  这个四元组在整个互联网中唯一标识了"这一个" TCP 连接
  换个端口就是另一个连接,换个 IP 也是另一个连接
1
2
3
4
5
6
7
8
9

疑惑:四元组能支撑多少连接?有理论上限吗?

答疑:对一台服务器而言,四元组中服务端 IP 和端口是固定的,变的部分是客户端 IP 和客户端端口。理论上,最大连接数 = 客户端 IP 数 × 客户端端口数(65535)。对 IPv4 全球互联网来说,理论值是天文数字。但实际上,受限于单机的文件描述符、内存、CPU,单机通常支撑数万到数十万连接。

  1. 源IP地址:指发起通信的主机的IP地址,用于标识数据包的来源。
  2. 源端口号:指发起通信的主机上的应用程序或进程使用的端口号。它是一个16位的数字,用于标识数据包在源主机上的具体应用程序或进程。
  3. 目标IP地址:指接收数据包的主机的IP地址,用于标识数据包的目的地。
  4. 目标端口号:指接收数据包的主机上的应用程序或进程监听的端口号。它也是一个16位的数字,用于标识数据包在目标主机上的具体应用程序或进程。

# 3.2 TCP Socket的特点

TCP Socket 基于面向连接的 TCP 协议,提供可靠的、有序的、全双工的字节流传输。它的核心特点:

  1. 面向连接:通信前必须通过三次握手建立连接,通信后通过四次挥手释放连接
  2. 可靠传输:通过序列号、确认应答、超时重传、校验和等机制,保证数据不丢失、不重复、不乱序
  3. 流式传输:数据没有边界,应用层需要自行处理"粘包"和"拆包"问题
  4. 全双工:建立连接后,双方可以同时发送和接收数据

# 3.3 TCP通信的完整流程

TCP Socket 通信分为服务端和客户端两个角色,完整流程如下:

服务端流程:                      客户端流程:
socket()   → 创建Socket          socket()   → 创建Socket
bind()     → 绑定IP和端口         
listen()   → 开始监听             
                                  connect()  → 发起三次握手
accept()   → 接受连接(阻塞)       
                                  ← 三次握手完成,连接建立 →
read()     ← 读取数据             write()    → 发送数据
write()    → 发送数据             read()     ← 读取数据
close()    → 关闭连接             close()    → 关闭连接
1
2
3
4
5
6
7
8
9
10

关于 listen() 的 backlog 参数:listen(fd, backlog) 中的 backlog 指定了内核为该 Socket 维护的已完成三次握手但尚未被 accept 的连接队列的最大长度。当队列满时,新的连接请求会被拒绝(客户端会收到 RST 或者超时)。

backlog 在 TCP 三次握手中的角色:

客户端 SYN → ┌────────────SYN队列─────────────┐
              │ 收到SYN但未完成三次握手的连接     │
              │ 被 syncookies / tcp_max_syn_   │
              │ backlog 参数控制                │
              └────────┬───────────────────────┘
客户端 ACK → ┌────────┴────────────────────────┐      ← 三次握手完成
              │  accept队列(已完成三次握手)      │
              │  被listen(fd, backlog)参数控制    │
              │  应用程序调用accept()从此队列取    │
              └────────────────────────────────┘

  崩溃⑦的根因:backlog太小 → accept队列满 → 新连接被丢
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 3.4 TCP Socket的缓冲区

每个 TCP Socket 在内核中维护两个缓冲区:

  • 发送缓冲区(Send Buffer):应用层调用 write()/send() 时,数据先写入发送缓冲区,由内核负责将数据发送到网络。如果缓冲区满,write() 会阻塞。
  • 接收缓冲区(Receive Buffer):内核收到对端发来的数据后,先存入接收缓冲区。应用层调用 read()/recv() 时从中读取。

TCP 的流量控制(滑动窗口)就是基于接收缓冲区的剩余空间来通知对端"我还能接收多少数据"。当接收缓冲区满时,对端窗口变为 0,发送方暂停发送。

数据在 TCP Socket 缓冲区的流动:

应用程序 write("Hello")
      │
      ▼
┌─────────────────┐
│  发送缓冲区       │  ← 用户态 → 内核态拷贝发生在这里
│  (sk_write_queue)│
└────────┬────────┘
         │ 内核按MSS切片、添加TCP头、发送
         ▼
       网络
         │
         ▼
┌─────────────────┐
│  接收缓冲区       │
│ (sk_receive_queue)│
└────────┬────────┘
         │
         ▼
应用程序 read() → "Hello"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 04.UDP协议Socket

# 4.1 UDP Socket的特点

UDP Socket 基于无连接的 UDP 协议,提供不可靠的、无序的数据报传输。它的核心特点:

  1. 无连接:不需要建立和维护连接,直接发送数据报
  2. 不可靠:不保证数据送达,不保证顺序,不保证不重复
  3. 数据报式:每次发送都是一个完整的数据报,有明确的边界,不存在粘包问题
  4. 开销小:没有连接状态维护,头部只有 8 字节(TCP 是 20 字节起)

# 4.2 UDP通信流程

UDP 的通信流程比 TCP 简单得多:

服务端流程:                      客户端流程:
socket()     → 创建Socket         socket()     → 创建Socket
bind()       → 绑定IP和端口        
                                   sendto()    → 直接发送数据
recvfrom()   ← 接收数据            recvfrom()  ← 接收数据
sendto()     → 发送数据            
close()      → 关闭               close()      → 关闭
1
2
3
4
5
6
7

注意 UDP 不需要 listen() 和 accept(),也不需要 connect()。sendto() 和 recvfrom() 每次都需要指定/获取对端地址。

# 4.3 TCP与UDP Socket对比

对比维度 TCP Socket UDP Socket
连接方式 面向连接,需三次握手 无连接,直接发送
可靠性 可靠传输(重传、确认、排序) 不可靠,尽力而为
数据边界 字节流,无边界 数据报,有边界
传输效率 较低(连接管理+可靠性开销) 较高(极少的协议开销)
头部大小 最小 20 字节 固定 8 字节
适用场景 文件传输、网页浏览、邮件 视频直播、DNS查询、游戏同步
编程复杂度 较高(需处理连接状态) 较低(直接收发)

疑惑:既然 TCP 更可靠,为什么视频直播和游戏要用 UDP?

答疑:实时场景的关键诉求是低延迟,而不是绝对可靠。丢一个视频帧或者丢一个游戏位置数据,肉眼几乎看不出来,但如果为了重传一个丢失的包而卡住后续所有数据(TCP 队头阻塞),用户体验会更差。QUIC 协议(第 9.4 节)就是看到这个矛盾后,把 TCP 的可靠传输机制搬到 UDP 之上,实现"有选择的可靠"。

# 05.Socket操作步骤与实践

# 5.1 基础实践步骤

  • Socket可基于TCP或者UDP协议,但TCP更加常用。所以下面的使用步骤 & 实例的Socket将基于TCP协议。
    • 第一步:创建客户端 & 服务器的连接。
    • 第二步:客户端 & 服务器 通信。
    • 第三步:断开客户端 & 服务器 连接。

# 5.2 Socket连接

  • 第一步:创建客户端 & 服务器的连接。创建Socket对象 & 指定服务端的IP及端口号 ,判断客户端和服务器是否连接成功。
// 创建Socket对象 & 指定服务端的IP及端口号 
Socket socket = new Socket("192.168.1.32", 1989);  
// 判断客户端和服务器是否连接成功  
socket.isConnected();
1
2
3
4
  • Socket连接条件
    • 需要指定ip地址和port端口号。然后调用 socket?.connect(address, timeOut)

# 5.3 Socket中TLS连接

  • 这一步的作用主要是:增加安全性校验。

TLS(Transport Layer Security)是在TCP之上添加的一层加密协议。使用TLS的Socket通信流程:

普通TCP Socket通信:
  客户端 ←→ TCP连接 ←→ 服务端
  数据以明文传输,可被窃听和篡改

TLS Socket通信:
  客户端 ←→ TLS加密层 ←→ TCP连接 ←→ TLS加密层 ←→ 服务端
  数据经过加密,即使被截获也无法解读

TLS握手过程(在TCP三次握手之后):
  1. 客户端发送ClientHello(支持的TLS版本、加密套件列表)
  2. 服务端发送ServerHello(选择的加密套件、服务器证书)
  3. 客户端验证证书合法性
  4. 双方协商对称加密密钥
  5. 加密通信开始
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在编程实现中,各语言都提供了对TLS Socket的封装:

  • Java:SSLSocket(继承自Socket,透明地添加TLS层)
  • Python:ssl.wrap_socket()(将普通socket包装为SSL socket)
  • Go:tls.Dial()(创建TLS连接)
  • C/C++:OpenSSL库的 SSL_new() / SSL_connect()

# 5.4 Socket数据读写

  • 第二步:客户端 & 服务器 通信。通信包括:客户端 接收服务器的数据 & 发送数据到服务器
<-- 操作1:接收服务器的数据 -->
// 步骤1:创建输入流对象InputStream
InputStream is = socket.getInputStream() 
// 步骤2:创建输入流读取器对象 并传入输入流对象
// 该对象作用:获取服务器返回的数据
InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
// 步骤3:通过输入流读取器对象 接收服务器发送过来的数据
br.readLine();

<-- 操作2:发送数据 到 服务器 -->                  
// 步骤1:从Socket 获得输出流对象OutputStream
// 该对象作用:发送数据
OutputStream outputStream = socket.getOutputStream(); 
// 步骤2:写入需要发送的数据到输出流对象中
outputStream.write(("杨充"+"\n").getBytes("utf-8"));
// 特别注意:数据的结尾加上换行符才可让服务器端的readline()停止阻塞
// 步骤3:发送数据到服务端 
outputStream.flush();  
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 5.5 Socket断开连接

  • 第三步:断开客户端 & 服务器 连接
// 断开 客户端发送到服务器 的连接,即关闭输出流对象OutputStream
os.close();
// 断开 服务器发送到客户端 的连接,即关闭输入流读取器对象BufferedReader
br.close();
// 最终关闭整个Socket连接
socket.close();
1
2
3
4
5
6

# 5.6 Socket完整案例

  • TCP Socket分为Socket和ServerSocket对应着client和server,下面我来用代码实现一个简单的TCP通讯功能:
  • 客户端:首先创建一个Socket和InetSocketAddress,然后通过Socket的connect()方法进行连接,连接成功后可以获取到输出流,通过该输出流就可以向服务端传输数据。
public class TCPClient {
    public static void main(String[] args) throws IOException {
        //1.创建TCP客户端Socket服务
        Socket client = new Socket();
        //2.与服务端进行连接
        InetSocketAddress address = new InetSocketAddress("192.168.31.137",10000);
        client.connect(address);
        //3.连接成功后获取客户端Socket输出流
        OutputStream outputStream = client.getOutputStream();
        //4.通过输出流往服务端写入数据
        outputStream.write("hello server".getBytes());
        //5.关闭流
        client.close();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 服务端:创建一个服务端Socket并明确端口号,通过accept()方法获取到链接过来的客户端Socket,从客户端Socket中获取输入流,最后由输入流读取客户端传输来的数据。
public class TCPServer {
    public static void main(String[] args) throws IOException {
        //1.创建服务端Socket并明确端口号
        ServerSocket serverSocket = new ServerSocket(10000);
        //2.获取到客户端的Socket
        Socket socket = serverSocket.accept();
        //3.通过客户端的Socket获取到输入流
        InputStream inputStream = socket.getInputStream();
        //4.通过输入流获取到客户端传递的数据
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
        String line = null;
        while ((line = bufferedReader.readLine())!=null){
            System.out.println(line);
        }
        //5.关闭流
        socket.close();
        serverSocket.close();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  • 一个服务端是可以同时和多个客户端进行通信的,那么它是如何区分不同客户端呢?
    • 从上面代码我们可以看到,服务端首先通过accept()获取到客户端Socket,然后通过客户端的Socket获取的流进行通讯,这也让服务端得以区分每个客户端。

# 06.IO多路复用与C10K

# 6.1 为何需要IO多路复用

学会了基本的 Socket 函数之后,可以轻松地写一个网络交互的程序了。但如果使用简单的循环方式,基本上只能一对一沟通。如果是一个服务器,同时只能服务一个客户,肯定是不行的。

回到崩溃③的场景:小赵的代码大致是这样的——

// 崩溃③的代码:一次只能处理一个客户端
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket client = server.accept();  // 阻塞,等待新连接
    // 处理这个客户端的所有请求(阻塞)
    BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
    String line;
    while ((line = reader.readLine()) != null) {
        handleMessage(line);  // 处理消息
    }
    // 这个客户端断开前,永远轮不到下一个
}
1
2
3
4
5
6
7
8
9
10
11
12

这就是典型的"一个线程只服务一个连接"——当有 800 个连接时,要么创建 800 个线程(线程爆炸),要么排队(后面的等到天荒地老)。

那最多能接多少连接呢?系统会用一个四元组来标识一个 TCP 连接:

{本机IP, 本机端口, 对端IP, 对端端口}
1

服务器通常固定在某个本地端口上监听,因此最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数。对 IPv4 而言,理论上限约为 2^48。但实际上受限于文件描述符数目和内存,远达不到理论值。

# 6.2 三种并发模型对比

| 模型 | 原理 | 优点 | 缺点 | 😐------|------|------|------| | 多进程(fork) | 每个连接 fork 一个子进程 | 隔离性好,一个崩溃不影响其他 | 进程创建/销毁开销大,资源占用高 | | 多线程(pthread) | 每个连接创建一个线程 | 比进程轻量,共享内存 | 线程数有上限,上下文切换开销 | | IO多路复用 | 一个线程监控多个Socket | 资源占用极低,支持大量连接 | 编程模型相对复杂 |

三种模型面对800连接的对比(以崩溃③为场景):

多进程模型:
  800个进程 × 约100MB每个 = 80GB内存 → 直接OOM
  
多线程模型:
  800个线程 × 约1MB栈空间每个 = 800MB → 勉强,但CPU上下文切换爆炸
  
IO多路复用:
  1个线程监控800个Socket → 内存几MB,CPU几乎无额外开销
1
2
3
4
5
6
7
8
9
10

# 6.3 select/poll/epoll演进

select:将所有被监听的 Socket 放在文件描述符集合 fd_set 中,调用 select 函数监听变化。一旦有变化,遍历所有文件描述符找到就绪的。缺点是每次需要轮询全部,受 FD_SETSIZE 限制(通常 1024)。

poll:与 select 类似,但使用链表存储,没有最大数量限制。仍然需要遍历全部描述符。

epoll:Linux 特有的高性能方案。内核中使用红黑树保存监听的 Socket,当某个 Socket 有事件时,通过回调函数主动通知,无需轮询。这使得监听的 Socket 数量增加时效率不会大幅降低。epoll 被称为解决 C10K 问题的利器。

select:轮询 → O(n) 复杂度,FD_SETSIZE 限制(默认1024)
poll:  轮询 → O(n) 复杂度,无数量限制
epoll: 回调 → O(1) 复杂度,支持百万级连接

在800个Socket的场景下:
  select: 每次需要扫描全部800个fd → 约800次检查
  epoll:  只返回有事件的那几个fd → 有10个活跃就只返回10个
  
在10000个Socket的场景下:
  select: 直接超出FD_SETSIZE限制 → 不可用
  epoll:  依然O(1),无压力
1
2
3
4
5
6
7
8
9
10
11

疑惑:select 为什么被限制在 1024?能改大吗?

答疑:FD_SETSIZE 是在编译内核时定义的常量。理论上可以改大重新编译,但 select 的本质问题不是这个数——而是每次调用都要把整个 fd_set 从用户态拷贝到内核态,并且在内核中遍历所有 fd。fd 数量增大时,O(n) 的轮询开销线性增长,成了性能瓶颈。epoll 通过事件驱动(回调)+ 内存映射(mmap 共享事件数组)彻底解决了这两个问题。

# 6.4 Socket在内核中的数据结构

在 Linux 中,Socket 是一个文件,对应一个文件描述符。每个进程的 task_struct 中指向文件描述符数组,Socket 的 inode 存在于内存中(而非磁盘),其中包含发送队列和接收队列,队列中保存 sk_buff 缓存,里面能看到完整的包结构。

Socket在Linux内核中的结构层次:

进程(task_struct)
  └── 文件描述符表(files_struct)
        └── file对象
              └── socket结构体
                    ├── sock结构体(网络层)
                    │     ├── 发送队列(sk_write_queue)
                    │     ├── 接收队列(sk_receive_queue)
                    │     ├── 等待队列(sk_wq) → 用于IO多路复用
                    │     ├── 发送缓冲区大小(sk_sndbuf)
                    │     └── 接收缓冲区大小(sk_rcvbuf)
                    └── proto_ops(协议操作函数指针)
                          ├── connect / accept
                          ├── sendmsg / recvmsg
                          └── shutdown / close
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关键设计思想:"一切皆文件"。Socket被抽象为文件,可以用统一的 read/write/close 接口操作。这使得应用程序无需关心底层是网络通信还是文件IO,大大简化了编程模型。

"一切皆文件"的威力:

  操作磁盘文件:        操作Socket:
  open("data.txt")     socket()
  read(fd, buf, n)     read(sockfd, buf, n)   ← 同一个 read()
  write(fd, buf, n)    write(sockfd, buf, n)  ← 同一个 write()
  close(fd)            close(sockfd)          ← 同一个 close()

  对于上层代码,文件和网络连接没有区别
1
2
3
4
5
6
7
8
9

sk_buff的设计:sk_buff是Linux内核中网络数据包的核心数据结构。它巧妙地通过指针操作来添加/删除各层协议头,避免了数据拷贝:

sk_buff的指针布局:
  head ──────→ ┌──────────────┐
               │  预留空间     │
  data ──────→ ├──────────────┤
               │  协议头部     │  ← 各层协议头(ETH+IP+TCP)
               ├──────────────┤
               │  有效数据     │  ← 应用层数据
  tail ──────→ ├──────────────┤
               │  预留空间     │
  end ───────→ └──────────────┘

发送时:从tail向head方向依次添加TCP头、IP头、以太网头
接收时:从head向tail方向依次剥离各层头部
1
2
3
4
5
6
7
8
9
10
11
12
13

这就是崩溃⑥"Too many open files"的根源:每个 Socket 就是一个文件描述符(fd),系统的 ulimit -n(通常是 1024 或 4096)决定了单个进程能打开的最大文件数。当你创建了第 1025 个 Socket 时,内核拒绝分配新的 fd。

# 07.Socket选项与参数

# 7.1 Socket选项的设计

Socket选项是操作系统提供的用于精细控制网络行为的接口。通过 setsockopt() 和 getsockopt() 函数,应用程序可以调整Socket的各种参数。

疑惑:为什么需要Socket选项?默认设置不够用吗?

答疑:默认设置是为"通用场景"设计的折衷方案。但不同的应用场景有不同的需求:Web服务器需要快速释放端口(SO_REUSEADDR);实时游戏需要禁用Nagle算法(TCP_NODELAY);大文件传输需要增大缓冲区(SO_SNDBUF/SO_RCVBUF);长连接服务需要保活检测(SO_KEEPALIVE)。

Socket选项分层结构:

SOL_SOCKET层(通用选项):
  SO_REUSEADDR  - 地址复用       SO_KEEPALIVE  - 保活检测
  SO_REUSEPORT  - 端口复用       SO_SNDBUF     - 发送缓冲区大小
  SO_RCVBUF     - 接收缓冲区     SO_RCVTIMEO   - 接收超时
  SO_SNDTIMEO   - 发送超时       SO_LINGER     - 关闭行为控制

IPPROTO_TCP层(TCP专用选项):
  TCP_NODELAY   - 禁用Nagle      TCP_CORK      - 聚合小包
  TCP_KEEPIDLE  - 保活起始时间   TCP_KEEPINTVL - 保活间隔
  TCP_KEEPCNT   - 保活次数       TCP_QUICKACK  - 快速确认
1
2
3
4
5
6
7
8
9
10
11
12

# 7.2 地址复用选项

SO_REUSEADDR 是服务器编程中几乎必设的选项。

疑惑:服务器重启后为什么会报"Address already in use"错误?

答疑:TCP连接关闭后,主动关闭方会进入 TIME_WAIT 状态,默认持续 2MSL(通常60秒-4分钟)。在此期间,该端口仍被占用。如果服务器重启,尝试绑定相同端口就会失败。

不设SO_REUSEADDR时:
  服务器关闭 → TIME_WAIT(60秒) → 端口释放
  服务器重启 → bind()失败:"Address already in use"

设置SO_REUSEADDR后:
  服务器关闭 → TIME_WAIT(60秒)
  服务器重启 → bind()成功(允许绑定处于TIME_WAIT的端口)
1
2
3
4
5
6
7

SO_REUSEPORT(Linux 3.9+)允许多个Socket绑定到同一个端口:

传统模型:主进程listen → fork多个子进程 → 共用listen socket → 惊群效应

REUSEPORT模型:
  进程1 bind+listen 端口80 ─┐
  进程2 bind+listen 端口80 ─┤→ 内核负载均衡,每个进程独立accept
  进程3 bind+listen 端口80 ─┘   无惊群问题,性能提升2-3倍
1
2
3
4
5
6

这就是崩溃①的终极解决方案——一行代码 serverSocket.setReuseAddress(true)。

# 7.3 缓冲区大小调优

Socket的发送和接收缓冲区大小直接影响网络吞吐量:

缓冲区与吞吐量的关系(带宽延迟积BDP):

理论最大吞吐量 = 窗口大小 / RTT
所以缓冲区 >= BDP = 带宽 × RTT

示例:带宽=1Gbps, RTT=20ms
  BDP = 1Gbps × 20ms = 2.5MB
  缓冲区64KB时:吞吐量上限 = 64KB/20ms ≈ 25.6Mbps
  缓冲区2.5MB时:吞吐量上限 ≈ 1Gbps(充分利用带宽)
1
2
3
4
5
6
7
8
9

现代Linux内核支持TCP缓冲区自动调优(tcp_wmem/tcp_rmem),内核会根据网络状况动态调整缓冲区大小。但在高带宽×高延迟的长肥网络中,可能仍需手动调大上限。

# 7.4 超时与保活选项

SO_KEEPALIVE:TCP保活机制,与应用层心跳的对比:

对比项 TCP SO_KEEPALIVE 应用层心跳
实现层级 内核TCP协议栈 应用程序代码
默认间隔 2小时 通常30秒-5分钟
灵活性 参数有限 可自定义格式和内容
开销 极低(空包) 略高(需序列化)
可携带业务数据 否 是
适用场景 检测死连接 检测业务层可用性

崩溃⑤的解决思路:TCP Keep-Alive 默认 2 小时才探测,太慢了。所以实际项目都用"应用层心跳(30秒间隔)+ TCP Keep-Alive(5分钟)"双保险。

# 7.5 Nagle算法控制

Nagle算法是TCP协议中减少小包发送数量的优化策略。如果有已发送但未被确认的数据,新数据会缓存直到收到ACK或缓存达到MSS大小。

TCP_NODELAY:禁用Nagle算法,数据立即发送。

场景 是否禁用 原因
实时游戏 禁用 操作需要立即到达
交互式终端(SSH) 禁用 每个按键需即时响应
大文件传输 不禁用 Nagle合并小包提高效率
数据库协议 通常禁用 查询需要低延迟

Nagle与Delayed ACK的冲突:

经典死锁场景:客户端开启Nagle,服务端开启Delayed ACK

  客户端发送请求片段1 → 服务端收到,启动Delayed ACK计时器(40ms)
  客户端想发送片段2   → Nagle等待片段1的ACK
  服务端等更多数据才发ACK → Delayed ACK计时器

  结果:双方互等,直到Delayed ACK超时(40ms)后才继续
  解决:客户端设TCP_NODELAY,或一次write发送完整消息
1
2
3
4
5
6
7
8

崩溃④就是这个死锁——小赵的一条消息刚好被拆成两个 write(),Nagle 等 ACK,Delayed ACK 等更多数据,结果精准卡了 40ms。

# 08.Socket编程常见问题

# 8.1 粘包和拆包问题

粘包是TCP编程中最常见的问题。TCP是面向字节流的协议,不保留消息边界。

粘包场景示例:
  发送方:send("Hello"), send("World")
  接收方可能收到:
    "HelloWorld"(粘包)| "Hel","loWorld"(拆包)| "HelloWor","ld"(混合)
1
2
3
4

解决粘包的三种经典方案:

方案1:固定长度 —— 每条消息固定N字节,不足补零(简单但浪费带宽)

方案2:分隔符  —— 消息间用特殊字符分隔如\r\n(简单但内容不能含分隔符)

方案3:长度前缀(最常用)
  ┌──────────┬──────────────────┐
  │ 长度(4B) │    消息内容       │
  └──────────┴──────────────────┘
  接收方先读4字节获取长度N,再读N字节获取完整消息
1
2
3
4
5
6
7
8
9

# 8.2 半关闭状态处理

TCP连接是全双工的,两个方向可以独立关闭。shutdown(fd, SHUT_WR) 只关闭写方向,读方向仍然打开。

close() vs shutdown() 的区别:

操作 close(fd) shutdown(fd, how)
作用 关闭文件描述符 关闭连接的某个方向
引用计数 fd引用计数减1,为0时才真正关闭 立即关闭指定方向
fork后 父子进程各持有一个fd 任一进程shutdown立即生效
方向控制 双向都关闭 可以只关闭读或写

# 8.3 TIME_WAIT问题

主动关闭连接的一方会进入TIME_WAIT,持续2MSL(通常60秒)。

疑惑:为什么需要TIME_WAIT?直接释放不行吗?

答疑:两个核心目的——确保最后的ACK到达(对方可重发FIN);避免旧连接残余数据干扰新连接。

思考:如果没有TIME_WAIT会怎样?
  
  1. 服务端发FIN,客户端回ACK后立即释放端口
  2. ACK丢失 → 服务端重发FIN
  3. 客户端端口已被新连接占用 → 新连接收到一个莫名其妙的FIN
  4. 新连接被意外关闭
  
TIME_WAIT就是为了防止这种"残留报文命中新连接"的悲剧
1
2
3
4
5
6
7
8
缓解TIME_WAIT的方法:
  1. SO_REUSEADDR(必设):允许绑定TIME_WAIT端口
  2. tcp_tw_reuse=1:允许复用TIME_WAIT连接
  3. 使用长连接(Keep-Alive):减少连接创建/销毁频率
  4. 应用层连接池:复用已建立的连接
1
2
3
4
5

# 8.4 异常断线检测

异常断线的几种表现及检测方式:

1. 对方进程崩溃 → OS发FIN → recv()返回0(正常EOF检测)
2. 对方机器崩溃 → 无FIN → TCP Keep-Alive或应用层心跳
3. 网络中断     → 无ACK → TCP重传超时或应用层心跳

推荐:应用层心跳(30秒间隔) + TCP Keep-Alive(5分钟) 双保险
1
2
3
4
5
6
7

# 09.Socket的演进与未来

# 9.1 Socket API的历史

Socket API发展时间线:
  1983年  BSD 4.2    首次引入Socket API
  1993年  WinSock    Windows兼容BSD Socket
  2002年  Java 1.4   引入NIO,支持非阻塞IO
  2014年  Go 1.3     goroutine隐藏IO复杂性
  2018年  Rust       async/await + tokio

40年来底层BSD Socket API几乎没变
变化的是上层的编程模型和抽象方式
1
2
3
4
5
6
7
8
9

# 9.2 从BIO到NIO到AIO

模型 原理 并发能力 编程复杂度 典型框架
BIO 每连接一线程,阻塞 低 低(最直观) 传统Java Socket
NIO Selector监控多连接 高 高(状态管理) Netty、libuv
AIO 异步回调,IO完成通知 最高 中(回调/Future) IOCP、io_uring

# 9.3 协程与Socket

协程让开发者用同步写法实现异步效果。当协程执行到IO操作时,运行时自动挂起并切换到其他协程。IO完成后恢复执行,底层仍用epoll/kqueue。

Go的goroutine是典范:每个连接一个goroutine,开销仅约2KB栈空间(线程需1MB),轻松支撑百万级并发。

# 9.4 QUIC对Socket的影响

传统TCP Socket的局限 → QUIC的解决方案:
  连接建立慢(2-3RTT)  → 0-RTT/1-RTT连接
  队头阻塞             → 流级别独立,互不影响
  协议僵化             → 基于UDP,可在用户态演进
  迁移困难(IP变化断连) → Connection ID标识连接
1
2
3
4
5

QUIC把可靠传输移到用户空间,协议可随应用更新,无需等OS升级。这代表了网络协议从"内核实现"走向"用户态实现"的趋势。

# 10.综合案例:聊天服务的四次进化

前面我们已经讲了 Socket 的通信模型、IO 多路复用、缓冲区、粘包处理、选项调优等知识。但孤立地看这些知识点,很难形成实战直觉。

本章用一个贯穿全文的综合案例——从零开始构建一个即时通讯服务器,让它经历四次进化,从"只能服务一个用户"到"支撑 C10K 并发"。每一代版本的代码都给出了,每一代的问题和性能瓶颈都和前面的章节对应。读完这一节,你应该能形成"看到一个并发场景就能脑补出Socket架构选型"的能力。

# 10.1 案例背景与目标

我们假设要开发一个企业内部 IM 服务,需求很简单:

  • 客户端连接后可以发送文本消息
  • 服务端把消息转发给所有在线客户端(类似群聊)
  • 每条消息约 200 字节
  • 目标是支撑 10000 个同时在线用户

我们将用 Java 实现四个版本,逐步进化:

版本 模型 并发能力 复杂性 对应的崩溃
第一代 BIO 单线程 1个用户 极低 崩溃③
第二代 BIO 多线程 ~500个 低 崩溃③⑤
第三代 NIO + Selector ~3000个 中 崩溃⑥
第四代 epoll 事件驱动 10000+ 中高 全部解决

# 10.2 第一代:BIO单线程——阻塞的噩梦

直接用最直觉的方式写——accept 一个客户端,处理完再 accept 下一个。

// 第一代:BIO 单线程版本
public class ChatServerV1 {
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(8080);
        System.out.println("V1 聊天服务启动,端口 8080");
        
        List<Socket> clients = new ArrayList<>();
        
        while (true) {
            // ⚠️ 阻塞1:等待新连接
            Socket client = server.accept();
            clients.add(client);
            System.out.println("新客户端连接,当前在线:" + clients.size());
            
            // ⚠️ 阻塞2:读取这个客户端的数据
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(client.getInputStream()));
            String msg;
            while ((msg = reader.readLine()) != null) {
                System.out.println("收到:" + msg);
                // 转发给所有客户端 ⚠️ 阻塞3:write 可能阻塞
                for (Socket c : clients) {
                    PrintWriter writer = new PrintWriter(c.getOutputStream(), true);
                    writer.println(msg);
                }
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

问题诊断:

为什么一个用户就卡住所有其他用户?

时间线:
  t0: accept()  → 客户端A连接,进入A的read循环
  t1: read(A)   → 阻塞,等待A发消息
  t2: 客户端B尝试连接 → accept()根本不会被调用(还在A的read里)
  t3: 客户端C尝试连接 → 同上
  
结果:整个服务被第一个连接的用户A"绑架"了
1
2
3
4
5
6
7
8
9

性能测试:

V1 压测结果(100个客户端依次连接):
  第1个客户端:连接成功,消息正常
  第2个客户端:connect timeout(服务端还在read第1个)
  其余客户端:全部 timeout
  
结论:只能服务1个用户。对应第6.1节——"一个线程只服务一个连接"的极限。
1
2
3
4
5
6

# 10.3 第二代:多线程版本——线程的陷阱

给每个客户端分配一个线程,这样就不会互相阻塞了。

// 第二代:BIO 多线程版本
public class ChatServerV2 {
    private static List<PrintWriter> allWriters = 
        Collections.synchronizedList(new ArrayList<>());
    
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(8080);
        System.out.println("V2 聊天服务启动,端口 8080");
        
        while (true) {
            Socket client = server.accept();
            // 每个客户端一个线程
            new Thread(() -> handleClient(client)).start();
        }
    }
    
    private static void handleClient(Socket client) {
        try {
            PrintWriter writer = new PrintWriter(client.getOutputStream(), true);
            allWriters.add(writer);
            
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(client.getInputStream()));
            String msg;
            while ((msg = reader.readLine()) != null) {
                // 广播消息
                synchronized (allWriters) {
                    for (PrintWriter w : allWriters) {
                        w.println(msg);
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // ⚠️ 问题:readLine()返回null=客户端正常关闭
            // 但如果客户端网络断了(拔网线),readLine()可能永远不会返回
            // 这就是僵尸连接!(对应崩溃⑤)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

问题诊断:

V2 的三个致命缺陷:

缺陷1:线程爆炸
  1000个连接 = 1000个线程
  每个线程默认栈空间 1MB = 1GB 内存只用于栈!
  此外还有1000次/秒的上下文切换开销

缺陷2:僵尸连接(崩溃⑤)
  客户端kill -9 → OS发RST,readLine抛异常 ✓(能检测到)
  客户端拔网线/切WiFi → 无任何信号 → readLine永远阻塞 ✗
  
缺陷3:广播锁竞争
  1000个线程同时广播 → 争抢 synchronized(allWriters) → 排队
1
2
3
4
5
6
7
8
9
10
11
12
13

性能测试:

V2 压测结果:
  100连接:正常,CPU 5%
  500连接:正常,CPU 30%
  800连接:CPU 85%,消息延迟 2-5秒
  1000连接:部分客户端 connect timeout,CPU 100%
  
瓶颈:800线程的上下文切换吃光CPU
结论:BIO多线程最多撑到500-800连接。对应第6.2节的多线程模型分析。
1
2
3
4
5
6
7
8

# 10.4 第三代:NIO+Selector——非阻塞的曙光

用一个 Selector 监控所有 Channel,只处理有数据的连接。

// 第三代:NIO + Selector 版本
public class ChatServerV3 {
    private Selector selector;
    private ServerSocketChannel serverChannel;
    
    public ChatServerV3(int port) throws IOException {
        selector = Selector.open();
        serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(port));
        serverChannel.configureBlocking(false);  // 非阻塞
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
    }
    
    public void start() throws IOException {
        System.out.println("V3 聊天服务启动");
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        
        while (true) {
            selector.select();  // 阻塞,直到有事件
            
            Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
            while (keys.hasNext()) {
                SelectionKey key = keys.next();
                keys.remove();
                
                if (key.isAcceptable()) {
                    // 新连接
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel client = server.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                    System.out.println("新连接:" + client.getRemoteAddress());
                    
                } else if (key.isReadable()) {
                    // 有数据可读
                    SocketChannel client = (SocketChannel) key.channel();
                    buffer.clear();
                    int bytesRead = client.read(buffer);
                    
                    if (bytesRead == -1) {
                        // 客户端关闭
                        client.close();
                    } else {
                        buffer.flip();
                        byte[] data = new byte[buffer.remaining()];
                        buffer.get(data);
                        String msg = new String(data);
                        System.out.println("收到:" + msg);
                        // 广播给所有客户端
                        broadcast(msg);
                    }
                }
            }
        }
    }
    
    private void broadcast(String msg) throws IOException {
        ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
        for (SelectionKey key : selector.keys()) {
            if (key.channel() instanceof SocketChannel && key.isValid()) {
                SocketChannel client = (SocketChannel) key.channel();
                client.write(buffer.duplicate());
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

关键改进:

V3 相比 V2 的核心变化:

1. 非阻塞模式:configureBlocking(false)
   → 一个线程处理所有连接,不再为每个连接开线程

2. Selector 事件驱动:
   → 只处理有事件(新连接/有新数据)的Channel
   → 没有事件的Channel不消耗CPU

3. 理论上的优势:
   → 10000个连接 = 1个线程(而不是10000个线程)
   → 内存:几MB(而不是10GB)
1
2
3
4
5
6
7
8
9
10
11
12

性能测试:

V3 压测结果(使用 select 而非 epoll):
  1000连接:正常,CPU 15%
  3000连接:CPU 35%,延迟正常
  5000连接:CPU 55%,开始出现延迟抖动
  6000连接:java.io.IOException: Too many open files
  
瓶颈1:select 的1024限制 → 在Java中可以用-Dsun.nio.ch.maxUpdateArraySize调大
                    但本质问题仍在——select每次需要轮询所有fd
瓶颈2:单线程广播10000个write() → write可能阻塞 → 需要一个线程池处理写操作
瓶颈3:无粘包/拆包处理 → 需要用长度前缀(第8.1节方案3)
1
2
3
4
5
6
7
8
9
10

# 10.5 第四代:epoll事件驱动——C10K的答案

改用 epoll(Linux 内核 2.6+)作为底层实现,并引入 Reactor 线程模型。

// 第四代:基于 epoll 的主从 Reactor 模型
public class ChatServerV4 {
    // 主 Reactor:专门处理 accept
    private EventLoop bossGroup;
    // 从 Reactor:处理 read/write
    private EventLoop[] workerGroup;
    
    public ChatServerV4(int port, int workerCount) throws IOException {
        // 在 Linux 上,Java NIO Selector 底层已自动使用 epoll
        // 但我们需要合理的线程模型来充分发挥 epoll 的优势
        this.bossGroup = new EventLoop("boss");
        this.workerGroup = new EventLoop[workerCount];
        for (int i = 0; i < workerCount; i++) {
            workerGroup[i] = new EventLoop("worker-" + i);
        }
        
        // boss 监听端口
        bossGroup.register(port);
    }
}

// 事件循环(模拟 Netty 的 EventLoop)
class EventLoop {
    private final String name;
    private final Selector selector;
    private final Queue<Runnable> taskQueue = new ConcurrentLinkedQueue<>();
    
    public void register(SocketChannel channel, int ops) {
        // 线程安全地注册到 selector
        taskQueue.offer(() -> {
            channel.register(selector, ops);
        });
        selector.wakeup();
    }
    
    public void run() {
        while (true) {
            selector.select();  // Linux上自动使用 epoll_wait
            // 处理 IO 事件...
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

关键设计决策(这些正是 Netty 做的):

V4 的核心设计选择:

1. 主从Reactor:
   Boss线程:只管accept,不处理业务
   Worker线程:负责读写 + 业务处理
   优势:accept和IO分离,互不阻塞

2. 每个Worker一个Selector + epoll:
   多线程共享epoll反而有锁开销
   每个线程独立epoll实例,无锁竞争

3. 零拷贝:
   利用FileChannel.transferTo()代替传统的read+write
   数据直接从内核缓冲区到目标Socket,绕过用户空间

4. 内存池:
   复用ByteBuffer,减少GC压力

5. 粘包处理:
   使用长度前缀编解码器(对应第8.1节方案3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

性能测试:

V4 压测结果(8核CPU,4个Worker线程):
  1000连接:  CPU 5%,  消息延迟 &lt;1ms
  5000连接:  CPU 12%, 消息延迟 &lt;3ms
  10000连接: CPU 25%, 消息延迟 &lt;8ms
  20000连接: CPU 48%, 消息延迟 &lt;15ms
  50000连接: CPU 72%, 消息延迟 &lt;40ms(瓶颈转向网络带宽)
  
瓶颈分析:
  - 不再是连接数限制(epoll支持百万级fd)
  - 不再是CPU(4个线程足够处理)
  - 瓶颈变成了网络带宽 + 内存带宽
1
2
3
4
5
6
7
8
9
10
11

# 10.6 四种方案横向对比

用一张大表把四个版本的差异全部摊开:

维度 V1 BIO单线程 V2 BIO多线程 V3 NIO+Select V4 epoll+Reactor
线程数 1 1/连接 1 N (=CPU核数)
并发上限 1 ~500 ~3000 50000+
1万连接内存 — ~10GB ~50MB ~80MB
1万连接CPU — 100% — ~25%
消息延迟(1万连接) — 5-10s ~200ms <8ms
粘包处理 无 无 无 ○
断线检测 无 无 无 ○(心跳)
对应知识点 5.6基础案例 6.2多线程模型 6.3 select 6.3 epoll
对应崩溃 ③ ③⑤ ⑥ 全部解决
性能曲线(连接数 vs 吞吐量):

    吞吐量(QPS)
    │
    │                    V4 ─────────────────────
    │                 /
    │              /
    │           /    V3 ────
    │        /      /
    │     /    V2 /
    │  /      /
    │/    V1 (就是一根横线...)
    └──────────────────────────────→ 连接数
         500   1000  3000  10000  50000
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 10.7 从案例看Netty设计

看到这里,你应该明白了——V4 版本本质就是在实现一个迷你 Netty。Netty 的设计选择现在回头看就非常清晰:

Netty 设计 对应 V4 的实践 解决的核心问题
主从Reactor线程模型 Boss + Worker EventLoop accept vs IO 分离
EpollEventLoopGroup 每个线程独立epoll 无锁化高性能
ChannelPipeline 消息处理器链 粘包→解码→业务→编码(责任链)
ByteBuf 内存池 复用 ByteBuffer 零GC、零拷贝
IdleStateHandler 心跳检测器 僵尸连接自动剔除
SO_REUSEADDR 地址复用 崩溃① 重启不报错
TCP_NODELAY 禁用Nagle 崩溃④ 消灭40ms延迟
Netty 的本质:
  不是发明了新东西,而是把 Socket 编程 40 年的最佳实践
  ——epoll、Reactor、零拷贝、内存池、责任链——
  封装成一个高性能、易扩展的框架,
  让开发者不用再像小赵那样一一踩坑。
1
2
3
4
5

# 10.8 全文知识图谱回顾

走到这里,我们用"聊天服务的四次进化"把全文的核心都串了起来。最后用一张图收敛所有知识点:

                    小赵的七次崩溃
                    │
        ┌───────────┼───────────┐
        │           │           │
    ①重启报错    ②消息串了    ③800极限
        │           │           │
        ▼           ▼           ▼
    SO_REUSEADDR  粘包/拆包   IO多路复用
    TIME_WAIT     长度前缀    select/poll/epoll
    [第07章]      [第08章]    [第06章]
                                │
        ┌───────────────────────┤
        │                       │
    ④40ms延迟              ⑤僵尸连接
        │                       │
        ▼                       ▼
    Nagle算法              Keep-Alive
    TCP_NODELAY            应用层心跳
    [第07章]               [第07/08章]
        │                       │
        └───────────┬───────────┘
                    │
              ⑥文件描述符耗尽
                    │
                    ▼
              Socket=文件
              ulimit/epoll优化
              [第06章]
                    │
                    ▼
              ⑦ backlog丢连接
                    │
                    ▼
              listen队列
              [第03章]
                    │
        ┌───────────┴───────────┐
        │                       │
    V1→V2→V3→V4              Netty设计
    四次进化                  最佳实践封装
    [第10章]                 [第10.7节]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

最终的方法论沉淀——设计一个网络服务时,都应该问自己三个问题:

  1. 连接模型是什么?(BIO/NIO/epoll → 能撑多少并发)
  2. 数据边界在哪?(粘包/拆包怎么解决 → 协议设计是否健壮)
  3. 异常怎么处理?(心跳、重连、TIME_WAIT、文件描述符 → 稳定性能不能兜底)

把这三个问题问到位,你就从"会写 Socket 代码"进化到了"能设计网络架构的工程师"。

# 11.思考题与作业

# 11.1 基础思考题

  1. 为什么 TCP 是字节流:TCP 为什么不保留消息边界?这是设计缺陷还是有意为之?(提示:从 MSS、滑动窗口、重传效率三个角度思考)

  2. accept 队列 vs SYN 队列:listen(fd, backlog) 中的 backlog 控制的是哪个队列?另一个队列被什么参数控制?回顾崩溃⑦,K8s 的健康检查为什么会被 backlog 影响?

  3. TIME_WAIT 的技术必要性:如果去掉 TIME_WAIT,在什么场景下会出现数据错乱?请用具体的四元组例子说明。

  4. select vs epoll 的复杂度差异:为什么说 select 是 O(n),epoll 是 O(1)?这个 n 是什么?在 100 vs 10000 vs 100000 个连接时表现差多少?

# 11.2 进阶思考题

  1. 崩溃④的深入分析:Nagle 算法和 Delayed ACK 都是为提高效率而设计的,为什么同时启用反而成了性能杀手?如果让你设计一个"自动检测并规避这个死锁"的方案,你会怎么做?

  2. mmap 与 epoll 的关系:epoll 内部使用了 mmap 来映射事件数组(第 6.3 节提到过),这和第 4.7 节(计算机原理篇)中 MMKV 用的 mmap 是同一个东西。它们都解决什么问题?为什么"零拷贝"在这两个场景都如此关键?

  3. Go goroutine vs Java NIO:Go 用 goroutine(每个连接一个协程)+ 底层 epoll;Java NIO 用 Selector + 线程池。哪个方案更容易写?哪个性能更高?在什么场景下会出现差异?

  4. QUIC 对 Socket 编程的颠覆:QUIC 把可靠传输从内核搬到用户态(第 9.4 节)。这意味着开发者可以"改 TCP 的行为"而不需要升级内核。请预测:10 年后的"Socket 编程"会变成什么样?BSD Socket API 还会存在吗?

# 11.3 动手作业

作业一(必做):复现本章第 10 节聊天服务的四个版本。

  • 用你熟悉的语言(Java/Python/Go)实现 V1~V4 四个版本。
  • 自己写一个简单的压测客户端,模拟 500/1000/5000 个连接同时发消息。
  • 把实测结果填入下表,与第 10.6 节的数据对比:
版本 500连接延迟 1000连接延迟 5000连接延迟 瓶颈原因
V1
V2
V3
V4

作业二(选做):给 V4 版本加上完整的粘包处理。

  • 用长度前缀方案(4字节头 + 消息体)实现一个编解码器。
  • 测试:连续发送 100 条 200 字节的消息,检查接收端是否每条都完整。
  • 增加"半包测试":发送方每次只发 50 字节,接收方要能正确拼接。

作业三(架构思考):分析你熟悉的某个开源项目的网络层设计。

  • 任选一个(Netty、Redis、Nginx、gRPC、Kafka……),画出它的 Socket 架构图。
  • 标注:用了什么 IO 模型?有没有 Reactor 模式?有几个 EventLoop?怎么处理粘包?
  • 这张图画出来,你对"高性能网络编程"的理解会立刻上一个台阶。
上次更新: 2026/06/09, 15:47:57
传输协议TCP和UDP
传输数据的设计思想

← 传输协议TCP和UDP 传输数据的设计思想→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式