I/O 编程模型

用户应用程序运行在用户态,只能访问有限的内存,不能访问外围I/O设备,如硬盘、网卡等; os运行在内核态,可以访问所有内存区域以及所有外围I/O设备

用户态应用程序访问I/O需要发起系统调用,由内核线程(指令)来完成

内核线程完成相应I/O操作(读取/写入),数据需要从内核态复制到用户空间的内存,应用程序从内存获取数据,才能继续完成相应业务逻辑的执行

  1. 用户空间通过系统调用通知内核准备数据 -> 等待数据
  2. 从内核空间复制数据到用户空间 -> 拷贝数据

由于拷贝数据实在内存中进行(或者内核内存空间映射到用户空间就更快了),因此速度较快,而等待数据则是两个阶段中最消耗时间的

《Unix网络编程》 IO模型:阻塞IO、非阻塞IO、IO复用、信号驱动IO和异步IO

五种可单独用其中一种,也可组合使用

POSIX 只分两类:同步IO和异步IO

一个IO操作分成 发起IO请求和实际的IO操作两个步骤

阻塞和非阻塞IO区别在于第一步发起IO请求是否会被阻塞

同步和异步IO区别在于第二个步骤是否阻塞请求进程

非阻塞是os做完IO操作再将结果返回前四是同步模型,最后一种是异步模型

  • 同步:指用户线程发起了I/O请求后,需一直等待或轮询内核I/O操作完成后,才会继续执行
  • 异步:指用户线程发起I/O请求后依然继续执行,当内核完成I/O操作后会通知用户线程,或者回调用户线程已注册的回调函数
  • 阻塞:指只有内核的I/O操作彻底完成后,才会返回用户空间
  • 非阻塞:指I/O操作被调用后,立即返回一个状态值,无需等到I/O操作彻底完成

阻塞 IO 模型

char* buf = malloc(256);
int ret = read(fd, buf, 256);
if(ret == -1) {
    perror();
    exit(-1);
}

一个系统调用完成了等待数据与拷贝数据两个阶段的任务

阻塞IO在系统调用上阻塞,没有开线程,整个进程阻塞,无法继续别的任务,

为每个connection开启一个线程/进程来解决,资源利用率不高,曾经Apache httpd就是这种模型

非阻塞 IO模型

给fd设置O_NONBLOCK,开启非阻塞式IO

拷贝数据阶段和阻塞式IO,等待数据阶段不同,系统调用不会等待

缓冲区并未处于“读就绪”或“写就绪”状态,系统调用就会返回EAGAIN或EWOULDBLOCK errno,linux中都是11

非阻塞IO模型使程序员能有机会规避等待,利用这段时间执行别的任务。只不过依旧无法保证当其他任务执行完毕时数据是否已经准备完毕。如果尚未准备就绪,则依旧会返回EAGAIN,否则执行拷贝数据阶段的任务

// 开启fd的非阻塞模式
flag = fcntl(0, F_GETFL, 0);
fcntl(0, F_SETFL, flag | O_NONBLOCK);
// 此处应该添加错误处理
char* buf = malloc(256);
while(1) {
    int ret = read(fd, buf, 256);
    if(ret == -1) {
        if(errno == EAGAIN) {
            // 执行其他任务
            continue;
        } else {
            perror();
            exit(-1);
        }
    }
    break;
}

代码麻烦,但一定程度上提高程序执行效率。对错误处理需要更加细化,严格来说EAGAIN/EWOULDBLOCK并不算错误。由于EAGAIN打断函数的执行,可能不得不保存点执行状态,以便能够继续进入执行,比如可以通过协程来解决

IO复用模型

阻塞IO和非阻塞式IO模型都是针对单个fd的,仅依靠上述内容写 server,放在一个数组内遍历一遍效率太低,得有个方法来管理大量fd的数据等待与数据拷贝

IO复用就是用来完成这个任务的,select/poll/epoll(Linux)/kqueue(FreeBSD)等

IO复用用了两个系统调用(select+recvfrom)

管理大量fd的时候,优势出现,同样是一段时间的等待,由于fd数量大,平均等待时间就下来了

