Linux高级IO

在平时read/recv 时,如果内核缓冲区有数据,会拷贝到应用层缓冲区并返回,但如果没有数据,默认会阻塞,直到有数据时再读上来

write时也是一样,只不过内核缓冲区很少会被写满

因此,IO不仅涉及拷贝,更重要的是等待过程IO的本质为 等 + 拷贝,要想高效的IO,只要减少【等】的比重即可

五种IO模型

**阻塞IO:**默认的IO类型,也是最常见的IO模型

在内核将数据准备好之前,系统调用会一直等待,所有的套接字(文件描述符)默认都是阻塞方式

非阻塞IO: 如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK/EAGAIN 错误码

当采用非阻塞IO,一般会用轮询的方式反复调用读取/写入接口

信号驱动IO: 内核将数据准备好的时候,使用 SIGIO信号通知应用程序进行IO操作

其余时间应用程序完全不管它

IO多路转接/复用: 能够同时等待多个文件描述符的就绪状态,就像多进程/线程同时等待。但多进程/线程的成本太高,而多路转接成本很低

由于多路转接原理是可以同时等待多个文件描述符,就注定了之前的那些其他IO类接口不能直接使用,因此操作系统为了满足我们同时等待多个文件描述符的需求,必须单独设计其他的系统调用,例如select/poll/epoll

select接口只负责IO操作中【等】的部分,当等到数据后,会交给例如recvfrom进行数据拷贝工作

异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)

进程发起读请求后立即返回 ,继续执行其他任务,内核自动完成数据拷贝 ,完成后通知进程,不参与进程参与数据拷贝过程

同步/异步通信?

虽然和线程/进程同步/异步一样都用的同步/异步这个词,但两者完全没关系。

线程/进程同步指的是协调任务执行顺序 ,例如加锁保护临界区 ,而线程/进程异步为多个任务并发执行

而判断是同步通信还是异步通信的核心依据是:数据从内核缓冲区复制到用户空间缓冲区时,应用程序的线程是否亲自参与了等待或执行 。同步通信例如阻塞IO、非阻塞IO、多路转接IO、信号驱动IO,都参与了数据拷贝工作;异步IO当发送方发出请求/消息后,无需等待结果即可立即去处理其他事务,直到接收方数据拷贝工作完成后再通知发送方

非阻塞IO

要想在IO时为非阻塞,可以将例如recv/recvfrom/send 接口的flags 参数设为 MSG_DONTWAIT ,或在打开文件时就将open接口的flags参数加上O_NONBLOCK

cpp 复制代码
ssize_t ret = recv(sockfd, buf, len, MSG_DONTWAIT);
int fd = open("/dev/mydevice", O_RDONLY | O_NONBLOCK);

这只能暂时设为非阻塞,要想将一个文件描述符永久设为非阻塞,可以用fcntl系统调用

cpp 复制代码
int fcntl(int fd, int cmd, ... /* arg */ );

fd为要操作的文件描述符,cmd为要执行的操作,后面为可变参数

当cmd为F_GETFL 时,代表获取fd的访问模式与状态标志

当cmd为F_SETFL 时,代表修改fd的状态标志位 ,若要设为非阻塞,就设为O_NONBLOCK ,但这样会覆盖 掉fd原来的状态标志,因此要先获取原状态标志,再按位或O_NONBLOCK代表加上该状态标志

cpp 复制代码
bool SetNonBlock(int fd) //为fd文件描述符设置非阻塞
{
    int fl = fcntl(fd, F_GETFL); // 获取原状态标志
    if(fl < 0) // 获取失败
    {
        std::cerr << "fcntl(F_GETFL): " << strerror(errno) << std::endl;
        return false;
    }
    // 给fd的状态标志加进非阻塞
    if(fcntl(fd, F_SETFL, fl | O_NONBLOCK) < 0) // 若设置失败
    {
        std::cerr << "fcntl(F_SETFL): " << strerror(errno) << std::endl;
        return false;
    }
    return true;
}

