服务器IO模型

208 阅读17分钟

网络IO的本质是socket的读取,socket是在Linux系统被抽象为流,IO可以理解为对流的操作。对于一次IO访问(以read为例),数据会先拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备
  2. 将数据从内核拷贝到进程中

对于socket流而言,第一步通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区,第二步把数据从内核缓冲区复制到应用程序缓冲区

I/O模型

linux下有五种网络IO模型,BIO 同步阻塞式 IO;NIO 同步非阻塞式 IO;多路复用IO,也就是常说的select,poll和epoll;信号驱动式I/O;AIO 异步非阻塞式IO

  • 阻塞:若读写未完成,调用读写的线程一直等待
  • 非阻塞:若读写未完成,调用读写的线程不用等待,可以处理其他工作
  • 异步:读写过程完全托管给操作系统完成,操作系统完成后通知调用读写的线程
  • 同步:读写过程由本线程完成,期间可以处理其他工作,但要轮询读写是否完毕

BIO

最简单的IO模型,开一个线程池,每来一个请求连接分配一个线程进行处理,BIO 虽然可以使用线程池+等待队列进行优化,避免使用过多的线程,但是依然无法解决线程利用率低的问题

socket在创建的时候默认是阻塞的,在BIO模型中,内核依然经历两阶段:第一阶段准备数据(对于网络IO来说,很多时候数据一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边整个进程就会被阻塞(这个阻塞是进程自己选择的阻塞)。第二阶段:当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才会解除block的状态,重新运行起来。

优点:能够及时返回数据,没有延迟,对内核开发者来说这是省事的

缺点:对于用户来说处于等待状态需要付出性能代价

NIO

同步非阻塞采用轮询(pollong)的方式。在这个模型中,设备是以非阻塞的形式打开的。这就说明IO操作不会立即完成,read操作可能会返回一个错误代码,说明这个命令不能立即满足(EAGAIN 再试一次或EWOULDBLOCK 期待阻塞)

针对非阻塞I/O执行的系统调用则总是立即返回,而不管事件是否已经发生,非阻塞I/O通常要和其他I/O通知机制一起使用,如I/O复用和SIGIO信号,也就是IO多路复用预计信号驱动式IO

NIO轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。需要注意的是,拷贝数据整个过程,进程仍然是属于阻塞的状态,当用户进程发出read操作时,然后kernel中的数据还没有准备好,那么它并不会block用户进程,而是立即返回一个error。从用户进程的角度讲,它发起一个read操作后,并不需要等待,而是马上就得到一个结果。用户进程判断结果是一个error时,它就知道数据还没准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就会将数据拷贝到了用户内存,然后返回。用户进程需要不断的主动询问kernel数据好了没有。

优点:同步非阻塞方式是能够在等待任务完成的时间里做其他事情(包括提交其他任务,后台可以有多个任务在同时执行)

缺点:同步非阻塞方式任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低

IO多路复用

由于同步非阻塞方式需要不断主动轮询,轮询占据了很大一部分过程,轮询会消耗大量的CPU时间,而后台可能有多个任务在同时进行,人们就想到了循环查询多个任务的完成状态,只要有任何一个任务完成,就去处理它。IO多路复用就是只让内核去轮询多个任务完成状态,IO多路复用采用一个线程或一个进程,去处理多个IO请求

应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。Linux上常用的I/O复用函数是select、poll和epoll_wait,I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个I/O事件的能力

以select举例,select(只支持1024个文件)调用是内核级别的,select轮询相对非阻塞的轮询的区别在于前者可以等待多个socket,能实现同时对多个IO端口进行监听,当其中任何一个socket的数据准备好了,就能返回进行可读,然后进程再进行。

IO复用

I/O复用使得程序能同时监听多个文件描述符,这对提高程序的性能至关重要,I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。当多个文件描述符同时就绪时,如果不采用多线程或多进程,程序就只能按顺序依次处理其中的每一个文件描述符,Linux下实现I/O复用的系统调用主要有select、poll和epoll

select

select函数仅仅知道有几个I/O事件发生了,但并不知道具体是哪几个socket连接有I/O事件,还需要轮询去查找,时间复杂度为O(n),处理的请求数越多,所消耗的时间越长

select函数执行流程:

  • 从用户空间拷贝fd_set(注册的事件集合)到内核空间
  • 遍历所有fd文件,并将当前进程挂到每个fd的等待队列中,当某个fd文件设备收到消息后,会唤醒设备等待队列上睡眠的进程,那么当前进程就会被唤醒
  • 如果遍历完所有的fd没有I/O事件,则当前进程进入睡眠,当有某个fd文件有I/O事件或当前进程睡眠超时后,当前进程重新唤醒再次遍历所有fd文件
#include<sys/select.h>
int select(int nfds,fd_set* readfds,fd_set* writefds,fd_set* exceptfds,struct timeval* timeout);
  • nfds:描述符中最大描述符值+1,遍历时取0-max_fd
  • readfdswritefdsexceptfds:分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。fd_set结构体仅包含一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,默认1024
  • timeout:用来设置select函数的超时时间。它是一个timeval结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。不过不能完全信任select调用返回后的timeout值,如调用失败时timeout值是不确定的,如果给timeout变量的tv_sec成员和tv_usec成员都传递0,则select将立即返回,如果设置为NULL,则select一直阻塞直到某个文件描述符就绪

select成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select将返回0。select失败时返回-1并设置errno。如果在select等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR

使用定义好的宏来判断注册的读写异常事件是否准备就绪

#include<sys/select.hFD_ZERO(int fd, fd_set* fds)   // 清空集合
FD_SET(int fd, fd_set* fds)    // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds)  // 判断指定描述符是否在集合中 
FD_CLR(int fd, fd_set* fds)    // 将给定的描述符从文件中删除  

**select**就是向内核注册读写异常事件,程序记录文件描述符集合(事件集合),内核不断遍历注册的事件,有事件发生时通知程序进行处理,但程序并不知道具体是哪个文件描述符发生事件,因此需要遍历文件描述符集合判断事件是否发生,发生则进行处理

/**
 * @Author: lsl
 * @Date: 2022-10-31 21:20:14
 * @Description: linux 网络编程 IO模型,IO复用 select示例
 * @Modified By: lsl
 */
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

#include <vector>
#include <algorithm>

#include "spdlog/spdlog.h"
#include "spdlog/sinks/stdout_color_sinks.h"

class SimpleSelectServer {
private:
    const static int BUF_SIZE = 1024;
    std::shared_ptr<spdlog::logger> logger_;
    int listen_fd_{};
    int port_;
    int conn_max_;
    fd_set read_fd_set_;                // 定义写事件文件描述符集合
    std::vector<int> select_fds_;       // select注册读写事件数组
    std::vector<int> close_select_fds_; // 需要关闭的client连接
    char buffer_[BUF_SIZE]{};

    bool InitSocket_();

public:
    SimpleSelectServer(int port, int conn_max);
    ~SimpleSelectServer();

    void Start();
};
SimpleSelectServer::SimpleSelectServer(int port, int conn_max) :
    port_(port), conn_max_(conn_max) {
    // logger
    logger_ = spdlog::stdout_color_st("PollServerLogger");
}
SimpleSelectServer::~SimpleSelectServer() {
    logger_->info("close server");
    close(listen_fd_);
}

bool SimpleSelectServer::InitSocket_() {
    // create
    listen_fd_ = socket(PF_INET, SOCK_STREAM, 0); // 创建socket,ipv4,字符流(tcp)
    if (listen_fd_ < 0) {
        logger_->error("create socket error!");
        return false;
    }
    // bind
    struct sockaddr_in address {};
    address.sin_family = AF_INET; // ipv4
    address.sin_port = htons(port_);
    address.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
    if (bind(listen_fd_, (struct sockaddr *)&address, sizeof(address)) < 0) {
        logger_->error("bind socket error! port:{}", port_);
        return false;
    }
    // listen
    if (listen(listen_fd_, conn_max_) < 0) {
        logger_->error("listen port error!");
        return false;
    }
    return true;
}

void SimpleSelectServer::Start() {
    if (InitSocket_()) {
        logger_->info("select server running");
        // 处理事件,读写循环
        while (true) {
            FD_ZERO(&read_fd_set_);            // 清空集合
            FD_SET(listen_fd_, &read_fd_set_); // 注册服务器socket事件
            int max_fd = listen_fd_;           // 获取最大文件描述符值,系统会遍历0-max_fd取出事件
            for (auto client_fd : select_fds_) {
                FD_SET(client_fd, &read_fd_set_); // 注册客户端socket事件
                max_fd = std::max(max_fd, client_fd);
            }
            // select复用,在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。阻塞等到事件到来
            int fd_ready = select(max_fd + 1, &read_fd_set_, nullptr, nullptr, nullptr);
            // 有事件到来,不同的事件有不同的处理方式
            switch (fd_ready) {
            case 0:
                spdlog::info("select 无事件,轮询");
                break;
            case -1:
                spdlog::error("select return error");
                break;
            default:
                // 服务器监听到连接事件
                if (FD_ISSET(listen_fd_, &read_fd_set_)) {
                    struct sockaddr_in client; // 客户端tcp连接描述,等待符合格式的连接
                    socklen_t client_addr_len = sizeof(client);
                    int client_fd = accept(listen_fd_, (struct sockaddr *)&client, &client_addr_len);
                    select_fds_.push_back(client_fd);
                    logger_->info("客户端连接成功,conn_fd:{}", client_fd);
                    FD_SET(client_fd, &read_fd_set_); // 注册客户端socket读写事件
                }
                // 遍历监听的文件描述符,判断是否有事件
                for (auto client_fd : select_fds_) {
                    if (FD_ISSET(client_fd, &read_fd_set_)) {
                        int ret = recv(client_fd, buffer_, sizeof(buffer_) - 1, 0); // 读取数据
                        if (ret < 0) {
                            logger_->info("读取普通数据失败,关闭客户端连接,错误码:{}", ret);
                            close(client_fd);
                            close_select_fds_.push_back(client_fd); // 主动关闭客户端连接,返回0,客户端主动断开连接,被动关闭
                        } else if (ret == 0) {
                            logger_->info("客户端关闭连接,client_fd:{}", client_fd);
                            close(client_fd);
                            close_select_fds_.push_back(client_fd); // 主动关闭客户端连接,返回0,客户端主动断开连接,被动关闭
                        } else {
                            logger_->info("读取数据:client_fd:{},data:{}", client_fd, buffer_);
                            bzero(buffer_, sizeof(buffer_)); // 清空buffer_
                        }
                    }
                }
                // 删除断开的客户端
                for (auto client_fd : close_select_fds_) {
                    select_fds_.erase(std::find(select_fds_.begin(), select_fds_.end(), client_fd));
                }
                close_select_fds_.clear();
            }
        }
    }
}

int main(int argc, char *argv[]) {
    SimpleSelectServer server(8999, 10);
    server.Start();
    return 0;
}

poll

poll本质上和select没有区别,它将用户传入的文件描述符数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的

#include <poll.h>
// 数据结构
struct pollfd {
    int fd;        // 需要监视的文件描述符
    short events;  // 需要内核监视的事件
    short revents; // 实际发生的事件,1:表示有事件发生,0:没有事件发生
};

// 阻塞方法
int poll(struct pollfd fds[], // 需要监听的文件描述符列表
         nfds_t nfds,         // 文件描述符个数
         int timeout          // 超时时间
);

poll相比于select进行了两点优化,一是没有最大连接限制,二是不在需要从0遍历文件描述符值,直接遍历文件描述符数组即可,其本质仍然是将事件注册到内核空间,等待内核通知有事件之后遍历处理文件描述符,判断事件是否发生,如果发生事件就进行处理

......
void SimplePollServer::Start() {
    if (InitSocket_()) {
        // poll
        struct pollfd server_poll_fd {};
        server_poll_fd.fd = listen_fd_; // 注册服务器事件监听
        server_poll_fd.events = POLLIN; // 监听读事件
        poll_fds_.push_back(server_poll_fd);
        logger_->info("poll server running");
        // 处理事件,读写循环
        while (true) {
            // poll复用,在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。阻塞等到事件到来
            int fd_ready = poll(&poll_fds_[0], poll_fds_.size(), -1);
            // 有事件到来,不同的事件有不同的处理方式
            switch (fd_ready) {
            case 0:
                logger_->info("poll no things");
                break;
            case -1:
                logger_->error("poll return error");
                break;
            default:
                // 服务器监听到连接事件
                if (poll_fds_[0].revents & POLLIN) {
                    struct sockaddr_in client {};
                    socklen_t client_addr_len = sizeof(client);
                    int client_fd = accept(listen_fd_, (struct sockaddr *)&client, &client_addr_len);
                    logger_->info("client connection,conn_fd:{}", client_fd);
                    struct pollfd client_poll_fd {}; // 注册客户端读事件
                    client_poll_fd.fd = client_fd;
                    client_poll_fd.events = POLLIN;
                    poll_fds_.push_back(client_poll_fd);
                }
                // 遍历监听的文件描述符,判断是否有事件
                for (auto it = poll_fds_.begin() + 1; it != poll_fds_.end(); it++) {
                    if (it->revents & POLLIN) {
                        int ret = recv(it->fd, buffer_, sizeof(buffer_) - 1, 0); // 读取数据
                        if (ret < 0) {
                            logger_->info("读取普通数据失败,关闭客户端连接,错误码:{}", ret);
                            close(it->fd);
                            close_poll_fds_.push_back(it->fd);
                        } else if (ret == 0) {
                            logger_->info("客户端关闭连接,client_fd:{}", it->fd);
                            close(it->fd);
                            close_poll_fds_.push_back(it->fd); // 返回0,客户端主动断开连接,被动关闭
                        } else {
                            logger_->info("读取数据:client_fd:{},data:{}", it->fd, buffer_);
                            bzero(buffer_, sizeof(buffer_)); // 清空buffer
                        }
                    }
                }
            }
            // 删除断开的client fd
            for (auto client_fd : close_poll_fds_) {
                for (auto it = poll_fds_.begin() + 1; it != poll_fds_.end(); it++) {
                    if (client_fd == it->fd) {
                        poll_fds_.erase(it);
                        break;
                    }
                }
            }
        }
    }
}
int main(int argc, char *argv[]) {
    SimplePollServer server(8999, 1024);
    server.Start();
    return 0;
}

epoll

epoll是Linux特有的I/O复用函数。它在实现和使用上与select、poll 有很大差异。首先,epoll使用一组函数来完成任务,而不是单个函数。 其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。

epoll采用的是事件驱动机制,每个fd上有注册有回调函数,当网卡接收到数据时会回调该函数,同时将该fd的引用放入rdlist就绪列表中。当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。

#include <sys/epoll.h>
// 每一个epoll对象都有一个独立的eventpoll结构体用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
};

