从零起步学习计算机操作系统:I/O篇

0 阅读6分钟

Q1:I/O是什么?

A1:

核心概念:什么是 I/O?🔄

定义

I/O = Input/Output(输入/输出) ,指数据在计算机内部(CPU/内存)与外部设备之间流动的过程。

 数据流动方向

输入 (Input):外部 → 内存
• 键盘输入 → 程序
• 磁盘文件 → JVM 堆
• 网络请求 → Socket 缓冲区
• 数据库查询结果 → 应用内存

输出 (Output):内存 → 外部  
• 程序日志 → 磁盘文件
• HTTP 响应 → 网络客户端
• 计算结果 → 数据库
• 监控指标 → Prometheus

I/O 的本质:速度不匹配的桥梁

图片来自www.mianshiya.com/

​编辑


I/O 的分类 

 按设备类型分类

类型示例特点Java 对应
磁盘 I/O读写文件、数据库慢(毫秒级)、顺序/随机访问FileInputStream, RandomAccessFile
网络 I/OHTTP/RPC/Socket慢(网络延迟)、流式传输Socket, HttpClient, Netty
终端 I/O键盘、屏幕、日志人机交互、缓冲输出System.in/out, Logger
设备 I/OGPU、网卡、USB专用协议、驱动交互JNI, JNA

Q2:为什么网络I/O会被阻塞?

A2:

核心原因 1:内核缓冲区没数据(Read 阻塞)

这是最常见的阻塞场景。当你调用 socket.read() 时,数据并不是直接从网卡到你的 Java 程序,中间经过了内核缓冲区

数据流动过程

 网络线路 →  网卡 →  内核接收缓冲区 (Kernel Receive Buffer) →  用户缓冲区 (Java Heap)

阻塞发生点

  1. Java 应用 调用 read()

  2. 操作系统 检查 内核接收缓冲区

  3. 判断

    • 有数据:拷贝到用户缓冲区,返回数据长度。
    • 无数据阻塞当前线程,将线程放入等待队列,直到网卡收到新数据并唤醒线程。

形象类比

 取快递: 你去快递柜(内核缓冲区)取包裹(数据)。

• 如果柜子里有货 → 直接拿走(返回)。

• 如果柜子是空的 → 你只能在旁边干等(阻塞),直到快递员把货放进柜子(网卡收到数据)。

Java 代码体现

Socket socket = new Socket("server", 8080);
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];

//  如果服务器不发数据,这里永远卡住!
int len = in.read(buf);  // 线程进入 BLOCKED/WAITING 状态(OS 层面)


核心原因 2:内核缓冲区满了(Write 阻塞)

写操作也会阻塞!很多人误以为 write() 只是把数据发到内存,应该很快。但如果网络慢,内存也会满。

阻塞发生点

  1. Java 应用 调用 write()

  2. 操作系统 尝试将数据拷贝到 内核发送缓冲区 (Kernel Send Buffer)

  3. 判断

    •  有空间:拷贝成功,立即返回(数据还在内核,没真正发出去)。
    •  空间满阻塞当前线程,等待 TCP 协议栈把缓冲区的数据发走,腾出空间。

为什么会满?

  • 网络带宽不足:发送速度 > 网卡发送速度。
  • 对端接收慢:对端的接收窗口(Receive Window)满了,触发 TCP 流控,本端停止发送。
  • 网络拥塞:TCP 拥塞控制 限制了发送速率。

Java 代码体现

OutputStream out = socket.getOutputStream();
byte[] data = new byte[10 * 1024 * 1024]; // 10MB 大数据

//  如果网络极慢或对端不读,这里会卡住!
out.write(data);  // 等待内核发送缓冲区腾出空间


核心原因 3:TCP 协议状态等待(Connect/Accept 阻塞)

在连接建立阶段,阻塞是由 TCP 状态机决定的。

1. Connect 阻塞(三次握手)

// 客户端
Socket socket = new Socket();
//  阻塞直到三次握手完成,或超时
socket.connect(new InetSocketAddress("server", 8080)); 

  • 原因:必须收到服务端的 SYN+ACK 才能建立连接。如果服务端挂了、防火墙丢了包、或网络延迟高,客户端会一直等(直到 TCP 超时,通常几十秒)。

2. Accept 阻塞(服务端等待连接)

// 服务端
ServerSocket server = new ServerSocket(8080);
//  阻塞直到有新连接请求到达
Socket client = server.accept(); 

  • 原因:内核监听队列(Backlog)中没有已完成的连接,线程必须等待。

