IO 杂记
1.用户空间 和 内核空间
Kernel space 是 Linux 内核的运行空间, User space 是用户程序的运行空间. 为了安全, 它们是隔离的, 即使用户的程序崩溃了, 内核也不受影响.
ni
: niceness 的缩写, CPU 消耗在 nice 进程 (低优先级) 的时间百分比.
id
: idle 的缩写, CPU 消耗在闲置进程的时间百分比, 这个值越低, 表示 CPU 越忙.
wa
: wait 的缩写, CPU 等待外部 I/O 的时间百分比, 这段时间 CPU 不能干其他事, 但是也没有执行运算, 这个值太高就说明外部设备有问题.
hi
: hardware interrupt 的缩写, CPU 响应硬件中断请求的时间百分比.
si
: software interrupt 的缩写, CPU 响应软件中断请求的时间百分比.
st
: stole time 的缩写, 该项指标只对虚拟机有效, 表示分配给当前虚拟机的 CPU 时间之中, 被同一台物理机上的其他虚拟机偷走的时间百分比.
2. PIO与DMA
PIO 我们拿磁盘来说, 很早以前, 磁盘和内存之间的数据传输是需要CPU控制的, 也就是说如果我们读取磁盘文件到内存中, 数据要经过CPU存储转发, 这种方式称为PIO.
DMA 后来, DMA(直接内存访问, Direct Memory Access) 取代了PIO, 它可以不经过CPU而直接进行磁盘和内存 (内核空间) 的数据交换. 在DMA模式下, CPU只需要向DMA控制器下达指令, 让DMA控制器来处理数据的传送即可, DMA控制器通过系统总线来传输数据, 传送完毕再通知CPU, 这样就在很大程度上降低了CPU占有率, 大大节省了系统资源.
3. 缓存IO 和 直接IO
3.1 缓存IO
在Linux的缓存I/O机制中, 数据先从磁盘复制到内核空间的缓冲区, 然后从内核空间缓冲区复制到应用程序的地址空间.
1. 读操作
操作系统检查内核的缓冲区有没有需要的数据, 如果已经缓存了, 那么就直接从缓存中返回;否则从磁盘中读取, 然后缓存在操作系统的缓存中.
2. 写操作
将数据从用户空间复制到内核空间的缓存中. 这时对用户程序来说写操作就已经完成, 至于什么时候再写到磁盘中由操作系统决定, 除非显示地调用了sync同步命令.
3. 优点
在一定程度上分离了内核空间和用户空间, 保护系统本身的运行安全; 可以减少读盘的次数, 从而提高性能.
4. 缺点
在缓存 I/O 机制中, DMA 方式可以将数据直接从磁盘读到页缓存中, 或者将数据从页缓存直接写回到磁盘上, 而不能直接在应用程序地址空间和磁盘之间进行数据传输. 数据在传输过程中需要在应用程序地址空间 (用户空间) 和缓存 (内核空间) 之间进行多次数据拷贝操作, 带来的CPU以及内存开销是非常大的.
3.2 直接IO
直接IO就是应用程序直接访问磁盘数据, 而不经过内核缓冲区, 也就是绕过内核缓冲区,自己管理I/O缓存区, 这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制.
引入内核缓冲区的目的在于提高磁盘文件的访问性能, 因为当进程需要读取磁盘文件时, 如果文件内容已经在内核缓冲区中, 那么就不需要再次访问磁盘;而当进程需要向文件中写入数据时, 实际上只是写到了内核缓冲区便告诉进程已经写成功, 而真正写入磁盘是通过一定的策略进行延迟的.
数据库服务器, 它们为了充分提高性能, 希望绕过内核缓冲区, 由自己在用户态空间实现并管理I/O缓冲区, 包括缓存机制和写延迟机制等, 以支持独特的查询机制, 比如数据库可以根据更加合理的策略来提高查询缓存命中率. 另一方面, 绕过内核缓冲区也可以减少系统内存的开销, 因为内核缓冲区本身就在使用系统内存.
Linux提供了对这种需求的支持, 即在open()系统调用中增加参数选项O_DIRECT, 用它打开的文件便可以绕过内核缓冲区的直接访问, 这样便有效避免了CPU和内存的多余时间开销.
4. IO 访问方式
4.1 磁盘IO
当应用程序调用read接口时, 操作系统检查在内核的高速缓存有没有需要的数据, 如果已经缓存了, 那么就直接从缓存中返回, 如果没有, 则从磁盘中读取, 然后缓存在操作系统的缓存中.
应用程序调用write接口时, 将数据从用户地址空间复制到内核地址空间的缓存中, 这时对用户程序来说, 写操作已经完成, 至于什么时候再写到磁盘中, 由操作系统决定, 除非显示调用了sync同步命令.
4.2 网络IO
普通的网络传输步骤如下:
1)操作系统将数据从磁盘复制到操作系统内核的页缓存中.
2)应用将数据从内核缓存复制到应用的缓存中.
3)应用将数据写回内核的Socket缓存中.
4)操作系统将数据从Socket缓存区复制到网卡缓存, 然后将其通过网络发出.
1.当调用read系统调用时, 通过DMA(Direct Memory Access) 将数据copy到内核模式.
2.然后由CPU控制将内核模式数据copy到用户模式下的 buffer中.
3.read调用完成后, write调用首先将用户模式下 buffer中的数据copy到内核模式下的socket buffer中
4.最后通过DMA copy将内核模式下的socket buffer中的数据copy到网卡设备中传送. 从上面的过程可以看出, 数据白白从内核模式到用户模式走了一圈, 浪费了两次copy, 而这两次copy都是CPU copy, 即占用CPU资源.
4.3 磁盘 IO vs 网络IO
磁盘IO主要的延时是由(以15000rpm硬盘为例) :
机械转动延时机械磁盘的主要性能瓶颈, 平均为2ms) + 寻址延时(2~3ms) + 块传输延时(一般4k每块, 40m/s的传输速度, 延时一般为0.1ms) 决定. (平均为5ms)
而网络IO主要延是由: 服务器响应延时 + 带宽限制 + 网络延时 + 跳转路由延时 + 本地接收延时 决定. (一般为几十到几千毫秒, 受环境干扰极大)
5. 同步IO 和 异步IO
同步和异步是针对 应用程序和内核的交互 而言的
同步指 用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪, 而异步指 用户进程触发IO操作以后便开始做自己的事情
用户空间要的数据, 必须等到内核空间给它才做其他事情; 用户空间要的数据, 不需要等到内核空间给它, 才做其他事情
6. 阻塞IO 和 非阻塞IO
阻塞方式下读取或者写入函数将一直等待, 而非阻塞方式下, 读取或者写入函数会立即返回一个状态值
用户和内核空间IO操作的方式
堵塞: 用户空间通过系统调用(systemcall)和内核空间发送IO操作时, 该调用是堵塞的
非堵塞: 用户空间通过系统调用 (systemcall) 和 内核空间发送IO操作时, 该调用是不堵塞的, 直接返回的, 只是返回时, 可能没有数据而已
7. IO 模型
IO模式 | BIO | NIO | AIO |
---|---|---|---|
同步阻塞 | 同步非阻塞 | 异步非阻塞 | |
实现难度 | easy | hard | hard |
可靠性 | 差 | 好 | 好 |
吞吐量 | 低 | 高 | 高 |
-
同步阻塞IO (Blocking IO): 即传统的IO模型
-
同步非阻塞IO (Non-blocking IO): 默认创建的socket都是阻塞的, 非阻塞IO要求socket被设置为NONBLOCK.
-
IO多路复用(IO Multiplexing) : 即经典的Reactor设计模式, 有时也称为异步阻塞IO
-
异步IO(Asynchronous IO) : 即经典的Proactor设计模式, 也称为异步非阻塞IO
7.1 同步阻塞IO BIO
同步阻塞IO模型是最简单的IO模型, 用户线程在内核进行IO操作时被阻塞.
用户线程通过系统调用read发起IO读操作, 由用户空间转到内核空间. 内核等到数据包到达后, 然后将接收的数据拷贝到用户空间, 完成read操作
即用户需要等待read将socket中的数据读取到buffer后, 才继续处理接收的数据. 整个IO请求的过程中, 用户线程是被阻塞的, 这导致用户在发起IO请求时, 不能做任何事情, 对CPU的资源利用率不够
缺点
IO代码里read操作是阻塞操作, 如果连接不做数据读写操作会导致线程阻塞, 浪费资源
如果线程很多, 会导致服务器线程太多, 压力太大, 比如C10K问题
7.2 同步非阻塞IO NIO
将socket设置为NONBLOCK. 这样做用户线程可以在发起IO请求后可以立即返回
由于socket是非阻塞的方式, 因此用户线程发起IO请求时立即返回. 但并未读取到任何数据, 用户线程需要不断地发起IO请求, 直到数据到达后, 才真正读取到数据, 继续执行
整个IO请求的过程中, 虽然用户线程每次发起IO请求后可以立即返回, 但是为了等到数据, 仍需要不断地轮询、重复请求, 消耗了大量的CPU的资源
8.3 IO多路复用
建立在内核提供的多路分离函数select基础之上的, 使用select函数可以避免同步非阻塞IO模型中轮询等待的问题
用户首先将需要进行IO操作的socket添加到select中, 然后阻塞等待select系统调用返回. 当数据到达时, socket被激活, select函数返回. 用户线程正式发起read请求, 读取数据并继续执行
使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求. 用户可以注册多个socket, 然后不断地调用select读取被激活的socket, 即可达到在同一个线程内同时处理多个IO请求的目的
其中while循环前将socket添加到select监视中, 然后在while内一直调用select获取被激活的socket, 一旦socket可读, 便调用read函数将socket中的数据读取出来
使用select函数的优点并不仅限于此. 虽然上述方式允许单线程内处理多个IO请求, 但是每个IO请求的过程还是阻塞的(在select函数上阻塞)
优化: 如果用户线程只注册自己感兴趣的socket或者IO请求, 然后去做自己的事情, 等到数据到来时再进行处理, 则可以提高CPU的利用率
通过Reactor的方式, 可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理. 用户线程注册事件处理器之后可以继续执行做其他的工作(异步), 而Reactor线程负责调用内核的select函数检查socket状态. 当有socket被激活时, 则通知相应的用户线程(或执行用户线程的回调函数), 执行handle_event进行数据读取、处理的工作
由于select函数是阻塞的, 因此多路IO复用模型也被称为异步阻塞IO模型. 注意, 这里的所说的阻塞是指select函数执行时线程被阻塞, 而不是指socket.
void UserEventHandler::handle_event() {
if(can_read(socket)) {
read(socket, buffer);
process(buffer);
}
}
{
Reactor.register(new UserEventHandler(socket));
}
用户需要重写EventHandler的handle_event函数进行读取数据、处理数据的工作, 用户线程只需要将自己的EventHandler注册到Reactor即可.
Reactor::handle_events() {
while(1) {
sockets = select();
for(socket in sockets) {
get_event_handler(socket).handle_event();
}
}
}
8.4 异步IO
异步IO模型中, 当用户线程收到通知时, 数据已经被内核读取完毕, 并放在了用户线程指定的缓冲区内, 内核在IO完成后通知用户线程直接使用即可
void UserCompletionHandler::handle_event(buffer) {
process(buffer);
}
{
aio_read(socket, new UserCompletionHandler);
}
用户需要重写CompletionHandler的handle_event函数进行处理数据的工作, 参数buffer表示Proactor已经准备好的数据, 用户线程直接调用内核提供的异步IO API, 并将重写的CompletionHandler注册即可.