多路转接——select、poll、epoll

目录

select

select 函数

select 服务器

select 编写规则

select 的优点

select 的缺点

poll

poll 函数

poll 服务器

poll 的优点

poll 的缺点

epoll

epoll 的系统调用

epoll 的工作原理

epoll 服务器

epoll 的优点

epoll 的工作方式


select

        select 是 Linux 为我们提供的多路转接方案的一种。

        上一篇中我们说过,IO = 等待 + 数据拷贝,select 做的工作就是一次性帮我们等待多个文件描述符,数据就绪后再调用recv/recvfrom/read等方法进行读取。

select 函数

#include <sys/select.h>
int select(int nfds,
           fd_set* readfds,
           fd_set* writefds,
           fd_set* exceptfds,
           struct timeval* timeout);

参数:(除了第一个,后面的都是输入输出型参数)

  • nfds:需要监视的文件描述符的最大值+1。
  • readfds:调用时让内核监视哪些文件描述符的读事件就绪,返回时内核告知哪些文件描述符的读事件已经就绪。
  • writefds:调用时让内核监视哪些文件描述符的写事件就绪,返回时内核告知哪些文件描述符的写事件已经就绪。
  • exceptfds:调用时让内核监视哪些文件描述符的异常事件就绪,返回时内核告知哪些文件描述符的异常事件已经就绪。
  • timeout:调用时设置select的等待时间,返回时表示timeout的剩余时间。

返回值:

  • 函数调用成功,返回就绪的文件描述符个数。
  • timeout时间耗尽,返回0,也就没有文件描述符就绪。
  • 调用失败返回-1,错误码被设置。

        我们先来看一看最后一个参数,这是一个时间戳。和我们使用的time函数是类似的,但是它多了一个微秒参数,使用gettimeofday就可以获取时间戳,参数一个是timeval,另个是timezone,就是时区,设置成nullptr就是东八区。

int main()
{
    while (1)
    {
        std::cout << "time: " << (unsigned long)time(nullptr) << std::endl;
        struct timeval currtime = {0, 0};
        int n = gettimeofday(&currtime, nullptr);
        assert(n == 0);
        (void)n;

        std::cout << "gettimeofday: " << currtime.tv_sec << "." << currtime.tv_usec << std::endl;
        sleep(1);
    }
}

        select等待多个fd也是可以选择策略的:

  • 如果timeval为nullptr,那就是阻塞式的等。
  • 如果timeval为{0, 0},那就是非阻塞式的。
  • 如果timeval为{n, 0},那就是在n秒内阻塞,时间到,立马返回。

        再来看中间三个参数,select可以将传入的文件描述符的作用分成这三类,想让操作系统帮我关心哪种事件,就传入哪种文件描述符,传入的是fd_set这个类型,这是一个文件描述符集,文件描述符本质是0、1、2这种下标,所以它也是位图结构,就像信号集一样,但是这个位图结构不能使用按位操作的方式添加文件描述符,必须使用操作系统提供的内置方法。

void FD_CLR(int fd, fd_set *set);   // 在位图结构中清除某个文件描述符
int  FD_ISSET(int fd, fd_set *set); // 判断一个文件描述符是否在位图中
void FD_SET(int fd, fd_set *set);   // 将一个文件描述符设置进位图结构
void FD_ZERO(fd_set *set);          // 将整个位图结构清空

        我们挑一个readfds来说明:

  • 在输入时,用户要告知内核,我传入的位图结构中的比特位的位置表示文件描述符的值,每个位置的值就表示是否关心,1为关心,0为不关心。
  • 在输出时,内核要告知用户,我输出的位图结构中的比特位的位置表示文件描述符的值,每个位置的值就表示是否就绪,1为就绪,0为未就绪。
  • 所以后续可以直接读取就绪的文件描述符,而不会被阻塞。
  • 而且用户和内核后悔修改同一个位图结构,这个参数用了一次之后,一定要重新设定。
  • 所以 writefds 和 exceptfds 的意思也是差不多的。

        参数我们看完了,那我们再来看看这个fd_set结构是什么。

select 服务器

        有了上面的知识,我们已经可以调用select了,那我们就可以把原来写过的服务器改为select版本。原来我们写过的套接字代码就不再重新写了,我们只写服务器的代码,这里的区别就是把Sock类中的方法变成了静态方法,就不用在服务器类中添加Sock对象了。

#ifndef _SELECT_SVR_H_
#define _SELECT_SVR_H_

