rokevin
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
移动
前端
语言
  • 基础

    • Linux
    • 实施
    • 版本构建
  • 应用

    • WEB服务器
    • 数据库
  • 资讯

    • 工具
    • 部署
开放平台
产品设计
  • 人工智能
  • 云计算
计算机
其它
GitHub
  • IO模型

  • 同步异步/阻塞非阻塞
    • 核心概念定义
    • 四种组合关系与代码示例
      • 1. 同步阻塞(最常见传统模式)
      • 2. 同步非阻塞
      • 3. 异步阻塞(实际中较少见)
      • 4. 异步非阻塞(高效模式)
    • 四者关系总结
  • Linux下的IO模型
    • 不管文件IO还是网络socket的IO,其读写都需要经过两个阶段:
    • I/O模式
      • IO请求流程
    • 阻塞IO
    • 非阻塞IO
    • IO多路复用
    • 信号驱动IO模型
    • 异步IO
    • IO Model 比较
    • 模型形象描述
    • I/O多路复用的形成原因
      • fd是什么
    • POSIX
    • select
      • 调用过程
      • 缺点
      • 优点
    • poll
      • 优点
      • 缺点
    • epoll
      • epoll_create
      • epoll_ctl
      • epoll_wait
      • epoll的ET与LT模式
      • epoll的函数调用流程
      • epoll的优点
    • select poll epoll 对比
    • kqueue
    • iocp
    • ACE
    • ASIO
    • libevent
  • Java中的三种IO模型
    • Java IO模型和操作系统IO模型关系
    • 阻塞IO(BIO)
    • 非阻塞IO(NIO)
    • 异步IO(AIO)
    • NIO与Netty
  • 网络编程: Reactor与Proactor
    • 1. 标准定义
    • 2. 通俗理解
    • 3. 备注
  • Reactor模式
    • Reactor模式跟IO模型关系
    • 单进程单线程
    • 单进程多线程
    • 多进程单线程
    • 多进程多线程
    • 主从进程多线程
  • 常见组件使用的模型
  • 资料
  • 书籍

IO模型

同步异步/阻塞非阻塞

同步(Synchronous)
异步(Asynchronous)
阻塞(Blocking)
非阻塞(Non-blocking)

要理解阻塞 / 非阻塞与同步 / 异步的关系,需要从两个维度区分:

  • 同步 / 异步:关注 “结果获取方式”(是否主动等待结果,还是被动通知)。
  • 阻塞 / 非阻塞:关注 “等待过程中线程状态”(是否挂起等待,还是可以做其他事)。

这四个概念组合组合出四种种常见交互模式,下面通过 Java 代码示例详细说明。

核心概念定义

  1. 同步(Synchronous)

    调用方主动等待任务完成并获取结果,期间不接收其他通知。

    例:打电话问客服问题,一直拿着电话等对方回复(主动等待)。

  2. 异步(Asynchronous)

    调用方发起任务后无需等待,通过回调、事件等方式被动接收结果。

    例:发邮件问问题,发送后可以做其他事,收到回复再处理(被动通知)。

  3. 阻塞(Blocking)

    任务执行期间,线程进入挂起状态,无法执行其他操作,直到任务完成。

    例:银行排队办业务,排到时才能处理,期间不能做其他事。

  4. 非阻塞(Non-blocking)

    任务执行期间,线程不挂起,可以继续执行其他操作(需轮询检查结果)。

    例:用手机 APP 查快递进度,每隔一段时间刷新一次(不一直盯着)。

四种组合关系与代码示例

以 “读取文件” 为例,展示四种模式的区别:

1. 同步阻塞(最常见传统模式)

  • 特点:调用方主动等待结果,且等待期间线程挂起。
  • 场景:普通文件读取、Socket 阻塞式 IO。
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;

public class SyncBlocking {
    public static void main(String[] args) throws IOException {
        System.out.println("开始读取文件(同步阻塞)");
        
        // 同步:主动调用 read() 并等待结果
        // 阻塞:read() 执行时,主线程挂起,无法执行后续打印
        File file = new File("test.txt");
        try (FileInputStream in = new FileInputStream(file)) {
            byte[] buffer = new byte[1024];
            int len = in.read(buffer); // 阻塞点:线程挂起直到读取完成
            System.out.println("读取到内容:" + new String(buffer, 0, len));
        }
        
        System.out.println("读取完成");
    }
}
  • 过程:in.read() 调用后,主线程进入阻塞状态(操作系统将线程挂起),直到数据读取完成才继续执行。

2. 同步非阻塞

  • 特点:调用方主动等待结果,但等待期间线程不挂起,可轮询检查。
  • 场景:NIO 中的 SocketChannel 非阻塞模式。
import java.io.File;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.ByteBuffer;

