简介
本文最重要的一个收获是我们要知道:一次网络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便用于释放它们。
阻塞非阻塞
我们从代码上理解下阻塞和非阻塞的含义
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);
}
阻塞io
非阻塞io
Non-blocking I/O 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。通常非阻塞I/O与I/O事件通知机制结合使用,避免应用层不断去轮询检查是否可读,提高程序的处理效率。
IO事件通知机制——IO复用,I/O 多路复用需要使用特定的系统调用,比如select/poll/epoll 等。
IO事件通知机制——SIGIO
同步异步——调用方和执行方是不是一个线程
POSIX规范定义了一组异步操作I/O的接口,不用关心fd 是阻塞还是非阻塞,异步I/O是由内核接管应用层对fd的I/O操作,以aio_read (注意,异步io 连方法名都不一样,这就是没看APUE( UNIX环境高级编程) 的缺点)实现异步读取IO数据为例
各个io模型对比
从bio 到nio
对于一次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 学习
从nio 到aio
异步I/O是由内核接管应用层对fd的I/O操作,像 Windows 的 IOCP 这一类的异步 I/O,只需要在调用 WSARecv 或 WSASend 方法读写数据的时候把用户空间的内存 buffer 提交给 kernel,kernel 负责数据在用户空间和内核空间拷贝,完成之后就会通知用户进程,整个过程不需要用户进程参与
netty 通过多加一层,netty 引擎层持有fd 引用(也就是socket channel),变相的将多路复用io封装为异步效果。参见异步编程
陈皓在《左耳听风》中提到:异步io模型的发展技术是:select -> poll -> epoll -> aio -> libevent -> libuv。其演化思想参见: Understanding Reactor Pattern: Thread-Based and Event-Driven
io 与 上层应用/rpc整合
同步阻塞 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线程。
源码分析
select
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 和 select 紧密结合的四个宏:
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
- 理解 select 的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd。可监控的文件描述符个数取决于 sizeof(fd_set) 的值。假设服务器上 sizeof(fd_set)=512,每 bit 表示一个文件描述符,则服务器上支持的最大文件描述符是 512*8=4096。
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
- 每次 kernel 都需要线性扫描整个 fd_set,所以随着监控的描述符 fd 数量增长,其 I/O 性能会线性下降
epoll
I/O 多路复用的核心设计是 1 个线程处理所有连接的 等待消息准备好 I/O 事件,这一点上 epoll 和 select&poll 是大同小异的。但 select&poll 错误预估了一件事,当数十万并发连接存在时,可能每一毫秒只有数百个活跃的连接,同时其余数十万连接在这一毫秒是非活跃的。select&poll 的使用方法是这样的: 返回的活跃连接 == select(全部待监控的连接)
。什么时候会调用 select&poll 呢?在你认为需要找出有报文到达的活跃连接时,就应该调用。所以,select&poll 在高并发时是会被频繁调用的。它的轻微效率损失都会被 高频 二字所放大。
epoll内核源码分析epoll 接口
// 创建一个epoll struct,当创建好epoll句柄后,它就是会占用一个fd值
int epoll_create(int size);
// epoll的事件注册函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epoll_wait 则是阻塞监听 epoll 实例上所有的 file descriptor 的 I/O 事件
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- epoll 采用红黑树来存储所有监听的 fd,而红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN)。通过 epoll_ctl 函数添加进来的 fd 都会被放在红黑树的某个节点内
- 当把 fd 添加进来的时候时候会完成关键的一步:该 fd 会与相应的设备(网卡)驱动程序建立回调关系,也就是在内核中断处理程序为它注册一个回调函数ep_poll_callback,在 fd 相应的事件触发(中断)之后(设备就绪了),内核就会调用ep_poll_callback,把这个 fd 添加到 rdllist 双向链表(就绪链表)中。
- epoll_wait 实际上就是去检查 rdllist 双向链表中是否有就绪的 fd,当 rdllist 为空(无就绪 fd)时挂起当前进程,直到 rdllist 非空时进程才被唤醒并返回。
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout){
if (timeout > 0) {
...
} else if (timeout == 0) {
...
goto check_events; // 如果timeout等于0,函数不阻塞,直接返回
}
fetch_events:
if (!ep_events_available(ep))
ep_busy_loop(ep, timed_out);
spin_lock_irqsave(&ep->lock, flags);
/*
当没有事件产生时((!ep_events_available(ep))为true),调用__add_wait_queue_exclusive函数将当前进程加入到ep->wq等待队列里面,然后在一个无限for循环里面,首先调用set_current_state(TASK_INTERRUPTIBLE),将当前进程设置为可中断的睡眠状态,然后当前进程就让出cpu,进入睡眠,直到有其他进程调用wake_up或者有中断信号进来唤醒本进程,它才会去执行接下来的代码。
*/
if (!ep_events_available(ep)) {
ep_reset_busy_poll_napi_id(ep);
init_waitqueue_entry(&wait, current);
__add_wait_queue_exclusive(&ep->wq, &wait);
for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
/*
如果进程被唤醒后,首先检查是否有事件产生,或者是否出现超时还是被其他信号唤醒的。如果出现这些情况,就跳出循环,将当前进程从ep->wp的等待队列里面移除,并且将当前进程设置为TASK_RUNNING就绪状态。
*/
if (ep_events_available(ep) || timed_out)
break;
if (signal_pending(current)) {
res = -EINTR;
break;
}
spin_unlock_irqrestore(&ep->lock, flags);
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;
spin_lock_irqsave(&ep->lock, flags);
}
__remove_wait_queue(&ep->wq, &wait);
__set_current_state(TASK_RUNNING);
}
check_events:
// 如果真的有事件产生,就调用ep_send_events函数,将events事件转移到用户空间里面。
eavail = ep_events_available(ep);
spin_unlock_irqrestore(&ep->lock, flags);
if (!res && eavail &&
!(res = ep_send_events(ep, events, maxevents)) && !timed_out)
goto fetch_events;
return res;
}
进程什么时候被唤醒呢?在调用epoll_ctl为目标文件注册监听项时,对目标文件的监听项注册一个ep_ptable_queue_proc回调函数,ep_ptable_queue_proc回调函数将进程添加到目标文件的wakeup链表里面,并且注册ep_poll_callbak回调,当目标文件产生事件时,ep_poll_callbak回调就去唤醒等待队列里面的进程。
其它
read 系统调用剖析“Linux 系统调用(SCI,system call interface)的实现机制实际上是一个多路汇聚以及分解的过程,该汇聚点就是 0x80 中断这个入口点(X86 系统结构)。也就是说,所有系统调用都从用户空间中汇聚到 0x80 中断点,同时保存具体的系统调用号。当 0x80 中断处理程序运行时,将根据系统调用号对不同的系统调用分别处理(调用不同的内核函数处理)。”
存储之道 - 51CTO技术博客 中的《一个IO的传奇一生》
Linux IO模式及 select、poll、epoll详解
俱往矣——UIO
Linux设计初衷是以通用性为目的的,但随着Linux在服务器市场的广泛应用,其原有的网络数据包处理方式已很难跟上人们对高性能网络数据处理能力的诉求。在这种背景下DPDK应运而生,其利用UIO技术,在Driver层直接将数据包导入到用户态进程,绕过了Linux协议栈,接下来由用户进程完成所有后续处理,再通过Driver将数据发送出去。原有内核态与用户态之间的内存拷贝采用mmap将用户内存映射到内核,如此就规避了内存拷贝、上下文切换、系统调用等问题,然后再利用大页内存、CPU亲和性、无锁队列、基于轮询的驱动模式、多核调度充分压榨机器性能,从而实现高效率的数据包处理。
缓冲区
缓冲区的表现形式:
- 对于网络: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操作,数据从发起,到调用网卡驱动发数据,都是一起干完的。
缓冲区既可以处理各部件速度不一致的矛盾,也可以作为各个子系统的边界存在。
linux0.11内核文件读取的过程
- 应用程序调用系统调用read(包含文件路径等参数),进入内核态。
- 内核根据文件路径找到对应的设备号和磁盘数据块。(磁盘的索引块事先会被加载到内存)
- 先申请一个缓冲区块,将磁盘数据块挂到缓冲区块上(如果该缓冲区块已存在,就算了),进程挂起(直到缓冲块数据到位)。
- 将缓冲区块挂接到一个请求项上(struct request)。(该struct描述了请求细节:将某个设备的某数据块读到内存的某个缓冲区块上)
- 将请求项挂到该设备的请求队列上
- 该设备处理这个请求项时,根据设备号和块设备struct(预先初始化过),找到该设备的请求项处理函数
- 请求项处理函数取出该设备请求项队列的队首请求项,根据请求项的内容(操作什么设备,读还是写操作,操作那个部分,此处以读操作为例)给设备下达指令(将相应数据发送到指定端口),并将读盘服务程序与硬盘中断操作程序挂接。
- 硬盘读取完毕后发生中断,硬盘中断程序除进行常规操作(将数据读出到相应寄存器端口)外,调用先前挂接到这里的读盘服务程序
- 读盘服务程序将硬盘放在数据寄存器端口的数据复制到请求项指定的缓冲块中,并根据数据是否读取完毕(根据请求项内容判断),决定是否停止读取。
- 如果读取完毕,唤醒因为缓冲块挂起的进程。否则,继续读取。
上述叙述主要涉及了内核态操作,并不完全妥当,但整体感觉是有了。缓冲区读取完毕后,内核随即把数据从内核空间的临时缓冲区拷贝到进程执行read()调用时指定的缓冲区。