【网络编程】一请求一线程
一、前言
使用 C 语言实现多线程 TCP 服务器,通过调用 pthread_create()
为每个客户端连接创建一个独立线程,完成回显服务(Echo Server)。通信模型采用 一请求一线程(One-Connection-One-Thread) 的方式。
二、TCP套接字流程概览
TCP 是面向连接的协议,在客户端和服务端之间建立可靠的双向通信。基本通信流程如下:
服务端流程
- 创建套接字
socket()
- 绑定地址和端口
bind()
- 监听连接请求
listen()
- 接收连接
accept()
- 收发数据
recv()/send()
- 关闭连接
close()
客户端流程
- 创建 socket;
- 连接服务器
connect()
; - 数据交互;
- 关闭连接。
三、一请求一线程模型
3.1 流程
使用 pthread 创建线程,具体策略:
- 主线程执行
accept()
; - 每接收一个客户端连接,就创建一个新线程;
- 线程内持续使用
recv()
接收数据; - 回显数据给客户端,再次循环;
- 客户端断开连接时,线程关闭该 fd 并结束。
3.2 优缺点
优点:
- 结构简单、逻辑清晰
- 每来一个客户端就
accept()
,然后创建一个新线程去处理; - 每个线程只管自己的连接,代码非常直观;
- 不用考虑连接复用、事件回调;
- 哪个客户端出了问题,对应线程查日志就行,不会影响其他线程。
- 用
recv()
阻塞接收数据,不用处理EAGAIN
等非阻塞复杂情况;
- 适合连接不多的项目
缺点(不适合高并发)
- 线程多 → 系统资源占用高
- 每个线程都占内存(默认栈大小通常是 1MB);
- 线程多了容易吃光内存或系统线程数达到上限。
- 线程切换开销大
- 系统频繁在线程之间切换,会占用 CPU;
- 实际跑业务的时间变少,性能变差。
- 没有线程复用,效率低
- 每个客户端都创建线程,用完就销毁,系统一直在创建+销毁,浪费资源;
- 没有线程池这种“复用线程”的优化。
- 线程退出不处理可能会资源泄漏
- 如果不写
pthread_detach()
或pthread_join()
; - 线程退出后资源不会被系统清理 → 僵尸线程越来越多。
- 不适合高并发(上千连接)
- 比如聊天室、游戏服务器、网关代理等,一旦连接多了就会卡或崩。
四、完整代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>// 客户端处理线程函数:收发数据
void *client_thread(void *arg) {int clientfd = *(int *)arg; // 获取客户端连接的 socketwhile (1) {char buffer[1024] = {0};int count = recv(clientfd, buffer, sizeof(buffer), 0);if (count == -1) {perror("recv");// return -1;} else if (count == 0) {printf("Client disconnected\n");break;} send(clientfd, buffer, count, 0);printf("Received %d bytes: %s\n", count, buffer);}close(clientfd);
}int main() {// 1. 创建 socket(监听套接字)int sockfd = socket(AF_INET, SOCK_STREAM, 0);// 2. 配置服务器地址结构struct sockaddr_in serveraddr;memset(&serveraddr, 0, sizeof(struct sockaddr_in));serveraddr.sin_family = AF_INET;serveraddr.sin_port = htons(8888); // 端口号serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到所有可用的接口// 3. 绑定地址和端口int ret = bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));if (ret == -1) {perror("bind");return -1;}// 4. 开始监听(最大等待队列长度为10)ret = listen(sockfd, 10);if (ret == -1) {perror("listen");return -1;}// 5. 循环接收客户端连接while (1) {struct sockaddr_in clientaddr;socklen_t len = sizeof(clientaddr);int clientfd = accept(sockfd, (struct sockaddr *)&clientaddr, &len);if (clientfd == -1) {perror("accept");return -1;}// 6. 创建线程处理客户端连接pthread_t thid;pthread_create(&thid, NULL, client_thread, &clientfd);}return 0;
}
五、要点总结
1. IO、fd、socket
-
IO(Input/Output):输入输出的统称,程序通过 IO 与外部世界(比如文件、网络)进行数据交互。
- 在网络编程中,IO 通常指的是“网络数据的读写操作”。
-
fd(file descriptor,文件描述符):
- 是一个非负整数;
- 操作系统用于标识打开的文件、网络连接等资源;
- 所有IO操作(包括网络IO)都是基于fd进行的。
-
socket 是一种特殊的文件描述符,用于网络通信。
- 本质上:socket = 一种用于网络通信的 fd。
-
在口语中,fd / IO / socket 往往会混着说,语境不同但常指同一件事。
2. 为什么要清零?
memset(&serveraddr, 0, sizeof(struct sockaddr_in));
struct sockaddr_in serveraddr = {0};
- 避免脏数据:若未初始化,结构体可能包含随机值,导致网络编程中出现不可预期的错误(如连接失败、绑定错误等)。
3. 调用 accept()
函数时,必须通过指针传递 len
变量
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int clientfd = accept(sockfd, (struct sockaddr *)&clientaddr, &len);
- 传入
&len
:让accept()
知道客户端地址缓冲区的初始大小(即clientaddr
的大小); - 接受返回值:
accept()
可能会将len
修改为实际填充的地址长度; - 这种设计可以兼容 IPv4 和 IPv6 等不同地址结构;
- 防止地址溢出或截断,确保程序安全。
- 这种设计允许
accept()
适应不同类型的地址结构(如 IPv4、IPv6),如果客户端使用 IPv6 连接,而服务器分配的是 IPv4 结构,accept()
会将len
设置为实际使用的长度(可能小于或大于初始值),避免缓冲区溢出。
4. TCP三次握手建立连接不需要代码显式参与
- TCP三次握手(SYN→SYN+ACK→ACK)由操作系统内核的TCP/IP协议栈自动完成,应用层代码只需通过套接字API进行抽象操作。
- 服务器通过
listen()
准备接收连接,accept()
阻塞等待握手完成;客户端通过connect()
触发握手。握手成功后,accept()
返回新的套接字描述符(服务器)或connect()
返回0(客户端)。 - 应用层无需直接编写处理SYN、SYN+ACK、ACK包的代码。
5. 出现大量TIME_WAIT
TIME_WAIT
是 TCP 连接正常关闭后,客户端/服务器端保持连接一段时间(默认 60 秒以上)来确保数据完整关闭的状态。
- TCP 四次挥手的最后一步,主动关闭连接的一方进入
TIME_WAIT
。 - 它会持续一段时间(通常是 2 倍的最大报文生存时间,Linux默认 60 秒或更长)。
- 它的作用是:
- 确保最后一个 ACK 能被对方收到;
- 防止旧连接的数据混入新连接(连接复用时端口相同)。
常见场景:
- HTTP 请求频繁(短连接);
- 服务器不断接受短连接(如聊天、爬虫);
- 自己写的 socket 客户端/服务端每次 connect -> close 都触发。
大量出现:
- 端口资源会被占满(端口号 + TIME_WAIT = 不可复用);
- 内核需要维护大量连接状态,影响性能;
- 新连接会被拒绝或超时。
6. 出现CLOSE_WAIT
当对方关闭连接(发送 FIN)后你收到了,但你没有关闭自己的 socket,就会进入
CLOSE_WAIT
状态。
- 表示自己接收到了对方断开连接的请求;
- 但程序未及时调用
close()
关闭该 socket; - 连接会卡在
CLOSE_WAIT
状态不释放资源。
7. fd 和 TCP 连接的回收
- 什么时候 fd 会被回收?
- 当调用
close(fd)
的时候,fd 就被“归还”给系统,可以被下一个 socket 复用。
- 当调用
- TCP 连接什么时候断?
- TCP 是网络层的事,fd 是操作系统的事,两者回收机制不同步。
close(fd)
只是关闭了 fd,但底层的 TCP 连接可能仍处于 TIME_WAIT 状态。
- 分层解释
概念 | 属于哪一层 | 回收时机 |
---|---|---|
fd | 文件系统 | 调用 close() 后立即回收 |
TCP连接(状态) | 网络协议栈(内核) | 等待状态机完成(如TIME_WAIT) |
8. 命令 netstat -anop | grep 8888
- 用于查看当前系统上所有连接的状态;
- 过滤出与端口
8888
有关的连接; - 可以看到每个连接的状态(如 ESTABLISHED、TIME_WAIT、CLOSE_WAIT);
-o
还可以显示每个连接对应的 PID,方便定位问题进程。
9. fd 回收机制(延伸理解)
- **fd 是 int 类型,通常从 3 开始分配(0=stdin,1=stdout,2=stderr);
- 每次
socket()
、open()
等调用都会分配新的 fd; close(fd)
后,fd 会被系统回收,并在后续优先重用(越早释放越早被重用);- 如果短时间内连接大量断开又快速建立,容易出现 fd 被重复复用(比如第 4 个客户端连接得到的 fd 是前一个断开的 fd);
- 底层 TCP 状态仍处于
TIME_WAIT
,但 fd 已换人使用,这就是 fd 和 TCP 的不同步表现。
10. 查看和修改系统最大 fd 限制
ulimit -n
- 表示系统允许的最大打开文件描述符数(fd 数);
- 默认可能是 1024,可以通过配置提升(对高并发服务);
- 太小会导致连接建立失败、accept 报错等问题。