网络IO管理

简介: 网络IO管理

网络 IO,会涉及到两个系统对象,一个是用户空间调用 IO 的进程或者线程,另一个是内核

空间的内核系统,比如发生 IO 操作 read 时,它会经历两个阶段:

1. 等待数据准备就绪

2. 将数据从内核拷贝到进程或者线程中。

因为在以上两个阶段上各有不同的情况,所以出现了多种网络 IO 模型

五种 IO 网络模型

阻塞 IOblocking IO

linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程

当用户进程调用了 read 这个系统调用,kernel 就开始了 IO 的第一个阶段:准备数据。对于

network io 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的数据包),

这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel

一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,

用户进程才解除 block 的状态,重新运行起来。 所以,blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被 block 了。 几乎所有的程序员第一次接触到的网络编程都是从 listen()send()recv() 等接口开始的, 这些接口都是阻塞型的。使用这些接口可以很方便的构建服务器/客户机的模型。下面是一 个简单地“一问一答”的服务器。

服务器                 客户端
socket()              socket()
bind()                
accept()  <---------  connect() 
recv()    <---------  send()            
send()    ----------> recv()

大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)

不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返

回。 实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给

网络编程带来了一个很大的问题,如在调用 send()的同时,线程将被阻塞,在此期间,线程

将无法执行任何运算或响应任何的网络请求。 一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create ()创建新线程,fork()创建新进程。我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。

在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新

线程,并在新线程中提供为前例同样的问答服务。

很多初学者可能不明白为何一个 socket 可以 accept 多次。实际上 socket 的设计者

可能特意为多客户机的情况留下了伏笔,让 accept()能够返回一个新的 socket。下面是

accept 接口的原型:

int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

输入参数 s 是从 socket()bind()listen()中沿用下来的 socket 句柄值。执行完 bind()和 listen()后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请 求,则将该连接请求加入请求队列。调用 accept()接口正是从 socket s 的请求队列抽 取第一个连接信息,创建一个与 s 同类的新的 socket 返回句柄。新的 socket 句柄即是后续 read()recv()的输入参数。如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进入队列。

上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。

很多程序员可能会考虑使用线程池连接池线程池旨在减少创建和销毁线程

的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。连接池

持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很

好的降低系统开销,都被广泛应用很多大型系统,如 webspheretomcat 和各种数据库等。

但是,线程池连接池技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占

用。而且,所谓始终有其上限,当请求大大超过上限时,构成的系统对外界的响

应并不比没有池的时候效果好多少。所以使用必须考虑其面临的响应规模,并根据响

应规模调整的大小。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,线程池连 接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

非阻塞 IOnon-blocking IO

Linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读

操作时,流程是这个样子:

从图中可以看出,当用户进程发出 read 操作时,如果 kernel 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error。从用户进程角度讲 ,它发起一个read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call,那么它马上就将数据拷贝到了用户内存,然后返回,所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel数据准备好了没有。

在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在

本例中,

* recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;

* recv() 返回 0,表示连接已经正常断开;

* recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;

* recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno

非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下

的函数可以将某句柄 fd 设为非阻塞状态。

fcntl( fd, F_SETFL, O_NONBLOCK );

下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据的模

型。

可以看到服务器线程可以通过循环调用 recv()接口,可以在单个线程内实现对所有连 接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用 recv()将大幅度推高 CPU占用率;此外,在这个方案中 recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如 select()多路复用模式,可以一次检测多个连接是否活跃。

多路复用 IOIO multiplexing

IO multiplexing 这个词可能有点陌生,但是提到 select/epoll,大概就都能明白了。有些地方也称这种 IO 方式为事件驱动 IO(event driven IO)。我们都知道,select/epoll 的好处就在于单个process 就可以同时处理多个网络连接的 IO。它的基本原理就是 select/epoll 这个 function会不断的轮询所负责的所有 socket,当某个 socket 有数据到达了,就通知用户进程。它的流程如图:

