深入探讨NIO
目录
传统阻塞IO
非阻塞IO
select()
epoll
总结
传统阻塞IO
非阻塞IO
IO多路复用select()
IO多路复用epoll
传统阻塞IO
在传统的阻塞IO模型中,当一个线程执行到IO操作(如读取数据)时,如果数据尚未准备好,它会阻塞,直到数据准备就绪。这种模型下,每个IO操作都与一个线程紧密绑定,这意味着如果有很多并发的IO操作,就需要创建大量的线程来处理它们,这可能会导致资源消耗过大。
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连接,但需要不断地检查每个连接的状态。
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()实现细节:
-
调用select()时,系统会为所有监控的文件描述符注册回调函数,这些回调函数被存储在文件描述符的wait_queue中。
-
select线程会被挂起,直到有文件描述符就绪或超时。
-
当文件描述符收到数据时,会触发其wait_queue中的回调函数,并唤醒select线程。
-
回调函数会标记哪些文件描述符就绪,并从所有文件描述符的wait_queue中移除回调函数,类似于资源清理。
-
select线程恢复后,可以处理就绪的文件描述符。
#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的操作分为三个步骤:
-
epoll_create()
:这一步是初始化Epoll,准备其内部所需的数据结构。 -
epoll_ctl()
:这个API用于动态地向Epoll注册新的连接或者从Epoll中注销已有的连接。(只关心当前操作的连接,不关心所有连接,实现了全量操作向增量操作的优化) -
epoll_wait()
:该API使调用线程挂起,直到有连接准备好进行I/O操作或者超过指定的超时时间。
Epoll的优化之处在于:
-
它通过这三个API将原本需要全量操作的过程转变为增量操作,减少了不必要的重复工作。
-
内部使用红黑树这种高效的数据结构,将查找和操作的算法复杂度降低到了O(logN),显著提升了处理大量连接时的性能。
#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线程的策略来解决。
-