[C/C++后端开发学习] 7 tcp服务器的epoll实现以及Reactor模型

本文深入探讨了C/C++后端开发中的IO多路复用,重点介绍了epoll的使用及Reactor模型。通过对比select、poll和epoll,阐述了epoll在性能和效率上的优势。详细讲解了epoll的LT和ET触发模式,以及如何在服务器端利用epoll实现监听和接收数据。同时,文章讨论了单Reactor和多线程多Reactor的优化策略,分析了它们在不同场景下的适用性。
摘要由CSDN通过智能技术生成

1 IO多路复用

IO多路复用简单地理解就是,一个线程(或进程)同时负责多个文件描述符fd的读写操作,它通过某种方式对这些fd进行监听,当内核发现指定的某个描述符就绪时,就通知线程进行读写。

select、poll和epoll是最常见的IO多路复用实现方式。基于此实现的服务器模型往往又被称为事件驱动模型。

select

select将需要监听的接口描述符标记到集合中(数组实现),包括读、写、异常3个集合,然后将这些集合传递给内核;内核轮询遍历集合中标记的描述符,并将就绪的描述符保留在这些集合中返回。应用程序从内核处拷贝回这些集合后,还需要再遍历一遍才能获知哪些描述符处于就绪的状态。

当描述符较大时,select()接口本身需要消耗大量时间去轮询各个句柄;其次,整个描述符集合在用户程序和内核之间来回拷贝也存在一定的开销;最后,应用程序还需要遍历一遍描述符集合才能获得各描述符的状态。可见,select 显然不是实现“事件驱动”的最好选择。

下面是select 接口的原型定义:

FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,struct timeval *timeout)

其中 fd_set 类型按 bit 位标记句柄,比如设置了一个值为16的句柄(通过FD_SET() ),则fd_set 的第16位bit被设置为1,select调用将对其进行监视;select调用返回后,也需要检查fd_set 中的16号句柄(第16位bit)是否被标记为1(通过FD_ISSET() ),从而可判断对应的描述符是否可读写。

客户端的一个 connect() 操作,将在服务器端的监听socket激发一个“可读事件”;close()操作也是对相应连接的socket触发可读,只不过读操作返回的是0。

poll

poll的工作方式与select本质上差不多,只不过将内部管理描述符的数据结构由数组改为了链表,取消了最大可监控文件描述符数的限制,其他方面与select基本无异。

epoll

epoll 在内核中维护一个简易的文件系统,其中包含一个红黑树和一个就绪队列。每一个事件结构体都包含需要监听的描述符、监听的读写事件以及事件触发时的回调函数,这些事件会被挂到红黑树中,这样可以实现事件结构的快速查询,方便实现事件的修改和删除;就绪的描述符会被加入到等待队列中,因此应用程序不需要轮询,直接从队列中读走就绪的事件结构即可。

关于epoll的工作原理可以参考:Select、Poll、Epoll详解。一个简单的示意图如下图所示,其要点在于:所有添加到 epoll 的事件都会与网卡驱动程序建立回调关系,当对应事件发生时,会调用其回调⽅法(ep_poll_callback)并将事件的event结构体放到就绪队列rdllist双向链表中;之后再将就绪队列中的数据拷贝到用户空间,epoll_wait 调用返回。

在这里插入图片描述

与select相对地,1)epoll只需要将待监视的描述符信息拷贝一次给内核,不必每次调用前都设置一次;2)不必每次都遍历描述符集合,直接读取就绪的描述符事件结构即可;3)最大描述符数量没有限制。

不过,select是POSIX的,而不同的操作系统特供的 epoll 接口差异很大,因此跨平台能力就相对差一些,不过对于以Linux为主的服务器环境问题不大。

如果处理的连接数不是特别多的话,使用select/epoll 方案的服务器不一定就比使用多线程+阻塞IO 方案的性能更好,甚至可能延迟还更大。但对于连接数更多的情况下,select/epoll 等肯定具有优势。

2 epoll详解

2.1 基本使用方法

  • 创建一个epoll实例,返回该实例的描述符epfd:
int epoll_create(int size);		// size参数只有0和1的区别(Since Linux 2.6.8, the argument is ignored, but must be  greater  than  zero)
  • 设置epoll事件结构(注意了哈,epoll_data 是个**union**!)。