// API
// 内核中间加一个 ep 对象,把所有需要监听的socket都放到ep对象中
int epoll_create(int size);
// epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_ctl(int epfd,                 // 创建的ep对象
              int op,                   // 操作类型 新增、删除等
              int fd,                   // 要操作的对象
              struct epoll_event *event // 事件
);
// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll执行流程

  • 调用epoll_create()创建一个ep对象,即红黑树的根节点,返回一个文件句柄
  • 调用epoll_ctl()向这个ep对象(红黑树)中添加、删除、修改感兴趣的事件
  • 调用epoll_wait()等待,当有事件发生时网卡驱动会调用fd上注册的函数并将该fd添加到rdlist中,解除阻塞

每个文件描述符上都有一个callback函数,当socket有事件发生时会回调这个函数将该fd的引用添加到就绪列表中,select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多

......
void SimpleEpollServer::Start() {
    if (InitSocket_()) {
        // 向内核注册epoll
        epoll_fd_ = epoll_create(1);
        // epoll 监听连接事件
        struct epoll_event ev {};
        ev.data.fd = listen_fd_;
        ev.events = EPOLLIN;
        // 加入事件
        epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, listen_fd_, &ev);
        // 初始化epoll_events
        epoll_events_ = new epoll_event[conn_max_];
        logger_->info("epoll server running");
        // 处理事件,读写循环
        while (true) {
            // epoll多路复用 -1代表阻塞等待事件到来,内核会把已经就绪的事件副本传递给epoll_events,返回事件发生个数
            int fd_ready = epoll_wait(epoll_fd_, epoll_events_, conn_max_, -1);
            if (fd_ready < 0) {
                if (errno != EINTR) {
                    logger_->error("epoll error,error code:{}!", fd_ready);
                }
            }
            // 只返回已有事件的socket,必有事件
            for (int i = 0; i < fd_ready; i++) {
                // 服务器监听到事件,接受连接
                if (epoll_events_[i].data.fd == listen_fd_) {
                    struct sockaddr_in client;
                    socklen_t client_addrlength = sizeof(client);
                    int client_fd = accept(listen_fd_, (struct sockaddr *)&client, &client_addrlength);
                    // 增加新的监听事件
                    struct epoll_event conn;
                    conn.data.fd = client_fd;
                    conn.events = EPOLLIN;
                    epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, client_fd, &conn);
                    logger_->info("client connection,conn_fd:{}", client_fd);
                }
                // 其余事件
                else if (epoll_events_[i].events & EPOLLIN) {
                    int client_fd = epoll_events_[i].data.fd;
                    int ret = recv(client_fd, buffer_, sizeof(buffer_) - 1, 0);
                    if (ret < 0) {
                        logger_->info("读取普通数据失败,关闭客户端连接,错误码:{}", ret);
                        close(client_fd);
                        epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, client_fd, &epoll_events_[i]); // 删除注册事件
                    } else if (ret == 0) {
                        logger_->info("客户端关闭连接,client_fd:{}", client_fd);
                        close(client_fd);
                        epoll_ctl(epoll_fd_, EPOLL_CTL_DEL, client_fd, &epoll_events_[i]); // 删除注册事件
                    } else {
                        logger_->info("读取数据:client_fd:{},data:{}", client_fd, buffer_);
                        bzero(buffer_, sizeof(buffer_)); // 清空buffer
                    }
                }
            }
        }
    }
}