非阻塞IO一般和轮询一起使用,通过循环调用read/write等系统调用,不断询问内核数据是否就绪。

拿read举例,读取成功时返回读取的字节数(>0),若读取到文件结尾返回0,若读取失败返回-1,并且errno被设置

但是非阻塞情况下的read,如果没检测到数据,返回后,它的返回值也是-1,虽然不是错误,但以错误的形式被返回了,它的errno会被设置为11,代表资源暂时不可用,意思就是资源还没就绪

cpp 复制代码
errno: 11,Resource temporarily unavailable

因此对于返回值为-1的情况需要先判断是否为非阻塞正常返回,虽然直接判断errno是否为11也可以,但Linux提供了对应的宏,可以不用硬编码:EAGAIN / EWOULDBLOCK,这两个宏的值在Linux中完全一致:

cpp 复制代码
// <errno.h>
#define EAGAIN          11
#define EWOULDBLOCK     EAGAIN    // 直接别名

除此之外,EINTR 错误代表被信号打断 ,并不清楚数据到底有没有,因此需要重来

完整代码:

cpp 复制代码
#include "util.hpp"
#include <unistd.h>
using namespace std;

int main()
{
    SetNonBlock(0);
    char buffer[1024] = {0};
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if (s > 0) // 读到数据
        {
            buffer[s - 1] = 0; // 把换行符去掉
            printf("echo# %s\n", buffer);
        }
        else if (s == 0) // 读到文件结尾,Linux下是Ctrl+D
        {
            printf("read end\n");
            break;
        }
        else if (s < 0) // 出错
        {
            if (errno == EWOULDBLOCK || errno == EINTR)//EWOULDBLOCK代表只是非阻塞的正常返回,EINTR代表被信号打断
            {
                sleep(1);
                continue;
            }
            else // 真出错了
            {
                cerr << "errno: " << errno << "," << strerror(errno) << endl;
                break;
            }
        }
        sleep(1);
    }
    return 0;
}

也可以在轮询检测时干点别的事,例如使用回调函数

完整代码:

cpp 复制代码
#include "util.hpp"
#include <unistd.h>
#include <vector>
#include <functional>
using namespace std;

//初始化
#define INIT(v) do{\
    v.push_back(printlog);\
    v.push_back(download);\
    v.push_back(executeSql);\
}while(0)

//遍历任务
#define EXEC_OTHER(cbs) do{\
    for(auto cb : cbs)\
        cb();\
}while(0)

int main()
{
    vector<function<void ()>> cbs; // 任务集合
    INIT(cbs);

    SetNonBlock(0);
    char buffer[1024] = {0};
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if (s > 0) // 读到数据
        {
            buffer[s - 1] = 0; // 把换行符去掉
            printf("echo# %s\n", buffer);
        }
        else if (s == 0) // 读到文件结尾,Linux下是Ctrl+D
        {
            printf("read end\n");
            break;
        }
        else if (s < 0) // 出错
        {
            if (errno == EWOULDBLOCK)//EWOULDBLOCK/EAGAIN代表只是非阻塞的正常返回
            {
                EXEC_OTHER(cbs);
            }
            else if(errno == EINTR) // EINTR代表被信号打断,需要重来一次
            {
                continue;
            }
            else // 真出错了
            {
                cerr << "errno: " << errno << "," << strerror(errno) << endl;
                break;
            }
        }
        sleep(1);
    }
    return 0;
}

ps:printlog、download、executeSql为函数名

多路转接

select

select是多路转接的一种方式,它只负责等待,可以一次等待多个fd,select本身没有数据拷贝的能力,拷贝要例如read/write来完成

cpp 复制代码
int select(int nfds, fd_set *_Nullable restrict readfds,
                  fd_set *_Nullable restrict writefds,
                  fd_set *_Nullable restrict exceptfds,
                  struct timeval *_Nullable restrict timeout);