typedef union epoll_data
{
   
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event
{
   
  uint32_t events;	/* Epoll events */
  epoll_data_t data;	/* User data variable */
} __EPOLL_PACKED;

其中,events 事件类型变量常用 EPOLLINEPOLLOUTEPOLLET,可以通过‘“或”运算符|进行组合。data 下的 fd 和 ptr 根据需要设置其一,一般使用 ptr 指向自定义的结构体,其中可以是包含了fd 和回调函数等等需要的参数,一个实例如下:

struct sockitem
{
   
	int sockfd;
	int (*callback)(int fd, int events, void *arg);
};

事件类型EPOLLERREPOLLHUP的意义在于,当连接发生错误或关闭时,可以通知处理函数从socket读出 errno。EPOLLHUP出现在TCP四次挥手接收到最后一个ACK后,此时读写通道都已关闭。

EPOLLRDHUB的意义则在于检测到对端调用shutdown(SHUT_WR)发送⼀个 FIN 包关闭了写通道,此时对于本地端就是读通道关闭了,但写通道没有关闭(本地没有调用close),连接处于半关闭状态。

  • 添加事件到epoll实例中:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd : 就是epoll实例的描述符
op : EPOLL_CTL_ADD(增加事件)、EPOLL_CTL_MOD(修改事件)、EPOLL_CTL_DEL(删除事件)
event : 自然就是对应的事件结构体了
  • 获取就绪的触发事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

此处,events是一个输入/输出参数,传入的应该要是一个struct epoll_event数组;maxevents一般是数组大小;timeout是以毫秒为单位的超时事件,传入-1则永久阻塞,传入0则会立即返回;Linux的man中说的很清楚:

The call will block until either :
* a file descriptor delivers an event;
* the call is interrupted by a signal handler; or
* the timeout expires.

正常返回时,返回值为就绪的事件数目n,events数组中的前n个元素则存放了就绪的事件结构体。

2.2 LT水平触发和ET边沿触发

LT水平触发是 epoll 的默认触发方式,与select一样,读时只要 fd 对应的缓冲区中有数据,就一直触发通知应用程序 fd 可读,写时只要缓冲区中有空间就通知应用 fd 可写。而ET边沿触发是epoll特有的,读时只有当缓冲区中新加入新的数据时,才触发一次,之后如果数据没有全部读走,即使缓冲区中还有数据,也不会再触发,直至缓冲区又送入了新数据才又触发一次。

显然,在

要使用边沿触发的话,在epoll_event结构的变量events中设置EPOLLET即可。

一般的使用方法为:

  • ET + 循环读出全部数据
  • LT + 一次读一块数据,不管多少

使用ET时一次触发往往需要通过循环去将所有数据读出,否则有可能导致死等,即:客户端发送数据后等待响应,但一次触发后服务端没有读出全部数据,不会响应客户端,客户端也不再发数据引起触发,于是二者就僵持住了。

但ET的优势在于:因为在ET模式下只有当数据到达网卡时才会触发一次,而LT模式下,内核需要不断轮询I/O状态表,涉及的epoll_wait系统调用的次数也更多,所以针对大数据量的场景ET模式的效率要比LT模式高很多。

⽔平触发的时候, io函数既可以是阻塞的也可以是⾮阻塞的。
边沿触发的时候, io函数只能是⾮阻塞的。因为ET触发一次读事件后,应该要循环将所有数据读完毕,此时如果采用阻塞IO去读,那么最后一次读必然会被阻塞住。

具体实现中,使用ET还是LT取决于:1)首先看服务是否需要界定数据包并进行数据解析,2)其次看每次读的数据量是否比较小。

  • 如果需要界定数据包,则适合使用LT,每次读一部分数据就先做业务逻辑相关的解析,然后再触发读下一部分数据,再做其他处理。比如redis和memcached,主要职责是实现业务逻辑,比较关心的是是否读到了完整的数据包,如果读到了足够的数据包就先去做业务处理,剩下的下一次触发再来读;
  • 如果每次读取的数据量比较小,适合用LT,因为在使用LT模式读数据时每次触发往往只会调用一次read,如果一次读的比较少而没有把缓冲区中的数据全读完也没关系,反正下次还会触发;这往往也是在需要界定数据包的情况下才会出现;
  • 如果二者都不是,则适合使用ET。比如Nginx作反向代理,因为不需要处理业务逻辑,数据量又比较大,一有数据就尽量把读缓冲区中的所有数据一股脑地读出来并转发出去。

2.3 实现服务器监听和接收数据

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <errno.h>
#include <sys/epoll.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>

int main(int argc, char* argv[])
{
   
    int port = atoi(argv[1]);   // 传入一个参数作为端口号,此处对参数简单处理

    /* 开启监听 */
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
		return -1;

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(struct sockaddr_in));

    addr.sin_family = AF_INET;
    addr
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值