简述
IO 操作不外乎读和写, 但是不同场景对读写有不同的需求, 例如网络中同时监控多个文件句柄, 例如关键数据希望一路刷到存储设备而不是扔到 cache 就返回.
怎么读, 怎么写, 等不等结果返回, 是否等获取到数据才发返回, 组成了不同的 IO 模型, 分别适用于不同的场景.
根据同步与异步, 阻塞与非阻塞, 可以把 IO 模型如下分类:
同步 / 异步与阻塞 / 非阻塞怎么理解呢?
同步与异步, 就是 IO 发起人是否等待数据操作结束. 发起一次 IO 请求后, 发起 IO 的进程死等操作结束才继续往下跑, 就是同步. 发起一次 IO 请求后, 不等结束直接往下跑, 就是异步.
阻塞与非阻塞, 就是没有数据时是否等到有数据才返回. 如果没有数据, 就让 IO 进程干等, 直到有数据再返回, 就是阻塞. 如果看到没有数据, 直接就返回了, 就是非阻塞.
什么情况下会没有数据呢? 一个文件就这么大, 除非读到文件末尾了, 不然怎么会说没有数据呢? 实际上, 阻塞与非阻塞并不指磁盘上常规的文件读写, 而是指 socket 或者 pipe 之类的特殊文件. 这些特殊文件并没有明确意义上的大小, 就是说, 理论上他们的数据是无限大的, 只要有人往里面写数据, 你就能无限读出数据. 这种特殊文件有数据的前提, 就是有人往里面 "灌" 数据. 如果你读的太快, 别人写得太慢, 就会出现池子里面没有数据的情况. 这情况并不表示文件读到末端了, 只表示暂时还没有数据. 这时候你等呢? 还是不等呢? 这就是阻塞与非阻塞.
如果是同步 / 异步是 IO 发起人是否主动想等待, 阻塞 / 非阻塞就是没有数据时读是否被动等待.
本文并不会严格按上图依次介绍 6 种 IO 模型, 相信网上有一大堆的资料. 本文尝试从常规读写, 多路复用的 2 个常用场景介绍 IO 模型.
上图中的信号 IO, 需要内核在某个时机向用户空间传递 SIGIO 信号, 且用户空间在捕抓到此信号后进行 IO 处理. 这做法并不常见, 本文不会展开介绍. 且由于是需要被动通知后才会执行 IO 操作, 因此被我归类为异步 IO.
上图中的事件驱动依赖于某些特定的库. 其原理类似于为某些事件注册钩子函数, 由库函数实现事件监控. 在事件触发时调用钩子函数解决. 这些函数库屏蔽了平台之间的差异, 例如 Linux 中通过 epoll()来监控多个 fd. 不同库有不同的使用方法, 本文也不会展开介绍.
常规 read()和 write()
读操作
- /* 同步阻塞 */
- fd = open("./test.txt", O_RDONLY); // 常规文件
- ret = read(fd, buf, len);
- /* 同步非阻塞 */
- int fds[2];
- ret = pipe2(fds, O_NONBLOCK); // 无名管道
- ret = read(fds[0], buf, len);
- /* 同步非阻塞 */
- fd = open("./fifo", O_RDONLY | O_NONBLOCK); // 有名管道
- ret = read(fd, buf, len);
在大多数场景下, 我们系统调用 read()正确返回时就表示已经读到数据了, 此次的 IO 操作已经结束了. 毫无疑问, 大多数情况下的读操作, 都是同步的.
是否要阻塞, 取决于 open()时是否有 O_NONBLOCK 参数.
总的来说, 我们系统调用 read(), 除非指定 O_NONBLOCK, 否则都是同步且阻塞的.
写操作
- /* 异步 */
- fd = open("./test.txt", O_WRONLY);
- ret = write(fd, buf, len);
- /* 同步 */
- fd = open("./test.txt", O_WRONLY | O_SYNC);
- ret = write(fd, buf, len);
阻塞与非阻塞主要针对读数据, 写数据主要区分同步还是异步
由于 Linux 的 IO 栈中, 有专门为 IO 准备的 Cache 层. 在正常情况下, 写操作只是把数据直接扔到了 Cache 就返回了, 此时数据并没回刷到磁盘. 要不等到系统回刷线程主动回刷, 要不应用主动调用 fsync(), 否则数据一直都在 Cache 层, 此时掉电数据就丢了.
写操作是同步还是异步, 主要看 open()时, 是否带 O_SYNC 参数. 带 O_SYNC 就是同步写, 否则就是异步写.
本文开头就提到, 同步 / 异步是 IO 发起人是否主动想等待 IO 结束, 这里的写 IO 结束, 指的是数据完全写入到磁盘, 而非 write()返回.
在没有 O_SYNC 情况下, 数据只是写到了 Cache, 需要内核线程定期回刷, 所以此时的 write 是并没有结束的, 因此是异步的. 相反, 如果有 O_SYNC,write()操作会一直等到数据完全写入到磁盘后再返回, 所以是同步的.
从写性能角度来说, 异步写会优于同步写. 由内核 IO 调度算法, 对写请求进行合并与排序, 再一次性写入, 效率绝对高于东一块西一块的随机写. 因此, 除非是担心掉电丢失的关键数据, 否则建议使用异步写
多路复用
多路复用常用于网络开发, 例如每个客户端由一个 socket 与服务器进行远程通信, 此时这个服务程序需要同时监控多个 socket, 为了避免资源损耗和提高响应速率, 就会使用多路复用.
多路复用是怎么一回事呢? 我们假设一下有 100 个 socket, 在某一时间可能只有个别 socket 是有数据的, 即客户端向服务端发送的请求数据. 此时服务程序怎么监控这 100 个 socket, 找出有数据的 socket, 并做出响应? 有一种做法, 就是非阻塞读每个 socket, 没数据直接返回读下一个, 有数据 (请求) 就响应, 以此实现轮询. 还有一种做法, 创建 100 个进程 / 线程, 每个线程, 进程对应一个 socket.
对少量的文件还行, 如果文件数量一多, 数百个, 上千个 socket 逐一轮询, 或者创建上千个线程, 这效率得多低啊. 可不可以批量等待, 当哪怕有一个 socket 有数据时, 内核直接告诉应用那个 socket 来数据了? 可以! 这就是内核支持的多路复用的系统调用 select()和 epoll()
- /* select 函数原型 */
- int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- /* epoll 函数原型 */
- int epoll_create(int size);
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
他们原理大抵相似:
创建一个文件句柄集合, 把要监视的文件句柄按顺序整合到一起
在有数据时置位对应的标识后返回
应用通过检查标识就可以知道是哪个 socket 有数据了, 此时读 socket 即可直接获取数据
具体的使用方法不在这里详细介绍, 网上有总多资料, 可以参考《UNIX 环境高级编程》.
本文顺便记录下 select()与 epoll()的优缺点对比:
select()
每次调用 select, 都需要把 fd 集合从用户态拷贝到内核态, 这个开销在 fd 很多时会很大
每次调用 select, 都需要在内核遍历传递进来的所有 fd, 这个开销在 fd 很多时也很大
select 支持的文件描述符数量太小了, 默认是 1024
epoll()
epoll 不仅仅一个函数, 而是切分为 3 个函数, 使得监控新的 fd 时, 不需要拷贝所有的 fd 集合, 只需要拷贝新的 fd 到内核即可.
epoll 采取回调的形式, 当某个 fd 就绪了, 就会调用回调, 而在回调中, 把就绪的 fd 加入就绪链表
epoll 没有数量, 它所支持的 FD 上限是最大可以打开文件的数目, 这个数字一般远大于 2048
可以发现, epoll()是对 select()存在的问题进行针对性的解决.
来源: https://www.cnblogs.com/gmpy/p/12652578.html