nfds:select等待的多个文件描述符中最大的一个+1

告诉内核只需要在 0nfds - 1 的范围内检查描述符的状态,避免内核进行不必要的全量检查,提升效率

timeout:输入输出型参数,输入时通过传入timeval结构体决定阻塞的时间,当超过该时间还没有就绪的文件描述符时就超时返回,返回值被置0,例如struct timeval timeout = {5, 0}代表5秒、timeout = {0, 0}代表非阻塞、timeout = nullptr代表阻塞式调用

cpp 复制代码
struct timeval
{
    time_t tv_sec;       /* 秒 */
    suseconds_t tv_usec; /* 微秒 */
};

当select返回时,timeout参数会返回剩余未等待的时间。如果输入时timeout = {5, 0},等待了3秒后有文件描述符就绪返回,还剩余2秒,输出时timeout = {2, 0}

readfds:读位图,输入输出型参数,输入时通过传入fd_set结构体(位图)指针告知select需要关注可读事件的文件描述符集合,若不关心可传nullptr

当select返回时,readfds会返回读就绪的文件描述符集合(改变原位图)

writefds:写位图,输入输出型参数,输入时通过传入fd_set结构体指针告知select需要关注可写事件 的文件描述符集合,若不关心可传nullptr

当select返回时,writefds会返回写就绪的文件描述符集合(改变原位图)

exceptfds:异常位图,输入输出参数,输入时通过传入fd_set结构体指针告知select需要关注带外数据(Out-of-band / OOB,即带外异常)的文件描述符集合,若不关心可传nullptr

当select返回时,exceptfds会返回异常就绪的文件描述符集合(改变原位图)

fd_set:本质上是位图,用bit位1代表被监视

cpp 复制代码
//内核版fd_set
typedef struct
  {
    /* XPG4.2 requires this member name.  Otherwise avoid the name
       from the global namespace.  */
#ifdef __USE_XOPEN
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
    __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
  } fd_set;


// 简化版fd_set
typedef struct {
    unsigned long fds_bits[1024 / (8 * sizeof(long))];  // 1024 位
} fd_set;

由于直接操作位图非常繁琐且不安全,系统提供了 4 个宏 来操作fd_set

cpp 复制代码
FD_ZERO(fd_set *set)          // 清空集合(将所有 bit 位置 0)
FD_SET(int fd, fd_set *set)   // 将指定的 fd 加入集合(位置 1)
FD_CLR(int fd, fd_set *set)   // 将指定的 fd 从集合中移除(位置 0)
FD_ISSET(int fd, fd_set *set) // 检查 fd 是否在集合中(常用于 select 返回后判断就绪状态)

当因为有就绪文件描述符而返回时,select的返回值为就绪文件描述符总数(readfds.size+writefds.size+exceptfds.size);当调用失败时返回值被置-1,且errno被设置

select多路转接服务器

cpp 复制代码
// socket.hpp

#pragma once
#include <iostream>
#include <string>

#include <cstring>
#include <cerrno>

#include <sys/socket.h>
#include <arpa/inet.h>

#include "log.hpp"

const static int backlog = 32;

class Socket
{
public:
    static int create_socket() // 创建套接字
    {

        int sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd == -1)
        {
            LogMessage(FATAL, (char *)"socket创建监听套接字失败, 错误码: %d, 错误描述:%s", errno, strerror(errno));
            exit(SOCKET_ERR);
        }
        LogMessage(DEBUG, (char *)"socket创建监听套接字成功");

        // 设置端口复用
        int opt = 1;
        setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

