从muduo源码里面魔改的一个Logger,优化了一些性能,主要用来自己学习
在陈硕老师muduo的那本书上面看到它的日志库,比较感兴趣。这个东西又本身比较简单,容易入手,所以就下载下来魔改了一下,尝试进行优化,因此里面肯定有很多muduo原来的代码。这里不讲muduo原来的一些技巧,比如四缓冲具体怎么切换, 怎么用一个临时对象达到 << 的方式在多线程的情况下写日志等。
原始的版本参见陈硕老师的那本书,主要是使用了一个mutex 和四个Buffer,用uniquePtr持有Buffer, 用RAII的手法持有mutex和conditionVariable, 使得临界区尽量可能短。 用一个队列存储写慢或者超时的Buffer,然后单独启动一个后端线程去写磁盘,保证磁盘写入的速度 由于有四个Buffer,在正常情况下,性能已经足够了,测试下单线程全力去抛(macpro 16G内存)可以写入到220M/S的速度(日志大小为110 + 55 = 165字节)
首先从正常使用的角度上来讲,实际没有啥必要去修改了。业务几乎不可能写200M/s的日志,如果这么大批量的写入,说明业务有问题。另外业务写日志的时间可能只有千分之一,所以竞争一般也不会激烈,唯一需要改的是将Buffer换成mmap构建出来的内存,这样如果进程挂了日志文件并不会丢失,而原来的版本是靠在coredump文件里面去找日志的,如果挂的时候没有coredump,那么就彻底丢了。
但是如果实在要改,也有这么几个地方可以考虑改进: 1. 性能越好,换个角度想写入同样数量的日志消耗的CPU就越少,这个是有意义的 2. 原来的方法是使用一个Mutex + 条件变量去做,虽然临界区已经尽量简短了,但是如果线程很多,还是存在性能问题 3. 以上有点吹毛求疵,但是就让我们假设有这么个场景吧
首先用clion自带的profile搞了下火焰图,进行了简单的性能分析,发现有一些地方是可以进行优化的:
写日志的时候会格式化,这个时候需要snprintf很多东西,比如精确到微妙的时间,线程id,代码行数(利用__FILE__, _LINE)等信息,其中时间每次都在变化,调用很多次snprintf特别耗费时间,muduo本身会缓存一s以内的时间,这样只需要格式化后面的即可。最简单的办法就是缓存到毫秒,这样性能应该也ok(1s最多调用1000次)。 本着追求极致的精神,抛弃snprintf,手写了一个转换函数。 首先还是会缓存每s的格式化的结果,然后剩下有6位数,也就是微秒的范围从0到999999,我先写了一个处理0-999到字符串转换的函数,然后调用两次即可。0-999的核心思路就是先计算好,然后缓存到一个char[1000]里面,这里用到了pthread_once的语法,保证相应的init计算只执行一次。然后这里的性能瓶颈就直接消除调了。相同的还有线程id这个是一直不变的东西,也可以第一次计算好之后缓存到__thread变量里面。
第二点就是分桶加锁,因为多线程情况下锁开销比较大。这个有几个问题需要解决: a. 首先是桶的数量不能动态变化,只能一开始就固定下来(可以想想java 的concurrentHashMap也是这样做的) 。 b. 其次每个桶必须对应Buffer和Lock,一个Lock保护一个Buffer,但是后端获取锁的时候必须所有的Buffer都要获取一遍,因为没有记录每一个Buffer对应的超时时间,所以超时就是全部超时,内存里面的都要写入文件里面
c. 保护队列的锁必须是pthread里面的mutex,但是保护桶的buffer的锁可以换成spinlock,因为这个临界区非常的小,spinlock更高效,用一个atomic_bool就可以搞定。而且对buffer加锁是一个超高频率的操作,有必要优化(实际上profile发现这里有一定的性能损失)
d. 桶不是按照线程id取余来分的,用__thread缓存来一个atomic变量, 使用了roundrobin的方式来搞,可以更加公平和均匀。
- 第三点改进就是把四Buffer换成emptyBuffers,这样可以在一开始的时候先预先初始化一些Buffer,并且可以保留所有用完的Buffer,在写入速度很快的情况下可以不用反复的new Buffer。关于这个东西也是需要mutex锁来进行保护的。另外还尝试使用writev减少系统调用,以及对emptyBuffer进行排序减少pageFault的情况,但是profile发现没有明显的提升
实际上上面8线程的时候已经达到800M - 900M/s了,而且内存里面会有大量Buffer堆积,说明此时继续优化意义并不大,同时单个日志写入的延迟也非常低了
为什么还需要第二版本呢, 考虑下面几点: 1. 如果要求所有线程完全按照先后顺序出现在日志里面怎么办? 分bucket的做法只能保证一个Bucket里面的线程是按照顺序出现的
2. 线程如果继续增多,比如在某个64核的机器上面开上1000个worker,或者是自己使用了类似goroutine一样的东西可以开很多个并行运行的单元(虽然我们是在写C++,就假设有这么个并发 & 且一直写日志很多的场景吧。。。), 分桶还是会有性能问题,因为桶不能无限增多,否则Buffer未免也太多了,而且worker数量可能会发生变化。当然可以减少Buffer的大小,增加桶的数量,但是就算这样后端线程获取锁的成本也会增加
一个可能的代替方式是使用一个可以支持并发的环状Buffer,灵感来自于disruptor,这样对于日志的写入我们可以不用锁维护了。只有队列需要加锁,这个可以忽略。我们可以设置一个比较大的Buffer(64M),让所有线程一起用。
- 首先实现了这么一个Buffer,叫做CircularBufferTemplate,将大小变成模版参数,这样Buffer直接定义为 buffer[size],缓存更友好,不需要单独new一个出来。
- 然后就是对原来的并发Buffer进行改进,我们的场景是多写单读的,而且读只在后端线程单独进行。并且读的频率非常低,基于这个考虑,整个Buffer只需要一个原子变量 writeCount即可, readCount只需要是一个普通变量,空间大小可以用 capacity - (write - read)计算出来(这个只能用于读和写不并发的情况,如果要并发无法这么做),所以一次append只有一次CAS操作。但是这里有一个问题,读写不能并发,否则(write-read)不是原子操作,read可能发生变化导致结果计算不准确。另外我们如果不是使用一个Buffer,而是将Buffer放入队列里面这样做(因为Buffer没有办法扩容,如果只有一个Buffer很难控制合适大小)实际上也算是读操作。
- 因此还需要增加一个读写自旋锁,在写buffer的时候加读锁,在读buffer的时候加写锁。另外读锁可以分桶,因为读远远多余写,另外必须是写优先,否则后端线程可能饿死导致Buffer堆积。 同时还需要错开各个桶刷入后端线程的时机(需要依赖当前Buffer的剩余容量去判断,每个线程错开一点),这样避免同时并发的去争抢锁。最后是读的时候发现如果已经有写锁在等待,必须yield线程,否则双方都无法获取到锁,对性能和延迟影响都比较大(以上优化都是经过profile有效果的),此外还尝试了用algin消除伪共享,但是没有啥提升,甚至有轻微负面提升。
- 如果一个Buffer一直写容易触发很多pageFault,所以回收之后需要设置下写入位置到起始点,避免在压力不大的情况下触发pageFault(压力大肯定是直接写满了),另外这样实际占用的物理内存也可以保持在比较低的一个位置。
- 基于以上操作, 还可以继续分桶,每个桶都对应一个读写锁和一个CircularBuffer,桶数目就固定为8-16个,每个Buffer大小可以自行设定,我测试的时候试过4M和16M的。基本上这样操作对于256线程也能跑到380M/s的速度,128线程能跑700M/s,也就是说锁的竞争带来的开销没有那么大的影响了,如果是普通的分桶,除非一开始就设置几十个上百个桶,否则竞争开销很大。
实际上如果在第二版上使用分桶的操作,那么还是会有各个线程写入的先后顺序不一致的问题,这个可以通过把桶的数目设置为1来解决。
在线程数目不大的情况下(8或者以下),采取方案1更简单,而且延迟性能也是最好的,单线程就能写到600+M/s的速度,如果活跃线程数目确实很多, 那么分桶策略不太好设定具体的数目和Buffer的大小,容易占用太多内存或者竞争太激烈。而且过多的桶后端线程要获取这些锁的时候也会延迟较高。这个时候选取方案二是更好的选择,单线程也可以写入到460M - 500M/s,而且线程到200多个性能也还算稳定。
另外其实现在最大的问题是磁盘写入速度不够,如果一直快速写入,容易导致内存爆炸。原来muduo的解决方案是直接丢了的,正常来说可以接入报警了。所以上面这些优化感觉实际意义都不是特别大,非要说只能说有一种日志每次只写很少的字节,但是又有很多的线程在高频的写入,这样磁盘可能不会跟不上,但是前面的写入部分会成为瓶颈。不过很难想象这种场景,所以上面的优化都只是在练手而已~