3. TCP客户端与服务端:TCP三次握手与四次挥手、bind/listen/accept/connect函数详解、简单的回射服务器
说实话,TCP 这块内容,是网络编程里最绕不开的硬骨头。我当年刚入行时,被三次握手和四次挥手折磨得够呛——背了无数遍状态图,一到写代码就懵。后来在项目里踩过几次坑,才真正理解这些机制到底在干什么。
今天咱们就把 TCP 客户端和服务端的核心逻辑拆开揉碎。从握手挥手,到那几个关键函数,最后撸一个简单的回射服务器。嗯,跟着我走一遍,你也能写出健壮的 TCP 程序。
3.1 TCP 三次握手:连接是怎么建立的?
先问一个问题:客户端和服务端之间,怎么知道对方准备好了?
TCP 用三次握手来解决这个问题。说白了,就是双方互相确认「我在,你也在,咱们可以聊了」。
三次握手流程:
- 客户端 → 服务端:SYN — 「我想跟你建立连接,这是我的初始序列号 x」
- 服务端 → 客户端:SYN + ACK — 「收到,我准备好了,这是我的初始序列号 y,我确认收到了你的 x」
- 客户端 → 服务端:ACK — 「好,我确认收到了你的 y,咱们开始通信吧」
为什么是三次,不是两次?我举个例子你就明白了。假设只有两次握手:客户端发 SYN,服务端回 ACK,连接就算建立了。但如果这个 SYN 在网络中延迟了,客户端超时重传了一个新的 SYN,服务端又回了 ACK。这时候第一个延迟的 SYN 到了,服务端会误以为客户端又要建立新连接,白白浪费资源。三次握手能避免这种「历史重复连接」的问题。
我在项目中遇到过一个问题:客户端频繁重连时,服务端出现大量 TIME_WAIT 状态的连接。后来排查发现,是客户端没有正确处理三次握手中的 ACK 丢失场景。嗯,这个后面讲四次挥手时会细说。
3.2 TCP 四次挥手:连接是怎么关闭的?
有建立就有关闭。四次挥手比握手复杂一点,因为 TCP 连接是全双工的——双方可以同时收发数据。关闭时,每一方向都要单独关闭自己的发送通道。
四次挥手流程:
- 主动关闭方 → 被动关闭方:FIN — 「我这边数据发完了,我要关闭发送通道」
- 被动关闭方 → 主动关闭方:ACK — 「收到你的 FIN,但我还有数据要发,你先等着」
- 被动关闭方 → 主动关闭方:FIN — 「我这边数据也发完了,可以关了」
- 主动关闭方 → 被动关闭方:ACK — 「收到,我关闭连接」
注意第二步和第三步之间,被动关闭方可能还在发送数据。这就是为什么挥手需要四次,而不是三次。
我曾经踩过一个坑:服务端主动关闭连接后,没有处理 TIME_WAIT 状态。结果端口被占用,导致服务端无法立即重启。后来我在代码里加了 SO_REUSEADDR 选项,才解决这个问题。
避坑指南:主动关闭方在发送最后一个 ACK 后,会进入 TIME_WAIT 状态,持续 2MSL(约 1-4 分钟)。这是为了确保被动关闭方收到了 ACK。如果 ACK 丢失,被动关闭方会重发 FIN,主动关闭方需要重新响应。
3.3 核心函数详解:bind/listen/accept/connect
这几个函数是 TCP 编程的基石。我习惯把它们分成两组:服务端用的(bind、listen、accept)和客户端用的(connect)。
3.3.1 bind() — 绑定地址和端口
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind 的作用,就是把 socket 和一个具体的 IP 地址、端口号绑定在一起。服务端必须调用 bind,否则客户端不知道往哪连。
有几个细节要注意:
- 端口号小于 1024 的需要 root 权限。我刚开始写程序时,老用 80 端口,结果 bind 总是失败,后来才发现是权限问题。
- 可以绑定
INADDR_ANY(0.0.0.0),表示监听所有网络接口。这在服务器有多块网卡时特别有用。 - 如果端口被占用,bind 会返回 -1,错误码
EADDRINUSE。这时候可以用SO_REUSEADDR选项来重用地址。
3.3.2 listen() — 开始监听
int listen(int sockfd, int backlog);
listen 把 socket 从主动模式变成被动模式,开始等待客户端连接。backlog 参数指定了连接队列的最大长度。
你想想看,如果客户端连接来得太快,服务端来不及处理,这些连接就会排队。backlog 就是队列的上限。超过这个数,客户端会收到 ECONNREFUSED。
我的经验:backlog 不要设太大,一般 128 或 256 就够了。设太大反而会掩盖服务端处理能力不足的问题。我曾经在一个高并发项目里把 backlog 设成 1024,结果服务端崩溃时,队列里积压了大量连接,恢复后全部超时,雪上加霜。
3.3.3 accept() — 接受连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept 从连接队列里取出一个已完成三次握手的连接,返回一个新的 socket 文件描述符。这个新 socket 专门用于和客户端通信,原来的监听 socket 继续等待新连接。
accept 是阻塞的——如果没有新连接,它会一直等。当然,你可以用非阻塞模式或者 select/epoll 来避免阻塞。
3.3.4 connect() — 发起连接
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect 是客户端用的。它发起三次握手,如果成功返回 0,失败返回 -1。
connect 也是阻塞的,默认会等待 75 秒(不同系统略有差异)才超时。我在写客户端程序时,一般会设置 SO_SNDTIMEO 来控制超时时间,避免用户等太久。
3.4 简单的回射服务器
理论说完了,咱们写个实际能跑的程序。回射服务器(echo server)是最简单的 TCP 服务:客户端发什么,服务端原样返回什么。
3.4.1 服务端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
char buffer[BUFFER_SIZE];
ssize_t n;
// 1. 创建 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(1);
}
// 2. 设置地址重用(避免 TIME_WAIT 问题)
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(server_fd);
exit(1);
}
// 4. 开始监听
if (listen(server_fd, 128) < 0) {
perror("listen");
close(server_fd);
exit(1);
}
printf("Echo server listening on port %d...\n", PORT);
// 5. 循环接受连接
while (1) {
client_len = sizeof(client_addr);
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
continue;
}
printf("New client connected\n");
// 6. 回射数据
while ((n = read(client_fd, buffer, BUFFER_SIZE)) > 0) {
write(client_fd, buffer, n);
}
printf("Client disconnected\n");
close(client_fd);
}
close(server_fd);
return 0;
}
3.4.2 客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int sock_fd;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];
ssize_t n;
// 1. 创建 socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket");
exit(1);
}
// 2. 设置服务器地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// 3. 连接服务器
if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sock_fd);
exit(1);
}
printf("Connected to server\n");
// 4. 发送数据并接收回射
while (1) {
printf("Enter message: ");
if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
break;
}
write(sock_fd, buffer, strlen(buffer));
n = read(sock_fd, buffer, BUFFER_SIZE);
if (n <= 0) {
break;
}
buffer[n] = '\0';
printf("Echo: %s", buffer);
}
close(sock_fd);
return 0;
}
3.5 核心流程可视化
下面这张图把整个 TCP 通信流程串起来了。从 socket 创建,到三次握手建立连接,再到数据收发,最后四次挥手关闭连接。你对照着代码看,会更清晰。
3.6 几个容易踩的坑
代码写完了,图也画了,我再分享几个实际项目中遇到的坑:
- bind 失败:端口被占用是最常见的。用
netstat -tlnp | grep 端口号查一下谁在用。记得加SO_REUSEADDR。 - accept 返回 -1:如果客户端在三次握手完成前断开了连接,accept 可能返回
ECONNABORTED。这时候不要 panic,继续 accept 就行。 - read 返回 0:表示对方关闭了连接。很多新手会忽略这个,导致死循环。记住,read 返回 0 就是对方说拜拜了。
- write 被信号中断:write 返回 -1 且 errno 是
EINTR时,重试即可。这不是真正的错误。
一个小技巧:调试 TCP 程序时,用 strace 跟踪系统调用,能清楚看到每一步发生了什么。比如 strace -e trace=network ./server,所有 socket 相关的调用都会打印出来。我排查连接问题时,这招屡试不爽。
好了,TCP 客户端和服务端的基础就讲到这里。代码你可以直接拿去跑,跑通了再回头看看三次握手和四次挥手的流程,会有更深的理解。
公众号:蓝海资料掘金营,微信deep3321