【Linux】--- 多路转接select / poll / epoll

Welcome to 9ilk's Code World

(๑•́ ₃ •̀๑) 个人主页: 9ilk

(๑•́ ₃ •̀๑) 文章专栏: Linux


本篇博客主要是对Linux下多路转接技术select/poll/epoll的梳理总结。

多路转接,也叫多路复用,是一种能对多个描述符进行等待的手段,进而通知上层哪些fd已经就绪,本质是一种对IO事件就绪的通知机制。常见的多路转接方式有select、poll、epoll。

select

cpp 复制代码
NAME
       select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing

SYNOPSIS
       #include <sys/select.h>

       int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

参数说明

int nfds

该参数代表等待的多个fd中,fd的最大值+1。

struct timeval* timeout

该参数代表了应用层的三种等待方式:

  1. 阻塞等待:该参数设置为NULL,等待多个fd的IO事件时,没有一个fd就绪就一直阻塞,如果存在一个fd就绪,select就会返回。

  2. timeout方式:通过设置结构体参数,代表超时时间,比如(5,0)就是tv_sec为5,tv_usec为0,代表的是当从前时间开始,5s之内阻塞等待,超时之后立即返回。这个参数还是一个输入输出型参数,当select返回时,这个参数表示剩余多少时间。

cpp 复制代码
struct timeval {
        time_t      tv_sec;         /* seconds */
        useconds_t tv_usec;        /* microseconds */
};

The corresponding argument for pselect() has the following type:

struct timespec {
        time_t      tv_sec;         /* seconds */
        long        tv_nsec;        /* nanoseconds */
};
  1. 非阻塞等待:将timeval设置为(0,0)就是非阻塞等待,即等待多个fd,没有一个就绪则立即返回,如果有就绪的也是立即返回,因此需要进行轮询。

返回值

  • n > 0 :表示就绪的fd个数
  • n < 0 :-1表示select等待失败,比如其中一个fd已经被close了,但你还交给select监控IO事件
  • n == 0 :如果你设置的不是阻塞IO,则该返回值表示超时了,底层没有fd就绪,也没有出错

fd_set

fd_set是个集合数据类型,是OS提供给用户的数据类型,readfds、writefds、exceptfds分别表示读文件描述符集、写文件描述符集、异常文件描述符集。这个结构本质是个位图比特位位置表示文件描述符,比特位内容表示的是是否关心该fd的某个事件

我们把fd交给select,一般是让他帮我们干以下三件事:

  • 关心读事件->底层的文件描述符是否可读->接收缓冲区是否有数据。
  • 关心写事件->底层fd是否可写->发送缓冲区是否有空间。
  • 关心异常事件:fd是否出现异常,比如fd是一个错误的文件描述符。

正因为关心这三类,所以参数有三个fd_set,比如你的fd只关心读,fd添加到read_fdset当中,如果你既关心读又关心写,那你可以既添加到write_fdset也可以添加到write_fdset;如果你想先读再写,你可以先扔到read_fdset,读取完毕再扔到writefdset,因此这三个参数本质是可以把指定fd单个/同时/选择的设置进某个集合里,让select关心该fd特定的事件!

cpp 复制代码
typedef __kernel_fd_set		fd_set;

typedef struct {
	unsigned long fds_bits [__FDSET_LONGS];
} __kernel_fd_set;

#undef __NFDBITS
#define __NFDBITS	(8 * sizeof(unsigned long))

#undef __FD_SETSIZE
#define __FD_SETSIZE	1024

#undef __FDSET_LONGS
#define __FDSET_LONGS	(__FD_SETSIZE/__NFDBITS)

我们可以看到fd_set本质上就是一个内部封装了固定长度的unsigned long数组,也就说fd_set是一种具体的数据类型,内部不是指针或柔性数组成员,这注定了这是固定大小,换句话话,select能管理的fd个数是有上限的

由于能管理的fd个数是有限的,因此select适用于小型应用,更大型可以考虑使用poll、epoll。这个上限 = 数组大小*8 = 总共bit位数量 = fd数

cpp 复制代码
fd_set fds
sizeof(fds)*8

对于select这几个事件集,它们是当做输出输入型参数的,以read_fds为例子:

(1)输入的时候:用户告诉内核,你要帮我关心位图中被设置了的fd上的读事件

(2)输出的时候:内核告诉用户,你让我关心的read_fds中,哪些fd的读事件已经就绪了,此时对就绪的fd进行读取,读取的时候一定不会被阻塞,因为内核告诉我们读事件已经就绪了。

需要注意的是:对于某个fd,如果在输入的时候你没有设置进行位图中,当你就绪的时候,输出的fd集中是没有该fd的,你没有设置进输入集,OS认为你并不关心。

既然需要提交给系统输入集,那不可避免要对位图操作,在系统中,封装了几个能批量化对位图操作的方法:

cpp 复制代码
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);
  • FD_SET:将指定fd添加到集合
  • FD_ZERO:将指定fd集清空
  • FD_CLR:将指定fd从集合中移除
  • FD_ISSET:判断指定fd是否在集合中

Select Server

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include "Socket.hpp"
using namespace std;
using namespace SocketModule;
using namespace LogModule;
#define NUM sizeof(fd_set) * 8
const int gdefaultfd = -1;

class SelectServer
{
public:
    SelectServer(int port)
        : _port(port),
          _listen_socket(make_unique<TcpSocket>()),
          _isrunning(false)
    {
    }
    void Init()
    {
          _listen_socket->BuildTcpSocket(_port); // 创建套接字
    }

    void loop()
    {

    }
    
  

    ~SelectServer()
    {
    }

private:
    int _port;
    unique_ptr<Socket> _listen_socket;
    bool _isrunning;

};

在开始启动服务器时,我们需要把监听套接字listen_sockfd给加入输入集中,对于它来说,它只关心读事件就绪,而IO = 读 + 拷贝,我们应该让select帮我们等,select告诉我们有链接就绪,再从特定fd中读取,因此我们需要将监听套接字添加到select内部管理,在获取新链接的时候就不会阻塞:

cpp 复制代码
    void loop()
    {
        _isrunning = true;
        fd_set rfds;
        while (_isrunning)
        {
            // 清空 rfds
            FD_ZERO(&rfds);
            // 将listensockfd添加到rfds中
            FD_SET(_listen_socket->Fd(), &rfds);
            struct timeval timeout = {12, 0};
            // 不能让accpet来阻塞检测连接到来 而应该让select负责
            int n = ::select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
            switch (n)
            {
            case 0:
                cout << "time out..." << endl;
                break;
            case -1:
                perror("select");
                break;
            default: // 有事件就绪
                cout << "有事件就绪..." << endl;
                
                //..
                break;
            }
        }
        _isrunning = false;
    }

