多线程、事件驱动与推荐引擎框架选型

时间:2024-01-24 17:13:47

  事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。多线程是另一种常用编程范式,并且更容易理解。

  高性能通用型C++网络框架 Nebula 是基于事件驱动的多进程网络框架(适用于即时通讯、数据采集、实时计算、消息推送等应用场景),已有即时通讯、埋点数据采集及实时分析的生产应用案例。经常有人问Nebula的每个进程里是单线程还是多线程的?又问为什么不用多线程?不用多线程又怎么处理并发问题?

  最近 Nebula 将会用于一个新的生产项目——推荐引擎,在此之前团队已有使用某知名度较高的RPC框架多线程版推荐引擎(业界许多推荐引擎都用了目前比较知名的开源RPC框架来开发)。本文不做Nebula与各知名RPC框架的比较,也无意说明哪个框架更适合做推荐引擎,只说明Nebula可以用于推荐引擎,且有信心效果会很好。最终结果如何,等推荐引擎研发出来,拭目以待。

  为什么是事件驱动而不是多线程?事件驱动无须多线程。我们先来回顾一下服务器编程范式。

1. 服务器程序设计范式

  《UNIX网络编程》卷一里介绍了9种服务器设计范式:

服务器设计范式图

  九种服务器设计范式并不是全都有实用价值,在《UNIX网络编程》卷一最后一节里给出了几种TCP服务器设计范式代码示例:

  • TCP并发服务器程序,每个客户一个子进程
  • TCP预先派生子进程服务器程序
  • TCP预先派生子进程服务器程序,传递描述符
  • TCP并发服务器程序,每个客户一个线程
  • TCP预先创建线程服务器程序,每个线程各自accept
  • TCP预先创建线程服务器程序,主线程统一accept

  Nginx采用的是九种服务器设计范式里的第5种“预先派生子进程,使用互斥锁上锁方式保护accept”,Nebula采用的是九种服务器设计范式里的第6种“预先派生子进程,由父进程向子进程传递套接字文件描述符”。

2. 单线程、多线程以及事件驱动编程模型比较

  一个典型的事件驱动的程序,就是一个死循环,并以一个线程的形式存在,这个死循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是事件的处理过程。程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu。

  某种意义上说,服务端程序大多是事件驱动的,或者说是IO请求事件驱动的。这里比较的编程模型里的事件驱动是指事件处理部分是异步的,即不仅IO请求事件驱动,还有IO响应事件驱动,它的特点是当外部IO响应事件发生时使用回调机制来触发相应的处理。

单线程、多线程、事件驱动比较图

  在单线程同步模型中,任务按照顺序执行。如果某个任务因为I/O而阻塞,其他所有的任务都必须等待,直到它完成之后它们才能依次执行。这种明确的执行顺序和串行化处理的行为是很容易推断得出的。如果任务之间并没有互相依赖的关系,但仍然需要互相等待的话这就使得程序不必要的降低了运行速度。

  在多线程模型,每个任务分别在独立的线程中执行。这些线程由操作系统来管理,在多处理器系统上可以并行处理,或者在单处理器系统上交错执行。这使得当某个线程阻塞在某个资源的同时其他线程得以继续执行。与完成类似功能的同步程序相比,这种方式更有效率,但程序员必须写代码来保护共享资源,防止其被多个线程同时访问。多线程程序更加难以推断,因为这类程序不得不通过线程同步机制如锁、可重入函数、线程局部存储或者其他机制来处理线程安全问题,如果实现不当就会导致出现微妙且令人痛不欲生的bug。另一个问题,操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时,时间将会被耗用在上下文切换中。所以在大并发量时,多线程结构还是无法做到强大的伸缩性。

  在事件驱动版本的程序中,3个任务交错执行,但仍然在一个单独的线程控制中。当处理I/O或者其他昂贵的操作时,注册一个回调到事件循环中,然后当I/O操作完成时继续执行。回调描述了该如何处理某个事件。事件循环轮询所有的事件,当事件到来时将它们分配给等待处理事件的回调函数。这种方式让程序尽可能的得以执行而不需要用到额外的线程。当无IO操作时每个任务占用cpu的时间又比较少,进程就会处于空闲状态。同等并发量情况下,事件驱动占用的系统资源会更好,负载足够大时,事件驱动程序可以将cpu利用到100%。事件驱动型程序比多线程程序更容易推断出行为,因为程序员不需要关心线程安全问题。

