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 已经帮你做好了。
咱们来看一张图,把四层模型和对应的协议串起来:
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%。正确的做法是让操作系统告诉你什么时候可以读写。
最后,咱们用一张图总结阻塞与非阻塞的核心区别:
好了,第一课就到这里。TCP/IP 协议栈让你知道数据怎么走,Socket 模型让你知道代码怎么写,阻塞与非阻塞让你知道怎么选。这些基础打牢了,后面讲稳定性方案的时候你才能理解为什么这么做。