        return sockfd;
    }

    static void bind_socket(int sockfd, uint16_t port) // bind绑定自己的网络信息
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));

        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if (bind(sockfd, (struct sockaddr *)&local, sizeof(local)) != 0)
        {
            LogMessage(FATAL, (char *)"bind绑定失败, 错误码: %d, 错误描述:%s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        LogMessage(DEBUG, (char *)"bind绑定成功");
    }

    static void listen_socket(int sockfd) // 开启监听状态
    {
        if (listen(sockfd, backlog) != 0)
        {
            LogMessage(FATAL, (char *)"listen监听状态开启失败, 错误码: %d, 错误描述:%s", errno, strerror(errno));
            exit(LISTEN_ERR);
        }
        LogMessage(DEBUG, (char *)"listen监听状态开启成功");
    }

    static int accpet_socket(int listenfd, std::string &clientIp, uint16_t &clientPort) // 当接收到新连接时,通过输出型参数返回客户端ip+port
    {
        struct sockaddr_in ClientAddr;
        memset(&ClientAddr, 0, sizeof(ClientAddr));
        socklen_t socklen = sizeof(ClientAddr);

        int sockfd = accept(listenfd, (struct sockaddr *)&ClientAddr, &socklen);
        if (sockfd == -1)
        {
            LogMessage(WARNING, (char *)"accept建立新连接失败, 错误码: %d, 错误描述:%s", errno, strerror(errno));
            // exit(ACCEPT_ERR);
        }
        else
        {
            LogMessage(DEBUG, (char *)"accept建立新连接成功,sockfd = %d", sockfd);
            // 将IP传给输出参数clientIp
            char ip_str[16] = {0};
            if (inet_ntop(AF_INET, &(ClientAddr.sin_addr.s_addr), ip_str, sizeof(ip_str)) == nullptr)
            {
                LogMessage(FATAL, (char *)"inet_ntop()失败,错误码:%d,错误信息:%s", errno, strerror(errno));
                exit(INETPN_ERR);
            }
            else
            {
                clientIp = ip_str;
            }
            // 将port传给输出参数clientPort
            clientPort = ntohs(ClientAddr.sin_port);
        }
        return sockfd;
    }
};

// SelectServer.hpp:

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include "socket.hpp"

namespace select_ns
{
    static const uint16_t defaultport = 8080;    // 默认端口
    static const int fdnum = sizeof(fd_set) * 8; // fd_set可以存储的文件描述符数量
    static const int defaultfd = -1;             // 非法文件描述符的标志

    using func_t = std::function<std::string(const std::string &)>;

    class SelectServer
    {
    public:
        SelectServer(func_t func, int port = defaultport) : _func(func), _port(port), _listensockfd(-1)
        {
        }

        void init()
        {
            _listensockfd = Socket::create_socket();
            Socket::bind_socket(_listensockfd, _port);
            Socket::listen_socket(_listensockfd);

            // 初始化_fdarray
            _fdarray = new int[fdnum];
            for (int i = 0; i < fdnum; i++)
                _fdarray[i] = defaultfd;
            _fdarray[0] = _listensockfd; // 先把listensock放进去
        }

        void Accepter()
        {
            // accept接收新连接
            std::string ClientIp;
            uint16_t ClientPort;
            int sockfd = Socket::accpet_socket(_listensockfd, ClientIp, ClientPort);
            if (sockfd < 0)
                return; // accept失败

            // 更新读位图
            int i;
            for (i = 0; i < fdnum; i++)
            {
                if (_fdarray[i] != defaultfd)
                    continue; // 跳过合法fd
                break;
            }
            if (i == fdnum) // 读位图已满
            {
                LogMessage(WARNING, (char *)"读位图被占满");
                close(sockfd);
            }
            else // 将新获取的sockfd添加到文件描述符集合_fdarray中
            {
                _fdarray[i] = sockfd;
            }
        }