#include <iostream>
#include <sys/select.h>
#include "Sock.hpp"

using namespace std;

class SelectServer
{
public:
    SelectServer(const uint16_t& port = 8080)
        : _port(port)
    {
        _listensock = Sock::Socket();
        Sock::Bind(_listensock, _port);
        Sock::Listen(_listensock);
        logMessage(DEBUG, "create base socket success");
    }
    void Start()
    {
        while (true)
        {
            // 
        }
    }
    ~SelectServer()
    {
        if (_listensock >= 0) close(_listensock);
    }
private:
    uint16_t _port;
    int _listensock;
};

#endif 

        到这里我们完成了创建套接字,绑定和监听,下面就是Accept了,那我们就要重新理解一下listensock,除了默认打开的文件描述符,还有一个就是listensock,它用来帮我们获取新连接,这个操作也可以是一次IO,类似与listensock的读事件,但是如果没有连接到来,accept就会阻塞,那和之前的写法就没区别了。

        所以我们不能直接调用accept,先把listensock添加到select中。

class SelectServer
{
    // ...
    void Start()
    {
        fd_set rfds; // 读文件描述符集
        FD_ZERO(&rfds);
        while (true)
        {
            FD_SET(listensock, &rfds); // 将listensock添加到文件描述符集
            int n = select(_listensock + 1, &rfds, nullptr, nullptr, nullptr);
            switch(n)
            {
            case 0:
                logMessage(DEBUG, "time out...");
                break;
            case -1:
                logMessage(WARNING, "select error: %d : %s", errno, strerror(errno));
                break;
            default:
                // 成功,下面就是调用处理函数
                break;
            }
        }
    }
    // ...
};

        这样就是阻塞的方式等待,当然也可以给select设置timeout时间,比如让他每五秒返回一次,如果有就处理,如过没有就继续select,就在这5s中就可以让服务器干一些其他事。

class SelectServer
{
public:
    void Start()
    {
        fd_set rfds; // 读文件描述符集
        FD_ZERO(&rfds);
        while (true)
        {
            // ...
            switch(n)
            {
            // ...
            default:
                // 成功,下面就是调用处理函数
                HandlerEvent(rfds);
                break;
            }
        }
    }

private:
    void HandlerEvent(const fd_set& rfds)
    {
        string clientip;
        uint16_t clientport = 0;
        if (FD_ISSET(_listensock, &rfds))
        {
            // _listensock获取了新的连接
            int sock = Sock::Accept(_listensock, &clientip, &clientport);
            if (sock < 0)
            {
                logMessage(WARNING, "accept error");
                return;
            }
            logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock);
        }
    }
}

        现在我们就可以获取新连接了,之后就是我们可以读数据吗,答案是不可以,因为现在只是获取了连接,但是不清楚sock上的数据什么时候到,正所谓IO=等待+拷贝,所以还要select帮我们等待sock上的数据是否就绪。

        现在又有一个问题了,以后从listensock获取的sock会越来越多,添加到select中的也会增加,fds一定每次都要变化,因为他是输入输出型参数,如果有timeout也要改变。

        所以有了这些原因,我们需要将文件描述符单独保存起来,用来更新最大的fd,还要更新文图结构,这就需要说明一下select的讲解规则。

select 编写规则

        下面就是select的编写规则:

  • 先初始化服务器,创建套接字、绑定和监听。
  • 需要有一个第三方数组,用来保存所有的合法的文件描述符。
  • 将listensock添加到数组中,之后有新连接到来就添加到数组。
#define BITS 8
#define NUM sizeof(fd_set)*BITS
#define FD_NONE -1