/* SELECT示例 */
fd_set rset;
FD_ZERO(&rset);
// 设置监控的fd
FD_SET(fd, &rset);
// 开始等待
while(1) {
    select(maxfd, &rset, NULL, NULL, NULL);
    // 筛选数据就绪的fd。。。
    if(FD_ISSET(fd, &rset)) {
        // 对fd做读写处理
    }
}
/* POLL示例 */
struct pollfd client[maxfd];
client[0].fd = fd;
client[0]/events = POLLIN;
while(1) {
nready = poll(client, maxfd + 1, INFTIM);
    for(i = 0; i < nready; ++i) {
        if(client[i].revents & POLLIN) {
            // 读取数据
        }
    }
}
/* EPOLL示例 */
epfd = epoll_init1(0);
event.events = EPOLLET | EPOLLIN;
event.data.fd = serverfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, serverfd, &event);
count = epoll_wait(epfd, &events, MAXEVENTS, timeout);
    for(i = 0; i < count; ++i) {
        if(events[i].events & EPOLLERR || events[i].events & EPOLLHUP)
            // 处理错误
        if(events[i].data.fd == serverfd)
            // 为接入的连接注册事件
        else if(events[i].events & EPOLLIN)
            // 处理可读的缓冲区
            read(events[i].data.fd, buf, len);
            event.events = EPOLLET | EPOLLOUT;
            event.data.fd = events[i].data.fd;
            epoll_ctl(epfd, EPOLL_CTL_MOD, events[i].data.fd, &event);
        else
            // 处理可写的缓冲区
            write(events[i].data.fd, buf, len);
            // 后续可以关闭fd或者MOD至EPOLLOUT
    }
}

select和poll都是线性的,需要自己检查fd的状态,fd数量大后效率低

epoll只返回满足条件的fd

信号驱动IO模型

用的比较少

似乎和异步IO有点像,只不过异步IO更加干脆,它把数据复制也完成了,而信号驱动IO依旧需要自己使用系统调用来完成

这种模型能够避免等待数据阶段的阻塞

用的较少,是此模型基本不适用TCP应用,TCP能够产生SIGIO信号的位置多达7处:

  1. 监听套接字上某个链接请求已经完成
  2. 某个断连请求已经发起
  3. 某个断连请求已经完成
  4. 某个断连请求已经关闭
  5. 数据到达套接字
  6. 数据已经从套接字发送
  7. 发生某个异步错误

但它用在UDP中就很完美了,因为UDP中只有2处会产生SIGIO信号:

  1. 数据报到达套接字
  2. 套接字上发生异步错误

echo server demo:

// 处理SIGIO和SIGHUP信号的函数
static void sig_io(int signo) {
    // 处理报文数据读取
    ++nq;
}
static void sig_hup(int signo) {
    // 可以实现一些诊断任务
}
// 注册信号处理函数
signal(SIGHUP, sig_hup);
signal(SIGIO, sig_io);
fcntl(sockfd, F_SETOWN, getpid());
int on = 1;
// 信号驱动IO的指定参数
ioctl(sockfd, FIOASYNC, &on);
// 设置为非阻塞
ioctl(sockfd, FIONBIO, &on);
sigset_t zeromask, newmask, oldmask;
// 初始化信号集
sigemptyset(&zeromask);
sigemptyset(&oldmask);
sigemptyset(&newmask);
// 设置我们希望阻塞的信号
sigaddset(&newmask, SIGIO);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);
while(1) {
    // 如果没有收到数据,则阻塞进程
    while(nq == 0) sigsuspend(&zeromask);
    // 解除SIGIO的阻塞
    sigprocmask(SIG_SETMASK, &oldmask, NULL);
    // 发送数据
    // 阻塞SIGIO,使nq可以安全读写
    sigprocmask(SIG_BLOCK, &newmask, &oldmask);
    --nq;
}

主进程和信号处理函数存在共享数据时,需要注意阻塞SIGIO信号,以免出现莫名其妙的错误

异步IO模型

Linux下基本只用来处理磁盘IO,没和和epoll统一

通过一个系统调用告知内核应该做什么,然后返回,你可以继续做别的事情,等到内核操作完成的时候,它会用aio_read中指定的信号或者回调函数来通知我们

2种aio的实现:

  • POSIX AIO:aio.h,编译连接librt(-lrt)
  • libaio:libaio.h,编译连接libaio(-laio)

区别:

  • POSIX AIO 是用户空间aio实现,用多线程和阻塞式IO,对于各种文件系统的兼容性更好,并且不需要强制设置O_DIRECT
  • libaio是内核aio实现,io请求存储于内核中的一个队列中,然后根据不同的磁盘调度来响应请求,对各种文件系统的兼容性不如前者,并且需要为fd设置O_DIRECT