int main(int argc, char *argv[]) {
    SimpleEpollServer server(8999, 1024);
    server.Start();
    return 0;
}

epoll模型

LT模式与ET模式

针对常用的epoll,有两种事件处理模式:LT与ET

  • ET:边沿触发,一次事件只会触发一次,如一次客户端发来消息fd可读,epoll_wait返回。等下次再调用epoll_wait则不会返回该可读事件,默认该事件已经处理完毕,ET模式必须采用非阻塞模式
  • LT:电平触发,一次事件会触发多次,如一次客户端发消息fd可读,epoll_wait返回,不处理这个fd或事件仍未处理完毕,再次调用epoll_wait立刻返回,epoll会提醒应用程序处理该事件

默认使用LT模式,通过注册事件设置模式

listenEvent_ = EPOLLIN  | EPOLLET;
connEvent_ = EPOLLIN | EPOLLET;

LT的处理过程

  1. accept一个连接,添加到epoll中监听EPOLLIN事件
  2. 当EPOLLIN事件到达时,read fd中的数据并处理
  3. 当需要写出数据时,把数据write到fd中;如果数据较大,无法一次性写出,那么在epoll中监听EPOLLOUT事件
  4. 当EPOLLOUT事件到达时,继续把数据write到fd中;如果数据写出完毕,那么在epoll中关闭EPOLLOUT事件

