多路转接 | select,poll,epoll的区别以及各自的应用实现

前言

IO可以分为两个步骤,等待+实际的读写。

  • 等待是指等待文件的某一事件就绪,如果文件始终没有就绪,IO就要阻塞
  • 实际的读写就是IO最重要的操作了,这部分所占的比重越大,IO效率越高效

比如调用read时,指定套接字文件始终没有数据可以读取(读事件没有就绪),那么read将一直阻塞,直到数据的到来

多路转接是一种高效的IO方式,可以同时监听多个套接字文件,但是不是一个一个的阻塞等待,而是等待多个文件,当有文件就绪时,立即进行IO操作。这样就可以使IO的等待时间重叠,提高效率,减少CPU的空闲时间。常见的多路转接技术有select,poll,epoll,它们都是系统调用接口,具体实现被系统封装与隐藏了。

select函数介绍

select是一个多路复用(转接)输入/输出模型,它可以让程序监听多个套接字,在其中的一个或多个套接字准备好读写或者发送异常时通知程序

select函数原型如下:

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout)
  • nfds:表示要监听的文件描述符最大值,如果要监听的文件描述符最大值为n,那么就要设置nfds为n + 1,表示要监听文件描述符处于[0, n + 1)间的左闭右开区间
  • readfds:指向一个fd_set结构体,用于存储监听的读事件文件描述符集合
  • writefds:指向一个fd_set结构体,用于存储监听的写事件文件描述符集合
  • exceptfds:指向一个fd_set结构体,用于存储发生异常的文件描述符集合
  • timeout:指向一个timeval结构体,用于设置select的超时时间
    • 如果为NULL,表示无限等待(阻塞式),只有监听的文件描述符集合中有一个或多个发生了事件,select才会返回,否则一直阻塞
    • 如果为0秒0微秒,表示不等待(非阻塞式),不论文件描述符集合中是否发生了事件,select都直接返回
    • 如果大于0秒0微妙,表示等待指定的时间,监听的文件描述符集合中有一个或多个发生了事件或者超过了指定时间,select才会返回

select出错返回-1,并设置errno。返回0表示没有事件发生。有事件发生时,返回值表示发生事件的文件描述符数量

设置了超时时间,select就会返回在这段时间内,发生事件的文件描述符数量吗? 答案是不一定,发生了一个或多个事件,select可能也会返回。所以select可能提前返回,并且发生事件的文件描述符数量是不确定的,可能为1,可能大于1。我们要用FD_ISSET宏对每个文件描述符进行判断并处理