public class SyncNonBlocking {
    public static void main(String[] args) throws Exception {
        System.out.println("开始读取文件(同步非阻塞)");
        File file = new File("test.txt");
        RandomAccessFile raf = new RandomAccessFile(file, "r");
        FileChannel channel = raf.getChannel();
        channel.configureBlocking(false); // 设置为非阻塞模式
        
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        boolean done = false;
        
        while (!done) {
            // 同步:主动调用 read() 检查是否有数据
            // 非阻塞:read() 立即返回,若没数据则返回 0,线程不挂起
            int bytesRead = channel.read(buffer);
            
            if (bytesRead > 0) {
                buffer.flip();
                System.out.println("读取到内容:" + new String(buffer.array(), 0, bytesRead));
                buffer.clear();
                done = true;
            } else if (bytesRead == -1) {
                // 文件读取完毕
                done = true;
            } else {
                // 没读取到数据,线程可做其他事(非阻塞)
                System.out.println("未读取到数据,处理其他任务...");
                Thread.sleep(100); // 模拟其他操作
            }
        }
        
        channel.close();
        raf.close();
        System.out.println("读取完成");
    }
}
  • 过程:channel.read() 是非阻塞的,若暂时无数据则立即返回 0,主线程可执行其他逻辑(如打印 “处理其他任务”),但仍需主动轮询结果(同步)。

3. 异步阻塞(实际中较少见)

  • 特点:调用方被动接收结果(异步),但等待通知期间线程挂起。
  • 场景:某些框架的异步 API 配合阻塞等待(如 Future.get())。
import java.util.concurrent.*;

public class AsyncBlocking {
    public static void main(String[] args) throws Exception {
        System.out.println("开始读取文件(异步阻塞)");
        ExecutorService executor = Executors.newSingleThreadExecutor();
        
        // 异步:任务提交给线程池执行,主线程不主动等待
        Future<String> future = executor.submit(() -> {
            // 子线程执行文件读取(模拟异步操作)
            Thread.sleep(1000); // 模拟IO耗时
            return "文件内容:Hello Async";
        });
        
        // 阻塞:主线程调用 get() 时挂起,等待子线程结果(被动接收)
        String result = future.get(); // 阻塞点:线程挂起直到结果返回
        System.out.println(result);
        
        executor.shutdown();
        System.out.println("读取完成");
    }
}
  • 过程:任务异步提交给线程池,但主线程调用 future.get() 时会阻塞等待结果(线程挂起),属于 “异步任务 + 阻塞等待结果” 的组合。

4. 异步非阻塞(高效模式)

  • 特点:调用方发起任务后无需等待(异步),且等待期间线程可自由执行其他操作(非阻塞),结果通过回调通知。
  • 场景:NIO2(AIO)、Netty 事件驱动、RxJava 响应式编程。
import java.nio.file.*;
import java.nio.channels.*;
import java.util.concurrent.CompletionHandler;

