Libevent源码剖析-开篇

发布于:2024-10-15 ⋅ 阅读:(108) ⋅ 点赞:(0)

1 背景

    关于libevent,曾在项目中直接或间接使用过,360开源的evpp便是对libevent的c++封装,窃以为简单好用,但裸用的话本人在最近开源的缓存库C缓存库Github地址里使用过,感受是比较累。累的原因,一则是C语言所写,一则是对libevent源码还不够熟,踩坑较多。

    因此,从本文开始,拟对libevent写系列文章,目的是通过对libevent的源码分析,来加深理解。   

    后文均基于libevent2.1.18版本来剖析。

library version
libevent 2.1.18

2 结构

    这里简要介绍下文章结构,算是提纲挈领。

  1. 首先,介绍libevent的核心部分,包括各平台IO多路复用,reactor原理,事件注册&收集&分发&处理,以及一些重要数据结构的实现原理,比如小根堆。
  2. 其次,会详细分析evbuffer的实现原理,以及为何需要evbuffer、它解决了什么问题。
  3. 再则,进一步探讨bufferevent的实现原理,以及相关api、为何需要bufferevent、它解决了什么问题;
  4. 最后,有了以上核心特性之后,我们便可以利用libevent开发任何基于tcp/udp的应用程序了,这里会以http server为例来介绍。

2.1 libevent原理

    此处简要介绍下libevent的各层次,及其职责:

  1. libevent的核心层,是基于各平台IO多路复用的reactor实现,其中iocp是proactor模型,libevent亦将其融入进核心层。
  2. 中间层,则是引入了evbuffer,本层是bufferevent外围及调用方和libevent的核心层的一个数据缓冲层。
  3. 最外层,便是bufferevent,本层是libevent库对外的模块,也就是直接面向库的使用者。

2.1.1 reactor

    reactor 模式 是一种设计模式,常用于处理多并发的 I/O 事件,尤其在高性能服务器或网络框架中广泛应用。它通过事件驱动机制和非阻塞 I/O 实现多个客户端请求的并发处理,而无需为每个连接分配一个独立的线程或进程。

核心组件

reactor 模式通常包含以下几个主要组件:

1. reactor (反应器):

  • 负责等待并检测 I/O 事件(如读、写、连接等)。当事件发生时,Reactor 将事件分派给合适的事件处理器(或回调函数)进行处理。
  • 一般通过事件多路复用机制(如 select、poll、epoll 在 Linux 上,kqueue 在 BSD 上,或 Windows 的 IOCP)来监视多个 I/O 操作是否准备就绪。

2. handlers (事件处理器):

  • 每个事件处理器都与某一类事件(如读、写、错误事件)关联。当 Reactor 检测到某类事件时,会调用相应的事件处理器来处理该事件。

3. demultiplexer (事件多路分离器):

  • 这是底层的操作系统功能,用来将多个事件(I/O 操作)组合并检测哪些事件已经准备就绪。常见的多路分离器有 select、poll、epoll 等。

4. event loop (事件循环):

  • 事件循环是 reactor 模式的核心机制,它持续循环等待新的事件并处理。整个 reactor 的流程是基于事件循环的不断运行。

工作流程

1. 注册事件

  • 服务器在启动时,将需要监听的 I/O 事件(如客户端连接请求、读写请求等)注册到 reactor 中。

2. 等待事件发生

  • reactor 通过事件多路分离器(如 epoll 或 select)持续监听多个 I/O 事件,当有事件就绪时,会通知 reactor。

3. 事件分派

  • 当有 I/O 事件就绪时,reactor 会调用对应的事件处理器(handler)进行处理。每个事件类型都会有其对应的处理器。

4. 处理事件

  • 事件处理器对具体的事件(如读写数据、处理请求)进行处理,完成后返回到 reactor 继续监听新的事件。

2.1.2 reactor与proactor的区别

reactor 模式与 proactor 模式 是两种不同的 I/O 模型:

reactor:

  • I/O 操作是由应用程序发起的,当 I/O 事件(如数据可读、可写)准备好时,reactor 通知应用程序进行处理。应用程序在事件触发时执行实际的 I/O 操作。
  • 同步IO模型