当用户进程调用了 select,那么整个进程会被 block,而同时,kernel 监视所有 select 负责的 socket,当任何一个 socket 中的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

这个图和 blocking IO 的图其实并没有太大的不同,事实上还更差一些。因为这里需要使用两个系统调用(select read),而 blocking IO 只调用了一个系统调用(read)。但是使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket IO 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。(多说一句:所以,如果处理的连接数不是很高的话,使用select/epoll 的 web server 不一定比使用 multi-threading + blocking IO web server 性能更好,可能延迟还更大。select/epoll 的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在多路复用模型中,对于每一个 socket,一般都设置成为 non-blocking,但是,如上图所示,整个用户的 process 其实是一直被 block 的。只不过 process 是被 select 这个函数 block,而不是被 socket IO block。因此 select()与非阻塞 IO 类似。

大部分 Unix/Linux 都支持 select 函数,该函数用于探测多个文件句柄的状态变化。下面给出select 接口的原型:

FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set 
*exceptfds,struct timeval *timeout)

这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set中标记一个值为 16 的句柄,则该 fd_set 的第 16 bit 位被标记为 1。具体的置位、验证可使用 FD_SET、FD_ISSET 等宏实现。在 select()函数中,readfdswritefds 和exceptfds 同时作为输入参数和输出参数。如果输入的 readfds 标记了 16 号句柄,则select()将检测 16 号句柄是否可读。在 select()返回后,可以通过检查 readfds 有否标记 16 号句柄,来判断该可读事件是否发生。另外,用户可以设置 timeout 时间。

上述模型只是描述了使用 select()接口同时从多个客户端接收数据的过程;由于 select()

接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多

个客户端提供独立问答服务的服务器系统。

这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。

上述模型中,最关键的地方是如何动态维护 select()的三个参数 readfdswritefds和 exceptfds。作为输入参数,readfds 应该标记所有的需要探测的可读事件的句柄,其中永远包括那个探测 connect() 的那个句柄;同时,writefds exceptfds 应该标记所有需要探测的“可写事件错误事件的句柄 ( 使用 FD_SET() 标记 )

作为输出参数,readfdswritefds exceptfds 中的保存了 select() 捕捉到的所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用 FD_ISSET()检查 ),以确定到底哪些句柄发生了事件。

上述模型主要模拟的是“一问一答的服务流程,所以如果 select()发现某句柄捕捉到了“可读事件,服务器程序应及时做recv()操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入 writefds,准备下一次的可写事件select()探测。同样,如 果 select()发现某句柄捕捉到可写事件,则程序应及时做 send()操作,并准备好下一次的“可读事件探测准备。

这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。 但这个模型依旧有着很多问题。首先 select()接口并不是实现事件驱动的最好选择。 因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。 很多操作系统提供了更为高效的接口,如linux提供了epollBSD提供了kqueueSolaris提供了/dev/poll。如果需要实现更高效的服务器程序,类似 epoll 这样的接口更被推荐。遗憾的是不同的操作系统特供的 epoll 接口有很大差异,所以使用类似于 epoll 的接口实现具有较好跨平台能力的服务器会比较困难。

其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。如下例,庞大的执行体 1 的将直接导致响应事件 2 的执行体迟迟得不到执行,并在很大程度上降低了事件探测的及时性.幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent 库,还有作为 libevent 替代者的 libev 库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。下章将介绍如何使用 libev 库替换 select 或 epoll接口,实现高效稳定的服务器模型。

实际上,Linux 内核从 2.6 开始,也引入了支持异步响应的 IO 操作,如 aio_read, aio_write,这就是异步 IO

异步 IO(Asynchronous I/O)

Linux 下的 asynchronous IO 用在磁盘 IO 读写操作,不用于网络 IO,从内核 2.6 版本才开始引

入。先看一下它的流程