public class AsyncNonBlocking {
    public static void main(String[] args) throws Exception {
        System.out.println("开始读取文件(异步非阻塞)");
        Path path = Paths.get("test.txt");
        
        // 异步非阻塞:发起读取后立即返回,主线程不阻塞
        AsynchronousFileChannel channel = AsynchronousFileChannel.open(path);
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        
        // CompletionHandler 作为回调:操作完成后自动触发
        channel.read(buffer, 0, buffer, new CompletionHandler<Integer, ByteBuffer>() {
            @Override
            public void completed(Integer bytesRead, ByteBuffer attachment) {
                // 异步回调:读取完成后被动通知
                attachment.flip();
                System.out.println("读取到内容:" + new String(attachment.array(), 0, bytesRead));
                try {
                    channel.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            
            @Override
            public void failed(Throwable exc, ByteBuffer attachment) {
                exc.printStackTrace();
            }
        });
        
        // 主线程非阻塞:发起异步操作后,可立即执行其他任务
        System.out.println("发起读取后,主线程处理其他事情...");
        Thread.sleep(2000); // 等待异步操作完成(实际中无需主动等待)
        System.out.println("程序结束");
    }
}
  • 过程:channel.read() 是异步非阻塞的,调用后立即返回,主线程可继续执行(如打印 “处理其他事情”);当文件读取完成,系统会自动调用 completed() 回调通知结果。

四者关系总结

模式核心区别(同步 / 异步)核心区别(阻塞 / 非阻塞)典型场景
同步阻塞主动等待结果等待时线程挂起传统 IO(BIO)
同步非阻塞主动轮询结果等待时线程可做其他事NIO 非阻塞模式
异步阻塞被动接收结果(回调)等待通知时线程挂起Future.get () 阻塞等待
异步非阻塞被动接收结果(回调)等待时线程可自由执行AIO、事件驱动编程

关键结论:

  • 同步 / 异步与阻塞 / 非阻塞是两个独立维度,前者描述 “结果获取方式”,后者描述 “线程状态”。
  • 异步非阻塞是性能最优的模式(如 Netty、Node.js),因为它避免了线程阻塞,充分利用 CPU 资源。

Linux下的IO模型

编程语言的IO实现是依赖于底层的操作系统,如果OS内核不支持,那么语言层面也无能为力。任何一个跨平台的编程语言,一定是能够在不同操作系统之间选择使用最优的IO模型。

Linux(UNIX)操作系统IO从概念上来说,总共有5种:

  1. 阻塞IO (blocking I/O)

  2. 非阻塞IO (nonblocking I/O)

  3. IO多路复用 (I/O multiplexing (select and poll))

  4. 信号驱动IO (signal driven I/O (SIGIO))

  5. 异步IO (asynchronous I/O (the POSIX aio_functions))

不管文件IO还是网络socket的IO,其读写都需要经过两个阶段:

  1. wait for data(准备数据到内核的缓冲区)

  2. copy data from kernel to user(从缓冲区拷贝到用户空间)

I/O模式

对于一次IO访问(这回以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的缓冲区,最后交给进程。所以说,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready)

  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正是因为这两个阶段,linux系统产生了下面五种网络模式的方案:

-- 阻塞 I/O(blocking IO)

-- 非阻塞 I/O(nonblocking IO)

-- I/O 多路复用( IO multiplexing)

-- 信号驱动 I/O( signal driven IO)

-- 异步 I/O(asynchronous IO)

IO请求流程

阶段1. 准备数据,这时数据可能还没有到达,如还没有收到一个完成的udp包,kernel需要等待.也就是 数据被拷贝到操作系统内核的缓冲区是需要一个过程的

阶段2. 拷贝数据到用户内存,对于synchronous IO 这一步需要用户进程去请求read操作,阻塞.对于asynchronous IO,这一步由kernel主动完成,非阻塞.

阻塞/非阻塞是指阶段1,synchronous/asynchronous是指阶段2

阻塞IO

或者说synchronous阻塞 IO,这里 1 2 阶段都是阻塞的

用户进程请求后等待阶段1阻塞,阶段1完成后等待阶段2仍然阻塞,整个过程只需要一次系统调用

非阻塞IO

或者说 synchronous非阻塞IO,这里2是阻塞的

  1. 用户进程轮询请求数据,没有数据时kernel返回错误状态,用户进程收到后会重试.
  2. 某次请求后如果数据到达,kernel返回数据到达状态,阶段1结束,用户进程调用read,将数据从kernel拷贝到用户内存

需要两次有效的系统调用

IO多路复用

和阻塞IO一样是synchronous阻塞IO,这里的 1 2是阻塞的 ,唯一的区别是一个用户进程负责多个socket,也是IO多路复用的优势

基本原理就是

  1. select poll epoll请求数据,在阶段1被阻塞,当某个socket有数据到达了就通知用户进程
  2. 用户进程调用read操作,将数据从kernel拷贝到用户内存,在阶段2被阻塞
阻塞io只需要一次系统调用,IO多路复用需要两次,如果连接数不是很高时 select/epoll不一定比multi-threading+blocking IO更快

信号驱动IO模型

应用进程在读取文件时通知内核,如果某个 socket 的某个事件发生时,请向我发一个信号。在收到信号后,信号对应的处理函数会进行后续处理。

异步IO

asynchronous非阻塞 IO,完全的非阻塞

  1. 用户进程发起read操作后立即返回去做其他事,kernel收到asynchronous read后也立刻返回
  2. 在数据准备完成后,kernel将数据拷贝到用户内存,并发送给用户signa

理论上是这样,目前的实现不尽如人意,因此应用不多,参见 Linux AIO(异步IO)那点事儿

IO Model 比较

模型形象描述

  1. 阻塞I/O模型

    老李去火车站买票,排队三天买到一张退票。 耗费:在车站一直排队,没法干别的事。

  2. 非阻塞I/O模型

    老李去火车站买票,火车站说没有,每隔12小时去火车站问有没有退票,三天后买到一张票。 耗费:往返车站路上耗费时间,其他时间可以做别的事。

  3. I/O复用模型

  • select/poll 老李去火车站买票,委托黄牛(内核中),然后每隔6小时电话黄牛询问,黄牛三天内买到票,然后老李去火车站交钱领票。 耗费:打电话

  • epoll 老李去火车站买票,委托黄牛,黄牛买到后即通知老李去领,然后老李去火车站交钱领票。 耗费:无需打电话

  1. 信号驱动I/O模型

    老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李,然后老李去火车站交钱领票。 耗费:无需打电话

  2. 异步I/O模型

    老李去火车站买票,给售票员留下电话,有票后,售票员电话通知老李并快递送票上门。 耗费:无需打电话

I/O多路复用的形成原因

如果一个I/O流进来,我们就开启一个进程处理这个I/O流。那么假设现在有一百万个I/O流进来,那我们就需要开启一百万个进程一一对应处理这些I/O流(——这就是传统意义下的多进程并发处理)。思考一下,一百万个进程,你的CPU占有率会多高,这个实现方式及其的不合理。所以人们提出了I/O多路复用这个模型,一个线程,通过记录I/O流的状态来同时管理多个I/O,可以提高服务器的吞吐能力。

fd是什么

操作文件的描述符。

fd的类型为int, < 0 为非法值, >=0 为合法值。在linux中,一个进程默认可以打开的文件数为1024个,fd的范围为0~1023。

POSIX

POSIX 可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)是IEEE为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称,其正式称呼为IEEE Std 1003,而国际标准名称为ISO/IEC 9945。此标准源于一个大约开始于1985年的项目。POSIX这个名称是由理查德·斯托曼(RMS)应IEEE的要求而提议的一个易于记忆的名称。它基本上是Portable Operating System Interface(可移植操作系统接口)的缩写,而X则表明其对Unix API的传承。

Linux基本上逐步实现了POSIX兼容,但并没有参加正式的POSIX认证。

微软的Windows NT声称部分实现了POSIX标准。

当前的POSIX主要分为四个部分:Base Definitions、System Interfaces、Shell and Utilities和Rationale。

select

原型

int select (int maxfd, 
            fd_set *readfds, 
            fd_set *writefds, 
			fd_set *exceptfds, 
            struct timeval *timeout);
  • maxfd:代表要监控的最大文件描述符fd+1
  • writefds:监控可写fd
  • readfds:监控可读fd
  • exceptfds:监控异常fd
  • timeout:超时时长
    • NULL,代表没有设置超时,则会一直阻塞直到文件描述符上的事件触发
    • 0,代表不等待,立即返回,用于检测文件描述符状态
    • 正整数,代表当指定时间没有事件触发,则超时返回

select函数监控3类文件描述符,调用select函数后会阻塞,直到描述符fd准备就绪(有数据可读、可写、异常)或者超时,函数便返回。 当select函数返回后,可通过遍历描述符集合,找到就绪的描述符。

调用过程

select函数的调用过程