class SelectServer
{
public:
    SelectServer(const uint16_t& port = 8080)
        : _port(port)
    {
        // 初始化fd数组,将每个值都设置为无效
        for (int i = 0; i < NUM; i++) _fd_array[i] = FD_NONE;
        // 并且规定数组0号下标就是listensock
        _fd_array[0] = _listensock;
    }
    void Start()
    {
        fd_set rfds; // 读文件描述符集
        while (true)
        {
            DebugPrint(); // 打印有效的文件描述符
            FD_ZERO(&rfds);
            int maxfd = 0;
            for (int i = 0; i < NUM; i++)
            {
                if (_fd_array[i] == FD_NONE) continue;
                FD_SET(_fd_array[i], &rfds);
                if (maxfd < _fd_array[i]) maxfd = _fd_array[i];
            }

            int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
            switch(n)
            {
            case 0:
                logMessage(DEBUG, "time out...");
                break;
            case -1:
                logMessage(WARNING, "select error: %d : %s", errno, strerror(errno));
                break;
            default:
                // 成功,下面就是调用处理函数
                HandlerEvent(rfds);
                break;
            }
        }
    }

private:
    void HandlerEvent(const fd_set& rfds)
    {
        string clientip;
        uint16_t clientport = 0;
        if (FD_ISSET(_listensock, &rfds))
        {
            // _listensock获取了新的连接
            int sock = Sock::Accept(_listensock, &clientip, &clientport);
            if (sock < 0)
            {
                logMessage(WARNING, "accept error");
                return;
            }
            logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock);

            int pos = 0; // 找到可以插入的位置
            while (_fd_array[pos] != FD_NONE) pos++;
            if (pos == NUM)
            {
                // 说明数组中fd已经满了
                logMessage(WARNING, "%s:%d", "select server already full, close: %d", sock);
                close(sock);
            }
            else
            {
                _fd_array[pos] = sock;
            }
        }
    }
    void DebugPrint()
    {
        cout << "fd_array[]: ";
        for (int i = 0; i < NUM; i++)
        {
            if (_fd_array[i] == FD_NONE) continue;
            cout << _fd_array[i] << " ";
        }
        cout << endl;
    }
private:
    int _fd_array[NUM];
};

        现在我们就可以保存所有的有效的文件描述符,但是还有一个问题,获取的sock有两类:

  • 一类是listensock,用来获取连接。
  • 一类是普通的sock,用来接收和读取客户端信息的。

        所以在HandlerEvent中就要区分一下。

void HandlerEvent(const fd_set& rfds)
{
    for (int i = 0; i < NUM; i++)
    {
        if (_fd_array[i] == FD_NONE) continue; // 去掉不合法的fd
        if (FD_ISSET(_fd_array[i], &rfds)) // 判断文件描述符是否就绪
        {
            if (_fd_array[i] == _listensock)
            {
                // listensock就绪,连接事件到来
                Accepter();
            }
            else
            {
                // 其他sock就绪,读事件到来,本次fd上的数据就绪,不会被阻塞
                Recver(i);
            }
        }
    }
}

void Accepter()
{
    string clientip;
    uint16_t clientport = 0;

    // _listensock获取了新的连接
    int sock = Sock::Accept(_listensock, &clientip, &clientport);
    if (sock < 0)
    {
        logMessage(WARNING, "accept error");
        return;
    }
    logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock);
    int pos = 0;
    while (_fd_array[pos] != FD_NONE) pos++;
    if (pos == NUM)
    {
        // 说明数组中fd已经满了
        logMessage(WARNING, "%s:%d", "select server already full, close: %d", sock);
        close(sock);
    }
    else
    {
        _fd_array[pos] = sock;
    }
}

void Recver(int pos)
{
    char buffer[1024];
    int n = recv(_fd_array[pos], buffer, sizeof(buffer) - 1, 0);
    if (n > 0)
    {
        buffer[n] = 0;
        logMessage(DEBUG, "client[%d]# %s", _fd_array[pos], buffer);
    }
    else if (n == 0)
    {
        logMessage(DEBUG, "client[%d]: quit, me too...", _fd_array[pos]);
        // 对端已经关闭,关闭对应文件描述符,把该文件描述符从数组中去掉
        close(_fd_array[pos]);
        _fd_array[pos] = FD_NONE;
    }
    else
    {
        logMessage(WARNING, "%d sock recv error, %d : %s", _fd_array[pos], errno, strerror(errno));
        // 读取错误,关闭对应文件描述符,把该文件描述符从数组中去掉
        close(_fd_array[pos]);
        _fd_array[pos] = FD_NONE;
    }
}

  • 如果有连接就获取连接。
  • 如果有其他sock就绪就调用recv等接口。
  • 如果对端关闭或读取错误就要先关闭文件描述符,再把该文件描述符从数组中去掉。

        所以这个代码没有使用多线程和多进程,依然可以实现并发访问,但是还有问题要注意的就是:

  • 这个服务器使用的是TCP协议,TCP是面向字节流的,所以就会出现丢包、粘包等问题,在应用层协议的篇章已经详细讲解了如何处理这种问题,如何序列化和反序列化,如何检验是一个完整的报文,所有在读取数据时,要先检查一下,这里我们就不处理,等到后面会解决这个问题的。
  • 服务器可以读取数据了,但是还要解决给客户端发的问题,不可以直接使用writer这样的接口,如果对方没有把数据读上去,就会阻塞住,所以需要再定义一个输入数组和输入文件描述符集。