C10K Problem 另外总结了 5 种高性能的 I/O 编程模型

1) 单线程非阻塞式水平触发 I/O

2) 单线程非阻塞式边沿触发 I/O

3) 多线程异步 I/O 模型

4) 单个服务线程对应单个客户

5) 将服务线程放到内核

煮开水

1 水壶到火上,立等水开(同步阻塞) 有点傻 2 水壶到火上,去客厅看电视,时不时去厨房看看水开没有(同步非阻塞) 还是有点傻 3 响水壶放到火上,立等水开(异步阻塞) 4 响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶(异步非阻塞) 聪明了

同步异步,只是对于水壶而言 普通水壶,同步;响水壶,异步 同步只能让调用者去轮询自己(情况2中),造成效率的低下

阻塞非阻塞,仅仅对于人而言 立等,阻塞;看电视,非阻塞

情况1和3中人是阻塞的。虽然3中响水壶是异步的,可立等对人没有太大的意义

所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用

Reactor

Reactor 、Proactor 都基于 I/O 多路复用的单线程处理模型,事件驱动机制

通常使用 Reactor 模式代替多线程处理方式,节省资源,提高吞吐量

普通函数调用主动调用API,Reactor 逆置了事件处理流程,应用事先提供相应的接口并注册到 Reactor 上,事件发生,Reactor 调用应用注册的接口(回调函数)

好莱坞原则

Reactor 用于同步 I/O

核心思想

所有要处理的 I/O 事件注册到一个中心 I/O 多路复用器上

主线程/进程阻塞在多路复用器上

I/O 事件到来或准备就绪(fd 或socket fd可读、写),多路复用器返回并将事先注册的相应 I/O 事件分发到对应的处理器中

三个重要组件

  • 多路复用器:OS 提供, Linux 一般是 select, poll, epoll 等系统调用
  • 事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中
  • 事件处理器:特定事件的处理函数

模型经常使用,跨平台事件处理库,比较典型的有 libevent,libev,boost asio等对这些系统调用做了一层封装

Reactor 与 Observer 某些方面极为相似:一个主体发生改变时,所有依属体都得到通知

观察者模式与单个事件源关联,Reactor 与多个事件源关联

5个关键的参与者

  • 描述符(handle)

os 提供的资源,识别每一个事件,Socket fd、file fd、信号等。Linux 中用一个整数表示

事件可以来自外部(客户端连接请求、数据等)、内部(信号、定时器事件)

  • 同步事件多路分离器(event demultiplexer)

os 内核实现的函数(select/poll/epoll),用于阻塞等待发生在句柄集合上的一个或多个事件

事件到来是随机、异步,无法预知的,程序需要循环等待并处理事件,这就是事件循环。事件循环中等待事件一般使用I/O复用技术实现,即上面的函数

I/O框架库一般将各种I/O复用系统调用封装成统一的接口,称事件多路分离器

调用者会被阻塞,直到分离器分离的描述符集上有事件发生

  • 事件处理器(event handler)

I/O框架库提供一或多个模板函数组成的接口,让用户继承它来实现自己的事件处理器,即具体事件处理器

  • 具体的事件处理器(concrete event handler)

事件处理器接口的实现

  • Reactor 管理器(reactor)

1)供应用程序注册和删除关注的事件句柄 2)运行事件循环 3)有就绪事件到来时,分发事件到之前注册的回调函数上处理

Hollywood 原则

应用仅实现具体事件处理器,然后注册到 Reactor 管理器中

接下来的工作由管理器来完成:相应的事件发生,调用具体事件处理器

应用场景

长途客车上,乘客希望在客车上得到休息

  • 传统做法

每隔一段时间(或每一个站),司机或售票员对每一个乘客询问是否下车

  • Reactor做法

汽车是乘客访问的主体(Reactor),乘客上车后,到售票员(acceptor)处登记,之后乘客便可以休息睡觉去了

当到达乘客所要到达的目的地时(指定的事件发生,乘客到了下车地点),售票员将其唤醒即可


网络编程为什么要用反应堆?有了 I/O 复用,有了 epoll 已经可以使服务器并发几十万连接的同时,维持高 TPS 了,难道这还不够吗?

技术层面足够了,但在软件工程层面却是不够的

程序使用 IO 复用的难点

1个请求可能由多次 IO 处理完成,但相比传统的单线程完整处理请求生命期的方法,IO复用在人的大脑思维中并不自然