但是这样存在的问题是:当有新链接到来,正常情况下你应该把链接获取,如果没获取该fd就一直处于读事件就绪状态,而每次select都要关心监听套接字,你不获取上来,每次select都能检测到连接,一直通知你直到你处理该读事件,因此就绪之后我们需要及时处理新链接,未来除了要监管监听套接字之外,我们还可能要监管IO套接字,因此我们统一封装一个函数HandlerEvents来处理:

cpp 复制代码
void HandlerEvents(fd set &rfds)
{
    //判断listensockfd,是否在rfds中!
  if(FD_ISSET(_listen_socket->Fd(),&rfds))
  { 
    InetAddr client;
    //listensockfd就绪了!获取新连接不就好了吗?
    int newfd=listen_socket->Accepter(&client);//会不会被阻塞呢?
    if(newfd<0)
        return;
    else
    {
        std::cout<<"获得了一个新的链接:"<<newfd <<" c)lientinfo:"<<client.Addr()<<std::endl;
        break;
    }
  }
}        

    void loop()
    {
        _isrunning = true;
        fd_set rfds;
        while (_isrunning)
        {
            // 清空 rfds
            FD_ZERO(&rfds);
            // 将listensockfd添加到rfds中
            FD_SET(_listen_socket->Fd(), &rfds);
            struct timeval timeout = {12, 0};
            // 不能让accpet来阻塞检测连接到来 而应该让select负责
            int n = ::select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
            switch (n)
            {
            case 0:
                cout << "time out..." << endl;
                break;
            case -1:
                perror("select");
                break;
            default: // 有事件就绪
                cout << "有事件就绪..." << endl;
                HandlerEvents(rfds);
                //..
                break;
            }
        }
        _isrunning = false;
    }

和之前阻塞IO不同的是,执行到accept时,并不会出现阻塞,因为select已经告诉我们监听套接字读事件就绪了,不会被阻塞,用户处理事件时,执行的就只是IO中的拷贝。在获取到新链接之后,不能直接读,因为客户端可能只是刚连上你,数据还没发送,那么就需要服务器先等后拷,该事件什么就绪,我们并不清楚,因此我们需要把新链接newfd也交给select进行托管,让他帮我们关心newfd上的读事件

select参数中的fd_set是用来进行用户和内核之间的数据传递的,只要调用select,我们之前输入时传给select的rfds就可能被修改了,比如开始时关心1、2、3、4、5,就绪的时候只有1就绪了,其他的fd对应的bit位被置为0,下次调用时,这些没就绪的fd也还要继续被关心的,这就要求select每次调用过后,都要独自输入参数进行重新设置,timeout也是要重新设置,否则会返回剩余时间,下一次传给select的超时时间就不对了,所以代码中我们将read_fds是在循环体外定义的,而对其的位图操作是在循环体内进行的。

但我们要考虑一个问题是,我们在获取新链接之后,需要将newfd添加到select中管理,问题是在退出HandlerEvent之后,select已经调用结束了,我们如何在下次循环时设置进输入集中呢?这就需要我们要有个设置源,存储了你目前关心的fd有哪些,有了这个设置源,我们就可以保存历史上所有的文件描述符,方便多次添加到fd_set中,这个设置源我们叫辅助数组,每次循环时,根据这个辅助数组设置输入集告诉select关系哪些fd上的读事件,由于select能管理的fd是存在上限的,因此我们不用关心数组遍历的时间复杂度问题:

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include "Socket.hpp"
using namespace std;
using namespace SocketModule;
using namespace LogModule;
#define NUM sizeof(fd_set) * 8
const int gdefaultfd = -1;

class SelectServer
{
public:
    SelectServer(int port)
        : _port(port),
          _listen_socket(make_unique<TcpSocket>()),
          _isrunning(false)
    {
    }
    void Init()
    {
        _listen_socket->BuildTcpSocket(_port); // 创建套接字
        for (int i = 0; i < NUM; i++)          // 初始化辅助数组
            _fd_array[i] = -1;
        _fd_array[0] = _listen_socket->Fd(); // 监听套接字先设置进辅助数组
    }

    void loop()
    {
        _isrunning = true;
        fd_set rfds;
        while (_isrunning)
        {
            // 清空 rfds
            FD_ZERO(&rfds);
            // 将listensockfd添加到rfds中
            FD_SET(_listen_socket->Fd(), &rfds);
            struct timeval timeout = {12, 0};
            int maxfd = gdefaultfd;
            // 判断辅助数组中合法的fd
            for (int i = 0; i < NUM; i++)
            {
                if (_fd_array[i] == gdefaultfd)
                    continue;
                FD_SET(_fd_array[i], &rfds); //  合法fd设置进要关心的读fd集,
                // 更新最大值
                if (maxfd < _fd_array[i])
                    maxfd = _fd_array[i];
            }
            // 不能让accpet来阻塞检测连接到来 而应该让select负责
            int n = ::select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
            switch (n)
            {
            case 0:
                cout << "time out..." << endl;
                break;
            case -1:
                perror("select");
                break;
            default: // 有事件就绪
                cout << "有事件就绪..." << endl;
                HandlerEvents(rfds); //任务派发
                break;
            }
        }
        _isrunning = false;
    }
    


    ~SelectServer()
    {
    }

private:
    int _port;
    unique_ptr<Socket> _listen_socket;
    bool _isrunning;
    int _fd_array[NUM]; // 辅助数组
};

有了辅助数组,newfd托管给select就很简单了,将newfd添加到辅助数组即可,HandlerEvents返回的时候会重新进入循环,根据辅助数组将newfd设置进输入集中:

cpp 复制代码
void HandlerEvents(fd set &rfds)
{
    //判断listensockfd,是否在rfds中!
   if(FD_ISSET(_listen_socket->Fd(),&rfds))
   {
        InetAddr client;
        //listensockfd就绪了!获取新连接不就好了吗?
        int newfd=listen_socket->Accepter(&client);//会不会被阻塞呢?
        if(newfd<0)
            return;
        else
        {
               cout << "获得了一个新连接: " << newfd << " client info: " << client.Addr() << endl;
            // recv我们并不清楚读事件是否就绪也交给select -> 把newfd存到辅助数组里
            int pos = -1;
            for (int j = 0; j < NUM; j++)
            {
                if (_fd_array[j] == gdefaultfd) // 看有没空位存到辅助数组
                {
                    pos = j;
                    break;
                }
            }
            if (pos == -1)
            {
                LOG(LogLevel::ERROR) << "服务器已经满了...\n";
                close(newfd);
            }
            else
                _fd_array[pos] = newfd;
        }
    }
}  