select 的优点

        它的优点是与我们之前写过的代码相比的,也是任何一个多路转接方案的优点。

  • 它可以同时等待多个文件描述符,而读取或写入的操作都是系统调用接口,这些接口并不会被阻塞,将等的时间重叠,提高了IO的效率。
  • 在单执行流中,如果有大量的连接,其中只有少量活跃的,那么它的效率是很高的;如果使用多执行流,每一个文件描述符就是一个执行流,少量的活跃执行流,那么其他资源就会被浪费,所以它还可以省资源。

select 的缺点

  • 为了维护文件描述符数组,select 会有大量的遍历操作,所以如果有大量的连接,而且很活跃,这时 select 的效率还会变低。
  • 每一次都要重新设定 select 的参数。
  • 使用的fd_set是一个类型,只要是类型就会有上限,能接收的文件描述符就有限。
  • 它的参数几乎都是输入输出型参数,那就一定会频繁的进行用户和内核之间的拷贝。
  • 基于以上几个缺点,导致了它的编码比较复杂。

        有了这些缺点,才会有其他的方案,下面要介绍的方案就是poll。


poll

        poll也是一种多路转接的方式,和 select 一样,它只负责等待,函数调用的时候,用户告诉内核要等待哪些文件描述符的哪些事件,返回的时候,内核告诉用户哪些fd的哪些事件已经就绪。

poll 函数

#include <poll.h>

int poll(struct pollfd *fds,
         nfds_t nfds,
         int timeout);

参数:

  • fds:poll函数监视的结构体数组,fds为首元素的地址,每一个元素包含三部分内容:文件描述符、监视的事件集合、就绪的事件集合。
  • nfds:fds数组的长度。
  • timeout:表示poll的超时时间,单位为毫秒。传入n就等待n毫秒返回,传入0就代表非阻塞式,传入-1就代表阻塞式。

返回值:

  • 大于0,就代表就绪几个文件描述符。
  • 等于0,就代表timeout超时了。
  • 小于0,就代表poll失败了。

        我们再来看一看struct pollfd结构体中有什么参数。

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};
  • fd:就是特定的文件描述符。
  • events:需要监视该文件描述符上的哪些事件。
  • revents:poll返回时告知用户该fd上的哪些事件已经就绪了。

        这是个结构体,不像fd_set是一个类型,它里面有不同成员要解决不同的问题。不管是传参还是返回时,都不会去修改fd,因为下面两个参数都是关于这个fd的,而events是让内核看的也不会修改这个变量,而内核等待这个fd的事件就绪后,写到revents中,所以解决了 select 每次都要重新设置参数的缺点。

        而fd_set是一个类型,最大传入的值就是1024个fd,但是如果用户想,也可以给nfds设置更大的数值,因为它没有文件描述符上限。

        其他的都说完了,我再看看events怎么设置。

事件描述可否作为输入可否作为输出
POLLIN数据(包括普通数据和优先数据)可读
POLLRDNORM普通数据可读
POLLRDBAND优先级带数据可读(Linux不支持)
POLLPRI高优先级数据可读,比如TCP带外数据,urg标志位
POLLOUT数据(包括普通数据和优先数据)可写
POLLWRNORM普通数据可写
POLLWRBAND优先级带数据可写
POLLRDHUPTCP连接被对方关闭,或者对方关闭了写操作,它由GNU引入
POLLERR错误
POLLHUP挂起。比如管道的写端被关闭后,读端描述符上将收到POLLHUP事件
POLLNVAL文件描述符没有打开

        常用的还是加黑的这几个,而且这些events都是大写,也是位图结构,传参的时候使用按位或传参。

poll 服务器

        现在我们只需要修改一下 select服务器 就可以让它变成 poll服务器。

#include <poll.h>

#define FD_NONE -1

