事件驱动编程是一种编程范式, 这里程序的执行流由外部事件来决定. 它的特点是包含一个事件循环, 当外部事件发生时使用回调机制来触发相应的处理. 多线程是另一种常用编程范式, 并且更容易理解.
高性能通用型 C++ 网络框架 https://www.oschina.net/p/NebulaBootstrap 是基于事件驱动的多进程网络框架(适用于即时通讯, 数据采集, 实时计算, 消息推送等应用场景), 已有即时通讯, 埋点数据采集及实时分析的生产应用案例. 经常有人问 Nebula 的每个进程里是单线程还是多线程的? 又问为什么不用多线程? 不用多线程又怎么处理并发问题?
最近 https://github.com/Bwar/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 资源.
https://github.com/Bwar/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 框架理解不够深入, 导致业务逻辑开发比较复杂, 对后续需求扩展不易.
https://github.com/Bwar/Nebula 是 Bwar 开发的 C++ 网络框架, 生而为分布式服务, 经过两个生产环境的应用. Nebula 不是 rpc 框架而是一个基 proactor(框架层实现 proactor 而非操作系统支持)事件驱动 (回调) 的框架. 并不像大多数异步事件回调框架那样开发者需要自己注册回调函数, Nebula 同时也是个 IoC 框架, 通过 actor 类的巧妙设计实现降低了异步编程的复杂度, 开发者真正意义上只需聚焦业务逻辑开发.
https://github.com/Bwar/Nebula 框架提供的 Cmd 类非常适合推荐服务的逻辑入口, 支持动态加载, 随时不停机升级推荐算法推荐模型. Step 类异步获取 Redis 等存储中的数据, 无阻塞等待让 CPU 资源只用于推荐逻辑. session 类用于缓存用户, item, 模型等数据. 所有的数据获取, 传递均可通过 session 智能指针十分方便而高效地得到.
在那些基于 rpc 框架的推荐引擎中, 许多开发人员提到了反射功能, 并且通过大量宏以很费劲很难理解的方式实现了所谓的反射功能. 这些都不是 IoC 框架, Bwar 不理解为什么需要实现反射功能, 如果用 https://github.com/Bwar/Nebula 来做将是非常简单的事, Nebula 是 IoC 框架, 所有的 actor 实例创建都是通过反射创建的, 无须开发者做业务逻辑之外的任何事情. Nebula 的反射实现很优雅, 如果感兴趣, 可以参考这篇文章《C++ 反射机制: 可变参数模板实现 C++ 反射》 https://blog.51cto.com/5662165/2142574 .
开发 Nebula 框架目的是致力于提供一种基于 C++ 快速构建高性能的分布式服务. 如果觉得本文对你有用, 别忘了到 Nebula 的 GitHub 或 码云 给个 star, 谢谢.
参考资料:
http://www.aosabook.org/en/twisted.html
Python Twisted 介绍
来源: https://www.cnblogs.com/bwar/p/10922758.html