        void Recver(int sockfd, int pos /*在_fdarray中的位置*/)
        {
            // 读取
            char request[1024];
            ssize_t s = recv(sockfd, request, sizeof(request) - 1, 0); // 不考虑读不完的情况
            if (s > 0)                                                 // 读到数据
            {
                request[s] = 0;
                std::cout << "client# " << request << std::endl;
            }
            else if (s == 0) // 文件被关闭
            {
                close(sockfd);
                _fdarray[pos] = defaultfd;
                LogMessage(DEBUG, (char *)"客户端退出");
            }
            else // recv报错
            {
                close(sockfd);
                _fdarray[pos] = defaultfd;
                LogMessage(ERROR, (char *)"客户端退出,错误码:%d, 错误描述:%s", errno, strerror(errno));
            }

            // 请求转响应
            std::string response = _func(request);
            // 写入
            write(sockfd, response.c_str(), response.size());
        }

        void HandleEvent(fd_set &fds)
        {
            for (int i = 0; i < fdnum; i++)
            {
                if (_fdarray[i] == defaultfd)
                    continue; // 过滤非法fd

                if ((_fdarray[i] == _listensockfd) && (FD_ISSET(_fdarray[i], &fds))) // accept就绪
                    Accepter();
                else if (FD_ISSET(_fdarray[i], &fds)) // 普通fd读就绪
                    Recver(_fdarray[i], i);
            }

            // Print
            std::cout << "_fdarray: ";
            for (int i = 0; i < fdnum; i++)
            {
                if (_fdarray[i] == defaultfd)
                    continue;
                std::cout << _fdarray[i] << " ";
            }
            fflush(stdout);
        }

        void start()
        {

            while (true)
            {
                // std::string ClientIp;
                // uint16_t ClientPort;
                // int sock = Socket::accpet_socket(_listensockfd, ClientIp, ClientPort);
                // if(sock < 0) continue;

                // 初始化rfds与maxfd
                fd_set rfds;
                FD_ZERO(&rfds); // 初始化
                int maxfd = _fdarray[0];
                for (int i = 0; i < fdnum; i++)
                {
                    if (_fdarray[i] == defaultfd)
                        continue; // 非法fd跳过

                    FD_SET(_fdarray[i], &rfds); // 将合法fd加进读位图
                    if (maxfd < _fdarray[i])
                        maxfd = _fdarray[i]; // 更新maxfd
                }

                // int n = select(_listensockfd + 1, &rfds, nullptr, nullptr, &timeout);
                int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
                switch (n)
                {
                case 0: // 超时返回
                    std::cout << "timeout...\n";
                    break;
                case -1: // 错误返回
                    LogMessage(WARNING, (char *)"select失败,错误码:%d,错误描述:%s", errno, strerror(errno));
                    break;
                default: // 就绪返回
                    LogMessage(DEBUG, (char *)"读就绪");
                    HandleEvent(rfds);
                    break;
                }
            }
        }

        ~SelectServer()
        {
            if (_listensockfd == -1)
                close(_listensockfd);

            if (_fdarray)
                delete[] _fdarray;
        }

    private:
        uint16_t _port;    // 服务端口号
        int _listensockfd; // 监听套接字
        int *_fdarray;     // 文件描述符集合
        func_t _func;      // 用于将请求转响应的函数
    };

}

// SelectServer.cpp:

#include <iostream>
#include <memory>
#include <string>

#include "SelectServer.hpp"
#include "log.hpp"

using namespace std;
using namespace select_ns;

static void usage(string proc) // 使用手册
{
    cout << RED << "\nUsage:\n\t" << proc << " " << "port\n\n " << ED;
}

string translation(const string &request) // [TODO] 请求转响应
{
    return request;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        usage(argv[0]);
        exit(USAGE_ERR);
    }
    uint16_t port = atoi(argv[1]);

    unique_ptr<SelectServer> svr(new SelectServer(translation, port));
    svr->init();
    svr->start();

    return 0;
}