此时select返回的输出集中,就绪的fd不仅有监听套接字,也可能有IO套接字,但是这些fd只能是辅助数组的子集,因此在处理就绪事件时,我们只能遍历辅助数组,拿着每一个合法的fd(即fd_array[i] != gdefaultfd),如果该fd在输出集中,则说明该fd是就绪的,根据已知的监听套接字判断是什么类型的套接字进行不同处理:

cpp 复制代码
void HandlerEvents(fd set &rfds)
{
    //判断listensockfd,是否在rfds中!
   if(FD_ISSET(_listen_socket->Fd(),&rfds))
   {
        InetAddr client;
        //listensockfd就绪了!获取新连接不就好了吗?
        int newfd=listen_socket->Accepter(&client);//会不会被阻塞呢?
        if(newfd<0)
            return;
        else
        {
               cout << "获得了一个新连接: " << newfd << " client info: " << client.Addr() << endl;
            // recv我们并不清楚读事件是否就绪也交给select -> 把newfd存到辅助数组里
            int pos = -1;
            for (int j = 0; j < NUM; j++)
            {
                if (_fd_array[j] == gdefaultfd) // 看有没空位存到辅助数组
                {
                    pos = j;
                    break;
                }
            }
            if (pos == -1)
            {
                LOG(LogLevel::ERROR) << "服务器已经满了...\n";
                close(newfd);
            }
            else
                _fd_array[pos] = newfd;
        }
  }
  //IO套接字
  else 
  {
      if (FD_ISSET(_fd_array[i], &rfds))
      {
               // 合法&&就绪普通fd
         char buffer[1024];
         ssize_t n = ::recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0); // 不会阻塞 因为已经就绪
         if (n > 0)
         {
             buffer[n] = 0;
             cout << "client# " << buffer << endl;
             string message = "echo# ";
             message += buffer;
             send(_fd_array[i], message.c_str(), message.size(), 0); // 回显 这里先不关心写事件就绪 TODO
         }
         else if (n == 0)
         {
             LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[i];
             close(_fd_array[i]);
             _fd_array[i] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
         }
         else
         {
             LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[i];
             close(_fd_array[i]);
             _fd_array[i] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
         }
      }
  }
}  

未来一个IO套接字,不一定是只关心读事件,还可能关心写事件、异常事件,只要再设置两个辅助数组即可。到目前为止,HandlerEvent本质做的工作其实就是把已经就绪的sockfd进行不同处理,因此我们可以单独将监听套接字划分到Accept模块处理,IO套接字划分到Recv模块处理,这其实就是事件的派发(Dispatcher),select的工作就很明显了,也就是等+通知(派发sockfd到不同模块):

cpp 复制代码
    void loop()
    {
        _isrunning = true;
        fd_set rfds;
        while (_isrunning)
        {
            // 清空 rfds
            FD_ZERO(&rfds);
            // 将listensockfd添加到rfds中
            FD_SET(_listen_socket->Fd(), &rfds);
            struct timeval timeout = {12, 0};
            int maxfd = gdefaultfd;
            // 判断辅助数组中合法的fd
            for (int i = 0; i < NUM; i++)
            {
                if (_fd_array[i] == gdefaultfd)
                    continue;
                FD_SET(_fd_array[i], &rfds); //  合法fd设置进要关心的读fd集,
                // 更新最大值
                if (maxfd < _fd_array[i])
                    maxfd = _fd_array[i];
            }
            // 不能让accpet来阻塞检测连接到来 而应该让select负责
            int n = ::select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
            switch (n)
            {
            case 0:
                cout << "time out..." << endl;
                break;
            case -1:
                perror("select");
                break;
            default: // 有事件就绪
                cout << "有事件就绪..." << endl;
                Dispacher(rfds); //任务派发
                break;
            }
        }
        _isrunning = false;
    }
    
    void Dispacher(fd_set &rfds) // rfds不仅仅是监听套接字就绪 还有普通IO
    {
        // 我们还是要判断listensockfd是否在rfds中,即我们要关心的
        for (int i = 0; i < NUM; i++)
        {
            if (_fd_array[i] == gdefaultfd)
                continue;
            if (_fd_array[i] == _listen_socket->Fd())
            {
                if (FD_ISSET(_listen_socket->Fd(), &rfds))
                {
                    Accept();
                }
            }
            else
            {
                if (FD_ISSET(_fd_array[i], &rfds))
                {
                   Recev(i);
                }
            }
        }
    }

    void Accept()
    {
        InetAddr client;
        // 此时listensockfd就绪 此时获取新连接不会阻塞
        int newfd = _listen_socket->Accepter(&client);
        if (newfd < 0)
            return;
        else
        {
            cout << "获得了一个新连接: " << newfd << " client info: " << client.Addr() << endl;
            // recv我们并不清楚读事件是否就绪也交给select -> 把newfd存到辅助数组里
            int pos = -1;
            for (int j = 0; j < NUM; j++)
            {
                if (_fd_array[j] == gdefaultfd) // 看有没空位存到辅助数组
                {
                    pos = j;
                    break;
                }
            }
            if (pos == -1)
            {
                LOG(LogLevel::ERROR) << "服务器已经满了...\n";
                close(newfd);
            }
            else
                _fd_array[pos] = newfd;
        }
    }

    void  Recev(int who)
    {
         // 合法&&就绪普通fd
         char buffer[1024];
         ssize_t n = ::recv(_fd_array[who], buffer, sizeof(buffer) - 1, 0); // 不会阻塞 因为已经就绪
         if (n > 0)
         {
             buffer[n] = 0;
             cout << "client# " << buffer << endl;
             string message = "echo# ";
             message += buffer;
             send(_fd_array[who], message.c_str(), message.size(), 0); // 回显 这里先不关心写事件就绪 TODO
         }
         else if (n == 0)
         {
             LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[who];
             close(_fd_array[who]);
             _fd_array[who] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
         }
         else
         {
             LOG(LogLevel::DEBUG) << "客户端退出,sockfd: " << _fd_array[who];
             close(_fd_array[who]);
             _fd_array[who] = gdefaultfd; // 将其移除出我们关心的fd集(辅助数组)里
         }
    }

总结

