文章目录
前言
在http://t.csdnimg.cn/Vn9aw这篇文章中介绍使用TCP通讯协议实现的客户端以及服务端实现进程间的网络通信。这里TCP服务端是通过创建进程的方式来处理每一个客户端的连接。当并发量不是很大时,这种处理方式还可以使用。一旦并发量很大,频繁创建的进程会带来巨大的资源消耗以及上下文切换消耗。而IO多路复用技术的核心是减少服务端线程的创建,通过使用较少线程处理所有请求的方式提高整体效率,可以很好的解决这个问题。有关IO多路复用技术的详细介绍见https://www.cnblogs.com/MyXjil/p/17478795.html这篇文章。
一、IO 多路复用的三种实现方式
在Linux系统中,常见的IO多路复用实现方式主要有三种:select、poll和epoll。下面分别介绍这三种方式的特点和用法。
1. select机制
原理与特点:
- select是一个系统调用函数,用于监视多个文件描述符的状态变化。
- 它将多个文件描述符集合传递给内核,由内核监视这些文件描述符的状态(可读、可写、异常)。
- 当有文件描述符就绪或超时发生时,select函数返回,程序根据返回的文件描述符集合进行相应的读写操作。
限制与缺点:
- 文件描述符集合的大小有限制,通常是1024个,这限制了select能够同时监视的文件描述符数量。
- select在每次调用时都需要将文件描述符集合从用户态拷贝到内核态,并在返回时将结果从内核态拷贝回用户态,这增加了额外的开销。
- select使用轮询方式检查文件描述符集合,效率较低,特别是在文件描述符数量较多的情况下。
函数原型:
fd_set 是文件描述符的集合,使用以下函数操作:
void FD_CLR(int fd, fd_set *set);
功能:从集合set中删除fd文件描述符
int FD_ISSET(int fd, fd_set *set);
功能:判断集合set中是否存在fd文件描述符
void FD_SET(int fd, fd_set *set);
功能:向集合set中添加fd文件描述符
void FD_ZERO(fd_set *set);
功能:清空集合set
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
功能:同时监控多个文件描述的读、写、异常操作
nfds:被监控的文件描述符中的最大值+1
readfds:监控读操作的文件描述符集合
writefds:监控写操作的文件描述符集合
exceptfds:监控异常操作的文件描述符集合
timeout:设置超时时间
NULL 一直阻塞,直到某个文件描述符发生了变化
0秒0微秒 非阻塞
大于0秒 等待超时时间,超时返回0
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};
返回值:监控到发生相关操作的文件描述符的个数,超时返回0,错误返回-1
实现 :
代码如下(示例):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
/*TCP服务端调用select函数实现多路复用*/
int main(int argc , const char* argv[])
{
int svr_fd = socket(AF_INET, SOCK_STREAM, 0);
if(svr_fd < 0)
{
perror("socket");
exit(1);
}
struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定回环地址
socklen_t addrlen = sizeof(addr);
if(bind(svr_fd, (struct sockaddr *)&addr, addrlen))
{
perror("bind");
exit(1);
}
if(listen(svr_fd, 10))
{
perror("listen");
exit(1);
}
// 定义一个文件描述符集合
fd_set reads;
FD_ZERO(&reads);
// 把需要等待的socket描述符添加到集合中
FD_SET(svr_fd, &reads);
// 定义超时时间
struct timeval timeout = {5, 0};
// 记录下最大的socket描述符
int max_fd = svr_fd; // 当前
char buf[4096] = {};
size_t buf_size = sizeof(buf);
while(1)
{
// 若有多个新的连接,集合会发生变化。会把之前没发生变化的文件描述符覆盖
// 备份之前文件描述符集合
fd_set reads_copy = reads;
// 调用select监控多个文件描述
int ret = select(max_fd+1, &reads_copy, NULL, NULL, &timeout);
if(ret < 0)
{
perror("select");
exit(1);
}
else
{
// 测试网络等待的socket描述符,检查服务器是否有新的连接请求。
if(FD_ISSET(svr_fd, &reads_copy))
{
// 调用accept连接客户端
int cli_fd = accept(svr_fd, (struct sockaddr *)&addr, &addrlen);
if(cli_fd < 0)
{
perror("accept");
}
else
{
// 把客户端的socket描述符添加到监控集合中
FD_SET(cli_fd, &reads);
if(cli_fd > max_fd)
{
max_fd = cli_fd; // 记录被监控文件描述符的最大值
}
}
}
else
{
// 没有新的连接请求,测试其他socket描述符是否发生读操作
for(int fd = 3; fd <= max_fd; fd++) // 遍历文件描述符0、1、2(标准输入、输出、错误)之外的所有文件描述符
{
if(FD_ISSET(fd, &reads_copy) && fd != svr_fd) // 找到连接的客户端文件描述符
{
int ret = recv(fd, buf, buf_size, 0);
if(ret <= 0)
{
FD_CLR(fd, &reads); // 从集合中删除fd文件描述符
printf("客户端%d退出\n", fd);
continue;
}
printf("recv:%s bits:%d\n", buf, ret);
strcat(buf, ":return");
ret = send(fd, buf, strlen(buf)+1, 0);
if(ret <= 0 || strcmp("quit", buf) == 0)
{
FD_CLR(fd, &reads);
printf("客户端%d退出\n", fd);
continue;
}
}
}
}
}
}
return 0;
}
代码说明:
1. 初始化套接字
- 使用
socket
函数创建一个TCP套接字(AF_INET
表示使用IPv4地址,SOCK_STREAM
表示使用TCP协议)。 - 如果套接字创建失败,则打印错误信息并退出程序。
2. 绑定套接字
- 设置
sockaddr_in
结构体,指定服务器将监听的IP地址(这里使用回环地址127.0.0.1
,即仅监听本地连接)和端口号(8888
)。 - 使用
bind
函数将套接字与指定的地址和端口绑定。 - 如果绑定失败,则打印错误信息并退出程序。
3. 监听连接
- 使用
listen
函数使套接字进入监听状态,准备接受客户端的连接请求。这里设置的监听队列长度为10。 - 如果监听失败,则打印错误信息并退出程序。
4. 使用select
处理多个连接
- 初始化一个文件描述符集合
reads
,用于存放需要检查的套接字描述符。 - 将服务器套接字描述符
svr_fd
添加到reads
集合中。 - 设置超时时间
timeout
,这里设置为5秒。 - 进入一个无限循环,使用
select
函数等待一个或多个套接字变为可读状态。
5. 处理可读套接字
- 在
select
返回后,首先检查服务器套接字svr_fd
是否可读(即是否有新的连接请求)。- 如果有新的连接请求,使用
accept
函数接受连接,并获取新的客户端套接字描述符cli_fd
。 - 将
cli_fd
添加到reads
集合中,以便后续可以检查其可读状态。 - 更新
max_fd
为当前最大的文件描述符,以便select
能正确检查所有套接字。
- 如果有新的连接请求,使用
- 如果没有新的连接请求,遍历
reads
集合中除了服务器套接字外的所有套接字。- 使用
recv
函数从客户端套接字读取数据。 - 如果读取到数据,则打印数据,并构造响应消息发送回客户端。
- 如果读取到0字节或发生错误,则认为客户端已关闭连接,从
reads
集合中移除该套接字描述符,并打印客户端退出的信息。 - 如果接收到的消息是"quit",则同样关闭连接并从
reads
集合中移除该套接字描述符。
- 使用
2. poll机制
原理与特点:
- poll是select的改进版,它使用结构体数组而不是位图来表示要监视的文件描述符。
- 这种方式消除了文件描述符数量的限制,因为数组的大小可以根据需要动态调整。
- poll同样需要将文件描述符集合从用户态拷贝到内核态,但由于使用了结构体数组,其表示方式更加灵活和高效。
限制与缺点:
- 虽然poll没有文件描述符数量的限制,但它仍然需要复制大量的数据在用户态和内核态之间,这在文件描述符数量较多时会导致性能下降。
- poll同样使用轮询方式检查文件描述符集合,效率较低。
函数原型:
int poll(struct pollfd *fds,nfds_t nfds,int timeout);
fds:struct pollfd结构变量数组
struct pollfd {
int fd; //被监控的文件描述符
short events; //想要监控的事件
short revents; //实际监控到的事件
POLLIN 普通优先级的读事件
POLLPRI 高优先级的读事件
POLLOUT 普通优先级的写事件
POLLRDHUP 对方socket关闭
POLLERR 错误事件
POLLHUP 对方挂起
POLLNVAL 非法描述符
};
nfds:数组的长度
timeout:超时时间 按毫秒赋值 1000毫秒=1秒
返回值:监控到发生相关操作的描述符的个数,超时返回0,错误返回-1
实现:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h>
/*TCP服务端调用poll函数实现多路复用*/
// 把客户端的socket描述符添加到pollfd数组中,并设置事件
int add_fds(struct pollfd* fds, int nfds, int fd, short events)
{
for(int i = 0; i < nfds; i++)
{
if(0 == fds[i].fd)
{
fds[i].fd = fd;
fds[i].events = events;
return 0;
}
}
return -1;
}
int main(int argc , const char* argv[])
{
int svr_fd = socket(AF_INET, SOCK_STREAM, 0);
if(svr_fd < 0)
{
perror("socket");
exit(1);
}
struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定回环地址
socklen_t addrlen = sizeof(addr);
if(bind(svr_fd, (struct sockaddr *)&addr, addrlen))
{
perror("bind");
exit(1);
}
if(listen(svr_fd, 10))
{
perror("listen");
exit(1);
}
// 创建pollfd数组初始化
struct pollfd *fds = calloc(sizeof(struct pollfd), 10);
// 设置[0]位置要监听的描述符和事件
fds[0].fd = svr_fd;
fds[0].events = POLLIN; // 普通优先级的读事件
// 定义超时时间
int timeout = 10000;
char buf[4096] = {};
size_t buf_size = sizeof(buf);
while(1)
{
// 调用poll函数
int ret = poll(fds, 10, timeout);
if(ret <= 0)
{
perror("poll");
exit(1);
}
// 判断[0]位置是否有读事件发生,有客户端在等待连接
if(fds[0].revents & POLLIN) // &判断 |赋值
{
// 创建连接
int cli_fd = accept(svr_fd, (struct sockaddr*)&addr, &addrlen);
if(cli_fd > 0)
{
// 把客户端的socket描述符添加到pollfd数组中,并设置事件
if(add_fds(fds, 10, cli_fd, POLLIN))
{
printf("客户端数量已满!\n");
exit(1);
}
}
}
else
{
// 遍历pollfd数组判断其他位置是否有读事件产生
for(int i = 1; i < 10; i++)
{
if(fds[i].events & POLLIN)
{
int ret = recv(fds[i].fd, buf, buf_size, 0);
if(ret <= 0 || 0 == strncmp(buf, "quit", 4))
{
printf("客户端%d退出\n", fds[i].fd);
fds[i].fd = 0;
fds[i].events = 0;
continue;
}
else
{
printf("recv:%s bits:%d\n", buf, ret);
strcat(buf, ":return");
ret = send(fds[i].fd, buf, strlen(buf)+1, 0);
if(ret <= 0)
{
printf("客户端%d退出\n", fds[i].fd);
fds[i].fd = 0;
fds[i].events = 0;
}
}
}
}
}
}
return 0;
}
代码说明:
1.初始化服务器套接字
2.准备poll
使用的pollfd
数组:
- 使用
calloc
分配一个pollfd
结构数组(fds
),大小为10,用于跟踪最多10个文件描述符(包括服务器监听套接字和客户端连接套接字)。 - 将服务器监听套接字(
svr_fd
)设置为fds
数组的第一个元素,并设置其事件为POLLIN
(表示有数据可读)。
3.进入事件循环:
- 使用
poll
函数等待一个或多个文件描述符上的事件发生。poll
会阻塞直到有事件发生或达到超时时间(timeout
毫秒)。 - 如果
poll
返回错误(ret <= 0
),则打印错误信息并退出。
4.处理事件:
- 如果服务器监听套接字(
fds[0]
)上的POLLIN
事件发生,表示有新的客户端连接请求。- 使用
accept
函数接受客户端连接,并获取新的客户端文件描述符(cli_fd
)。 - 尝试将新的客户端文件描述符添加到
fds
数组中。如果数组已满(即找不到fd
为0的pollfd
结构),则打印错误信息并退出。
- 使用
- 否则,遍历
fds
数组(从索引1开始,因为索引0是服务器监听套接字),检查是否有客户端文件描述符上的POLLIN
事件发生。- 如果有,使用
recv
函数从客户端接收数据。 - 如果接收到的数据长度小于等于0,或者接收到特定字符串("quit"),则认为客户端已断开连接,将对应的
pollfd
结构的fd
和events
设置为0,以便下次可以重用该位置。 - 否则,打印接收到的数据,并添加":return"字符串后发送回客户端。如果发送失败,则认为客户端已断开连接,并重置对应的
pollfd
结构。
- 如果有,使用
3. epoll机制
原理与特点:
- epoll是select和poll的升级版,它提供了更高效的方式来处理大量并发连接。
- epoll使用红黑树来管理待检测的文件描述符集合,这提高了查找和修改文件描述符的效率。
- epoll还使用了回调机制,当文件描述符就绪时,内核会直接将就绪的文件描述符集合返回给用户空间,无需用户空间进行额外的检测。
epoll的条件触发和边缘触发:
- 条件触发:当文件缓冲区中有需要读取的数据时就会触发事件,类似于键盘
- 边缘触发:当数据发送时触发一次事件,类似于鼠标
把监控事件增加设置为EPOLLET
优点:大大地降低事件触发的次数,在某些只需要处理一次事件即可的情境下能够提高效率
优点:
- 没有文件描述符数量的限制,仅受系统中进程能打开的最大文件数目限制。
- 使用红黑树和回调机制,提高了处理大量并发连接的效率。
- 避免了在每次调用时都进行大量的数据复制。
函数原型:
int epoll_create(int size);
功能:创建一个epoll的内核对象,该对象可以管理、保存被监控的描述符
size:epoll对象管理描述符的数量
返回值:epoll对象的描述符
int epoll_ctl(int epfd,int op,int fd, struct epoll_event *event);
功能:控制epoll对象,添加、删除描述符
epfd:epoll对象描述符
op:
EPOLL_CTL_ADD 添加监控的描述符
EPOLL_CTL_DEL 删除监控的描述符
EPOLL_CTL_MOD 修改要监控的描述符的事件
fd:
要操作的描述符
event:要监听的事件
struct epoll_event {
uint32_t events; //要监控事件,参数poll
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd; // 产生事件的描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
返回值:成功0 失败-1
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
功能:监控文件描述符,并返回发生事件的描述符
epfd:epoll对象描述符
events:输出型参数,用于获取发生事件的描述符
maxevents:可以返回事件数目的最大值
timeout:超时时间
返回值:监控到发生相关操作的描述符的个数,超时返回0,错误返回-1
实现:
代码如下(示例):
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
/*TCP服务端调用epoll函数实现多路复用*/
int main(int argc , const char* argv[])
{
int svr_fd = socket(AF_INET, SOCK_STREAM, 0);
if(svr_fd < 0)
{
perror("socket");
exit(1);
}
struct sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定回环地址
socklen_t addrlen = sizeof(addr);
if(bind(svr_fd, (struct sockaddr *)&addr, addrlen))
{
perror("bind");
exit(1);
}
if(listen(svr_fd, 10))
{
perror("listen");
exit(1);
}
// 创建epoll对象
int epfd = epoll_create(10);
if(epfd < 0)
{
perror("epoll_create");
exit(1);
}
// 添加描述符
struct epoll_event event;
event.events = EPOLLIN;
event.data.fd = svr_fd;
if(epoll_ctl(epfd, EPOLL_CTL_ADD, svr_fd, &event))
{
perror("epoll_ctl");
exit(1);
}
// 定义一个存储监控结果的数组
struct epoll_event events[10] = {};
char buf[4] = {};
size_t buf_size = sizeof(buf);
while(1)
{
// 监听
int event_cnt = epoll_wait(epfd, events, 10, 10000);
if(event_cnt < 0)
{
perror("epoll_wait");
exit(1);
}
// 遍历监听的结果
for(int i = 0; i < event_cnt; i++)
{
if(svr_fd == events[i].data.fd)
{
// 创建连接
int cli_fd = accept(svr_fd, (struct sockaddr *)&addr, &addrlen);
if(cli_fd > 0)
{
//event.events = EPOLLIN; // 条件触发
event.events = EPOLLIN | EPOLLET; // 边缘触发
event.data.fd = cli_fd;
if(epoll_ctl(epfd, EPOLL_CTL_ADD, cli_fd, &event))
{
perror("epoll_ctl");
exit(1);
}
}
}
else
{
// 处理边缘触发数据丢失情况(循环接收)
int ret = 0;
while(ret = recv(events[i].data.fd, buf, buf_size, MSG_DONTWAIT) != -1) // 不阻塞接收
{
if(ret <= 0 || 0 == strncmp(buf, "quit", 4))
{
printf("客户端%d退出\n", events[i].data.fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
continue;
}
printf("recv:%s bits:%d\n", buf, ret);
}
strcat(buf, ":return");
ret = send(events[i].data.fd, buf, strlen(buf)+1, 0);
if(ret <= 0)
{
printf("客户端%d退出\n", events[i].data.fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL);
}
}
}
}
return 0;
}
代码说明:
1.初始化服务器套接字:
2.创建epoll实例:
- 使用
epoll_create
函数创建一个epoll实例(epfd
),该实例用于管理多个文件描述符上的事件。
3.注册服务器监听套接字到epoll:
- 创建一个
epoll_event
结构体(event
),设置其事件类型为EPOLLIN
(表示有数据可读),并将服务器监听套接字(svr_fd
)作为事件的数据。 - 使用
epoll_ctl
函数将服务器监听套接字添加到epoll实例中,以便监听其上的事件。
4.事件循环:
- 进入一个无限循环,使用
epoll_wait
函数等待epoll实例上发生的事件。该函数会阻塞直到有事件发生或达到超时时间(10000毫秒)。 epoll_wait
返回后,遍历所有发生的事件。
5.处理事件:
- 如果事件对应的文件描述符是服务器监听套接字(
svr_fd
),则表示有新的客户端连接请求。- 使用
accept
函数接受客户端连接,并获取新的客户端文件描述符(cli_fd
)。 - 设置
event
结构体的事件类型为EPOLLIN | EPOLLET
(EPOLLIN
表示有数据可读,EPOLLET
表示边缘触发模式),并将客户端文件描述符作为事件的数据。 - 使用
epoll_ctl
函数将新的客户端文件描述符添加到epoll实例中,以便监听其上的事件。
- 使用
- 如果事件对应的文件描述符是客户端文件描述符,则表示该客户端上有数据可读或发生了其他注册的事件。
- 使用
recv
函数尝试从客户端接收数据,这里使用了MSG_DONTWAIT
标志来设置非阻塞模式。 - 如果接收到的数据长度小于等于0,或者接收到特定字符串("quit"),则认为客户端已断开连接,从epoll实例中删除该客户端文件描述符,并打印退出信息。
- 否则,打印接收到的数据,并添加":return"字符串后发送回客户端。
- 如果发送失败,则认为客户端已断开连接,从epoll实例中删除该客户端文件描述符,并打印退出信息。
- 使用