select 缺点:

  1. select能同时等待的文件fd是有上限的,除非重新改内核,否则无法解决

  2. 必须借助第三方数组,来维护合法的fd集合

  3. select的大部分参数是输入输出型的,调用select前,要重新设置所有的fd,调用之后,还需要检查更新所有的fd,遍历的成本很大

  4. select 采用位图,输入:用户告诉内核,输出:内核告诉用户,来回的进行数据拷贝,拷贝成本大

poll

poll方案解决了select中fd有上限、每次调用都要重新设置所有关心的fd的问题

cpp 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

fds为struct pollfd类型的动态数组,用于存储需要关心的fd及事件

每个元素所包含的信息如下:

cpp 复制代码
struct pollfd
{
    int fd;        /* 文件描述符 */
    short events;  /* 请求的事件 */
    short revents; /* 返回的事件 */
};

fd为文件描述符

events为想要关心 的事件,revents为返回时就绪的事件,可以填的值如下:

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

常用的为POLLIN(读)、POLLOUT(写)、POLLERR(错误)

每个宏都占用一个比特位,因此可以通过按位操作包含多个事件

fds通过将用户告诉内核与内核告诉用户的事件分开,避免了每次都要重新设置fd

nfds为fds的长度,也就是要关心的文件描述符的个数

timeout参数单位是ms(毫秒),>0代表阻塞timeout ms,超时则返回;=0代表非阻塞;<0代表阻塞;

有就绪事件就返回每个事件就绪的fd相加的值,若超时返回0,若失败返回-1

当调用poll接口时,用户通过fdsi.events告诉内核需要关心的fd及其事件,当poll返回时,内核通过fdsi.revents告诉用户每个关心的fd关心的事件中已就绪的事件

但poll为了知道哪些fd的哪些事件就绪,需要遍历struct pollfd数组 ,当fd多了后会影响效率

epoll

epoll是为了解决poll遍历问题的多路转接接口

epoll_create

cpp 复制代码
int epoll_create(int size);
int epoll_create1(int flags);

创建一个epoll模型,size 参数为预期要管理的fd个数,现已被忽略,只需要保证>0 即可

返回值为epoll模型的fd

现代写法推荐用epoll_create1 ,flags填0和epoll_create(1)代表的意思一样

