用户空间与内核空间
在正文开始之前,先了解一下什么是内核空间,什么是用户空间。
内核空间 和用户空间是操作系统中的两个关键概念,它们在运行时具有不同的特性和权限。
- 内核空间 (Kernel Space):
- 内核空间是操作系统内核运行的区域,包括了操作系统内核代码、数据结构和设备驱动程序等。
- 内核空间通常是操作系统中的一块受保护的内存区域,只有操作系统内核才能够访问这个区域。
- 在内核空间中,操作系统可以执行任意命令,调用系统的一切资源。
- 用户空间 (User Space):
- 用户空间是指用户应用程序运行的区域,包括用户应用程序代码、数据和堆栈等。
- 用户空间中的
普通应用程序可以直接访问
,但不能直接访问硬件设备
。 - 用户空间中的进程运行在用户态,受到一定的限制,不能执行一些特权操作。
为了安全和隔离
,内核空间和用户空间是相互隔离的。即使用户的程序崩溃了,内核也不受影响。用户空间的应用程序需要通过系统接口(也称为系统调用)向内核发出指令,以便访问内核空间的功能和资源。
例如,当应用程序需要进行文件写入时,它必须切换到内核空间
,因为用户空间不能直接访问文件系统。内核空间负责处理文件写入操作,然后再切换回用户空间。
DMA(直接存储器访问)是计算机系统中的一项功能,允许某些硬件子系统独立于中央处理单元(CPU)访问主系统内存。
五种 IO 模型
阻塞IO(Blocking I/O)
在内核将数据准备好之前,系统调用会一直等待
所有的套接字。这个模型是我们最常见的,程序调用和我们编写的基本程序是一致的。一些传统的输入输出流基本都是阻塞 IO 模型。
举个例子:假如你有个女朋友,你们约好去约会,你女朋友要先化个妆,你在楼下等她,期间啥都不能做,就干等,你女朋友化了 2 个小时终于化完了,然后下楼,你们一起愉快的去约会了
非阻塞IO(Non-blocking I/O)
在没有数据报准备好时,也不阻塞程序,内核直接返回未准备就绪的信号,等待用户程序的下一个轮询。但是,轮询对于CPU来说是较大的浪费,一般只有在特定的场景下才使用。
还是假如你有个女朋友,你女朋友去化妆了,然后你就去做其他事情了,然后每隔 5 分钟给她打个电话,问她化完了没,如果没化完就继续做其他事情,如果化完了,就去陪她约会
信号驱动IO(Signal-driven I/O)
应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。
信号驱动看似很好,但是缺点也很大
优点:
- 相较于非阻塞IO,信号驱动IO具有资源利用更加充分的优势。
- 信号到来后就
直接强行中断
进行处理,更加实时。
缺点:
- 信号驱动IO的流程较为复杂,因为需要
自定义信号
,又要有主控流程也要有信号处理流程
,并且还需要考虑信号是否可靠
导致的事件丢失情况。 - 无法指定需要监控的事件类型。例如,在TCP套接口中,信号驱动IO几乎是没用的,原因是该
信号产生得过于频繁
,并且该信号的出现并没有告诉我们发生了什么事情。 - 总的来说,信号驱动IO模型在某些场景下可以提供较高的性能,但其
实现较为复杂
,且在某些应用中可能不太适用。
还是假如你有个女朋友,你跟她说,她化完妆然后打你的电话通知你,这你就不用每隔一段时间去打电话问她了,这电话一来一回也是需要时间的。如果她来信息了,哪怕你是拉屎拉一半都要暂停拉屎,去跟她约会
IO多路转接(I/O Multiplexing
IO多路转接是多了一个select函数,select函数有一个参数是文件描述符集合
,对这些文件描述符进行循环监听
,当某个文件描述符就绪时,就对这个文件描述符进行处理。
-
select
- select函数监视的
文件描述
符分3类,分别是writefds
、readfds
、和exceptfds
。 - 当用户进程调用select的时候,select会将需要监控的
readfds集合
拷贝到内核空间,然后遍历自己监控的socket buffer
,挨个调用socket buffer的poll逻辑以便检查该socket是否有可读事件
。 - 遍历完所有的socket buffer后,如果没有任何一个socket可读,那么select会调用schedule_timeout进入schedule循环,使得进程进入睡眠。
- 如果在timeout时间内某个socket上有数据可读了,或者等待timeout了,则调用select的进程会被唤醒,接下来select就是遍历监控的集合,挨个收集可读事件并返回给用户。
- select函数监视的
-
poll
- poll函数与select函数类似,但是
没有最大连接数的限制
,因为poll使用的是链表结构
而不是select使用的数组结构
。 - poll函数同样也会将需要
监控的文件描述符
集合拷贝到内核空间,然后遍历自己监控的socket buffer,挨个调用socket buffer的poll逻辑以便检查该socket是否有可读事件。原理同上。
- poll函数与select函数类似,但是
-
epoll
-
epoll是Linux特有的IO多路复用机制,它的效率比select和poll更高。
-
epoll使用一组函数(
epoll_create
、epoll_ctl
、epoll_wait
)来完成任务。 -
当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果;内核把整个IO处理完后,会通知进程结果。
-
如果IO操作成功则进程直接获取到数据。
-
epoll模型(selector模型)还有不足,就是如果当epoll_wait方法返回了10w个就绪事件,就需要等待这10w个就绪事件处理完成,才能继续下面的命令,去响应新的事件,这样就容易让新的事件超时。
还是假如你有个女朋友,select 就是宿管阿姨,她监听着每个宿舍,每隔一段时间去看你的女朋友们化好妆了没,发现谁的女朋友化好妆了就通知她男朋友过来接她(这里就是相当于 select 监听这每个文件描述符,如果数据已经完全加载到了内核缓冲区中,文件描述符的状态就会从 0 改成 1,select 每次都遍历所有的文件描述符,发现文件描述符改成1 之后,就通知它的调用线程来进行后续处理)
-
异步IO(Asynchronous I/O)
-
基本概念:
- 在异步I/O模型中,应用程序发起I/O请求后,内核会立即返回,而不会阻塞应用程序。
- 内核会在I/O操作完成后通知应用程序,以便获取I/O结果。
-
特点:
- 非阻塞:应用程序不会被I/O操作阻塞,可以继续执行其他任务。
- 高并发:一个线程可以同时处理多个I/O请求,无需切换线程。
- 数据一步到位:I/O操作成功后,应用程序直接获取数据。
NIO
很多地方说 NIO 也属于非阻塞 IO 的一种,它是一种同步非阻塞 IO,笼统来说也确实是。
NIO 是从 Java 1.4 版本开始引入的一个新的 I/O API,它可以
替代标准的 Java I/O API
。虽然 NIO 和原来的 I/O 有相同的作用和目的,但使用方式完全不同。让我们逐步了解一些核心概念:
通道和缓冲区
- **通道(Channel):**表示打开到 I/O 设备(例如文件、套接字)的连接。在使用 NIO 系统时,我们需要获取用于
连接 I/O 设备
的通道
以及用于容纳数据的缓冲区。。 - **缓冲区(Buffer):**是一个用于特定基本数据类型的容器。Java NIO 中的所有缓冲区都是 Buffer 抽象类的子类。它们用于存储不同类型的数据,例如 ByteBuffer、CharBuffer、ShortBuffer 等。
简而言之,通道负责传输,缓冲区负责存储。好比于通道是一条铁路,缓冲区是一辆火车,火车就装着数据,一车一车运过去
面向流和面向缓冲区
- **传统 I/O 流:**原来的 I/O 面对的是直接在管道中流动的
字节数据
。它是单向
的,需要建立输入流
和输出流
。 - **NIO:**NIO 也是为了完成数据传输,但它使用
通道
来连接
目标地点和源地点。通道本身不能传输数据,必须依赖缓冲区
。缓冲区就像火车,通过通道进行数据传输。
缓冲区的数据存取
- 缓冲区提供了 put() 和 get() 方法来存取数据。
- 缓冲区有四个核心属性:
- 容量(capacity):表示缓冲区中最大存储数据的容量,一旦声明后不能更改。
- 界限(limit):表示缓冲区中可以操作数据的大小,limit 后的数据不能进行读写。
- 位置(position):表示缓冲区中正在操作数据的位置。
- 标记(mark):记录当前 position 的位置,可以通过 reset() 恢复到 mark 的位置。
java
public class TestBuffer {
public static void main(String[] args) {
String str = "abcde";
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
System.out.println("Capacity: " + byteBuffer.capacity()); // 1024
System.out.println("Limit: " + byteBuffer.limit()); // 1024
System.out.println("Position: " + byteBuffer.position()); // 0
byteBuffer.put(str.getBytes()); // 写入缓存
byteBuffer.flip(); // 切换为读模式 默认是写模式
System.out.println("Limit: " + byteBuffer.limit()); // 5
System.out.println("Position: " + byteBuffer.position()); // 0
byte[] dst = new byte[byteBuffer.limit()];
byteBuffer.get(dst); // 读取
System.out.println(new String(dst, 0, dst.length)); // "abcde"
byteBuffer.rewind();
System.out.println("Position after rewind: " + byteBuffer.position()); // 0
}
}
NIO 使用案例
java
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
public class NIOFileReaderExample {
public static void main(String[] args) {
// 定义文件路径
Path filePath = Paths.get("file.txt");
try (FileChannel fileChannel = FileChannel.open(filePath, StandardOpenOption.READ)) {
// 创建一个缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从文件通道读取数据到缓冲区
int bytesRead;
while ((bytesRead = fileChannel.read(buffer)) != -1) {
buffer.flip(); // 切换到读模式
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get()); // 逐字节打印到控制台
}
buffer.clear(); // 清空缓冲区,准备下一次读取
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
疑问?
既然NIO这么牛,为什么我在做项目的时候却几乎没有用到呢?
- 历史原因 :
- 传统的 IO 是 Java 最早引入的 I/O 操作方式,因此在许多旧项目中仍然广泛使用。
- 开发人员可能对传统的 IO 更熟悉,因此不愿意迁移到新的 NIO。代码能跑就不动。
- 简单性 :
- 传统的 IO 提供了简单的、阻塞式的 I/O 模型,适用于许多简单的场景。
对于小规模的文件读写,IO 流的使用更加直观
。
- NIO 的复杂性 :
- NIO 引入了
更复杂的概念
,如通道、缓冲区和选择器。 - 使用 NIO 需要更多的代码和理解,因此在某些情况下,开发人员可能更愿意使用传统的 IO。
- NIO 引入了
- 性能考虑 :
- NIO 的设计目标是
提高 I/O 操作的效率
,特别是在处理大量并发连接时。 - 但并不是所有项目都需要高度并发的能力。对于简单的文件读写,传统的 IO 也足够。
- NIO 的设计目标是
NIO 适用于什么类型的场景
- 高并发连接 :
- NIO 适合处理大量并发连接,例如聊天室、即时通讯、多人在线游戏等。
- 通过 NIO 的非阻塞模式,可以使用一个线程来处理多个连接,减少线程开销。
- 长连接 :
- 长连接指的是客户端和服务器之间保持持久连接的情况,例如 WebSocket、推送服务等。
- NIO 可以有效地管理多个长连接,而不需要为每个连接创建一个线程。
- 大文件处理 :
- 如果需要处理
大文件
,NIO 的缓冲区和通道机制可以提高效率。 - 通过 NIO,可以将文件
分块读取到缓冲区
,然后进行处理。
- 如果需要处理
- 网络编程 :
- NIO 适用于
网络编程
,特别是需要高伸缩性的网络应用。 - 例如 Web 服务器、代理服务器、负载均衡器等。
- NIO 适用于
- 异步操作 :
- NIO 的选择器(Selector)允许异步地处理多个通道上的事件。
- 适用于需要同时处理多个读写事件的场景。