  1. 从用户空间将fd_set拷贝到内核空间
  2. 注册回调函数
  3. 调用其对应的poll方法
  4. poll方法会返回一个描述读写是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
  5. 如果遍历完所有的fd都没有返回一个可读写的mask掩码,就会让select的进程进入休眠模式,直到发现可读写的资源后,重新唤醒等待队列上休眠的进程。如果在规定时间内都没有唤醒休眠进程,那么进程会被唤醒重新获得CPU,再去遍历一次fd。
  6. 将fd_set从内核空间拷贝到用户空间

缺点

  • 两次拷贝耗时(从用户空间将fd_set拷贝到内核空间, 将fd_set从内核空间拷贝到用户空间)
  • 轮询所有fd耗时(遍历完所有的fd查看是否返回一个可读写的mask掩码)
  • 性能衰减严重:IO随着监控的描述符数量增长,其性能会线性下降
  • 文件描述符个数受限:单进程能够监控的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义增大上限,但同样存在效率低的弱势

优点

  • 跨平台支持

poll

原型

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

其中pollfd表示监视的描述符集合,如下

struct pollfd {
    int fd; //文件描述符
    short events; //监视的请求事件
    short revents; //已发生的事件
};

pollfd结构包含了要监视的event和发生的event,并且pollfd并没有最大数量限制。 和select函数一样,当poll函数返回后,可以通过遍历描述符集合,找到就绪的描述符。

优点

  • 连接数(也就是文件描述符)没有限制(链表存储)
  • poll函数的调用过程与select完全一致

缺点

  • 大量拷贝,水平触发(当报告了fd没有被处理,会重复报告,很耗性能),从上面看select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其性能会线性下降。

epoll

epoll 是Linux内核的可扩展I/O事件通知机制[1]。于Linux 2.5.44首度登场,它设计目的旨在取代既有POSIX select(2)与poll(2)系统函数,让需要大量操作文件描述符的程序得以发挥更优异的性能(举例来说:旧有的系统函数所花费的时间复杂度为O(n),epoll的时间复杂度O(log n))。epoll 实现的功能与 poll 类似,都是监听多个文件描述符上的事件。

epoll与FreeBSD的kqueue类似,底层都是由可配置的操作系统内核对象建构而成,并以文件描述符(file descriptor)的形式呈现于用户空间。epoll 通过使用红黑树(RB-tree)搜索被监视的文件描述符(file descriptor)。

在 epoll 实例上注册事件时,epoll 会将该事件添加到 epoll 实例的红黑树上并注册一个回调函数,当事件发生时会将事件添加到就绪链表中。

epoll是在内核2.6中提出的,是select和poll的增强版。相对于select和poll来说,epoll更加灵活,没有描述符数量限制。epoll使用一个文件描述符管理多个描述符,将用户空间的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。epoll机制是Linux最高效的I/O复用机制,在一处等待多个文件句柄的I/O事件。

select/poll都只有一个方法,epoll操作过程有3个方法,分别是epoll_create(), epoll_ctl(),epoll_wait()。

epoll_create

int epoll_create(int size);

功能:用于创建一个epoll的句柄,size是指监听的描述符个数, 现在内核支持动态扩展,该值的意义仅仅是初次分配的fd个数,后面空间不够时会动态扩容。 当创建完epoll句柄后,占用一个fd值。

ls /proc/<pid>/fd/  //可通过终端执行,看到该fd

使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

功能:用于对需要监听的文件描述符(fd)执行op操作,比如将fd加入到epoll句柄。