至于说fd_set结构体,这是一个long int类型(长度与操作系统和编译器有关,32位及以前的系统,长度为4字节,64位系统,长度为8字节。可以用sizeof检查你的平台上long int的大小)的数组。用来存放文件描述符,每一比特位表示一个文件描述符,1/0表示该文件描述符的某一事件是否发生的状态。有以下4个宏可以处理fd_set结构体

  • FD_ZERO(fd_set* fdset):清除fdset的所有位(置0
  • FD_SET(int fd, fd_set* fdset):将fd添加到fdset中
  • FD_CLR(int fd, fd_set* fdset):将fd从fdset中清除
  • FD_ISSET(int fd, fd_set* fdset):检查fd是否在fdset集合中,如果在返回非0值,不在返回0

可以直接位操作fd_set,但推荐使用宏来操作fd_set,因为fd_set的内部实现因平台而异,直接位操作不仅破坏其封装性和可移植性,还可能引发错误

关于最后一个参数timeout,它是struct timeval类型的指针,以下是struct timeval结构体成员的具体信息

// tv_sec表示秒,tv_usec表示微秒
struct timeval {
	time_t      tv_sec;     /* seconds */
	suseconds_t tv_usec;    /* microseconds */
};

tcp多路转接代码实现

使用tcp协议的四个步骤:socket,bind,listen,accept。为提高IO效率,使用select函数,

  • 如果监听套接字暂时没有与其他主机建立tcp连接,监听套接字文件会处于LISTEN状态
  • 如果有主机三次握手成功了,监听套接字会从LISTEN状态变为READABLE状态,表示新连接的到来
  • 并且将该连接从半连接队列中删除,加入全连接队列

所以select可以根据监听套接字文件的状态变化,检测是否有读事件发生:如果文件从LISTEN->READABLE,就表示发生了读事件。除了LISTEN和READABLE状态,还有一些常见状态

  • WRITABLE:表示文件可写入
  • EXECUTABLE:表示文件可执行
  • CLOSED:表示文件已关闭
  • ERROR:表示文件发生错误

select根据文件的状态,来判断是否有读事件,写事件或者异常事件发生,以返回发生事件的数量

#include "Socket.hpp"
#include <sys/select.h>

void usage(char *process_name)
{
    cout << "usage: " << process_name << " port"
         << endl;
}

// 历史套接字数组和它的长度,长度为1024,表示可以同时运行的套接字数量
// 其实select只能同时监听1024个套接字
int fd_array[sizeof(fd_set) * 8] = {0};
int arr_num = (sizeof(fd_array) / sizeof(fd_array[0]));
// 数组初始值
#define DFL -1
#define BUF_SIZE 1024

// select监听到了事件的发生,调用HandlerEvent处理事件
void HandlerEvent(int listen_sock, fd_set& readfds)
{
    for (int i = 0; i < arr_num; ++i)
    {
        // 跳过默认值,寻找需要监听的套接字
        if (fd_array[i] == DFL)
            continue;
        // 如果发生了读事件
        if (FD_ISSET(fd_array[i], &readfds))
        {
            if (fd_array[i] == listen_sock)
            {
                // 有新连接了,判断是否能获取该连接
                int j = 0;
                for (j = 0; j < arr_num; ++j)
                {
                    if (fd_array[j] == DFL)
                        break;
                }
                if (j == arr_num)
                {
                    cerr << "当前队列已满"  << endl;
                }
                else
                {
                    // 处理事件
                    uint16_t peer_port;
                    string peer_ip;
                    int server_sock = tcpSock::Accept(listen_sock, &peer_ip, &peer_port);
                    if (server_sock < 0)
                    {
                        cerr << errno << ": " << strerror(errno) << endl;
                        // accept失败,暂时不管这个连接了
                        continue;
                    }
                    // 将其添加到历史数组中
                    fd_array[j] = server_sock;
                    cout << peer_ip << "[" << peer_port << "] 连接..." << endl;
                }
            } // end of if (fd_array[i] == listen_sock)
            else
            {
                // 创建读缓冲区
                char read_buffer[BUF_SIZE] = {0};
                // 普通IO事件就绪,此时读取不会阻塞
                int ret = recv(fd_array[i], (void*)&read_buffer, sizeof(read_buffer) - 1, 0);
                // 读取出错
                if (ret < 0)
                {
                    cerr << errno << ": " << strerror(errno) << endl;
                    // 注意,程序不要直接退出,应该关闭该服务套接字
                    close(fd_array[i]);
                    fd_array[i] = DFL;
                }
                // 对端关闭
                else if (0 == ret)
                {
                    cout << "peer close..." << endl;
                    close(fd_array[i]);
                    fd_array[i] = DFL;
                }
                // 读取成功
                else
                {   
                    read_buffer[ret] = '\0';
                    // 这里需要对读取的信息进行处理,暂时用打印替代
                    cout << read_buffer;
                }
            }
        }
    }
}

int main(int argc, char *argv[])
{
    // 判断调用者是否传入了端口号
    if (argc != 2)
    {
        usage(argv[0]);
        exit(-1);
    }
    // 创建套接字
    int listen_sock = tcpSock::Socket();
    // 将用户传入的端口绑定到套接字上
    tcpSock::Bind(listen_sock, atoi(argv[1]));
    // 使监听套接字处于监听状态
    tcpSock::Listen(listen_sock);
    // 初始化历史套接字数组
    for (int i = 0; i < arr_num; ++i)
    {
        fd_array[i] = DFL;
    }

    // 默认将listen套接字设置进fd数组
    fd_array[0] = listen_sock;

    // 不断地检测事件的发生
    while (true)
    {
        fd_set readfds = {0};
        int max_fd = DFL;
        // 添加读事件监听集
        for (int i = 0; i < arr_num; ++i)
        {
            // 默认值不需要监听,直接跳过,找要监听的套接字
            if (fd_array[i] == DFL)
                continue;
            // 设置套接字到监听事件集中
            FD_SET(fd_array[i], &readfds);
            // 需要维护select的第一个参数
            if (fd_array[i] > max_fd)
                max_fd = fd_array[i];
        }
        // 设置超时时间为5秒
        struct timeval timeout = {5, 0};
        // 只关心读事件
        int n = select(max_fd + 1, &readfds, nullptr, nullptr, &timeout);
        switch (n)
        {
        case 0:
            cout << "没有事件发生,但超时了..." << endl;
            break;
        case -1:
            cerr << errno << ":" << strerror(errno) << endl;
            break;
        default:
            HandlerEvent(listen_sock, readfds);
            break;
        }
    }
    return 0;
}

poll函数介绍

poll也是一个用于实现多路转接的函数,其原型如下

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
  • fds:struct pollfd类型的指针,可以想象成数组,存储了多个struct pollfd

  • nfds:表示fds的长度,需要监听的套接字数量,可以存在无效套接字

  • timeout:单位为毫秒,超时时间的设置

    • 如果为-1,表示非阻塞等待。无论监听的描述符集合中是否有事件发生,都立即返回
    • 如果为0,表示阻塞式等待。除非监听的描述符集合中有一个或多个事件发生,否则不会返回
    • 如果大于0,表示在指定的时间内,如果监听的描述符集合中有一个或多个事件发生,或者超过了指定时间,poll才返回
  • 函数执行成功,返回监听的描述符集合中,发生事件的描述符个数。如果超时且没有任何事件发生,就返回0。如果调用失败,poll返回-1,并设置errno

poll比select使用简单,select有两个主要问题

  • select监听的套接字有数量上限,最大为1024
  • 由于事件集是一个输入输出参数,每次处理完事件,需要重新设置事件集。因为内核修改了上次的事件集,当需要监听的文件描述符数量增多时,重新设置事件集将会是极大的开销

poll就是为解决select的这两个问题而生的。这里有一个结构体:struct pollfd

// pollfd结构
struct pollfd {
	int   fd;         /* file descriptor */
	short events;     /* requested events */
	short revents;    /* returned events */
};

该结构体指明了需要监听的文件描述符,需要监听的事件(用户告诉内核),以及监听到的事件(内核告诉用户)。events和revents字段可以用以下常量来指定或测试不同类型的事件

  • POLLIN:普通或优先数据可读
  • POLLRDNORM:普通数据可读
  • POLLRDBAND:优先数据可读
  • POLLOUT:普通或优先数据可写
  • POLLWRNORM:普通数据可写
  • POLLWRBAND:优先数据可写
  • POLLERR:发生错误
  • POLLHUP:发生挂起

关于这些字段或者更多的字段,需要用到时可以上网查。

poll函数 vs poll系统调用

这一小节所谈论的poll是指poll系统调用

poll系统调用是用户向内核发起的一种请求,使用poll系统调用可以监听多个文件描述符的状态,等待其中一个或多个就绪或超时。poll系统调用会调用poll函数,poll函数是文件描述符所属的设备或对象提供的一种检查状态的接口,它通常是一个设备驱动程序中实现的函数。poll系统调用在执行过程中,会遍历fds数组中的每个元素,并且调用其对应文件描述符的poll函数来检查其状态,将结果保存在revents域中。如果没有任何文件描述符就绪,poll系统调用会将当前进程挂起到等待队列中,并进入休眠状态。当有设备发生IO事件时,内核会唤醒等待队列中的进程,并重新检查fds数组中的每个元素。

不同类型的文件描述符,如套接字,终端,管道,可能含有不同的poll函数实现。但poll函数主要有两个功能

  • 一是返回文件描述符当前的状态,如可读,可写,异常等,用户根据其返回的状态进行相应的操作
  • 二是将当前进程注册到设备或对象的等待队列中,并指定当文件描述符状态发生变化时,需要执行的回调函数。当设备或对象发生IO事件时,就可以唤醒等待队列中的进程并执行回调函数

所以poll系统调用是用户和内核的一种交互方式,而poll函数则是poll系统调用执行过程中所需的一个接口函数

tcp多路转接代码实现

#include "Socket.hpp"
#include <poll.h>

void usage(char *process_name)
{
    cout << "usage: " << process_name << " port"
         << endl;
}

#define FDS_SIZE  1024
struct pollfd fds[FDS_SIZE] = {0};
nfds_t fds_count = 1;
#define DFL -1
#define BUF_SIZE 1024

// select监听到了事件的发生,调用HandlerEvent处理事件
void HandlerEvent(int listen_sock)
{
    for (int i = 0; i < fds_count; ++i)
    {
        // 跳过默认值,寻找需要监听的套接字
        if (fds[i].fd == DFL)
            continue;
        // 如果发生了读事件
        if (fds[i].revents & POLLIN)
        {
            // 有新连接了,判断是否能获取该连接
            if (fds[i].fd == listen_sock)
            {
                int j = 0;
                for (j = 0; j < fds_count; ++j)
                {
                    if (fds[i].fd == DFL)
                        break;
                }
                if (j == FDS_SIZE)
                {
                    cerr << "当前队列已满"  << endl;
                }
                else
                {
                    // 处理事件
                    uint16_t peer_port;
                    string peer_ip;
                    int server_sock = tcpSock::Accept(listen_sock, &peer_ip, &peer_port);
                    if (server_sock < 0)
                    {
                        cerr << errno << ": " << strerror(errno) << endl;
                        // accept失败,暂时不管这个连接了
                        continue;
                    }
                    // 将其添加到监听队列中
                    fds[j].fd = server_sock;
                    // 监听读事件
                    fds[j].events |= POLLIN;
                    fds[j].revents = 0;
                    ++fds_count;
                    cout << peer_ip << "[" << peer_port << "] 连接..." << endl;
                }
            } // end of if (fds[i].fd == listen_sock)

            else
            // 普通IO事件就绪,此时读取不会阻塞
            {
                // 创建读缓冲区
                char read_buffer[BUF_SIZE] = {0};
                // 读取数据
                int ret = recv(fds[i].fd, (void*)&read_buffer, sizeof(read_buffer) - 1, 0);
                // 读取出错
                if (ret < 0)
                {
                    cerr << errno << ": " << strerror(errno) << endl;
                    // 注意,程序不要直接退出,应该关闭该服务套接字
                    close(fds[i].fd);
                    fds[i].fd = DFL;
                }
                // 对端关闭
                else if (0 == ret)
                {
                    cout << "peer close..." << endl;
                    close(fds[i].fd);
                    fds[i].fd = DFL;
                }
                // 读取成功
                else
                {   
                    read_buffer[ret] = '\0';
                    // 这里需要对读取的信息进行处理,暂时用打印替代
                    cout << "收到了: " << read_buffer;
                }
            }
        }
    }
}

int main(int argc, char *argv[])
{
    // 判断调用者是否传入了端口号
    if (argc != 2)
    {
        usage(argv[0]);
        exit(-1);
    }
    // 创建套接字
    int listen_sock = tcpSock::Socket();
    // 将用户传入的端口绑定到套接字上
    tcpSock::Bind(listen_sock, atoi(argv[1]));
    // 使监听套接字处于监听状态
    tcpSock::Listen(listen_sock);

    // 默认将listen套接字设置进fd数组
    fds[0].fd = listen_sock;
    fds[0].events |= POLLIN;
    fds[0].revents = 0;
    fds_count = 1;

    // 初始化fds数组
    for (int i = 1; i < FDS_SIZE; ++i)
    {
        fds[i].fd = DFL;
        fds[i].events = 0;
        fds[i].revents = 0;
    }

    // 设置超时时间为2秒
    int timeout = 2000;
    
    // 不断地检测事件的发生
    while (true)
    {
        // 只关心读事件
        int n = poll(fds, FDS_SIZE, timeout);
        switch (n)
        {
        case 0:
            cout << "没有事件发生,但超时了..." << endl;
            break;
        case -1:
            cerr << errno << ":" << strerror(errno) << endl;
            break;
        default:
            HandlerEvent(listen_sock);
            break;
        }
    }
    return 0;
}

对比select,poll有以下的优点

  • poll没有了监听数量的限制,可以监听任意数量的套接字
  • poll用一组结构体数组表示要监听的文件与事件,而select用位图结构表示要监听的文件描述符集合,poll可以更详细的表示要监听的事件类型
  • poll只会修改每个结构体中的revents字段,而select会修改传入的位图参数,导致select每次都要重新设置位图

但是poll还是没有解决以下问题

  • poll仍需遍历整个文件描述符数组,并检测哪些文件发生了事件
  • poll仍是轮询检测地对文件进行监听,当文件描述符的数量增多时,不仅浪费cpu资源,还不能及时的响应事件的发生
  • poll仍需将文件描述符数组从用户态拷贝到内核态,这样的开销随着文件描述符数量的增加而线性增长

epoll函数介绍

epoll有三个主要接口:epoll_creat,epoll_ctl,epoll_wait

#include <sys/epoll.h>
int epoll_create(int size);  

epoll_create用于创建一个epoll对象,返回其描述符,失败返回-1并设置errno。关于其唯一参数size:自从Linux2.6版本之后,可以忽略该参数,但是要将其设置为大于0的值

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

对epoll描述符的控制接口,可以实现对epoll控制描述符的添加,删除,修改

  • epfd:表示epoll的文件描述符,由epoll_create返回
  • op:表示要执行的操作,可以是EPOLL_CTL_ADD(添加),EPOLL_CTL_MOD(修改),EPOLL_CTL_DEL(删除
  • fd:表示要监听的文件描述符
  • event:表示一个指向struct epoll_event类型的结构体,它包含了两个字段
    • events:是epoll要监听的具体事件,如EPOLLIN(可读),EPOLLOUT(可写)等等,使用epoll_ctl注册事件时,需具体明确告知
    • data:是一个联合体,可以存储用户的数据,如指针,文件描述符,整数等,这个参数用来在epoll_wait返回事件时,传递参数或表示事件源
  • epoll_ctl成功返回0,失败返回-1并设置errno

以下是struct 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 events */
	epoll_data_t data;        /* User data variable */
};

events可以是下面几个宏的集合:

  • EPOLLIN:对应文件描述符可读
  • EPOLLOUT:对应文件描述符可写
  • EPOLLERR:对应文件描述符发生错误
  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读
  • EPOLLHUP : 表示对应的文件描述符被挂断
  • EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

收集在监控事件中发生的事件

  • epfd:由epoll_create返回的epoll文件描述符
  • events:指向struct epoll_event的指针,用来存储返回的事件
  • maxevents:表示用户期望捕获的事件个数,不能大于events数组大小
  • timeout:和poll一样,表示毫秒,具体看poll关于timeout的介绍
  • epoll_wait的返回值是待处理的事件,如果为0表示超时或者非阻塞时的返回。失败时返回-1并设置errno

epoll原理

select与poll对于事件的监听采用轮询检测的方式,而epoll不再使用这种低效且浪费资源的方式,转而使用回调函数。利用内核的事件通知机制,当监听的文件描述符发生状态变化时,通过回调函数将其加入到就绪链表中,然后用户可以通过就绪链表获取已经就绪的文件描述符

epoll在内核中主要涉及以下数据结构:

  • struct eventpoll:对应一个epoll实例(由epoll_create返回),包含了一个红黑树和一个就绪链表。红黑树用于存储所有被监听的文件描述符,就绪链表用于存储已经就绪的文件描述符
  • struct epitem:对应一个被监听的文件描述符,存储了epoll fd的相关信息。包含一个eppoll_entry结构体指针,epoll_entry结构体用于将epitem挂载到文件描述符对应的等待队列中
  • struct eppoll_entry:存储被监听文件描述符的等待队列信息,每个被监听的文件描述符都有一个等待队列,其存储了注册在该文件描述符的回调函数。当该文件描述符发生事件时,内核会调用这些回调函数。eppol_entry就是用来链接这些文件描述符和回调函数的
  • struct poll_table:表示轮询表,包含了一个函数指针_qproc和一个事件掩码_key,分别用来注册等待队列和返回可操作的事件。驱动程序不需要知道其内部细节,只需要将其作为参数传递给poll方法
  • ep_poll_callback:当某个被监听的文件描述符fd有事件发生,会触发该回调函数(也称唤醒函数),该函数会将对应的epitem加入到eventpoll对象的就绪链表中,并通过wake_up_locked唤醒正在睡眠的epoll_wait。该函数是在ep_insert函数中被注册到文件描述符的等待队列中的

epoll主要用三个结构来管理和存储fd:eventpoll,epitem,eppoll_entry。eventpoll是一个全局对象,对应一个epoll实例。其红黑树和双向链表分别用来存储所有注册到epoll的fd和所有就绪的fd。每个注册到epoll中的fd都有一个对应的epitem对象,它含有一个红黑树节点和一个双向链表节点,分别用来插入到eventpoll中的两个数据结构中,epitem包含了一个eppoll_entry的链表(其pwqlist结构就是用来链接eppoll_entry的)。eppoll_entry包含了等待队列头和等待队列节点(wait_queue_t),用来将fd挂载到设备驱动程序提供的等待队列上,并注册回调函数。

关于红黑树与就绪链表:

  • 红黑树是一颗平衡的二叉搜索树,用来存储所有被epoll监听的文件描述符和对应的事件(epitem)。每个epoll实例都有一颗红黑树,当用户调用epoll_ctl注册或删除文件描述符时,内核会对红黑树进行相关操作,以保证其平衡,从而提高查找效率
  • 就绪链表是一个双向链表,用来存储已经发生事件的文件描述符和对应的事件(epitem)。每个epoll实例都有一个就绪链表,当socket收到数据包或发生其他事件时,内核会通过回调函数将对应的epitem插入到就绪队列中,然后唤醒等待在epoll_wait的用户进程,用户进程可以通过epoll_wait获取事件

eppoll_entry主要用于ep_insert和ep_remove两个函数中,eppoll_entry在函数中的作用是:在等待队列中添加和删除文件描述符

  • ep_insert的实现流程:
    • 当用户调用epoll_ctl注册一个文件描述符时,内核会创建一个epitem对象,将其插入到eventpoll的红黑树中
    • 然后内核会调用该文件驱动的poll函数,并传入一个poll_table参数,该参数用于建立__pollwait回调函数
    • 该回调函数会调用poll_wait把当前进程注册到文件驱动的等待队列中,还会创建一个eppoll_entry结构(其中包含了epitem和ep_poll_callback的信息),将其插入到epitem->pwqlist中。
  • ep_remove的实现流程:
    • 内核会先从eventpoll的红黑树中删除对应的epitem
    • 在删除前会遍历epitem的pwqlist,对每个eppoll_entry节点,从文件的等待队列中删除它,并释放其内存
    • 接着检查被删除epitem是否在eventpoll的等待队列中,如果在,则删除
    • 最后释放epitem占用的资源,并减少文件描述符的引用计数

当设备发送IO事件时,设备驱动会遍历其等待队列头对应的链表,并调用每个节点上注册的回调函数

  • 如果注册的回调函数是__poll_wait,那么__poll_wait会检查该节点是否对应了某个epoll实例中的epitem
    • 如果不对应,那么__poll_wait什么都不会做
    • 如果对应,那么__poll_wait会在epoll实例的红黑树中找到该epitem,将其加入就绪队列
  • 如果注册的回调函数是ep_poll_callback,那么ep_poll_callback会将已产生事件与关系事件做对比,如果有交集,将对应epitem加入到就绪队列中

综上,__poll_wait和ep_poll_callback都有将epitem加入到就绪队列中的功能,不同的是:__poll_wait是在设备驱动中被调用,而ep_poll_callback是在epoll_wait中被调用。__poll_wait是在设备事件发生时被动添加epitem,而ep_poll_callback是在用户请求时主动检查并添加epitem


epoll_item vs epitem

struct epitem {
    struct rb_node rbn; /* 红黑树节点 */
    struct list_head rdllink; /* 双向链表节点 */
    struct epitem *next; /* 指向下一个epitem */
    struct epoll_filefd ffd; /* 文件描述符和文件指针 */
    struct eventpoll *ep; /* 所属的eventpoll指针 */
    struct epoll_event event; /* 事件类型和数据 */
};

struct epoll_item {
	struct rb_node rbn;
	struct list_head rdllink;
	int nwait;
	struct list_head pwqlist;
	struct epitem *epi;
};

epitem和epoll_item是两个不同的结构

  • epitem表示一个被监听的文件描述符的结构体,它包含了文件描述符,事件类型,回调函数等信息
  • epoll_item表示一个epoll实例中,所有文件描述符的集合的结构体。包含了一个红黑树和一个链表,用来遍历所有的epitem
  • 所以说,epitem是epoll_item的元素,epoll_item是epitem的容器
  • 它两都是内核数据结构。用户调用epoll_ctl()传递一个epoll_event给内核空间,内核空间根据这个结构体创建或更新一个对应的epitem,并将其添加到epoll_item中。用户根据epoll_wait获取epoll_event信息,这个信息是从epitem中复制出来的
  • 或者说:struct epoll_item和eventpoll一样,都表示(关注)一个epoll的实例

epoll相对于select和poll的优势:

  • epoll使用内核结构存储和管理被监听的文件描述符信息,避免了重复的拷贝文件描述符集合
  • epoll使用红黑树存储注册的文件描述符,提高了查找和插入的效率
  • epoll采用回调函数(事件驱动)唤醒进程,避免了不必要的轮询检测,节省了cpu资源
  • epoll支持边沿触发与水平触发两种模式,方便用户根据需求具体的定制epoll

ET && LT

ET和LT是epoll的两种工作模式,默认选择LT,而select和poll只有LT模式。

  • ET模式只有在状态变化时会触发事件,LT模式只要有事件就会一直触发
  • ET可以做到更简洁的编程,因为它不需要对每个事件进行多余的判断和处理
  • ET可以避免开关EPOLLOUT事件的开销,因为它只在tcp窗口从不饱和变为饱和与再一次变为不饱和时才会触发写事件。而LT需要在每次写数据后检测是否需要关闭或开启EPOLLOUT事件

关于最后一点:EPOLLOUT表示内核的发送缓冲区有数据可写。LT模式下,如果发送缓冲区不满,就会一直触发写事件。所以每次向内核的发送缓冲区写完数据后,需要检查是否要关闭或者开启EPOLLOUT事件(事件的开关根据用户需要发送的数据是否发送完来判断),以免浪费cpu资源。但是在ET模式下,只有发送缓冲区从满到不满才会触发写事件,表示可以进行数据的写入。所以ET不用频繁的进行EPOLLOUT的开关。但是在ET模式下,如果需要下一次的写事件触发来驱动任务,就需要重新注册EPOLLOUT。这是因为重新注册后EPOLLOUT一定会触发一次(相当于手动触发),ET模式下的发送一般都是直接发送,如果数据量太大,没有发送完,那么这时再设置EPOLLOUT,使写事件触发,再次发送数据

事件的触发体现在epoll_wait是否返回该事件上,LT模式下,如果(内核)接收缓冲区的数据没有及时处理完,epoll_wait依旧会返回该事件,以表示读事件的就绪。但在ET模式下,数据没有及时处理完,epoll_wait不会返回该事件,无论是否有新的数据到来。LT模式下,只要发送缓冲区不为满(可写),就会一直触发写事件。ET模式下,只有发送缓冲区从满到不满时,才会触发写事件

总结和补充:

  • ET模式下
    • 只要接收缓冲区的数据从无到有,就会触发读事件
    • 发送缓冲区从满到不满,重新注册EPOLLOUT | EPOLLET,第一次进行tcp连接时,会触发写事件
  • LT模式下
    • 只要接收缓冲区有数据就会触发读事件
    • 只要发送缓冲区不为满,就会触发写事件

不过选择了ET模式,就要设置fd为非阻塞。因为ET模式需要不断的读取或者发送数据,如果缓冲区满或空了,不能使程序进入阻塞,所以要设置非阻塞

void Util::set_nonblock(int sockfd)
{
    int fd_flag = fcntl(sockfd, F_GETFL);
    if (fd_flag < 0)
    {
        cerr << errno << ": " << strerror(errno) << endl;
        exit(-FTL_FAIL);
    }
    int ret = fcntl(sockfd, F_SETFL,fd_flag | O_NONBLOCK);
    if (ret < 0)
    {
        cerr << errno << ": " << strerror(errno) << endl;
        exit(-FTL_FAIL);
    }
}

tcp多路转接代码实现

#include "Socket.hpp"
#include <sys/epoll.h>

#define MAXEVENTS 1024
#define BUF_SIZE 1024

#define CRT_FAL -1
#define CTL_FAL -2
#define WAIT_FAL -3
#define ACP_FAL -4

class epoll_server
{
public:
    epoll_server(uint16_t port, int listen_sockfd = -1, int epoll_fd = -1)
        : _listen_sockfd(listen_sockfd), _epoll_fd(epoll_fd), _port(port)
    {
    }
    ~epoll_server()
    {
        if (-1 != _listen_sockfd)
            close(_listen_sockfd);
        if (-1 != _epoll_fd)
            close(_epoll_fd);
    }

    // 监听套接字的初始化
    void init_server();
    // 使用epoll进行IO
    void run_server();

private:
    // IO事件的处理
    void handler_event(struct epoll_event* revs, int n);

private:
    // 监听套接字fd与epoll实例fd
    int _listen_sockfd;
    int _epoll_fd;
    // epoll_server绑定的端口号
    uint16_t _port;
};

void epoll_server::init_server()
{
    // 创建sock,绑定端口并使之处于监听状态
    _listen_sockfd = tcpSock::Socket();
    tcpSock::Bind(_listen_sockfd, _port);
    tcpSock::Listen(_listen_sockfd);

    cout << "init_server done" << endl;
}

void epoll_server::run_server()
{
    _epoll_fd = epoll_create(128);
    if (-1 == _epoll_fd)
    {
        cerr << errno << ": " << strerror(errno) << endl;
        exit(CRT_FAL);
    }
    struct epoll_event ev = {0};
    ev.events = EPOLLIN;
    ev.data.fd = _listen_sockfd;
    // 注册文件到epoll实例
    int ret = epoll_ctl(_epoll_fd, EPOLL_CTL_ADD, _listen_sockfd, &ev);
    if (-1 == ret)
    {
        cerr << errno << ": " << strerror(errno) << endl;
        exit(CTL_FAL);
    }

    struct epoll_event revs[MAXEVENTS] = {0};
    int timeout = 2000; // 设置超时时间2秒
    while (true)
    {
        int n = epoll_wait(_epoll_fd, revs, MAXEVENTS, timeout);
        switch (n)
        {
        case -1:
            cerr << errno << ": " << strerror(errno) << endl;
            exit(WAIT_FAL);
            break;
        case 0:
            cout << "超时事件内没有事件发生..." << endl;
            break;
        default:
            handler_event(revs, n);
            break;
        }
    }
}

void epoll_server::handler_event(struct epoll_event* revs, int n)
{
    for (int i = 0; i < n; ++i)
    {
        // 发生了读事件
        if (revs[i].events & EPOLLIN)
        {
            // 监听到一个新连接
            if (revs[i].data.fd == _listen_sockfd)
            {
                string peer_ip;
                uint16_t peer_port;
                int server_sock = tcpSock::Accept(_listen_sockfd, &peer_ip, &peer_port);
                if (-1 == server_sock)
                {
                    cerr << errno << ": " << strerror(errno) << endl;
                    exit(ACP_FAL);
                }
                // 向epoll实例中注册这个服务套接字
                struct epoll_event ev= {0};
                ev.events = EPOLLIN;
                ev.data.fd = server_sock;
                int ret = epoll_ctl(_epoll_fd, EPOLL_CTL_ADD, server_sock, &ev);
                if (-1 == ret)
                {
                    cerr << errno << ": " << strerror(errno) << endl;
                    exit(CTL_FAL);
                }

                cout << "与客户端[" << peer_ip << "]:" << peer_port << "连接成功" << endl;
            }
            // 监听到普通IO事件
            else
            {
                char read_buff[BUF_SIZE] = {0};
                int ret = recv(revs[i].data.fd, read_buff, BUF_SIZE, 0);
                if (ret < 0)
                {
                    cerr << "recv fali" << endl;
                    close(revs[i].data.fd);
                }
                else if (ret == 0)
                {
                    cout << "peer close..." << endl;
                    close(revs[i].data.fd);
                }
                else
                {
                    cout << "普通IO:" << read_buff;
                }
            }
        }
        // 发生了写事件,暂时不处理
        else{}
    }
}
// Socket.hpp
#pragma once

#include <cstdio>
#include <cstring>
#include <stdlib.h>
#include <iostream>
#include <cerrno>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>

using namespace std;

class tcpSock
{
    static const int _backlog = 20;
    public:
    // 创建套接字文件,并设置套接字选项,使服务器可以立即重启
    // 最后返回套接字fd
    static int Socket()
    {
        int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
        if (listen_sock < 0)
        {
            cerr << errno << ": " << strerror(errno) << endl;
            exit(-1);
        }

        int opt = 1;
        int ret = setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
        if (ret < 0)
        {
            cerr << errno << ": " << strerror(errno) << endl;
            exit(-1);
        }

        return listen_sock;
    }

    // 填充服务器IP与端口信息,将其绑定到listen套接字上
    static void Bind(int listen_sock, u_int16_t local_port)
    {
        // 服务器信息的填充
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = INADDR_ANY; // 只要绑定本机IP就行
        local.sin_port = htons(local_port);

        int ret = bind(listen_sock, (struct sockaddr*)&local, sizeof(local));
        if (ret < 0)
        {
            cerr << errno << ": " << strerror(errno) << endl;
            exit(-1);
        }
    }

    // 使指定套接字处于监听状态
    static void Listen(int listen_sock)
    {
        int ret = listen(listen_sock, _backlog);
        if (ret < 0)
        {
            cerr << errno << ": " << strerror(errno) << endl;
            exit(-1);
        }
    }

    static int Accept(int listen_sock, string* peer_ip, uint16_t* peer_port)
    {
        struct sockaddr_in peer;
        socklen_t peer_len = sizeof(peer);
        int server_sock = accept(listen_sock, (struct sockaddr*)&peer, &peer_len);
        if (server_sock < 0)
        {
            cerr << errno << ": " << strerror(errno) << endl;
            exit(-1);
        }

        *peer_ip = inet_ntoa(peer.sin_addr);
        *peer_port = ntohs(peer.sin_port);

        return server_sock;
    }
};
// main.cc
#include "epoll_server.hpp"

void usage(char *process_name)
{
    cout << "usage: " << process_name << " port"
         << endl;
}


int main(int argc, char* argv[])
{
     // 判断调用者是否传入了端口号
    if (argc != 2)
    {
        usage(argv[0]);
        exit(-1);
    }

    epoll_server eserver(atoi(argv[1]));
    eserver.init_server();
    eserver.run_server();
    return 0;
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值