大厂是如何设计基于Epoll的网络通信模型
- 背景
- Epoll的优势
- 设计思想
- 具体实现
- Agent类
- TCPListenAgent类
- Epoll类
- EchoAgent
- RunControl类
- 代码链接
背景
网上讲Epoll的很多,但是都仅仅停留在简单的示例使用和Epoll接口介绍,但是在真正的工程应用中不可能就使用这种简单的过程式开发。为了让小伙伴们对Epoll为什么对高并发网络通信有很好的应用,下面就结合以前在菊厂的开发经历来讲下网络通信对象同Epoll是如何很好得结合的。
Epoll的优势
传统的处理网络I/O的多进程、多线程同步I/O,或者是单线程的select和poll的事件驱动模型。其中多线程和多进程同步阻塞网络I/O技术,具有模型直观,使用方便等优点,但当处理高并发的网络连接时,因为存在Fork(线程池可部分避免)和上下文切换操作产生较大的系统开销;同时内存开销也较大,不能满足服务器性能要求,适用于并发数不高以及服务器负载不大的场合。因此,为了提升系统的高并发情况下的性能和吞吐率,一般采用IO多路复用模型。IO多路复用包括Select,Poll和Epoll三种方式,Epoll作为Linux内核为处理大批文件描述符而改进的poll。相对于select和poll,Epoll有以下两个优势:
- 支持理论上无限大的socket描述符
select限制了每个进程打开的socket描述符,例如Linux系统在linux/include/linux/posix_types.h中定义了_FD_SETSIZE为1024,如下图
即在Linux系统中Select最大只能支持1024个描述符,当需要监听1024个以上的描述符时,Select函数就会监听出错。而Epoll使用红黑树管理注册的描述符,理论上能监听无限个描述符,现实中会收到内存的限制。
- 采用回调函数避免遍历所有描述符
select和poll都是通过链表管理注册好的描述符,每次当有描述符监听到读写事件发生时,select和poll都需要遍历整个链表从而找到有事件发生的描述符,在活动描述符较少的情况下,这种方案是非常低效的。Epoll采用红黑树管理描述符,如果有描述符监听到读写时间发生,Epoll会通过回调函数将该描述符插入到就绪队列中,即每次Epoll扫描的只是就绪队列中的发生读写事件的描述符。
设计思想
下面我们就来讲讲。
在Epoll中有个重要的结构体epoll_event,它被用于注册所感兴趣的事件和回传所发生的事件。它的定义如下:
struct epoll_event {
__uint32_t events; /* epoll event /
epoll_data_t data; / User data variable */
};
它当中的epoll_data_t保存了触发事件的某个文件描述符相关的数据。定义如下
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
这里的关键设计是把epoll_data_t中的地址指针ptr同一个通信实体交互的通信单元类Agent进行绑定。为什么这么做呢?
我们把每个通信实体对应于一个Agent实例。当这个套接字或者文件描述符上有I/O事件到达时,Epoll会返回这个套接字所绑定的地址指针,这里把这个地址指针指向这个套接字或者文件描述符对应的Agent实例,这样就可以返回Agent实例的地址,然后根据I/O事件的不同调用Agent里面对应的处理函数处理与通信实体间的交互。Agent类处理的交互一般包括读写时间处理,以及Agent的启动和停止。
具体实现
下面通过一个Epoll非阻塞的回射服务器的例子来讲解下上面提到的设计思想是如何实现的。
主要的涉及到的类有两个Epoll和Agent:
- Epoll类封装事件驱动核心,负责事件通知。读写事件发生后,由Agent处理网络读写。
- Agent类是事件处理器的基类。TCPListenAgent和TCPAgent继承自Agent,分别处理TCP监听套接字和普通TCP连接的网络收发。
该模型的简化类图如下:
下面我们来介绍下这些类的设计
Agent类
Agent类是事件处理器的基类,它声明如下:
class Agent
{
protected:
int conn_type; // Agent连接状态
int connfd;
public:
Agent(){}
virtual ~Agent(){}
virtual int sendData()=0;
virtual int recvData()=0;
int getState() const
{
return conn_type;
}
void setState(int st)
{
conn_type = st;
}
int getErrorno() const
{
return errno;
}
}
- recvData函数作为纯虚函数用于接受客户端的请求。成功返回读取的字节数,失败返回-1。
- sendData函数作为纯虚函数用于回复客户端的请求。成功返回写出去的字节数,失败返回-1。
TCPListenAgent类
主要负责处理客户端发送过来的TCP连接请求。该类声明如下:
class TCPListenAgent : public Agent
{
public:
TCPListenAgent(EchoAgent *);
~TCPListenAgent();
bool init(void);
int SetNonblock(int fd);
virtual int RecvData();
virtual int sendData();
private:
void Socket();
private:
struct sockaddr_in m_cliAddr; //存储客户端连接的地址
struct sockadd_in m_servAddr; //存储服务端的地址
EchoAgent *m_pEchoAgent; //执行回射消息的通信对象
26 };
TCPListenAgent类主要实现Agent类提供的两个纯虚函数,这里列出TCPListenAgent类提供的主要方法:
- init函数用于初始化TCPListenAgent 本身。初始化成功返回true,失败返回false。
- recvData函数用于处理客户端发送来的TCP连接请求。
Epoll类
通过初始化及运行在单例类RunControl中来实现单例模式,对于整个程序,全局仅有唯一的一个Epoll实例。Epoll类的声明如下:
class Epoll
{
public:
Epoll();
~Epoll();
int epollInitial(int size);
int doEvent(int fd, Agent *agentPtr, int op, unsigned int event);
int epollRegister(int fd, Agent *agent,int event);
int epollChange(int fd, Agent *agent, int event);
int epollDelete(int fd);
void run(void);
private:
struct epoll_event ev, *events;
int m_epfd; //Epoll 的句柄
int maxevents;
};
- epollInitial函数用于初始化Epoll对象,参数size是Epoll监听队列的长度。
- doEvent函数用于对Epoll事件进行操作,增加、删除或者修改。参数agentPtr具体的Agent对象,fd为需要加入Epoll的描述符,op为Epoll的具体操作可传入参数包括EPOLL_ADD,EPOLL_CTL,EPOLL_DEL,event为要监听的Epoll事件,比如EPOLLOUT, EPOLLIN。
- run函数用于执行EPOLL整个运行流程。函数中主要调用epol_wait函数,当struct epoll_event的events判断是EPOLLIN时,Agent对象调用recvData函数,如果是EPOLLOUT事件时调用sendData()函数。当events是EPOLLHUP或者是EPOLLERR,则判断通信链路的连通性是否是EISCONN。
EchoAgent
主要负责接受并解析客户端发送过来的回射消息,并重新封装好后发送回去。
class EchoAgent : public Agent
{
struct iov_req {
iov_req():mComplete(true) {}
iov_req(char *buffer, unsigned int len):mComplete(true)
{
mIov.iov_base = buffer;
mIov.iov_len = len;
}
struct iovec mIov;
bool mComplete ;
};
public:
EchoAgent();
EchoAgent(int fd);
~EchoAgent();
int recvData();
int sendData();
int SendPackage(MsgHeader &header, char *buffer);
int WriteDynamic(char *buf , int len);
private:
int Read();
int write();
private:
unsigned int m_iLen;//存取MsgHeader中的length字段
unsigned int m_iOffset;//读取偏移量
struct InReq m_InReq;
bool m_bInit;
bool m_bReadHead;//是否读取头部
void* mLastIov;
private:
std::list<iov_req> mIovList;//接收缓冲队列
};
其中struct MsgHeader同 InReq定义如下
struct MsgHeader
{
uint32_t cmd;
uint32_t length;
} __attribute__((packed));
struct InReq
{
MsgHeader m_msgheader;
char *ioBuf;
};
在EchoAgent类中定义了缓冲队列mIovList,它当中的成员都是struct iovec类型。struct iovec定义了一个向量元素。通常这个结构用作一个多元素的数组。对于每一个传输的元素,指针成员iov_base指向一个缓冲区,这个缓冲区是存放的是readv所接收的数据或是writev将要发送的数据。成员iov_len在各种情况下分别确定了接收的最大长度以及实际写入的长度。
当接收到客户端发过来的回射消息,EchoAgent调用recvData函数。函数定义如下
int EchoAgent::recvData()
{
if(this->Read() < 0)
{
err_sys("Read error");
return -1;
}
return 0;
}
从定义可以看出,调用了私有成员函数read, read通过成员m_InReq接受回射消息,当回射消息接收完成,将m_InReq的消息追加到 mIovList并调用doEvent将Epoll事件从EPOLLIN切换为EPOLLOUT。这个时候就会调用EchoAgent的成员函数sendData。sendData函数定义如下:
int EchoAgent::sendData()
{
if(write() < 0)
{
err_sys("write error");
return -1;
}
if(mIovList.size() == 0) {
gEpoll->doEvent(connfd, this, EPOLL_CTL_MOD, EPOLLIN);
return 0;
}
}
可以看到在成员函数sendData中调用了私有成员函数write。write主要实现了遍历 mIovList中的每个成员,并调用writev将接受到的回射消息回复给客户端。wirtev的第一个参数传入的文件描述符就是Agent的成员变量connfd。write调用成功后则再调用doEvent将Epoll事件从EPOLLOUT切换成EPOLLIN。
RunControl类
该类实例化了模板类Singleton,负责创建级初始化了Epoll和TCPListenAgent的全局对象。该类声明如下:
class RunControl : public Singleton<RunControl>
{
friend Singleton<RunControl>;
private:
RunControl() {}
~RunControl() {}
private:
void initEpoll();
void initListenAgent();
public:
void runEpoll();
void run();
};
成员函数runEpoll则通过全局对象指针g_pEpoll调用Epoll中的run接口。
void
RunControl::runEpoll()
{
// INFO_LOG("Gateway started. Waiting for connections.");
g_pEpoll->run();
}
代码链接
以上主要是对该网络模型的逻辑进行了介绍,想查看具体代码如下:
Epoll非阻塞通信模型源码。希望对大家对Epoll的理解有所帮助,谢谢大家!