4、TCP Socket编程流程:服务端与客户端的完整通信流程,三次握手与四次挥手在Socket中的体现
TCP(Transmission Control Protocol)是面向连接的、可靠的字节流传输协议。在Socket编程中,TCP的通信流程严格遵循“连接建立—数据传输—连接释放”三阶段模型。理解服务端与客户端在Socket API调用上的对应关系,以及三次握手、四次挥手在内核协议栈与用户态API之间的映射,是编写健壮网络程序的基础。本章将从API调用时序、状态迁移、异常处理三个维度,拆解TCP Socket的完整生命周期。
上图展示了TCP Socket从创建到关闭的完整时序。服务端通过 socket()→bind()→listen()→accept() 四步进入监听状态,客户端通过 socket()→connect() 发起连接。三次握手发生在 connect() 与 accept() 之间,四次挥手则发生在任一方调用 close() 之后。下面逐一拆解每个阶段的原理与编程要点。
4.1 核心原理:Socket API与TCP状态机的映射
TCP协议栈在内核中维护了一个状态机,Socket API是用户态操作该状态机的唯一入口。理解API调用如何驱动状态迁移,是排查网络问题的关键。
| API调用 | 服务端/客户端 | 触发TCP状态变化 | 阻塞行为 |
|---|---|---|---|
socket() | 双方 | 创建文件描述符,初始状态CLOSED | 立即返回 |
bind() | 服务端(客户端可选) | 绑定IP和端口,状态不变 | 立即返回 |
listen() | 服务端 | CLOSED → LISTEN | 立即返回 |
connect() | 客户端 | SYN_SENT → ESTABLISHED | 阻塞直到握手完成或超时 |
accept() | 服务端 | 从已完成队列取连接,返回新fd | 阻塞直到有连接到达 |
close() | 任一方 | FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT | 立即返回,内核异步完成挥手 |
值得注意的是,accept() 并不参与三次握手。握手由内核协议栈自动完成,accept() 只是从“已完成连接队列”中取出一个已经建立好的连接。这意味着即使服务端来不及调用 accept(),内核也能完成多个客户端的握手,只是队列长度受 listen() 的 backlog 参数限制。
4.2 操作流程:服务端与客户端的标准编程骨架
以下代码展示了TCP通信的最小完整实现。服务端使用多线程处理并发连接,客户端模拟发送与接收。
/* 服务端代码 (server.c) */
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void* handle_client(void* arg) {
int client_fd = *(int*)arg;
char buffer[1024] = {0};
// 接收客户端数据
int n = read(client_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
printf("收到: %s\n", buffer);
// 回显
write(client_fd, buffer, n);
}
close(client_fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = INADDR_ANY
};
// 设置端口复用,避免TIME_WAIT导致重启失败
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(server_fd, 5); // backlog=5
while (1) {
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &len);
pthread_t tid;
pthread_create(&tid, NULL, handle_client, &client_fd);
pthread_detach(tid);
}
close(server_fd);
return 0;
}
/* 客户端代码 (client.c) */
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
int main() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
.sin_addr.s_addr = inet_addr("127.0.0.1")
};
// 三次握手发生在此处
if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
return 1;
}
char *msg = "Hello TCP";
write(sock, msg, strlen(msg));
char buffer[1024] = {0};
read(sock, buffer, sizeof(buffer));
printf("服务端回应: %s\n", buffer);
close(sock); // 四次挥手开始
return 0;
}
编译运行:gcc server.c -o server -lpthread && gcc client.c -o client。先启动 ./server,再启动 ./client,即可看到回显效果。
4.3 案例拆解:一次完整的连接与断开过程
假设客户端连接服务端后立即发送“PING”,服务端回“PONG”,然后双方关闭连接。我们通过 tcpdump 抓包观察实际交互。
抓包命令:sudo tcpdump -i lo -nn -S port 8080
关键输出(已简化):
1 0.000000 127.0.0.1.50000 > 127.0.0.1.8080: Flags [S], seq 1000
2 0.000021 127.0.0.1.8080 > 127.0.0.1.50000: Flags [S.], seq 2000, ack 1001
3 0.000042 127.0.0.1.50000 > 127.0.0.1.8080: Flags [.], ack 2001
4 0.000100 127.0.0.1.50000 > 127.0.0.1.8080: Flags [P.], seq 1001:1005, ack 2001
5 0.000120 127.0.0.1.8080 > 127.0.0.1.50000: Flags [.], ack 1005
6 0.000150 127.0.0.1.8080 > 127.0.0.1.50000: Flags [P.], seq 2001:2005, ack 1005
7 0.000170 127.0.0.1.50000 > 127.0.0.1.8080: Flags [.], ack 2005
8 0.000200 127.0.0.1.50000 > 127.0.0.1.8080: Flags [F.], seq 1005
9 0.000220 127.0.0.1.8080 > 127.0.0.1.50000: Flags [.], ack 1006
10 0.000250 127.0.0.1.8080 > 127.0.0.1.50000: Flags [F.], seq 2005
11 0.000270 127.0.0.1.50000 > 127.0.0.1.8080: Flags [.], ack 2006
分析过程:
- 第1-3包:三次握手。客户端初始序列号seq=1000,服务端seq=2000。注意SYN包消耗一个序列号,因此ack=1001。
- 第4-7包:数据传输。客户端发送5字节“PING”(seq 1001:1005),服务端确认ack=1005;服务端回“PONG”(seq 2001:2005),客户端确认ack=2005。
- 第8-11包:四次挥手。客户端先发FIN(seq=1005),服务端确认ack=1006;服务端随后发FIN(seq=2005),客户端确认ack=2006。注意服务端的FIN和ACK分开发送,这是四次挥手的典型特征。
风险与代价:客户端在发送FIN后进入TIME_WAIT状态,持续2MSL(约2分钟)。如果服务端立即重启并绑定同一端口,可能因端口占用而失败。解决方案是服务端设置 SO_REUSEADDR,如代码所示。
4.4 常见误区与总结复盘
误区1:认为accept()参与三次握手。实际上握手由内核完成,accept()只是从队列取连接。如果accept()调用过慢,backlog队列满后,新连接会被内核丢弃或客户端收到RST。
误区2:忽略TIME_WAIT状态。主动关闭方(通常是客户端)会进入TIME_WAIT,这是为了确保最后一个ACK到达对端。高并发短连接场景下,服务端也可能成为主动关闭方,导致大量TIME_WAIT连接占用端口。
误区3:混淆close()与shutdown()。close()会立即释放文件描述符,但若socket被多个进程共享,引用计数不为0则不会触发FIN。shutdown()可以单独关闭读或写方向,更精细地控制挥手过程。
经验总结:
- 服务端务必设置
SO_REUSEADDR,避免重启时端口被TIME_WAIT占用。 - 客户端connect()的超时时间默认约75秒,可通过
setsockopt(SO_SNDTIMEO)缩短。 - 使用
netstat -tpn或ss -tn查看连接状态,排查卡在SYN_SENT或FIN_WAIT_1的问题。 - 对于长连接,建议应用层增加心跳机制,防止TCP连接因中间设备超时被静默断开。
掌握TCP Socket的完整生命周期,是编写可靠网络程序的基础。下一阶段的学习应聚焦于I/O多路复用(select/poll/epoll),以解决多连接并发场景下的资源效率问题。