select的特点

  • select可以监控的fd数量是有上限的,取决于sizeof(fd_set)的值
  • select的fd集是个输入输出型参数,而且将三种事件分离,用户输入时根据需要设置自己所关心的fd和对应关心的事件,输出时返回的是关心的fd中哪些fd就绪了该事件
  • select需要一个辅助数组,保存监控的fd,一是用于在select返回后,array作为源数据和fd_set进行FD_ISSET判断,二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描amay的同时取得fd最大值maxfd,用于select的第一个参数。

select缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd这个开销在fd很多时也很大,fd就绪时,用户态还需要再遍历一次,找出哪些fd就绪
  • select支持的文件描述符数量太小,但是注意这是它的设计导致的(固定位图),而不是因为进程或系统能力有限

poll

为什么会存在poll?

  • select的输入输出参数是一个位图,导致参数每次都要重置,而poll就帮我们分离了输入输出参数,不需要重置
  • poll管理的fd个数是"没有上限"的
cpp 复制代码
NAME
       poll, ppoll - wait for some event on a file descriptor

SYNOPSIS
       #include <poll.h>

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

参数说明

timeout

  • timeout == 0 :非阻塞等待
  • timeout < 0:阻塞式等待
  • timeout > 0:设置超时时间,毫秒级

返回值

  • n > 0:有fd就绪
  • n == 0:超时
  • n < 0:poll失败,设置errno

struct pollfd * fds && nfds_t nfds

这两个参数组合起来相当于是一个数组,第一个参数表示数组的起始地址,第二个参数代表的是这个数组的元素个数。

cpp 复制代码
struct pollfd {
       int   fd;         /* file descriptor */
       short events;     /* requested events */
       short revents;    /* returned events */
};

通过这个fd数组,poll一次可以等多个描述符的IO事件,调用poll时,用户告诉内核要关心哪个fd上的哪些事件,poll返回时返回的是你关心的fd的子集,内核告诉用户你要关心的哪些fd上面的哪些事件已经就绪了,因此poll和select一样也是一个多个fd的IO事件等待机制,从而达到事件派发的目的。

而对于struct pollfd这个结构体:

  • fd代表poll要关心的是哪个fd
  • short events:代表用户对该fd要关心的是哪些事件,它和fd组合表示传递给内核要关心哪些fd上的是哪些事件
  • short revents:就绪时,表示的是哪些事件就绪了

也就是说,poll是输入输出信息分离的,此时调用poll时就不用频繁地对参数进行重置,而且fd是只读的不会被修改。那怎么理解事件呢?之前select是提供三种事件,也就是三个集合数据结构,那在这里如何理解呢?

cpp 复制代码
/* These are specified by iBCS2 */
#define POLLIN		0x0001
#define POLLPRI		0x0002
#define POLLOUT		0x0004
#define POLLERR		0x0008
#define POLLHUP		0x0010
#define POLLNVAL	0x0020

/* The rest seem to be more-or-less nonstandard. Check them! */
#define POLLRDNORM	0x0040
#define POLLRDBAND	0x0080
#ifndef POLLWRNORM
#define POLLWRNORM	0x0100
#endif
#ifndef POLLWRBAND
#define POLLWRBAND	0x0200
#endif
#ifndef POLLMSG
#define POLLMSG		0x0400
#endif
#ifndef POLLREMOVE
#define POLLREMOVE	0x1000
#endif
#ifndef POLLRDHUP
#define POLLRDHUP       0x2000

上面的就是poll提供的事件,它们其实就是宏,本质是只有一个bit位互不重复的宏,因此我们的events、revents是位图结构,关心什么事件进行位操作设置就行,但是这里系统没有提供对应操作,需要我们自己进行位操作,同时将来我们还需要一个struct pollfd数组表示poll关心的fd集,达到poll同时等待多个fd的目的,在poll中是会对传入的数组元素进行校验的,比如struct pollfd结构中传入的fd为-1,同时events是0,表示不关心该fd,也不关心其任何事件,因此poll会直接忽略掉该pollfd结果,不会监听其任何事件,也不会报错。

Q:那又该如何理解,poll传递的fd没有上限呢?

select传递的fd存在上限,是因为select传递的数据结构类型fd_set是固定位图,而poll没有写死,而是由用户自己决定如何设置nfds,而且你还可以对struct pollfd数组动态扩容,也就是说,数组的大小此时不由poll本身设计决定,而是由用户和计算机配置决定 ,进程所能打开的fd上限与我这个接口无关,即接口设计上没有上限。

Poll Server

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include<poll.h>
#include "Socket.hpp"
#include<cstring>
#include<errno.h>
using namespace std;
using namespace SocketModule;
using namespace LogModule;
const int gdefaultfd = -1;

#define MAX 1024 //4096就poll出错

