操作系统-IO
操作系统
架构
内核程序: 操作系统中, 可以访问所有硬件设备, 如网卡、内存设备等的一些特殊的高权限的系统程序,
用户程序: 只能有限的访问部分内存空间, 对硬件设备没有访问权限的低权限的应用程序或系统程序获取数据流程: 当我们需要读取一条数据的时候, 首先需要发请求告诉内核, 我需要什么数据, 等内核准备好数据之后, 再从内核空间拷贝到用户空间
- 数据准备: 等待内核准备数据
- 数据拷贝: 数据从内核空间拷贝到用户空间
IO 模型
IO 会一直占用 CPU 吗
IO所需要的CPU资源非常少. 大部分工作是分派给DMA(Direct Memory Access)直接内存存取 完成的
CPU计算文件地址 ——> 委派DMA读取文件 ——> DMA接管总线 ——> CPU的A进程阻塞, 挂起——> CPU切换到B进程 ——> DMA读完文件后通知CPU(一个中断异常)——> CPU切换回A进程操作文件
阻塞/非阻塞
阻塞和非阻塞强调的是进程对于操作系统IO是否处于就绪状态的处理方式
对于读取数据的操作, 如果操作系统IO处于未就绪状态, 当前进程或线程如果一直等待直到其就绪, 该种IO方式为阻塞IO. 如果进程或线程并不一直等待其就绪, 而是可以做其他事情, 这种方式为非阻塞IO. 对于非阻塞IO, 我们编程时需要经常去轮询就绪状态
同步/异步
同步和异步描述的是针对当前执行线程、或进程而言, 发起IO调用后, 当前线程或进程是否挂起等待操作系统的IO执行完成
以一个读取数据的IO操作而言, 在操作系统将外部数据写入进程缓冲区这个期间, 进程或线程挂起等待操作系统IO执行完成的话, 这种IO执行策略就为同步, 如果进程或线程并不挂起而是继续工作, 这种IO执行策略便为异步
BIO(同步阻塞 IO)
用户发起IO请求, 在等待数据和数据拷贝阶段, 都会被阻塞, 只有这两个阶段都完成了, 才能去做下一阶段的事情
NIO(同步非阻塞 IO)
非阻塞IO, 可以看作是半阻塞IO, 因为他在第一阶段数据准备阶段不阻塞(轮训询问数据是否准备好), 第二阶段数据拷贝阶段阻塞
IO 多路复用
IO请求都通过一个selector来管理, 用户进程的IO请求就不直接发给内核处理程序了, 而是注册到这个selector上面, 由selector来告诉内核需要哪些数据, 然后定时的去查询内核程序, 我这个selector上需要的数据, 有哪些准备好了, 然后再由selector告诉那些准备好了的用户线程, 让该用户线程去拷贝数据
和 NIO 的区别
NIO 是用户线程不断轮训, 比如有 10 个用户线程执行 IO 操作, 那就是 10 个用户线程在轮训等待. 而 IO 多路复用是内核级
select
调用后select函数会阻塞, 直到有描述副就绪(有数据 可读、可写、或者有except), 或者超时(timeout指定等待时间, 如果立即返回设为null即可), 函数返回. 当select函数返回后, 可以 通过遍历fdset, 来找到就绪的描述符
实际上发起调用后(从用户态拷贝到内核态), 如果没有准备好的数据, 会 sleep, 当 socket 有数据会通过等待队列唤醒, 唤醒之后进程再遍历 fd_set 来判断哪些数据就绪(内核态拷贝到用户态).
流程:
- 使用copy_from_user从用户空间拷贝fd_set到内核空间
- 注册回调函数__pollwait
- 遍历所有fd, 调用其对应的poll方法
- 主要工作就是把current(当前进程)挂到设备的等待队列中, 不同的设备有不同的等待队列. 在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后, 会唤醒设备等待队列上睡眠的进程, 这时current便被唤醒了
- poll方法返回时会返回一个描述读写操作是否就绪的mask掩码, 根据这个mask掩码给fd_set赋值
- 如果遍历完所有的fd, 还没有返回一个可读写的mask掩码, 则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠. 当设备驱动发生自身资源可读写后, 会唤醒其等待队列上睡眠的进程. 如果超过一定的超时时间(schedule_timeout指定), 还是没人唤醒, 则调用select的进程会重新被唤醒获得CPU, 进而重新遍历fd, 判断有没有就绪的fd
- 永远等待下去: 仅在有一个描述字准备好I/O时才返回
- 等待一段固定时间: 在有一个描述字准备好I/O时返回, 但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数
- 根本不等待: 检查描述字后立即返回, 这称为轮询
- 把fd_set从内核空间拷贝到用户空间
缺点:
- 每次调用select, 都需把fd集合从用户态拷贝到内核态, fd很多时开销就很大
- 每次调用select后, 都需在内核遍历传递进来的所有fd, fd很多时开销就很大
- select支持的文件描述符数量太小, 默认最大支持1024个
poll
优点: poll无最大文件描述符数量的限制
缺点: 同 select
epoll
不同于 select 的主动轮询, 使用被动通知(事件驱动), 当有事件发生时, 被动接收通知. 所以epoll模型注册套接字后, 主程序可做其他事情, 当事件发生时, 接收到通知后再去处理
如何解决 select 缺点:
- 每次注册新的事件到epoll句柄中时(在epoll_ctl中指定 EPOLL_CTL_ADD), 会把所有的fd拷贝进内核, 而不是在epoll_wait的时候重复拷贝. epoll保证了每个fd在整个过程中只会拷贝 一次
- epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中, 而只在 epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数, 当设备就绪, 唤醒等待队列上的等待者时, 就会调用这个回调 函数, 而这个回调函数会把就绪的fd加入一个就绪链表). epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用 schedule_timeout()实现睡一会, 判断一会的效果, 和select实现中的第7步是类似的)
- epoll没有这个限制, 它所支持的FD上限是最大可以打开文件的数目, 这个数字一般远大于2048,举个例子, 在1GB内存的机器上大约是10万左右
触发方式:
- LT(level-triggered 水平触发, 默认): 当epoll_wait检测到描述符事件发生并将此事件通知应用程序, 应用程序可以不立即处理该事件. 下次调用epoll_wait时, 会再次响应应用程序并通知此事件.
- ET(edge-triggered 边缘触发, 高速模式): 当epoll_wait检测到描述符事件发生并将此事件通知应用程序, 应用程序必须立即处理该事件. 如果不处理, 下次调用epoll_wait时, 不会再次响应应用程序并通知此事件
ET模式在很大程度上减少了epoll事件被重复触发的次数, 因此效率要比LT模式高. epoll工作在ET模式的时候, 必须使用非阻塞套接口, 以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死
select/epoll 区别
- select 醒着的时候, 需要遍历整个fd集合, epoll 只需遍历就绪队列
- select 数据就绪时需要每次将文件描述符从内核态拷贝到用户态, 而 epoll 利用 mmap, 无需拷贝
- select 和 epoll 都需要轮训, 期间可能多次睡眠和唤醒交替. 但当设备就绪时 epoll 会回调把就绪fd放入就绪链表, 并唤醒在epoll_wait中进入睡眠的进程
信号驱动 IO
信号驱动IO, 用户线程发出一个请求告诉内核我需要什么数据, 数据准备好了你告诉我一声, 然后内核就会记录下这个请求, 内核准备好了之后会主动通知用户线程去执行拷贝数据, 数据拷贝阶段是阻塞的, 需要等数据拷贝完才能做其他的事
和 NIO 的区别
NIO 是主动轮训, 信号驱动 IO 是回调线程
AIO(全异步 IO)
用户进程发起了一个IO请求, 接下来可以干其他的事了, 不需要等内核准备好, 也不需要执行数据拷贝, 数据异步拷贝到用户空间之后, 用户进程直接拿来用就行了, 这两个阶段都是由内核自动完成
零拷贝
Page Cache(页缓存)
Page Cache是通过将磁盘中的数据缓存到内存中, 减少磁盘I/O操作, 从而提高性能. 属于内核层, Linux 内核会以页大小(4KB)为单位, 将文件划分为多数据块. 当用户对文件中的某个数据块进行读写操作时, 内核首先会申请一个内存页(称为页缓存)与文件中的数据块进行绑定
产生方式:
- Buffered I/O(标准I/O)
- Memory-Mapped I/O(存储映射I/O)
读缓存
当内核发起一个读请求时, 会先检查请求的数据是否缓存到了page cache中, 如果有则直接从内存中读取, 不需要访问磁盘. 如果cache没有请求的数据, 就必须从磁盘中读取数据, 然后内核将数据缓存到cache中. 这样后续读请求就可以命中cache了. page可以只缓存一个文件部分的内容, 不需要把整个文件都缓存进来
写缓存
当内核发起一个写请求时(例如进程发起write()请求), 直接往cache中写入. 内核会将被写入的page标记为dirty, 并将其加入dirty list中. 内核会周期性地将dirty list中的page写回到磁盘上, 从而使磁盘上的数据和内存中缓存的数据一致
页缓存回收
应用在申请内存的时候, 即使没有free内存了, 只要还有足够的可回收的Page Cache, 也可以通过回收Page Cache的方式来申请到内存
回收算法: LRU
回收时机:
- 使用水位线标识剩余内存的状况
- 不同的水位线感知剩余内存对对分配的紧急程度
- 在内存分配的过程中检查水位线
满足条件:
- pagecache是否处于不活跃的状态, 即没有PG_refrenced标志
- 在非活动链表中, pagecache对应的refcount(访问数量)为0
- pagecache没有设置内存锁
DMA(Direct Memory Access: 直接内存访问)
在进行I/O设备和内存的数据传输的时候, 数据搬运的工作全部交给DMA控制器, 而CPU不再参与任何与数据搬运相关的事情, 这样CPU就可以去处理其他事务
传统读写数据
期间共发生了4次用户态与内核态的上下文切换, 4次数据拷贝, 因为发生了两次系统调用, 一次是read() , 一次是write() ,每次系统调用都得先从用户态切换到内核态
- 第一次拷贝, 把磁盘上的数据拷贝到操作系统内核的缓冲区里, 这个拷贝的过程是通过DMA搬运的
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里, 于是我们应用程序就可以使用这部分数据了, 这个拷贝到过程是由CPU完成的
- 第三次拷贝, 把刚才拷贝到用户的缓冲区里的数据, 再拷贝到内核的socket的缓冲区里,这个过程依然还是由CPU搬运的
- 第四次拷贝,把内核的socket缓冲区里的数据, 拷贝到网卡的缓冲区里,这个过程又是由DMA搬运的
mmap(内存直接映射) + write
系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样, 操作系统内核与用户空间就不需要再进行任何的数据拷贝操作. 替代了 read(), 需要4次上下文切换, 3 次数据拷贝, 因为系统调用还是2次
- 应用进程调用了mmap() 后, DMA会把磁盘的数据拷贝到内核的缓冲区里. 接着, 应用进程跟操作系统内核共享这个缓冲区
- 应用进程再调用write(), 操作系统直接将内核缓冲区的数据拷贝到socket缓冲区中, 这一切都发生在内核态, 由CPU来搬运数据
- 最后,把内核的socket缓冲区里的数据, 拷贝到网卡的缓冲区里,这个过程是由DMA搬运的
优点: 即使频繁调用, 使用小文件块传输, 效率也很高
缺点: 不能很好的利用DMA方式, 会比sendfile多消耗CPU资源, 内存安全性控制复杂, 需要避免JVM Crash问题
sendFile
替代前面的read()和write()这两个系统调用, 这样就可以减少一次系统调用,也就减少了2次上下文切换的开销. 直接把内核缓冲区里的数据拷贝到socket缓冲区里, 不再拷贝到用户态, 这样就只有2次上下文切换, 和3次数据拷贝
优点: 可以利用DMA方式, 消耗CPU资源少, 大块文件传输效率高, 无内存安全新问题
缺点: 小块文件效率低于mmap方式. 无法执行修改
SG-DMA
真正零拷贝(Zero-copy) 技术, 因为我们没有在内存层面去拷贝数据, 也就是说全程没有通过CPU来搬运数据, 所有的数据都是通过DMA来进行传输的
- 第一步,通过DMA将磁盘上的数据拷贝到内核缓冲区里
- 第二步, 缓冲区描述符和数据长度传到socket缓冲区,这样网卡的SG-DMA控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里, 此过程不需要将数据从操作系统内核缓冲区拷贝到socket缓冲区中, 这样就减少了一次数据拷贝
direct-io(直接 IO)
大文件使用. java 不支持
Read 操作:由于其不使用 page cache, 每次读操作是真的从磁盘中读取, 不会从文件系统的缓存中读取
Write 操作:由于其不使用 page cache, 所以其进行写文件, 如果返回成功, 数据就真的落盘了(不考虑磁盘自带的缓存)优点: 避免了内核空间到用户空间的数据拷贝, 如果要传输的数据量很大, 使用直接 I/O 的方式进行数据传输, 而不需要操作系统内核地址空间拷贝数据操作的参与, 这将会大大提高性能
缺点:
- 由于设备之间的数据传输是通过 DMA 完成的, 因此用户空间的数据缓冲区内存页必须进行页锁定
- 在应用层引入直接 I/O 需要应用层自己管理, 这带来了额外的系统复杂性
mmap 和 sendFile 区别
- mmap 用于文件共享, 很少用于socket操作, sendfile用于发送文件
- mmap 适合小数据量读写, sendFile 适合大文件传输
- mmap 需要 4 次上下文切换, 3 次数据拷贝;sendFile 需要 2 次上下文切换, 最少 2 次数据拷贝
- sendFile 可以利用 DMA 方式, 减少 CPU 拷贝, mmap 则不能(必须从内核拷贝到 Socket 缓冲区)
mmap 和 共享内存区别
mmap是共享一个文件, 共享内存是共享一段内存. mmap还可以写回到file
Kafka 运用
Kafka 作为一个消息队列, 涉及到磁盘 I/O 主要有两个操作:
Provider 向 Kakfa 发送消息, Kakfa 负责将消息以日志的方式持久化落盘;
Consumer 向 Kakfa 进行拉取消息, Kafka 负责从磁盘中读取一批日志消息, 然后再通过网卡发送.Kakfa 服务端接收 Provider 的消息并持久化的场景下使用 mmap 机制, 能够基于顺序磁盘 I/O 提供高效的持久化能力, 使用的 Java 类为 java.nio.MappedByteBuffer
Kakfa 服务端向 Consumer 发送消息的场景下使用 sendfile 机制, 这种机制主要两个好处:
- sendfile 避免了内核空间到用户空间的 CPU 全程负责的数据移动
- sendfile 基于 Page Cache 实现, 因此如果有多个 Consumer 在同时消费一个主题的消息, 那么由于消息一直在 page cache 中进行了缓存, 因此只需一次磁盘 I/O, 就可以服务于多个 Consumer
- 使用 mmap 来对接收到的数据进行持久化, 使用 sendfile 从持久化介质中读取数据然后对外发送是一对常用的组合. 但是注意, 你无法利用 sendfile 来持久化数据, 利用 mmap 来实现 CPU 全程不参与数据搬运的数据拷贝