1.IO模型通读
IO模型参考来自 「Richard Stevens」 的UNIX Network Programming The Sockets Networking API Volume 1 • Third Edition
-
blocking I/O
-
nonblocking I/O
-
I/O multiplexing (select and poll)
-
signal driven I/O (SIGIO)
-
asynchronous I/O (the POSIX aio_functions)
我们先来说说 blocking
跟 asynchronous
的区别,这两者的区别主要关注 synchronous communication/ asynchronous communication
也就是消息的通信机制。
所谓的 blocking
就是在发出一个 「调用」 的时候,在没有结果进行返回的时候,该 「调用」 就不返回。但是一旦 「调用」 返回了,就得到返回值了。
下图是 blocking I/O
所谓的 synchronous
就是 「调用」 发出之后,这个 「调用」 就立即返回了,但是注意了这个 「调用」 是没有返回结果的,当一个 synchronous
发出之后,调用者不会立马得到结果的。而是在 「调用」 发出之后,被调用者通过 status
、 notify
来通知调用者或者是通过回调函数处理这个调用。
下图是 synchronous I/O
我们再来说说 blocking
跟 nonblocking
的区别,blocking
跟 nonblocking
,这两者的区别主要关注 程序在等待调用结果(消息,返回值)时的状态
所谓的 blocking
注重的是,在等待调用结果的这个过程当中,该线程是被挂起的
所谓的 nonblocking
注重的是,在等待调用结果的这个过程当中,该线程可以去执行其他的task,然后有空就过来 check一下调用是否完成
下图是nonblocking I/O
至此,我们说完了三个IO模型了:blocking I/O 、nonblocking I/O 、asynchronous I/O。
signal-driven I/O
意思就是信号驱动IO,允许Socket采用信号去通知,这个就需要我们准备一个信号处理的函数了,进程可以继续执行而不阻塞,当数据准备好的时候,进程就会收到SIGIO信号,可以在信号处理函数中调用I/O操作函数处理数据。
而我们的 I/O multiplexing
也就是我们的IO多路复用,IO多路复用主要是来解决 nonblocking I/O
出现的问题的, nonblocking I/O
当中我们提到 需要去 polling
(轮询),像 nonblocking I/O
每次都去等待一个的IO完成的操作,有点浪费,所以就有了IO多路复用,可以理解为我们专门开辟一条线程去轮询IO完成,有点像我们在机场候机的时候,那个播报器就是去轮询航班信息表,然后通知候机的乘客。( 而 nonblocking
就是需要每个乘客时不时自己点一下航班信息查询 )
上述的五个IO模型对比如下
2.深入谈谈IO多路复用
可能工作又或者面试最常听到的,莫过于IO多路复用,IO多路复用的实现就是我们经常听到的 select、poll、epoll
(当然epoll的效率最高,但也是干这个的)。
IO多路复用同样也会阻塞,没错,调用 select、poll、epoll
这几个函数也会使线程阻塞,但是跟 blocking I/O
不同的是,这三个函数所阻塞的是多个IO。
IO多路复用的大概过程就是:
用户调用了 select、poll、epoll
时进程线程阻塞,这个时候内核就会监视所有 select
所负责的 socket
,当其中一个socket当中的数据准备好了,select就会返回,此时用户再进行 read
操作,将数据从内核态拷贝到用户态。
IO多路复用相对于阻塞IO而言,真的是银弹吗?其实不然,对于阻塞IO而言system call的次数少于IO多路复用,如果在IO操作较为少的场景下,线程池+阻塞IO是一个更好的选择。
上文我们提到IO多路复用主要是借助于 select、poll、epoll
这三个函数,那我们接下来就好好来了解一下这三个函数
select()
// 四个参数的解析
// nfds:监视对象文件描述符数量
// *readfds:用户检查可读性
// *writefds:用户检查可写性
// *exceptfds:用于检查外带数据
// *timeout:超时等待时间
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
官方文档描述
select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file
descriptors become "ready" for some class of I/O operation (e.g.,input possible). A file descriptor is considered
ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.
select()允许程序监控多个fd,阻塞等待直到一个或多个fd到达"就绪"状态。理解 select 的关键在于理解 fd_set,为说明方便,取 fd_set 长度为 1 字节,fd_set 中的每一 bit 可以对应一个文件描述符 fd,则 1 字节长的 fd_set 最大可以对应 8 个 fd。(对应的数据结构是位图)
要理解 fd_set
关键点还有四个宏函数
FD_SET(int fd, fd_set *fdset); //将fd加入set集合
FD_CLR(int fd, fd_set *fdset); //将fd从set集合中清除
FD_ISSET(int fd, fd_set *fdset); //检测fd是否在set集合中,不在则返回0
FD_ZERO(fd_set *fdset); //将set清零使集合中不含任何fd
select 的调用过程如下:
-
执行 FD_ZERO(&readfds), 则 set 用位表示是
0000,0000
-
若 fd=7, 执行 FD_SET(fd, &set); 后 set 变为
0100,0000
(第 7 位置为 1) -
再加入 fd=5, fd=1,则 set 变为
0101,0001
-
执行 select(fd, &set, 0, 0, 0) 阻塞等待
-
若 fd=1, fd=2 上都发生可读事件,则
*readfds
返回,此时*readfds
变为0000,0011
(注意:没有事件发生的 fd=7 不会加入可读set)
select函数的几个特点
-
复杂度O(n),轮询的任务交给了内核来做,复杂度并没有变化,数据取出后也需要轮询哪个fd上发生了变动;
-
用户态还是需要不断切换到内核态,直到所有的fds数据读取结束,整体开销依然很大;
-
fd_set有大小的限制,目前被硬编码成了「1024」;
-
fd_set不可重用,每次操作完都必须重置;
select的缺点
-
最大并发数限制:使用 32 个整数的 32 位,即 32*32=1024 来标识 fd,虽然可修改,但是还有以下第 2, 3 点的瓶颈
-
每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
-
性能衰减严重:每次 kernel 都需要线性扫描整个 fd_set,所以随着监控的描述符 fd 数量增长,其 I/O 性能会线性下降
poll()
// 三个参数的解析
// *fds:pollfd结构体
// nfds:要监视的描述符的数量
// *timeout:超时等待时间
int poll(struct pollfd *fds, nfds_t nfds, int *timeout);
官方文档描述
poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to
perform I/O.
poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达"就绪"状态。
「poll()「和」select()「是非常相似的,唯一的区别在于」poll()「摒弃掉了位图算法,使用自定义的结构体」pollfd」,在「pollfd」内部封装了fd,并通过event变量注册感兴趣的可读可写事件(「POLLIN、POLLOUT」),最后把 「pollfd」 交给内核。当有读写事件触发的时候,我们可以通过轮询 「pollfd」,判断revent确定该fd是否发生了可读可写事件。
/* struct pollfd结构类型的数组,用于存放需要检测其状态的Socket描述符;每当调用这个函数之后,系统不会清空这个数组,操作起来较
方便;特别是对于socket连接比较多的情况下,在一定程度上可以提高处理的效率;这一点与select()函数不同,调用select()函数之后,
select()函数会清空它所检测的socket描述符集合,导致每次调用select()之前都必须把socket描述符重新加入到待检测的集合中;因此,
select()函数适合于只检测一个socket描述符的情况,而poll()函数适合于大量socket描述符的情况; */
struct pollfd{
int fd;
//events和revents是通过对代表各种事件的标志进行逻辑或运算构建而成的
/* events包括要监视的事件,poll用已经发生的事件填充revents。poll函数通过在revents中设置标志POLLHUP、POLLERR和POLLNVAL
来反映相关条件的存在。不需要在events中对于这些标志符相关的比特位进行设置。如果fd小于0,则events字段被忽略;*/
short int events;
/*revents被置为0,标准中没有说明如何处理文件结束。文件结束可以通过revents的标识符POLLHUN或返回0字节的常规读操作来传达。即
使POLLIN或POLLRDNORM指出还有数据要读,POLLHUP也可能会被设置。因此,应该在错误检验之前处理正常的读操作;*/
short int revents;
};
「poll()」 相对于「select()」,主要的优势是使用了pollfd的结构体:
-
没有了bitmap大小1024的限制;(poll采用的是链表的形式)
-
通过结构体中的revents置位;
但是用户态到内核态切换及O(n)复杂度的问题依旧存在(还是要遍历扫描),poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
select与poll总结
其实select跟poll本质上是没有区别的,两者都是将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
epoll登场
epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驱动技术,I/O 多路复用的核心设计是 1 个线程处理所有连接的 等待消息准备好
I/O 事件,这一点上 epoll 和 select&poll
是大同小异的。但是在处理上万个并发上,select&poll显得有些力不从心了,
既然我把epoll放在 select&poll
之后,那么epoll肯定有着自己的过人之处,我们来简单看一下场景假设
有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。而我们的epoll可以处理数十万的并发处理,别着急,我们接下来一起看看epoll
// epoll_create 创建一个 epoll 实例并返回 epollfd;
int epoll_create(int size); // int epoll_create1(int flags);
// epoll_ctl 注册 file descriptor 等待的 I/O 事件(比如 EPOLLIN、EPOLLOUT 等) 到 epoll 实例上;
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/* epoll_wait 则是阻塞监听 epoll 实例上所有的 file descriptor 的 I/O 事件,它接收一个用户空间上的一块内存地址 (events 数
组),kernel 会在有 I/O 事件发生的时候把文件描述符列表复制到这块内存地址上,然后 epoll_wait 解除阻塞并返回,最后用户空间上的
程序就可以对相应的 fd 进行读写了;*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原
先的 select&poll
调用分成了3个部分:
-
调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
-
调用epoll_ctl向epoll对象中添加这100万个连接的套接字
-
调用epoll_wait收集发生的事件的连接
调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树
中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是logN,其中N为红黑树元素个数)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方
法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中(这一步也是优于 select&poll
的地方, select&poll
都需要去线性扫描整个fd集合)。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
epoll_wait 实际上就是去检查 rdlist 双向链表中是否有就绪的 fd,当 rdlist 为空(无就绪 fd)时挂起当前进程,直到 rdlist 非空时进程才被唤醒并返回。
3.从golang的角度看看IO模型
值得一提的是go的netpoll为我们封装了 epoll/kqueue/iocp 这一类的 I/O 事件驱动技术,也就是采用go去编写I/O相关的代码的时候,是不
会存在同步阻塞IO的情况的,go的IO模式是配合goroutine的设计的,模式是 goroutine-per-connection
,在这种模式下,开发者使用的
是同步的模式去编写异步的逻辑而且对于开发者来说 I/O 是否阻塞是无感知的,也就是说开发者无需考虑 goroutines 甚至更底层的线程、
进程的调度和上下文切换。
结合在Stack Overflow看到比较有趣的讨论
这个提问的Roger Johansson就是好奇:当从文件或网络读取时,Go使用阻塞IO吗?
下面的回答
当你的代码从goroutine被阻塞的时候,其实并没有真正被阻塞
下面的:I would add that the Go runtime scheduler currently (Go 1.6 and below) multiplexes (epoll on Linux, IOCPs on Windows etc) only network I/O syscalls.
主要是说到在Go1.6及以下的时候,Linux是采用了epoll,Windows的话采用的是IOCP
下面还有关于阻塞跟阻塞的一些争论
对应的Stack Overflow链接是:https://stackoverflow.com/questions/36112445/golang-blocking-and-non-blocking
还有另外一个关于Go block的,链接:https://stackoverflow.com/questions/35471480/does-go-block-on-processor-intensive-operations-like-node/35471842#35471842
这个问题有一个很有意思的回答就是:
So, if you have a single goroutine doing something CPU-intensive, it generally won't have an impact on other
goroutines' ability to run, because there are a bunch of other threads available to run them. If *all* of your
goroutines are occupied with computation, then it's true that other ones won't get a chance to run until one gives
up the CPU. This might not *necessarily* be a problem if they're actually getting work done, since all of your CPUs
are doing actual work, but of course sometimes it can be in latency-sensitive situations.
大概意思就是就算goroutine去运行CPU密集的操作的时候,通常也不会影响其他goroutine,除非是 If *all* of your goroutines are occupied with computation
所有的goroutine都在处于计算当中,也就是跑满了goroutine了。
当然,在我个人角度认为这个是属于概念的理解上问题,并没有什么意义,我的立场是 goroutine
也是存在阻塞这种说法,只能说golang对应到 thread
上面是一个不阻塞的状态。
从GMP谈谈这个操作吧
G 在运行过程中如果被阻塞在某个 system call 操作上,那么不光 G 会阻塞,执行该 G 的 M 也会解绑对应的P(实质是被 sysmon 抢走了),与 G 一起进入 sleep 状态。如果此时有「空闲」的 M ,则 P 与其绑定继续执行其他 G;如果没有 idle M,但仍然有其他 G 要去执行,那么就会创建一个新的 M。当阻塞在 system call
上的 G 完成 system call
调用后,G 会去尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 _Grunnable
并把它放入全局的 runqueue 中等待调度,之前的那个 sleep 的 M 将再次进入 sleep。
为什么golang只设计了非阻塞(线程角度)
如果一旦采用阻塞IO,那么就会将这个操作的goroutine进入内核态,而一旦进入内核态,golang的GMP模型是不起作用的,整个程序的控制权就会发生转移(到内核),不再属于用户进程了。当gouroutine被阻塞在network I/O 的,实际上并不是进入了内核态的阻塞,而是go runtime调用了gopark将goroutine给park了,此时的goroutine会被放到一个等待队列当中,然后根据GMP模型,对应的M会继续取下一个_Grunnable状态的goroutine继续运行。当 I/O available,在 wait queue 中的 goroutine 会被唤醒,标记为 _Grunnable
,放入某个可用的 P 的 local 队列中,绑定一个 M 恢复执行。
4.Reactor 模式
给自己挖个坑,golang的Ractor模式,虽然字节内部又或者其他开源框架已经有实现的,但是对于这方面的学习文章还较少,抽空研究怎么拿golang写Reactor
Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O)
的模式。
先看看Wikipedia对于Reactor的解释
The reactor design pattern is an event handling pattern for handling service requests delivered concurrently to a
service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches
them synchronously to the associated request handlers.
从维基百科当中我们提取几个关键的点:
事件驱动模式(event handling pattern)、可以处理一个或多个输入源(one or more inputs)、The service handler then demultiplexes (多路复用)the incoming requests and dispatches them synchronously to the associated request handlers. (其实就是同步地将输入的事件采用多路复用dispatches 到对应的request handlers)
Reactor Pattern 处理模式中,定义以下三种角色:
-
「Reactor」 将I/O事件分派给对应的Handler
-
「Acceptor」 处理客户端新连接,并分派请求到处理器链中
-
「Handlers」 执行非阻塞读/写 任务
在golang当中非阻塞肯定是开goroutine去执行啦,Reactor不能说是一种IO编程模型吧,因为这种是一个IO编程模型的设计,当然Reactor有着自己的演进过程。
几种Reactor还是蛮容易理解的,我这里就不加以赘述了。
单Reactor单线程模型
单Reactor多线程模型
多Reactor多线程模型
Reactor不难理解,中间的代码编写稍微复杂了一些,这个挖好坑再慢慢填坑吧,再说说基于事件驱动的处理模式一共有以下:
-
「Reactor」
-
「Proactor」
-
「Asynchronous Completion Token」
-
「Acceptor-Connector」