// 最开始的时候,tcpserver只有一个listensockfd
class PollServer
{
public:
    PollServer(int port)
        : _port(port),
          _listen_socket(std::make_unique<TcpSocket>()),
          _isrunning(false)
    {
    }
    void Init()
    {
        _listen_socket->BuildTcpSocket(_port);
        for(int i = 0;i < MAX; i++)
        {
            _fds[i].fd = gdefaultfd;
            _fds[i].events = 0;
            _fds[i].revents = 0;
        }
        // 先把唯一的一个fd添加到poll中
        _fds[0].fd = _listen_socket->Fd();
        _fds[0].events =  POLLIN;
        // _fds[0].revents;
    }
    void loop()
    {
        int timeout = 5000;
        _isrunning = true;
        while (_isrunning)
        {
            // 我们不能让accept来阻塞检测新连接到来,而应该让select来负责进行就绪事件的检测
            // 用户告诉内核,你要帮我关心&rfds,读事件啊!!
            int num = 0;
            // for(int i = 0;i < MAX; i++)
            // {
            //    if(_fds[i].fd != gdefaultfd) num++; 
            // }
            int n = poll(_fds,MAX, timeout); // 通知上层的任务!
            switch (n)
            {
            case 0:
                std::cout << "time out..." << std::endl;
                break;
            case -1:
                sleep(2);
                perror("Poll");
                break;
            default:
                // 有事件就绪了
                // rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!
                std::cout << "有事件就绪啦..., timeout: " << std::endl;
                Dispatcher(); // 把已经就绪的sockfd,派发给指定的模块
                TestFd();
                break;
            }
        }
        _isrunning = false;
    }
    void Accepter() // 回调函数呢?
    {
        InetAddr client;
        // listensockfd就绪了!获取新连接不就好了吗?
        int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"
        if (newfd < 0)
            return;
        else
        {
            std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;
            // recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪
            // 怎么把新的newfd托管给poll?让poll帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!
            int pos = -1;
            for (int j = 0; j < MAX; j++)
            {
                if (_fds[j].fd == gdefaultfd)
                {
                    pos = j;
                    break;
                }
            }
            if (pos == -1)
            {
                // _fds进行自动扩容
                LOG(LogLevel::ERROR) << "服务器已经满了...";
                close(newfd);
            }
            else
            {
                _fds[pos].fd = newfd;
                _fds[pos].events = POLLIN;
            }
        }
    }
    void Recver(int who) // 回调函数?
    {
        // 合法的,就绪的,普通的fd
        // 这里的recv,对不对啊!不完善!必须得有协议!
        char buffer[1024];
        ssize_t n = recv(_fds[who].fd, buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client# " << buffer << std::endl;
            // 把读到的信息,在回显会去
            std::string message = "echo# ";
            message += buffer;
            send(_fds[who].fd, message.c_str(), message.size(), 0); // bug
        }
        else if (n == 0)
        {
            LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << _fds[who].fd;
            close(_fds[who].fd);
            _fds[who].fd = gdefaultfd;
            _fds[who].events = _fds[who].revents = 0;
        }
        else
        {
            LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " <<_fds[who].fd;
            close(_fds[who].fd);
            _fds[who].fd = gdefaultfd;
            _fds[who].events = _fds[who].revents = 0;
        }
    }
    void Dispatcher() // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了
    {
        for (int i = 0; i < MAX; i++)
        {
            if (_fds[i].fd == gdefaultfd)
                continue;
            // 文件描述符,先得是合法的
            if (_fds[i].fd == _listen_socket->Fd()) // 该fd是listenfd
            {
                if (_fds[i].revents & POLLIN)
                {
                    Accepter(); // 连接的获取
                }
            }
            else
            {
                if (_fds[i].revents & POLLIN)
                {
                    Recver(i); // IO的处理
                }
                // else if(_fds[i].revents & POLLOUT)
                // {
                //     // wirte
                // }
            }
        }
    }
    void TestFd()
    {
        std::cout << "pollfd: ";
        for(int i = 0; i < MAX; i++)
        {
            if(_fds[i].fd == gdefaultfd)
                continue;
            std::cout << _fds[i].fd << "[" << Events2Str(_fds[i].events)  << "] ";
        }
        std::cout << "\n";
    }
    std::string Events2Str(short events)
    {
        std::string s = ((events & POLLIN) ? "EPOLLIN" : "");
        s += ((events & POLLOUT) ? "POLLOUT" : "");
        return s;
    }
    ~PollServer()
    {
    }

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listen_socket;
    bool _isrunning;
    struct pollfd _fds[MAX];
    // struct pollfd *_fds; // malloc
};

总结

poll特点

  • poll使用struct pollfd结构将输入输出参数分离,该结构包含了某个fd要监视的event和就绪的revents,接口使用比select更方便
  • poll也是需要一个数组,但是这个数组支持动态扩容
  • poll在接口设计上并没有最大数量限制,但是受限于计算机配置,数量过大之后性能也是会下降

poll缺点

当poll中监听的文件描述符数目增多时:

  • 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符
  • 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中
  • 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降

epoll

epoll也是负责多个fd的IO事件等待机制,从而达到事件派发的目的,本质上和select、poll一样是一种就绪事件的通知机制。它是在2.5.44内核中被引进的,man手册上介绍说,它是为处理大批量句柄而做了改进的poll,被公认为是Linux 2.6下性能最好的多路I/O就绪通知方法。

cpp 复制代码
#include <sys/epoll.h>

int epoll_create(int size);
int epoll _createl(int flags);

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

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

接口说明

int epoll_create(int size);

  • 作用:在内核创建一个epoll模型
  • 返回值:表示epoll模型的文件描述符
  • int size:这个参数实际上是被忽略的,可以随便填写,但是必须大于0

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

  • 作用:主要功能是在指定的epoll模型中,添加(或修改、删除操作)fd进而关心事件,未来调用epoll_wait之前,它能提前帮我们告诉内核我们的需求(epoll在功能上进行分离)
  • op:表示要对epoll模型做什么操作
bash 复制代码
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL
  • fd:代表是哪一个文件描述符
  • struct epoll event *event:代表关心该fd的哪些事件
  • 返回值:操作成功返回0

int epoll_wait(int epfd, struct epoll event *events,int maxevents,int timeout)

  • epfd:代表epoll模型的fd
  • timeout:同poll
  • 返回值:同poll
  • 剩余参数组合起来表示一个数组,只不过它是个输出型参数,events表示的是输出数组的起始地址,mexevents代表的是数组的元素个数,这个输出型参数就是内核告诉用户你历史上关心的fd的哪些事件已经就绪

从上面的接口来看,epoll把接口做了分离,即输入用一个接口,输入用另一个接口,同时参数也做了分离。

cpp 复制代码
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 */
};

struct epoll_event内部其实也是封装了一个位图结构events代表事件类型,在epoll_ctl中events表示输入集,在epoll_wait中表示输出集。epoll_data_t是个联合体类型,里面包含了相关属性,其中就有fd。对于events来说,可以是以下几个宏的集合:

  • EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
  • EPOLLOUT:表示对应的文件描述符可以写
  • EPOLLPR:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
  • EPOLLERR:表示对应的文件描述符发生错误
  • EPOLLHUP:表示对应的文件描述符被挂断
  • EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是且对于水平触发(LevelTriggered)来说的
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还不需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

epoll工作原理

我们知道,对于网卡来说,OS通过网卡驱动来操作网卡,在操作系统之上是各种的系统调用接口供用户使用,网卡也是个外设,当网卡中有数据到来时,就会触发硬件中断执行对应中断向量表中的向量表,将数据从网卡中获取上来。

在之前select和poll进行多路复用的时候,对于遍历这个操作,本质上就是去查看这个文件描述符对应的某个资源和数据是否就绪,如果没有就绪就让其继续进行阻塞等待,此时这个进程就会被挂起到等待队列当中,而在底层操作系统进行定期唤醒和调度的时候,轮到该进程,就继续对其文件描述符进行检测,这也就意味着,操作系统需要主动去检查这个内容到底有没有就绪,需要进行轮询检测

如果像select、poll这样需要定期遍历文件描述符表,检测对应套接字的接收队列是否存在数据,没有就阻塞挂起,这样的效率是比较低的。因此操作系统提供了一种底层回调机制,当数据自底向上交付时,网络部分的代码就会自动执行这样的回调,这个回调默认是空的,将来在OS中,就可以把这个回调设置指向上层中的某一个方法,比如可以注册称一旦数据到了,向上交付到TCP的接收缓冲区,此时OS会给目标进程发送信号SIGIO,此时上层进程就可以在合适时机进行处理,这其实就是信号驱动式IO的原理。

