爱奇艺网络协程编写高并发应用实践
The following article is from 爱奇艺技术产品团队 Author 系统网络团队
⼆、⽹络协程基本原理
⽹络协程的本质是将应⽤层的阻塞式 IO 过程在底层转换成⾮阻塞 IO 过程,并通过程序运⾏栈的上下⽂切换使 IO 准备就绪的协程交替运⾏,从⽽达到以简单⽅式编写⾼并发⽹络程序的⽬的。既然⽹络协程的底层也是⾮阻塞IO过程,所以在介绍⽹络协程基本原理前,我们先了解⼀下⾮阻塞⽹络通信的基本过程。
下⾯给出了⾮阻塞⽹络编程的常⻅设计⽅式:
• ⼀次完整的 IO 会话过程会被分割成多次的 IO 过程;
• 每次 IO 过程需要缓存部分数据及当前会话的处理状态;
• 要求解析器(如:Json/Xml/Mime 解析器)最好能⽀持流式解析⽅式,否则就需要读到完整数据后才能交给解析器去处理,当遇到业数据较⼤时就需要分配较⼤的连续内存块,必然会造成系统的内存分配压⼒;
• 当前⼤部分后台系统(如数据库、存储系统、缓存系统)所提供的客户端驱动都是阻塞式的,⽆法直接应⽤在⾮阻塞通信应⽤中,从⽽限制了⾮阻塞通信⽅式的应⽤范围;
最⼩调度单元:当前⼤部分操作系统的最⼩调度单元是线程,即在单核或多核 CPU 环境中,操作系统是以线程为基本调度单元的,操作系统负责将多个线程任务唤⼊唤出; 上下⽂切换: 当操作系统需要将某个线程挂起时,会将该线程在 CPU 寄存器中的栈指针、状态字等保存⾄该线程的内存栈中;当操作系统需要唤醒某个被挂起的线程时(重新放置在CPU中运⾏),会将该线程之前被挂起的栈指针重新置⼊ CPU 寄存器中,并恢复之前保留的状态字等信息,从⽽使该线程继续运⾏;通过这样的挂起与唤醒操作,便完成了不同线程间的上下⽂切换; 并⾏与⽹络并发:并⾏是指同⼀『时刻』同时运⾏的任务数,并⾏任务数量取决于 CPU 核⼼数量;⽽⽹络并发是指在某⼀『时刻』⽹络连接的数量;类似于⼆⼋定律,在客户端与服务端保持 TCP ⻓连接时,⼤部分连接是空闲的,所以服务端只需响应少量活跃的⽹络连接即可,如果服务端采⽤多路复⽤技术,即使使⽤单核也可以⽀持 100K 个⽹络并发连接。
(二)协程的切换过程
因此,存在于线程中的⼤量协程需要相互协作,合理地占⽤ CPU 时间⽚,在合适的运⾏点(如:⽹络阻塞点)主动让出 CPU,给其它协程提供运⾏的机会,这也正是『协程』这一概念的由来。每个协程一般都会经历如下过程:
下图是使用网络过程协程化示意图:
在网络协程库中,内部有一个缺省的IO调度协程,其负责处理与网络IO相关的协程调度过程,故称之为IO调度协程:
每⼀个⽹络连接绑定⼀个套接字句柄,该套接字绑定⼀个协程;
当对⽹络套接字进⾏读或写发生阻塞时,将该套接字添加⾄ IO 调度协程的事件引擎中并设置读写事件,然后将该协程挂起;这样所有处于读写等待状态的⽹络协程都被挂起,且与之关联的⽹络套接字均由 IO 调度协程的事件引擎统⼀监控管理; 当某些⽹络套接字满⾜可读或可写条件时,IO 调度协程的事件引擎返回这些套接字的状态,IO 调度协程找到与这些套接字绑定的协程对象,然后将这些协程追加至协程调度队列中,使其依次运⾏; IO 事件协程内部本身是由系统事件引擎(如:Linux 下的 epoll 事件引擎)驱动的,其内部 IO 事件的驱动机制和上⾯介绍的⾮阻塞过程相似,当某个套接字句柄『准备就绪』时,IO 调度协程便将其所绑定的协程添加进协程调度队列中,待本次 IO 调度协程返回后,会依次运⾏协程调度队列⾥的所有协程。
创建⼀个监听协程,使其『堵』在 accept() 调⽤上,等待客户端连接;
启动协程调度器,启动新创建的监听协程及内部的 IO 调度协程;
监听协程每接收⼀个网络连接,便创建⼀个客户端协程去处理,然后监听协程继续等待新的网络连接;
客户端协程以『阻塞』⽅式读写⽹络连接数据;网络连接处理完毕,则关闭连接,协程退出。
在介绍了⽹络协程的基本原理后,本章节主要介绍 libfiber ⽹络协程的核⼼设计要点,为⽹络协程应⽤实践化提供了基本的设计思路。
多核环境下 CPU 缓存的亲和性:CPU 本身配有⾼效的多级缓存,虽然 CPU 多级缓存容量较内存⼩的多,但其访问效率却远⾼于内存,在单线程调度⽅式下,可以⽅便编译器有效地进⾏ CPU 缓存使⽤优化,使运⾏指令和共享数据尽可能放置在 CPU 缓存中,⽽如果采⽤多线程调度⽅式,多个线程间共享的数据就可能使 CPU 缓存失效,容易造成调度线程越多,协程的运⾏效率越低的问题;
多线程分配任务时的同步问题:当多个线程需要从公共协程任务资源中获取协程任务时,需要增加『锁』保护机制,⼀旦产⽣⼤量的『锁』冲突,则势必会造成运⾏性能的严重损耗;
事件引擎操作优化:在多线程调度则很难进⾏如此优化,下⾯会介绍在单线程调度模式下的事件引擎操作优化。
启动多个进程,每个进程运⾏⼀个线程,该线程运行一个协程调度器;
同⼀进程内启动多个线程,每个线程运⾏独⽴的协程调度器;
协程锁需要⽀持『同⼀线程内的协程之间、不同线程的协程之间、协程线程与⾮协程线程之间』的互斥;
⽹络连接池的线程隔离机制,需要为每个线程建⽴各⾃独⽴的连接池,防⽌连接对象在不同线程的协程之间共享,否则便会造成同⼀⽹络连接在不同线程的协程之间使⽤,破坏单线程调度规则;
需要防⽌线程内的某个协程『疯狂』占⽤ CPU 资源,导致本线程内的其它协程得不到运⾏的机会,虽然此类问题在多线程调度时也会造成问题,但显然在单线程调度时造成的后果更为严重。
3.2、协程事件引擎设计
libfiber 的事件引擎⽀持当今主流的操作系统,从⽽为 libfiber 的跨平台特性提供了有⼒的⽀撑,下⾯为 libfiber 事件引擎所⽀持的平台:
libfiber ⽀持采⽤界⾯消息引擎做为底层的事件引擎,这样在编写 Windows 界⾯程序的⽹络模块时便可以使⽤协程⽅式了,之前⼈们在 Windows 平台编写界⾯程序的⽹络模块时,⼀般采⽤如下两种⽅式:
现在 libfiber ⽀持 Windows 界⾯消息引擎,我们就可以在界⾯线程中直接创建⽹络协程,直接进⾏阻塞式⽹络编程。
⼤家在谈论⽹络协程程序的运⾏效率时,往往只重视协程的切换效率,却忽视了事件引擎对于性能影响的重要性,虽然现在很⽹络协程库所采⽤的事件引擎都是内核级的,但仍需要合理使⽤才能发挥其最佳性能。
在使⽤ libfiber 的早期版本编译⽹络协程服务程序时,虽然在 Linux 平台上也是采⽤了 epoll 事件引擎,但在对⽹络协程服务程序进⾏性能压测(使⽤⽤系统命令 『# perf top -p pid』 观察运⾏状态)时,却发现 epoll_ctl API 占⽤了较⾼的 CPU,分析原因是 epoll_ctl 使⽤次数过多导致的:因为 epoll_ctl 内部在对套接字句柄进⾏添加、修改或删除事件操作时,需要先通过红⿊树的查找算法找到其对应的内部套接字对象(红⿊树的查找效率并不是O (1)的),如果 epoll_ctl 的调⽤次数过多必然会造成 CPU 的占⽤较⾼。
在 libfiber 中之所以可以针对中间的事件操作过程进⾏合并处理,主要是因为 libfiber 的调度过程是单线程模式的,如果想要在多线程调度器中合并中间态的事件操作则要难很多:在多线程调度过程中,当套接字所绑定的协程因IO 可读被唤醒时,假设不取消该套接字的读事件,则该协程被某个线程『拿⾛』后,恰巧该套接字又收到新数据,内核会再次触发事件引擎,协程调度器被唤醒,此时协程调度器也许就不知该如何处理了。
3.3.1、单⼀线程内部的协程互斥
• 线程B 中的协程B2 对线程锁2成功加锁;
通过使⽤原⼦数可以使协程快速加锁空闲的事件锁,原⼦数在多线程或协程环境中的⾏为相同的,可以保证安全性;
当锁被占⽤时,该协程进入IO管道读等待状态而被挂起,这并不会影响其所属的线程调度器的正常运行;在 Linux 平台上可以使⽤ eventfd 代替管道,其占⽤资源更少。
3.3.3、协程条件变量
在使⽤线程编程时,都知道线程条件变量的价值:在线程之间传递消息时往往需要组合线程条件变量和线程锁。因此,在 libfiber 中也设计了协程条件变量(源码⻅ fiber_cond.c),通过组合使⽤ libfiber 中的协程事件锁(fiber_event.c)和协程条件变量,⽤户便可以编写出⽤于在线程之间、线程与协程之间、线程内的协程之间、线程间的协程之间进⾏消息传递的消息队列。下图为使⽤ libfiber 中协程条件变量时的交互过程:
3.3.4、协程信号量
3.4、域名解析
3.5、Hook 系统 API
在网络协程广泛使用前,很多⽹络库很早就存在了,并且⼤部分这些⽹络库都是阻塞式的,要改造这些⽹络库使之协程化的成本是⾮常巨⼤的,我们不可能采⽤协程⽅式将这些⽹络库重新实现⼀遍,⽬前⼀个⼴泛采⽤的⽅案是 Hook 与 IO 及网络相关的系统中 API,在 Unix 平台上 Hook 系统 API 相对简单,在初始化时,先加载并保留系统 API 的原始地址,然后编写⼀个与系统 API 函数名相同且参数也相同的函数,将这段代码与应⽤代码⼀起编译,则编译器会优先使⽤这些 Hooked API,下⾯的代码给出了在 Unix 平台上 Hook 系统 API 的简单示例:
在 libfiber 中Hook 了⼤部分与 IO 及⽹络相关的系统 API,下⾯列出 libfiber 所 Hook 的系统 API:
• 读 API:read/readv/recv/recvfrom/recvmsg;
• 写API:write/writev/send/sendto/sendmsg/sendfile64;
⽹络相关 API
• 域名解析 API:gethostbyname/gethostbyname_r, getaddrinfo/freeaddrinfo。
通过 Hook API ⽅式,libfiber 已经可以使 Mysql 客户端库、⼀些 HTTP 通信库及 Redis 客户端库的⽹络通信协程化,这样在使⽤⽹络协程编写服务端应⽤程序时,⼤⼤降低了编程复杂度及改造成本。
4.1.1、项目背景
• 合并回源:当多个用户访问同一段数据内容时,回源软件应合并相同请求,只向源站发起一个请求,一方面可以降低源站的压力,同时可以降低回源带宽;
• 断点续传:当数据回源时如果因网络或其它原因造成回源连接中断,则回源软件应能在原来数据断开位置继续下载剩余数据;
• 随机位置下载:因为很多用户喜欢跳跃式点播视频内容,为了能够在快速响应用户请求的同时节省带宽,要求回源软件能够快速从视频数据的任意位置下载、同时停止下载用户跳过的内容;
• 数据完整性:为了防止数据在传输过程中因网络、机器或软件重启等原因造成损坏,需要对已经下载的块数据和完整数据做完整性校验;
在爱奇艺的自建 CDN 系统中,作为数据回源及本地缓存的核心软件,奇迅承担了重要角色,该模块采用多线程多协程的软件架构设计,如下所示奇迅回源架构设计的特点总结如下:
• 更有助于客户端与奇迅之间保持长连接,提升响应性能。
对于后端下载模块,由于采用协程方式,在数据回源时允许建立更多的并发连接去多个源站下载数据,从而获得更快的下载速度;同时,为了节省带宽,奇迅采用合并回源策略,即当前端多个客户端请求同一段数据时,下载模块将会合并相同的请求,向源站发起一份数据请求,在合并回源请求过程中,因数据共享原因,必然存在如 “3.3.2、多线程之间的协程互斥”章节所提到的多个线程之间的协程同步互斥的需求,通过使用 libfiber 中的事件锁完美地解决了一这需求(其实,当初事件锁就是为了满足奇迅的这一需求而设计编写)。
4.1.3、项目成果
采用协程方式编写的回源与缓存软件『奇迅』上线后,爱奇艺自建CDN视频卡顿比小于 2%,CDN 视频回源带宽小于 1%。
4.2、⾼性能 DNS 模块使⽤协程
4.2.1、项目背景
4.2.2、软件架构
DNS 做为互联网的基础设施,在整个互联网中发挥着举足轻重的作用,爱奇艺为了满足自身业务的发展需要,自研了高性能 DNS(简称 HPDNS),该 DNS 的软件架构如下图所示:
HPDNS 服务的特点如下:
优点 | 说明 |
高性能 | 启用 Linux 3.0 内核的 REUSEPORT 功能,提升多线程并行收发包的能力 |
采用 Linux 3.0 内核的 recvmmsg/sendmmsg API,提升单次 IO 数据包收发能力 | |
采用内存预分配策略,减少内存动态分配/释放时的“锁”冲突 | |
针对 TCP 服务模式,采用网络协程框架,最大化 TCP 并发能力 | |
高可用 | 采用RCU(Read Copy Update)方式更新视图数据及配置项,无需停止服务,且不影响性能 |
网卡 IP 地址变化自动感知(即可自动添加新 IP 或摘除老IP而不必停止服务) | |
采用 Keepalived 保证服务高可用 | |
易管理 | 由 master 服务管理模块管理 DNS 进程,控制 DNS 进程的启动、停止、重读配置/数据、异常重启及异常报警等 |
由于 DNS 协议要求 DNS 服务端需要同时支持 UDP 及 TCP 两种通信方式,除了要求 UDP 模块具备高性能外,对 TCP 模块也要求支持高并发及高性能,该模块的网络通信部分使用 libfiber 编写,从而支持更高的并发连接,同时具备更高的性能,又因启用多个线程调度器,从而可以更加方便地使用多核。
4.2.3、项目成果
五、总结
本文讲述了爱奇艺开源项目 libfiber 网络协程库的设计原理及核心设计要点,方便读者了解网络协程的设计原理及运行机制,做到知其然且知其所以然;还从爱奇艺自身的项目实践出发,总结了在应用网络协程编程时遇到的问题及解决方案,使读者能够更加全面地了解编写网络协程类应用的注意事项。
技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。
长按二维码 关注「高可用架构」公众号