epoll_create1(EPOLL_CLOEXEC) 代表防止 epoll 文件描述符泄露给子进程(例如fork()exec()

epoll_ctl

cpp 复制代码
int epoll_ctl(int epfd, int op, int fd,
              struct epoll_event *_Nullable event);

控制指定epoll模型中需要关心的fd及其事件

  • epfd:通过epoll_create创建的epoll模型
  • op:有三种选项:EPOLL_CTL_ADD (增)、EPOLL_CTL_DEL (删)、 EPOLL_CTL_MOD(改),代表要增/删/改指定fd及其事件(若为删,event参数被忽略,直接删除fd)
  • fd:要操作的目标文件描述符
  • event:描述感兴趣的事件和携带的用户数据

其中events参数为关心的事件类型,值如下:

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

data为用户数据:

里面的fd成员用于表明该事件所属的文件描述符,epoll_wait拿取事件时只有struct epoll_event结构体,只能通过.data.fd确定文件描述符

成功返回0,失败返回-1

epoll_wait

cpp 复制代码
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

相当于pool()或select(),用于等待文件描述符就绪

  • epfd:通过epoll_create创建的epoll模型
  • events:用于接收就绪事件,输出型参数
  • maxevents:用于表示events的数组容量,即一次最多返回多少个事件,必须 > 0
  • timeout:语义与poll()时一致

有就绪事件就返回就绪事件的个数,若超时返回0,若失败返回-1

epoll模型

epoll_create会创建一个epoll模型,它主要有两个核心数据结构:红黑树和就绪链表(双向)

当调用epoll_ctl接口时,会对红黑树进行增删改操作每个节点对应一个需要关心的fd及其事件 ,并注册当该fd有数据时回调的接口ep_poll_callback ,用于将事件挂载到就绪队列
该红黑树的key为对应fd+struct file*指针组成

这其中epitem.event就是epoll_ctl时传入的event结构体,里面包含了events(事件)和data(用户数据,包含fd、指针等 )

当底层的网卡驱动接收到数据后,传给协议栈解析并放在Socket的接收缓冲区中后,就会调用属于该fd的回调方法,而由于该fd的回调方法是 ep_poll_callback , 就会将epoll模型中红黑树的对应节点挂载到就绪队列(将epitem.rdlink插入到就绪双向链表的末尾)

当调用epoll_wait()时,就会从就绪队列中拿取事件

epitem结构:

cpp 复制代码
struct epitem {
    /* 红黑树节点:用于将当前 epitem 挂载到 epoll 实例的红黑树中 */
    struct rb_node rbn;

    /* 双向链表节点:用于将当前 epitem 挂载到就绪队列 (rdllist) 或 overflow 队列中 */
    struct list_head rdlink;

    /* 指向当前 epitem 所属的 epoll 实例 (struct eventpoll) */
    struct eventpoll *ep;

    /* 包含用户态传入的 fd 和 event(用户感兴趣的事件掩码及用户自定义数据 epoll_data) */
    struct epoll_filefd ffd;

    /* 用户注册的关注事件掩码(如 EPOLLIN | EPOLLOUT | EPOLLET) */
    struct epoll_event event;

    /* 等待队列头:用于管理当前 epitem 挂载在底层 Socket (sk_sleep) 上的等待项 */
    struct eppoll_entry *pwqlist;

    /* 指向该 fd 在 VFS 层的内核文件结构体 struct file */
    struct file *ffd_file;

    // ... 其他内核同步/统计相关的内部字段
};

因此epoll并不需要遍历数据结构,直接用**O(1)**的复杂度就可以知道哪些fd就绪

epoll_create返回的是epol模型抽象成文件的fd

水平触发(LT)/边缘触发(ET)

epoll_wait 将所有就绪的事件,按照顺序放到用户传入的数组的中,如果就绪队列很多数据节点,一次拿不完,下次epoll_wait 时默认会继续通知,也就是水平触发(LT)

水平触发:只要状态"处于就绪状态"(例如缓冲区还有数据未读完、或者就绪队列里还有未处理完的事件),epoll_wait 就会不断通知

但如果将对应epitem节点改为边缘触发(events),对于 ET 模式 的节点,内核在将事件拷贝给用户态数组后,直接从就绪链表(rdllist)中移除该节点 ,不再放回。下一次调用 epoll_wait 时,即使底层缓冲区还有未读完的数据/就绪队列之前没处理完,epoll_wait 也不会通知你 ,它会一直阻塞等待,直到这个 fd再次有新的数据到达 (发生新的状态边沿变化)。

边缘触发:只有当状态"发生变化/出现新边缘"(例如数据从无到有、从不可写变为可写)时,epoll_wait 才会通知一次

要对某个fd设置边缘触发,就将events |= EPOLLET

cpp 复制代码
struct epoll_event ev;

// 设置为 读事件(EPOLLIN) + 边缘触发(EPOLLET)
ev.events = EPOLLIN | EPOLLET; 
ev.data.fd = sockfd;

// 将该 fd 注册到 epoll 实例中
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
ET模式的优点

虽然在LT模式下也可以通过尽快将数据读上来而达到和ET一样的效果,但ET模式下强制约束了代码要高效的IO数据 ,否则就跑不对,适用于高并发场景

在ET模式下,为防止服务器因fd阻塞而挂起,必须要将fd设为非阻塞 。由于直到read/recv返回EWOULDBLOCK/EAGAIN 时才可以确认数据读完,但在阻塞模式下会导致整个事件循环卡死(因为只会通知一次)。但只要是非阻塞,可以一直读取,直到返回EWOULDBLOCK/EAGAIN

并且由于ET模式下就算有数据没有读完,也不会一直通知,减少了重复通知,可以将这个名额用到其他新事件上(因为epoll_wait()传入的事件数组是有大小限制的!)

由于ET模式的机制,应用层会及时读取,那么就会给TCP接收缓冲区腾出更大的空间 ,从而让TCP可以通告更大的窗口大小 ,发送方就可以一次发出更多数据

bash 复制代码
应用层及时 read()
  → 内核接收缓冲区 (sk_rmem) 腾出空间
    → TCP 通告更大的 Window Size
      → 发送方可以一次发出更多数据
        → 吞吐量提升

Reactor

Reactor模式(反应堆模式),半同步(IO同步)半异步(事件分发异步),通过让主线程监听分线程负责执行IO和业务。与之对应的还有Proactor模式,这里不做介绍

Reactor实现代码已放出,下面主要讲设计中的一些问题(只实现了单线程Reactor)

为什么要用Connection来管理一个fd,每个fd都有自己的IO缓冲区?

ET模式下的epoll,由于阻塞模式下可能会出现一次读取读不完的情况 (例如被信号中断、应用层缓冲区过小等原因),因此需要设置非阻塞。

但如果其中一个fd的报文只来了一半,多个连接的数据是交错到达 的,共用缓冲区会互相覆盖。每个fd 一个Connection,本质上是给每个连接一个独立的"记忆",让它记住自己读到哪了、发到哪了、状态是什么。

写事件也是一样,若一个报文只发送了一半,而此时返回了 EWOULDBLOCK ,就要将数据存到写就绪时再发送。但等到写就绪时,不一定是这个fd先执行,如果共用一个缓冲区,就会被覆盖掉

一个 fd 对应一个 Connection,是为了给每个网络连接建立独立的上下文环境。由于 TCP 数据可能分批到达、非阻塞 IO 可能部分读写、多个连接事件交错发生,因此每个 fd 必须独立保存自己的输入输出缓冲区和状态,否则数据会互相覆盖,连接状态会混乱。

epoll中对于IO就绪事件的不同设计

对于读取事件,会将EPOLLIN常驻在epoll 中,只要有数据到来就通知

对于写入事件,会先尝试直接写入 ,如果没有写完或返回了EWOULDBLOCK(因为是非阻塞),再让epoll关注写就绪事件(将EPOLLOUT添加到epoll),当底层的输出缓冲区空出来,就会通知。并且在确定写完数据后再将EPOLLOUT从epoll中移除!

这种读取与写入的不同处理,是因为大部分情况下,写总是就绪的,如果epoll一直关注写就绪,可能我们还没有想写入的数据,但epoll一直在通知写就绪...

相关推荐
꯭自꯭闭꯭1 小时前
达梦(DM8)安装测试
linux·运维·服务器·数据库
牢姐与蒯1 小时前
Linux进程(七).进程控制
linux·运维·服务器·ubuntu
Mr. zhihao1 小时前
Linux 内存惰性分配:从虚拟地址到物理页的深度解析
linux·服务器·内存分配
努力努力再努力wz1 小时前
【Docker入门系列】:从架构演进到容器化:一文建立 Docker、虚拟化与 Namespace 的底层心智模型
运维·开发语言·数据结构·c++·docker·容器·架构
清风玉骨1 小时前
zero3W的镜像制作和镜像烧录
linux
光电笑映2 小时前
Linux 线程编程:从进程、分页到线程控制与封装
linux·运维·服务器·c++
wzg20162 小时前
本地部署Deepseek-coder + Vscode + Continue 插件(3)启动与关闭deepseek-coder大模型
linux·运维·服务器
凯哥Java2 小时前
接口采集工具自动化验证文章(测试发布,可删除)
运维·自动化
H_oRIZoN_2 小时前
Linux入门DAY37(IO 多路复用、TCP 并发服务器与数据库 SQLite3 详解)
linux·服务器·数据库