当创建epoll模型时,首先会在底层创建一棵红黑树结构,默认这棵红黑树的节点数量为空,后续可以通过某种方式让底层往红黑树中添加节点,比如我现在要关心的是3号描述符的EPOLLIN事件,此时OS要:

  1. 往红黑树中新增节点,这个节点包含了文件描述符和对应关心事件

  2. 创建就绪队列,未来EPOLLIN事件就绪时,就绪队列就会产生一个节点,即4号描述符和EPOLLIN事件的节点。

所以这棵红黑树表达的含义是用户告诉内核,需要关心哪些fd上的哪些事件,其中红黑树的一个节点代表的是一个文件描述符的若干个事件。而这个就绪队列表示内核告诉用户哪些fd上的哪些事件已经就绪了。

回过头来看,epoll_ctl的EPOLL_ADD、EPOLL_MOD、EPOLL_DEL这几个操作本质是在对红黑树进行操作,epoll_wait本质是从就绪队列中把已经就绪的节点获取上来。

现在的问题就是,整个系统怎么知道当前有文件描述符事件就绪呢?其实在进行epoll_ctl的时候除了要设置节点对应的fd和事件之外,其实还有注册该fd的底层回调,当网卡有数据自底向上解包,拿到对应struct sock指针后,放到对应的TCP缓冲区,此时就会调用该回调,它要做的是比如判断4号描述符上对应的事件类型,然后判断是否有事件就绪,有则将对应的事件就绪节点插入到就绪队列中,此时一个节点级别的迁移,即从红黑树->就绪队列,是由底层回调机制自动驱动完成的,我们把红黑树+就绪队列+底层回调这一套叫epoll模型。换句话说,epoll_create其实是在底层创建一个红黑树、就绪队列、维护回调机制。

Q:在epoll中,这棵红黑树相当于select、poll中的什么?

对于epoll来说,这棵红黑树相当于是辅助数组,表示的是用户告诉内核,要关心哪些fd上的哪些事件。我们可以从源码中验证一下,是否存在红黑树和对应节点,博主查看的是Linux 2.6.38版本:

我们可以看到每一个epoll模型其实对应的是一个eventpoll结构体,内部封装了红黑树和就绪队列

而在epoll中,每一个红黑树节点其实就是对应一个epitem结构体:

Q:具体是如何将红黑树节点迁移到就绪队列?

实际上内核中一个数据结构节点,并不是只能属于一个数据结构,而是可以通过一些链接字段,通过指针移动就能完成节点的被激活状态:rbn负责把它挂在红黑树,rdllink 负责把它挂在双向循环链表

Q:红黑树是有key值的,新增红黑树节点时谁作为键值呢?

在红黑树中,键值就是文件描述符本身,通过fd值作为比较关键字,可以保证同一个epoll模型中不会重复插入同一个fd。

Q:如何理解epoll模型,为什么epoll_create返回的是一个文件描述符

Linux下一切皆文件,当调用epoll_create时,其实是分配inode+dentry,创建一个新的struct file,返回创建epoll模型(红黑树+就绪队列),然后通过private_data指针将struct file和epoll模型关联,后续操作时,就可以通过fd找到struct file,再通过private_data字段拿到epoll模型进行操作了,实现了一切皆文件的统一。

Q:epoll为什么高效

  1. 内核无需定期轮询遍历:用户只需要注册节点,事件就绪由底层回调机制驱动,就绪之后就会移动到就绪队列。

  2. O(1)检测就绪:epoll_wait只需要看就绪队列是否为空就能判断是否有fd就绪,队列空就进行睡眠,队列不为空就一定是有就绪的

  3. 没有拷贝冗余:对于select、poll即使关心的fd没有就绪,也会从内核态拷贝到用户态,而epoll是直接把已经就绪的fd拷贝到数组中,返回值为n,则数组中0~n-1就都是就绪的。

epoll Server

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include "Log.hpp"
#include<poll.h>
#include <sys/epoll.h>
#include "Socket.hpp"
#include<cstring>
#include<errno.h>
using namespace std;
using namespace SocketModule;
using namespace LogModule;
const int gdefaultfd = -1;

#define MAX 1024 //4096就poll出错

