多路复用IO用于对大量描述符进行IO就绪事件监控,能够让用户只针对就绪了指定事件的描述符进行操作。
IO的就绪事件分为可读、可写、异常
- 可读事件:一个描述符对应的缓冲区中有数据可读
- 可写事件:一个描述符对应的缓冲区中有剩余空间可以写入数据
- 异常事件:一个描述符发生了特定的异常信息
相比较于其他IO方式,多路复用IO 避免了对没有就绪的描述符进行操作而带来的阻塞,同时只针对已就绪的描述符进行操作,提高了效率
在Linux下,操作系统提供了三种模型:select模型、poll模型、epoll模型。
即在单执行流中进行轮询处理就绪的描述符。如果就绪的描述符较多时,很难做到负载均衡(最后一个描述符要等待很长时间,前边的描述符处理完了才能处理它)。
解决这一问题的方法就是在用户态实现负载均衡,规定每个描述符只能读取指定数量的数据,读取了就进行下一个描述符。
多路复用IO模型适用于有大量描述符需要监控,但是同一时间只有少量活跃的场景
即操作系统通过轮询调度执行流实现每个执行流中描述符的处理
由于其在内核态实现了负载均衡,所以不需要用户态做过多操作
多路复用适合于IO密集型服务,多进程或线程适合于CPU密集型服务,它们各有各的优势,并不存在谁取代谁的倾向。基于两者的特点,通常可以将多路复用IO和多线程/多进程搭配一起使用。
使用多路复用IO监控大量的描述符,哪个描述符有事件到来,就创建执行流去处理。这样做的好处是防止直接创建执行流而描述符还未就绪,浪费资源。
定义指定监控事件的描述符集合(即位图),初始化集合后,将需要监控指定事件的描述符添加到指定事件(可读、可写、异常)的描述符集合中
将描述符集合拷贝到内核当中,对集合中所有描述符进行轮询判断,当描述符就绪或者等待超时后就调用返回,返回后的集合中只剩下已就绪的描述符(未就绪会在位图中置为0)
通过遍历描述符,判断哪些描述符还在集合中,就可以知道哪些描述符已经就绪了,开始处理对应的IO时间。
//清空集合
void FD_ZERO(fd_set *set);
//向集合中添加描述符fd
void FD_SET(int fd, fd_set *set);
//从集合中删除描述符fd
void FD_CLR(int fd, fd_set *set);
//判断描述符是否还在集合中
int FD_ISSET(int fd, fd_set *set);
//发起调用将集合拷贝到内核中并进行监控
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
/*
fd:文件描述符
set:描述符位图
nfds:集合中最大描述符数值+1
readfds:可读事件集合
writefds:可写事件集合
exceptfds:异常事件集合
timeout:超时等待时间
timeval结构体有两个成员
struct timeval {
long tv_sec; 毫秒
long tv_usec; 微秒
};
*/
缺点:
优点:
为了能让select使用更加便利,对其进行一层封装。
#ifndef __SELECT_H_
#define __SELECT_H_
#include
#include
#include
#include"TcpSocket.hpp"
class Select
{
public:
Select() : _maxfd(-1)
{
//将集合初始化清空
FD_ZERO(&_rfds);
}
//向集合中添加描述符
bool Add(const TcpSocket& socket)
{
int fd = socket.GetFd();
FD_SET(fd, &_rfds);
//如果新增描述符比最大描述符大,则更新
if(fd > _maxfd)
{
_maxfd = fd;
}
return true;
}
//从集合中删除描述符
bool Del(const TcpSocket& socket)
{
int fd = socket.GetFd();
FD_CLR(fd, &_rfds);
//如果被删除的描述符是最大的,则从后往前再找一个
if(fd == _maxfd)
{
for(int i = _maxfd; i >= 0; i--)
{
//如果这个描述符在集合中,则更新最大值
if(FD_ISSET(i, &_rfds))
{
_maxfd = i;
break;
}
}
}
return true;
}
//从集合中找到所有就绪的描述符
bool Wait(std::vector<TcpSocket>& vec, int outlime = 3)
{
struct timeval tv;
//以毫秒为单位
tv.tv_sec = outlime;
//计算剩余的微秒
tv.tv_usec = 0;
//因为select会去掉集合中没就绪的描述符,所以不能直接操作集合,只能操作集合的拷贝
fd_set set = _rfds;
int ret = select(_maxfd + 1, &set, NULL, NULL, &tv);
if(ret < 0)
{
std::cerr << "select error" << std::endl;
return false;
}
else if(ret == 0)
{
std::cerr << "wait timeout" << std::endl;
return true;
}
for(int i = 0; i < _maxfd + 1; i++)
{
//将就绪描述符放入数组中
if(FD_ISSET(i, &set))
{
TcpSocket socket;
socket.SetFd(i);
vec.push_back(socket);
}
}
return true;
}
private:
//需要监控的描述符,因为select会修改集合,所以每次进行操作的都是它的拷贝
fd_set _rfds;
//最大的描述符,因为fd_set是位图,所以保存最大的描述符可以减少遍历的次数。
int _maxfd;
};
#endif
#include
#include
#include
#include
#include
#include
#include"TcpSocket.hpp"
#include"select.hpp"
using namespace std;
int main(int argc, char* argv[])
{
if(argc != 3)
{
cerr << "正确输入方式: ./select_srv.cc ip port\n" << endl;
return -1;
}
string srv_ip = argv[1];
uint16_t srv_port = stoi(argv[2]);
TcpSocket lst_socket;
//创建监听套接字
CheckSafe(lst_socket.Socket());
//绑定地址信息
CheckSafe(lst_socket.Bind(srv_ip, srv_port));
//开始监听
CheckSafe(lst_socket.Listen());
Select s;
s.Add(lst_socket);
while(1)
{
vector<TcpSocket> vec;
//去掉未就绪描述符
bool ret = s.Wait(vec);
if(ret == false)
{
continue;
}
//取出就绪描述符进行处理
for(auto socket : vec)
{
//如果就绪的是监听套接字,则代表有新连接
if(socket.GetFd() == lst_socket.GetFd())
{
TcpSocket new_socket;
ret = lst_socket.Accept(&new_socket);
if(ret == false)
{
continue;
}
//新建套接字加入集合中
s.Add(new_socket);
}
//新数据到来
else
{
string data;
//接收数据
ret = socket.Recv(data);
//断开连接,移除监控
if(ret == false)
{
s.Del(socket);
socket.Close();
continue;
}
cout << "cli send message: " << data << endl;
data.clear();
if(ret == false)
{
s.Del(socket);
socket.Close();
continue;
}
}
}
}
//关闭监听套接字
lst_socket.Close();
return 0;
}
struct pollfd
{
int fd; //需要监控的文件描述符
short events; //需要监控的事件
short revents; //实际就绪的事件
};
/*
操作相对简单,如果某个描述符不需要继续监控时,直接将对应结构体中的fd置为-1即可。
*/
//发起监控
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
/*
fds:pollfd数组
nfds:数组的大小
timeout:超时等待时间,单位为毫秒
*/
缺点:
优点:
#include
#include
#include
#include"TcpSocket.hpp"
#define MAX_SIZE 10
using namespace std;
int main(int argc, char* argv[])
{
if(argc != 3)
{
cerr << "正确输入方式: ./select_srv.cc ip port\n" << endl;
return -1;
}
string srv_ip = argv[1];
uint16_t srv_port = stoi(argv[2]);
TcpSocket lst_socket;
//创建监听套接字
CheckSafe(lst_socket.Socket());
//绑定地址信息
CheckSafe(lst_socket.Bind(srv_ip, srv_port));
//开始监听
CheckSafe(lst_socket.Listen());
struct pollfd poll_fd[MAX_SIZE];
poll_fd[0].fd = lst_socket.GetFd();
poll_fd[0].events = POLLIN;
int i = 0, maxi = 0;
for(i = 1; i < MAX_SIZE; i++)
{
poll_fd[i].fd = -1;
}
while(1)
{
int ret = poll(poll_fd, maxi + 1, 2000);
if(ret < 0)
{
cerr << "not ready" << endl;
continue;
}
else if(ret == 0)
{
cerr << "wait timeout" << endl;
continue;
}
//监听套接字就绪则增加新连接
if(poll_fd[0].revents & (POLLIN | POLLERR))
{
struct sockaddr_in addr;
socklen_t len = sizeof(sockaddr_in);
//创建一个新的套接字与客户端建立连接
int new_fd = accept(lst_socket.GetFd(), (sockaddr*)&addr, &len);
for(i = 1; i < MAX_SIZE; i++)
{
if(poll_fd[i].fd == -1)
{
poll_fd[i].fd = new_fd;
poll_fd[i].events = POLLIN;
break;
}
}
if(i > maxi)
{
maxi = i;
}
if(--ret <= 0)
{
continue;
}
}
for(i = 1; i <= maxi; i++)
{
if(poll_fd[i].fd == -1)
{
continue;
}
if(poll_fd[i].revents & (POLLIN | POLLERR))
{
//新数据到来
char buff[4096] = {
0 };
int ret = recv(poll_fd[i].fd, buff, 4096, 0);
if(ret == 0)
{
std::cerr << "connect error" << std::endl;
close(poll_fd[i].fd);
poll_fd[i].fd = -1;
}
else if(ret < 0)
{
std::cerr << "recv error" << std::endl;
close(poll_fd[i].fd);
poll_fd[i].fd = -1;
}
else
{
cout << "cli send message: " << buff << endl;
}
if(--ret <= 0)
{
break;
}
}
}
}
lst_socket.Close();
return 0;
}
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
//在内核中创建eventpoll结构体,返回操作句柄(size为监控的最大数量,但是在linux2.6.8后忽略上限,只需要给一个大于0的数字即可)
int epoll_create(int size);
//组织描述符事件结构体
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd:eventpoll结构体的操作句柄
op:操作的选项,EPOLL_CTL_ADD/EPOLL_CTL_MOD/EPOLL_CTL_DEL
fd:描述符
event:监控描述符对应的事件信息结构体
struct epoll_event
{
uint32_t events; // 要监控的事件,以及调用返回后实际就绪的事件
epoll_data_t data; // 联合体,用来存放各种类型的描述符
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
*/
//开始监控,当有描述符就绪或者等待超时后调用返回
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
/*
maxevents:events数组的结点数量
timeout:超时等待时间
返回值为就绪的描述符个数
*/
epoll是Linux下性能最高的多路复用IO模型,几乎具备了一切所需的优点
缺点:
优点:
#ifndef __EPOLL_H_
#define __EPOLL_H_
#include
#include
#include
#include
#include"TcpSocket.hpp"
const int EPOLL_SIZE = 1000;
class Epoll
{
public:
Epoll()
{
//现版本已经忽略size,随便给一个大于0的数字即可
_epfd = epoll_create(1);
if(_epfd < 0)
{
std::cerr << "epoll create error" << std::endl;
exit(0);
}
}
~Epoll()
{
close(_epfd);
}
//增加新的监控事件
bool Add(const TcpSocket& socket, bool epoll_et = false, uint32_t events = EPOLLIN) const
{
int fd = socket.GetFd();
//组织监控事件结构体
struct epoll_event ev;
ev.data.fd = fd;//设置需要监控的描述符
if(epoll_et == true)
{
ev.events = events | EPOLLET;
}
else
{
ev.events = events;
}
int ret = epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
if(ret < 0)
{
std::cerr << "epoll ctl add error " << std::endl;
return false;
}
return true;
}
//删除监控事件
bool Del(const TcpSocket& socket) const
{
int fd = socket.GetFd();
int ret = epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, NULL);
if(ret < 0)
{
std::cerr << "epoll ctl del error" << std::endl;
return false;
}
return true;
}
//开始监控
bool Wait(std::vector<TcpSocket>& vec, int timeout = 3000) const
{
vec.clear();
struct epoll_event evs[EPOLL_SIZE];
//开始监控,返回值为就绪描述符数量
int ret = epoll_wait(_epfd, evs, EPOLL_SIZE, timeout);
//当前没有描述符就绪
if(ret < 0)
{
std::cerr << "epoll not ready" << std::endl;
return false;
}
//等待超时
else if(ret == 0)
{
std::cerr << "epoll wait timeout" << std::endl;
return false;
}
for(int i = 0; i < ret; i++)
{
//将所有就绪描述符放进数组中
TcpSocket new_socket;
new_socket.SetFd(evs[i].data.fd);
vec.push_back(new_socket);
}
return true;
}
private:
//epoll的操作句柄
int _epfd;
};
#endif
epoll有两种工作模式,LT模式(水平触发模式)和ET模式(边缘触发模式)。
LT模式也就是水平触发模式,是epoll的默认触发模式(select和poll只有这种模式)
触发条件
可读事件:接受缓冲区中的数据大小高于低水位标记,则会触发事件
可写事件:发送缓冲区中的剩余空间大小大于低水位标记,则会触发事件
低水位标记:一个基准值,默认是1
所以简单点说,水平触发模式就是只要缓冲区中还有数据,就会一直触发事件
- 当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
- 如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait 仍然会立刻返回并通知socket读事件就绪.
- 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
- 支持阻塞读写和非阻塞读写
ET模式也就是边缘触发模式,如果我们在第1步将socket添加到epoll_event描述符的时候使用了EPOLLET标志, epoll就会进入ET工作模式
触发条件
可读事件:(不关心接受缓冲区是否有数据)每当有新数据到来时,才会触发事件。
可写事件:剩余空间从无到有的时候才会触发事件
简单点说,ET模式下只有在新数据到来的情况下才会触发事件。这也就要求我们在新数据到来的时候最好能够一次性将所有数据取出,否则不会触发第二次事件,只有等到下次再有新数据到来才会触发。而我们也不知道具体有多少数据,所以就需要循环处理,直到缓冲区为空,但是recv是一个阻塞读取,如果没有数据时就会阻塞等待,这时候就需要将描述符的属性设置为非阻塞,才能解决这个问题
void SetNoBlock(int fd)
{
int flag = fcntl(fd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(fd, F_SETFL, flag);
}
- 当epoll检测到socket上事件就绪时, 必须立刻处理.
- 如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候, epoll_wait 不会再返回了.
- 也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
- ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
- 只支持非阻塞的读写
所以简单点说,LT就是只要缓冲区中还有数据,就会一直触发事件,而ET模式下只有在新数据到来的情况下才会触发事件。
LT模式的优点主要在于其简单且稳定,不容易出现问题,传统的select和poll都是使用这个模式。但是他也有缺点,就是因为事件触发过多导致效率降低
ET最大的优点就是减少了epoll的触发次数,但是这也带来了巨大的代价,就是要求必须一次性将所有的数据处理完,虽然效率得到了提高,但是代码的复杂程度大大的增加了。Nginx就是默认采用ET模式
还有一种场景适合ET模式使用,如果我们需要接受一条数据,但是这条数据因为某种问题导致其发送不完整,需要分批发送。所以此时的缓冲区中数据只有部分,如果此时将其取出,则会增加维护数据的开销,正确的做法应该是等待后续数据到达后将其补全,再一次性取出。但是如果此时使用的是LT模式,就会因为缓冲区不为空而一直触发事件,所以这种情况下使用ET会比较好。
#include
#include
#include
#include"TcpSocket.hpp"
#include"epoll.hpp"
using namespace std;
int main(int argc, char* argv[])
{
if(argc != 3)
{
cerr << "正确输入方式: ./epoll_lt_srv ip port\n" << endl;
return -1;
}
string srv_ip = argv[1];
uint16_t srv_port = stoi(argv[2]);
TcpSocket lst_socket;
//创建监听套接字
CheckSafe(lst_socket.Socket());
//绑定地址信息
CheckSafe(lst_socket.Bind(srv_ip, srv_port));
//开始监听
CheckSafe(lst_socket.Listen());
Epoll epoll;
epoll.Add(lst_socket);
while(1)
{
vector<TcpSocket> vec;
int ret = epoll.Wait(vec);
if(ret <= 0)
{
continue;
}
for(auto& socket : vec)
{
//如果就绪的是监听套接字,则说明有新连接到来
if(socket.GetFd() == lst_socket.GetFd())
{
TcpSocket new_socket;
lst_socket.Accept(&new_socket);
epoll.Add(new_socket);
}
//如果不是,则说明已连接的套接字有新数据到来
else
{
string data;
//接收数据
ret = socket.Recv(data);
//断开连接,移除监控
if(ret == false)
{
epoll.Del(socket);
socket.Close();
continue;
}
cout << "cli send message: " << data << endl;
data.clear();
if(ret == false)
{
epoll.Del(socket);
socket.Close();
continue;
}
}
}
}
lst_socket.Close();
return 0;
}
因为ET模式只支持非阻塞的读写,所以需要新增非阻塞读以及非阻塞写的接口,同时要对加入epoll的套接字加上EPOLLET的选项
//非阻塞发送数据,因为ET模式对于读写的响应只处理一次,所以需要通过轮询的将缓冲区一次性读取完
bool SendNoBlock(const std::string& data)
{
ssize_t pos = 0;
ssize_t left_size = data.size();
while (1)
{
ssize_t ret = send(_socket_fd, data.data() + pos, left_size, 0);
if (ret < 0)
{
//尝试重新写入
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
continue;
}
return false;
}
pos += ret;
left_size -= ret;
//如果数据发送完毕
if (left_size <= 0)
{
break;
}
}
return true;
}
//非阻塞接收数据
bool RecvNoBlock(std::string& data)
{
data.clear();
char buff[4096] = {
0 };
while (1)
{
ssize_t ret = recv(_socket_fd, buff, 4096, 0);
//没有内容
if (ret < 0)
{
//尝试重新写入
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
continue;
}
return false;
}
//对端关闭
else if (ret == 0)
{
return false;
}
buff[ret] = '\0';
data += buff;
//如果当前接受数据小于缓冲区长度,则说明数据全部接收完毕,反之则说明还需要多次轮询接收
if (ret < 4096)
{
break;
}
}
return true;
}
#include
#include
#include
#include"TcpSocket.hpp"
#include"epoll.hpp"
using namespace std;
int main(int argc, char* argv[])
{
if(argc != 3)
{
cerr << "正确输入方式: ./epoll_et_srv ip port\n" << endl;
return -1;
}
string srv_ip = argv[1];
uint16_t srv_port = stoi(argv[2]);
TcpSocket lst_socket;
//创建监听套接字
CheckSafe(lst_socket.Socket());
//绑定地址信息
CheckSafe(lst_socket.Bind(srv_ip, srv_port));
//开始监听
CheckSafe(lst_socket.Listen());
lst_socket.SetNoBlock();
Epoll epoll;
epoll.Add(lst_socket);
while(1)
{
vector<TcpSocket> vec;
int ret = epoll.Wait(vec);
if(ret <= 0)
{
continue;
}
for(auto& socket : vec)
{
//如果就绪的是监听套接字,则说明有新连接到来
if(socket.GetFd() == lst_socket.GetFd())
{
TcpSocket new_socket;
lst_socket.Accept(&new_socket);
new_socket.SetNoBlock();
epoll.Add(new_socket, true);
}
//如果不是,则说明已连接的套接字有新数据到来
else
{
string data;
//接收数据
bool ret = socket.RecvNoBlock(data);
//断开连接,移除监控
if(!ret)
{
epoll.Del(socket);
socket.Close();
continue;
}
cout << "cli send message: " << data << endl;
data.clear();
if(ret == false)
{
epoll.Del(socket);
socket.Close();
continue;
}
}
}
}
lst_socket.Close();
return 0;
}
在一个执行流中,如果添加了特别多的描述符进行监控,则轮询处理就会比较慢。
因此就会采取多执行流的解决方法,在多个执行流中创建epoll,每个epoll监控一部分描述符,使压力分摊。但是可能因为无法确定哪些描述符即将就绪,所以就会让每个执行流都监控所有描述符,谁先抢到事件则谁去处理。
所以当多个执行流同时在等待就绪事件时,如果某个描述符就绪,他就会唤醒全部执行流中的epoll进行争抢,但是此时就只会有一个执行流抢到并执行,而此时其他的执行流都会因为争抢失败而报错,错误码EAGAIN。这就是惊群问题。
惊群问题带来了什么坏处呢?
这种方法其实也就是本篇博客开头提到的一种做法。只使用一个线程进行事件的监控,每当有就绪事件到来时,就将这些事件转交给其他线程去处理,这样就避免了因为多执行流同时使用epoll监控而带来的惊群问题。
这里主要借鉴的是lighttpd和nginx的解决方法。
lighttpd的解决思路很简单粗暴,就是直接无视这个问题,事件到来后依旧能够唤醒多个进程来争抢,并且只有一个能成功,其他进程争抢失败后的报错EAGAIN会被捕获,捕获后不会处理这个错误,而是直接无视,就当做没有发生。
nginx的解决思路是其实就是加锁与负载均衡。使用一个全局的互斥锁,每当有描述符就绪,就会让每个进程都去竞争这把锁(如果某个进程当前连接数达到了最大连接数的7/8,也就是其负载均衡点,此时这个进程就不会再去争抢所资源,而是将负载均衡到其他进程上),如果成功竞争到了锁,则将描述符加入进自己的wait集合中,而对于没有竞争到锁的进程,则将其从自己的wait集合中移除,这样就保证了不会让多个进程同一时间进行监控,而是让每个进程都通过竞争锁的方式轮流进行监控,这样保证了同一时间只会有一个进程进行监控,所以惊群问题也得到了解决。
参考资料
高并发网络编程之epoll详解
epoll详解
Linux惊群效应详解
epoll的惊群效应
Apache与Nginx网络模型
[框架]高并发中的惊群效应
Nginx如何解决“惊群”现象