Q3:I/O模型都有哪些?

A3:

5 种经典 I/O 模型(POSIX 定义)

1️⃣ 阻塞 I/O(Blocking I/O - BIO)

机制:两个阶段都阻塞。

  1. 应用发起 recvfrom
  2. 内核等待数据准备(阻塞)。
  3. 数据准备好后,内核拷贝到用户空间(阻塞)。
  4. 返回结果。
 应用线程:[ 阻塞等待数据 ][ 阻塞拷贝数据 ] → 继续执行
 内核:    [ 准备数据...  ][ 拷贝数据...  ]

  • 优点:编程简单,逻辑清晰。
  • 缺点:一个线程只能处理一个连接,并发低。
  • Java 对应java.io.Socket, ServerSocket

2️⃣ 非阻塞 I/O(Non-blocking I/O - NIO*)

机制:两个阶段都不阻塞(轮询)。

  1. 应用发起 recvfrom
  2. 内核立即返回(如果数据没准备好,返回 EWOULDBLOCK 错误)。
  3. 应用不断轮询(while 循环),直到数据准备好。
  4. 数据准备好后,再次发起拷贝。
 应用线程:[ 问:好了吗?❌ ][ 问:好了吗?❌ ][ 问:好了吗?✅ ][ 拷贝数据 ] → 继续
 内核:    [ 准备数据...  ][ 准备数据...  ][ 数据就绪    ][ 拷贝数据 ]

  • 优点:线程不阻塞,可以做别的事。
  • 缺点:轮询浪费 CPU 资源(忙等)。
  • Java 对应SocketChannel.configureBlocking(false)(单纯非阻塞模式,少用)。

 注意:Java 的 "NIO" 包其实主要用的是 模型 3(多路复用) ,而不是这个纯轮询的非阻塞 I/O。


3️⃣ I/O 多路复用(I/O Multiplexing)⭐ 最常用

机制:阶段 1 阻塞(监听),阶段 2 阻塞(拷贝)。

  1. 应用发起 select/poll/epoll,监听多个 FD(文件描述符)。
  2. 内核阻塞等待,直到任何一个FD 数据准备好。
  3. 返回就绪的 FD 列表。
  4. 应用对就绪的 FD 发起 recvfrom 拷贝数据。
 应用线程:[ 阻塞监听多个 FD ][ 收到就绪通知 ][ 拷贝数据 ] → 继续
 内核:    [ 监控多个连接... ][ 某个数据就绪 ][ 拷贝数据 ]

  • 优点:一个线程可以处理成千上万个连接,CPU 利用率高。
  • 缺点:编程复杂(需要处理就绪事件);数据拷贝阶段仍阻塞。
  • Java 对应java.nio.Selector, Netty, Redis, Nginx

4️⃣ 信号驱动 I/O(Signal Driven I/O)

机制:阶段 1 非阻塞(信号通知),阶段 2 阻塞(拷贝)。

  1. 应用开启信号驱动,发起 recvfrom 后立即返回。
  2. 内核准备数据,准备好后发送 SIGIO 信号给应用。
  3. 应用收到信号,发起 recvfrom 拷贝数据
 应用线程:[ 注册信号 ][ 做别的事... ][ 收到信号✅ ][ 拷贝数据 ] → 继续
 内核:    [ 准备数据... ][ 发送信号    ][ 拷贝数据 ]

  • 优点:等待数据时不阻塞。
  • 缺点:信号处理复杂,容易出错,Linux 支持不完善。
  • Java 对应:基本无直接支持。

5️⃣ 异步 I/O(Asynchronous I/O - AIO)⭐ 最理想

机制:两个阶段都不阻塞。

  1. 应用发起 aio_read,提供缓冲区指针和回调函数。
  2. 内核等待数据准备 拷贝到用户缓冲区。
  3. 全部完成后,通知应用(回调/信号)。
 应用线程:[ 发起请求 ][ 做别的事... ][ 收到完成通知✅ ] → 继续
 内核:    [ 准备数据... ][ 拷贝数据... ][ 发送通知    ]

  • 优点:真正的异步,应用完全不参与 I/O 过程。
  • 缺点:实现复杂,Linux 底层 AIO 支持不完善(性能有时不如 epoll)。
  • Java 对应java.nio.channels.AsynchronousSocketChannel(使用较少)。