class PollServer
{
public:
    static const int nfds = 100;
public:
    PollServer(const uint16_t& port = 8080)
        : _port(port)
        , _nfds(nfds)
    {
        _listensock = Sock::Socket();
        Sock::Bind(_listensock, _port);
        Sock::Listen(_listensock);
        logMessage(DEBUG, "create base socket success");

        // 申请_nfds个struct pollfd结构体
        _fds = new struct pollfd[_nfds];
        // 初始化_fds
        for (int i = 0; i < _nfds; i++)
        {
            _fds[i].fd = FD_NONE;
            _fds[i].events = _fds[i].revents = 0;
        }
        // 将_listensock设置到_fds,并设置events为POLLIN,关心读事件
        _fds[0].fd = _listensock;
        _fds[0].events = POLLIN;

        _timeout = 1000;
    }

    ~PollServer()
    {
        if (_listensock >= 0) close(_listensock);
        if (_fds) delete[] _fds;
    }

private:
    uint16_t _port;
    int _listensock;
    struct pollfd* _fds;
    int _nfds;
    int _timeout;
};

        接下来就是Start函数和HandlerEvent函数。

void Start()
{
    while (true)
    {
        int n = poll(_fds, _nfds, _timeout);
        switch(n)
        {
        case 0:
            logMessage(DEBUG, "time out...");
            break;
        case -1:
            logMessage(WARNING, "poll error: %d : %s", errno, strerror(errno));
            break;
        default:
            // 成功,下面就是调用处理函数
            HandlerEvent();
            break;
        }
    }
}