编程中,处理请求A的时候,假定必须经过多个IO操作A1-An(两次IO间可能间隔很长时间)

每经过一次IO操作,再调用IO复用时,IO复用的调用返回里,非常可能不再有A,而是返回了请求B

即请求A会经常被请求B打断,处理请求B时,又被C打断。这种思维下,编程容易出错

形象例子

  • 传统编程方法

银行营业厅,每个窗口前排了长队,业务员们在窗口后一个个的解决客户们的请求

一个业务员可以尽情思考着客户A依次提出的问题,例如:

“我要买2万XX理财产品”

“看清楚了,5万起售”

“等等,查下我活期余额”

“余额5万”

“那就买 5万吧”

业务员开始录入信息

“对了,XX理财产品年利率8%?”

“是预期8%,最低无利息保本”

“早不说,拜拜,我去买余额宝”

业务员无表情的删着已经录入的信息进行事务回滚

“下一个!”

  • IO 复用则

业务大师刚指导A填写转帐单的某一项,B又来申请兑换泰铢,给了B兑换单后,C又来办理定转活,然后 …

这就是基于事件驱动的 IO 复用编程比起传统1线程1请求的方式来,有难度的设计点了

没有反应堆时,可能的设计方法是这样的:

大师把每个客户的提问都记录下来,当客户 A 提问时,首先查阅 A 之前问过什么做过什么,这叫联系上下文

然后再根据上下文和当前提问查阅有关的银行规章制度,有针对性的回答 A,并把回答也记录下来

当圆满回答了A的所有问题后,删除 A 的所有记录


程序中:

服务器10 万个并发连接,一次 IO 复用接口调用返回了 100 个活跃的连接等待处理

先根据 100个连接找出其对应的对象,这并不难,epoll 的返回连接数据结构里就有这样的指针可以用

接着,循环的处理每一个连接,找出这个对象此刻的上下文状态,再使用 read、write 这样的网络 IO 获取此次的操作内容,结合上下文状态查询此时应当选择哪个业务方法处理,调用相应方法完成操作后,若请求结束,则删除对象及其上下文

这样就陷入了面向过程编程方法之中了,早晚得把自己玩死

主程序需要关注各种不同类型的请求,在不同状态下,对于不同的请求命令选择不同的业务处理方法

导致随着请求类型的增加,请求状态的增加,请求命令的增加,主程序复杂度快速膨胀,导致维护越来越困难,苦逼的程序员再也不敢轻易接新需求、重构


反应堆是解决上述软件工程问题的一种途径,它也许并不优雅,开发效率上也不是最高的,但其执行效率与面向过程的使用IO复用却几乎是等价的

所以,nginx、memcached、redis等等这些高性能组件的代名词,都义无反顾的一头扎进了反应堆的怀抱中

反应堆模式可以在软件工程层面,将事件驱动框架分离出具体业务,将不同类型请求之间用OO的思想分离

通常,反应堆不仅使用IO复用处理网络事件驱动,还会实现定时器来处理时间事件的驱动(请求的超时处理或者定时任务的处理)

Reactor 的几种模式

web 服务很多都涉及基本的操作:read request、decode request、process service、encod reply、send reply等

经典的“thread per request”

单线程模式

最简单的单 Reactor 单线程模型

Reactor 线程是个多面手,负责多路分离套接字,Accept 新连接,并分派请求到处理器链中

适用于处理器链中业务处理组件能快速完成的场景。这种单线程模型不能充分利用多核资源,实际使用的不多

多线程模式(单 Reactor)

事件处理器(Handler)链部分采用了多线程(线程池),也是后端程序常用的模型

多线程模式(多个Reactor)

比起第二种模型,将 Reactor 分成两部分,mainReactor 监听并 accept 新连接,然后将建立的 socket 通过多路复用器(Acceptor)分派给 subReactor

subReactor 多路分离已连接的 socket,读写网络数据

worker 线程池完成业务处理功能

通常,subReactor 可与 CPU 个数等同

Proacotr

和异步 I/O 相关

Reactor 中事件分离者等事件或状态发生,把事件传给事先注册的处理器(回调函数)做实际读写操作

Proactor 中事件处理者(或事件分离者代发起)直接发起异步读写操作(相当于请求),实际工作是os完成 发起时需要提供读写缓存区,读大小,请求完后的回调函数等信息

事件分离器等待请求完成,然后转发完成事件给相应的事件处理者或者回调

Reactor 在事件发生时就通知事先注册的事件(读写由处理函数完成)