用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从 kernel 的角度,当它受到一个 asynchronous read 之后,首先它会立刻返回,所以不会对用户进程产生任何 block。然后,kernel 会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel 会给用户进程发送一个 signal,告诉它 read 操作完成了。 用异步 IO 实现的服务器这里就不举例了,以后有时间另开文章来讲述。异步 IO 是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。 到目前为止,已经将四个 IO 模型都介绍完了。现在回过头来回答最初的那几个问题: blocking 和 non-blocking 的区别在哪,synchronous IO asynchronous IO 的区别在哪。

先回答最简单的这个:blocking non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用 blocking IO 会一直 block 住对应的进程直到操作完成,而non-blocking IO 在 kernel还在准备数据的情况下会立刻返回。

   两者的区别就在于 synchronous IO ”IO operation”的时候会将 process 阻塞。按照这个定义,之前所述的 blocking IOnon-blocking IOIO multiplexing 都属于synchronous IO。有人可能会说,non-blocking IO 并没有被 block 啊。这里有个非常“狡猾的地方,定义中所指的”IO operation”是指真实的 IO 操作,就是例子中的 read 这个系统调用。non-blocking IO 在执行 read 这个系统调用的时候,如果 kernel 的数据没有准备好,这时候不会 block 进程。但是当 kernel 中数据准备好的时候,read 会将数据从 kernel 拷贝到用户内存中,这个时候进程是被 block 了,在这段时间内进程是被 block的。而 asynchronous IO 则不一样,当进程发起 IO 操作之后,就直接返回再也不理睬了,直到 kernel 发送一个信号,告诉进程说 IO 完成。在这整个过程中,进程完全没有被 block

信号驱动 IO(signal driven I/O, SIGIO)

首先我们允许套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。当数据报准备好读取时,内核就为该进程产生一个 SIGIO 信号。我们随后既可以在信号处理函数中调用 read 读取数据报,并通知主循环数据已准备好待处理,也可以立即通知主循环,让它来读取数据报。无论如何处理 SIGIO 信号,这种模型的优势在于等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了 select 的阻塞与轮询,当有活跃套接字时,由注册的 handler 处理。

asynchronous IO 则完全不同。它就像是用户进程将整个 IO 操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查 IO 操作的状态,也不需要主动的去拷贝数据。

服务器模型 Reactor Proactor

对高并发编程,网络连接上的消息处理,可以分为两个阶段:等待消息准备好、消息处理。当使用默认的阻塞套接字时(例如上面提到的 1 个线程捆绑处理 1 个连接),往往是把这两个阶段合而为一,这样操作套接字的代码所在的线程就得睡眠来等待消息准备好,这导致了高并发下线程会频繁的睡眠、唤醒,从而影响了 CPU 的使用效率。 高并发编程方法当然就是把两个阶段分开处理。即,等待消息准备好的代码段,与处理消息的代码段是分离的。当然,这也要求套接字必须是非阻塞的,否则,处理消息的代码段很容易导致条件不满足时,所在线程又进入了睡眠等待阶段。那么问题来了,等待消息准备好这个阶段怎么实现?它毕竟还是等待,这意味着线程还是要睡眠的!解决办法就是,线程主动查询,或者让 1 个线程为所有连接而等待!这就是 IO 多路复用了。多路复用就是处理等待消息准备好这件事的,但它可以同时处理多个连接!它也可能“等待”,所以它也会导致线程睡眠,然而这不要紧,因为它一对多、它可以监控所有连接。这样,当我们的线程被唤 醒执行时,就一定是有一些连接准备好被我们的代码执行了。 作为一个高性能服务器程序通常需要考虑处理三类事件: I/O 事件,定时事件及信号。 两种高效的事件处理模型:Reactor 和 Proactor。

Reactor 模型

首先来回想一下普通函数调用的机制:程序调用某函数,函数执行,程序等待,函数将结果和控制权返回给程序,程序继续处理。Reactor 释义“反应堆”,是一种事件驱动机制。 和普通函数调用的不同之处在于:应用程序不是主动的调用某个 API 完成处理,而是恰恰相反,Reactor 逆置了事件处理流程,应用程序需要提供相应的接口并注册到 Reactor 上, 如果相应的时间发生Reactor 将主动调用应用程序注册的接口,这些接口又称为“回调函数”。

