TCP通信:从握手到挥手,再到并发服务器
说实话,TCP这块内容,是网络编程里最绕不开的硬骨头。我当年刚接触时,也被三次握手、四次挥手搞得晕头转向。但等你真正理解了,就会发现它其实挺符合直觉的——就像两个人打电话,总得先确认对方在听,才能开始聊,聊完了还得说声再见。
今天咱们就把TCP通信的来龙去脉捋一遍。从最基础的握手挥手,到客户端/服务器模型,再到多进程和多线程的并发服务器。嗯,内容不少,但都是干货。
TCP三次握手:建立连接的过程
为什么叫三次握手?说白了,就是为了确认双方的收发能力都没问题。
我第一次在项目中调试TCP连接时,抓包看到SYN、SYN-ACK、ACK这三个包,才真正理解了它的意义。你想想看:
- 第一次握手:客户端发送SYN包,告诉服务器“我想跟你建立连接”。此时客户端进入SYN_SENT状态。
- 第二次握手:服务器收到SYN,回复SYN-ACK,意思是“我收到了,我也准备好了”。服务器进入SYN_RCVD状态。
- 第三次握手:客户端收到SYN-ACK,再发一个ACK,表示“好的,我知道了”。双方都进入ESTABLISHED状态。
这里有个细节我提醒一下:第三次握手是可以携带数据的。但前两次不行,因为连接还没建立。我在项目中就见过有人试图在SYN包里塞数据,结果被对端丢弃了——嗯,白费功夫。
核心要点:三次握手最少需要2个RTT(往返时间),但实际应用中,第三次握手的数据可以捎带,节省一次RTT。
TCP四次挥手:优雅地断开连接
挥手比握手多一次,原因在于TCP是全双工的。说白了,两边都得独立地关闭自己的通道。
我记得有一次排查线上问题,发现大量TIME_WAIT状态的连接。当时就意识到,挥手过程没处理好,会导致端口被占用很久。
四次挥手的流程是这样的:
- 第一次挥手:主动关闭方发送FIN,表示“我这边没数据要发了”。进入FIN_WAIT_1状态。
- 第二次挥手:被动关闭方回复ACK,表示“知道了”。但此时被动方可能还有数据要发,所以不急着关闭。进入CLOSE_WAIT状态。
- 第三次挥手:被动关闭方数据发完了,发送FIN,表示“我也没数据了”。进入LAST_ACK状态。
- 第四次挥手:主动关闭方回复ACK,然后进入TIME_WAIT状态,等待2MSL后彻底关闭。
避坑指南:TIME_WAIT状态会持续2MSL(约1-4分钟)。我曾经在压测时没注意这个,导致端口耗尽,新连接建立不了。解决方案是调整内核参数或使用SO_REUSEADDR选项。
TCP客户端/服务器模型
最基本的模型,就是单进程、单线程的阻塞式通信。代码结构很清晰:
// 服务器端伪代码
socket() → bind() → listen() → accept() → recv()/send() → close()
// 客户端伪代码
socket() → connect() → send()/recv() → close()
这个模型的问题很明显:一次只能服务一个客户端。如果accept()之后,第二个客户端连上来,就得排队等着。我刚开始写网络程序时就用这个模型,结果一压测就卡死——太天真了。
所以,我们需要并发服务器。
多进程并发服务器
思路很简单:每次accept()到一个新连接,就fork()一个子进程去处理。父进程继续accept()。
代码骨架大概是这样:
while (1) {
client_fd = accept(server_fd, ...);
if (fork() == 0) {
// 子进程
close(server_fd);
handle_client(client_fd);
close(client_fd);
exit(0);
}
// 父进程
close(client_fd);
}
这里有个坑:子进程要记得关闭server_fd,父进程要关闭client_fd。否则文件描述符会泄漏。我见过一个线上事故,就是因为父进程没关client_fd,导致系统fd耗尽——嗯,教训深刻。
个人建议:多进程模型适合CPU密集型任务,因为每个进程独立调度。但进程间通信麻烦,而且创建进程开销大。如果并发量不大(几百以内),这个模型够用。
多线程并发服务器
多线程模型跟多进程类似,但用线程代替进程。线程共享地址空间,创建开销小,通信也方便。
void *handle_client(void *arg) {
int client_fd = *(int *)arg;
// 处理客户端...
close(client_fd);
return NULL;
}
while (1) {
client_fd = accept(server_fd, ...);
pthread_create(&tid, NULL, handle_client, &client_fd);
pthread_detach(tid); // 分离线程,自动回收资源
}
注意:这里我把client_fd传给了线程,但主循环里可能很快又accept()到新连接,覆盖了client_fd的值。我建议用动态分配内存的方式传参:
int *pfd = malloc(sizeof(int));
*pfd = client_fd;
pthread_create(&tid, NULL, handle_client, pfd);
多线程模型适合I/O密集型任务,但要注意线程安全问题。比如多个线程同时操作全局变量,就得加锁。
两种模型的对比
| 特性 | 多进程 | 多线程 |
|---|---|---|
| 资源开销 | 高(独立地址空间) | 低(共享地址空间) |
| 通信方式 | IPC(管道、共享内存等) | 直接读写全局变量 |
| 隔离性 | 好(进程间互不影响) | 差(一个线程崩溃可能影响整个进程) |
| 适用场景 | CPU密集型、高稳定性要求 | I/O密集型、高并发低延迟 |
说实话,没有银弹。我个人的习惯是:如果并发连接数超过1000,我会考虑用事件驱动模型(如epoll),而不是多进程或多线程。但那是后话了。
TCP状态转换图
下面这张图是我自己画的,把三次握手和四次挥手的完整状态流转都标出来了。你仔细看一遍,基本就能理解TCP的状态机了。
这张图里,左侧是客户端的状态流转,右侧是服务器端。箭头上的序号①到⑦对应了三次握手和四次挥手的完整过程。你仔细看,从CLOSED开始,经过三次握手到达ESTABLISHED,再经过四次挥手回到CLOSED——这就是一个TCP连接的生命周期。
好了,TCP通信的基础内容就这些。从握手挥手的基本原理,到两种并发模型的实现,再到它们各自的优缺点,我觉得已经讲得比较清楚了。实际项目中怎么选,还得看你的具体场景。我个人的经验是:先跑通单进程模型,再根据压力测试结果决定要不要上并发。