// 最开始的时候,tcpserver只有一个listensockfd
class EpollServer
{
    static const int rev_num = 64;
public:
    EpollServer(int port)
        : _port(port),
          _epfd(gdefaultfd),
          _listen_socket(std::make_unique<TcpSocket>()),
          _isrunning(false)
    {
    }
    void Init()
    {
        _listen_socket->BuildTcpSocket(_port);
        //1.创建epoll模型
        _epfd = ::epoll_create(256);
        if(_epfd < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_create error\n";
            exit(EPOLL_CREATE_ERR);
        }
        LOG(LogLevel::DEBUG) << "epoll_create success: " << _epfd << "\n";
        //2.至少要先将listen套接字添加到epoll模型
        struct epoll_event ev;
        ev.events = EPOLLIN; //关心事件
        ev.data.fd = _listen_socket->Fd();
        int n = ::epoll_ctl(_epfd,EPOLL_CTL_ADD,_listen_socket->Fd(),&ev); //??为什么要有第三个参数,第四个参数中不是有了?
        if(n < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_ctl error";
            exit(EPOLL_CTL_ERR);
        }
        LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
    }
    void loop()
    {
        int timeout = 5000;
        _isrunning = true;
        while (_isrunning)
        {
            // 我们不能让accept来阻塞检测新连接到来,而应该让select来负责进行就绪事件的检测
            // 用户告诉内核,你要帮我关心&rfds,读事件啊!!
            int n = epoll_wait(_epfd,_revs,rev_num , timeout); // 通知上层的任务!
            switch (n)
            {
            case 0:
                std::cout << "time out..." << std::endl;
                break;
            case -1:
                sleep(1);
                perror("epoll error");
                break;
            default:
                // 有事件就绪了
                // rfds: 内核告诉用户,你关心的rfds中的fd,有哪些已经就绪了!!
                std::cout << "有事件就绪啦..., timeout: " << std::endl;
                Dispatcher(n); // 把已经就绪的sockfd,派发给指定的模块
                break;
            }
        }
        _isrunning = false;
    }
    void Accepter() // 回调函数呢?
    {
        InetAddr client;
        // listensockfd就绪了!获取新连接不就好了吗?
        int newfd = _listen_socket->Accepter(&client); // 会不会被阻塞呢?不会!select已经告诉我,listensockfd已经就绪了!只执行"拷贝"
        if (newfd < 0)
            return;
        else
        {
            std::cout << "获得了一个新的链接: " << newfd << " client info: " << client.Addr() << std::endl;
            // recv()?? 读事件是否就绪,我们并不清楚!newfd也托管给select,让select帮我进行关心新的sockfd上面的读事件就绪
            // 怎么把新的newfd托管给epoll?让epoll帮我去关心newfd上面的读事件呢?把newfd,添加到辅助数组即可!
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = newfd;
            int n = epoll_ctl(_epfd,EPOLL_CTL_ADD,newfd,&ev);//设置新fd进epoll模型(红黑树)
            if(n < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                exit(EPOLL_CTL_ERR);
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
        }
    }
    void Recver(int fd) // 回调函数?
    {
        // 合法的,就绪的,普通的fd
        // 这里的recv,对不对啊!不完善!必须得有协议!
        char buffer[1024];
        ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0); // 会不会被阻塞?就绪了
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client# " << buffer << std::endl;
            // 把读到的信息,在回显会去
            std::string message = "echo# ";
            message += buffer;
            send(fd, message.c_str(), message.size(), 0); // bug
        }
        else if (n == 0)
        {
            LOG(LogLevel::DEBUG) << "客户端退出, sockfd: " << fd;
            //close(_fds[who].fd);
            //对于客户端退出的fd需要从红黑树移除
           int m = epoll_ctl(_epfd,EPOLL_CTL_DEL,fd,nullptr);
           if(m < 0)
           {
             LOG(LogLevel::ERROR) << "epoll_ctl error\n";
             return;
           }
           LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
           close(fd);//注意移除前提是合法的fd,所以需要最后才close
        }
        else
        {
            LOG(LogLevel::DEBUG) << "客户端读取出错, sockfd: " << fd;
                //对于客户端退出的fd需要从红黑树移除
           int m = epoll_ctl(_epfd,EPOLL_CTL_DEL,fd,nullptr);
           if(m < 0)
           {
             LOG(LogLevel::ERROR) << "epoll_ctl error\n";
             return;
           }
           LOG(LogLevel::DEBUG) << "epoll_ctl success\n";
           close(fd);//注意移除前提是合法的fd,所以需要最后才close
        }
    }
    void Dispatcher(int num) // rfds就可能会有更多的fd就绪了,就不仅仅 是listenfd就绪了
    {
        for (int i = 0; i < num; i++)
        {
            int events = _revs[i].events;
            int fd = _revs[i].data.fd;
            if(fd == _listen_socket->Fd())
            {
                if(events & EPOLLIN) //判断就绪类型
                  Accepter();
            }
            else //普通文件描述符就绪
            {
                 if(events & EPOLLIN)
                 {
                     Recver(fd);
                 }
                 else 
                 {
                     //写事件就绪
                 }
            }
        }
    }

    std::string Events2Str(short events)
    {
        std::string s = ((events & POLLIN) ? "EPOLLIN" : "");
        s += ((events & POLLOUT) ? "POLLOUT" : "");
        return s;
    }
    ~EpollServer()
    {
        _listen_socket->Close();
        if(_epfd >= 0) //关闭epoll模型
          ::close(_epfd);
    }

private:
    uint16_t _port;
    std::unique_ptr<Socket> _listen_socket;
    bool _isrunning;
    int _epfd;//epoll模型绑定的fd
    struct epoll_event _revs[rev_num];//就绪事件集合 输出型参数
    // struct pollfd _fds[MAX];
    // struct pollfd *_fds; // malloc
};

需要注意的是:

  1. 对于输出数组,如果内核中epoll模型的就绪队列中有128个fd就绪了,但是你设置的输出数组大小为64,此时它的数组中只能是64个fd,其他的到下一次处理。

  2. 将fd从epoll删除时,必须先保证fd合法,所以要先移除再close,这与select和poll是不同的,否则可能造成内存或资源泄漏,毕竟直接close之后,可能会丢失文件描述符与epoll的连接,导致一些epoll模型内部资源不能正确释放。

  3. 与select、poll不同的是,在进行派发时,不再需要判断是否是合法fd,可以直接判断就绪类型。

select/poll/epoll对比

前面我们讨论了select、poll和epoll三组I/O复用系统调用,这3组系 统调用都能同时监听多个文件描述符。它们将等待由timeout参数指定 的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返 回值是就绪的文件描述符的数量。返回0表示没有事件发生。现在我们 从事件集、最大支持文件描述符数、工作模式和具体实现等四个方面 进一步比较它们的异同,以明确在实际应用中应该选择使用哪个(或 哪些)。

这3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符 上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。 select的参数类型fd_set没有将文件描述符和事件绑定,它仅仅是一个文 件描述符集合,因此select需要提供3个这种类型的参数来分别传入和输 出可读、可写及异常等事件。这一方面使得select不能处理更多类型的 事件,另一方面由于内核对fd_set集合的在线修改,应用程序下次调用 select前不得不重置这3个fd_set集合。

poll的参数类型pollfd则多少"聪 明"一些。它把文件描述符和事件都定义其中,任何事件都被统一处 理,从而使得编程接口简洁得多。并且内核每次修改的是pollfd结构体 的revents成员,而events成员保持不变,因此下次调用poll时应用程序 无须重置pollfd类型的事件集参数。由于每次select和poll调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的),所以应用程 序索引就绪文件描述符的时间复杂度为O(n)。

epoll则采用与select和 poll完全不同的方式来管理用户注册的事件。它在内核中维护一个事件 表,并提供了一个独立的系统调用epoll_ctl来控制往其中添加、删除、 修改事件。这样,每次epoll_wait调用都直接从该内核事件表中取得用 户注册的事件,而无须反复从用户空间读入这些事件。epoll_wait系统 调用的events参数仅用来返回就绪的事件,这使得应用程序索引就绪文 件描述符的时间复杂度达到O(1)。

poll和epoll_wait分别用nfds和maxevents参数指定最多监听多少个文 件描述符和事件。这两个数值都能达到系统允许打开的最大文件描述 符数目,即65535(cat/proc/sys/fs/file-max)。而select允许监听的最大 文件描述符数量通常有限制。虽然用户可以修改这个限制,但这可能 导致不可预期的后果。

