现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

libev 笔记(二):事件驱动模型 在 稳定的网络服务器 程序中的应用

2019-01-11 21:54 工业·编程 ⁄ 共 4649字 ⁄ 字号 暂无评论

    libev目前最典型的应用场景可能就是 网络服务器的 编程框架中,常规的 网络服务器编程都是 从 bind()、listen()、accept()、read()等接口开始的,标准的 socket 编程套路,不管Windows还是linux都是一样的,这些标准的接口,可以方便的构建 服务器/ 客户机的模型的。假设我们希望建立一个简单的服务器程序,实现向单个客户端提供 “一问一答”的内容服务。

单线程、阻塞型网络编程

1

图 1. 简单的一问一答的服务器 / 客户机模型

大部分的 socket 接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是 IO 接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回,比如我们调用read 函数,如果 缓冲中没有数据,那么该函数将阻塞整个线程或者函数。实际上,除非特别指定,几乎所有的 IO 接口 ( 包括 socket 接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用 send() 的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。这给多客户机、多业务逻辑的网络编程带来了挑战。这时,很多程序员可能会选择多线程的方式来解决这个问题

问题小结:由于 系统调用 接口多是 阻塞型,当线程阻塞,无法响应新的网络请求。

为了解决上面的问题,一种解决方案就出现了,就是多线程服务器程序。

多线程服务器程序

应对多客户机的网络应用,最简单的解决方式是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。

具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以,如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用 pthread_create () 创建新线程,fork() 创建新进程。

我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。

2

多线程的服务器模型

在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务

这里有个重要机制:一个socket可以accept多次,并且执行成功一次,就会返回一个新的 socket,来对应新的客户端。

accept函数原型

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

@ 参数 s 是从 socket()、bind(),listen()中沿用下来的socket 文件描述符,也就是服务器的socket。

@ 参数 addr 服务器ip、端口信息

@ 参数 addrlen 服务器 信息长度

@ 返回: 成功 则返回 新的 socket ,对应新的客户端

@ 返回的 socket 句柄即是后续 read() 和 recv() 的输入参数。如果请求队列当前没有请求,则 accept() 将进入阻塞状态直到有请求进入队列。

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

很多程序员可能会考虑使用“线程池”或“连接池”。 “线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。

但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。

对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。

小结:多线程模式可以解决单线程阻塞的问题,但是面对大规模服务请求,多线程模型也不是最佳的方案。

非阻塞服务方案

以上面临的问题,一定程度都是因为IO接口的“阻塞”机制导致的,多线程是一种解决方案,另一种方案就是使用非阻塞的接口

非阻塞的接口,相比阻塞型接口的差异就在于,调用之后立即返回,一般使用 fcntl 函数,代码如下:

fcntl( fd, F_SETFL, O_NONBLOCK );

@ 设置 文件 描述符 fd 为非阻塞状态。

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

3

使用非阻塞的接收数据模型

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

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

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

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

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

可以看到服务器线程可以通过循环调用 recv() 接口,可以在单个线程内实现对所有连接的数据接收工作。

这种方案其实就是 轮询方案,虽然看起来简单,但是循环的调用 recv 会大幅度提高CPU占用率,而且效率并不高,而且,同样的功能,其实linux系统已经提供了更高效的 检测“操作是否完成”的 接口函数:select

使用 select 机制实现服务器功能

大部分 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() 函数中,readfds、writefds 和 exceptfds 同时作为输入参数和输出参数。如果输入的 readfds 标记了 16 号句柄,则 select() 将检测 16 号句柄是否可读。在 select() 返回后,可以通过检查 readfds 有否标记 16 号句柄,来判断该“可读”事件是否发生。另外,用户可以设置 timeout 时间。

下面将重新模拟上例中从多个客户端接收数据的模型。

上述模型只是描述了使用 select() 接口同时从多个客户端接收数据的过程;由于 select() 接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。

4

使用 select() 接口的基于事件驱动的服务器模型

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

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

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

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

5

这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。

相比其他模型,使用 select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。

这个模型依旧有着很多问题

首先,select() 接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select() 接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如 linux 提供了 epoll,BSD 提供了 kqueue,Solaris 提供了 /dev/poll …。如果需要实现更高效的服务器程序,类似 epoll 这样的接口更被推荐。遗憾的是不同的操作系统特供的 epoll 接口有很大差异,所以使用类似于 epoll 的接口实现具有较好跨平台能力的服务器会比较困难。

其次,该模型将事件探测事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。如下例,庞大的执行体 1 的将直接导致响应事件 2 的执行体迟迟得不到执行,并在很大程度上降低了事件探测的及时性。

6

庞大的执行体对使用 select() 的事件驱动模型的影响

缺点小结:select 本质上也是轮询,只不过效率比 一个个的轮询高一些,所以当要 监视的 事件 数量庞大时,效率依然低。

幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有 libevent 库,还有作为 libevent 替代者的 libev 库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号 (signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。

总结:

    libev、libevent、libuv 这三种 异步事件库,实现了 高级、智能版的 epoll 功能,当然这三者 都是依托 操作系统 提供的基本 接口(比如linux下的epoll),我们可以简单的理解为,这三个库 对 epoll进行了二次开发,既保证了 智能高效的 异步事件驱动功能,也给 程序员 提供了友好的编程接口,要知道,如果在linux下,我们使用epoll机制去写代码,原理大概是清楚的,写出来,写好还是很难的,而libev。。。这三种库,作者帮我们已经写好了 编程框架,我们来按照框架模块进行写我们自己的业务逻辑即可。

给我留言

留言无头像?