1. Socket 基础回顾:TCP/IP 协议栈、Socket 通信模型、阻塞与非阻塞 IO 概念

好,咱们正式开始。第一章我打算先带大家把地基夯实。Socket 编程这东西,说白了就是让两个程序能在网络上聊天。但别小看这个“聊天”,背后的门道深着呢。我见过太多人,框架用得飞起,结果线上出了个丢包问题,抓瞎一整天——最后发现是最基础的 TCP 状态机没搞明白。

所以这一章,咱们不急着写代码。先把 TCP/IP 协议栈、Socket 通信模型、还有阻塞与非阻塞 IO 这几个核心概念,用我自己的理解捋一遍。你想想看,如果连数据是怎么封装、怎么拆解的都搞不清,后面调优的时候,你连问题出在哪一层都不知道。

1.1 TCP/IP 协议栈:数据是怎么“打包”的?

我个人习惯把 TCP/IP 协议栈想象成一个快递公司。你发一个包裹,从家里到收件人手里,中间要经过好几个环节。

  • 应用层:就是你写的程序,比如 HTTP、FTP。它只关心“我要发什么内容”,不关心怎么发。
  • 传输层:相当于快递公司的分拣中心。TCP 在这里负责把数据切成小块,编上号,保证顺序不乱,丢了还能重发。UDP 就比较糙,扔出去就不管了。
  • 网络层:就是快递的运输路线规划。IP 协议在这里决定数据包走哪条路,从北京到上海,中间可能经过天津、济南。
  • 链路层:最后一段路,比如快递员骑着电动车从网点送到你家门口。它负责在同一个局域网内传输。

数据从应用层往下走,每经过一层,都会在数据前面加一个头部(Header)。这个过程叫封装。接收方收到数据后,从下往上走,每层拆掉一个头部,这叫解封装。

核心要点:调优的时候,你得清楚问题出在哪一层。延迟高,可能是网络层路由跳数多;丢包重传,那多半是传输层 TCP 的锅;如果是应用层协议解析慢,那就得优化你的业务代码。

我记得有一次帮客户排查一个视频流卡顿的问题。他们怀疑是服务器带宽不够。我抓包一看,好家伙,TCP 重传率高达 15%。根本不是带宽的事,是客户端那边的网络丢包太严重。这就是典型的传输层问题,你光加带宽没用。

TCP/IP 协议栈数据封装与解封装流程 发送方 应用层 (Data) 传输层 (TCP Header + Data) 网络层 (IP Header + ...) 链路层 (Frame Header + ...) 物理网络 (比特流传输) 接收方 链路层 (拆 Frame) 网络层 (拆 IP) 传输层 (拆 TCP) 应用层 (Data)

1.2 Socket 通信模型:不就是个“管道”吗?

Socket,中文叫“套接字”。名字挺唬人,其实它就是操作系统提供的一个编程接口,让你能通过文件描述符(File Descriptor)来操作网络数据。说白了,你把它当成一个双向的管道就行。你往管道这头写数据,另一头的程序就能读到。

一个典型的 TCP Socket 通信流程是这样的:

  1. 服务端:先创建一个 Socket,然后 bind 到一个 IP 和端口上,接着 listen 开始监听。这时候服务端就“蹲”在那里等客户上门。
  2. 客户端:也创建一个 Socket,然后调用 connect 去连接服务端的 IP 和端口。
  3. 三次握手:客户端和服务端之间,操作系统内核会自动完成 TCP 的三次握手。握手成功后,连接就建立了。
  4. 数据传输:双方都可以用 send 和 recv 来读写数据。
  5. 四次挥手:通信结束,任意一方调用 close,开始四次挥手断开连接。

避坑指南:我曾经遇到一个新手,服务端代码里 bind 之后忘了 listen,结果客户端一直连不上。他查了半天,以为是防火墙的问题。所以,写代码的时候,这几个步骤的顺序千万别搞错。

这里有个关键点:listen 的时候,可以指定一个 backlog 参数。这个参数决定了内核为这个监听 Socket 维护的已完成连接队列的最大长度。如果客户端连接来得太快,你来不及 accept,队列满了之后,新的连接就会被内核直接拒绝。嗯,这里要注意,backlog 设得太小,高并发下会丢连接;设得太大,又可能耗尽系统资源。

Socket 函数 作用 常见坑点
socket() 创建一个套接字,指定协议族和类型 忘记检查返回值,创建失败还继续用
bind() 将 Socket 绑定到本地地址和端口 端口被占用时返回 EADDRINUSE
listen() 将 Socket 设为监听模式 backlog 参数设置不合理
accept() 从已完成连接队列中取出一个连接 阻塞模式下,没有新连接时会卡住
connect() 客户端主动发起连接 非阻塞模式下,返回 EINPROGRESS 是正常的
send() / recv() 发送和接收数据 返回值可能小于请求长度,需要循环处理
close() 关闭 Socket 连接 多线程环境下,要小心引用计数

1.3 阻塞与非阻塞 IO:你到底等不等?

这是新手和老手的分水岭。阻塞和非阻塞,说的是调用 IO 函数(比如 recv、send、accept)时,线程的行为模式。

阻塞模式:你调用 recv,如果内核缓冲区里没数据,你的线程就“睡”在那里,一直等到有数据来了才返回。这就像你打电话给客服,对方让你“请稍等,正在查询”,然后你就一直拿着电话等,啥也干不了。

非阻塞模式:你调用 recv,如果没数据,它立刻返回一个错误码(比如 EAGAIN 或 EWOULDBLOCK)。你的线程不用等,可以继续干别的事。这就像你发了个消息,对方没回,你该干嘛干嘛,过一会儿再看一眼。

警告:非阻塞模式不是“不阻塞”,而是“不等待”。它只是把“等待”的责任从内核抛给了应用程序。如果你用非阻塞模式,却在一个 while 循环里不停地轮询,那 CPU 占用率会直接飙到 100%,比阻塞模式还糟糕。

我个人习惯,在写高性能服务器时,几乎不用纯阻塞模式。为什么呢?因为一个线程只能处理一个连接,太浪费了。你想想看,如果服务端有 1000 个连接,难道要开 1000 个线程吗?线程切换的开销就能把 CPU 拖垮。

那非阻塞模式怎么用?通常配合 IO 多路复用(比如 select、poll、epoll)。你把这些非阻塞的 Socket 注册到 epoll 里,然后让一个线程去 epoll_wait。内核会告诉你:“嘿,这个 Socket 有数据可读了,那个 Socket 可以写数据了。” 这时候你再去调用 recv 或 send,基本不会阻塞,因为内核已经保证条件满足了。

这里有个对比表格,一目了然:

特性 阻塞 IO 非阻塞 IO
线程行为 等待数据,线程挂起 立即返回,线程继续运行
CPU 利用率 低(线程休眠不占 CPU) 低(配合 epoll 时) / 高(轮询时)
并发能力 差(一个线程一个连接) 强(一个线程管理数千连接)
编程复杂度 简单 较高(需要处理 EAGAIN)
典型应用 低并发、简单脚本 高并发服务器(Nginx, Redis)

核心要点:性能调优的第一步,就是选对 IO 模型。如果你的应用是内部工具,连接数不超过几十个,用阻塞模式加多线程,开发快,维护简单。但如果你要做高并发网关或游戏服务器,必须上非阻塞 + IO 多路复用。别犹豫。