Reactor 模式是处理并发 I/O 比较常见的一种模式,用于同步 I/O,中心思想是将所有要处理的I/O 事件注册到一个中心 I/O 多路复用器上,同时主线程/进程阻塞在多路复用器上; 一旦有 I/O 事件到来或是准备就绪(文件描述符或 socket 可读、写),多路复用器返回并将事先注册的相应 I/O 事件分发到对应的处理器中。

Reactor 模型有三个重要的组件:

 多路复用器:由操作系统提供,在 linux 上一般是 select, poll, epoll 等系统调用。

 事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中。

 事件处理器:负责处理特定事件的处理函数。

具体流程如下: 1. 注册读就绪事件和相应的事件处理器; 2. 事件分离器等待事件; 3. 事件到来,激活分离器,分离器调用事件对应的处理器; 4. 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。

Reactor 模式是编写高性能网络服务器的必备技术之一,它具有如下的优点:

 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的;

 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;

 可扩展性,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源;

 可复用性,reactor 框架本身与具体事件处理逻辑无关,具有很高的复用性;

Reactor 模型开发效率上比起直接使用 IO 复用要高,它通常是单线程的,设计目标是希望单线程使用一颗 CPU 的全部资源,但也有附带优点,即每个事件处理中很多时候可以不考虑共享资源的互斥访问。可是缺点也是明显的,现在的硬件发展,已经不再遵循摩尔定律,CPU 的频率受制于材料的限制不再有大的提升,而改为是从核数的增加上提升能力, 当程序需要使用多核资源时,Reactor 模型就会悲剧, 为什么呢? 如果程序业务很简单,例如只是简单的访问一些提供了并发访问的服务,就可以直接开启多个反应堆,每个反应堆对应一颗 CPU 核心,这些反应堆上跑的请求互不相关,这是完全可以利用多核的。例如 Nginx 这样的 http 静态服务器。

LT和ET模式

       epoll对文件描述符的操作有两种模式: LT(Level Trigger , 电平触发)模式和ET(Edge Trigger , 边沿触发)模式,LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。

       对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。而对于采用ET模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率就比LT模式高。

结论: 小块数据用ET , 大块数据用LT.           listenfd 适合用LT ,

水平触发:一直触发,一次recv    |   可以用阻塞IO

边沿触发:只触发一次,循环读 while  recv   |  必须用非阻塞

两者业务代码会有点差异,但是性能差异是比较小的。

单线程reactor  ----> 参考 libevent/redis      多线程reactor --->多个worker --->参考 memcached

多进程reactor----> 1个master---n个worker ------>  参考nginx      

/*************************************************************************
  > File Name: epoll_et_lt.c
  > Author: 
  > Mail: 
  > Created Time: Mon 11 Oct 2021 09:02:28 AM CST
 ************************************************************************/