void HandlerEvent()
{
    for (int i = 0; i < _nfds; i++)
    {
        if (_fds[i].fd == FD_NONE) continue; // 去掉不合法的fd
        if (_fds[i].revents & POLLIN) // 判断文件描述符是否就绪
        {
            if (_fds[i].fd == _listensock)
            {
                // listensock就绪,连接事件到来
                Accepter();
            }
            else
            {
                // 其他sock就绪,读事件到来,本次fd上的数据就绪,不会被阻塞
                Recver(i);
            }
        }
    }

        因为和select一样,只处理的读事件,所以再来写一下 Accepter 和 Recver 这两个函数。

void Accepter()
{
    string clientip;
    uint16_t clientport = 0;

    // _listensock获取了新的连接
    int sock = Sock::Accept(_listensock, &clientip, &clientport);
    if (sock < 0)
    {
        logMessage(WARNING, "accept error");
        return;
    }
    logMessage(DEBUG, "get a new link success: [%s:%d]: %d", clientip.c_str(), clientport, sock);
    int pos = 1;
    while (_fds[pos].fd != FD_NONE) pos++;
    if (pos == _nfds)
    {
        // 说明数组中fd已经满了,这里也可以设置成动态的,可以扩容
        logMessage(WARNING, "%s:%d", "poll server already full, close: %d", sock);
        close(sock);
    }
    else
    {
        _fds[pos].fd = sock;
        _fds[pos].events = POLLIN;
    }
}

void Recver(int pos)
{
    char buffer[1024];
    int n = recv(_fds[pos].fd, buffer, sizeof(buffer) - 1, 0);
    if (n > 0)
    {
        buffer[n] = 0;
        logMessage(DEBUG, "client[%d]# %s", _fds[pos].fd, buffer);
    }
    else if (n == 0)
    {
        logMessage(DEBUG, "client[%d]: quit, me too...", _fds[pos].fd);
        // 对端已经关闭,关闭对应文件描述符,把该文件描述符从数组中去掉
        close(_fds[pos].fd);
        _fds[pos].fd = FD_NONE;
        _fds[pos].events = _fds[pos].revents = 0;
    }
    else
    {
        logMessage(WARNING, "%d sock recv error, %d : %s", _fds[pos].fd, errno, strerror(errno));
        // 读取错误,关闭对应文件描述符,把该文件描述符从数组中去掉
        close(_fds[pos].fd);
        _fds[pos].fd = FD_NONE;
        _fds[pos].events = _fds[pos].revents = 0;
    }
}

poll 的优点

  • 效率高也是多路转接方案共同的优点。
  • 适应的场景就是有大量的连接,只有少量是活跃的,节省了资源。
  • 输入输出参数分离,不需要重新设置。
  • poll的参数可以设置大量的struct pollfd,没有上限。

poll 的缺点

  • poll 依旧需要多次遍历,在用户层检测时间就绪,在内核检测fd就绪,在连接多的时候效率也会降低。
  • poll 也需要用户到内核之间的拷贝,这个操作也是少不了的。
  • poll 的代码还是有些复杂。

epoll

        epoll 是一个多路转接接口,与其他多路转接方案一样,它也可以同时等待多个文件描述符,e 可以理解为extend扩展的意思,相比于poll扩展了一些功能。

epoll 的系统调用

        第一个系统调用就是epoll_create,用于创建一个epoll模型。

#include <sys/epoll.h>

int epoll_create(int size);

参数:如今的服务器,size参数是被忽略的,设置成256等即可。

返回值:epoll 模型创建成功返回对应的文件描述符,失败返回-1,错误码被设置。

【注意】:不再使用时,必须调用close关闭epoll模型对应的文件描述符。

        第二个系统调用就是epoll_ctl,作用就是对epoll模型操作。

#include <sys/epoll.h>

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

参数:

  • epfd:对指定的epoll模型进行修改。
  • op:表示具体的动作。
  • fd:需要添加到epoll模型中的文件描述符。
  • event:需要监视该文件描述符上的哪些事件。

参数op的选项:

  • EPOLL_CTL_ADD:注册新的文件描述符到指定的epoll模型中。
  • EPOLL_CTL_MOD:修改已经注册的文件描述符的监听事件。
  • EPOLL_CTL_DEL:从epoll模型中删除指定的文件描述符。

返回值:

  • 函数成功返回0,失败返回-1,错误码被设置。

event参数:

        先看一下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表示对应的文件描述符可以写。
EPOLLPRI表示对应的文件描述符有紧急的数据可读。(TCP中URG标志位)
EPOLLERR表示对应的文件描述符发送错误。
EPOLLHUP表示对应的文件描述符被挂断,即对端将文件描述符关闭了。
EPOLLET将epoll的工作方式设置为边缘触发(Edge Triggered)模式。
EPOLLONESHOT只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中。

        第三个系统调用就是epoll_wait,用于收集监视的事件中已经就绪的事件。

#include <sys/epoll.h>

int epoll_wait(int epfd,
               struct epoll_event *events,
               int maxevents,
               int timeout);

参数:

  • epfd:指定的epoll模型。
  • events:输出型参数,内核将已经就绪的事件拷贝到events数组中,events数组一定是一个已经分配内存和初始化的一块空间。
  • maxevents:events数组中的元素个数,该值不能大于创建epoll模型时传入的size值。
  • timeout:epoll_wait的超时时间,单位是毫秒,且参数意义与poll一样。

返回值:

  • 函数调用成功返回有事件就绪的文件描述符个数。
  • timeout时间耗尽返回0。
  • 函数调用失败返回-1,错误码被设置。
  • 错误码可被设置为:
    • EBADF:传入的epoll模型对应的文件描述符无效。
    • EFAULT:events指向的数组空间无法通过写入权限。
    • EINTR:此调用被信号中断。
    • EINVAL:epfd不是epoll模型对应的文件描述符,或者maxevents<0

epoll 的工作原理

        我们知道了网络的知识,网路中的数据是从网卡中来的,网卡是个硬件,向上是网卡驱动,但是OS是怎么知道网卡中有数据到来呢,OS如何知道底层硬件或者外设有数据到来呢?

        实际上OS采用的是硬件中断的方式。

        当我们创建一个epoll模型时,OS会帮我们创建一棵红黑树,如何操作OS会帮我们,数中的每个结点都是结构体struct rb_node。

        这棵树中的每个结点就相当于select和poll我们自己维护的数组,除了将结点插入红黑树中,OS还要向网卡驱动注册一个回调方法。

        select和poll都要遍历数组中所有的结点,等待某个文件描述符,本质上是进程或线程在等待,等待时OS就会把执行流的PCB链入文件描述符匹配的struct file中,一旦数据就绪,OS就会唤醒对应的执行流,就这样依次遍历数组。

        回调函数就不一样,当底层数据因中断等情况到达时,会自动调用回到函数。

        下面就是就绪队列。

        这个队列中都是已经就绪的结构体,所以可以以O(1)的时间复杂度获取就绪结点,不需要查找红黑树。

        所以epoll的工作原理:

  • 调用epoll_create,创建epoll模型,之后就是创建红黑树,建立底层回调机制,构建就绪队列。
  • 调用epoll_ctl,对epoll模型中的红黑树结点关心的事件进行增删改的操作。
  • 调用epoll_wait,从底层的就绪队列拿到数据。

        所以一个进程创建epoll的时候也要分配一个文件描述符,这个文件描述符对应的struct file中就有epoll模型对应的指针,该指针中有不同的参数指向不同的结构,不同的系统调用就会修改对应的结构。

        在调用epoll_ctl的时候,需要对epoll模型中的红黑树做修改,向红黑树中插入的时候也是有key值的,要不然如何确定节点的顺序,所以fd就可以作为key值。

        用户在使用epoll的时候只需要使用对应的系统调用。而不需要关心fd和events了。

        底层只要有fd就绪,OS会帮我们构建结点,链入就绪队列,我们只需要从就绪队列中拿数据即可。所以底层帮我们向就绪队列中放数据,我们拿数据,这不就是一个生产者消费者模型吗,而且epoll也保证了线程安全。

epoll 服务器

        首先就要有一个EpollServer服务器。

static const uint16_t default_port = 8080;

class EpollServer
{
public:
    EpollServer(const uint16_t& port = default_port)
        : _port(port)
    {
        // 创建socket
        _listensock = Sock::Socket();
        Sock::Bind(_listensock, _port);
        Sock::Listen(_listensock);

        // 创建epoll模型
        _epfd = Epoll::CreateEpoll();
        logMessage(DEBUG, "init success, listensock: %d, epfd: %d", _listensock, _epfd);
    
        // 将listensock设置进epoll用来获取已建立好连接的sock
        if (!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, _listensock, EPOLLIN)) exit(6);

        logMessage(DEBUG, "add listensock to epoll success");
    }
    ~EpollServer()
    {
        if (_listensock > 0) close(_listensock);
        if (_epfd > 0) close(_epfd);
    }
private:
    int _listensock;
    uint16_t _port;
    int _epfd;
};