ET的处理过程:

  1. accept一个一个连接,添加到epoll中
  2. 监听EPOLLIN|EPOLLOUT事件
  3. 当EPOLLIN事件到达时,read fd中的数据并处理,read需要一直读,直到返回EAGAIN为止
  4. 当需要写出数据时,把数据write到fd中,直到数据全部写完,或者write返回EAGAIN 。当EPOLLOUT事件到达时,继续把数据write到fd中,直到数据全部写完,或者write返回EAGAIN

从ET的处理过程中可以看到,ET的要求是需要一直读写,直到返回EAGAIN,否则就会遗漏事件。

关闭事件EPOLLRDHUP

只要注册EPOLLRDHUP事件就可以知道对端socket事件是否关闭,不必通过调用recv系统函数进行判断

if (events[n].events & EPOLLIN) {
  if (events[n].events & EPOLLRDHUP) {
    printf("对端关闭socket\n");
    close(events[n].data.fd);
    continue;
  }

多线程处理事件EPOLLONESHOT

EPOLLONESHOT通常用于多线程处理客户端socket事件,注册EPOLLONESHOT 代表某次循环中epoll_wait唤醒该事件fd后,就会从注册中删除该fd,也就是说以后epollfd的表格中将不会再有这个fd,也就不会出现多个线程同时处理一个fd的情况

listenEvent_ = EPOLLIN | EPOLLRDHUP;
connEvent_ = EPOLLIN | EPOLLONESHOT | EPOLLRDHUP;