在使用多进程/多线程服务端框架的时候,如果每次有一个新的客户端连接,都重新去创建一个进程/线程来处理的话,对CPU和内存的开销是比较大的。
于是乎,就有了I/O复用。
I/O复用简介
多进程/多线程并发模型:为每个socket分配一个线程/进程。
I/O复用:采用单个进程/线程来管理多个socket。
这就是他们区别所在。
I/O复用有三种方案:select、poll、epoll,各有优缺点,适应不同场景,但是相对来说,epoll会更常用。
在网络设备(交换机、路由器),网游后台、nginx、redis等都使用了I/O复用。
1.select模型
在接触代码之前,我们先了解一些必要的函数与结构体
fd_set结构体
#undef __FD_SETSIZE
#define __FD_SETSIZE
typedef struct{
unsigned long fds_bits[__FD_SETSIZE/(8*sizeof(long)];
}__kernel_fd_set;
这个是用来存储网络通信使用的socket的。其中数组大小为什么是__FD_SETSIZE/(8*sizeof(long)呢?
它使用的数据结构是位图bitmap,不是数组的每一个下标存储一个socket,而是一个位存储一个socket。对于fds_bits[0],就可以存储sizeof(long)个字节,而1字节8位,所以光是fds_bits[0]就可以存储8*sizeof(long)个位。
每有一个socket被使用于我们的程序,这个socket在位图中对应的位就会置为1。也许位图你还是不了解,但是你知道这个用途就足够了。
由函数定义可以知道,它能存储的就是值为1024,如果再大的话我们可以改宏定义,当然最好还是换一个模型。
select函数
int select(int maxfdp,fd_set *readfds,fd_set *writefds,fd_set *errorfds,struct timeval *timeout);
(1)maxfdp是所有文件描述符范围,值为范围中最大的文件描述符+1
(2)接下来三个参数,都是传入fd_set结构的,分别用于监视读文件、写文件、异常文件的socket是否有事件发生,select会阻塞等待直至有事件发生(如果你设置了最后一个参数,会超时返回)。
网络通信大多数都只用到第一个readfds,后两个填NULL即可。
同时,这个函数会修改readfds,将没有事件发生的socket对应位置0,视情况我们需要保存select之前的readfds。
(3)最后一个参数
struct timeval
{
__time_t tv_sec; /* Seconds. */
__suseconds_t tv_usec; /* Microseconds. */
};
分别设置超时的秒和微妙。超时还未检测到事件发生,select也会返回,无需超时设置NULL即可。
(4)如果监测错误(比如fd_set中有socket并未申请等)返回-1;
超时返回0;
监测成功返回发生事件的数量。
(5)一点补充,为什么是监测某个范围的fd呢,因为计算机的socket不是随机分配的,而是从可用的空闲socket中选取最小的来分配,通常在申请完listen_fd以后,没有其他程序申请socket,那么之后产生的client_fd都是从listen_fd开始连续的数字。这一点其实是很有用的。
(6)另外有pselect函数,与信号处理有关,可以另外了解。
FD_CLR()函数
void FD_CLR(int fd,fd_set *set);
将set中fd对应位置为0,表示踢出该集合
FD_SET()函数
void FD_SET(int fd,fd_set *set);
将fd对应位置1,表示添加到集合
FD_ISSET()函数
int FD_ISSET(int fd,fd_set *set);
监测fd是否在set中,若不在返回值<=0
FD_ZERO()函数
void FD_ZERO(fd_set *set);
将集合清空。
了解完了关键函数,我们就照着流程去实现一遍就好了,我会在代码中写上注释帮助食用。
#include <bits/stdc++.h>
#include "yzz_server.h"
#include <unistd.h>
#include <signal.h>
using namespace std;
int Init(int port, std::string ip = "10.0.16.16"){///封装一个默认参数函数来完成申请socket、bind、listen这三步操作
int listen_fd = socket(AF_INET,SOCK_STREAM,0);
if(listen_fd <= 0){
printf("apply socket error\n");
return -1;
}
sockaddr_in server_addr;
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(ip.data());//指定ip地址,如果没有就用默认参数
server_addr.sin_port = htons(port);
if(bind(listen_fd, (sockaddr *)&server_addr, sizeof(server_addr)) != 0){
close(listen_fd);
printf("bind error\n");
return -1;
}
if(listen(listen_fd, 10) != 0){
close(listen_fd);
printf("listen error");
return -1;
}
return listen_fd;
}
int main(int argc,char* argv[]){
int listen_fd = -1;
if(argc == 2)listen_fd = Init(atoi(argv[1]));
else if(argc == 3)listen_fd = Init(atoi(argv[2]), argv[1]);
if(listen_fd <= 0){
printf("Init error\n");
return -1;
}
fd_set sock_set;//初始化一个fd_set,并且把listen_fd加入
FD_ZERO(&sock_set);
int maxfd = listen_fd;//当前只有一个listen_fd,最大值就是它自己,之后建立连接了会更新这个值
FD_SET(listen_fd, &sock_set);
while(true){
fd_set tmp_set = sock_set;//select函数会把sock_set里面没有发生事件的socket对应位置0,而这些socket以后可能还有事件,所以我们要保存下来,tmp_set仅供select临时使用
timeval tv;
tv.tv_sec = 0;//设置超时
tv.tv_usec = 500000;//500毫秒
int retval = select(maxfd + 1, &tmp_set, NULL, NULL, &tv);
if(retval <= 0){//-1为错误,0为超时
printf("select error\n");
continue;
}
//到这里说明socket有事件发生并且被select监测到了,需要我们去处理
for(int event_fd = 3; event_fd <= maxfd; ++event_fd){//0是标准输入,1是标准输出,2是标准错误,所以listen_fd至少是从3开始
if(FD_ISSET(event_fd, &tmp_set) <= 0)continue;//我们只是枚举小于maxfd的socket,它可能没有事件发生所以没有被select选到tmp_set中,也可能一开始就不在tmp_set中
if(event_fd == listen_fd){//如果当前这个发生事件的socket是listen_fd,说明有客户端产生连接,我们要生成一个新的client_fd去处理这个客户端
sockaddr_in client_addr;
int socklen = sizeof(sockaddr_in);
int client_fd = accept(listen_fd, (sockaddr *)&client_addr, (socklen_t *)&socklen);//这个时候不会阻塞,因为select已经阻塞监听到事件,这里accept是可以直接执行的
if(client_fd <= 0){
printf("accept error\n");
continue;
}
printf("client %s has been connect\n",inet_ntoa(client_addr.sin_addr));//输出一下表示连接成功
FD_SET(client_fd, &sock_set);//将client_fd加入,记住,tmp_set仅供select使用,与监听和客户端通信有关的socket我们都是保存到sock_set中
if(client_fd > maxfd)maxfd = client_fd;//更新一下socket范围的最大值
continue;
}else{//到这里说明有客户端发生通信
int client_fd = event_fd;//为了更清楚而写的
char buffer[1024];
std::string ret_buffer;
memset(buffer,0,sizeof(buffer));
if(recv(client_fd, buffer, sizeof(buffer), 0) <= 0){//接收失败,可能断开连接了
printf("recv error\n");
goto to_close_fd;
}
printf("%s\n",buffer);
ret_buffer = "已收到";//补充一下,其实发送由于缓冲区不够大也可能阻塞,但基于现在硬件水平,多半不会发生
ret_buffer += buffer;
if(send(client_fd, ret_buffer.data(), ret_buffer.size(),0) <= 0){//发送失败,也可能断连了
printf("send error\n");
goto to_close_fd;
}
continue;//如果正常运行到这里就没错误,不需要走到CLOSE_FD部分
to_close_fd:
close(client_fd);
FD_CLR(client_fd, &sock_set);
if(client_fd == maxfd){//如果这就是maxfd的话,我们删除client_fd之后需要更新maxfd
for(int i = client_fd; i >= 3; --i){//从大到小遍历出第一个存在sock_set的socket即为新的maxfd
if(FD_ISSET(i, &sock_set)){
maxfd = i;
break;
}
}
}
}
}
}
close(listen_fd);
return 0;
}
以上是我自己写的select代码,试运行以后暂时没发现问题,就先放着。
注释我认为写的还是挺详细的,不过去看select模型这个讲解也可以,我这个代码也是看视频以后修改了一些写出来的。
select采用"水平触发"的方式,如果监测到fd有事件并且报告后,没有处理fd的数据,或者没有读取完数据(例如客户端发来10字节你只收了5字节),那么下次select仍然会报告此fd,这就保证了select不会丢失事件和数据。
1.select支持的文件描述符有限(默认1024),当然这个是可以修改的,但是会带来内存和遍历的开销都会增大。
2.select实际上是把用户态传入的数组,拷贝到核心态,再去监测,需要一个O(n)过程。
3.select监测的时候,实际上是去遍历扫描文件描述符,这也是一个O(n)的扫描过程。
2.poll模型
poll和select没有本质区别,只是因为poll采用了数组而非bitmap,可以得到更大数量的fd,但是监听事件的时候同样需要用户态和核心态之间拷贝数组、同样需要采用轮询机制遍历每一个fd。
在我看来,至少应用方面比select好不了多少。
pollfd结构体
struct pollfd{
int fd; //文件描述符
short events; //请求的事件
short revents; //返回的事件
};
我们填入的是fd和events即可,监听事件以后返回的结构体会修改revents的值,并且fd<0的时候,poll函数会自动忽略。
关于events和revents参数如下:
poll函数
int poll(struct pollfd *fd, nfds_t nfds, int timeout);
第一个参数就是pollfd数组首地址,直接传入数组名即可。
第二个参数类似于select函数的maxfd。
第三个参数设置超时,单位为毫秒,直接填入数字即可,这个比select设置超时要方便不少。
超时部分可填参数如下:
#include <bits/stdc++.h>
#include "yzz_server.h"
#include <poll.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
#define MAXFD 100000
pollfd fds[MAXFD];//创建存储fd的数组
int Init(int port, std::string ip = "10.0.16.16"){///封装一个默认参数函数来完成申请socket、bind、listen这三步操作
int listen_fd = socket(AF_INET,SOCK_STREAM,0);
if(listen_fd <= 0){
printf("apply socket error\n");
return -1;
}
sockaddr_in server_addr;
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(ip.data());//指定ip地址,如果没有就用默认参数
server_addr.sin_port = htons(port);
if(bind(listen_fd, (sockaddr *)&server_addr, sizeof(server_addr)) != 0){
close(listen_fd);
printf("bind error\n");
return -1;
}
if(listen(listen_fd, 10) != 0){
close(listen_fd);
printf("listen error");
return -1;
}
return listen_fd;
}
int main(int argc,char* argv[]){
int listen_fd = -1;
if(argc == 2)listen_fd = Init(atoi(argv[1]));
else if(argc == 3)listen_fd = Init(atoi(argv[2]), argv[1]);
if(listen_fd <= 0){
printf("Init error\n");
return -1;
}
int maxfd = listen_fd;//当前只有一个listen_fd,最大值就是它自己,之后建立连接了会更新这个值
for(int i = 3; i < MAXFD; ++i){//初始化
fds[i].fd = -1;
}
fds[listen_fd].fd = listen_fd;//一般用fd作为下标
fds[listen_fd].events = POLLIN;
while(true){
int retval = poll(fds, maxfd + 1, 1000);
if(retval <= 0){//-1为错误,0为超时
printf("poll error\n");
continue;
}
//到这里说明poll有事件发生并且被select监测到了,需要我们去处理
for(int event_fd = 3; event_fd <= maxfd; ++event_fd){//0是标准输入,1是标准输出,2是标准错误,所以listen_fd至少是从3开始
if(fds[event_fd].fd <= 0 || fds[event_fd].revents != POLLIN)continue;
fds[event_fd].revents = 0;//清空
if(event_fd == listen_fd){//如果当前这个发生事件的socket是listen_fd,说明有客户端产生连接,我们要生成一个新的client_fd去处理这个客户端
sockaddr_in client_addr;
int socklen = sizeof(sockaddr_in);
int client_fd = accept(listen_fd, (sockaddr *)&client_addr, (socklen_t *)&socklen);//这个时候不会阻塞,因为select已经阻塞监听到事件,这里accept是可以直接执行的
if(client_fd <= 0){
printf("accept error\n");
continue;
}
printf("client %s has been connect\n",inet_ntoa(client_addr.sin_addr));//输出一下表示连接成功
fds[client_fd].fd = client_fd;//初始化
fds[client_fd].events = POLLIN;
fds[client_fd].revents = 0;
if(client_fd > maxfd)maxfd = client_fd;//更新一下socket范围的最大值
continue;
}else{//到这里说明有客户端发生通信
int client_fd = event_fd;//为了更清楚而写的
char buffer[1024];
std::string ret_buffer;
memset(buffer,0,sizeof(buffer));
if(recv(client_fd, buffer, sizeof(buffer), 0) <= 0){//接收失败,可能断开连接了
printf("recv error\n");
goto to_close_fd;
}
printf("%s\n",buffer);
ret_buffer = "已收到";//补充一下,其实发送由于缓冲区不够大也可能阻塞,但基于现在硬件水平,多半不会发生
ret_buffer += buffer;
if(send(client_fd, ret_buffer.data(), ret_buffer.size(),0) <= 0){//发送失败,也可能断连了
printf("send error\n");
goto to_close_fd;
}
continue;//如果正常运行到这里就没错误,不需要走到CLOSE_FD部分
to_close_fd:
close(client_fd);
fds[client_fd].fd = -1;
if(client_fd == maxfd){//如果这就是maxfd的话,我们删除client_fd之后需要更新maxfd
for(int i = client_fd; i >= 3; --i){//从大到小遍历出第一个存在sock_set的socket即为新的maxfd
if(fds[i].fd > 0){
maxfd = i;
break;
}
}
}
}
}
}
close(listen_fd);
return 0;
}
完整代码只是在select的代码上做一些修改即可,主要是把fd_set操作的地方替换掉就没问题了。
3.epoll模型
epoll模型解决了select和poll的问题(用户态到核心态的拷贝,轮询机制)。简单来说,select和poll可以理解为,有人告诉饭店老板有人买单,老板一桌桌去问;而epoll则是有人来找老板买单。
epoll_create函数
int epoll_create(int size);
创建一个epoll对象,并返回一个文件描述符,使用完后需要close关闭。
从Linux内核2.6.8后size参数就被忽略,填入一个正整数即可。
epoll_ctl函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd,传入epoll_create创建的fd即可。
op,填入对红黑树的操作,包括添加节点 EPOLL_CTL_ADD,删除节点EPOLL_CTL_DEL,修改节点EPOLL_CTL_MOD。添加和删除即是添加/取消socket监听,修改则是修改监听的事件。
fd,传入需要监听的socket,比如listen_fd。
event,传入该fd需要监听的事件。
epoll_data联合体和epoll_event结构体
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
//以下是我们要传入的
struct epoll_event {
uint32_t events; /* Epoll 事件 */
epoll_data_t data; /* 用户数据 */
};
events:
我们在listen和accept的时候,肯定都是EPOLLIN事件。
epoll_data,这个我并没有去好好了解,我去别人博客找到了一些介绍。
来自epoll函数原理和使用介绍
epoll_wait函数
int epoll_wait(int epid, struct epoll_event *events, int maxevents, int timeout);
epid,使用epoll_create创建的fd即可。
events,存储监听到发生事件的集合,可以传入数组去接收。
maxevents,集合的最大数量限制,假如有200个事件发生,而这里只填入100,那么多余的事件不会丢失,下一次epoll_wait的时候就会拿到。
timeout,超时时间,timeout>0则等待对应毫秒,等于0则立即返回,传入负数则一直阻塞。
返回值:返回值是正整数代表监听到并返回事件的数量(<=maxevents),返回0则是超时,返回-1则是出错。
epoll在epoll_ctl就会将fd加入内核态,之后如果有事件发生,epoll_wait会从双向链表取出数据。而select和poll每次调用对应函数的时候,重新将fd从用户态拷贝到内核态。
可以参考epoll优点
#include <bits/stdc++.h>
#include "yzz_server.h"
#include <sys/epoll.h>
#include <unistd.h>
#include <signal.h>
using namespace std;
#define MAXFD 100
int Init(int port, std::string ip = "10.0.16.16"){///封装一个默认参数函数来完成申请socket、bind、listen这三步操作
int listen_fd = socket(AF_INET,SOCK_STREAM,0);
if(listen_fd <= 0){
printf("apply socket error\n");
return -1;
}
sockaddr_in server_addr;
memset(&server_addr,0,sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(ip.data());//指定ip地址,如果没有就用默认参数
server_addr.sin_port = htons(port);
if(bind(listen_fd, (sockaddr *)&server_addr, sizeof(server_addr)) != 0){
close(listen_fd);
printf("bind error\n");
return -1;
}
if(listen(listen_fd, 10) != 0){
close(listen_fd);
printf("listen error");
return -1;
}
return listen_fd;
}
int main(int argc,char* argv[]){
int listen_fd = -1;
if(argc == 2)listen_fd = Init(atoi(argv[1]));
else if(argc == 3)listen_fd = Init(atoi(argv[2]), argv[1]);
if(listen_fd <= 0){
printf("Init error\n");
return -1;
}
int epoll_fd = epoll_create(1);//创建epoll_fd
epoll_event ev;//将listen_fd加入红黑树
ev.data.fd = listen_fd;
ev.events = EPOLLIN;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
epoll_event events[MAXFD];//接收事件的数组
while(true){
int event_num = epoll_wait(epoll_fd, events, MAXFD, 1000);
if(event_num <= 0){//-1为错误,0为超时
printf("epoll error\n");
continue;
}
for(int event_id = 0; event_id < event_num; ++event_id){//0是标准输入,1是标准输出,2是标准错误,所以listen_fd至少是从3开始
if(events[event_id].data.fd == listen_fd && events[event_id].events == EPOLLIN){//如果当前这个发生事件的socket是listen_fd,说明有客户端产生连接,我们要生成一个新的client_fd去处理这个客户端
sockaddr_in client_addr;
int socklen = sizeof(sockaddr_in);
int client_fd = accept(listen_fd, (sockaddr *)&client_addr, (socklen_t *)&socklen);//这个时候不会阻塞,因为select已经阻塞监听到事件,这里accept是可以直接执行的
if(client_fd <= 0){
printf("accept error\n");
continue;
}
printf("client %s has been connect\n",inet_ntoa(client_addr.sin_addr));//输出一下表示连接成功
//添加新的client_fd进epoll
memset(&ev, 0, sizeof(epoll_event));
ev.data.fd = client_fd;
ev.events = EPOLLIN;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
}else if(events[event_id].events == EPOLLIN){//到这里说明有客户端发生通信
int client_fd = events[event_id].data.fd;//为了更清楚而写的
char buffer[1024];
std::string ret_buffer;
memset(buffer,0,sizeof(buffer));
if(recv(client_fd, buffer, sizeof(buffer), 0) <= 0){//接收失败,可能断开连接了
printf("recv error\n");
goto to_close_fd;
}
printf("%s\n",buffer);
ret_buffer = "已收到";//补充一下,其实发送由于缓冲区不够大也可能阻塞,但基于现在硬件水平,多半不会发生
ret_buffer += buffer;
if(send(client_fd, ret_buffer.data(), ret_buffer.size(),0) <= 0){//发送失败,也可能断连了
printf("send error\n");
goto to_close_fd;
}
continue;//如果正常运行到这里就没错误,不需要走到CLOSE_FD部分
to_close_fd:
/*
memset(&ev,0,sizeof(epoll_event));
ev.events = EPOLLIN;
ev.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, &ev);
*/
close(client_fd);//如果保证当前fd的引用只有这一个,那么直接close,这个fd会被epoll自动剔除
}
}
}
close(listen_fd);
close(epoll_fd);
return 0;
}
其实也是在之前select和poll的代码上作一些修改即可。而且在我看来,epoll代码会更加简单。