目录
1.4 开始监听,最大等待连接队列长度为 10(小姐姐开始迎宾)
2.1模式1:单次 accept —— 只接受第一个连接,处理一次请求就退出
2.2模式2:循环 accept —— 每次只处理一个客户端的一次通信,然后继续 accept 下一个
2.3模式3:accept + 多线程 —— 每来一个连接就创建一个线程处理
2.6模式 6:使用 epoll 实现高性能 I/O 多路复用
2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
3.int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
1.tcp服务器基础编程部分
1.1首先需要创建监听 socket(迎宾的小姐姐)
IPv4(TCP/IP), 流式套接字(SOCK_STREAM), 使用默认协议(IPPROTO_TCP)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
1.2定义服务器地址结构(迎宾的地址)
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET; // IPv4 协议族
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有本地网卡(0.0.0.0)
servaddr.sin_port = htons(2000); // 监听端口号 2000(主机字节序转网络字节序)
1.3绑定地址到 socket(小姐姐站到迎宾的地方)
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
// 绑定失败时打印错误信息
printf("bind failed: %s\n", strerror(errno));
}
1.4 开始监听,最大等待连接队列长度为 10(小姐姐开始迎宾)
listen(sockfd, 10);
printf("listen finished: %d\n", sockfd); // 输出监听 socket 的 fd 号(通常是 3)
1.5客户端地址结构体,用于 accept 获取对端信息
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr); // 必须传指针大小给 accept
2.接收模式
2.1模式1:单次 accept —— 只接受第一个连接,处理一次请求就退出
printf("accept\n");
// 阻塞等待第一个客户端连接
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished\n");
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0); // 接收数据
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0); // 发送回显
printf("SEND: %d\n", count);
若想看被监听的端口否被成功监听,可以输入netstat -anop | grep 指定端口:
![]()
进入listen后,就可以被连接。
若再次重新运行程序则会出现提示如下错误,则说明该端口已经被占用了:

此时若程序正常运行,说明tcp服务器已经启动,这里用网络助手连接服务器所对应的端口号,客户端可以成功连接到这台tcp服务器:

发送数据成功后会回显,成功实现。

