3. TCP客户端服务器模型:阻塞式TCP通信、三次握手与四次挥手
各位同学,今天我们来聊聊TCP通信中最基础、也是最核心的一块——阻塞式TCP通信模型。说实话,我刚开始做网络编程那会儿,觉得TCP不就是个可靠传输嘛,写个客户端连上服务器就完事了。结果呢?第一次上线就被线上问题教做人了。
嗯,咱们今天就把这块彻底讲透。你想想看,TCP通信的本质是什么?说白了就是两个进程之间,通过操作系统内核帮忙,建立一条可靠的、双向的字节流通道。而阻塞式模型,就是最直观、最容易理解的那一套。
3.1 阻塞式TCP通信的基本流程
先看服务端。服务端的代码逻辑,我习惯把它总结成五个步骤:
- 创建socket——向内核申请一个套接字资源
- 绑定地址——把socket和IP、端口绑在一起
- 监听——告诉内核,我准备好接客了
- 接受连接——阻塞等待客户端上门
- 收发数据——用accept返回的新socket通信
客户端就更简单了:创建socket,然后直接connect。connect这个函数,一调用就会触发三次握手。
核心要点:服务端的accept和客户端的connect,都是阻塞函数。调用后线程会挂起,直到条件满足才返回。这是阻塞式模型最显著的特征。
3.2 三次握手——你看到的只是冰山一角
很多教材把三次握手画成客户端发SYN、服务端回SYN+ACK、客户端再回ACK。没错,流程是对的。但我在项目中遇到过一个问题:客户端connect成功了,服务端accept还没返回,这时候数据能发吗?
答案是:能发,但不建议发。为什么?因为三次握手完成后,TCP的发送缓冲区就已经就绪了。但应用层的accept还没拿到新的socket描述符,数据到了服务端内核,没人读。这就可能造成数据堆积。
我曾经踩过这个坑。一个高并发服务,客户端连上来就发一大包数据,服务端accept稍微慢一点,客户端就超时重传。后来怎么解决的?在accept之前,先设置好非阻塞或者调整内核的backlog参数。
三次握手的本质,说白了就是双方互相确认:你的发送能力没问题,我的接收能力也没问题。SYN包相当于问一句「在吗?」,ACK包相当于回一句「在的,你说」。
| 步骤 | 发送方 | 接收方 | 状态变化 |
|---|---|---|---|
| 第一次 | 客户端 → SYN → | 服务端 | 客户端: SYN_SENT |
| 第二次 | 客户端 | ← SYN+ACK ← 服务端 | 服务端: SYN_RCVD |
| 第三次 | 客户端 → ACK → | 服务端 | 双方: ESTABLISHED |
小技巧:用tcpdump抓包看三次握手,你会发现SYN包的序列号是随机的。这是为了防止TCP序列号预测攻击。我建议你亲自抓一次包看看,比看十遍书都管用。
3.3 四次挥手——优雅地分手
挥手比握手复杂一点。为什么?因为TCP是全双工的,两边都要关闭自己的通道。
四次挥手的流程:
- 主动关闭方发FIN,表示「我这边没数据要发了」
- 被动关闭方回ACK,表示「知道了,但我可能还有数据要发」
- 被动关闭方发FIN,表示「我这边也发完了」
- 主动关闭方回ACK,表示「收到,拜拜」
这里有个关键点:TIME_WAIT状态。主动关闭方在发送最后一个ACK之后,会进入TIME_WAIT状态,持续2MSL(最大报文生存时间)的时间。为什么要有这个状态?
我举个例子你就明白了。假设最后一个ACK丢了,被动关闭方没收到,它会重发FIN。如果主动关闭方直接关闭了,收到这个重发的FIN就没办法处理了。TIME_WAIT就是为了处理这种「迟到的报文」。
注意:TIME_WAIT状态在高并发服务器上是个大问题。一个端口只能处理有限数量的TIME_WAIT连接。我曾经在一个游戏服务器上,因为TIME_WAIT堆积太多,导致新连接无法建立。后来用了SO_REUSEADDR选项才解决。
3.4 阻塞式模型的优缺点
阻塞式模型,说白了就是「一个线程伺候一个连接」。优点是简单、直观、不容易出错。缺点是啥?连接多了线程就多了,线程多了上下文切换就频繁了,性能就下来了。
我个人的习惯是:原型开发用阻塞式,生产环境用非阻塞或IO多路复用。但不管用哪种模型,三次握手和四次挥手的原理是不变的。你理解了阻塞式,后面学epoll、kqueue就轻松多了。
3.5 代码示例:一个最简单的echo服务器
// 服务端代码(阻塞式)
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5);
while(1) {
int client_fd = accept(server_fd, NULL, NULL);
// 这里阻塞,直到有客户端连接
char buf[1024];
int n = read(client_fd, buf, sizeof(buf));
// 这里也阻塞,直到收到数据
write(client_fd, buf, n);
close(client_fd);
}
// 客户端代码
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr));
// connect返回,说明三次握手完成
char *msg = "Hello, TCP!";
write(sock, msg, strlen(msg));
char buf[1024];
int n = read(sock, buf, sizeof(buf));
printf("收到: %s\n", buf);
close(sock);
建议:把这段代码跑起来,然后用netstat -anp看看连接状态。你会发现三次握手和四次挥手的状态变化,比任何文字描述都直观。
3.6 知识体系总览
下面这张图,是我自己总结的TCP阻塞式通信的核心逻辑。你看一眼,就能把整个流程串起来。
这张图把客户端和服务端的步骤对应起来了。你注意看中间的三次握手和四次挥手,它们是在内核层面自动完成的。应用层只需要调用connect、accept、close这些接口就行。
好了,阻塞式TCP通信的核心内容就这些。你把这个模型吃透了,后面学非阻塞、多路复用、异步IO,都会轻松很多。记住一句话:TCP通信的本质,就是两个进程之间,通过内核这座桥,可靠地传递字节流。
总结:阻塞式模型虽然简单,但它是理解所有网络编程模型的基础。三次握手建立连接,四次挥手断开连接,中间的数据收发都是阻塞的。这个模型适合连接数少、逻辑简单的场景。连接多了,就得考虑用IO多路复用了。