简介
要知道一个server是如何运行的,从硬件到操作系统,直到编程语言。优化IO调用的数量是你通往最好架构的首选之路。
一个 TCP 连接建立后,用户代码会获得一个用于收发数据的通道,每个通道会在内存中开辟两片区域用于收发数据的缓存。
- 发送数据的过程比较简单,我们直接往这个通道里面来写入数据就可以了。用户代码在发送时写入的数据会暂存在缓存中,然后操作系统会通过网卡,把发送缓存中的数据传输到对端的服务器上。只要这个缓存不满,或者说,我们发送数据的速度没有超过网卡传输速度的上限,那这个发送数据的操作耗时,只不过是一次内存写入的时间。
- 比较麻烦的是接收数据。对于数据的接收方来说,它并不知道什么时候会收到数据。
《深入理解Linxu网络》当数据包到来以后,第一个迎接它的是网卡,网卡将数据帧DMA 到内存的RingBuffer中,然后向CPU 发起中断通知。CPU 响应中断请求,调用网卡启动时注册的中断处理函数。中断处理函数几乎没干什么,只发起了软中断请求。内核线程ksoftirqd 发现有软中断请求到来,先关闭硬中断,开始调用驱动的poll函数收包,poll 函数将收到的包送到协议栈注册的ip_rcv函数中,ip_rcv 函数将包送到udp_rcv/tcp_rcv_v4 函数中。接下来要能通知到用户进程,让用户进程能够接收到并处理这些数据,怎么通知?内核是如何与用户进程协作的?
阻塞非阻塞
我们从代码上理解下阻塞和非阻塞的含义
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);
}
动画图解 socket 缓冲区的那些事儿执行 send 之后,数据只是拷贝到了socket 缓冲区。然后根据实际情况(比如拥塞窗口等)判断是否要发数据。阻塞其实说的是 进程因为等待某个事件而主动让出CPU 挂起。
- 当发送缓冲区满了,如果还向socket执行send。
- 如果此时 socket 是阻塞的,那么程序会在那干等、死等,直到释放出新的缓存空间,就继续把数据拷进去,然后返回。
- 如果此时 socket 是非阻塞的,程序就会立刻返回一个 EAGAIN 错误信息,意思是 Try again , 现在缓冲区满了,你也别等了,待会再试一次。
- 当接收缓冲区为空,如果还向socket执行 recv
- 如果此时 socket 是阻塞的,那么程序会在那干等,直到接收缓冲区有数据(io中断),就会把数据从接收缓冲区拷贝到用户缓冲区,然后返回。
- 如果此时 socket 是非阻塞的,程序就会立刻返回一个 EAGAIN 错误信息。
阻塞io
int main(){
int sk = socket(AF_INET, SOCK_STREAM, 0);
connect(sk, ...)
recv(sk, ...)
}
从用户进程创建 socket,到一个网络包抵达网卡到被用户进程接收到,总体上的流程图如下:
非阻塞io
Non-blocking I/O 的特点是用户进程需要不断的主动询问 kernel 数据好了没有。非阻塞 IO 解决了阻塞 IO每个连接一个线程处理的问题,所以其最大的优点就是 一个线程可以处理多个连接,这也是其非阻塞决定的。但这种模式,也有一个问题,就是需要用户多次发起系统调用。频繁的系统调用是比较消耗系统资源的。
IO事件通知机制——IO复用,I/O 多路复用需要使用特定的系统调用,比如select/poll/epoll 等。可以一次找到所有缓冲区或连接状态发生变化的TCP连接。
网络 IO 演变发展过程和模型介绍很多人都说,IO 多路复用是用一个线程来管理多个网络连接,但本人不太认可,因为在非阻塞 IO 时,就已经可以实现一个线程处理多个网络连接了,这个是由于其非阻塞而决定的。作者观点,多路复用主要复用的是通过有限次的系统调用来实现管理多个网络连接。
IO事件通知机制——SIGIO
同步异步
数据的传递需要两个阶段,只要任何一个阶段会阻塞用户请求,都将其称为同步 IO,两个阶段都不阻塞,则称为异步 IO。在目前所有的操作系统中,linux 中的 epoll、mac 的 kqueue 都属于同步 IO,因为其在第二阶段(数据从内核态到用户态)都会发生拷贝阻塞。而只有 windows 中的 IOCP 才真正属于异步 IO,即 AIO。
各个io模型对比
从bio 到nio
进入系统调用后,用户进程就进入了内核态,执行一系列的内核协议层函数,然后到socket 对象的接收队列中查看是否有数据,没有的话就把自己添加到socket对应的等待队列里(等待队列项包含用户进程的描述符)。最后让出CPU,进程将进入睡眠状态,操作系统会选择下一个就绪状态的进程执行(会导致一次进程上下文切换)。
网卡接收到数据后,最后交给软中断处理,如果是tcp包就会执行tcp_rcv_v4,根据接收到的网络包的header里的source和dest信息在本机上查询对应的socket,如果是ESTABLISH状态下的数据包,则执行tcp_rcv_established,最终会把数据拆出来放到对应的socket 接收队列中,然后调用sk_data_ready 来唤醒用户进程(又将产生一次进程上下文切换)。即使有多个进程都阻塞在同一个socket 上,也只唤醒一个进程, 其作用是避免了“惊群”,而不是把所有进程都唤醒。 PS:有点锁的味道了。
这种模式在客户端角色上,还存在使用的情形,因为你的进程可能确实要等到Mysql返回数据后,才能做事情,否则什么也干不了(有一些封装很好的网络框架,在客户端角色上也摒弃了这种低效的模式)。但在服务端角色上,这种模式完全没办法使用,因为socket 和进程是一对一的,现在要单台服务器承载成千上万的用户连接请求。
对于一次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线程。
源码分析
IO 多路复用中,select()/poll()/epoll_wait()
这几个函数对应第一阶段;read()/recvfrom()
对应第二阶段
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 函数监视的文件描述符分 3 类,分别是 writefds、readfds、和 exceptfds。调用后 select 函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有 except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当 select 函数返回后,可以 通过遍历 fdset,来找到就绪的描述符。
- 理解 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 性能会线性下降
IO 多路复用第一版:IO 多路复用,主要在于复用。通过 select()或者 poll()将多个 socket fds 批量通过系统调用传递给内核,由内核进行循环遍历判断哪些 fd 上数据就绪了,然后将就绪的 readyfds 返回给用户。再由用户进行挨个遍历就绪好的 fd,读取或者写入数据。所以通过 IO 多路复用+非阻塞 IO,一方面降低了系统调用次数,另一方面可以用极少的线程来处理多个网络连接。 但返回的活跃连接 == select(全部待监控的连接)
同时引入了新的问题:用户需要每次将海量的 socket fds 集合从用户态传递到内核态,让内核态去检测哪些网络连接数据就绪了,再把就绪的fd从内核态拷贝到用户态。这个地方开销挺大。
epoll
epoll 是一个指令组,能在单个指令层面支持让用户态 thread 同时对多个 fd 发起监听,调用模式还可以根据使用需要调整为非阻塞、阻塞或超时模式,其中包含三个指令:
- epoll_create;通过 epoll_create 可以开辟一片内核空间用于承载 epoll 事件表,在表中可以注册一系列关心的 fd 、相应的监听事件类型以及回调时需要携带的数据。epoll 事件表是基于红黑树实现的 key-value 有序表,其中 key 是 fd,value 是监听事件类型以及使用方自定义拓展数据。
- epoll_ctl; crud fd 和 crud fd event。
- epoll_wait. 执行 epoll_wait 操作时,会传入一个固定容量的就绪事件列表,当注册监听的 io 事件就绪时,内核中会基于事件回调机制将其添加到就绪事件列表中并进行返回. PS:说白了epoll_create 建红黑树,epoll_ctl crud节点,epoll_wait把轮询的逻辑下沉到内核态了
int main(){
listen(lfd,...)
cfd1 = accept(...)
cfd2 = accept(...)
efd = epoll_create(...) // 创建一个epoll 对象
epoll_ctl(efd,EPOLL_CTL_ADD,cfd1,...) // 向epoll 对象添加要管理的连接
epoll_ctl(efd,EPOLL_CTL_ADD,cfd2,...)
epoll_wait(efd,...) // 等待其管理的连接上的IO 事件
}
在用户进程调用epoll_create 时,内核会创建一个struct eventpoll 的内核对象,并把它关联到当前进程已打开文件列表中,
epoll内核源码分析epoll 接口
// 创建一个epoll struct,当创建好epoll句柄后,并把它关联到当前进程的已打开文件列表中。
int epoll_create(int size);
// epoll的事件注册函数。告诉内核 需要监听 fd 的哪些 epoll_event
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epoll_wait 则是阻塞监听 epoll 实例上所有的 file descriptor 的 I/O 事件。参数events用来从内核得到事件的集合
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
struct eventpoll{
file *file;
rb_root rbr; // 红黑树 管理用户进程添加进来的所有socket,方便高效的 添加和删除 socket
list_head rdlist; // 就绪链表,在数据到来的时候,不断地将数据 Ready 的socket 放到就绪链表 rdllist 里
wait_queue_head_t wq // 等待队列,软中断数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。
}
- epoll 采用红黑树来存储所有监听的 fd,而红黑树本身插入和删除性能比较稳定,时间复杂度 O(logN)。通过 epoll_ctl 函数添加进来的 fd 都会被放在红黑树的某个节点内
- 当把 fd 添加进来的时候时候会 将ep_poll_callback 注册到socket 的等待队列项中(回到函数是 sk_data_ready)。数据包到来时 tcp_v4_rcv ==> tcp_v4_do_rcv ==> tcp_rcv_established ==> tcp_queue_rcv 将接收数据放到 socket 的接收队列上,(socket 等待队列项是一个回调函数 sk_data_ready)接着再调用 sk_data_ready(一个函数指针) ==> ep_poll_callback。ep_poll_callback 把这个 fd 添加到 rdllist 双向链表(就绪链表)中。
- epoll_wait 实际上就是(用户进程)去检查 rdllist 双向链表中是否有就绪的 fd,如果有,直接取走处理,处理完毕再次调用epoll_wait。当 rdllist 为空(无就绪 fd)时挂起当前进程,加入到eventpoll 的wq中(bio 时用户进程挂在 socket的wq中),直到 rdllist 非空时进程才被唤醒并返回。
进程什么时候被唤醒呢?当数据包到来以后,第一个迎接它的是网卡,网卡将数据帧DMA 到内存的RingBuffer中,然后向CPU 发起中断通知。CPU 响应中断请求,调用网卡启动时注册的中断处理函数。中断处理函数发起了软中断请求。内核线程ksoftirqd 发现有软中断请求到来,开始调用驱动的poll函数收包,poll 函数将收到的包送到协议栈注册的ip_rcv函数中,ip_rcv 函数将包送到udp_rcv/tcp_rcv_v4 函数中。 将接收数据放到 socket 的接收队列上,接着调用sk_data_ready 唤醒等待队列项,epoll 场景下 等待队列项是 epoll_ctl 添加socket时在其上设置的回调函数 ep_poll_callback,ep_poll_callback 根据 等待队列项额外的base 指针可以找到epitem ,进而找到eventpoll 对象。它做的第一件事 就是把自己的epitem 添加到epoll 的就绪队列中,接着它又查看 eventpoll 对象上的等待队列里是否有 等待项(epoll_wait时会设置),如果没有,软中断的事情就做完了。如果有等待项,那就找到等待项里设置的回调函数 default_wait_function,在default_wait_function 中找到等待队列项里的进程描述符,然后唤醒它。
是的,当没有 IO 事件的时候, epoll 也是会阻塞掉当前进程。但其实在实践中,只要活儿足够多,epoll_wait根本不会让进程阻塞。
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;
}
从操作系统层面分析Java IO演进之路多路复用充分减少了 system call,而epoll更进一步,再次降低了system call的时间复杂度。
深度解析单线程的 Redis 如何做到每秒数万 QPS 的超高处理能力基于C 对linux epoll的运用。epoll 机制相对于同步机制 内核多了系统调用(epoll_xx)和数据结构(eventpoll rdlist/wq)
- LT(水平触发)模式下,只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作;你可以根据业务一次性收取固定的字节数,或者收完为止。
- ET(边缘触发)模式下,在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,直到该文件描述符上出现第二次可读写事件才会通知你。
其它
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()调用时指定的缓冲区。
io设备
磁盘(和内存)是一个可寻址的大数组(内存寻址:段 ==> 页 => 字节,磁盘寻址 磁盘 ==> xx ==> 字节),而os和应用都无法直接访问这个大数组(强调一下,即便是os,也是通过文件系统,即/xx/xx
的方式来访问文件的。这也是为什么load os的时候,有一个初始化文件系统的过程)。文件系统则是更高层抽象,文件系统定义了文件名、路径、文件、文件属性等抽象,文件系统决定这些抽象数据保存在哪些块中。
设备 | |
---|---|
面向流 | tty、socket |
面向块 | 磁盘 |
当我们需要进行文件操作的时候,5个API函数是必不可少的:Create,Open,Close,Write和Read函数实现了对文件的所有操作。PS:不要觉得close方法没用,但一个文件io都会占用一个fd句柄,close便用于释放它们。