在这之中,我把epoll的接口也封装了一下。

class Epoll
{
public:
    static const int gsize = 256;
public:
    static int CreateEpoll()
    {
        int epfd = epoll_create(gsize);
        if (epfd > 0) return epfd;
        exit(5);
    }

    static bool CtlEpoll(int epfd, int op, int fd, uint32_t events)
    {
        struct epoll_event ev;
        ev.events = events;
        ev.data.fd = fd;
        int n = epoll_ctl(epfd, op, fd, &ev);
        return n == 0;
    }
};

        服务器的初始化已经做完了,接下来就是等待listensock有连接到来,使用的系统调用是epoll_wait,需要注意的就是它的参数,需要有一块空间保存从就绪队列中拿到的数据。

static const int gnum = 64;

class EpollServer
{
public:
    EpollServer(const uint16_t& port = default_port)
        : _port(port)
        , _revs_num(gnum)
    {
        // 申请struct epoll_event的空间
        _revs = new struct epoll_event[_revs_num];

        // 创建socket

        // 创建epoll模型
    
        // 将listensock设置进epoll用来获取已建立好连接的sock
    }
    ~EpollServer()
    {
        // ...
        if (_revs) delete[] _revs;
    }
private:
    // ...
    struct epoll_event* _revs;
    int _revs_num;
};

再封装一下epoll_wait。

static int WaitEpoll(int epfd, struct epoll_event revs[], int num, int timeout)
{
    return epoll_wait(epfd, revs, num, timeout);
}

        如果底层就绪的sock很多,revs的空间装不下怎么办呢?这是没有影响的,一次拿不完,下一次拿也可以。

        而且epoll_wait返回值大于0就表示有n个文件描述符就绪,遍历n次就可以,而且这个n个在revs中是按序排好的。

        epoll_wait写好后,剩下的和select和poll的逻辑是差不多的。

class EpollServer
{
    void Start()
    {
        while (true)
        {
            int n = Epoll::WaitEpoll(_epfd, _revs, _revs_num, 5000);

            switch(n)
            {
            case 0:
                logMessage(DEBUG, "timeout...");
                break;
            case -1:
                logMessage(WARNING, "epoll wait error: %s", strerror(errno));
                break;
            default:
                HandlerEvent(n);
                break;
            }
        }
    }

    void HandlerEvent(int n)
    {
        for (int i = 0; i < n; i++)
        {
            uint32_t revents = _revs[i].events;
            int fd = _revs[i].data.fd;
            
            // 读事件就绪
            if (revents & EPOLLIN)
            {
                // listensock就绪
                if (fd == _listensock) Accepter();
                // 其他sock就绪
                else Recver(fd);
            }
        }
    }

    void Accepter()
    {
        std::string clientip;
        uint16_t clientport;
        int sock = Sock::Accept(_listensock, &clientip, &clientport);
        if (sock < 0)
        {
            logMessage(WARNING, "accept error!");
            return;
        }

        // 将连接号的sock添加到epoll中
        if (!Epoll::CtlEpoll(_epfd, EPOLL_CTL_ADD, sock, EPOLLIN)) return;

        logMessage(DEBUG, "add new sock: %d to epoll success", sock);
    }