#include<stdio.h>
#include<sys/types.h>
#include<netinet/in.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<assert.h>
#include<unistd.h>
#include<errno.h>
#include<string.h>
#include<fcntl.h>
#include<stdlib.h>
#include<sys/epoll.h>
#include<pthread.h>
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10 
#define Bool unsigned int
// 设置文件描述符为非阻塞
int setnonblocking(int fd){
    int old_option = fcntl(fd , F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd , F_SETFL , new_option);
    return old_option;
}
// 将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核时间表中,参数enable_et指定
// 是否对fd启用ET模式
void addfd(int epollfd , int fd , Bool enable_et){
    struct epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN;
    if(enable_et){
        event.events |= EPOLLET;
    }
    epoll_ctl(epollfd , EPOLL_CTL_ADD , fd , &event);
    setnonblocking(fd);
}
// LT模式的工作流程
void lt(struct epoll_event* events , int number , int epollfd , int listenfd){
    char buf[BUFFER_SIZE] = {0};
    for(int i = 0 ; i < number ; i++){
        int sockfd = events[i].data.fd;
        if(sockfd == listenfd){
            struct sockaddr_in client_addr;
            socklen_t client_len = sizeof(client_addr);
            int connfd = accept(listenfd , (struct sockaddr*)&client_addr , &client_len);
            addfd(epollfd , connfd , 0);
        }else if(events[i].events & EPOLLIN){
            // 只要socket读缓存中还有未读出的数据,这段代码就会被触发
            printf("event trigger once\n");
            memset(buf , '\0' , BUFFER_SIZE);
            int ret = recv(sockfd , buf , BUFFER_SIZE - 1 , 0);
            if(ret <= 0){
                if(errno == EAGAIN || errno == EWOULDBLOCK){
                    continue;
                }else{
                    close(sockfd);
                    struct epoll_event ev;
                    ev.events = EPOLLIN;
                    ev.data.fd = sockfd;
                    epoll_ctl(epollfd , EPOLL_CTL_DEL , sockfd , &ev);
                }
            }
            printf("get %d bytes of content: %s\n" , ret , buf);
        }else{
            printf("something else happened \n");
        }
    }
}
void et(struct epoll_event* events , int number , int epollfd , int listenfd){
    char buf[BUFFER_SIZE] = {0};
    for(int i = 0 ; i < number ; i++){
        int sockfd = events[i].data.fd;
        if(sockfd == listenfd){
            struct sockaddr_in client_addr;
            socklen_t client_len = sizeof(client_addr);
            int connfd = accept(listenfd , (struct sockaddr*)&client_addr , &client_len);
            addfd(epollfd , connfd , 1);
        }else if(events[i].events & EPOLLIN){
            // 这段代码不会被重复触发,所有我们循环读取数据,以确保把socket读缓存中的
            // 所有数据读出
            printf("events trigger once\n");
            while(1){
                memset(buf , '\0' , BUFFER_SIZE);
                int ret = recv(sockfd , buf , BUFFER_SIZE - 1 ,0);
                if(ret < 0){
                    // 对于非阻塞IO,下面的条件成立表示数据已经读取完毕,此后
                    // epoll就能再次触发sockfd上的EPOLLIN时间,以驱动下一次读操作
                    if( errno == EAGAIN || errno == EWOULDBLOCK){
                        printf("read later\n");
                        break;
                    }
                    close(sockfd);
                    break;
                }else if(ret == 0){
                    close(sockfd);
                }else{
                    printf("get %d bytes of content: %s\n" , ret , buf);
                }
            }
        }else{
            printf("something else happened\n");
        }
    }
}
int main(int argc , char* argv[]){
    if(argc <= 2){
        printf("usage: %s ip_address port_number\n" , argv[0]);
        return 1;
    }
    const char* ip = argv[1];
    int port = atoi(argv[2]);
    int ret = 0;
    struct sockaddr_in addr;
    memset(&addr , 0 , sizeof(addr));
    addr.sin_family = AF_INET;
    inet_pton(AF_INET , ip , &addr.sin_addr);
    addr.sin_port = htons(port);
    int listenfd = socket(PF_INET , SOCK_STREAM , 0);
    if( listenfd <= 0){
        perror("socket");
        return -1;
    }
    if(bind(listenfd , (struct sockaddr*)&addr , sizeof(addr)) == -1){
        perror("bind");
        return -1;
    }
    if(listen(listenfd , 5) == -1){
        perror("listen");
        return -1;
    }
    struct epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(1);
    if( -1 == epollfd){
        perror("epoll_create");
        return -1;
    }
    addfd(epollfd , listenfd , 1);
    while(1){
        int ret = epoll_wait(epollfd , events , MAX_EVENT_NUMBER , -1);
        if(ret < 0){
            printf("epoll failure\n");
            break;
        }
        //lt(events , ret , epollfd , listenfd);
        et(events , ret , epollfd , listenfd);
    }
    close(listenfd);
    return 0;
}

