深入探讨NIO

目录

传统阻塞IO

非阻塞IO

select()

epoll

总结

传统阻塞IO

非阻塞IO

IO多路复用select()

IO多路复用epoll


传统阻塞IO

在传统的阻塞IO模型中,当一个线程执行到IO操作(如读取数据)时,如果数据尚未准备好,它会阻塞,直到数据准备就绪。这种模型下,每个IO操作都与一个线程紧密绑定,这意味着如果有很多并发的IO操作,就需要创建大量的线程来处理它们,这可能会导致资源消耗过大。

java 复制代码
public Order queryOrder() {
    // 这里会阻塞,直到订单服务返回订单信息,read()方法才会返回
    Order order = orderConnection.read(); // 查询订单信息
    log.info("查询订单信息, 收到返回 {}", order);
    return order;
}
​
// 当执行orderConnection.read()时,如果订单服务没有及时返回数据,
// 线程会阻塞,直到数据到达。在这期间,操作系统会挂起当前线程,
// 释放CPU去执行其他任务,直到IO操作完成,线程才会被唤醒继续执行。
​
// 优点:
// 1. 对开发人员友好,代码简单直观,易于理解和维护。
// 2. 编程模型简单,不需要复杂的状态管理和回调函数。
​
// 缺点:
// 1. IO操作会阻塞整个线程,导致线程资源不能被充分利用。
// 2. 每个连接都需要一个专门的线程来处理,这在高并发场景下会导致线程数量过多。
// 3. 以Java为例,线程默认的栈大小是1M,如果需要同时处理10万个连接,
//    就需要10万个线程,这将消耗100G的栈内存,对系统资源是一个巨大的负担。
​
// 为了避免创建过多的线程,阻塞IO通常与线程池一起使用,这样可以重用线程,
// 减少线程创建和销毁的开销,同时通过线程池来控制并发线程的数量,避免资源耗尽。

非阻塞IO

非阻塞IO调用(如读取或写入)不会使线程挂起等待数据,而是立即返回。如果数据尚未准备好,IO调用会返回一个错误码,告知操作不能立即完成。这种模式允许单个线程管理多个IO连接,但需要不断地检查每个连接的状态。

java 复制代码
public void mainLoop() {
    // 使用O_NONBLOCK选项打开连接,这样IO操作不会阻塞线程
    Connection conn1 = open(O_NONBLOCK);
    Connection conn2 = open(O_NONBLOCK);
    Connection conn3 = open(O_NONBLOCK);
    List<Connection> connections = List.of(conn1, conn2, conn3);
​
    // 无限循环,持续检查每个连接是否有数据可读
    while (true) {
        for (Connection conn : connections) {
            // 由于设置了O_NONBLOCK选项,read()方法不会阻塞
            Object data = conn.read();
            // 如果有数据可读,处理数据
            if (data != null) {
                System.out.println(data);
            }
        }
    }
}
​
// 优点:
// 1. 解决了IO操作导致整个线程挂起的问题,允许一个线程同时处理多个连接。
// 2. 减少了线程数量,降低了线程创建和上下文切换的开销。
​
// 缺点:
// 1. 不停地轮询每个连接是否有数据可读,这可能导致很多无效的检查和高CPU使用率。
// 2. 由于不知道何时会有数据到达,需要频繁地检查每个连接,这可能导致性能问题。
// 3. 编程模型相对复杂,需要额外的逻辑来处理非阻塞IO的回调和事件。

select()

IO多路复用(select())是一种解决非阻塞IO中高CPU轮询问题的技术。它允许单个线程监控多个文件描述符(连接),并在任何一个文件描述符准备好进行IO操作时得到通知。

select()实现细节:

  1. 调用select()时,系统会为所有监控的文件描述符注册回调函数,这些回调函数被存储在文件描述符的wait_queue中。

  2. select线程会被挂起,直到有文件描述符就绪或超时。

  3. 当文件描述符收到数据时,会触发其wait_queue中的回调函数,并唤醒select线程。

  4. 回调函数会标记哪些文件描述符就绪,并从所有文件描述符的wait_queue中移除回调函数,类似于资源清理。

  5. select线程恢复后,可以处理就绪的文件描述符。

cpp 复制代码
#include <sys/select.h>
​
int main(void) {
    fd_set rfds; // 用于存储需要监听的读就绪文件描述符集合
    struct timeval tv; // 超时时间设置
​
    // 主循环
    for(;;) {
        // 清空文件描述符集合,为下一次select调用做准备
        FD_ZERO(&rfds);
        FD_SET(0, &rfds); // 添加需要监听的文件描述符
​
        // 调用select阻塞当前线程,直到有文件描述符就绪或超时
        int retval = select(n, &rfds, NULL, NULL, &tv);
        if (retval == -1) {
            perror("select调用出错");
        } else if (retval) {
            printf("有连接就绪\n");
            // 遍历检查哪些文件描述符就绪
            for (int j = 0; j <= n; j++) {
                if(FD_ISSET(j, &rfds)) {
                    // 从就绪的文件描述符读取数据
                    recv(j, ...);
                }
            }
        } else {
            printf("在超时时间内没有任何连接就绪\n");
        }
    }
    return 0;
}
​
​
​
// 优点:
// 1. 实现了wait-notify机制,相比于不停地轮询,效率更高,减少了CPU的无效使用。
​
// 缺点:
// 1. select()的复杂度为O(n),其中n是要监控的文件描述符数量,因为它需要逐个注册和移除回调函数。
// 2. select()只返回哪些文件描述符就绪,实际的数据读取还需要额外调用recv()等函数。
// 3. select()有文件描述符数量的限制,通常限制为1024或2048,这限制了它可以同时监控的文件描述符数量。

