IO多路复用原理深度总结【万字总结】

文章目录

  • 前言
  • 一、同步异步与阻塞非阻塞
    • 1、阻塞和非阻塞
    • 2、异步和同步
    • 3、总结
  • 二、IO模型
    • 1、同步阻塞IO
    • 2、同步非阻塞IO
    • 3、异步阻塞IO
    • 4、异步非阻塞IO
  • 三、多路IO复用简介
    • 1、传统的多线程模型的瓶颈
    • 2、IO多路复用
  • 四、select/poll
    • 1、原理
    • 2、缺点与优点
  • 五、epoll
    • 1、原理
    • 3、优缺点
    • 3、两种模式
  • 总结


前言

一、同步异步与阻塞非阻塞

1、阻塞和非阻塞

阻塞(Blocking):在阻塞操作中,如果数据还没有准备好(例如,等待数据从磁盘读取或从网络接收),则调用者(通常是一个线程或进程)会被阻塞,直到数据准备好为止。在此期间,调用者无法执行其他任务,只能等待I/O操作完成。阻塞I/O操作的典型例子是普通的文件读写。
非阻塞(Non-blocking):在非阻塞操作中,如果数据还没有准备好,调用者不会被阻塞,而是立即返回一个错误码(例如,表示资源不可用)。调用者可以继续执行其他任务,然后在适当的时间点再次尝试I/O操作。非阻塞I/O操作的典型例子是使用select,poll或epoll等I/O多路复用技术处理的网络通信。

2、异步和同步

同步和异步则是与应用程序如何处理数据这一操作相关的。如果应用程序在读取数据时需要自己复制数据从内核空间到用户空间,则是同步方式;而如果应用程序不需要自己复制数据,而是由操作系统来完成这一过程,则是异步方式。通常情况下,同步方式对性能要求较高,因为数据复制操作是由应用程序自行完成的,而异步方式可以减少应用程序的运算负担,提升程序的处理性能。
1) I/O同步:
在应用程序上调用recv函数,这个sockfd我不管它工作在阻塞模式还是非阻塞模式,真的有数据准备好了之后(TCP的接收缓冲区有数据了,就是数据可读了),我们要读这个数据,这个buf是用户层自己定义的,recv就可以开始接收了,是应用程序卡在这里recv(),从内核的TCP接收缓冲区搬数据到应用层上的buf,在搬的过程中,因为size>0,这就表示从内核搬了多少字节的数据,我们就要访问buf了,没搬完之前,不会进入到下面的if语句。搬完了,recv才返回过来,看看size是多少,就是搬了多少数据,因此I/O同步是应用程序搬的数据。
I/O同步的意思就是:当我调用网络I/O的接口,当I/O阶段1数据准备好之后,在数据读写的时候,应用层自己调用网络I/O接口自己去读写,都花在应用层上。
recv和send是同步的I/O接口
2) I/O异步
当我请求内核的时候,我比较关心sockfd上的数据,远端如果发过来数据,我需要读sockfd上的数据,我有一个buf,到时候如果有数据来了,内核能不能帮忙把数据放到buf里面,我再给内核注册一个sigio信号,也就是说,对一个操作系统级别的异步的I/O接口来说,我先塞给内核一个sockfd,表示对这个sockfd上的事件感兴趣,如果sockfd上有数据可读的话,麻烦操作系统内核把数据搬到buf里面。

内核把内核缓冲区-sockfd对应的TCP接收缓冲区的数据搬到buf里面,搬完以后,通过信号sigio给应用程序通知一下。应用程序在这期间可以玩自己的了,做任何事清都可以。

3、总结

是否同步、是否阻塞,阻塞与非阻塞的区别是读取TCP接收数据缓冲区如果没有数据是否等待,如果等待即是阻塞,不等待即是不阻塞;至于同步和异步则是如果有数据应用程序是否自己复制数据从内核空间到用户空间,如果需要自己复制数据则是同步,否则即是异步

二、IO模型

1、同步阻塞IO

用户线程发起IO请求后,立即返回;但需要不断地调用read,尝试读取socket中的数据,直到读取成功后,才继续处理接收的数据。
虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

   int server_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;

    // 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8000);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // 监听连接
    listen(server_fd, 5);

    while (true) {
        // 阻塞等待客户端连接
        client_len = sizeof(client_addr);
        conn_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        std::cout << "connect from: " << inet_ntoa(client_addr.sin_addr) << std::endl;

        char buf[1024];
        // 阻塞等待客户端发送消息
        int len = recv(conn_fd, buf, sizeof(buf), 0);
        std::cout << "receive: " << buf << std::endl;

        // 发送数据给客户端
        const char *msg = "Hello, Client";
        send(conn_fd, msg, strlen(msg), 0);

        close(conn_fd);
    }

    close(server_fd);

2、同步非阻塞IO

同步非阻塞IO(Synchronous Non-blocking IO):使用fcntl函数设置Socket实例变为非阻塞式,调用recv()函数时,如果当前没有数据,该函数会立即返回,并提示无可用数据。应用程序需要通过不断轮询来检查数据是否准备好,一旦数据准备好,就可以读取数据。

   int server_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;

    // 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8000);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // 设置为非阻塞模式
    int flags = fcntl(server_fd, F_GETFL, 0);
    fcntl(server_fd, F_SETFL, flags | O_NONBLOCK);

    // 监听连接
    listen(server_fd, 5);

    while (true) {
        // 尝试接受客户端连接,如果没有连接则立即返回
        client_len = sizeof(client_addr);
        conn_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (conn_fd == -1) {
            continue;
        }
        std::cout << "connect from: " << inet_ntoa(client_addr.sin_addr) << std::endl;

        char buf[1024];
        // 尝试接收数据,如果没有收到则立即返回,不会一直阻塞
        int len = recv(conn_fd, buf, sizeof(buf), 0);
        if (len == -1) {
            std::cerr << "recv error" << std::endl;
            close(conn_fd);
            continue;
        }
        std::cout << "receive: " << buf << std::endl;

        // 发送数据给客户端
        const char *msg = "Hello, Client";
        send(conn_fd, msg, strlen(msg), 0);

        close(conn_fd);
    }

    close(server_fd);

3、异步阻塞IO

异步非阻塞IO(Asynchronous Non-blocking IO):异步阻塞IO(Asynchronous Blocking IO):主线程中通过accept函数等待客户端连接请求,一旦有连接请求到达,则创建新的线程处理该连接,同时继续在主线程中等待下一个连接请求。在新线程中,线程函数handle_client也是一直阻塞等待接收客户端发送的数据,直到接收到数据才会进行后续操作。

void handle_client(int conn_fd, struct sockaddr_in client_addr) {
    std::cout << "connect from: " << inet_ntoa(client_addr.sin_addr) << std::endl;

    char buf[1024];
    // 阻塞等待客户端发送消息
    int len = recv(conn_fd, buf, sizeof(buf), 0);
    std::cout << "receive: " << buf << std::endl;

    // 发送数据给客户端
    const char *msg = "Hello, Client";
    send(conn_fd, msg, strlen(msg), 0);

    close(conn_fd);
    std::cout << "disconnect from: " << inet_ntoa(client_addr.sin_addr) << std::endl;
}

int main() {
    int server_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;

    // 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8000);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // 监听连接
    listen(server_fd, 5);

    while (true) {
        // 阻塞等待客户端连接
        client_len = sizeof(client_addr);
        conn_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);

        std::thread th(std::bind(handle_client, conn_fd, client_addr));
        th.detach();
    }

    close(server_fd);

    return 0;
}

4、异步非阻塞IO

通过设置 server_fd 为非阻塞模式(O_NONBLOCK),在没有客户端连接时不会一直阻塞在 accept() 上,而是立即返回并继续执行程序,以便进行其他任务。在接收数据时也采用了尝试接收,如果没有收到则立即返回的方法来避免阻塞等待

void handle_client(int conn_fd, struct sockaddr_in client_addr) {
    std::cout << "connect from: " << inet_ntoa(client_addr.sin_addr) << std::endl;

    char buf[1024];
    // 尝试接收数据,如果没有收到则立即返回,不会一直阻塞
    int len = recv(conn_fd, buf, sizeof(buf), 0);
    if (len == -1) {
        std::cerr << "recv error" << std::endl;
        return;
    } else if (len == 0) {
        std::cout << "client closed" << std::endl;
        return;
    }
    std::cout << "receive: " << buf << std::endl;

    // 发送数据给客户端
    const char *msg = "Hello, Client";
    send(conn_fd, msg, strlen(msg), 0);

    close(conn_fd);
    std::cout << "disconnect from: " << inet_ntoa(client_addr.sin_addr) << std::endl;
}

int main() {
    int server_fd, conn_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len;

    // 创建套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8000);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

    // 设置为非阻塞模式
    int flags = fcntl(server_fd, F_GETFL, 0);
    fcntl(server_fd, F_SETFL, flags | O_NONBLOCK);

    // 监听连接
    listen(server_fd, 5);

    while (true) {
        // 尝试接受客户端连接,如果没有连接则立即返回
        client_len = sizeof(client_addr);
        conn_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (conn_fd == -1) {
            continue;
        }

        std::thread th(std::bind(handle_client, conn_fd, client_addr));
        th.detach();
    }

    close(server_fd);

    return 0;
}

三、多路IO复用简介

IO多路复用(IO Multiplexing):即经典的Reactor模式(并非23种设计模式之一),,Java中的Selector和Linux中的epoll都是这种模型。
Reactor模式称为反应器模式或应答者模式,是基于事件驱动的设计模式,拥有一个或多个并发输入源,有一个服务处理器和多个请求处理器,服务处理器会同步的将输入的请求事件以多路复用的方式分发给相应的请求处理器。 Reactor设计模式是一种为处理并发服务请求,并将请求提交到一个或多个服务处理程序的事件设计模式。

1、传统的多线程模型的瓶颈

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。
IO多路复用原理深度总结【万字总结】_第1张图片
比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。

2、IO多路复用

【经营方式一】就是传统的并发模型,每个 IO 流(快递)都有一个新的线程(快递员)管理。
【经营方式二】就是 IO 多路复用。只有单个线程(一个快递员),通过跟踪每个 IO 流的状态(每个快递的送达地点),来管理多个 IO 流。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
I/O 多路复用使得程序能同时监听多个文件描述符,能够提高程序的性能,Linux 下实现 I/O 多路复用的系统调用主要有 select、poll 和 epoll。

四、select/poll

1、原理

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
poll基本原理与 select 一致,也是轮询+遍历。唯一的区别就是 poll 没有最大文件描述符限制(使用链表的方式存储 fd)。

2、缺点与优点

优点:
几乎在所有的平台上支持,跨平台支持性好
缺点:
由于是采用轮询方式全盘扫描,会随着文件描述符 FD 数量增多而性能下降。
每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,并进行遍历(消息传递都是从内核到用户空间)。这会导致较低的效率。

五、epoll

1、原理

epoll是一个Linux内核提供的、基于事件驱动的IO多路复用机制。它可以监听多个文件描述符上的事件,当一个或者多个文件描述符有数据请求时,能够立即通知执行程序对这些事件进行处理,避免了轮询不必要的文件描述符带来的性能问题。它克服了select和poll的主要限制。epoll使用一个事件驱动(event-driven)的方式来处理I/O操作,它只会返回就绪的文件描述符,而不是遍历整个文件描述符集合。

3、优缺点

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

3、两种模式

边缘触发(Edge-triggered)

边缘触发是指在事件状态发生变化的时刻触发一次,例如从无事件变为有事件。在I/O多路复用中,边缘触发意味着当某个文件描述符发生I/O事件(如变为可读或可写)时,我们只会收到一次通知。当收到通知后,我们需要处理该文件描述符上的所有数据,直到数据全部处理完毕,否则不会再收到通知。

边缘触发的优点是只在事件状态改变时触发,可以减少事件通知的次数。然而,边缘触发的缺点是我们需要确保在收到通知后处理所有相关数据,否则可能会遗漏某些事件

条件触发(Level-triggered)

条件触发是指只要事件状态保持满足某种条件,就会持续触发。在I/O多路复用中,条件触发意味着只要某个文件描述符的I/O事件状态满足条件(如可读或可写),我们就会不断收到通知。

条件触发的优点是它可以确保我们不会遗漏任何事件,因为只要条件满足,就会持续触发。然而,条件触发的缺点是它可能导致大量的事件通知,从而增加处理开销。

总结

select是最早的I/O多路复用技术,但受到文件描述符数量和效率方面的限制。poll克服了文件描述符数量的限制,但仍然存在一定的效率问题。epoll是一种高效的I/O多路复用技术,尤其适用于高并发场景,但它仅在Linux平台上可用。一般来说,epoll的效率是要比select和poll高的,但是对于活动连接较多的时候,由于回调函数触发的很频繁,其效率不一定比select和poll高。所以epoll在连接数量很多,但活动连接较小的情况性能体现的比较明显。IO多路复用原理深度总结【万字总结】_第2张图片

你可能感兴趣的:(高性能网络框架,linux,c++)