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

各位同学好,我是老张。做网络编程这行快十五年了,踩过的坑比写过的代码还多。今天咱们开始第一课,先把地基打牢。

Socket 编程,说白了就是让两台机器能互相传数据。但你别小看这层抽象,我见过太多人连三次握手都说不清楚就开始写代码,结果线上出了故障抓瞎。咱们今天把 TCP/IP 协议栈、Socket 通信模型、阻塞与非阻塞这三个核心概念彻底捋一遍。

1.1 TCP/IP 协议栈:数据是怎么从 A 到 B 的?

你想想看,你在浏览器里输入一个网址,回车,页面就出来了。这背后数据到底经历了什么?

TCP/IP 协议栈分四层,每一层都有自己的职责。我习惯把它比作一个快递系统:

  • 应用层:你填的快递单,写明了收件人、地址、物品名称。对应 HTTP、FTP、SMTP 这些协议。
  • 传输层:快递公司分拣中心,决定走陆运还是空运,保证包裹不丢、不乱序。对应 TCP、UDP。
  • 网络层:货车司机看地图,规划从北京到上海走哪条高速。对应 IP 协议。
  • 网络接口层:实际的路面、桥梁、收费站。对应以太网、Wi-Fi 这些物理介质。

数据从应用层往下走,每经过一层就加一个头部,这叫封装。到了接收端再一层层拆开。嗯,这里要注意:每一层只关心自己的事,应用层不管数据走哪条路,网络层不管数据是 HTTP 还是 FTP。

核心要点:TCP/IP 协议栈的分层设计,让每一层可以独立演进。我在项目中遇到过有人试图在应用层做 TCP 的可靠传输,结果性能一塌糊涂——你没必要重复造轮子,TCP 已经帮你做好了。

咱们来看一张图,把四层模型和对应的协议串起来:

TCP/IP 四层协议栈与数据封装流程 应用层 HTTP / FTP / SMTP / DNS / WebSocket 数据:用户原始消息(如 HTTP 请求体) 传输层 TCP(可靠) / UDP(不可靠) 封装:加上源端口、目标端口、序号、校验和 网络层 IPv4 / IPv6 / ICMP / ARP 封装:加上源 IP、目标 IP、TTL、协议类型 网络接口层 以太网 / Wi-Fi / PPP / 光纤 封装:加上源 MAC、目标 MAC、帧校验 发送端:每层加头部 → 接收端:每层拆头部。数据在传输过程中,中间路由器只看网络层。

1.2 Socket 通信模型:从代码角度看网络通信

Socket 是什么?说白了就是操作系统提供的一个编程接口。你不需要自己拼 TCP 头部,不需要管 IP 路由,调几个 API 就能收发数据。

我刚开始学的时候,觉得 Socket 就是个文件描述符。你打开一个文件,读写,关闭。Socket 也一样——只不过读写的是网络数据。

咱们看一个最经典的 TCP 服务端/客户端模型:

// 服务端伪代码
socket()          // 创建套接字
bind()            // 绑定 IP 和端口
listen()          // 开始监听
accept()          // 等待客户端连接(阻塞在这里)
recv() / send()   // 收发数据
close()           // 关闭连接

// 客户端伪代码
socket()          // 创建套接字
connect()         // 连接服务端(三次握手在这里发生)
send() / recv()   // 收发数据
close()           // 关闭连接

这个流程看起来简单,但每个环节都有坑。我举个例子:accept() 返回的客户端 fd,如果不及时处理,新连接就会排队。队列满了怎么办?客户端会收到 ECONNREFUSED。

个人经验:我在做即时通讯项目时,服务端用单线程 accept + fork 子进程处理每个客户端。当时觉得挺合理,结果连接数一上千,系统直接挂了——fork 的开销太大了。后来改用 epoll + 线程池,才稳住。

咱们把 TCP 三次握手和四次挥手的过程,用表格对比一下:

阶段 方向 报文 状态变化(服务端) 状态变化(客户端)
三次握手 客户端 → 服务端 SYN LISTEN → SYN_RCVD CLOSED → SYN_SENT
三次握手 服务端 → 客户端 SYN + ACK SYN_RCVD SYN_SENT → ESTABLISHED
三次握手 客户端 → 服务端 ACK SYN_RCVD → ESTABLISHED ESTABLISHED
四次挥手 主动方 → 被动方 FIN ESTABLISHED → CLOSE_WAIT ESTABLISHED → FIN_WAIT_1
四次挥手 被动方 → 主动方 ACK CLOSE_WAIT FIN_WAIT_1 → FIN_WAIT_2
四次挥手 被动方 → 主动方 FIN CLOSE_WAIT → LAST_ACK FIN_WAIT_2 → TIME_WAIT
四次挥手 主动方 → 被动方 ACK LAST_ACK → CLOSED TIME_WAIT → CLOSED

避坑指南:我曾经在生产环境遇到过大量 TIME_WAIT 状态的连接,导致新连接无法建立。原因是服务端主动关闭连接,客户端没有及时回收。解决方案是调整 tcp_tw_reuse 和 tcp_tw_recycle 参数,或者让客户端主动关闭。

1.3 阻塞与非阻塞模式:两种截然不同的编程思维

阻塞模式,说白了就是调用一个函数,没结果就不返回。比如 recv(),如果对端没发数据,线程就卡在那。非阻塞模式呢?调用立即返回,有数据就返回数据,没数据就返回错误码 EAGAIN 或 EWOULDBLOCK。

这两种模式没有绝对的好坏,看场景。我习惯这样选:

  • 阻塞模式:适合连接数少、逻辑简单的场景。比如一个客户端连一个服务端,收发数据顺序明确。代码好写,不容易出错。
  • 非阻塞模式:适合高并发、连接数多的场景。比如服务端同时处理几千个连接,你不能为每个连接开一个线程——线程切换的开销太大了。

咱们用代码对比一下:

// 阻塞模式:简单直接
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, ...);  // 阻塞直到连接建立或超时
recv(sock, buf, 1024, 0);  // 阻塞直到收到数据或连接断开
printf("收到数据: %s\n", buf);

// 非阻塞模式:需要轮询或事件驱动
int sock = socket(AF_INET, SOCK_STREAM, 0);
fcntl(sock, F_SETFL, O_NONBLOCK);  // 设置为非阻塞
connect(sock, ...);  // 立即返回,连接在后台进行
// 用 select/poll/epoll 监听可读事件
struct pollfd fds[1];
fds[0].fd = sock;
fds[0].events = POLLIN;
poll(fds, 1, 5000);  // 等待 5 秒
if (fds[0].revents & POLLIN) {
    recv(sock, buf, 1024, 0);  // 此时一定可读
    printf("收到数据: %s\n", buf);
}

你想想看,阻塞模式就像你去银行柜台办业务,排队等,办完走人。非阻塞模式就像你取了号,先去逛商场,手机收到通知再回来。哪个效率高?看情况。如果银行只有 1 个窗口,你逛商场可能错过号。如果银行有 100 个窗口,你傻等就是浪费生命。

核心要点:非阻塞模式必须配合 I/O 多路复用(select/poll/epoll)使用,否则就是空转浪费 CPU。我在项目中见过有人用非阻塞 + 死循环轮询,CPU 直接飙到 100%。正确的做法是让操作系统告诉你什么时候可以读写。

最后,咱们用一张图总结阻塞与非阻塞的核心区别:

阻塞 vs 非阻塞:核心区别 阻塞模式 • 调用 recv() 后线程挂起 • 直到数据到达才返回 • 每个连接需要一个线程 非阻塞模式 • 调用 recv() 立即返回 • 无数据时返回 EAGAIN • 一个线程可管理数千连接 如何选择? • 连接数 < 100,逻辑简单 → 阻塞模式,代码清晰,调试方便 • 连接数 > 1000,高并发 → 非阻塞 + epoll,资源利用率高 • 混合场景:用阻塞模式处理控制面,非阻塞模式处理数据面

好了,第一课就到这里。TCP/IP 协议栈让你知道数据怎么走,Socket 模型让你知道代码怎么写,阻塞与非阻塞让你知道怎么选。这些基础打牢了,后面讲稳定性方案的时候你才能理解为什么这么做。