  • epfd:是epoll_create()的返回值;

  • op:表示op操作,用三个宏来表示,分别代表添加、删除和修改对fd的监听事件;

    • EPOLL_CTL_ADD(添加)
    • EPOLL_CTL_DEL(删除)
    • EPOLL_CTL_MOD(修改)
  • fd:需要监听的文件描述符;

  • epoll_event:需要监听的事件,struct epoll_event结构如下:

      struct epoll_event {
        __uint32_t events;  /* Epoll事件 */
        epoll_data_t data;  /*用户可用数据*/
      };
    

    events可取值:(表示对应的文件描述符的操作)

    • EPOLLIN :可读(包括对端SOCKET正常关闭);
    • EPOLLOUT:可写;
    • EPOLLERR:错误;
    • EPOLLHUP:中断;
    • EPOLLPRI:高优先级的可读(这里应该表示有带外数据到来);
    • EPOLLET: 将EPOLL设为边缘触发模式,这是相对于水平触发来说的 ;
    • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后就不再监听该事件 ;

epoll_wait

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

功能:等待事件的上报

  • epfd:等待epfd上的io事件,最多返回maxevents个事件 ;
  • events:用来从内核得到事件的集合 ;
  • maxevents:events数量,该maxevents值不能大于创建epoll_create()时的size ;
  • timeout:超时时间(毫秒,0会立即返回) ;

该函数返回需要处理的事件数目,如返回0表示已超时。

epoll的ET与LT模式

LT:延迟处理,当检测到描述符事件通知应用程序,应用程序不立即处理该事件。那么下次会再次通知应用程序此事件。 ET:立即处理,当检测到描述符事件通知应用程序,应用程序会立即处理。ET模式减少了epoll被重复触发的次数,效率比LT高。我们在使用ET的时候,必须采用非阻塞套接口,避免某文件句柄在阻塞读或阻塞写的时候将其他文件描述符的任务饿死。

epoll的函数调用流程

  1. 当调用epoll_wait函数的时候,系统会创建一个epoll对象,每个对象有一个evenpoll类型的结构体与之对应,结构体成员结构如下。

rbn,代表将要通过epoll_ctl向epll对象中添加的事件。这些事情都是挂载在红黑树中。
rdlist,里面存放的是将要发生的事件。

  1. 文件的fd状态发生改变,就会触发fd上的回调函数
  2. 回调函数将相应的fd加入到rdlist,导致rdlist不空,进程被唤醒,epoll_wait继续执行。
  3. 有一个事件转移函数——ep_events_transfer,它会将rdlist的数据拷贝到txlist上,并将rdlist的数据清空。
  4. ep_send_events函数,它扫描txlist的每个数据,调用关联fd对应的poll方法去取fd中较新的事件,将取得的事件和对应的fd发送到用户空间。如果fd是LT模式的话,会被txlist的该数据重新放回rdlist,等待下一次继续触发调用。

epoll的优点

  • 没有最大并发连接的限制
  • 只有活跃可用的fd才会调用callback函数
  • 内存拷贝是利用mmap()文件映射内存的方式加速与内核空间的消息传递,减少复制开销。(内核与用户空间共享一块内存)
  • 只有存在大量的空闲连接和不活跃的连接的时候,使用epoll的效率才会比select/poll高。

所以epoll相比select和poll,相当的利用最大连接数并节省内存

select poll epoll 对比

select/poll/epoll都是IO多路复用机制,可以同时监控多个描述符,当某个描述符就绪(读或写就绪),则立刻通知相应程序进行读或写操作。本质上select/poll/epoll都是同步I/O,即读写是阻塞的。

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

epoll优势
  1. 监视的描述符数量不受限制,所支持的FD上限是最大可以打开文件的数目,具体数目可以cat /proc/sys/fs/file-max查看,一般来说这个数目和系统内存关系很大,以3G的手机来说这个值为20-30万。
  2. IO性能不会随着监视fd的数量增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的,只有就绪的fd才会执行回调函数。

如果没有大量的空闲或者死亡连接,epoll的效率并不会比select/poll高很多。但当遇到大量的空闲连接的场景下,epoll的效率大大高于select/poll

kqueue

kqueue 是一种可扩展的事件通知接口。2000 年 7 月发布的 FreeBSD 4.1 中首次引入了 kqueue[1],随后也被 NetBSD、OpenBSD、macOS 等操作系统支持。

kqueue 在内核与用户空间之间充当输入输出事件的管线。因此在事件循环的迭代中,进行一次 kevent(2) 系统调用不仅可以接收未决事件,还可以修改事件过滤器。

支持 kqueue 且与操作系统无关的库:

  • libevent
  • libuv

其它平台上与 kqueue 等价的库:

