3. TCP客户端服务器模型:阻塞式TCP通信、三次握手与四次挥手

各位同学,今天我们来聊聊TCP通信中最基础、也是最核心的一块——阻塞式TCP通信模型。说实话,我刚开始做网络编程那会儿,觉得TCP不就是个可靠传输嘛,写个客户端连上服务器就完事了。结果呢?第一次上线就被线上问题教做人了。

嗯,咱们今天就把这块彻底讲透。你想想看,TCP通信的本质是什么?说白了就是两个进程之间,通过操作系统内核帮忙,建立一条可靠的、双向的字节流通道。而阻塞式模型,就是最直观、最容易理解的那一套。

3.1 阻塞式TCP通信的基本流程

先看服务端。服务端的代码逻辑,我习惯把它总结成五个步骤:

  1. 创建socket——向内核申请一个套接字资源
  2. 绑定地址——把socket和IP、端口绑在一起
  3. 监听——告诉内核,我准备好接客了
  4. 接受连接——阻塞等待客户端上门
  5. 收发数据——用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阻塞式通信的核心逻辑。你看一眼,就能把整个流程串起来。

TCP阻塞式通信核心流程 客户端 服务端 1. socket() 创建套接字 2. connect() 发起连接 3. 三次握手(内核完成) 4. send() / recv() 通信 5. close() 四次挥手 1. socket() 创建套接字 2. bind() 绑定地址端口 3. listen() 进入监听状态 4. accept() 接受连接(阻塞) 5. recv() / send() 通信 SYN SYN+ACK ACK 数据流 FIN ACK FIN ACK 阻塞点:connect()、accept()、recv() 都会阻塞当前线程

这张图把客户端和服务端的步骤对应起来了。你注意看中间的三次握手和四次挥手,它们是在内核层面自动完成的。应用层只需要调用connect、accept、close这些接口就行。

好了,阻塞式TCP通信的核心内容就这些。你把这个模型吃透了,后面学非阻塞、多路复用、异步IO,都会轻松很多。记住一句话:TCP通信的本质,就是两个进程之间,通过内核这座桥,可靠地传递字节流。

总结:阻塞式模型虽然简单,但它是理解所有网络编程模型的基础。三次握手建立连接,四次挥手断开连接,中间的数据收发都是阻塞的。这个模型适合连接数少、逻辑简单的场景。连接多了,就得考虑用IO多路复用了。

可可精品资料库