​1.IO模型通读

UNIX Network Programming The Sockets Networking API Volume 1 • Third Edition
blocking I/Ononblocking I/OI/O multiplexing (select and poll)signal driven I/O (SIGIO)asynchronous I/O (the POSIX aio_functions)
blockingasynchronoussynchronous communication/ asynchronous communication
blocking
blocking I/O

synchronoussynchronousstatusnotify
synchronous I/O

blockingnonblockingblockingnonblocking程序在等待调用结果(消息,返回值)时的状态
blocking
nonblocking
nonblocking I/O

至此,我们说完了三个IO模型了:blocking I/O  、nonblocking I/O  、asynchronous I/O。

signal-driven I/O

I/O multiplexingnonblocking I/Ononblocking I/Opollingnonblocking I/Ononblocking

上述的五个IO模型对比如下

 

 

2.深入谈谈IO多路复用

select、poll、epoll
select、poll、epollblocking I/O

IO多路复用的大概过程就是:

select、poll、epollselectsocketread

IO多路复用相对于阻塞IO而言,真的是银弹吗?其实不然,对于阻塞IO而言system call的次数少于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 的调用过程如下:

0000,00000100,00000101,0001*readfds*readfds0000,0011

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登场

等待消息准备好select&poll
select&poll
有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
  • 调用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中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方

select&pollselect&poll

在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相关的代码的时候,是不

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

下面还有关于阻塞跟阻塞的一些争论

https://stackoverflow.com/questions/36112445/golang-blocking-and-non-blocking
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.
If *all* of your goroutines are occupied with computation

goroutinethread

从GMP谈谈这个操作吧

system callsystem call_Grunnable

为什么golang只设计了非阻塞(线程角度)

_Grunnable

4.Reactor 模式

给自己挖个坑,golang的Ractor模式,虽然字节内部又或者其他开源框架已经有实现的,但是对于这方面的学习文章还较少,抽空研究怎么拿golang写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」