  • Solaris、Windows、AIX:IOCP
  • Linux:
    • epoll 系统调用语义类似,但并不完全相同。epoll 在文件描述符可进行 I/O 操作时进行通知,而 kqueue 和 IOCP 都在请求的操作完成时进行通知。
    • inotify 是 Linux 上的内核子系统,可以在文件系统发生变化时通知应用程序。

libkqueue 是在用户空间实现的 kqueue(2),将调用翻译为操作系统本地的事件机制。

iocp

IOCP 输入输出完成端口(Input/Output Completion Port,IOCP), 是支持多个同时发生的异步I/O操作的应用程序编程接口,在Windows NT的3.5版本以后[1],或AIX 5版以后[2]或Solaris第十版以后,开始支持。

IOCP特别适合C/S模式网络服务器端模型。因为,让每一个socket有一个线程负责同步(阻塞)数据处理,one-thread-per-client的缺点是:一是如果连入的客户多了,就需要同样多的线程;二是不同的socket的数据处理都要线程切换的代价。


ACE

“重量级的C++ I/O框架,用面向对象实现了一些I/O策略和其它有用的东西,特别是它的Reactor是用OO方式处理非阻塞I/O,而Proactor是用OO方式处理异步I/O的( In particular, his Reactor is an OO way of doing nonblocking I/O, and Proactor is an OO way of doing asynchronous I/O).”

从很多实际使用来看,ACE是一个很值得学习的网络框架,但由于它过于重量级,导致使用起来并不方便。

ACE中提出了两种网络模式:Proactor和Reactor。

ASIO

“C++的I/O框架,逐渐成为Boost库的一部分。it’s like ACE updated for the STL era。”

支持select、epoll、IOCP等IO模型;

libevent

由Niels Provos用C编写的一个轻量级的I/O框架。它支持kqueue和select、poll和epoll。

1.4.11版还不支持windows的IOCP,但已经有很多开发者自己修改源码,把IOCP合并进去。


Java中的三种IO模型

Java IO模型和操作系统IO模型关系

Java中的IO还是借助操作系统的IO模型的,只不过是对操作系统IO模型的封装而已啦。

可以把Java中的BIO、NIO和AIO理解为是Java语言对操作系统的各种IO模型的封装。

Java中提供的IO有关的API,在文件处理的时候,其实依赖操作系统层面的IO操作实现的。

比如在Linux 2.6以后,Java中NIO和AIO都是通过epoll来实现的,而在Windows上,AIO是通过IOCP来实现的。

阻塞IO(BIO)

BIO全称是Blocking IO,是JDK1.4之前的传统IO模型,本身是同步阻塞模式,针对网络通信都是一请求一应答的方式,虽然简化了上层的应用开发,但在性能和可靠性方面存在着巨大瓶颈,试想一下如果每个请求都需要新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽,当然,我们可以通过线程池来优化这种情况,但即使是这样,仍然改变不了阻塞IO的根本问题,就是在IO执行的两个阶段都被block了。拿一个read操作来举例子,在linux中,应用程序向linux发起read操作,会经历两个步骤:

第一个阶段linux内核首先会把需要读取的数据加载到操作系统内核的缓冲区中(Linux文件系统是缓存IO,也称标准IO)

第二个阶段应用程序拷贝内核里面的数据到自己的用户空间中

如果是socket操作,类似也会经历两个步骤:

第一个阶段:通常涉及等待网络上的数据分组包到达,然后被复制到内核的缓冲区

第二个阶段:把数据从内核缓冲区,从内核缓冲区拷贝到用户进程的内存空间里面

同步阻塞IO之所以效率低下,就是因为在这两个阶段,用户的线程或者进程都是阻塞的,期间虽然不占cpu资源,但也意味着该线程也不能再干其他事。有点站着茅坑不拉屎的感觉,自己暂时不用了,也不让别人用。

非阻塞IO(NIO)

由于BIO的缺点,导致Java在JDK1.0至JDK3.0中,网络通信模块的性能一直是短板,所以很多人更倾向于使用C/C++开发高性能服务端。为了强化Java在服务端的市场,终于在JSR-51也就是JDK4.0的时候发布了Java NIO,可以支持非阻塞IO。并新增了java.nio的包,提供很多异步开发的API和类库。

主要的类和接口如下:

(1)进行异步IO操作的缓冲区ByteBuffer

(2)进行异步IO操作的管道Pipe

(3)进行各种IO操作的Channel,主要包括ServerSocketChannel和SocketChannel

(4)实现非阻塞IO的多路复用器Selector

NIO主要有buffer、channel、selector三种技术的整合,通过零拷贝的buffer取得数据,每一个客户端通过channel在selector(多路复用器)上进行注册。服务端不断轮询channel来获取客户端的信息。channel上有connect,accept(阻塞)、read(可读)、write(可写)四种状态标识。根据标识来进行后续操作。所以一个服务端可接收无限多的channel。不需要新开一个线程。大大提升了性能。

新的nio类库,促进了异步非阻塞编程的发展和应用,但仍然有一些不足之处:

(1)没有统一的文件属性,例如读写权限

(2)api能力比较弱,例如目录的及联创建和递归遍历,往往需要自己完成。

(3)底层操作系统的一些高级API无法使用

(4)所有的文件操作都是同步阻塞调用,在操作系统层面上并不是异步文件读写操作。

Java里面的NIO其实采用了多路复用的IO模式,多路复用的模式在Linux底层其实是采用了select,poll,epoll的机制,这种机制可以用单个线程同时监听多个io端口,当其中任何一个socket的数据准备好了,就能返回通知用户线程进行读取操作,与阻塞IO阻塞的是每一个用户的线程不一样的地方是,多路复用只需要阻塞一个用户线程即可,这个用户线程通常我们叫它Selector,其实底层调用的是内核的select,这里面只要任何一个IO操作就绪,就可以唤醒select,然后交由用户线程处理。用户线程读取数据这个过程仍然是阻塞的,多路复用技术只是在第一个阶段可以变为非阻塞调用,但在第二个阶段拷贝数据到用户空间,其实还是阻塞的,多路复用技术的最大特点是使用一个线程就可以处理很多的socket连接,尽管性能上不一定提升,但支持并发能力却大大增强了。

异步IO(AIO)

AIO,其实是NIO的改进优化,也被称为NIO2.0,在2011年7月,也就是JDK7的版本中发布,它主要提供了三个方面的改进:

(1)提供了能够批量获取文件属性的api,通过SPI服务,使得这些API具有平台无关性。

(2)提供了AIO的功能,支持基于文件的异步IO操作和网络套接字的异步操作

(3)完成了JSR-51定义的通道功能等。

AIO 通过调用accept方法,一个会话接入之后再次调用(递归)accept方法,监听下一次会话,读取也不再阻塞,回调complete方法异步进行。不再需要selector 使用channel线程组来接收。

从NIO上面我们能看到,对于IO的两个阶段的阻塞,只是对于第一个阶段有所改善,对于第二个阶段在NIO里面仍然是阻塞的。而真正的理想的异步非阻塞IO(AAIO)要做的就是,将IO操作的两个阶段都全部交给内核系统完成,用户线程只需要告诉内核,我要读取一块数据,请你帮我读取,读取完了放在我给你的地址里面,然后告诉我一声就可以了。

AIO可以做到真正的异步的操作,但实现起来比较复杂,支持纯异步IO的操作系统非常少,目前也就windows是IOCP技术实现了,而在Linux上,目前有很多开源的异步IO库,例如libevent、libev、libuv,但基本都不是纯的异步IO操作,底层还是是使用的epoll实现的。

NIO与Netty

既然Java拥有了各种IO体系,那么为什么还会出现Netty这种框架呢?

Netty出现的主要原因,如下:

(1)Java NIO类库和API繁杂众多,使用麻烦。

(2)Java NIO封装程度并不高,常常需要配合Java多线程编程来使用,这是因为NIO编程涉及到Reactor模式。

(3)Java NIO异常体系不完善,如客户端面临断连,重连,网络闪断,半包读写,网络阻塞,异常码流等问题,虽然开发相对容易,但是可靠性和稳定性并不高。

(4)Java NIO本身的bug,修复较慢。

注意,真正的异步非阻塞io,是需要操作系统层面支持的,在windows上通过IOCP实现了真正的异步io,所以Java的AIO的异步在windows平台才算真正得到了支持,而在Linux系统中,仍然用的是epoll模式,所以在Linux层面上的AIO,并不是真正的或者纯的异步IO,这也是Netty里面为什么采用Java的NIO实现的,而并非是AIO,主要原因如下:

(1)AIO在linux上底层实现仍使用EPOLL,与NIO相同,因此在性能上没有明显的优势

(2)Windows的AIO底层实现良好,但Netty的开发者并没有把Windows作为主要使用平台,所以优化考虑Linux

网络编程: Reactor与Proactor

epoll, kqueue是Reactor模式,IOCP是Proactor模式

1. 标准定义

两种I/O多路复用模式:Reactor和Proactor

一般地,I/O多路复用机制都依赖于一个事件多路分离器(Event Demultiplexer)。分离器对象可将来自事件源的I/O事件分离出来,并分发到对应的read/write事件处理器(Event Handler)。开发人员预先注册需要处理的事件及其事件处理器(或回调函数);事件分离器负责将请求事件传递给事件处理器。两个与事件分离器有关的模式是Reactor和Proactor。Reactor模式采用同步IO,而Proactor采用异步IO。

在Reactor中,事件分离器负责等待文件描述符或socket为读写操作准备就绪,然后将就绪事件传递给对应的处理器,最后由处理器负责完成实际的读写工作。

而在Proactor模式中,处理器--或者兼任处理器的事件分离器,只负责发起异步读写操作。IO操作本身由操作系统来完成。传递给操作系统的参数需要包括用户定义的数据缓冲区地址和数据大小,操作系统才能从中得到写出操作所需数据,或写入从socket读到的数据。事件分离器捕获IO操作完成事件,然后将事件传递给对应处理器。比如,在windows上,处理器发起一个异步IO操作,再由事件分离器等待IOCompletion事件。典型的异步模式实现,都建立在操作系统支持异步API的基础之上,我们将这种实现称为“系统级”异步或“真”异步,因为应用程序完全依赖操作系统执行真正的IO工作。

举个例子,将有助于理解Reactor与Proactor二者的差异,以读操作为例(类操作类似)。

在Reactor中实现读:

  • 注册读就绪事件和相应的事件处理器
  • 事件分离器等待事件
  • 事件到来,激活分离器,分离器调用事件对应的处理器。
  • 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。

在Proactor中实现读:

  • 处理器发起异步读操作(注意:操作系统必须支持异步IO)。在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。
  • 事件分离器等待操作完成事件
  • 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成。
  • 事件分离器呼唤处理器。
  • 事件处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分离器。

可以看出,两个模式的相同点,都是对某个IO事件的事件通知(即告诉某个模块,这个IO操作可以进行或已经完成)。在结构上,两者也有相同点:demultiplexor负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler;不同点在于,异步情况下(Proactor),当回调handler时,表示IO操作已经完成;同步情况下(Reactor),回调handler时,表示IO设备可以进行某个操作(can read or can write)。

2. 通俗理解

使用Proactor框架和Reactor框架都可以极大的简化网络应用的开发,但它们的重点却不同。

Reactor框架中用户定义的操作是在实际操作之前调用的。比如你定义了操作是要向一个SOCKET写数据,那么当该SOCKET可以接收数据的时候,你的操作就会被调用;而Proactor框架中用户定义的操作是在实际操作之后调用的。比如你定义了一个操作要显示从SOCKET中读入的数据,那么当读操作完成以后,你的操作才会被调用。

Proactor和Reactor都是并发编程中的设计模式。在我看来,他们都是用于派发/分离IO操作事件的。这里所谓的IO事件也就是诸如read/write的IO操作。"派发/分离"就是将单独的IO事件通知到上层模块。两个模式不同的地方在于,Proactor用于异步IO,而Reactor用于同步IO。

3. 备注

其实这两种模式在ACE(网络库)中都有体现;如果要了解这两种模式,可以参考ACE的源码,ACE是开源的网络框架,非常值得一学。

Reactor模式

Reactor模式跟IO模型关系

Reactor模式跟IO模型中的IO多路复用模型非常相似

IO多路复用模型可以看成是Reactor模式在IO模型上的应用

Reactor模式在进程-线程模型上的应用。

单进程单线程

只有一个进程,监听套接字和连接套接字上的事件都由 Select 来处理。

过程