从实现原理上来说,select和poll采用的都是轮询的方式,即每次调 用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返 回给用户程序,因此它们检测就绪事件的算法的时间复杂度是 O(n)。epoll_wait则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对 应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事 件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描 述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。但是,当活动连接比较多的时候,epoll_wait的效率未必比select和poll 高,因为此时回调函数被触发得过于频繁。所以epoll_wait适用于连接数量多,但活动连接较少的情况。

ET模式和LT模式

epoll对文件描述符的操作有两种模式:LT(Level Trigger,电平触发)模式和ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符,ET模式是epoll的高效工作模式。

如何理解LT和ET

假设有个快递员叫做小王和小李,他负责校内的快递派发工作:

  1. 小王:小王给小张同学派发10个包裹,小王打电话让小张下来取,小张游戏开了,下楼拿了4个之后又上去继续打游戏,此时还没取完,小王就继续打电话,直到小张取完为止。

这个过程中,小王给小张打电话本质是对就绪事件的通知,他的策略是只要我的快递车里有你的快递,就要一直进行通知,直到你取走,这种默认其实就是LT 模式,水平触发模式的特点是只要底层有数据,就一直通知,直到你取完为止

  1. 小李:给小张派发快递时,只打一次电话让他下来取,不管取没取完,或者小王来不来取,通知一次就直接走了。

小李的派发策略是当新数据到来时,只通知一次,这里的新数据到了指的是从无都有和从有到多,底层数据增多时只通知一次 ,这种模式我们叫ET模式,即边缘触发模式。

在上面的例子中,小张相当于是用户层,取快递相当于是调用接口recv,快递员的快递车相当于是某个套接字的接收缓冲区,小王和小李相当于是epoll模型,小王是LT模式,小李是ET模式,打电话相当于是事件通知,之前select时候,我们发现新链接读事件就绪,不处理新链接就会一直通知,这是因为,poll、select的默认工作模式就是LT

Q:LT是怎么做到的/原理是什么

一旦有数据就绪,节点就会被激活,一直在就绪队列中,除非你把数据取完,否则该节点一直在就绪队列中,epoll_wait检测队列发现该节点一直存在,也就一直通知。

Q:ET是怎么做到的/原理是什么

当数据到来时,节点被激活,放到就绪队列中,用户只要调用epoll_wait,取走就绪节点,该节点里面从就绪队列中移除,除非又有数据从网络传递到我的接收缓冲区中,否则不再通知,即使数据没有取完。

Q:ET vs LT,谁更高效

普遍共识下,认为ET模式更高效,因为ET不做重复通知,一旦通知,就必须把数据取走,这个机制倒逼程序员在ET模式下必须把数据取走,这也意味着将来在底层可以给对方通知一个更大的接收窗口,这样就能提供对方的滑动窗口概率,发送效率整体提供,本质是提高了IO带宽,提高IO效率。

但是这样做也是有代价的,在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理,,不让这个就绪被重复提示的话,,其实性能也是一样的, 另一方面,**ET 的代码复杂程度更高了,**因为一旦读取数据,就必须把本次数据取完,这就要求必须循环读取,因为如果是阻塞读取的话,read可能被信号打断,不能保证一次就把所有数据都读取出来,此时服务器没有读取到完整数据,就不能给客户端应答,客户端也就不能发送下一个请求使epoll_wait返回,服务器就没有得到通知不能去读缓冲区中的剩余数据,从而导致服务器阻塞。那怎么保证循环读取的时候本轮数据取完了?

  1. 实际读取到的数据个数 < 期望的数据个数

  2. 阻塞住了就说明读取完了(但是不敢阻塞这会导致其他链接饿死,必须保证在ET模式下将fd读取的时候设置为非阻塞,直到返回EAGAIN,代表本轮数据读取完毕)

Q:为什么ET模式下要求所有fd,都必须是非阻塞的,而LT没有这个要求

ET模式只通知一次要求程序员必须每次把对应fd上数据取完,这就需要循环取,那就要求非阻塞等,不然可能导致服务器阻塞

Q:LT模式,为什么没有说明这些呢?

既能阻塞也能非阻塞,LT模式一旦数据就绪可以直接读,但只建议读一次,因为不怕,哪怕只读一次能保证这次不会被阻塞,下一次还有就绪还会通知我

Q:LT+非阻塞+循环读取不就相当于是ET,那为什么ET更高效

此时效率上确实不会存在本质区别,但是ET是有强制性要求的,LT模式的非阻塞+循环是程序员自己驱动的,不是每个程序员都是可以这样编码的,LT是满足基本使用的,LT模式常见应用场景就是基于阻塞+非循环读取,ET是满足高吞吐量设计的,未来根据应用场景选择LT还是ET,如果对IO要求比较高一般选择ET,但是编码复杂度高,而LT模式设计简单,主要是为了读取之后尽快处理,可以大部分时间边读变处理,即使一次没有读到完整请求。

epoll使用场景

epoll 的高性能,是有一定的特定场景的。如果场景选择的不适宜,epoll 的性能可能适得其反。对于多连接,且多连接中只有一部分连接比较活跃时,比较适合使用 epoll。这种场景把所有fd聚合起来,epoll既帮我们监听所有fd,又处理活跃的链接,用最少的进程线程资源处理,这就是多路转接

例如,典型的一个需要处理上万个客户端的服务器, 例如各种互联网 APP 的入口服务器,这样的服务器就很适合 epoll。

如果只是系统内部,服务器和服务器之间进行通信, 只有少数的几个连接,这种情况下用epoll 就并不合适,具体要根据需求和场景特点来决定使用哪种IO模型

相关推荐
vin_zheng几秒前
破解企业安全软件网络拦截实战记录
运维
林姜泽樾1 小时前
Linux入门第十二章,创建用户、用户组、主组附加组等相关知识详解
linux·运维·服务器·centos
xiaokangzhe2 小时前
Linux系统安全
linux·运维·系统安全
feng一样的男子2 小时前
NFS 扩展属性 (xattr) 提示操作不支持解决方案
linux·go
南棱笑笑生2 小时前
20260310在瑞芯微原厂RK3576的Android14查看系统休眠时间
服务器·网络·数据库·rockchip
xiaokangzhe2 小时前
Nginx核心功能
运维·nginx
松果1772 小时前
以本地时钟为源的时间服务器
运维·chrony·时间服务器
yy55272 小时前
LNAMP 网络架构与部署
网络·架构
Highcharts.js3 小时前
Highcharts React v4.2.1 正式发布:更自然的React开发体验,更清晰的数据处理
linux·运维·javascript·ubuntu·react.js·数据可视化·highcharts
ayaya_mana3 小时前
快速安装Nginx-UI:让Nginx管理可视化的高效方案
运维·nginx·ui