Proactor 在事件发生时进行异步I/O(读写由 OS 完成),待 IO 完成事件分离器才调度处理器来处理

工业实现

C++ 框架:ACE

提供大量平台独立的底层并发支持类(线程、互斥量等)

更高一层也提供了独立的几组 C++ 类,实现 Reactor 及 Proactor 模式

真正的异步模式需要 os 级别的支持,windows/linux系统底层异步的支持力度不一,为了在各系统上有更好的性能,得不维护独立的好几份代码: 为 Windows 准备的 ACE Proactor 以及为 Unix 系列提供的 ACE Reactor

由于事件处理者及 os 交互的差异,为 Reactor 和 Proactor 设计一种通用统一的外部接口是非常困难的。这也是设计通行开发框架的难点所在

设计模式Reactor,Proactor 等
层次架构底层是 C 风格的 OS 适配层,上一层基于 C++ 的 wrap 类,再上一层是一些框架 (Accpetor, Connector, Reactor, Proactor等),最上一层是框架上服务
事件分派处理注册 handler 类,当事件分派时,调用其 handler 的虚挂勾函数
涉及范围日志,IPC,线程池,共享内存,配置服务,递归锁,定时器等
线程调度Reactor 是单线程调度,Proactor 支持多线程调度
发布方式不依赖于第三方库,一般应用使用它时,以动态链接的方式发布动态库
开发难度对程序员要求比较高,要用好它,必须非常了解其框架。在其框架下开发,往往 new 出一个对象,不知在什么地方释放好

C 网络库:libevent

主要支持类 Linux ,最新的版本添加了对 windows 的IOCP 的支持。跨平台方面主要通过 select 模型来进行支持

设计模式Reactor
层次架构不同 os 下,做了多路复用模型的抽象,可以选择使用不同的模型,通过事件函数提供服务
可移植性主要支持 Linux ,freebsd ,其他平台 select 模型进行支持,效率不是太高
事件分派处理基于注册的事件回调函数来实现事件分发
涉及范围只提供简单的网络 API 的封装,线程池,内存池,递归锁等均需要自己实现
线程调度需要自己来注册不同的事件句柄
发布方式一般编译为静态库进行使用
开发难度相对容易,参考 memcached

模拟异步

将 Reactor 原来位于事件处理器内的 read/write 操作移至分离器,以此寻求将 Reactor 多路同步 IO 转化为模拟异步 IO

对于不提供异步 IO API 的 os 来说,这种办法可以隐藏 socket API 的交互细节,从而对外暴露一个完整的异步接口 借此,就可以进一步构建完全可移植的,平台无关的,有通用对外接口的解决方案 TProactor

总结

reactor 模型开发效率上比起直接使用IO复用要高,通常是单线程的,希望单线程使用一颗CPU的全部资源

附带优点,每个事件处理中很多时候可以不考虑共享资源的互斥访问

缺点,硬件发展不再遵循摩尔定律,CPU频率不再有大的提升,改为从核数的增加上提升能力,程序需要使用多核资源时,reactor 模型就会悲剧

如果程序业务很简单,如只是简单的访问一些提供了并发访问的服务,就可以直接开启多个反应堆,每个反应堆对应一颗CPU核心,这些反应堆上跑的请求互不相关,可以利用多核。例如 http静态服务器 Nginx

复杂程序,如一块内存数据处理希望多核共同完成,这样reactor 模型很难做到,要引入许多复杂的机制

可以理解 redis、nodejs 为什么只能是单线程,memcached简单些的服务可以是多线程了

Linux AIO(异步IO)那点事儿 http://www.yeolar.com/note/2012/12/16/linux-aio/

linux下的异步IO(AIO)是否已成熟? https://www.zhihu.com/question/26943558

Linux kernel AIO这个奇葩 http://www.wzxue.com/linux-kernel-aio%E8%BF%99%E4%B8%AA%E5%A5%87%E8%91%A9/

http://davmac.org/davpage/linux/async-io.html

高性能I/O设计模式概述 http://www.yeolar.com/note/2012/12/15/high-performance-io-design-patterns/

JAVA NIO2模式之Proactor( JDK7 AIO异步网络IO初探) http://blog.csdn.net/ajian005/article/details/18054009

IO多路复用到底是不是异步的? https://www.zhihu.com/question/59975081

怎样理解阻塞非阻塞与同步异步的区别? https://www.zhihu.com/question/19732473

萧萧