但是这一个fd就对应一个tcp连接信息,无法连接其他的客户端,所以考虑循环调用accept.
2.2模式2:循环 accept —— 每次只处理一个客户端的一次通信,然后继续 accept 下一个
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished\n");
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
close(clientfd); // 处理完即关闭连接
}
这样操作服务端没法及时的接收客户端发送来的信息
如果c1,c2一次连接,但是c2先发送消息,c2是接受不到数据的,直到c1发送后才接收到数据
虽然都能连接,数据也发到服务器了,但是程序会阻塞在recv处。要想程序不被阻塞在recv处,我应该将recv,send放入到线程的回调函数中。
2.3模式3:accept + 多线程 —— 每来一个连接就创建一个线程处理
void *client_thread(void *arg) {
int clientfd = *(int *)arg;
while (1) {
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", clientfd);
close(clientfd);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
//模式3:
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
pthread_t thid;
pthread_create(&thid, NULL, client_thread, &clientfd);
}
3 是 “监听套接字” 的编号,4、5... 是 “客户端连接套接字” 的编号,两者是不同类型的套接字
在客户端断开连接时,fd会被回收,重新连接时,会按fd递增次序重新分配一个fd

修改完代码已经不会发生阻塞了,但是只能实现一请求一线程
其弊端:
-
线程资源消耗大
-
并发能力受限
-
线程调度开销高
2.4模式4:使用 select 实现 I/O 多路复用
支持多个客户端同时连接和通信(单线程)
通俗的例子:
-
「一请求一线程」:像餐厅里来了 100 个顾客,就雇 100 个服务员,每个服务员只盯一个顾客。人少的时候还好,人多了(比如 1000 个顾客),雇 1000 个服务员会占满餐厅(耗内存),服务员之间抢着干活(CPU 调度忙),最后没人能高效服务。
-
「select 模式」:还是 100 个顾客,只雇 1 个服务员(单线程 / 少线程),这个服务员先站在门口(select),同时盯着所有顾客 —— 哪个顾客举手要点餐(IO 可读 / 可写),就过去服务这个顾客;没顾客举手时,服务员就站着等(阻塞在 select),不瞎忙活。

fd_set rfds, rset; // rfds: 所有要监控的读集合;rset: 每次调用 select 修改的副本
FD_ZERO(&rfds); // 清空集合
FD_SET(sockfd, &rfds); // 将监听 socket 加入监控集合
int maxfd = sockfd; // 当前最大的 fd 编号,用于 select 第一个参数
while (1) {
rset = rfds; // select 会修改集合内容,所以每次都要复制原始集合
// 阻塞等待任意 fd 就绪(可读)
int nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
// 如果是监听 socket 就绪,说明有新连接到来
if (FD_ISSET(sockfd, &rset)) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
FD_SET(clientfd, &rfds); // 将新的客户端 socket 加入监控集合
if (clientfd > maxfd) maxfd = clientfd; // 更新最大 fd
}
// 遍历所有可能的 fd(从 sockfd+1 开始),检查是否有客户端数据到达
for (int i = sockfd + 1; i <= maxfd; i++) {
if (FD_ISSET(i, &rset)) { // 该 fd 有数据可读
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // 客户端断开连接
printf("client disconnect: %d\n", i);
close(i);
FD_CLR(i, &rfds); // 从集合中移除
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
因为select是从0开始检查,比如要检查0~7,那么就是要检查8个fd(maxfd + 1)
假如新来的clientfd大于maxfd那么就要更新maxfd.
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
timeout为NULL一直阻塞;
nready返回的是有几个fd就绪,select会把rset已就绪的fd标记好,其他都去除掉
在调用select后,系统会把没有触发事件的fd从集合中移除,只保留触发了事件的fd。因此每次调用select,应该把rfds集合从用户空间copy到内核空间
FD_ISSET(sockfd, &rset);检查sockfd在rset中是否触发了事件,在代码中sockfd是监听事件,触发可读事件说明有新连接了,那么就应该执行accept逻辑。
select相关宏定义:
FD_ZERO(&rfds);
FD_SET(sockfd,&rfds);
FD_ISSET(sockfd,&rset);
FD_CTL(i,&rfds)。
select优点:实现io多路复用。缺点:参数太多,麻烦,因此可以进一步使用poll来处理。
2.5模式 5:使用 poll 实现 I/O 多路复用
struct pollfd {
int fd; // 文件描述符
short events; // 希望监听的事件(输入)
short revents; // 实际发生的事件(输出)
};
fd:要监控的目标(比如客户端连接的 clientfd、服务器监听的 sockfd);如果设为 -1,poll 会忽略这个结构体(可用于占位或删除监控)。
events:我们想监控的事件(输入参数,自己设置),支持多种事件组合
revents:实际发生的事件(输出参数,poll 函数返回后才有效),poll 会根据 fd 的状态填充,比如 “真的有数据可读” 就会置位对应标志。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
返回值:成功=发生事件的 fd 数量;失败=-1(如被信号中断);超时=0
fds:指向 “监控清单数组” 的指针(每个元素是 struct pollfd,描述 “盯哪个 fd + 盯什么事件”):
nfds:监控清单的长度(数组里有多少个 struct pollfd 元素,必须准确,不能多也不能少)
timeout:超时时间(单位:毫秒):- 正数:等待 timeout 毫秒后返回(没事件也返回);- 0:不阻塞,立即返回(不管有没有事件);- -1:无限阻塞(直到有事件发生才返回)
fds[sockfd].revents & POLLIN
-
revents是poll函数的 “输出结果”——poll监控完所有 fd 后,会给每个fds[i].revents赋上 “这个 fd 实际发生的事件”(用二进制标志位表示); -
POLLIN是一个 “事件标志宏”(本质是一个整数,比如系统定义为0x001,二制00000001),代表 “有数据可读”。 -
“按位与(&)” 的逻辑很简单:两个二进制位都为 1 时,结果才是 1;否则是 0。用这个逻辑检测:如果
revents & POLLIN的结果不是 0,就说明 “可读开关” 闭合了(POLLIN 事件真的发生了)。
struct pollfd fds[1024] = {0}; // 定义 pollfd 数组,最多监控 1024 个 fd
fds[sockfd].fd = sockfd; // 设置监听 socket
fds[sockfd].events = POLLIN; // 监听可读事件
int maxfd = sockfd; // 记录当前最大 fd 索引
while (1) {
// 阻塞等待事件发生(-1 表示无限等待)
int nready = poll(fds, maxfd + 1, -1);
// 检查监听 socket 是否就绪
if (fds[sockfd].revents & POLLIN) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
// 添加新客户端到 poll 数组
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd; // 更新最大索引
}
// 遍历所有客户端 fd
for (int i = sockfd + 1; i <= maxfd; i++) {
if (fds[i].revents & POLLIN) { // 可读事件触发
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // 断开连接
printf("client disconnect: %d\n", i);
close(i);
fds[i].fd = -1; // 标记为空闲槽位
fds[i].events = 0;
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
poll 和 select 的核心逻辑一致(都靠 “遍历” 检测事件),高并发下性能拉胯
每次调用 poll,都要把整个 struct pollfd 数组(全量监控的 fd 信息)拷贝到内核态,监控的 fd 越多,拷贝的数据量越大。比如监控 10 万个 fd,数组占用约 10 万 × 8 字节 = 800KB,每次调用都要拷贝 800KB,频繁调用时拷贝开销巨大,所以引入epoll
2.6模式 6:使用 epoll 实现高性能 I/O 多路复用
效率最高,适用于高并发场景
epoll 操作围绕 “创建实例→管理监控对象→等待事件” 三个步骤,对应三个核心函数
- 创建 epoll 实例(epoll_create)
- 管理监控的 fd(epoll_ctl)
- 等待就绪事件(epoll_wait)
1.int epoll_create(int size);
在内核中创建一个 “epoll 事件表”(用来存储要监控的 fd 和事件),返回一个 epoll 实例的 fd(类似 socket() 返回的 sockfd)
2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
1.epfd:epoll_create 返回的实例 fd
2.op(操作类型):
-
EPOLL_CTL_ADD:添加 fd 到 epoll 事件表(新监控一个 fd);
-
EPOLL_CTL_DEL:从事件表中删除 fd(停止监控);
-
EPOLL_CTL_MOD:修改 fd 的监控事件(比如从 “只读” 改成 “可读可写”);
3.fd:要监控的目标 fd(sockfd 或 clientfd)
4.struct epoll_event(事件结构体):
uint32_t events; // 要监控的事件(和 poll 类似,用标志位)
epoll_data_t data;// 附加数据(通常存 fd,方便后续处理)
-
EPOLLIN:有数据可读(客户端发数据、新连接); -
EPOLLOUT:可以写数据(发送缓冲区空闲);
3.int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- epfd:epoll 实例 fd
- events:存储就绪事件的数组(内核填充)
- maxevents:events 数组的最大长度(不能超过监控的 fd 总数)
- timeout:超时时间(毫秒):-1=无限阻塞,0=非阻塞,>0=超时时间
核心优势:返回的 events 数组只包含 “有事件的 fd”,用户态直接处理即可,不用遍历全量 fd。
- 在 “添加 / 删除 fd”(希望内核监听的事件) 时(
epoll_ctl)拷贝 1 次 fd 信息到内核(内核维护事件表),后续调用epoll_wait时,只拷贝 “就绪的 fd 信息”(“内核检测到的实际发生的事件”)。比如 10 个就绪 fd,只拷贝 10 个struct epoll_event(约 80 字节),拷贝开销可忽略 - 除了支持水平触发模式( LT 模式),还支持 边缘触发(ET 模式)——fd 从 “未就绪” 变为 “就绪” 时,只返回 1 次(哪怕数据没读完,后续也不返回,直到有新数据)。这减少了内核和用户态的交互次数,进一步提升效率(比如 2048 字节数据,ET 模式只返回 1 次,循环读完,LT 模式可能返回 2 次,读两次),是高并发场景的首选模式。
int epfd = epoll_create(1); // 创建 epoll 实例,参数旧版有用,现在一般写 1
struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件
ev.data.fd = sockfd; // 用户数据:保存监听 socket
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); // 将监听 socket 添加到 epoll
while (1) {
struct epoll_event events[1024] = {0}; // 存放就绪事件的数组
// 等待事件发生(阻塞)
int nready = epoll_wait(epfd, events, 1024, -1);
// 遍历所有就绪事件
for (int i = 0; i < nready; i++) {
int connfd = events[i].data.fd; // 获取触发事件的 socket
if (connfd == sockfd) {
// 是监听 socket 触发 → 有新连接
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
// 将新客户端加入 epoll 监控
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}
else if (events[i].events & EPOLLIN) {
// 是客户端 socket 触发 → 有数据可读
char buffer[1024] = {0};
int count = recv(connfd, buffer, 1024, 0);
if (count == 0) {
// 客户端断开连接
printf("client disconnect: %d\n", connfd);
close(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL); // 从 epoll 删除
continue;
}
printf("RECV: %s\n", buffer);
count = send(connfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
#endif
假设客户端发了 2048 字节数据,服务器缓冲区一次只能存 1024 字节:
- LT 模式:
- 数据第一次到达(缓冲区有 1024 字节),
epoll_wait返回 “可读事件”,服务器读 1024 字节; - 缓冲区还剩 1024 字节(fd 仍处于 “就绪状态”),下次调用
epoll_wait会再次返回 “可读事件”,服务器再读 1024 字节; - 两次
epoll_wait触发 + 两次用户态处理,内核和用户态交互了 2 次。
- 数据第一次到达(缓冲区有 1024 字节),
- ET 模式:
- 数据第一次到达(缓冲区从空→有数据),
epoll_wait只返回 1 次 “可读事件”; - 服务器必须在这次触发中,用循环把 2048 字节(分两次读 1024)全部读完;
- 只触发 1 次
epoll_wait,内核和用户态只交互 1 次,没有重复触发。
- 数据第一次到达(缓冲区从空→有数据),
1230

被折叠的 条评论
为什么被折叠?



