简介
本文最重要的一个收获是我们要知道:一次网络io 程序与内核的交互是两个阶段,正是按照这个两个阶段的不同处理,linux 网络io 分为5种模型。
io设备
磁盘(和内存)是一个可寻址的大数组(内存寻址:段 ==> 页 => 字节,磁盘寻址 磁盘 ==> xx ==> 字节),而os和应用都无法直接访问这个大数组(强调一下,即便是os,也是通过文件系统,即/xx/xx
的方式来访问文件的。这也是为什么load os的时候,有一个初始化文件系统的过程)。文件系统则是更高层抽象,文件系统定义了文件名、路径、文件、文件属性等抽象,文件系统决定这些抽象数据保存在哪些块中。
设备 | |
---|---|
面向流 | tty、socket |
面向块 | 磁盘 |
当我们需要进行文件操作的时候,5个API函数是必不可少的:Create,Open,Close,Write和Read函数实现了对文件的所有操作。PS:很多时候,觉得close方法没用,但一个文件io都会占用一个fd句柄,close便用于释放它们。
linux0.11内核文件读取的过程
- 应用程序调用系统调用read(包含文件路径等参数),进入内核态。
- 内核根据文件路径找到对应的设备号和磁盘数据块。(磁盘的索引块事先会被加载到内存)
- 先申请一个缓冲区块,将磁盘数据块挂到缓冲区块上(如果该缓冲区块已存在,就算了),进程挂起(直到缓冲块数据到位)。
- 将缓冲区块挂接到一个请求项上(struct request)。(该struct描述了请求细节:将某个设备的某数据块读到内存的某个缓冲区块上)
- 将请求项挂到该设备的请求队列上
- 该设备处理这个请求项时,根据设备号和块设备struct(预先初始化过),找到该设备的请求项处理函数
- 请求项处理函数取出该设备请求项队列的队首请求项,根据请求项的内容(操作什么设备,读还是写操作,操作那个部分,此处以读操作为例)给设备下达指令(将相应数据发送到指定端口),并将读盘服务程序与硬盘中断操作程序挂接。
- 硬盘读取完毕后发生中断,硬盘中断程序除进行常规操作(将数据读出到相应寄存器端口)外,调用先前挂接到这里的读盘服务程序
- 读盘服务程序将硬盘放在数据寄存器端口的数据复制到请求项指定的缓冲块中,并根据数据是否读取完毕(根据请求项内容判断),决定是否停止读取。
- 如果读取完毕,唤醒因为缓冲块挂起的进程。否则,继续读取。
上述叙述主要涉及了内核态操作,并不完全妥当,但整体感觉是有了。缓冲区读取完毕后,内核随即把数据从内核空间的临时缓冲区拷贝到进程执行read()调用时指定的缓冲区。
缓冲区
缓冲区的表现形式:
- 对于网络:socket有一个send buffer和receive buffer;
- 对于磁盘:内存会有一个专门的区域划分为缓冲区,由操作系统管理
浅谈TCP/IP网络编程中socket的行为,无论是磁盘io还是网络io,应用程序乃至r/w系统调用都不负责数据实际的读写(接收/发送)。对于每个socket,拥有自己的send buffer和receive buffer。以write操作为例,write成功返回,只是buf中的数据被复制到了kernel中的TCP发送缓冲区。至于数据什么时候被发往网络,什么时候被对方主机接收,什么时候被对方进程读取,系统调用层面不会给予任何保证和通知。已经发送到网络的数据依然需要暂存在send buffer中,只有收到对方的ack后,kernel才从buffer中清除这一部分数据,为后续发送数据腾出空间。这些控制皆发生在TCP/IP栈中,对应用程序是透明的,应用程序继续发送数据,最终导致send buffer填满,write调用阻塞。
这就跟我潜意识的认知,稍稍有点不同。我以前的认为是,一个write操作,数据从发起,到调用网卡驱动发数据,都是一起干完的。
缓冲区既可以处理各部件速度不一致的矛盾,也可以作为各个子系统的边界存在。
阻塞非阻塞
我们从代码上理解下阻塞和非阻塞的含义
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
为socket设置nonblocking
// 设置一个文件描述符为nonblock
int set_nonblocking(int fd){
int flags;
if ((flags = fcntl(fd, F_GETFL, 0)) == -1)
flags = 0;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
浅谈TCP/IP网络编程中socket的行为讲到了两个关键问题
- read/write的语义:为什么会阻塞?
- blocking(默认)和nonblocking模式下read/write行为的区别。
或者,我们可以说,blocking和nonblocking的本质,就是影响了read/write(可能还有connect)的语义
- blocking,表示等,缓冲区有数据(read)或有足够的空间
- nonblocking,成就成,不成就返回-1
阻塞io
非阻塞io
通常非阻塞I/O与I/O事件通知机制结合使用,避免应用层不断去轮询检查是否可读,提高程序的处理效率。
IO事件通知机制——IO复用
IO事件通知机制——SIGIO
同步异步——调用方和执行方是不是一个线程
POSIX规范定义了一组异步操作I/O的接口,不用关心fd 是阻塞还是非阻塞,异步I/O是由内核接管应用层对fd的I/O操作,以aio_read (注意,异步io 连方法名都不一样,这就是没看APUE( UNIX环境高级编程) 的缺点)实现异步读取IO数据为例
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
- 等待数据准备 (Waiting for the data to be ready)
- 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
也就是说,不管是阻塞、非阻塞、多路复用io,第一阶段都是用户进程主动去发现socket send/receive buffer是否ready,区别只是 用户态轮询还是内核态轮询(比如select/poll)和一次轮询几个fd的问题,第二阶段都是要阻塞。而异步io则是内核主动向用户进程发起通知的,第一和第二个阶段都不会阻塞。 PS: 这是这个博客最重要的一句话。
从bio 到 nio 这个小进步,便使得redis 有底气使用单线程来扛高负载,Redis 学习
异步I/O是由内核接管应用层对fd的I/O操作,从linux内核线程分析来看,本质就是linux 内核专为AIO 启动了多个内核线程,调用方和执行方不是一个线程。BIO 和NIO 的io 操作是调用方执行的,而AIO的io 操作是 kernel线程完成的 使用异步 I/O 大大提高应用程序的性能
- io 操作一定是在内核态执行的
- BIO/NIO 调用方执行
read(fd,buffer)
,read代码是os的,但操作是在调用方进程/线程的内核态执行的 - AIO,调用方只是传了个“需求”,类似于
aio_read("read 哪个fd,缓冲区位置,完事儿了去干啥")
。内核线程接到需求,干活儿,然后通知调用方。
netty 通过多加一层,netty 引擎层持有fd 引用(也就是socket channel),变相的将多路复用io封装为异步效果。参见异步编程
小结一下阻塞/非阻塞、同步/异步
陈皓在《左耳听风》中提到:异步io模型的发展技术是:select -> poll -> epoll -> aio -> libevent -> libuv。其演化思想参见: Understanding Reactor Pattern: Thread-Based and Event-Driven
各个io模型对比
建议先看下 不同层面的异步 不同层次的异步有所感觉。
同步阻塞 I/O(BIO) | 非阻塞 I/O(NIO) | 异步 I/O(AIO) | |
---|---|---|---|
客户端个数:I/O 线程 | 1:1 | M:1(1 个 I/O 线程处理多个客户端连接) | M:0(不需要用户启动额外的 I/O 线程,被动回调) |
I/O 类型(阻塞) | 阻塞 I/O | 非阻塞 I/O | 非阻塞 I/O |
I/O 类型(同步) | 同步 I/O | 同步 I/O(I/O 多路复用) | 异步 I/O |
API 使用难度 | 简单 | 非常复杂 | 复杂 |
调试难度 | 简单 | 复杂 | 复杂 |
可靠性 | 非常差 | 高 | 高 |
吞吐量 | 低 | 高 | 高 |
从中笔者解决了一直以来对NIO和AIO的一个疑惑:非阻塞io + rpc层异步化 也可以给上层业务层 提供 异步的感觉,但其毕竟比 AIO 多一个IO线程。
引用
存储之道 - 51CTO技术博客 中的《一个IO的传奇一生》
Linux IO模式及 select、poll、epoll详解
笔者个人微信订阅号