  1. 如果有建立连接的请求过来,Acceptor 负责接受并与之建立连接,同时将连接套接字加入 Select 进行监听。

  2. 如果某个连接上有读事件则进行 Read->业务处理->Write 等操作。

  3. 如此循环反复。

缺点:会有阻塞,在进行业务处理的时候不能进行其他操作:如建立连接,读取其他套接字上的数据等。

单进程多线程

与单进程单线程类似,不同的是该模型将业务处理放在线程中,进程就不会阻塞在业务处理上。

优点:比较完美的进程-线程模型,在 Java 实现中复杂度也不高,很多网络库都是基于此,比如 Netty 。

多进程单线程

与非 Reactor 模式中的多进程单线程相似,只是本模式在子进程中使用了 IO 多路复用,实用性一下就上来了。大名鼎鼎的 nginx 就采用这种进程-线程模型。

缺点:子进程还是会阻塞在业务处理上。

多进程多线程

主从进程多线程

前面几种 Reactor 模式的进程-线程模型中,连接的建立和连接的读写都是在同一进程中。本模型中将连接的建立和连接读写放在不同的进程中。

过程

  1. 主进程在监听套接字上 Select(监听套接字) 阻塞,一旦有请求过来则与之建立连接,并将连接套接字传递给从进程。

  2. 从进程在连接套接字上 Select(连接套接字) 阻塞,一旦连接上有数据过来则进行 Read,并将业务通过线程来处理。如果有必要还会向连接 Write 数据。

常见组件使用的模型

  1. netty-主从-多线程

  2. tomcat-单进程多线程

  3. redis-单进程单线程

  4. ngnix-多进程单线程

资料

  • 3分钟彻底理解IO多路复用
  • IO多路复用是什么意思?
  • 100%让你弄明白5种IO模型
  • I/O子系统:select,poll,epoll,kqueue, iocp(Windows)及各种I/O复用机制
  • 各种IO模型,一篇打尽
  • select/poll/epoll对比分析

书籍

  • 《UNIX网络编程 卷1:套接字联网API(第3版)》
最近更新:: 2025/11/27 22:07
Contributors: luokaiwen