Proactor 模型

具体流程如下:

1. 处理器发起异步操作,并关注 I/O 完成事件

2. 事件分离器等待操作完成事件

3. 分离器等待过程中,内核并行执行实际的 I/O 操作,并将结果数据存入用户自定义缓冲 区,最后通知事件分离器读操作完成

4. I/O 完成后,通过事件分离器呼唤处理器

5. 事件处理器处理用户自定义缓冲区中的数据

   从上面的处理流程,我们可以发现 proactor 模型最大的特点就是使用异步 I/O。所有的 I/O 操作都交由系统提供的异步 I/O 接口去执行。工作线程仅仅负责业务逻辑。在 Proactor 中,用户函数启动一个异步的文件操作。同时将这个操作注册到多路复用器上。多路复用器并不关心文件是否可读或可写而是关心这个异步读操作是否完成。异步操作是操作系统完成,用户程序不需要关心。多路复用器等待直到有完成通知到来。当操作系统完成了读文件操作——将读到的数据复制到了用户先前提供的缓冲区之后,通知多路复用器相关操作已完成。多路复用器再调用相应的处理程序,处理数据。

   Proactor 增加了编程的复杂度,但给工作线程带来了更高的效率。Proactor 可以在系统态将读写优化,利用 I/O 并行能力,提供一个高性能单线程模型。在 windows 上,由于没有 epoll 这样的机制,因此提供了 IOCP 来支持高并发, 由于操作系统做了较好的优化,windows 较常采用 Proactor 的模型利用完成端口来实现服务器。在 linux 上,在 2.6 内核出现了 aio 接口,但 aio 实际效果并不理想,它的出现,主要是解决 poll 性能不佳的问题,但实际上经过测试,epoll 的性能高于 poll+aio,并且 aio 不能处理 accept, 因此 linux 主要还是以 Reactor 模型为主。

   在不使用操作系统提供的异步 I/O 接口的情况下,还可以使用 Reactor 来模拟 Proactor, 差别是:使用异步接口可以利用系统提供的读写并行能力,而在模拟的情况下,这需要在用户态实现。具体的做法只需要这样:

   1. 注册读事件(同时再提供一段缓冲区) 2. 事件分离器等待可读事件 3. 事件到来,激活分离器,分离器(立即读数据,写缓冲区)调用事件处理器4. 事件处理器处理数据,删除事件(需要再用异步接口注册)

   我们知道,Boost.asio 库采用的即为 Proactor 模型。不过 Boost.asio 库在 Linux 平台采用 epoll 实现的 Reactor 来模拟 Proactor,并且另外开了一个线程来完成读写调度。

同步 I/O 模拟 Proactor 模型

   1. 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件。 2. 主线程调用 epoll_wait 等待 socket 上有数据可读。 3. 当 socket 上有数据可读时,epoll_wait 通知主线程。主线程从 socket 循环读取数据, 直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。 4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后 往 epoll 内核事件表中注册 socket 上的写就绪事件。 5. 主线程调用 epoll_wait 等待 socket 可写。 6. 当 socket 可写时,epoll_wait 通知主线程。主线程往 socket 上写入服务器处理客户 请求的结果。

    两个模式的相同点,都是对某个 IO 事件的事件通知(即告诉某个模块,这个 IO 操作可 以进行或已经完成)。在结构上两者也有相同点:demultiplexor 负责提交 IO 操作(异步)、 查询设备是否可操作(同步),然后当条件满足时,就回调注册处理函数。

   不同点在于,异步情况下(Proactor),当回调注册的处理函数时,表示 IO 操作已经完 成;同步情况下(Reactor),回调注册的处理函数时,表示 IO 设备可以进行某个操作(can read or can write),注册的处理函数这个时候开始提交操作。

   

Libevent,libev,libuv

libevent :名气最大,应用最广泛,历史悠久的跨平台事件库;