proactor:

  • I/O 操作由操作系统发起,应用程序只需提供完成后的处理逻辑。操作系统在 I/O 操作完成后通知应用程序进行处理。
  • 异步IO模型

在 reactor 模式下,事件的处理是由应用程序主动执行的;而在 proactor 模式下,事件处理通常由操作系统完成。

2.1.3 IO多路复用

    在以往的同步阻塞模式下,对IO的检测读写操作是由类似read()/write()系统调用来完成的,而在reactor模型里,对IO的检测则是通过IO多路复用系统调用来完成,read()/write()则具体执行读写操作

    换言之,IO多路复用的职责是IO检测,而由read()/write()来完成读写操作。    

2.1.3.1 select

    select 多路复用是一种用于监视多个文件描述符(如网络套接字、文件等)的 I/O 操作状态的系统调用。通过 select,程序可以同时等待多个 I/O 操作的就绪(如读、写或异常事件),从而避免为每个 I/O 操作创建独立的线程或进程。

    select 最常用于高并发场景下的事件驱动编程,例如网络服务器或实时通信系统。通过 select,应用可以一次性监视大量连接,处理 I/O 操作时不会阻塞。

  1. 注册文件描述符:程序将感兴趣的文件描述符(如网络连接的 socket 文件描述符)注册给 select。

  2. 监视文件描述符:select 进入阻塞状态,等待文件描述符上的 I/O 事件(如可读、可写或出现异常)发生。

  3. 事件就绪时返回:当某个文件描述符的 I/O 操作准备就绪时,select 返回,通知应用程序哪些文件描述符可以执行 I/O 操作。

  4. 处理事件:应用程序根据 select 返回的结果对已准备就绪的文件描述符执行相应的操作。

2.1.3.2 poll

    poll 多路复用是与 select 类似的系统调用,用于监控多个文件描述符上的 I/O 操作,允许程序同时等待多个事件(如可读、可写、或异常)。与 select 相比,poll 去除了文件描述符数量的限制,并提供了更灵活的接口。它是 select 的改进版本,适用于高并发场景。

  1. 监控多个文件描述符:poll 可以监控任意数量的文件描述符(不限于 select 的 1024 个限制)。

  2. 阻塞等待事件:程序调用 poll,阻塞等待指定的文件描述符变为就绪状态(如可读、可写或出现异常)。

  3. 事件返回:一旦文件描述符有事件发生,poll 返回,程序可以处理这些事件。

2.1.3.3 epoll

    epoll 是 Linux 提供的一种高效的多路复用机制,用于处理大量并发连接时的 I/O 事件。与传统的 select 和 poll 相比,epoll 的性能大幅提高,尤其在监控大量文件描述符时具有显著优势。

  1. 事件驱动:epoll 采用事件驱动模式(edge-triggered 和 level-triggered),只在文件描述符的状态发生变化时才通知应用程序,而不像 select 和 poll 需要每次调用时轮询所有文件描述符。

  2. O(1) 复杂度:epoll 的性能不随着监控文件描述符数量的增加而下降,在管理大量连接时表现出近乎恒定的时间复杂度。

  3. 内核事件表:epoll 通过内核维护的事件表,避免每次调用时重新遍历文件描述符列表。

2.1.3.4 iocp

I/O Completion Ports (IOCP) 是 Windows 上的一种高效 I/O 多路复用机制,用于处理大量并发连接和 I/O 操作。与 select、poll 等轮询机制不同,IOCP 采用了完成端口和回调机制来高效地处理异步 I/O 操作。

  1. 异步 I/O:IOCP 允许程序在执行 I/O 操作时不阻塞主线程,I/O 操作会在后台进行,完成后通过完成端口通知主线程。

  2. 线程池:IOCP 使用线程池来处理并发 I/O 事件,根据负载动态分配工作线程,从而最大化 CPU 和 I/O 资源的利用。

  3. 完成端口(Completion Ports):完成端口是 IOCP 的核心,它是一个操作系统提供的队列,异步 I/O 操作的结果会被推送到这个队列中,工作线程从中获取 I/O 结果并进行处理。

  4. 句柄绑定:每个 I/O 操作的文件句柄(socket、文件、管道等)可以绑定到一个完成端口,当操作完成后,系统将事件通知到相应的完成端口。

