Linux服务器编程实践92-TCP服务器与客户端的工作流程:从socket创建到连接关闭
在Linux网络编程中,TCP协议因其可靠、面向连接的特性,成为大多数网络应用的首选传输层协议。本文将从实践角度出发,详细拆解TCP服务器与客户端从socket创建到连接关闭的完整工作流程,结合代码示例和可视化图表,帮助开发者深入理解TCP通信的底层逻辑。
一、TCP通信的核心概念铺垫
在深入流程前,需明确两个核心前提:
- socket的本质:在Linux中,socket是"文件描述符"的一种,用于标识网络通信的端点,遵循"一切皆文件"的设计哲学。
- TCP的面向连接特性:通信前需通过"三次握手"建立连接,通信后需通过"四次挥手"关闭连接,确保数据可靠传输。
TCP通信模型示意图

二、TCP服务器的工作流程(从初始化到连接处理)
TCP服务器的核心任务是:监听指定端口、接受客户端连接、处理请求并返回响应。完整流程分为5个关键步骤。
步骤1:创建socket文件描述符
通过socket()系统调用创建TCP类型的socket,指定协议族(IPv4)、服务类型(流服务)和默认协议。
#include
#include
#include
#include
#include
int main() {
// 创建TCP socket:PF_INET(IPv4)、SOCK_STREAM(流服务)、0(默认TCP协议)
int listen_fd = socket(PF_INET, SOCK_STREAM, 0);
assert(listen_fd != -1); // 检查创建是否成功
printf("Socket创建成功,文件描述符:%d
", listen_fd);
// 后续步骤...
return 0;
}
注意:socket创建后默认是"阻塞模式",即调用如accept()、recv()等函数时会阻塞进程,直到事件就绪。
步骤2:绑定socket到指定IP和端口
通过bind()将socket与服务器的IP地址和端口号关联,确保客户端能通过该地址找到服务器。
// 定义IPv4 socket地址结构
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr)); // 初始化内存为0
server_addr.sin_family = AF_INET; // IPv4协议族
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网卡IP
server_addr.sin_port = htons(8080); // 绑定8080端口(主机字节序转网络字节序)
// 绑定操作
int ret = bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
assert(ret != -1);
printf("Socket绑定成功,IP:0.0.0.0,端口:8080
", listen_fd);
关键函数:htonl()和htons()用于将主机字节序(小端)转换为网络字节序(大端),避免不同架构机器间的字节序混乱。
步骤3:将socket设为监听状态
通过listen()将socket从"主动"状态转为"监听"状态,创建监听队列存储待处理的客户端连接。
// 监听队列最大长度为5(半连接+全连接队列,内核2.2后仅表示全连接队列)
int backlog = 5;
ret = listen(listen_fd, backlog);
assert(ret != -1);
printf("Socket进入监听状态,监听队列长度:%d
", backlog);
监听队列结构示意图

步骤4:接受客户端连接
通过accept()从监听队列中取出一个已完成三次握手的连接,返回新的"连接socket"(专门用于与该客户端通信)。
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
// 接受连接,获取客户端地址信息
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
assert(conn_fd != -1);
// 打印客户端信息
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
printf("接受客户端连接:IP=%s,端口=%d,连接socket:%d
",
client_ip, ntohs(client_addr.sin_port), conn_fd);
核心区别:listen_fd仅用于监听新连接,conn_fd用于与特定客户端的读写通信,一个服务器通常只有一个listen_fd,但可有多个conn_fd(对应多个客户端)。
步骤5:与客户端通信(读写数据)
通过recv()读取客户端发送的数据,处理后通过send()返回响应。
#define BUF_SIZE 1024
char buffer[BUF_SIZE];
// 读取客户端数据
ssize_t recv_len = recv(conn_fd, buffer, BUF_SIZE-1, 0);
if (recv_len == 0) {
printf("客户端主动关闭连接
");
close(conn_fd);
return 0;
}
buffer[recv_len] = ' '; // 确保字符串结束
printf("收到客户端数据:%s
", buffer);
// 处理请求(示例:返回"Hello, Client!")
const char* response = "Hello, Client! I'm TCP Server.";
ssize_t send_len = send(conn_fd, response, strlen(response), 0);
printf("发送响应长度:%zd字节
", send_len);
三、TCP客户端的工作流程(从连接到通信)
客户端的流程相对简单,核心是"主动发起连接",无需监听端口,具体分为3个步骤。
步骤1:创建socket(同服务器)
int client_fd = socket(PF_INET, SOCK_STREAM, 0);
assert(client_fd != -1);
printf("客户端Socket创建成功,文件描述符:%d
", client_fd);
步骤2:发起连接请求
通过connect()向服务器发起TCP连接,需指定服务器的IP和端口。
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
// 服务器IP(示例:192.168.1.108)
inet_pton(AF_INET, "192.168.1.108", &server_addr.sin_addr);
server_addr.sin_port = htons(8080); // 服务器端口
// 发起连接
int ret = connect(client_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
assert(ret != -1);
printf("成功连接到服务器:192.168.1.108:8080
");
连接超时处理:默认情况下connect()会阻塞约75秒(内核参数tcp_syn_retries控制),实际开发中可通过"非阻塞connect"或设置socket超时选项优化。
步骤3:与服务器通信
发送请求数据并接收服务器响应。
// 发送请求
const char* request = "Hello, Server! I'm TCP Client.";
send(client_fd, request, strlen(request), 0);
printf("发送请求:%s
", request);
// 接收响应
char buffer[BUF_SIZE];
ssize_t recv_len = recv(client_fd, buffer, BUF_SIZE-1, 0);
buffer[recv_len] = ' ';
printf("收到服务器响应:%s
", buffer);
四、TCP连接的关闭流程(四次挥手)
TCP连接是"全双工"的,关闭时需分别关闭读端和写端,通过close()或shutdown()实现,底层对应"四次挥手"过程。
TCP四次挥手示意图

1. 客户端主动关闭(常见场景)
客户端调用close()关闭连接,发送FIN报文,触发四次挥手。
// 客户端关闭连接
close(client_fd);
printf("客户端连接关闭
");
2. 服务器被动关闭
服务器检测到客户端关闭(recv()返回0),关闭对应的conn_fd。
// 服务器侧:读取到客户端关闭信号(recv返回0)
if (recv_len == 0) {
printf("客户端主动关闭,服务器关闭连接
");
close(conn_fd); // 关闭连接socket
}
3. 半关闭场景(特殊需求)
若需"关闭写端但保留读端"(如客户端发送完请求后,等待服务器返回大文件),可使用shutdown()。
// 半关闭:关闭写端,保留读端
shutdown(conn_fd, SHUT_WR);
// 此时仍可通过recv()读取服务器数据
ssize_t len = recv(conn_fd, buffer, BUF_SIZE-1, 0);
// 数据读取完毕后,关闭读端
shutdown(conn_fd, SHUT_RD);
close(conn_fd);
五、完整流程总结与常见问题
| 角色 | 核心步骤 | 关键系统调用 | 常见问题 |
|---|---|---|---|
| 服务器 | 创建socket → 绑定 → 监听 → 接受连接 → 通信 → 关闭 | socket()、bind()、listen()、accept()、recv()/send()、close() | 端口被占用(EADDRINUSE)、监听队列满(连接被拒绝) |
| 客户端 | 创建socket → 发起连接 → 通信 → 关闭 | socket()、connect()、recv()/send()、close() | 连接超时(ETIMEDOUT)、服务器拒绝连接(ECONNREFUSED) |
常见问题解决方案
- 端口被占用:使用
netstat -ntlp | grep 端口号查看占用进程,或设置SO_REUSEADDR选项重用端口。 - 连接超时:通过
setsockopt()设置SO_SNDTIMEO和SO_RCVTIMEO,或使用非阻塞I/O。 - 监听队列满:增大
listen()的backlog参数,或优化服务器处理连接的速度。
六、实践扩展:多客户端并发处理思路
上述示例为"串行服务器"(一次处理一个客户端),实际开发中需支持多并发,常见方案有:
- 多进程/多线程:每接受一个连接创建一个子进程/子线程处理。
- I/O复用:通过
select()/poll()/epoll()同时监听多个socket。 - 进程池/线程池:预先创建固定数量的进程/线程,避免频繁创建销毁的开销。
// 多线程示例:接受连接后创建线程处理
#include
void* handle_client(void* arg) {
int conn_fd = *(int*)arg;
// 客户端通信逻辑...
close(conn_fd);
return NULL;
}
// 主循环接受连接
while (1) {
int conn_fd = accept(listen_fd, ...);
pthread_t tid;
pthread_create(&tid, NULL, handle_client, &conn_fd);
}










