枫叶居

桃李春风一杯酒,江湖夜雨十年灯

0%

服务雪崩历险记(上)

服务雪崩——作为微服务架构中的经典问题,之前只是在技术博客中看到过,没想到自己有一天也遇到了,由于首次处理此类问题,经验较为欠缺,走了一些弯路,在此记录排查思路解决方案服务雪崩的概念可以参考网上技术文章,在此不做过多赘述。

线上现象

今天上午刚刚到公司,便收到【天气服务】CPU使用率超限报警,上午一般是百度APP流量低峰期,因此笔者感觉比较奇怪,于是便打开报警链接,发现北京机房实例CPU使用率达到了惊人的245%**,远远超过了70%**的阈值(事后非常庆幸,笔者撰文时所有容器已开启资源硬限,雪崩发生时尚未开启CPU资源硬限,否则服务可用性可能会跌到个位数=_=),如图所示:

CPU使用率

笔者第一时间打开业务监控,查看接口监控指标。如下图,可以明显看到接口流量有若干个波峰,最高的波峰上涨了约80%(8000Q左右)。情理之中,意料之外的是——接口可用性、接口平响同样出现若干个波峰,依赖的所有下游服务可用性均下跌严重。很明显【天气服务】出现了雪崩的迹象,但全线上涨的监控指标,掩盖了问题发生的根源,无从下手定位问题原因。雪崩的时候没有一片雪花是无辜的。

流量

问题定位

初步排查

因为无【服务雪崩】相关排查与处理经验,未能直击要害点。根据【止损优先】的原则,笔者凭直觉(经验),先列出可能的原因,尝试止损:

  1. Go模块未知Bug被触发(例如:Goroutine泄露、full GC),导致CPU使用率急剧飙升,流量急剧升高,下游服务可用性急剧变差;
  2. 端发生大规模崩溃,频繁重启,流量上涨,导致CPU过载;
  3. 下游服务不稳定,导致【天气接口】大量超时,触发上游服务重试,导致CPU使用率飙升;

首先,选择三台问题实例(容器),查看实例CPU使用率监控指标,三台实例CPU使用率波峰出现时间点完全一致,且且内存使用率、已打开文件描述符等等监控均未见异常,基本排除实例内Go模块未知Bug引起CPU使用率飙升,进而引起连锁反应可能性。深入跟踪,查看三台实例所在物理机的CPU使用率(未开启资源硬限,混布服务存在资源侵占的可能性),CPU IDLE指标较高,计算资源充裕,排除服务混布,其它服务实例资源占用的可能性(**注:CPU使用率升高与流量升高,是个鸡生蛋蛋生鸡的问题,CPU使用率升高,会导致部分请求处理不及时,引发上游服务重试,导致流量上涨、可用性下跌等等,所以不能武断**)。综合实例CPU使用率与物理机CPU使用率,基本排除CPU性能瓶颈(延伸问题:若容器开启硬限制,如何排除)。

实例CPU使用率图

物理机CPU使用率图

端发生大规模崩溃,【天气服务】接口流量会上涨,调用的其它接口都会比较明显的上涨。但观察其它接口正常,端崩溃率监控正常,可能性2排除。

