UNIX 网络编程 与 Netty 笔记
该笔记以模块为单位,记录重点知识。学习详情可参考《UNIX 网络编程》《Netty 权威指南》图书
Netty 面试题 视频(对着视频标题自己讲一遍)
1. 网络模型 + TCP 连接建立/终止 + 端口号/缓冲区相关概念OSI 模型国际标准化组织(ISO)制定的开放互联(OSI)模型,是七层模型,概念模型。
我们关注的套接字编程接口是顶上三层(网际协议的应用层)进入传输层的接口,为什么接口选在这个地方呢?原因有两个:
应用层处理具体网络应用(如FTP、Telnet或HTTP)的细节,却对通信细节了解很少;底下四层对具体网络应用了解不多,却处理所有的通信细节(发送数据,等待确认,给无序到达的数据排序,计算并验证校验和)
应用层通常构成所谓的用户进程(user process),底下四层却通常作为操作系统内核的一部分提供。Unix与其他现代操作系统都提供分隔用户进程与内核的机制。
由此可见,第4层和第5层之间的接口是构建API的自然位置。
传输层:TCP连接建立与终止UDP是一个简单的、不可靠的数据报协议,而TCP是一个复杂、可靠的字节流协议。
2. Linux 五种网络 I/O 模型重要!!5 种网络 IO 模型(注意,这是操作系统层面的IO模型,不是 Java NIO AIO)
搭配 https://chat.openai.com/share/30ca95a2-dfea-43bf-8592-593f30fc545a 链接学习
阻塞 I/O
在进程空间中调用 recvfrom(),发生上下文切换,用户进程进入阻塞状态 。该系统调用直到 数据报到达且被复制到应用进程缓冲区 或 发生错误时才返回,然后用户进程退出阻塞状态,继续执行后续操作。
12char buffer[1024];int n = read(fd, buffer, sizeof(buffer)); // 如果没有数据,进程被挂起
非阻塞 I/O
和 阻塞 I/O 区别就是应用进程反复调用 recvfrom,轮询内核,问“数据准备好了吗??”,没准备好就返回EWOULDBLOCK错误,准备好就正常数据复制内核->用户,返回成功!
说白了,就是 用户进程 不是在等(阻塞),而是在问(非阻塞)
之前一直不理解非阻塞IO的优势(轮询等待不也是等待吗,而且还占用cpu,不像BIO,虽然进程阻塞,但是能挂起乖乖交出cpu),后来看到这句话:轮询的时候,用户线程可以执行其他任务而不是阻塞(轮询操作是应用程序在用户态手动实现的,而不是操作系统实现的)
12345678910111213141516171819202122char buffer[1024];int flags = fcntl(sockfd, F_GETFL, 0); // 获取当前文件描述符的标志fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // 设置非阻塞模式while (1) { ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer), 0, NULL, NULL); if (n == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 数据未准备好,执行其他任务 printf("Data not ready. Performing other tasks...\n"); perform_other_tasks(); // 调用其他任务的函数 continue; // 回到循环,继续检查数据 } else { // 处理其他错误 perror("recvfrom"); break; } } // 数据已准备好,处理接收到的数据 printf("Received %zd bytes: %s\n", n, buffer); break;}
I/O 多路复用本质是:一个线程同时管理多个 I/O 通道(连接)
和gpt的聊天:https://chatgpt.com/share/67594b41-ff7c-800a-af3e-0e61720981f1
IO复用模型,本质是从阻塞在一个套接字上,变成阻塞在多个套接字上(不准确,但是可以这么理解。其实是阻塞在一个系统调用上 select/poll/epoll,这个系统调用关联多个套接字,只要有一个socket可读,就返回可读条件,这时候再去执行recvfrom调用,无需进行数据等待,直接复制数据到用户空间并成功返回!)
Linux 提供 select/poll,进程通过一个或多个 fd 传递给 select 或 poll 系统调用,阻塞在 select 操作上,这样 select/poll 可以帮我们侦测多个 fd 是否处于就绪状态。
select/poll 是顺序扫描 fd 是否就绪,而且支持的 fd 数量有限,因此它的使用受到一些制约。
Linux 还提供一个 epoll 系统调用,epoll 基于事件驱动方式代替顺序扫描,性能更高。当有 fd 就绪时,立即回调函数 rollback。
用户线程视角下的统一性
select/poll:用户线程阻塞在 select 或 poll 调用上,等待内核返回有事件的文件描述符集合。
epoll:用户线程阻塞在 epoll_wait 调用上,同样等待内核返回有事件的文件描述符。
从用户线程的角度来看,它们的行为模式是一样的:
注册需要监控的文件描述符和感兴趣的事件。
阻塞等待系统调用返回。
处理内核通知的就绪事件。
优化针对内核层
select/poll:内核需要在每次调用时遍历所有注册的文件描述符,检查是否有事件发生。这种线性扫描的方式在文件描述符数量很大时会导致性能瓶颈。
epoll:内核通过高效的数据结构(如红黑树和就绪队列)管理文件描述符,只需在事件发生时将就绪的文件描述符加入就绪队列,避免了每次调用时的全量扫描。这种机制极大提高了性能,但仅影响内核内部的实现。
对于用户线程来说,它只感知到内核通知哪些文件描述符有事件,而不关心内核具体是如何检测事件的。
信号驱动 I/OIO 多路复用(epoll)是内核层面的事件驱动,用户层面还是阻塞的;信号驱动 IO 是用户层面的事件驱动,但是具体的 IO 操作(数据复制)还得自己做;
”等数据准备好的时候告诉我,我先忙我自己的事情“
信号驱动 I/O 是 Linux 提供的一种 异步 I/O 通知机制,它通过 信号机制 实现对 I/O 事件的监听与处理。其核心思想是:应用程序注册一个信号处理函数(回调函数),当某个文件描述符上有 I/O 事件(如数据可读或可写)发生时,内核会发送信号(通常是 SIGIO 信号)通知应用程序执行回调函数进行处理。
信号处理机制中有两个主要阶段:
内核发送信号:当某个文件描述符上有 I/O 事件(如数据可读或可写)发生时,内核会检测到这个事件并向进程发送信号(通常是 SIGIO)。
这部分逻辑是由内核负责的,应用程序不需要关心如何发送信号。
不是回调函数的逻辑。
应用程序处理信号:收到信号后,内核会中断应用程序的正常执行并调用预先注册的信号处理函数(回调函数)。
这个信号处理函数(回调函数)由应用程序定义,主要逻辑是完成与信号相关的操作,如读取数据、记录日志等。
这是回调函数的逻辑。
回调函数逻辑的执行者还是应用程序,只是是由内核触发的
另外,信号驱动 I/O 和 I/O 多路复用的原理是不同的,信号驱动 I/O 不依赖 select/poll/epoll 等系统调用,而是基于 Linux 的信号机制实现的。
什么是回调函数?
回调函数(Callback Function)是一种通过某种机制,在特定事件发生时被自动调用的函数。它的核心特点是:
提前注册:回调函数在某一时刻被注册,供后续使用。
事件触发:回调函数在特定事件发生时被调用。
由外部系统调用:回调函数通常由某个框架、库或操作系统自动调用,而不是由程序显式调用。
信号处理函数具备这三个特点,因此它是一种典型的回调函数。
异步 I/O
异步IO就是,告诉内核执行某个操作,并让内核在整个操作(包括将数据从内核复制到用户缓冲区)完成后通知我们。
与信号驱动模型的区别:信号驱动式IO是由内核通知我们何时可以启动一个IO操作(数据复制),而异步IO模型是由内核通知我们I/O操作何时完成。
信号驱动 IO:喂,可以进行 IO 操作了~
异步 IO :喂,IO操作已经完成了~
与前面模型的根本区别:数据复制(内核->用户)都省的做了!
什么是异步 I/O?
在异步 I/O 模型中,I/O 操作(如读取或写入)是真正的异步:
应用程序发起一个 I/O 请求(如 aio_read 或 aio_write)。
内核负责完成整个 I/O 操作(包括数据的传输)。
内核在 I/O 完成后通知应用程序(通过信号、回调函数或其他机制)。
在异步 I/O 模型下,I/O 操作的实际执行完全由内核处理,用户程序无需等待 I/O 完成即可继续执行其他任务。
异步 I/O 的底层机制
异步 I/O 是通过 Linux 提供的 libaio(用户态的异步 I/O 库)或 POSIX AIO 接口实现的。
核心调用是 aio_read、aio_write 和 aio_suspend,这些接口通过向内核发送异步 I/O 请求,将 I/O 任务交由内核的 AIO 子系统处理。
异步 I/O 与 select/poll/epoll 的关系
(1)是否依赖 select/poll/epoll?
异步 I/O 不依赖 select/poll/epoll
异步 I/O 的核心机制是 完全交给内核去完成 I/O 操作,而不是依赖用户程序主动轮询文件描述符的状态。
内核会在 I/O 操作完成后通知应用程序,或者应用程序可以调用 aio_suspend 等接口等待异步 I/O 结果。
(2)为什么不依赖?
select/poll/epoll 本质上是同步机制,它们让用户程序主动等待文件描述符的事件就绪(可读、可写等),实际的 I/O 操作仍需由用户程序完成。
异步 I/O 则是由内核主动完成整个 I/O 操作,包括数据传输、缓存管理等。
总结
!!!一定要了解异步IO的底层操作系统原理!!https://developer.aliyun.com/article/1181464(博客说的不对,你找一下错误)
这里可能不太准确,严格来讲 异步IO原理和 epoll 无关:
epoll 的异步性在于事件通知,而不是 I/O 操作
epoll 的异步性体现在:
内核异步监控文件描述符的状态:当某个文件描述符发生可读、可写等事件时,内核会将其标记为“就绪”并加入事件队列。
用户程序调用 epoll_wait 时,内核直接返回这些已经就绪的事件。
但 epoll 的异步性仅限于 事件通知,而 真正的 I/O 操作(如 read 或 write)仍然是由用户程序同步完成的。
用户程序调用 epoll_wait 得到一个文件描述符就绪的通知后,必须显式调用 read 或 write 来完成数据的读写。
如果 read 或 write 是阻塞的,那么这部分操作仍然是同步的。
2. 为什么可以将 epoll 理解为内核层面的异步 I/O?
从“广义异步 I/O”的角度,epoll 的行为具有异步 I/O 的特性,特别是以下几点:
内核层面的异步事件监控:
内核会异步地(非阻塞地)监控注册的文件描述符,当事件发生时,内核会自动将事件加入事件队列,无需用户程序轮询状态。
高效的事件驱动机制:
epoll 使用事件驱动模型(而非 select 和 poll 的同步遍历模型),可以在内核中异步管理文件描述符状态。
用户程序无需主动轮询文件描述符:
用户程序只需要调用 epoll_wait,而不需要逐一检查每个文件描述符的状态,这种机制是异步事件通知的表现。
3. Java IO模型在面试中,BIO、NIO 和 AIO 是 Java 中常见的 I/O 模型,考察点通常包括它们的概念、工作机制、优缺点以及适用场景。以下是详细的解析:
1. BIO(Blocking I/O,阻塞 I/O)1.1 概念
BIO 是传统的 Java I/O 模型,所有的 I/O 操作(如 InputStream 和 OutputStream)都是阻塞式的。
在服务器端,每当有一个客户端连接时,服务器会为其创建一个独立的线程来处理这个连接。
如果某个线程正在等待 I/O(如读取数据),那么线程会被阻塞,直到数据准备好。
1.2 工作机制
每个线程只能处理一个客户端连接,一对一的线程模型。
如果有大量客户端连接,服务器需要为每个连接创建一个线程,线程资源消耗较大。
1.3 优缺点优点:
模型简单,代码实现清晰,易于理解和使用。
适合小规模、低并发场景。
缺点:
阻塞问题:线程在等待 I/O 操作时会被阻塞,导致资源浪费。
扩展性差:当并发连接数增多时,服务器需要创建大量线程,可能导致线程上下文切换成本过高,甚至引发 OutOfMemoryError。
1.4 适用场景
BIO 适用于连接数较少、业务处理较重的场景,例如管理后台系统。
示例:简单的文件操作、控制台程序。
2. NIO(Non-blocking I/O,非阻塞 I/O)2.1 概念
NIO 是 Java 1.4 引入的新 I/O 模型,解决了传统 BIO 的阻塞问题。
NIO 是基于 多路复用(Selector)的事件驱动模型,通过一个线程可以同时管理多个连接。
核心组件:
Channel:双向通信通道,类似于 InputStream 和 OutputStream。
Buffer:用于存储数据,解决传统 I/O 中流式数据的缺陷。
Selector:多路复用器,可以同时监控多个通道的状态(如是否可读或可写)。
2.2 工作机制
NIO 的通道是非阻塞的,线程可以请求读写操作,但不会因为数据没准备好而阻塞。
通过 Selector,单个线程可以同时监听多个 Channel 的事件,类似于操作系统的 epoll。
当某个通道有 I/O 事件(如数据可读)发生时,线程会从 Selector 得到通知,然后处理该通道的事件。
2.3 优缺点优点:
非阻塞:线程不会因为等待 I/O 而被阻塞。
高并发:单个线程可以管理多个连接,线程资源需求大大减少。
高性能:在高并发场景下性能优于 BIO,尤其是随着连接数的增加。
缺点:
复杂性增加:需要额外的代码逻辑处理 Buffer 和 Channel,编程难度比 BIO 高。
数据读写效率:虽然减少了线程切换的开销,但单次操作的数据量可能较小。
2.4 适用场景
NIO 适合连接数较多、业务处理较轻的场景,例如高并发的即时通讯服务器。
示例:聊天室服务器、大规模网络服务。
3. AIO(Asynchronous I/O,异步 I/O)3.1 概念
AIO 是 Java 7 引入的新特性(NIO.2),也是 真正的异步非阻塞 I/O 模型。
在 AIO 中,I/O 操作由操作系统完成,完成后通过回调机制通知用户程序,线程无需主动监控和处理。
核心组件:
AsynchronousChannel:支持异步操作的通道。
CompletionHandler:回调接口,处理操作完成的通知。
3.2 工作机制
用户程序提交 I/O 请求(如读写操作)后,不需要等待操作完成,直接返回继续处理其他任务。
I/O 操作完成后,操作系统会通过回调函数(CompletionHandler)通知用户程序进行后续处理。
3.3 优缺点优点:
真正的异步非阻塞:线程无需等待或轮询,操作系统完成 I/O 后会主动通知。
线程资源占用少:线程可以充分利用 CPU 时间,适合高吞吐量场景。
缺点:
依赖操作系统:AIO 的性能取决于底层操作系统对异步 I/O 的支持(如 Windows 的 IOCP)。
复杂性高:编程模型比 NIO 更复杂,错误处理、回调逻辑等需要更多的关注。
3.4 适用场景
AIO 适合连接数多、业务处理较重的场景,例如视频处理、文件传输。
示例:高吞吐量文件服务器、大规模异步通信服务。
4. 对比总结
特性
BIO(阻塞 I/O)
NIO(非阻塞 I/O)
AIO(异步 I/O)
线程模型
一个线程对应一个连接
一个线程管理多个连接
一个线程管理所有连接
阻塞与否
阻塞
非阻塞
异步非阻塞
核心机制
多线程模型
多路复用(Selector)
异步回调机制
复杂性
简单
中等
高
适用场景
低并发、业务处理较重的场景
高并发、连接数较多的场景
高吞吐量、延迟敏感的场景
性能
并发能力弱,线程切换开销大
较高,适合高并发
更高,但依赖操作系统的支持
编程模型
简单,容易理解
使用 Channel 和 Buffer,较复杂
使用异步回调,复杂度更高
5. 面试技巧总结如何回答面试官的问题?
概念清晰:分别解释 BIO、NIO 和 AIO 的基本概念。
机制说明:说明每种模型的工作机制,尤其是它们如何处理 I/O 请求。
优缺点对比:通过表格或关键词概括每种模型的优缺点。
场景匹配:结合实际应用场景,说明适合的使用场景。
示例回答
在 Java 中,I/O 模型可以分为 BIO、NIO 和 AIO:
BIO(Blocking I/O) 是阻塞式的 I/O 模型,每个连接对应一个线程,适合低并发场景,简单易用,但扩展性差。
NIO(Non-blocking I/O) 是基于非阻塞和多路复用的模型,一个线程可以同时管理多个连接,适合高并发场景,性能更好,但实现复杂。
AIO(Asynchronous I/O) 是真正的异步非阻塞模型,I/O 操作由操作系统完成,通过回调机制通知程序,适合高吞吐量场景,但依赖操作系统支持,复杂度高。
Java NIO 和底层 Linux 的 I/O 模型有很大的关联。
Java BIO 对应于 Linux 的阻塞 I/O 模型,它的 read 和 write 方法底层直接调用阻塞式系统调用。
Java NIO 通过非阻塞 Channel 和多路复用 Selector,对应 Linux 的 非阻塞 I/O 和 I/O 多路复用(如 epoll),使单线程能够管理多个连接,提升并发性能。
Java AIO 则对应于 Linux 的 异步 I/O 模型,使用操作系统完成整个 I/O 操作,并通过回调或通知的方式实现真正的异步处理。
总的来说,Java 的 I/O 模型是对底层操作系统 I/O 模型的一种抽象,不同场景下可以选择合适的模型,例如 BIO 适合简单场景,NIO 适合高并发场景,AIO 适合高吞吐量和异步处理的场景。
数据复制是谁来做的? Linux五种IO模型和Java IO有什么关系,和Netty中的IO有什么关系?Netty基于异步通信,那这里的异步和IO模型的异步有社么区别?事件驱动?NIO?
!!!讲一下netty中怎么用异步IO,怎么实现异步通信(区别NIO 和 异步通信),你的项目怎么实现的异步非阻塞通信??
我们调用 aio_read 函数(POSIX异步IO函数以aio_或lio_开头),给内核传递描述符、缓冲区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,而且在等待IO完成期间,我们的进程不被阻塞。本例子中我们假设要求内核在操作完成时产生某个信号。该信号直到数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动式I/O模型。
(一)BIO
采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的一请求一应答通信模型。(书上有源码)
优点:简单
缺点:一个连接就对应一个线程,耗资源;线程会阻塞。
(二)伪异步IO
当有新的客户端接入时,将客户端的 Socket 封装成一个 Task (该任务实现 java.lang.Runnable 接口)投递到后端的线程池中进行处理,JDK 的线程池维护一个消息队列和 N个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。(书上有源码)
优点:线程资源可控
缺点:阻塞的本质没变
Netty 是 基于 NIO 的异步网络编程框架,基于 Netty 能快速的搭建高性能易扩展的网络应用(包括客户端与服务端)。
说白了,Netty 就是对 Java NIO 的封装!!!(想知道什么是NIO,就又引向了五种IO模型)
以前一直不明白,NIO 是 非阻塞同步 IO,那为什么 Netty 是异步的网络框架呢?
问题:感觉 Netty 的 NIO 和 5 中 IO模型的 非阻塞 IO模型 不一样,为什么?
https://www.jianshu.com/p/df1d6d8c3f9d
区分好UNIX IO模型,Java NIO,Netty NIO。我的理解是,(1)UNIX IO模型更像是一种范式、思想,一种操作系统上的实现;
(2)Java NIO是在Java高级语言层面实现的同步非阻塞IO,会调用底层操作系统的NIO,同时也弥补了传统阻塞IO、非阻塞IO模型的缺点。如果下一次面试官问你Java NIO和NIO的区别是什么?你要意识到他更希望你说出IO多路复用的细节和Java NIO中的Buffer, Channel和Selector以及他们的工作流程。(https://zhuanlan.zhihu.com/p/169589455);
(3)Netty NIO是基于Java NIO的框架。
I/O 模型
描述
特点
阻塞 I/O
I/O 操作阻塞,直到数据准备好或完成。
简单,但低效,线程容易被阻塞。
非阻塞 I/O
I/O 操作立即返回,若未完成则需要反复检查(轮询)。
CPU 消耗大,需要不断轮询检查。
I/O 多路复用
通过 select、poll、epoll 等机制,监听多个文件描述符。
单线程管理多 I/O,提高并发能力。
事件驱动 I/O
基于事件通知机制,事件触发后回调处理数据。
高效、低开销,适合高并发场景。
异步 I/O(AIO)
操作系统完成 I/O 后,主动通知应用程序(与事件驱动不同)。
完全异步,较复杂,依赖操作系统支持。
Linux 五种IO模型 –> Java NIO(同步非阻塞) –> Netty 基于 Java NIO 的异步通信 –> 项目中的基于 Netty 的异步通信==把这条线捋清楚!!!!!!==
4. I/O 多路复用:select/poll/epoll参考文章:I/O 多路复用:select/poll/epoll
# select/pollselect 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其外理。
所以,对于 select 这种方式,需要进行 2 次【遍历】文件描述符集合,一次是在内核态里,一个次是在用户态里,而且还会发生 2 次【拷贝】文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符个数有限,在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 1024 ,只能监听 0~1023 的文件描述符
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表来组织,突破了select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用【线性结构】 存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
# epollepoll 通过两个方面,很好解决了 select/poll 的问题
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的 socket,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn) 。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点,epoll 使用事件驱动的机制,内核里维护了一个链表记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高检测效率。
epoll 的方式即使监听 Socket 数量越多,效率不会大幅线性降低,能够同时监听的 Socket 的数也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。
# 边缘触发和水平触发epoll 支持两种事件触发模式,分别是 边缘触发(edge-triggered,ET) 和 水平触发(level-triggeredLT)
使用边缘触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完。
使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取
举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。
这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。
如果使用水平触发模式,当内核通知 fd 可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行所以,边缘触发模式一般和非阻塞 I/O 搭配使用。多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数。系统调用也是有一定的开销的的,毕竟也存在上下文的切换。
select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。
总结最基础的 TCP 的 Socket 编程,”它是阻塞 //0 模型,基本上只能一对一通信,那为了服务更多的客户端我们需要改进网络 //0 模型。比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。为了解决上面这个问题,就出现了 !/0 的多路复用,可以只在一个进程里处理多个文件的 !0,Linux 下有三种提供 I/0 多路复用的 API,分别是:select、poll、epoll。select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket集合找到可读/可写的 Socket,然后对其处理。很明显发现,select 和 po 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对C10K。epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。·epol 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 0(logn),通过对这棵黑红树的管理,不需要像 select/pol 在每次操作时都传入整个Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。而且,epol 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。
4. 什么是零拷贝磁盘可以说是计算机系统最慢的硬件,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。
这次,我们就以「文件传输」作为切入点,来分析 I/O 工作方式,以及如何优化传输文件的性能。
#为什么要有 DMA 技术?(不看)在没有 DMA 技术前,I/O 的过程是这样的:
CPU 发出对应的指令给磁盘控制器,然后返回;cpu--(指令)-->磁盘控制器
磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;磁盘控制器--(中断)-->cpu
CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。磁盘控制器---(数据)--->cpu寄存器---(数据)--->内存
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。
什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。
具体过程:
用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
DMA 进一步将 I/O 请求发送给磁盘;
磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;
可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。
#==传统的文件传输有多糟糕?==如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
12read(file, tmp_buf, len);write(socket, tmp_buf, len);
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。
首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本导致高并发场景下的性能损失。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。
#如何优化文件传输的性能?
先来看看,如何减少「用户态与内核态的上下文切换」的次数呢?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。
再来看看,如何减少「数据拷贝」的次数?
在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
#==如何实现零拷贝?==零拷贝技术实现的方式通常有 2 种:
mmap + write
sendfile
下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。
#==mmap + write==在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。
12buf = mmap(file, len);write(sockfd, buf, len);
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
#sendfile在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:
12#include
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
12$ ethtool -k eth0 | grep scatter-gatherscatter-gather: on
于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:
第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
所以,这个过程之中,只进行了 2 次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
#==零拷贝在Netty中的实现==传统 IO 的读写流程,包括了4次用户态和内核态的切换,也就是4次上下文切换,4次数据拷贝,其中两次CPU拷贝以及两次的DMA拷贝。
那在Netty中,是如何实现零拷贝的呢?有以下三种方式
(1) 使用堆外内存(Direct Memory)
Netty 使用 堆外内存(DirectByteBuffer),即通过 Java 的 ByteBuffer.allocateDirect() 方法分配直接内存。直接内存位于堆外,不需要通过 JVM 堆进行数据传输,从而减少了 Java 堆内存到操作系统内存的拷贝。Netty 的 ByteBuf 默认会选择使用直接内存来支持高效的 I/O 操作。
堆外内存直接由操作系统管理,通过底层 mmap(内存映射)机制分配。
数据直接写入和读取操作系统的内核缓冲区,减少用户态和内核态之间的拷贝。
12ByteBuf directBuf = Unpooled.directBuffer(256);directBuf.writeBytes("Zero Copy Example".getBytes(CharsetUtil.UTF_8));
—- 下面的还不会,有时间再看吧,先只记住第一种 直接内存 的方式————————————
(2)组合 Buffer 对象(CompositeByteBuf)
Netty 提供了 CompositeByteBuf,可以将多个 ByteBuf 对象组合为一个逻辑上的 ByteBuf,避免传统方式中通过内存拷贝将多个小的 ByteBuf 合并为一个大的缓冲区。
CompositeByteBuf 在内部维护多个 ByteBuf 引用,但不进行实际数据的拷贝。
对 CompositeByteBuf 的操作会委派给内部的实际缓冲区,从而实现零拷贝。
1234567ByteBuf header = Unpooled.copiedBuffer("Header", CharsetUtil.UTF_8);ByteBuf body = Unpooled.copiedBuffer("Body", CharsetUtil.UTF_8);CompositeByteBuf composite = Unpooled.compositeBuffer();composite.addComponents(header, body);System.out.println(composite.toString(CharsetUtil.UTF_8)); // 输出 "HeaderBody"
(3)文件传输(FileRegion 和 sendfile)
Netty 利用 NIO 提供的 FileChannel.transferTo 方法实现文件传输,通过操作系统的 sendfile 系统调用,将文件缓冲区的数据直接从文件描述符发送到目标通道(如 Socket),无需将文件数据拷贝到用户空间。
传统方式:文件数据会从磁盘读取到内核缓冲区,再拷贝到用户空间缓冲区,然后通过 Socket 写入内核的网络缓冲
sendfile 机制:数据直接从内核的文件缓冲区传输到网络缓冲区,跳过了用户空间。
123456File file = new File("example.txt");RandomAccessFile raf = new RandomAccessFile(file, "r");FileChannel fileChannel = raf.getChannel();DefaultFileRegion fileRegion = new DefaultFileRegion(fileChannel, 0, file.length());ctx.writeAndFlush(fileRegion); // 直接发送文件
#PageCache 有什么作用?(不看)回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。
由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。
读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。
但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。
那问题来了,选择哪些磁盘数据拷贝到内存呢?
我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。
所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。
比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。
所以,PageCache 的优点主要是两个:
缓存最近被访问的数据;
预读功能;
这两个做法,将大大提高读写磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能
这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。
另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:
PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;
所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。
#大文件传输用什么方式实现?(有时间看)那针对大文件的传输,我们应该使用什么方式呢?
我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图:

具体过程:
当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。
对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图:

它把读操作分为两部分:
前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;
而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。
绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。
前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。
于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
直接 I/O 应用场景常见的两种:
应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:
内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;
于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
传输大文件的时候,使用「异步 I/O + 直接 I/O」;
传输小文件的时候,则使用「零拷贝技术」;
在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
12345location /video/ { sendfile on; aio on; directio 1024m; }
当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。
#总结早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。
于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。
传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。
为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。
Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。
需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。
另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。
在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。
5. Reactor 模式参考:9.3 高性能网络模式:Reactor 和 Proactor
演进一、传统方式:一个连接创建一个线程,不好!
服务器服务多客户端,最直接的方式是为每一条连接创建线程。处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,会带来性能开销和资源浪费;如果有几万条连接,创建几万个线程也不现实。
二、改进:连接扔进线程池,但是线程要是阻塞了呢?不好!
解决这个问题呢,使用「资源复用」的方式。也就是不再为每个连接创建线程,而是创建一个「线程池」,将连接分配给线程,然后一个线程可以处理多个连接的业务。
不过,线程怎样才能高效地处理多个连接的业务?
当一个连接对应一个线程时,线程一般采用「read -> 业务处理 -> send」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。
但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。
要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个 线程处理的连接越多,轮询的效率就会越低。
上面的问题在于,线程并不知道当前连接是否有数据可读,从而需要每次通过 read 去试探。
那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这一技术的就是 I/O 多路复用。I/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。
我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用 系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 的区别:这次答应我,一举拿下 I/O 多路复用!(opens new window)
select/poll/epoll 是如何获取网络事件的呢?
在获取事件时,先把我们要关心的连接传给内核,再由内核检测:
如果没有事件发生,线程只需阻塞在这个 select/poll/epoll 系统调用,而无需像前面的线程池方案那样轮训调用 read 操作来判断是否有数据。
如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。
当下开源框架(Netty)能做到网络高性能的原因就是 I/O 多路复用吗?
是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高。
于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。
这种模式就是Reactor 模式,即 分发者利用 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。
Reactor 模式主要由 Reactor 和 处理资源池 这两个核心部分组成,它俩负责的事情如下:
==Reactor 负责 监听 和 分发 事件,事件类型包含连接事件、读写事件;==
==处理资源池负责 处理 事件(处理连接事件、处理读写事件和业务逻辑),如 read -> 业务逻辑 -> send;==
Reactor 模式是灵活多变的:三个经典的 Reactor 方案视频
Reactor 的数量可以只有一个,也可以有多个;
处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;
#单 Reactor 单进程 / 线程
与 IO 多路复用相比:监听分发、建立连接、业务处理 模块化
Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。
进程里有 Reactor、Acceptor、Handler 三个对象:
Reactor 对象的作用是监听和分发事件;
Acceptor 对象的作用是获取连接;
Handler 对象的作用是处理业务;
对象里的 select、accept、read、send 是系统调用函数,dispatch(分发事件) 和 「业务处理」是需要完成的操作。
「单 Reactor 单线程」方案:
Reactor 对象通过 select(IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
单 Reactor 单线程的方案全部工作都在同一线程完成,实现简单,不考虑进程间通信和多进程竞争。
但是,这种方案存在 2 个缺点:
第一个缺点,因为只有一个线程,无法充分利用 多核 CPU 的性能;
第二个缺点,Handler 对象在业务处理时,整个线程是无法处理其他连接的事件的, 如果业务处理耗时比较长,那么就造成响应的延迟;
所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。
Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。
#单 Reactor 多线程 / 多进程
与 单 Reactor 单线程比:把业务处理放入新线程
如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案。
闻其名不如看其图,先来看看「单 Reactor 多线程」方案的示意图如下:
详细说一下这个方案:
Reactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了:
Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;
单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。例如,子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。
要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。
另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。
#多 Reactor 多进程 / 线程
与 单 Reactor 多线程比:主线程只负责接收新连接
要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多进程 / 线程的方案。
方案详细说明如下:
主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。
如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:
主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
主线程和子线程的交互很简单,主线程只需要把新连接传给子线程, 子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。
大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。
采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。
具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。
Netty 的线程模型Netty 可以同时支持 单 Reactor 单线程模型、单 Reactor 多线程模型、主从 Reactor多线程模型
https://juejin.cn/post/6844903712435994631
Netty主要基于主从Reactors多线程模型(如下图)做了一定的修改,其中主从Reactor多线程模型有多个Reactor:MainReactor和SubReactor:
MainReactor负责客户端的连接请求,并将请求转交给SubReactor
SubReactor负责相应通道的IO读写请求
非IO请求(具体逻辑处理)的任务则会直接写入队列,等待worker threads进行处理
具体来讲,Netty 线程模型的解释如下:(图片中的 NioEventGroup 改成 NioEventLoop)
Netty 抽象出两组线程池 BossGroup 和 WorkerGroup,BossGroup 专门负责接收客户端的连接,而 WorkerGroup 则专门负责网络的读写;
BossGroup 和 WorkerGroup 都是 NioEventLoopGroup 类的实例
NioEventLoopGroup 相当于一个事件循环线程组,它包含多个事件循环线程,每一个事件循环线程都是一个NioEventLoop
每个 NioEventLoop 都有一个 Selector,用于监听注册在其上的 socketChannel 的网络通信
BossGroup 下的 NioEventLoop 线程内部循环执行的步骤如下:
轮询 accept 事件
处理 accept 事件,与 client 建立连接,生成 NioScocketChannel,并将其注册 Worker Group 上的某个NioEventLoop 上的 selector
处理任务队列的任务,即 runAllTasks
每个Worker Group 下的 NIOEventLoop 线程内部循环执行的步骤如下:
轮询注册到自己 Selector 上的所有 NioSocketChannel 上的 read / write 事件
在对应的 NioScocketChannel 上处理 I/O 事件,即 read/write 事件
处理任务队列的任务,即 runAlITasks
每个Worker Group 下的 NIOEventLoop 处理业务时,会使用 pipeline(管道),pipline 中维护了很多 ChannelHandler 来对我们的数据进行一系列的处理。
==问:Netty 线程模型对 主从 Reactor 多线程模型 做了那些改良?==
A. 线程池化:Netty 将主从线程组抽象为 **NioEventLoopGroup**,内部包含多个 **NioEventLoop**(事件循环),这些 EventLoop 会被 复用,避免频繁创建和销毁线程的开销。
BossGroup:
负责处理客户端连接请求(accept 事件)。
通常由一个或少数几个 EventLoop 组成,因为处理 accept 是轻量级操作。
WorkerGroup:
负责已建立连接的读写操作和事件处理。
WorkerGroup 内的多个 EventLoop 轮询处理多个 Channel,通过 Selector 实现 I/O 多路复用。
B. 责任链模式:通过 ChannelPipeline 作为责任链包含一系列 ChannelHandler(处理一个或多个职责) 的设计,让 WorkerGroup 的 每个事件循环线程 能够直接完成数据的 编解码、序列化/反序列化 和 业务逻辑处理。这进一步简化了模型,提升了性能和开发效率。
ChannelPipeline 通过多个 Handler 将复杂的流程分解为简单的、可复用的处理单元。
InboundHandler 处理入站事件(如读操作)
解码(ByteBuf → POJO)
数据校验
业务逻辑处理
OutboundHandler处理出站事件(如写操作)
序列化(POJO → ByteBuf)
数据压缩/加密
数据传输
优势:
模块化设计:每个 Handler 可以独立开发、测试、复用。
易于扩展:添加新功能时,只需在 ChannelPipeline 中添加新的 Handler。
清晰的职责分工:使得数据处理和业务逻辑井然有序。
避免不必要的线程切换:传统Reactor模式可能将 subReactor 中业务逻辑的处理部分放在新的子线程中,引入责任链既能把读写操作和业务处理操作放在一个线程中,又能对他们进行解耦(放在不同的 Handler 中)
示意流程图:一个请求数据从接收(I/O 事件)到处理的流程如下:
123456789101112BossGroup: - 接收客户端连接 - 将连接分配给 WorkerGroupWorkerGroup (NioEventLoop): - I/O 事件发生 (e.g., channelRead) - ChannelPipeline 开始执行: | Handler1: 解码器 (ByteBuf → POJO) | Handler2: 数据校验器 | Handler3: 业务逻辑处理 (生成响应) | Handler4: 编码器 (POJO → ByteBuf) - I/O 事件结束 (e.g., write 数据返回客户端)
==问:你的项目是如何体现 Reactor 模式的?==
思路:三种Reactor模型 –> Netty 线程模型 –> 你的项目的责任链有哪些 Handler
==问:项目为啥用Netty?==
是什么:异步事件驱动、高性能网络框架,就是对Java NIO API的封装
Java NIO 难用,有Bug(epoll bug,它会导致Selector空轮询,最终导致CPU 100%)
Netty高性能来自于IO模型和线程处理模型
展开上面两点(5种IO balabala -> netty是啥类型的IO)
线程模型咋样的
可以的话,再讲讲零拷贝,讲讲异步是什么原理