Skip to content
Go back

Java AIO与io_uring——为什么AIO在Linux上是"伪异步"?

Java AIO 与 io_uring:什么才是真正的异步 IO?

一句话结论(30s)

同步非阻塞与异步的根本区别是「谁负责把数据读到用户缓冲区」——NIO 是内核通知就绪后你主动 read(),AIO 是内核读完回调你。Java AIO 在 Linux 上是「伪异步」,因为 POSIX aio_* 和 Java AIO 底层都用 epoll + 线程池模拟,内核并未接管读数据的全过程。核心权衡是:真正的异步需要内核配合(Windows IOCP / Linux io_uring),io_uring 靠共享环形队列 SQ/CQ 让提交与收割都免系统调用,代价是 JDK 官方至今无原生支持,只能靠 Netty 的 JNI 孵化项目。

核心原理(2min)

io_uring 的核心是 Shared Ring Buffer(共享环形缓冲区):应用把要做的 IO 操作写成 SQE 塞进 Submission Queue(纯用户态写,无系统调用),内核异步处理完把结果写成 CQE 放进 Completion Queue,应用再从 CQ 头读结果(同样无系统调用)。一次 io_uring_enter() 可同时批量提交 N 个 SQE 并收割 M 个 CQE,理想情况下(SQ 没满、CQ 有结果)完全不需要任何系统调用。对比之下,Java AIO 在 Linux 上走「提交读请求 → epoll 监听就绪 → 线程池线程去读 → 完成后回调」,本质仍是 NIO + 线程池封装;而 Windows IOCP 才是真内核异步——提交后内核直接与磁盘/网卡驱动交互,完成后投递完成包到完成端口,完全不消耗用户线程。

底层深入(5-10min)

BIO / NIO / AIO 的本质区别

BIONIOAIO
发起 IO 后线程阻塞等待立即返回(非阻塞)立即返回(异步)
数据就绪通知返回数据select() 返回就绪 fd内核回调通知
读数据同步等待主动去读内核读完回调通知

同步非阻塞 vs 异步的根本区别:谁负责”把数据读到用户缓冲区”。

NIO:selector.select() 告诉你就绪了,你主动调 channel.read(buf) 把数据读到 buf。你读数据时线程还是占着的(虽然不阻塞等待数据到达)。

AIO:提交读请求后立即返回。内核负责把数据读到你指定的 buf,完成后回调通知你。你在回调中直接处理数据——读数据的全过程线程都是自由的。

思考:NIO 和 AIO 表面上都「不阻塞」,凭什么说 AIO 才是异步?——关键看「谁把数据搬进用户缓冲区」。NIO 是内核通知就绪后,你的线程亲自 read() 搬运数据,搬运期间线程仍被占用;AIO 是内核替你读完再回调,搬运全过程线程都闲着。一个是「通知你该干活」,一个是「干完了通知你」。

Java AIO 在 Linux 上的尴尬

Linux 内核没有真正支持过的异步 IO API。 aio_* 系列系统调用(POSIX AIO)是 glibc 用线程池模拟的——内核并未接管读数据的全过程。Java AIO(AsynchronousFileChannelAsynchronousServerSocketChannel)在 Linux 上底层仍用 epoll 模拟——提交读请求 → 内核 epoll 监听就绪 → 线程池中的线程去读 → 完成后回调。本质上还是 NIO + 线程池的封装。

Windows IOCP(I/O Completion Port)是真正内核异步——提交 IO 请求后内核直接与磁盘驱动/网卡驱动交互,完成后投递完成包到完成端口,完全不消耗用户线程。

思考:Linux 内核 5.1 之前为什么一直「做不出」真正的异步 IO?——因为「内核替你读完数据」需要内核有专门的异步执行机制,早期 Linux 只有 aio_* 这种 glibc 线程池模拟,内核并未真正接管;而 IOCP 是 Windows 从内核层面就设计好的完成机制。真异步不是应用层能「封装」出来的,必须内核配合。

io_uring:Linux 终于有了真正的异步 IO(5.1+)

io_uring 核心:Shared Ring Buffer(共享环形缓冲区)

  Submission Queue (SQ)    ← 应用写入 SQE(想做的 IO 操作)
  Completion Queue (CQ)    ← 内核写入 CQE(已完成的 IO 结果)

  应用:往 SQ 尾写一个 SQE(用户态操作,无系统调用)
        → io_uring_enter() (批量提交 SQ, 批量收割 CQ)
        → 从 CQ 头读 CQE(用户态操作,无系统调用)
  
  内核:异步处理 SQE → 完成后写 CQE

一次 io_uring_enter() 系统调用 = 同时提交 N 个 IO 请求 + 收割 M 个已完成结果。 理想情况下(SQ 没满、CQ 有结果),不需要任何系统调用——完全在用户态通过共享内存环形缓冲区解决。

思考:io_uring 凭什么能「免系统调用」?——因为 SQ 和 CQ 是用户态与内核共享的一块内存环形队列。应用往 SQ 尾写 SQE、从 CQ 头读 CQE 都是普通内存读写,只有「批量提交 + 收割」那一下才需要 io_uring_enter() 进内核。对比 epoll 每次 epoll_wait 都要进内核,io_uring 把系统调用频次压到了极致。

Java 能用 io_uring 吗?

JDK 官方暂无 io_uring 支持。Netty 孵化了 io_uring 传输层(netty-incubator-transport-io_uring),用 JNI 包装 liburing。性能数据显示在小 IO 场景下延迟低于 epoll。

总结

平台真正异步 IOJava 实现
Linuxio_uring (5.1+)AIO ≈ epoll 模拟
WindowsIOCPAIO ≈ 真正的 IOCP 封装
macOS/BSDkqueueAIO ≈ 模拟

章末提问

追问 1:NIO 和 AIO 的本质区别是什么?

回答思路:结论先行——本质区别是「谁负责把数据读到用户缓冲区」。因为 NIO 是内核通知就绪后,你的线程主动 read() 搬运数据,搬运期间线程仍被占用;AIO 是提交读请求后内核替你读完再回调,读数据的全过程线程都是自由的。同步非阻塞 vs 异步,就卡在「数据搬运」这件事的归属上。

追问 2:为什么说 Java AIO 在 Linux 上是「伪异步」?

回答思路:结论先行——因为 Linux 底层用 epoll + 线程池模拟,内核并未真正接管读数据全过程。因为 POSIX aio_* 本来就是 glibc 用线程池模拟的,Java AIO(AsynchronousServerSocketChannel 等)在 Linux 上走「提交 → epoll 监听就绪 → 线程池线程去读 → 回调」,本质仍是 NIO + 线程池封装;而 Windows IOCP 才是内核驱动级真异步。

追问 3:io_uring 为什么能做到免系统调用?它的核心机制是什么?

回答思路:结论先行——核心是 SQ/CQ 共享环形缓冲区。因为应用往 Submission Queue 写 SQE、从 Completion Queue 读 CQE 都是纯用户态内存读写,不触发系统调用;只有批量提交/收割那一下才 io_uring_enter() 进内核。内核异步处理 SQE 后把结果写成 CQE,应用再读走,一次系统调用可同时提交 N 个请求 + 收割 M 个结果。


Share this post on:

Previous Post
Nginx vs Tomcat——为什么有了Nginx还要用Tomcat?
Next Post
JMeter压测方法论——从QPS概念到渐进式性能优化