肝!IO编程模型底层原理到net库不得不选择多路复用的原因剖析(golang)

​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」

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值