2.1.3.5 devpoll

/dev/poll 是 Solaris 操作系统上的一种 I/O 多路复用机制,类似于 Linux 上的 poll 和 epoll,但在性能和可扩展性上进行了优化。它为需要监视大量文件描述符(如网络服务器等高并发场景)的应用程序提供了一种高效的事件通知机制。

  1. 文件描述符监控:与传统的 poll 不同,/dev/poll 通过一个持久的设备文件(/dev/poll)来跟踪文件描述符的状态,而不是每次调用都需要重新传入一组文件描述符。程序只需一次性注册需要监控的文件描述符,之后可以多次调用等待事件,极大地减少了系统调用的开销。

  2. 持久性:注册的文件描述符是持久的,直到被手动移除或文件关闭,因此不需要像传统 poll 那样在每次事件检测时都传递整个文件描述符集。

  3. 事件通知:当文件描述符的状态发生变化时,/dev/poll 会通知应用程序,允许其处理相应的 I/O 操作。

2.1.3.6 devport

    dev/port 是 Solaris 操作系统中的一种设备接口,主要用于直接访问 I/O 端口,但它不是一种多路复用机制。与多路复用相关的机制主要包括 select、poll、epoll、/dev/poll、IOCP 等。因为 /dev/port 的主要用途与 I/O 端口的直接访问有关,而不是事件驱动的 I/O 多路复用,所以它与多路复用没有直接的联系。

    因此,本书不表,感兴趣的读者可查询libevent2.1.18相关源码。

2.1.3.7 win32select 

    TODO : 后续补充 

2.1.4 evbuffer

    evbuffer模块,在libevent库中,是介于reactor模块和bufferevent模块之间,属于中间层。

    在非阻塞的网络程序里,网络库应该能够缓存业务app的数据,以便在网络可用时再由网络库将数据发送出去(或者将数据接收后缓存起来再通知业务app),如此便不至于阻塞业务程序。

    这就是evbuffer的职责,也就是evbuffer要解决的问题。

    evbuffer的原理,一言以蔽之,就是用若干段堆上分配的非连续空间,以链条的方式来存储连续数据。

    libevent对evbuffer的实现,其功能是比较丰富的:

  • 接收栈上内存数据;
  • 接收堆上内存数据;
  • 接收evbuffer;
  • 接收可变参数数据;
  • 接收整个文件;
  • 接收片段文件;

2.1.5 bufferevent

    bufferevent模块,是libevent的最外层,是直接面向调用方的。换言之,若要用libevent开发程序,bufferevent是程序员最常打交道的。

    bufferevent,在libevent库的使用方和evbuffer之间,起着承上启下的作用。

2.1.6 evconnlistener

    在基于libevent实现tcp的服务端程序时,如果裸用系统调用的话,需要每次都要通过socket()来创建fd,再通过bind()来绑定,再通过listen()来监听,然后通过accept()来接收客户端连接,最后再通过send()/recv()来收发数据,完了再通过close()关闭fd。

    这些都是例行程序,libevent通过evconnlistener来完成此功能。换言之,当我们基于libevent实现tcp的服务端app时,直接用evconnlistener来实现一个acceptor即可,几行代码即可完成。

    值得一提的是,若选择的是epoll,则缺省用ET边缘模式来提高效率。

2.1.7 重要数据结构

    然后,来介绍下libevent的重要数据结构,比如event_baseeventeventopevconnlisteneriocp相关等等。

2.2 例子

http server

    在libevent实现了reactor和evbuffer以及bufferevent特性之后,这就是一个完整的网络库了,我们就可以利用libevent库来开发任何tcp或udp程序了。

    libevent的官方,利用libevent实现了3个例子:http客户端和服务器,dns和rpc。本文以http为例来介绍,其他例子可类比理解。

    值得一提的是,libevent实现的http,是基于http1.2协议以下,只实现了http1.1标准部分,未实现http的流式传输。