在可能性1与可能性2基本排除后,**笔者几乎可以肯定是下游服务可用性出现波动,拖累天气服务超时,触发接入层重试**,但监控中所有下游服务可用性(上游服务调用下游成功率)均大幅度下跌,无参考价值。分析线上RPC请求日志,失败原因均为”request canceled (client.timeout exceeded while awaiting header”,即读超时。一般情况下,因CPU使用率升高导致请求下游服务失败,在建立连接阶段就失败了,此处发生的错误为读超时,更加坚定笔者的判断。看上去真凶呼之欲出了,诡异的打脸马上来了,询问了所有下游业务方,业务方均反馈服务流量有上涨,但服务可用性、平响、CPU使用率均正常

尝试止损

虽然尚未完全定位原因,但基本可以确定流量上涨的原因为【天气服务】上游服务重试导致的,『请求重试』加重了系统的负载,移除上游服务的重试应当是有效的。【天气服务】架构图如下(基于安全考虑,已屏蔽细节,实际架构有出入,不影响理解):

架构图

可以看到【流量网关】 => 【业务网关】=> 【天气服务】=> 【下游服务】存在3层重试,假如相邻的上下两层,请求超时,触发重试,到【下游服务】的流量最大会被放大至正常流量的8倍(2^3=8),很容易发生服务雪崩。流量网关,因接入了众多产品线,摘除『重试』风险较大,在【业务网关】与【天气服务】这两层,将『重试』逻辑移除。【天气服务】与【下游服务】流量逐渐下降,CPU使用率开始逐渐下降,止损操作已生效

问题定位

摘除【业务网关】与【天气服务】的请求『重试』之后,【天气服务】流量趋于正常。因为【流量网关】『重试』尚未摘除,【天气服务】流量波峰与CPU波峰依然存在,参考价值依然不大。下游服务较为明显,陆续恢复正常,除了——Push地址位置同步服务显然该服务是此次雪崩的真凶,那么为什么业务方观察的现业务可用性、平响、CPU使用率均正常,如此诡异呢

长尾请求

笔者在将【天气服务】请求Push地址位置同步服务Timeout时间调小进行止损的同时,开始分析原因,因为RPC报错日志大多数是读超时,首先想到的便是存在长尾请求(有关长尾请求与分位时参考笔者上一篇博文:https://keepalive555.github.io/2020/09/24/%E9%95%BF%E5%B0%BE%E8%AF%B7%E6%B1%82/),统计RPC日志85分位时如下(实际上请求该服务的RPC日志50分位时也将近400ms):

85分位时

而业务方监控平响峰值在5ms以内(如图所示),即使RPC日志中前50%请求耗时为0ms,接口平响也有200ms,相差40倍。于是笔者与业务方RD开始梳理请求全链路,查找线索。

!mapi平响

请求链路梳理

与业务方RD沟通后得知,下游业务采用『Nginx+PHP』的部署方式,每台实例前端均部署一台Nginx用作反向代理, 若干PHP进程(线程)处理请求。请求链路:【天气服务】=> 【实例Nginx】=>【业务方PHP进程】。业务方RD在Nginx日志中发现了大量HTTP 499错误码的请求,HTTP 499表示客户端因请求超时关闭请求,与【天气服务】RPC日志表现一致,耗时500ms,问题基本定位——**495ms的平响时间差是在【实例Nginx】转发至【业务方PHP进程】的过程中产生的**

由于Nginx日志可供参考的信息有限,在OP同学的帮助下最终定位了原因——PHP的虚拟机Worker线程处理能力达到了上限,后续到达的请求排队等候处理,直至超时,**类似于限流算法中的漏桶算法**这也是业务服务可用性、平响、CPU使用率均正常这种诡异现象的原因

队列堆积

导火索

原因已定位,那导致请求堆积的罪魁祸首是什么呢——Push消息推送。年底运营活动较多,通过Push渠道进入百度APP的用户变多,下游业务吞吐量见顶。

事件复盘

“重试”属于控制论中的“正反馈”,会逐渐增强“”活动“——“雪崩”触发”重试”,“重试”强化“雪崩”程度,所以若发生“服务雪崩”可以且应当首先考虑调整“重试”策略。此次【服务雪崩】发生的逻辑链如下:

  1. 年底各业务方运营活动增多,Push推送频繁,“Push集群”流量逐渐上涨
  2. “Push集群”实例PHP虚拟机Worker线程全部被占用,并发处理能力达到上限
  3. “天气服务”请求“Push服务”,PHP虚拟机在处理其它请求,请求排队,读超时,此次请求失败
  4. “天气服务”请求”Push服务”超时,触发RPC请求重试,“天气服务”再次请求“Push服务”
  5. “天气请求”整体处理超时,触发“天气服务”上游“业务网关”重试策略,发起天气请求
  6. “天气服务”再次对所有“下游服务”发起请求,流量被放大到至4倍
  7. 因为下游所有服务负载加大,“业务网关”处理”天气请求”超时,触发“流量网关”请求重试
  8. “天气服务”再次对所有“下游服务”发起请求,流量被放大到至8倍
  9. “天气服务”所有下游服务流量上涨、可用性均下跌、平响升高
  10. “服务雪崩”ಥ_ಥ

服务雪崩解决方案

由于【天气服务】是由PHP模块迁移而来,尚未接入手百的Service Mesh,所以止损方案有限。服务雪崩是微服务架构中常见的问题,解决方案也比较成熟,笔者在下一篇博文中讲述,常见方案参考:

  • 入口流量限流
  • 访问下游服务增加断路器
  • 异步请求弱依赖服务
  • Locality-aware load balancing路由算法
  • ……

写在前面

本文章为笔者原创,转载需要表明出处,联系作者:luckydreamcatcher@163.com | the.matrix.vvv@gmail.com

QA同学在线上测试重构后的golang模块时发现,会偶现后端响应超时的现象。在之前的压测中,接口监控响应稳定在10ms左右,所以猜测存在长尾请求

目前问题

监控指标

目前业务监控系统,反应接口耗时的系统指标为——平响,即平均响应时间=单位时间内所有请求耗时总和/请求数

平均数并不能够反应数据的波动情况,例如:请求a耗时10ms(记为cost(a)=10ms),请求b耗时300ms(记为cost(b)=300ms),请求a与请求b的平均响应时间= cost(a, b) = (cost(a) + cost(b)) / 2 =155ms 。平均耗时155ms(<=200ms)是达标的,但是请求b耗时300ms明显是未达标的。

APP后端研发工程师,都了解对端接口请求耗时200ms是一个临界阈值——请求耗时200ms以下,用户对网络延迟几乎无感,体验较好,请求耗时200ms以上,网络延迟感明显,用户体验较差。因此请求耗时是否<=200ms经常作为接口性能优化的判断条件之一。在业务中,经常会遇到命中缓存未命中缓存时耗时差距较大的场景,所以平响无法全面的衡量系统的性能

长尾请求

业界关于延迟有一个常用的P99标准,即99%的请求应该比指定的延迟更小,仅允许1%的请求大于指定的延迟,这1%的请求即为”长尾请求”。打个形象的比喻,班级内99%同学的成绩都非常优秀,但总会有几位同学拖班级平均成绩后腿儿,拉低班级的“平均分,这几位同学就是“长尾请求”。

长尾请求的产生原因是多种多样的且复杂的,包括实现方式、系统因素、硬件因素等等,在分布式中常见原因如下:

  • 依赖的下游服务有波动;
  • 资源竞争(包括:文件、锁、硬件资源);
  • 网络波动;
  • 机器负载较大,系统调度,排队;
  • fullGC;
  • CPU降低功率控制温度;

有关长尾请求更多介绍于技术优化思路,参考Google Jeff Dean大神的论文:http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.732.6087&rep=rep1&type=pdf。

长尾请求在某种意义上来讲是无法消除的,但是我们可以通过技术手段将长尾请求控制在一定的比例之内,因此长尾请求也是很多性能优化工作的关注重点。由于长尾请求的存在,平响指标无法很好的反应绝大多数请求的耗时情况,因此有了分位时的概念,通俗的理解就是xx%的耗时在多少之内。

分位时

概念介绍

分位数,是统计学的一个术语,概念如下:

百分位数又称百分位分数(percentile),是一种相对地位量数,它是次数分布(Frequency Distribution,频数分布)中的一个点。把一个次数分布排序后,分为 100 个单位,百分位数就是次数分布中相对于某个特定百分点的原始分数,它表明在次数分布中特定个案百分比低于该分数。

通俗的讲,将数据按照升序(或降序)排列,等分为100份,在P=0.9(即99%)位置的数是多少。例如:全校800名学生,80分位数,指80%的学生考分在多少分以上,我们可以这样计算:

  1. 将800名学生成绩,按照从高到低的降序排列;
  2. 800名同学80%的名次为:800 * 80% = 640;
  3. 全校成绩排名第640名的学生成绩即我们所需的80分位数;

现实中,存在total(总数) * percent(百分比)为浮点数的情况,例如9名学生的分数分别为:100,88,89,90,95,70,65,78,79,求90分位数,按照上述思路来计算:

  1. 将9名学生成绩,按照从高到低的升序排列为:100, 95, 90, 89, 88, 79, 78, 70, 65;
  2. 9名同学90%的名次为:9 * 90% = 8.1;

问题来了,第8.1名学生的成绩为多少?显然不存在第8.1名学生,假如存在的话,那么第8.1名学生的成绩一定在第8名与第9名之间。拆开来看,第8.1名学生成绩等价于在第8名学生成绩基础上,加上第9名与第8名成绩之差乘以10%=score(8)+(score(9)-score(8))*10% = 70 + (65 - 70)*10% =69.50,即这9名学生的90分位数为69.50分(注意:假设第9名与第8名成绩区间是分布均匀的,实际上样本数量较少时波动比较大,随着样本数量变大趋向于均匀)。

总结分位数计算规则如下:

  1. 将输入数组升序/降序排列,数组长度为n;
  2. 求数组[0, n)的P%的下标,m = n*P% - 1 = i + j,i代表整数部分,j代表小数部分;
  3. 求下标为m的元素值 f(m) = f(i) + (f(j) - f(i)) * j;

参考上述,可得分位时,是将所有请求耗时由小至大升序排列,求得分位数。

计算工具

计算分位时的工具,可参考笔者写的简易Python脚本

1
curl -L -O https://raw.githubusercontent.com/keepalive555/victorinox/main/src/percentile.py

求一批请求耗时的99分位时,Linux示例命令如下:

1
cat service.log|grep -o -P "cost\[\d+(\.|\])?"|grep -o -P "\d+"|./percentile.py

在笔者的案例中,取生产环境日志约10w条,求得重构后golang接口,99.9分位时为200ms,平响为10ms,差距是要比想想中的要大的多,所以关注系统性能指标不只需要关注平响,也需要关注分位时

优化思路

长尾请求的产生原因是多种多样的,分布式系统中最常见的场景是受下游服务拖累,例如:MySQL慢查询、分布式缓存过期、下游服务过载等等,合理设置下游服务超时时间是非常有必要的。

目前许多流行的RPC框架,提供了解决长尾请求的方案——Backup Request,例如百度内部的BRPC框架。客户端首先向一台下游服务Server发送RPC请求,若在backup_request_ms(通常小于超时时间)内未取到数据,则在向下游服务另外一台Server发送RPC请求,哪台Server先响应则取哪条。设置合理的backup_request_ms,大部分情况下只会发一个请求,对下游服务的压力可以不计。

目前了解到,百度小程序C端团队,在做BackupRequest的改造,准备借鉴一下^_^。

参考资料

The tail at scale

经典分布式论文阅读:The Tail at Scale

百分位数

分位数

写在前面

本文章为笔者原创,转载需要表明出处,联系作者:luckydreamcatcher@163.com | the.matrix.vvv@gmail.com

笔者最近在做golang重构旧php模块的事情,PHP模块峰值请求约1.5w QPS,是典型的高并发场景,重构过程中,代码中一些容易被开发者**”选择性忽略”的问题会被指数级放大,比如内存泄露、full GC等等,所以上线/放量**前必须进行压力测试。

为了更贴近生产环境,与QA同学合作,将重构后的golang模块部署到生产集群,选择一台20标准CPU核的实例,从服务列表中摘除,做压力测试。预期单实例的配置,可以扛住400QPS,在压力测试过程中发现,并发达到400QPS时,实例CPU使用率达到100%,成为性能瓶颈。

节约机器资源作为golang重构旧php的重要收益之一,为了达成此目标,笔者必须解决golang模块的cpu性能瓶颈,达到预期性能。

CPU性能分析

CPU性能分析,又称为CPU Profiling,下面介绍了三种笔者常用的性能分析手段:

  • go tool pprof命令行工具
  • go tool pprof可视化工具
  • FlameGraph火焰图

go tool pprof工具非常强大,性能分析不止这三中方式,可根据业务场景自由选择。实践中,笔者推荐使用go tool pprofFlame Graph两种方式相结合。

为了在不影响阅读的前提下,保证服务安全,文章的敏感信息,笔者均用”xxx”进行了替换。

CPU Profiling原理

借助工具进行CPU Profiling之前,我们需要了解CPU Profiling的基本原理,这样才可以对数据做出更准确的判断。然而许许多多Google搜索到的技术博客,几乎千篇一律的都是在介绍golang pprof工具的使用。笔者在阅读golang runtime/pprof源码的基础上,借鉴了Linux perf工具的工作原理,说明一下。

Golang pprof默认会以100Hz(1秒100次)的频率,采集各个goroutine调用栈。假设函数foo在采样时,位于调用栈栈顶,则可以认为当前goroutine在执行foo函数,假如100次采样,foo函数30次位于调用栈栈顶,则可以认为foo函数执行消耗30%。了解了基本原理,下面我们便可以借助工具进行分析。

Golang模块开启Profiling

Golang官方提供强大的runtime/pprof包,用于Golang程序的Profiling。runtime/pprof包功能强大,但对于需长久运行的服务,不够方便。在生产环境中,建议开启http pprof,通过Web服务提供Profiling数据,方便直接使用浏览器查看或其它分析工具拉取数据进行进一步分析。

1
2
3
import (
_ "net/http/pprof"
)

net/http/pprofinit初始化函数会在默认HTTP Server注册几个路由,将runtime/pprof的输出包装为http服务的响应,逻辑比较简单,可以参考阅读net/http/pprof包源码,此处不做赘述。

go tool pprof命令行工具

采用Golang自带的pprof命令行工具,进行CPU性能分析:

1
go tool pprof http://xxxx.baidu.com:2021/debug/pprof/profile?seconds=120

go tool pprof会将服务端http响应数据写入本地文件(本地文件默认存储/root/pprof目录下,输入go tool pprof 即可分析本地文件),运行2min之后,自动进入交互式命令行,使用top命令即可查看CPU耗时排行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(pprof) top
Showing nodes accounting for 18.09s, 35.20% of 51.39s total
Dropped 856 nodes (cum <= 0.26s)
Showing top 10 nodes out of 246
flat flat% sum% cum cum%
3.20s 6.23% 6.23% 3.58s 6.97% syscall.Syscall
2.43s 4.73% 10.96% 10.65s 20.72% runtime.mallocgc
2.06s 4.01% 14.96% 2.10s 4.09% encoding/json.stateInString
2.02s 3.93% 18.89% 3.97s 7.73% runtime.scanobject
1.61s 3.13% 22.03% 4.76s 9.26% encoding/json.checkValid
1.43s 2.78% 24.81% 1.43s 2.78% runtime.usleep
1.39s 2.70% 27.52% 5.18s 10.08% runtime.mapassign_faststr
1.36s 2.65% 30.16% 1.67s 3.25% encoding/json.unquoteBytes
1.30s 2.53% 32.69% 1.45s 2.82% net/url.unescape
1.29s 2.51% 35.20% 1.86s 3.62% encoding/json.(*decodeState).rescanLiteral

标注:

  • flat: 函数(不包含子函数)执行耗时;
  • flat%:函数执行耗时占抽样时间百分比;
  • sum%: 此(包括)之前,flat%之和;
  • cum: 函数(包含调用的子函数)执行耗时;
  • cum%: 函数(包含调用的子函数)的执行耗时占抽样时间百分比;

top命令默认显示前10条数据,按照flat列降序排列。虽然定位了CPU耗时较高的函数,但是粒度较细,并不能直观反应产生性能瓶颈的代码。可以指定-cum参数,显示函数累加执行耗时排行,键入命令top20 -cum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(pprof) top20 -cum
Showing nodes accounting for 0.61s, 1.19% of 51.39s total
Dropped 856 nodes (cum <= 0.26s)
Showing top 20 nodes out of 246
flat flat% sum% cum cum%
0.04s 0.078% 0.078% 41.26s 80.29% net/http.(*conn).serve
0.04s 0.078% 0.16% 38.25s 74.43% github.com/gin-gonic/gin.(*Context).Next
0.03s 0.058% 0.21% 38.24s 74.41% icode.baidu.com/baidu/gdp/gdp.WebHandlerFunc.toGinHandlerFunc.func1
0 0% 0.21% 38.17s 74.28% github.com/gin-gonic/gin.(*Engine).handleHTTPRequest
0 0% 0.21% 38.06s 74.06% github.com/gin-gonic/gin.(*Engine).ServeHTTP
0 0% 0.21% 37.96s 73.87% net/http.serverHandler.ServeHTTP
0.01s 0.019% 0.23% 36.86s 71.73% github.com/gin-gonic/gin.RecoveryWithWriter.func1
0 0% 0.23% 36.86s 71.73% icode.baidu.com/baidu/gdp/gdp.ginHandler2WebHandler.func1
0.07s 0.14% 0.37% 36.80s 71.61% icode.baidu.com/baidu/gdp/gdp.ShoubaiTowerLogware
0 0% 0.37% 32.46s 63.16% icode.baidu.com/baidu/gdp/gdp.recovery
0.01s 0.019% 0.39% 32.45s 63.14% icode.baidu.com/baidu/xxxxxx/xxxxloc/middlewares.Recovery
0.03s 0.058% 0.45% 32.41s 63.07% icode.baidu.com/baidu/xxxxxx/xxxxloc/middlewares.PProfAuth
0 0% 0.45% 31.95s 62.17% icode.baidu.com/baidu/xxxxxx/xxxxloc/middlewares.ParseParams
0.04s 0.078% 0.53% 30.18s 58.73% icode.baidu.com/baidu/xxxxxx/xxxxloc/controllers.(*WeatherController).GetIndexWeather
0.04s 0.078% 0.6% 27.44s 53.40% icode.baidu.com/baidu/xxxxxx/xxxxloc/models/service/page/weather.(*WeatherIndexIphone).GetData
0.01s 0.019% 0.62% 25.48s 49.58% icode.baidu.com/baidu/xxxxxx/xxxxloc/models/service/data/weather.(*DataWeatherCommon).GetWeatherData
0.02s 0.039% 0.66% 21.03s 40.92% encoding/json.Unmarshal
0 0% 0.66% 16.16s 31.45% encoding/json.(*decodeState).unmarshal
0.04s 0.078% 0.74% 16.16s 31.45% encoding/json.(*decodeState).value
0.23s 0.45% 1.19% 16.15s 31.43% encoding/json.(*decodeState).object

由上可以观察到,系统执行流程大概包括了http框架、controller层、page层、data层,符合调用堆栈。encoding/json.Unmarshal函数累积执行耗时占总样本百分比为40.92%**,很明显不合理,是系统性能瓶颈**。go tool pprof命令行工具使用简单方便,无需要借助工具,但是表达不直观,我们可以借助下面提到的两种方式——以图或火焰图的形式。

go tool pprof可视化工具

go tool pprof命令行支持-png、-svg、-pdf等选项,输出png图片、svg图片、pdf文档。go tool pprof此功能依赖graphviz组件。

安装graphviz

graphviz组件依赖较多,建议解决各个linux发行版本的包管理器进行安装,源码安装参考官方:http://www.graphviz.org/download/source/。

1
2
3
4
# debian
apt-get install -y graphviz
# macos
brew install graphviz

导出图片

1
go tool pprof -png http://xxxx.baidu.com:2021/debug/pprof/profile?seconds=120 >> profile.png

火焰图

火焰图(FlameGraph)能直观的反映出系统的执行情况,是一种性能分析利器。Golang语言pprof工具暂不支持导出火焰图,需要安装第三方工具。笔者推荐使用由Uber开源的go-torch。

安装FlameGraph分析工具

安装go-torch:

1
go install github.com/uber/go-torch

安装go-torch依赖——FlameGraph脚本:

1
2
3
4
5
6
7
8
# 下载FlameGraph
wget https://github.com/brendangregg/FlameGraph/archive/master.zip
# 解压
unzip master.zip
# 移动至/opt目录
sudo mv FlameGraph-master /opt/FlameGraph
# 添加至系统Path中
echo 'export PATH=$PATH:/opt/FlameGraph' |sudo tee -a /etc/profile && source /etc/profile

go-torch工具安装成功,运行go torch --help查看帮助信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Usage:
go-torch [options] [binary] <profile source>

pprof Options:
-u, --url= Base URL of your Go program (default: http://localhost:8080)
--suffix= URL path of pprof profile (default: /debug/pprof/profile)
-b, --binaryinput= File path of previously saved binary profile. (binary profile is anything accepted by
https://golang.org/cmd/pprof)
--binaryname= File path of the binary that the binaryinput is for, used for pprof inputs
-t, --seconds= Number of seconds to profile for (default: 30)
--pprofArgs= Extra arguments for pprof

Output Options:
-f, --file= Output file name (must be .svg) (default: torch.svg)
-p, --print Print the generated svg to stdout instead of writing to file
-r, --raw Print the raw call graph output to stdout instead of creating a flame graph; use with Brendan Gregg's flame
graph perl script (see https://github.com/brendangregg/FlameGraph)
--title= Graph title to display in the output file (default: Flame Graph)
--width= Generated graph width (default: 1200)
--hash Colors are keyed by function name hash
--colors= set color palette. choices are: hot (default), mem, io, wakeup, chain, java, js, perl, red, green, blue,
aqua, yellow, purple, orange
--cp Use consistent palette (palette.map)
--reverse Generate stack-reversed flame graph
--inverted icicle graph

Help Options:
-h, --help Show this help message

通常情况下,我们只需要关注-u参数与-f参数即可,运行如下命令进行CPU采样,输出svg格式火焰图:

1
go-torch -u http://xxxx.baidu.com:2021/debug/pprof/profile?seconds=120

go-torch运行输出如下:

1
2
INFO[10:57:29] Run pprof command: go tool pprof -raw -seconds 30 http://xxxx.baidu.com:2021/debug/pprof/profile?seconds=120
INFO[10:58:00] Writing svg to torch.svg

FlameGrpah文件:torch.svg

FlameGraph-火焰图分析

许多人对火焰图的理解有歧义,有些似懂非懂,按照自己的主观意识去解读,导致陷入误区。要使用火焰图进行性能分析,首先需要明确火焰图x轴y轴的确切含义。

y 轴表示调用栈,每一层都标识一个函数,调用栈越深,火焰就越高,顶部就是当前在执行的函数。

x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,表示它被抽到的次数多,执行的时间长(x 轴非时间轴,是所有的调用栈合并后,按函数字母顺序排列的)。因此,火焰图顶部,只要有”平顶”(plateaus),则表示该函数可能存在性能问题

提示:svg格式,当移动鼠标至其中一栏时会显示”Tips”信息,包含采样数、占采样总数百分比等等信息,有关火焰图更详细的资料参考:cpu flame graph

示例火焰图

上图中,可以很明显观察到encoding/json.Unmarshal函数耗费了40%的CPU时间,是系统的性能瓶颈。定位了性能瓶颈之后,我们应当思考如何优化了。

性能优化

json反序列化成为系统性能瓶颈,可以说在情理之内,预期之外。业务角度,我们的Golang模块强依赖的下游服务,返回了一个大JSON(大约几十KB),且字段嵌套层级较深,json反序列化耗时是情理之内的,但是耗费了惊人的40%的CPU时间是预期之外的。

为了解决这个问题,有如下几条思路:

  • 对下游返回JSON”瘦身”,未使用的字段不做解析;
  • 使用LRU Cache,在内存中缓存已反序列化之后的Struct;
  • 使用性能更高的开源json序列化方案;

如何进行性能调优,解决文章中的Case,笔者将会在新的文章中阐述思路,本文不做过多叙述。

联系作者

有更好的”性能调优”方式,也欢迎一块儿交流一下(邮箱:luckydreamcatcher@163.com,微信号:15210466756);

参考资料

Go Pprof

graphviz

cpu flame graph

如何读懂火焰图

FlameGraph安装指南

应用场景

在互联网后台的开发工作中,笔者会经常遇到各种各样的**白名单**业务场景,比如以下典型场景:

  1. 现有1亿个用户user_id,如何快速判断一个user_id是否在该白名单内
  2. 网络爬虫解析出一个页面的url清单,如何快速判断该url是否已经被抓取过
  3. 现有1亿个user_id,如何快速判断哪些user_id曾重复出现
  4. 服务器收到来自某个ip地址的请求,快速判断该ip地址是否在黑名单
  5. ……

熟悉数据结构的读者,略微思考一下,便知以上若干问题的核心需求是:*设计一个内存占用少且又高效的查找算法/数据结构。*** 以场景1为例,大多数读者首先想到的数据结构为*哈希表***,任意元素均可在O(1)时间复杂度内快速完成查找。

假设哈希表的装载因子为0.5(实践中比较常见的取值),粗略计算一下1亿个int类型user_id的内存占用约为745MB,一个白名单要占用如此多的内存空间,这显然是不可接受的。那么我们如何既能达成我们的目的,又占用比较小的内存呢?

一个user_id是否在白名单之内,只可能存在两种取值——是/否,从**香农信息论** 角度来看,使用1个bit即可表示是/否两种取值。一个int类型变量可存储2^32种取值,而当前业务场景下我们仅仅需要01两种状态便可(存储4种状态使用2个bit,存储8种状态使用3个bit,以此类推…)。存储1亿个bit占用空间约为11MB,大大减少了内存占用,这便是Bitmap数据结构。

Bitmap

Bitmap是一种紧凑的数据结构。以场景1为例,首先在内存中连续分配1亿个bit,要判断user_id1000的用户是否在白名单之内,只需获取bit序列的第1000bit的状态(1:user_id在白名单,0:user_id不在白名单)。如下为c语言版本的示例代码(也可查看笔者的github):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>

#define MAXSIZE 1024
#define SHIFT 5
#define MASK 0xffffffff
static unsigned int bitmap[MAXSIZE / (sizeof(unsigned int) * 8) + 1];


void set(int n) {
// 置位操作
bitmap[n >> SHIFT] |= 1 << (n & MASK);
}

void clr(int n) {
// 复位操作
bitmap[n >> SHIFT] &= ~(1 << (n & MASK));
}

int test(int n) {
// 检测是否置位
int i = n >> SHIFT;
if(bitmap[i] & (1 << (n & MASK)))
return 1;
return 0;
}

int main(void) {
int n = 1023;
printf("space: %d\n", sizeof(bitmap) / sizeof(unsigned int));
set(n);
printf("has set flag: %d\n", test(n));
clr(n);
printf("has set flag: %d\n", test(n));
set(n);
printf("has set flag: %d\n", test(n));
clr(n);
}

Bitmap类似于哈希表,哈希规则便是将数字n映射到Bitmapnbit上。因此Bitmap在实际应用中存在一处问题——当n取值特别大时,Bitmap占用空间也会比较大。在此业务场景下,Bitmap数据结构是不合理的,所以便衍生出了Bloom Filter

Bloom Filter

Bloom Filter,中文译名布隆过滤器,是1970年由布隆提出来。布隆过滤器可以用于检索一个元素是否在一个集合中。朴素的讲,BloomFilterBitmap的基础上,将Hash函数的由一个扩展至多个。判断一个元素是否在一个集合中,仅需判断经过这些Hash函数后的值是否置位。布隆过滤器优点是*空间复杂度和时间复杂度*** 都优于一般的算法,缺点是*有一定的误识别率*** ,删除困难。

布隆过滤器

算法原理

假设所选Hash函数在散列空间内分布均匀,即散列到每一个位置的概率相等(对于Hash函数的核心诉求)。假设Bit数组的大小为mkHash函数的个数。

Bit数组中某一位位置在元素插入时的Hash操作中没有被置位1的概率是:

1

kHash函数散列之后该位置仍未被置位1的概率是:

2

连续插入n个元素,该位置仍未被置位1的概率是:

3

对立事件,该位为1的概率为:

4

代码实现

C语言实现请参考笔者Githubbloomfilter.c

参考资料

Bloom Filter Pagers

Bloom Filter

前言

本文所讲Python实现均为CPython,需读者具备一定的C语言阅读能力。本博文参考了**《Python源码剖析》**Python2.7源码。PyListObject采用顺序存储(而非链式存储),熟悉数据结构的读者,能很容易明白本博文所讲内容。

介绍

PyListObjectPython提供的List容器实现,与C++ STL中的vector实现机制相近。PyListObject是变长对象同时也是可变对象(很显然,不同时刻List中可以存在不同数目的元素)。

PyListObject定义如下:

1
2
3
4
5
typedef struct {
PyObject_VAR_HEAD
PyObject **ob_item;
int allocated;
} PyListObject;

PyObject_VAR_HEAD中的ob_sizePyListObject中的allocated字段分别标识了容器的现有*元素个数(size)**容器容量(capacity)***。ob_item为指向PyObject *的指针(即PyObject *数组),是PyListObject实现顺序存储的数组。

实现

1、创建对象

Python提供了唯一创建List的函数——PyList_New。下面是简化的后Python创建PyListObject对象的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#define MAXFREELISTS 80
static PyListObject *free_lists[MAXFREELISTS];
static int num_free_ists = 0;

PyObject *PyList_New(int size) {
PyListObject *op;
size_t nbytes;
// 判断int类型是否溢出,若溢出则返回内存分配失败
nbytes = size * sizeof(PyObject *);
if(nbytes / sizeof(PyObject *) != (size_t)size) {
return PyErr_NoMemory();
}
//
if(num_free_lists) {
// 缓冲池可用,则从缓冲池取一可用List
num_free_lists--;
op = free_lists[num_free_lists];
_Py_NewReference((PyObject *)op);
} else {
// 缓冲池不可用,直接新建对象并为Python中的自动垃圾收集机制做一些工作
op = PyObject_GC_New(PyListObject, &PyList_Type);
}

if(size <= 0) {
op->ob_item = NULL;
} else {
op->ob_item = (PyObject **)PyMem_MALLOC(nbytes);
memset(op->ob_item, 0, nbytes);
}
op->ob_size = size;
op->allocated = size;
return (PyObject *)op;
}

PyListObject对象分为两部分:①PyListObject对象②PyListObject对象容纳的PyObject元素。

2、设置元素

前面提到PyListObject是顺序存储,可以**随机访问**。通过下标设置List中元素值,是由PyList_SetItem函数实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int PyList_SetItem(register PyObject *op, register int i, register PyObject *new_item) {
// 保存指向旧元素的指针,用于减少引用计数
register PyObject *olditem;
register PyObject **p;
// 检查索引值得合法性
if(i < 0 || i>= (PyListObject)op->ob_size) {
// 报索引错误
return -1;
}
// 设置元素
p = ((PyListObject*)op)->ob_item + i;
olditem = *p;
Py_XDECREF(olditem);
return 0;
}

3、插入元素

了解顺序存储的读者,很容易想到新元素的插入会导致元素的移动。PyListObject的实现也不例外,而这其中又牵扯了PyListObject.ob_item的*扩容**缩容***(参考Redis或者其它若干软件的实现,都会有类似机制)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int PyList_Insert(PyObject *op, Py_ssize_t where, PyObject *new_item) {
return insl((PyListObject *)op, where, newitem);
}

static int insl(PyListObject *self, Py_ssize_t where, PyObject *v) {
Py_ssize_t i, n = self->ob_size;
PyObject **items;
// 调整列表容量
if(list_resize(self, n+1) == -1)
return -1;
// 确定插入点
if(where < 0) {
// 负数索引
where += n;
if(where < 0)
where = 0;
}
if(where > n)
where = n;
// 插入元素
items = self->ob_item;
for(i = n; --i >= where; )
// 从后往前将元素后移一个单位,空出新元素存储单元
item[i+1] = item[i]
// 使用宏Py_INCREF增加元素v的引用计数
Py_INCREF(v);
item[where] = v;
return 0;
}

其中函数list_resizePyListObject对象*扩容**缩容***的关键。list_resize函数的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int list_resize(PyObjectList *self, int newsize) {
PyObject **items;
size_t new_allocated;
int allocated = self->allocated;
// 不需要申请内存
if(allocated >= newsize && newsize >= (allocated >> 1)) {
self->ob_size = newsize;
return 0;
}
// 计算重新申请内存的大小
new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6) + newsize;
if(newsize == 0)
new_allocated = 0;
// 扩展列表
items = self->ob_items;
// 最终调用c语言的realloc
PyMem_RESIZE(item, PyObject *, new_allocated);
self->ob_itme = items;
self->ob_size = newsize;
self->allocated = new_allocated;
return 0;
}

List新的元素个数newsize,满足条件:allocated/2 <= newsize <= allocated时,不需要进行realloc。当newsize >= allocated时,PyObjectList会进行*扩容**操作,当newsize < allocated/2PyObjectList会进行缩容***操作。

对象池

CPython为了解决频繁创建对象带来的性能问题(大多数对性能要求较高的C程序均采用类似机制),采用了大量的对象池技术——PyListObject的实现也不例外。如果读者对此类技术不熟悉,请参阅**对象池**设计模式。

在如上PyList_New函数的实现代码中,free_lists指针数组便是用于PyListObject创建的对象池。我们可以看到如果存在可用的PyListObjectPython便会从对象池中取出并返回一个PyListObject对象。那么PyListObject对象是**何时、如何**归还给对象池的呢?答案就在销毁PyListObjectlist_dealloc函数里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void list_dealloc(PyListObject *op) {
int i;
if(op->ob_item != NULL) {
i = op->ob_size;
while(--i >= 0) {
Py_XDECREF(op->ob_item[i]);
}
PyMem_FREE(op->ob_item);
}
// 释放PyListObject自身
if(num_free_lists < MAXFREELISTS && PyList_CheckExact(op))
free_lists[num_free_lists++] = op;
else
op->ob_type->tp_free((PyObject *)op);
}

吐槽

作为一名学渣,每次回头去翻看一下大学课程的基础知识,总会有不同的感受。笔者也总想着把自己工作中领悟的做归纳。关于查找算法,思想大概可以归类为三类(大神请绕路):

  • 顺序查找
  • 二分查找(插入查找、斐波拉切查找…)
  • 哈希查找

顺序查找是我们常用的遍历。在对性能要求比较高的业务场景下,我们便需要考虑其他更好的实现方式了(例如:为了避免全表扫描,数据库通过B+ Tree索引提高查找效率)。哈希查找,时间复杂度为O(1),是一种常见且应用广泛的查找算法。本文将在剩余篇幅对二分查找法进行吐槽。

思考

咦?今天我们讨论的不是SkipList吗,为什么会谈到二分查找法,接下来笔者将阐述一下原因。在实际工程应用中,算法与数据结构是相辅相成的,相互依存,相互影响的, 没有数据结构支撑的算法只能是空中阁楼。接下来,我们思考尝试为二分查找(或类似思想)寻找一个适合的**数据结构**

通常会从CRUD(即增、删、改、查)四个角度,结合具体应用场景去衡量一个数据结构的适用性。我们知道数据的存储方式分为两种:①顺序存储②链式存储。顺序存储中,有序列表的元素在内存中紧紧相连,可以**随机访问**(直接用下标访问,时间复杂度O(1)),能用二分查找法快速定位节点。但是顺序存储对增、删操作的处理比较费力(当删除列表中一个元素时,列表应当将该元素后面的元素前移,填补空的节点,同样增加元素时亦是如此)。

顺序存储不适用于增 、删操作频繁的应用场景,那么我们考虑一下*链式存储**链表*能很好的处理增、删频繁的场景。但是链表一般**顺序访问(即读取第一个元素后才可以读取第二个元素,以此类推),显然传统的链表数据结构无法应用二分的思想进行快速查找。

聪明的人们结合二叉树,发明了**二叉查找树**———既可以二分查找,又能够快速添加、删除元素的数据结构。这正是我们期望的能够应用二分查找的完美数据结构吗?很遗憾,并不是。二叉查找树在最坏情况下可能变成一个链表。于是,在二分查找树的基础上,就出现了AVL平衡树。AVL树在增、删节点时,为了保持树的平衡,会进行左旋,右旋操作,增加了增、删操作的复杂度。于是乎根据人们在发明了B-TreeB+ Tree红黑树等。但是AVL树实现起来比较复杂,平衡操作较难理解。

所以便有了SkipList

实现

百度搜索网上一些SkipList的实现,代码多多少少存在一些瑕疵。笔者根据自己对SkipList的理解,结合网上的一些实现,整理出了一份C语言版本的SkipList实现。读者可以参阅笔者的GitHub,源文件:https://github.com/keepalive555/study/blob/master/skiplist/skiplist.c

其中SkipList新建Node节点,随机获取节点level值的random_level函数(源码如下所示),是笔者摘抄自Redis源码。*该函数是保证SkipListCRUD操作时间复杂度为**O(logN)的核心所在***。

1
2
3
4
5
6
7
8
9
#define MAX_LEVEL 32
#define P 0.25

int random_level(void) {
int level = 1;
while ((random() & 0xFFFF) < (P * 0xFFFF))
level += 1;
return (level < MAX_LEVEL) ? level : MAX_LEVEL;
}

前言

分布式,很多初学者对这个词的第一印象——高大上技术范儿。抛开技术细节不谈,纵观后台技术的发展,存在着普遍适用的规律,一项新技术的诞生,总是解决一些现有架构无法解决的问题。如果读者凭空去学习分布式,便容易坠入云里雾里。本文作为笔者自己学习的一个梳理,以实际问题出发阐述了笔者对Raft协议的理解。本文并不对Raft协议的实现机制做详细的描述,只是从一个新手解决问题的角度去阐述Raft协议做了些什么,不正确的地方请读者指正(邮箱:dreamcatchwang1991@gmail.com)。

思考

以经典单数据库实例架构(这也是很多企业级应用的典型架构)为例,所有的业务数据均存储于单机数据库,当数据库实例Crash了以后,业务便受到影响,在大多数情况下,这种Crash对企业业务的影响是可控范的。然而在互联网应用中,哪怕是一分钟的Crash对企业来说也是致命的,比如前段时间,美团的外卖系统出现崩溃,整个服务停摆几个小时,造成大量用户流失到饿了么平台。

笔者尝试根据自己的经验去解决该问题,为了让单机数据库实例在Crash了以后,整个系统仍然保持可用,我们很容易想到的一个策略——冗余(比如你在单位请假了需要有人代替你继续工作而不影响业务)。我们增加了一台数据库实例B(原来的数据库实例用A表示),在实例A挂掉了之后,我们期望B可以代替A继续提供服务,*所以BA必须具备一样的数据**,在分布式里面这个称作一致性*Raft协议为**分布式一致性协议的一种实现,主要目标就是解决上述这类问题。

脱离现有的MySQLRedisKafka等高可用方案(因为这些系统为了性能而做出一些折中),我们根据自己的诉求,去设计一个高可用的存储系统,需要注意哪些问题呢?假设我们的存储系统有A,B,C等3个节点用来保持高可用,那我们该怎么保持A,B,C3个节点内数据的一致性呢?

  • 一致性由客户端保证还是服务端保证
  • 如何保证A,B,C或更多节点的数据一致性

首先分析第一个问题,假设一致性工作是由客户端保证的(客户端向A写入数据的同时向BC写入数据,为保证A,B,C的一致性,需A,B,C3个节点全部写入成功,客户端回才判定写入成功),我们可能会遇到如下情况:

  • B下线了一段时间又重新上线,因为客户端未保存B处于下线状态这段时间的数据,所以B中就会缺失这部分数据,因而B中数据会与AC中数据不一致。
  • 客户端向AC中写入数据成功,但向B中写入数据失败,这次写入应当被认定为失败(因为A,B,C中数据不一致,也无法通过其他途径达到一致),我们期望整个系统可以表现的犹如一个**事务**,要么全部成功,要么全部失败回滚修改,客户端无法提供这种机制。

综上,**由客户端保证数据的一致性是不可取的**。 

我们将一致性保证工作放在服务端实现,那么我们如何保证A,B,C三节点数据的一致性呢?首先我们思考一个问题,**我们无法预知A,B,C三个节点中哪个节点会意外挂掉,所以客户端不应该至同单一节点建立联系**,也就是说——A,B,C3个节点对外应当表现为一个整体,也就是集群Cluster。那么客户端该如何向A,B,C组成的集群写入数据?以下是笔者想到的实现方式:

  • 所有客户端均向A,B,C中某一节点(比如A)写入数据,由该节点将数据拷贝至其它节点以达到一致性。
  • 向建立连接的节点写入数据,比如客户端1A建立连接,客户端1A写入数据,客户端2B建立连接,客户端2B写入数据,以此类推。

读者是否觉得以上两种实现方式似曾相识——这和*并发编程**下的并发更改共享变量问题相似,由经验我们可知,我们最好是将对共享的操作串行,有序的***执行。同样,如果多个客户端通过多个节点向集群写入数据,为了达到每个节点都有一份完整数据的目的,多个节点间会进行通讯,数据合并,而这其中又牵扯了数据的顺序等许多问题,工程实现起来比较复杂。
当然不是说不可以,笔者没见过这么做的~ ~)

方式一为目前流行的一致性解决思路,Raft协议采用了该思路,Raft协议解决了方式一面临的两大问题:

  • 集群启动(或者写入节点下线)时,如何选举出一个节点作为写入节点
  • 写入节点如何与其它节点通讯,复制数据,保持数据在各节点的一致性

以上两大问题便是Raft协议的两大功能:

  • Leader Election
  • Log Replication

分布式中任何环节都是不可靠的,实际问题比本人论述的复杂的多,但明确了上述问题,再去研究Raft Paper时,读者便可以快速掌握Raft协议。

建议大家观看Raft协议动画,简单明了生动:http://thesecretlivesofdata.com/raft/

参考

[1] Raft Pager

前言

学习工作多年,逐渐悟得一名优秀的程序员应有的态度———**优雅的解决问题**。简而言之,解决问题并不是最终解,如何求得该问题的最优解才是一名优秀程序员应该考虑的问题。360同事对待问题的态度让我这种感觉愈发强烈。

思考

最近笔者在考虑如下一个需求:

现有一集团,内部网络划分为N多个子网(N>100),该N多个子网网段互相之间不交叉。有一批告警数据,该数据里携带了产生告警事件的设备IPv4地址,现笔者需要根据设备IPv4地址,将这些告警数据按子网网段分类。

以上问题可抽象为:

问题Q:存在区间[1, 100],该区间是由[1, 10], [11, 20], [21, 30]…[91, 100]等子区间组成,现给定一个正整数N(1 <= N <= 100),求解N落在那个子区间。

注解:将N多个子网网段用区间(由计算机网络可知IPv4地址为32位无符号整数)的形式表现,比如子网网段10.95.12.0/24表示的IPv4地址范围为:10.95.12.0 ~ 10.95.12.255(即:0xa5f0c00~0xaf0cff),其他网段类推,由此可见该需求属于我们问题Q的同一类问题。

我们尝试去解决问题Q,首先比较容易想到,也是实现比较简单的便是**遍历**[1, 10], [11, 20]…[91, 100]等所有子区间,用N与子区间的左右端点作比较,确定N所在的子区间。显而易见,该方法简单明了,时间复杂度为O(n)。

子区间数目越多,遍历一次花费的代价就越大,在海量数据的处理中,这显然是不可忍受的。我们观察到子区间[1, 10], [11, 20], [21, 30]…[91, 100]是连续的,于是我们自然而然的想到了二分查找与二叉搜索树,不同的是以前我们接触的大多是单个节点的查找,现在是范围(即:子区间)的查找,由此今天的主角便登场了——线段树(又名区间树)。

定义

Tornado IOLoop简述

前言

笔者信奉这样一种哲学——“把书从薄读厚,然后从厚读薄”,Tornado源码犹如一部文学作品,汇集了众多优秀Python工程师的智慧结晶,奇思妙想让人拍手连连。一本好书每读一次,都有不同的感受,代码也是如此。为了能够在以后的工作学习中时时回顾品味一下,笔者决定将笔者对Tornado的理解以图记录下来。

在这里笔者推荐一款强大的在线绘图软件:https://www.draw.io/,想要Visio的专业,却不喜欢Visio笨重的读者绝对会让你好用到Cry

IOLoop图示

注意:IO多路复用技术不了解的同学,可以先了解一下 阻塞/非阻塞,同步/异步,select,epoll 等概念。

笔者注意到,任何语言的事件循环(比如:libevnodejs,…),最核心的Feature是相同的,不一样的只不过是实现方式,抽象层次不同,笔者将这些核心Feature总结如下:

  • 文件IO事件(比如:socketpipeREADWRITEHUP事件 …)
  • 系统信号(比如:SIGINT,SIGHUP…)
  • 定时器

Tornado IOLoop的实现也不例外,如下图所示:

IOLoop示意图

IOLoop代码随笔

以下是笔者对IOLoop核心方法start的源码注解,可以用于结合图示,加深理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def start(self):
if self._running:
# IOLoop已经启动。
raise RuntimeError("IOLoop is already running")
self._setup_logging() # 开始安装日志模块。
if self._stopped:
self._stopped = False # 如果已经停止,则直接返回。
return
# 获取当前线程的IOLoop实例。
old_current = getattr(IOLoop._current, "instance", None)
IOLoop._current.instance = self # 将当前IOLoop实例置为self。
self._thread_ident = thread.get_ident() # 线程id。
self._running = True

# signal.set_wakeup_fd closes a race condition in event loops:
# a signal may arrive at the beginning of select/poll/etc
# before it goes into its interruptible sleep, so the signal
# will be consumed without waking the select. The solution is
# for the (C, synchronous) signal handler to write to a pipe,
# which will then be seen by select.
#
# In python's signal handling semantics, this only matters on the
# main thread (fortunately, set_wakeup_fd only works on the main
# thread and will raise a ValueError otherwise).
#
# If someone has already set a wakeup fd, we don't want to
# disturb it. This is an issue for twisted, which does its
# SIGCHLD processing in response to its own wakeup fd being
# written to. As long as the wakeup fd is registered on the IOLoop,
# the loop will still wake up and everything should work.

# wakeup_fd是用来唤醒主事件循环(信号唤醒,或者从别的线程唤醒主线程)。
old_wakeup_fd = None
if hasattr(signal, 'set_wakeup_fd') and os.name == 'posix':
# requires python 2.6+, unix. set_wakeup_fd exists but crashes
# the python process on windows.

# Python2.6版本以上,Unix-like系统中,signal模块支持set_wakeup_fd方法。
# Windows上siganl存在该方法,但是会crash。
try:
old_wakeup_fd = signal.set_wakeup_fd(self._waker.write_fileno())
if old_wakeup_fd != -1:
# Already set, restore previous value. This is a little racy,
# but there's no clean get_wakeup_fd and in real use the
# IOLoop is just started once at the beginning.
signal.set_wakeup_fd(old_wakeup_fd)
old_wakeup_fd = None
except ValueError:
# Non-main thread, or the previous value of wakeup_fd
# is no longer valid.
# 参考signal的官方手册,set_wakeup_fd仅可在主线程中调用。
old_wakeup_fd = None

try:
while True:
# Prevent IO event starvation by delaying new callbacks
# to the next iteration of the event loop.
# ncallbacks记录了此次循环的回调函数个数,新增加的回调函数将要在下次循环被调用。
ncallbacks = len(self._callbacks)

# Add any timeouts that have come due to the callback list.
# Do not run anything until we have determined which ones
# are ready, so timeouts that call add_timeout cannot
# schedule anything in this iteration.
due_timeouts = [] # 即将超时的任务。
if self._timeouts:
now = self.time()
while self._timeouts:
if self._timeouts[0].callback is None:
# The timeout was cancelled. Note that the
# cancellation check is repeated below for timeouts
# that are cancelled by another timeout or callback.
heapq.heappop(self._timeouts)
self._cancellations -= 1
elif self._timeouts[0].deadline <= now:
due_timeouts.append(heapq.heappop(self._timeouts))
else:
break
if (self._cancellations > 512 and
self._cancellations > (len(self._timeouts) >> 1)):
# Clean up the timeout queue when it gets large and it's
# more than half cancellations.
# 如果定时任务取消数量大于512,并且超过总定时任务的半数,则清理self._timeouts,并重新平衡堆。
self._cancellations = 0
self._timeouts = [x for x in self._timeouts
if x.callback is not None]
heapq.heapify(self._timeouts)

for i in range(ncallbacks):
# 执行回调函数。
self._run_callback(self._callbacks.popleft())
for timeout in due_timeouts:
# 执行定时任务。
if timeout.callback is not None:
self._run_callback(timeout.callback)
# Closures may be holding on to a lot of memory, so allow
# them to be freed before we go into our poll wait.
due_timeouts = timeout = None # 防止内存泄漏

if self._callbacks:
# If any callbacks or timeouts called add_callback,
# we don't want to wait in poll() before we run them.
# 如果发现新增的_callbacks,(回调函数执行时加入了新的回调函数)。
poll_timeout = 0.0
elif self._timeouts:
# If there are any timeouts, schedule the first one.
# Use self.time() instead of 'now' to account for time
# spent running callbacks.
poll_timeout = self._timeouts[0].deadline - self.time() # 距离将来最近一次定时任务的时间,wait该时间。
poll_timeout = max(0, min(poll_timeout, _POLL_TIMEOUT))
else:
# No timeouts and no callbacks, so use the default.
# 未发现新的回调函数与定时任务,则调用poll,等待IO事件,超时事件为3600秒。
poll_timeout = _POLL_TIMEOUT

if not self._running:
# 如果回调函数中有调用stop的则,跳出事件循环。
break

if self._blocking_signal_threshold is not None:
# clear alarm so it doesn't fire while poll is waiting for
# events.
signal.setitimer(signal.ITIMER_REAL, 0, 0)

try:
# 等待IO事件,events_pairs内容为:[(fd, events), (fd, events), ]
event_pairs = self._impl.poll(poll_timeout)
except Exception as e:
# Depending on python version and IOLoop implementation,
# different exception types may be thrown and there are
# two ways EINTR might be signaled:
# * e.errno == errno.EINTR
# * e.args is like (errno.EINTR, 'Interrupted system call')

# poll陷入内核态以后,进程捕获到的信号会导致poll wait结束,并且错误码为EINTR。
if errno_from_exception(e) == errno.EINTR:
continue
else:
raise

if self._blocking_signal_threshold is not None:
signal.setitimer(signal.ITIMER_REAL,
self._blocking_signal_threshold, 0)

# Pop one fd at a time from the set of pending fds and run
# its handler. Since that handler may perform actions on
# other file descriptors, there may be reentrant calls to
# this IOLoop that modify self._events
self._events.update(event_pairs)
while self._events:
fd, events = self._events.popitem()
try:
# 获取file-like object,与IO事件的处理函数handler。
fd_obj, handler_func = self._handlers[fd]
# 调用handler,处理fd_obj上发生的events事件,
# handler_func在add_handler时候,加入了对事件处理的wraps。
handler_func(fd_obj, events)
except (OSError, IOError) as e:
if errno_from_exception(e) == errno.EPIPE:
# Happens when the client closes the connection
# 客户端关闭了同服务器的连接。
pass
else:
# 处理异常。
self.handle_callback_exception(self._handlers.get(fd))
except Exception:
self.handle_callback_exception(self._handlers.get(fd))
fd_obj = handler_func = None

finally:
# reset the stopped flag so another start/stop pair can be issued
self._stopped = False
if self._blocking_signal_threshold is not None:
signal.setitimer(signal.ITIMER_REAL, 0, 0)
# 还原前一个IOLoop实例(作者也说了这种情况基本没有...)
IOLoop._current.instance = old_current
if old_wakeup_fd is not None:
signal.set_wakeup_fd(old_wakeup_fd)

Python2.7内存回收机制(一)

写在前面

Python的内存回收采用引用计数机制。引用计数是一种简单而广泛使用的资源回收机制,例如Linux平台的文件描述符Windows平台下的内核对象等均采用引用计数的方式进行管理。本文将结合Python2.7官方手册阐明Python2.7引用计数机制(如无特殊说明,本文所提及的Python均为CPython)。

Python语言在设计之初就是一门面向对象的语言,即一切皆为对象,在Python官方介绍通用对象结构Common Object Structures一文中,有这样一段话:

All Python objects ultimately share a small number of fields at the beginning of the object’s representation in memory. These are represented by thePyObject and PyVarObject types, which are defined, in turn, by the expansions of some macros also used, whether directly or indirectly, in the definition of all other Python objects.

简单来说,Python中所有对象的开始位置,都有一组成员变量,以C语言的两个自定义类型——PyObjectPyVarObject来表示(有兴趣的,可以通读Common Object Structures一文,或者参考《Python源代码剖析》一书,这里不做具体阐述),PyObjectPyVarObject的开始位置都有一组相同的成员变量(分别由宏PyObject_HEADPyObject_HEAD扩展):

1
2
Py_ssize_t ob_refcnt;  // 对象引用计数
PyTypeObject *ob_type; // 指向对象类型结构体的指针(与本文无关不做具体阐述)

由此可见,在CPython实现中,每个Python对象均有一个名为ob_refcnt的成员变量用于标识该对象的引用计数。而众所周知,在Python的实现中,变量只是保存了一个对象的引用(即指针),而非对象本身,所以每当一个Python对象被一个不同变量所引用时,对象的引用计数就会+1,相反当变量不再引用该对象时,该对象的引用计数就会-1,当对象的引用计数变为0时,该对象就会在未来的某个时间被Python的垃圾回收器所回收。

通过Pythonsys模块的getrefcount方法可以获取Python对象的当前引用计数:

1
2
3
4
5
6
7
8
9
10
11
# -*- coding: utf-8 -*-

import sys


class A(object):
pass

a = A()
b = c = a
print(sys.getrefcount(a))
 示例运行结果为4,比实际引用数量(a, b, c)多1,那么为什么会这样呢,`sys.getrefcount`的文档作出了说明:

 >Return the reference count of the *object*. The count returned is generally one higher than you might expect, because it includes the (temporary) reference as an argument to [`getrefcount()`](https://docs.python.org/2.7/library/sys.html#sys.getrefcount).

 把对象当做参数调用`sys.getrefcount`方法会增加对象的一个临时引用计数。