在并发编程里面,锁是一个逃不开的东西,对于很多多线程共享的资源,使用锁是一个非常的好的办法,来支持对这些资源安全的访问。但锁虽然方便,用不好就容易出现问题。
首先大家需要知道,对锁单纯的进行 Lock 和 Unlock 操作其实是很快的,但如果涉及到了 Lock Contention,那么性能就会有较大的影响了。
Rust Prometheus Metric Vector
最开始注意到 Lock Contention 问题应该是在我们使用 Rust Prometheus 库的时候,因为当时是按照 Go 的 official library 来设计的,所以对 Metric Vector 类型的我们也是直接一个读写锁来保证里面共享的 HashMap 的安全性。
因为库的细节很多同学不会关心,他们会自然的认为 Prometheus 的 API 设计的比较高效,所以在一些频繁需要 metrics 的地方,他们直接调用了 Prometheus 的 API,如果只是单线程还好,但悲催的是,很多逻辑是会涉及到多线程操作的,然后我们就发现性能下降非常的严重。譬如这个 PR 就是发现了这样的问题才临时修改的。
为了解决 Prometheus Metric Vector 的 Lock Contention 问题,我们现在临时的解决办法就是使用 Local Metrics,也就是先线程自己去记录 metrics,然后定期的 flush 到全局的 Prometheus metrics 里面,这样整体性能就好了很多,但其实这种做法很丑陋。后面我们准备参考 RocksDB 的做法,使用 Per CPU 的 metrics 方案,也就是每个 CPU 自己去统计 metrics。无论是不是多线程,同时一个线程的时间片就只可能在一个 CPU 上面,所以相关的 metrics 自然可以跟这个 CPU 关联,详细可以参考 Core-local Statistics 这篇文章。
虽然我之前说单纯的进行 Lock 和 Unlock 很快,但在一些特别高频的地方,还是会有影响,所以对于一些非常快速的操作,我们后面开始使用 SpinLock。
Worker Thread Pool
上面说了 Prometheus Metrics 遇到的坑,再来说说最近遇到的第二个问题。大家知道,我们通常会写一个 worker thread pool 来并发的处理我们的任务,而通常的一个 thread pool 的实现方式,就是使用 mutex + condition var 了,这个实现的代码实在的太多了,大家直接 Google 吧,这里就不详细说明了。
在 TiKV 里面,我们也使用 mutex + condition var 来做了一个 thread pool,然后这周我在跟另一个线程调度问题,跟葱头讨论的时候,突然想 benchmark 下我们的 thread pool 的极限性能,先写了一个简单的测试,测试很简单,就是往一个 channel 里面发数据,然后还有一个独立的线程不停的收,每一秒统计收到的数据个数。
然后我开始 benchmark,悲催的发现,QPS 不到 200 K,也就是 TiKV 现在极限情况下面单个节点最多能处理 200 K 的请求,虽然还不错,但我总觉得可以在高一点。因为我们是 8 个 worker thread。如果调整成 4 个,性能会更好,这个是正常的,因为对于这种几乎没啥消耗的 task,CPU 执行起来非常的快,这时候反倒是多个线程的 context switch 会影响到整体性能,关于线程调度这块,以后我详细研究了在整理出来,这里先不讨论了。
然后我就想,仍然是 8 个 worker thread,如何还能性能提升?而且在测试的使用,使用 top -H
观察,会明显发现 CPU 打不满,而我的测试机器是 40 Core,不存在性能问题。我只能将我的目光锁定到了 mutex 的 Lock Contention 上面,因为在我们的实现中,给 task queue 添加任务以及从 worker thread 获取任务,都是在 mutex 里面进行的,也就是每次操作都会极大的概率遇到 Lock Contention。
于是我先做了第一个优化,将 task queue 从 mutex 里面移出来,使用 SpinLock,测试发现 QPS 上升到 230 K,然后用 perf record 两次测试,diff 对比:
# Event 'cycles:ppp'
#
# Baseline Delta Shared Object Symbol
# ........ ....... ................... ...................................................................................
#
8.40% +7.58% [kernel.vmlinux] [k] _raw_spin_lock
3.29% -0.17% [kernel.vmlinux] [k] __schedule
1.02% +0.09% [kernel.vmlinux] [k] futex_wait_setup
0.96% -0.06% [kernel.vmlinux] [k] finish_task_switch
发现之前的测试比 SpinLock 的多了 7.5% 的 _raw_spin_lock
开销,这个当然不是我们自己实现的 SpinLock,而是内核 Mutex 里面的。
但即使这样,我仍然发现,worker thread 的 CPU 跑不满,怀疑是 worker 线程取任务太快,而 Producer 线程没法那么快的把 task 加到队列里面,于是 worker 线程因为没有获取到任务,只能重新进入 condition wait 状态。于是我暴力的改了一下获取 task 的代码,循环 10 次,如果没取到任务,就调用 thread 的 yield 函数让出时间片,下一轮继续尝试。然后这次 QPS 涨了不少,直接到了 300 K+ 了,当然 CPU 也高了不少了。
但这个测试只是能评估我们极限性能,好指导我们后续的优化。譬如现在我就知道我们最多不到 200 K 的 QPS,如果还是这个模型,其它地方再怎么优化,也还是这么多。
说到这里,在聊一件性能测试的时候遇到的奇怪的事情,我在使用 perf stat 分析性能的时候,发现使用 SpinLock 的版本 QPS 能到 400 K,但之前的版本仍然不到 200 K。这个结果实话当时第一眼看到,是非常的奇怪的,后来思考了一下,因为 perf stat 其实是会在一些内核的地方进行 probe 的,这个会影响性能,可能因为这样,导致 worker thread 那边的 condition wait 以及调度变慢了,结果 Producer 那边扔进去更多的任务,使得 worker 后面一直能取到任务,不会进入 mutex 和 condition wait 了。而对于我们之前的版本来说,无论怎样,都要通过 mutex 来进行 task 操作,自然就不会出现 SpinLock 这样的情况。对于这个问题,这只是我的猜测,我也跟其他对 Linux 系统很精通的同学讨论过这个问题,大家都觉得这个解释比较合理,只是后面我也懒得去追下去了,等后面有空了,重新折腾调度的时候再看看吧。
Perf script
在测试的时候,我使用 perf trace 或者 perf record 大概知道了不同方案 mutex 的调用情况,但其实我最想知道的是到底出现了多少次 Lock Contention,因为我怀疑就是因为这个导致的性能差异。
虽然能通过 systemtap 来搞定这个问题,但我不知道为啥头脑抽风了,想用 perf 来搞定。开始想的是直接 perf stat 的时候传入相应的 event 来做,但我在 perf list 里面没找到对应的 event,Google 了下也没啥好的结果,这个其实还是我对底层了解的不够,真熟练了直接源码一翻就知道了。但幸运的是,我突然发现了 perf script,之前这个玩意我只是在生成火焰图的时候见过,但自己并没有用过。昨天才知道,它其实是一个非常强大的东西,也就是说,我们可以写自己的 script 来分析 perf 采样的数据,而使用的 script language 并不需要像 systemtap 那样重新学一门新的,就是 perl 或者 python,当然 perl 就算了,python 虽然我不喜欢,但至少之前还写过 2 年,完全够用了。具体的 perf script 的东西,后面有时间可以在详细的说明。
我使用 perf script -l
看现在有啥可用的脚本,幸运的发现了一个 futex-contention
,直接使用 perf script report futex-contention
得到了如下的输出:
test[132777] lock 7faaf8a220c0 contended 199 times, 132761 avg ns
test[132782] lock 7faaf8a22090 contended 1171 times, 519948 avg ns
test[132778] lock 7faaf8a22090 contended 1145 times, 2004888 avg ns
test[132778] lock 7faaf8a220c0 contended 226 times, 1694905 avg ns
test[132779] lock 7faaf8a22090 contended 1199 times, 2745749 avg ns
然后再把这个结果贴到 Excel 里面,对 times 汇总,确定了 SpinLock 的版本比原来的版本少了将近 15% 的 Lock Contention。
总结
其实上面折腾了这么多,看起来没折腾出啥东西,无非就是知道了 Lock Contention 对性能会有影响,但对我来说,更大的收获在于在排查问题的时候,对系统理解更加深入,对 perf 这些工具用起来更加熟练了。
TiKV 开发到现在,很多调优工作其实已经涉及到了跟系统和硬件的结合上面,之前我们还能假设更底层对我们是一个黑盒,但现在为了更好的性能,需要我们对更下面抽丝剥茧,开始深入理解和掌握了。如果你对 Linux 非常感兴趣,想折腾下 DPDK 等跟硬件更紧密的优化开发,欢迎给我发邮件,我的邮箱 tl@pingcap.com。