3. 事件驱动 != 只有一个线程

  事件驱动的一个非常有代表性的实现Node.js和redis,都是一个单进程(单线程)的服务(redis的数据落地或主从同步线程排除,其服务就是单线程的),事件处理都通过异步回调执行。第二节中单线程、多线程、事件驱动编程模型等类似比较中看起来事件驱动是单线程的,Node.js这一典型的事件驱动服务也是单线程的,导致许多人以为事件驱动只能是单线程的,不能充分利用多CPU多核资源。其实不然,Nginx也是一个典型的事件驱动服务,而Nginx是多进程的。从逻辑上划分后端服务,Nginx归为接入通信层(openresty这种nginx+lua实现业务逻辑的不在讨论范围),Node.js归为业务逻辑层。接入通信层的特点都是IO行为几乎不大消耗CPU是天然适合事件驱动的,也比较容易实现,而业务逻辑层的特点决定了事件驱动方式实现非常复杂,但这并意味着业务逻辑层的多线程事件驱动难以实现。

  Nebula就是一个多进程事件驱动服务的典型。事件驱动的每一个进程都足够高效,多个进程(多线程)又充分利用多CPU多核资源。Nebula的进程模型与Nginx相似,区别在于Nginx是各worker互斥锁上锁accept,而Nebula是由master进程accept后将连接对应的文件描述符传送给worker进程(跟Memcached相似)。Nebula是从满足即时通讯应用而开发的Starship框架发展而来的,与nginx的进程(线程)模型存在相似纯属偶然。为什么Nebula选择传送文件描述符而不是各worker进程抢accept?跟Nebula定位有关系,Nebula不仅需要做接入通信层、数据代理层,更要做业务逻辑层,分布式服务的各层服务都可以且应该用Nebula实现,这意味着每一个worker进程接近于分布式服务的一个节点的功能,如果是worker抢占式accept就无法做定向路由。为什么选择多进程而不是多线程?先看看多进程与多线程的优缺点比较:

  多进程:

  • 编程相对容易;通常不需要考虑锁和同步资源的问题
  • 更强的容错性:比起多线程的一个好处是一个进程崩溃了不会影响其他进程
  • 有内核保证的隔离:数据和错误隔离
  • 进程切换开销大

  多线程:

  • 创建速度快
  • 共享数据,多线程间可以共享同一虚拟地址空间,多进程间的数据共享就需要用到共享内存、信号量等IPC技术
  • 较轻的上下文切换开销
  • 一旦有一个线程挂掉,整个进程都可能会挂掉
  • 需要对共享资源的访问进行同步

  多进程的前三点都是优点,第四点是缺点。Nebula选择多进程就不需要考虑锁和同步资源问题,数据和错误隔离,worker进程崩溃不会影响整个节点服务,会被master进程迅速拉起。第四点缺点在Nebula不需要考虑,因为Nebula事件驱动的进程之间是不需要切换的,可以近似地认为每个worker进程都是一个节点,节点与节点之间只有网络通信,不需要共享资源更不需要做切换。

4. 事件驱动适用场景

  对于IO密集型的业务,事件驱动比多线程同步的并发能力要高很多,可以说不是一个数量级的。而大部分互联网业务都属于IO密集型业务,因此事件驱动的适用场景非常广泛。程序中有许多高度独立的任务,在等待事件到来时,某些任务会阻塞,单个任务需要占用较少CPU资源。

  Nebula 适用于即时通讯、数据采集、实时计算、消息推送等应用场景,也适用于web后台服务。Nebula已有即时通讯、埋点数据采集及实时分析的生产应用案例,很快将有一个面向亿级用户的推荐引擎生产应用案例。

5. 推荐引擎框架选型

  说到推荐系统,首先被想到的可能是基于内容、协同过滤、基于人口统计学、基于知识、基于社区、混合推荐等推荐技术。推荐技术的实施通常基于hadoop,用hive、spark、storm、flink等来实现。这些通常被称为推荐的数据挖掘部分。

  推荐引擎是推荐系统核心之一,负责将数据挖掘的结果按一定排序推送给用户,这就是推荐引擎的主要功能。

  已知业界推荐引擎有使用C++开发也有使用Java开发,C++开发占大多数。在Bwar了解到的C++开发的推荐引擎中多使用rpc框架,使用thrift的4个,使用brpc的2个,使用grpc的1个,使用tars的1个。因这些开源rpc框架不是专为推荐引擎所开发的框架,开发人员通常会在这些框架之上再架设一层框架,然后才是业务逻辑开发。Bwar接触的一个推荐引擎就是基于brpc再开发了自己的框架然后才做业务逻辑开发,其开发难度比较大,且不容易扩展。也许是开发人员对这些开源rpc框架理解不够深入,导致业务逻辑开发比较复杂,对后续需求扩展不易。

  Nebula是Bwar开发的C++网络框架,生而为分布式服务,经过两个生产环境的应用。Nebula不是rpc框架而是一个基proactor(框架层实现proactor而非操作系统支持)事件驱动(回调)的框架。并不像大多数异步事件回调框架那样开发者需要自己注册回调函数,Nebula同时也是个IoC框架,通过actor类的巧妙设计实现降低了异步编程的复杂度,开发者真正意义上只需聚焦业务逻辑开发。

Actor类图

  Nebula框架提供的Cmd类非常适合推荐服务的逻辑入口,支持动态加载,随时不停机升级推荐算法推荐模型。Step类异步获取redis等存储中的数据,无阻塞等待让cpu资源只用于推荐逻辑。session类用于缓存用户、item、模型等数据。所有的数据获取、传递均可通过session智能指针十分方便而高效地得到。

  在那些基于rpc框架的推荐引擎中,许多开发人员提到了反射功能,并且通过大量宏以很费劲很难理解的方式实现了所谓的反射功能。这些都不是IoC框架,Bwar不理解为什么需要实现反射功能,如果用Nebula来做将是非常简单的事,Nebula是IoC框架,所有的actor实例创建都是通过反射创建的,无须开发者做业务逻辑之外的任何事情。Nebula的反射实现很优雅,如果感兴趣,可以参考这篇文章《C++反射机制:可变参数模板实现C++反射》

  开发Nebula框架目的是致力于提供一种基于C++快速构建高性能的分布式服务。如果觉得本文对你有用,别忘了到Nebula的 Github 或 码云 给个star,谢谢。

参考资料: