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状态的连接。当时就意识到,挥手过程没处理好,会导致端口被占用很久。

四次挥手的流程是这样的:

  1. 第一次挥手:主动关闭方发送FIN,表示“我这边没数据要发了”。进入FIN_WAIT_1状态。
  2. 第二次挥手:被动关闭方回复ACK,表示“知道了”。但此时被动方可能还有数据要发,所以不急着关闭。进入CLOSE_WAIT状态。
  3. 第三次挥手:被动关闭方数据发完了,发送FIN,表示“我也没数据了”。进入LAST_ACK状态。
  4. 第四次挥手:主动关闭方回复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的状态机了。

TCP状态转换图(三次握手 + 四次挥手) CLOSED(客户端) SYN_SENT ESTABLISHED FIN_WAIT_1 TIME_WAIT CLOSED(服务器) LISTEN SYN_RCVD ESTABLISHED CLOSE_WAIT ① SYN (seq=x) ② SYN-ACK (seq=y, ack=x+1) ③ ACK (seq=x+1, ack=y+1) ④ FIN (seq=u) ⑤ ACK (ack=u+1) ⑥ FIN (seq=v) ⑦ ACK (ack=v+1) 客户端主动 服务器主动

这张图里,左侧是客户端的状态流转,右侧是服务器端。箭头上的序号①到⑦对应了三次握手和四次挥手的完整过程。你仔细看,从CLOSED开始,经过三次握手到达ESTABLISHED,再经过四次挥手回到CLOSED——这就是一个TCP连接的生命周期。

好了,TCP通信的基础内容就这些。从握手挥手的基本原理,到两种并发模型的实现,再到它们各自的优缺点,我觉得已经讲得比较清楚了。实际项目中怎么选,还得看你的具体场景。我个人的经验是:先跑通单进程模型,再根据压力测试结果决定要不要上并发。