epoll

Epoll与select()不同,它通过三个专门的API实现了对大量连接的高效管理,避免了select()在每次操作时都需要对所有连接进行注册和注销回调函数的开销。Epoll的操作分为三个步骤:

  1. epoll_create():这一步是初始化Epoll,准备其内部所需的数据结构。

  2. epoll_ctl():这个API用于动态地向Epoll注册新的连接或者从Epoll中注销已有的连接。(只关心当前操作的连接,不关心所有连接,实现了全量操作向增量操作的优化

  3. epoll_wait():该API使调用线程挂起,直到有连接准备好进行I/O操作或者超过指定的超时时间。

Epoll的优化之处在于:

  • 它通过这三个API将原本需要全量操作的过程转变为增量操作,减少了不必要的重复工作。

  • 内部使用红黑树这种高效的数据结构,将查找和操作的算法复杂度降低到了O(logN),显著提升了处理大量连接时的性能。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
​
#define MAX_EVENTS 10
​
// 定义事件结构体和事件数组
struct epoll_event ev, events[MAX_EVENTS];
​
// 定义套接字和epoll文件描述符
int listen_sock, conn_sock, nfds, epollfd;
​
// 设置监听套接字的代码(socket(), bind(), listen())省略
​
// 创建epoll实例
epollfd = epoll_create1(0);
if (epollfd == -1) {
    perror("epoll_create1 failed");
    exit(EXIT_FAILURE);
}
​
// 将监听套接字添加到epoll监听队列
ev.events = EPOLLIN; // 监听读事件
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    perror("epoll_ctl failed for listen_sock");
    exit(EXIT_FAILURE);
}
​
// 主事件循环
for (;;) {
    // 等待事件就绪
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_wait failed");
        exit(EXIT_FAILURE);
    }
​
    // 处理所有就绪的事件
    for (int n = 0; n < nfds; ++n) {
        if (events[n].data.fd == listen_sock) {
            // 处理新的连接请求
            conn_sock = accept(listen_sock, (struct sockaddr *) &addr, &addrlen);
            if (conn_sock == -1) {
                perror("accept failed");
                exit(EXIT_FAILURE);
            }
            setnonblocking(conn_sock); // 设置非阻塞模式
            ev.events = EPOLLIN | EPOLLET; // 监听读事件和边缘触发模式
            ev.data.fd = conn_sock;
            // 将新连接添加到epoll监听队列
            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) {
                perror("epoll_ctl failed for conn_sock");
                exit(EXIT_FAILURE);
            }
        } else {
            // 处理其他就绪的读写事件
            do_use_fd(events[n].data.fd); // 根据业务需求处理
        }
    }
}
​
// 辅助函数:设置非阻塞模式
void setnonblocking(int sock) {
    int flags = fcntl(sock, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl F_GETFL failed");
        exit(EXIT_FAILURE);
    }
    flags |= O_NONBLOCK;
    if (fcntl(sock, F_SETFL, flags) == -1) {
        perror("fcntl F_SETFL failed");
        exit(EXIT_FAILURE);
    }
}
​
// 辅助函数:处理文件描述符
void do_use_fd(int fd) {
    // 根据业务需求处理fd
    // 例如:读取数据、写入数据等
}

总结

传统阻塞IO

  • 优点

    • 对开发人员友好,代码编写简单直观。
  • 缺点

    • 连接和线程紧密耦合,每个连接需要一个线程,限制了单机能处理的最大连接数。

    • 为了避免内存耗尽,通常需要配合线程池使用。

非阻塞IO

  • 优点

    • 通过设置O_NONBLOCK标志位,可以让操作系统不挂起当前线程,实现一个线程同时处理多个连接。
  • 缺点

    • 需要不停地轮询检查,效率低,浪费CPU资源。

IO多路复用select()

  • 优点

    • 实现了wait-notify机制,相比轮询效率更高。
  • 缺点

    • 每次调用select()都需要重新准备参数,修改所有连接句柄的wait_queue,算法复杂度较高,为O(n)。n是要监控的连接数

IO多路复用epoll

  • 优点

    • 通过epoll_create()、epoll_ctl()、epoll_wait()三个API,epoll内部管理相关参数和结构,实现增量操作,效率更高,算法复杂度为O(logN)。
  • 缺点

    • 当单个线程管理的连接数过多时,epoll_wait线程本身可能成为瓶颈,可以通过多epoll_wait线程配合多IO线程的策略来解决。
相关推荐
㎝㎜22 分钟前
Windows系统搭建Docker
windows·docker·容器
spencer_tseng4 小时前
LuaForWindows_v5.1.5-52.exe
windows·lua
jupiter_8886 小时前
Java NIO channel
java·服务器·nio
Good_tea_h6 小时前
NIO(New IO)和BIO(Blocking IO)的区别
nio
小猫猫猫◍˃ᵕ˂◍6 小时前
NIO群聊系统demo示例
java·开发语言·nio
飞滕人生TYF6 小时前
java NIO 详解
java·nio
霜雪殇璃8 小时前
2024.11.29(单链表)
windows
APItesterCris12 小时前
对于大规模的淘宝API接口数据,有什么高效的处理方法?
linux·服务器·前端·数据库·windows
Narutolxy14 小时前
实现跨平台 SSH 连接:从 macOS 到 Windows WSL 的完整解决方案20241203
windows·macos·ssh
Lill_bin15 小时前
BeanUtils:Java Bean工具类详解
java·开发语言·windows·python·算法·servlet