4. TCP 客户端与服务端实现:单进程迭代服务器、客户端实现、代码调试与测试
好,咱们今天来点真家伙。前面几章把 TCP 协议和 Socket API 的理论讲了个七七八八,现在该动手了。这一章的目标很明确:写一个能跑起来的单进程迭代服务器,再写一个客户端跟它对话。说白了,就是让你亲眼看着数据从客户端发出去,经过网络栈,被服务器收进来,再原路返回。
我个人习惯,学网络编程一定要先跑通这个最小闭环。哪怕你以后做高并发、做异步框架,这个基础模型的理解深度,直接决定了你排查问题的速度。我在项目里见过太多人,一上来就怼 epoll、怼多线程,结果连最基本的 connect 超时都说不清楚——嗯,咱们不干那种事。
4.1 单进程迭代服务器的核心模型
什么叫「单进程迭代服务器」?说白了就是:一个进程,一次只服务一个客户端,等这个客户端断开连接了,才去 accept 下一个。它不并发,不 fork,不搞线程池,就是最朴素的 while 循环。
核心流程:
- 创建 socket → 绑定地址 → 监听 → 进入循环
- 调用 accept 阻塞等待客户端连接
- 连接建立后,循环 recv/send 处理该客户端的数据
- 客户端断开后,关闭连接 socket,回到步骤 2
你想想看,这个模型虽然简单,但它把 TCP 服务端最核心的几个状态都暴露出来了:LISTEN、ESTABLISHED、CLOSE_WAIT、TIME_WAIT。我当年第一次调通这个模型的时候,用 netstat 观察连接状态变化,那种感觉就像第一次看清了齿轮怎么咬合——很踏实。
4.2 服务端代码实现
来,直接上代码。我习惯把错误处理写得比较啰嗦,因为在实际项目中,你花在查「为什么连不上」上的时间,远比写业务逻辑多。
// tcp_server.c - 单进程迭代服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.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 = sizeof(client_addr);
char buffer[BUFFER_SIZE];
ssize_t bytes_read;
// 1. 创建 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 2. 设置地址复用,避免 TIME_WAIT 导致 bind 失败
int opt = 1;
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 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 failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// 4. 监听
if (listen(server_fd, 5) < 0) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 5. 主循环:迭代处理客户端
while (1) {
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept failed");
continue;
}
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
printf("New client connected: %s:%d\n", client_ip, ntohs(client_addr.sin_port));
// 6. 处理单个客户端的数据
while ((bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
buffer[bytes_read] = '\0';
printf("Received: %s", buffer);
// 回显数据
send(client_fd, buffer, bytes_read, 0);
}
if (bytes_read == 0) {
printf("Client disconnected: %s:%d\n", client_ip, ntohs(client_addr.sin_port));
} else {
perror("recv error");
}
close(client_fd);
}
close(server_fd);
return 0;
}
我的一点经验:SO_REUSEADDR 这个选项,新手经常忽略。你调试时 Ctrl+C 杀掉服务器,端口会进入 TIME_WAIT 状态,如果不设置这个选项,紧接着重启就会 bind 失败。我曾经因为这个浪费了半小时,后来就养成习惯了——每个服务端 socket 都加上它。
4.3 客户端代码实现
客户端相对简单:创建 socket,connect 到服务器,然后循环发送数据并接收回显。我故意让客户端从标准输入读取,这样你可以手动敲数据测试。
// tcp_client.c - TCP 客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define SERVER_IP "127.0.0.1"
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int sock_fd;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];
ssize_t bytes_sent, bytes_received;
// 1. 创建 socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
// 2. 配置服务器地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
perror("invalid address");
close(sock_fd);
exit(EXIT_FAILURE);
}
// 3. 连接服务器
if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
close(sock_fd);
exit(EXIT_FAILURE);
}
printf("Connected to server %s:%d\n", SERVER_IP, PORT);
// 4. 数据收发循环
while (1) {
printf("Enter message (or 'quit' to exit): ");
if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
break;
}
// 去掉换行符
buffer[strcspn(buffer, "\n")] = '\0';
if (strcmp(buffer, "quit") == 0) {
break;
}
bytes_sent = send(sock_fd, buffer, strlen(buffer), 0);
if (bytes_sent < 0) {
perror("send failed");
break;
}
bytes_received = recv(sock_fd, buffer, BUFFER_SIZE - 1, 0);
if (bytes_received < 0) {
perror("recv failed");
break;
}
buffer[bytes_received] = '\0';
printf("Server echoed: %s\n", buffer);
}
close(sock_fd);
printf("Connection closed.\n");
return 0;
}
4.4 编译与调试
代码写完了,怎么跑起来?我建议你开两个终端窗口,一个跑服务器,一个跑客户端。这样能直观看到两边同时输出的日志。
编译步骤
# 编译服务器
gcc -o tcp_server tcp_server.c
# 编译客户端
gcc -o tcp_client tcp_client.c
运行与测试
- 终端1:启动服务器
./tcp_server Server listening on port 8888... - 终端2:启动客户端
./tcp_client Connected to server 127.0.0.1:8888 Enter message (or 'quit' to exit): - 输入数据测试
Enter message (or 'quit' to exit): hello Server echoed: hello Enter message (or 'quit' to exit): world Server echoed: world
注意:如果你在客户端输入中文,可能会遇到编码问题。recv 是按字节收的,中文 UTF-8 编码占 3 个字节,如果 buffer 大小不是 3 的倍数,可能出现半个汉字的情况。生产环境中建议使用 protobuf 或 JSON 等结构化协议,不要裸发字符串。
4.5 常见问题与调试技巧
跑通是第一步,但跑通之后你可能会遇到各种奇怪的问题。我把自己踩过的坑列出来,你遇到了可以直接对照排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| bind 失败:Address already in use | 端口被占用,或上次连接处于 TIME_WAIT | 用 netstat -tlnp | grep 8888 查看;加上 SO_REUSEADDR |
| connect 超时 | 服务器没启动,或防火墙拦截 | 用 telnet 127.0.0.1 8888 测试端口是否可达 |
| recv 返回 -1 | 连接被对端重置(RST) | 检查是否在 send 后立即 close 了 socket |
| 客户端输入后无响应 | send 的数据没带换行符,服务器 recv 阻塞 | 确认 fgets 或 send 的数据长度正确 |
调试利器:我强烈建议你学会用 strace 和 tcpdump。strace 可以追踪系统调用,看 socket、bind、connect 每一步的返回值;tcpdump 可以抓包,看 TCP 三次握手和四次挥手的具体报文。这两个工具用熟了,网络编程基本没有你查不了的问题。
4.6 单进程模型的局限性
嗯,这里要说点实话。单进程迭代服务器虽然简单直观,但它的性能瓶颈很明显:
- 串行处理:一个客户端没处理完,其他客户端只能排队等 accept
- 阻塞 I/O:如果客户端发完数据后不主动断开,服务器会一直卡在 recv 上
- 无并发能力:无法同时服务多个客户端,适合教学和原型验证
我在早期做 IoT 网关时,曾经用这个模型接几十个设备,结果一个设备断连异常,整个服务就卡死了。后来才改成多进程 + select 模型。不过话说回来,没有这个基础,你后面理解多路复用、事件驱动也会很吃力。所以,先把这一步走扎实。
本章小结:你学会了单进程迭代服务器的完整实现,包括 socket 创建、bind、listen、accept 以及客户端的 connect 和收发数据。你还掌握了基本的调试方法和常见问题的排查思路。这个模型虽然简单,但它是所有复杂网络服务的地基。