libev :较 libevent 而言,设计更简练,性能更好,但对 Windows 支持不够好;

libuv :开发 node 的过程中需要一个跨平台的事件库,他们首选了 libev,但又要支持

Windows,故重新封装了一套,linux 下用 libev 实现,Windows 下用 IOCP 实现;

优先级

libevent: 激活的事件组织在优先级队列中,各类事件默认的优先级是相同的,可以通过设置 事件的优先级使其优先被处理 libev: 也是通过优先级队列来管理激活的时间,也可以设置事件优先级 libuv: 没有优先级概念,按照固定的顺序访问各类事件

事件循环

libevent: event_base 用于管理事件 libev: 激活的事件组织在优先级队列中,各类事件默认的优先级是相同的, libuv: 可以通过设置事件的优先级 使其优先被处理

线程安全

event_base 和 loop 都不是线程安全的,一个 event_base 或 loop 实例只能在用户的一个线程 内访问(一般是主线程),注册到 event_base 或者 loop 的 event 都是串行访问的,即每个执 行过程中,会按照优先级顺序访问已经激活的事件,执行其回调函数。所以在仅使用一个 event_base 或 loop 的情况下,回调函数的执行不存在并行关系

目录
相关文章
|
1月前
|
网络协议 前端开发 Java
网络协议与IO模型
网络协议与IO模型
网络协议与IO模型
|
17天前
|
网络协议 物联网 API
Python网络编程:Twisted框架的异步IO处理与实战
【10月更文挑战第26天】Python 是一门功能强大且易于学习的编程语言,Twisted 框架以其事件驱动和异步IO处理能力,在网络编程领域独树一帜。本文深入探讨 Twisted 的异步IO机制,并通过实战示例展示其强大功能。示例包括创建简单HTTP服务器,展示如何高效处理大量并发连接。
39 1
|
17天前
|
存储 关系型数据库 MySQL
查询服务器CPU、内存、磁盘、网络IO、队列、数据库占用空间等等信息
查询服务器CPU、内存、磁盘、网络IO、队列、数据库占用空间等等信息
191 2
|
1月前
|
安全 NoSQL Java
一文搞懂网络通信的基石✅IO模型与零拷贝
【10月更文挑战第1天】本文深入探讨了网络通信中的IO模型及其优化方法——零拷贝技术。首先介绍了IO模型的概念及五种常见类型:同步阻塞、同步非阻塞、多路复用、信号驱动和异步IO模型。文章详细分析了每种模型的特点和适用场景,特别是多路复用和异步IO在高并发场景中的优势。接着介绍了零拷贝技术,通过DMA直接进行数据传输,避免了多次CPU拷贝,进一步提升了效率。最后总结了各种模型的优缺点,并提供了相关的代码示例和资源链接。
一文搞懂网络通信的基石✅IO模型与零拷贝
|
1月前
|
开发者
什么是面向网络的IO模型?
【10月更文挑战第6天】什么是面向网络的IO模型?
21 3
|
1月前
|
数据挖掘 开发者
网络IO模型
【10月更文挑战第6天】网络IO模型
38 3
|
1月前
|
缓存 Java Linux
硬核图解网络IO模型!
硬核图解网络IO模型!
|
1月前
|
数据挖掘 开发者
网络IO模型如何选择?
网络IO模型如何选择?【10月更文挑战第5天】
19 2
|
16天前
|
网络协议 调度 开发者
Python网络编程:Twisted框架的异步IO处理与实战
【10月更文挑战第27天】本文介绍了Python网络编程中的Twisted框架,重点讲解了其异步IO处理机制。通过反应器模式,Twisted能够在单线程中高效处理多个网络连接。文章提供了两个实战示例:一个简单的Echo服务器和一个HTTP服务器,展示了Twisted的强大功能和灵活性。
28 0
|
1月前
|
Java Linux
【网络】高并发场景处理:线程池和IO多路复用
【网络】高并发场景处理:线程池和IO多路复用
45 2