    void Recver(int fd)
    {
        char buffer[10240];
        ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
        if (n > 0)
        {
            // 假设读到了完整的数据
            buffer[n] = 0;
            logMessage(DEBUG, "client[%d]: %s", fd, buffer);
        }
        else if (n == 0)
        {
            // 对端关闭
            logMessage(NORMAL, "client %d quit, me too...", fd);
            // 一定是先从epoll中删除该fd
            bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, fd, 0); // 删除event设置为0即可
            // 再关闭文件描述符
            close(fd);
        }
        else
        {
            logMessage(NORMAL, "recv errro: %s", strerror(errno));
            bool res = Epoll::CtlEpoll(_epfd, EPOLL_CTL_DEL, fd, 0);
            close(fd);
        }
    }
};

epoll 的优点

  • 使用方便,不需要我们自己去维护数组,只需要调用对应的函数就可以。
  • 不需要遍历确定事件是否就绪,事件就绪就会执行对应的回调函数,将数据添加到就绪队列,从队列中拿就可以。
  • 拷贝较轻量,只有在新增的事件的时候调用epoll_ctl将数据拷贝到内核,epoll_wait获取的也是就绪的事件,不进行不必要的拷贝。
  • 没有数量限制,可以一直向红黑树插入新的结点。

epoll 的工作方式

        现在有一个点,就是如果在底层对应的文件描述符就绪了,上层没有把数据拿走,那么数据就会一直处于就绪状态,不管是select、poll还是epoll都有这个特点,这和 epoll 的工作模式是有关系的,有两种工作方式:水平触发(LT)和边缘触发(ET)。

        水平触发模式(Level Triggered)。

  • 只要底层有就绪的数据,epoll就会一直通知用户。
  • 就像数电中的高电平触发,只要数据到来就一直处于高电平,就会一直触发。
  • 如果只处理一部分,底层数据没有处理完,epoll 下次还会通知。

        epoll 默认的工作方式就是水平触发。

        边缘触发模式(Edge Triggered)。

  • 底层有数据就绪,epoll 只会通知一次。
  • 如果没有拿完时,又有数据到来,这时epoll 也会通知一次。
  • 就像数电中的边缘触发,只有电平变高的一瞬间才触发。

        在epoll_ctl中设置events的参数设置为EPOLLET就可以把LT模式变为ET模式。

        所以以上的多路转接方案默认都是LT模式,那这两种工作模式那个更高效呢?

        LT这种工作模式会重复通知上层,ET这种工作模式只有在新增的时候才会通知上层,所以ET这种工作模式效率更高。

        如果使用ET模式,对端只发了一次数据,底层后续后通知上层,而上层不管,对端也不会再发送数据了,所以也就无法再获取该文件描述符的就绪事件了,也就无法调用recv等接口了,这就会导致数据的丢失,所以就要让用户必须取走全部的就绪数据。

        如果使用LT模式,数据不取也不会消失,因为底层会一直通知我,但是用户还是取走全部的就绪数据,这就看出LT和ET这两种工作方式在效率上没有区别。

        但为什么还说ET模式高效呢?其中一个原因上面已经说过了,还有一个原因就是网络层面的了,ET需要让用户尽快将数据拷贝到应用层缓冲区,所以就可以返回一个更大的TCP响应报文中的窗口大小,所以对方的滑动窗口就会变大,一次性发送更多的数据,提高了IO的吞吐量。

        但是如果LT模式也做一样的操作呢,所以才说这两个的效率是差不多的,主要区别还是对数据如何处理。

        现在我们知道了需要一次把底层就绪数据全部拿完,那么如何区分底层数据已经拿完了呢?假如我们上层的缓冲区设置的是1024字节,调用recv参数为1024 - 1,如果返回值为1023,那就证明底层至少有1023字节数据,所以我们就还可以再读,直到读取的数据不足1023了,那就证明底层已经没有数据了,所以需要循环读取。

        但是最后一次读取后,一定还要进行一次读取,因为无法保证数据已经读取完毕,如果数据已经没有了,那么此次调用就会阻塞,那么该进程就会挂起,为了避免这个问题,ET模式下的sock必须设置为非阻塞,需要循环读取,直到读取失败(EAGAIN 或 EWOULDBLOCK)。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

微yu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值