当前位置: 首页 > 科技观察

多线程异步【日志系统】,高效强大的实现方式:双缓冲!

时间:2023-03-15 17:00:49 科技观察

别人的经历就是我们的阶梯!大家好,我是刀哥。今天为大家讲解技术知识点:【如何在多线程环境下实现高效的日志系统】。很久以前,我写过一篇文章《【最佳实践】生产者和消费者模式中的双缓冲技术》,讨论如何在产品级日志系统中使用双缓冲机制解决生产者消费者相关问题。前段时间有小伙伴私信我,希望详细说一下这个实现方案。本来答应过国庆期间完成的,可是一拖再拖,直到今天,终于补上了这个作业。双缓冲的想法不是我的原创,而是参考了陈硕大师写的一本书《Linux 多线程服务端编程》。从书名可以看出讨论的是服务端编程内容,而且是在多线程的场景下,所以可以隐约看出书中给出的参考代码质量是很高。如果你的主要开发语言是C++,我强烈推荐你学习这本书。对于C++语言的很多细节,作者都给出了自己专业严谨的思考和解决方案。离家更近!在上一篇文章中,我主要从思路和概念的角度讲述了如何使用双缓冲机制。在本文中,我们将忠实于书中的原文,一起来了解作者的思考过程,并给出一些对性能起决定性作用的关键代码。先来看看书中的性能测试结果:单片机常用的环形缓冲区。说到buffer,相信大家一定看过很多关于buffer缓冲区的文章和代码,在单片机中的使用率是非常高的。所谓环形缓冲区就是一个扁平的内存区域,只是把它的尾巴和头部相连。另一个类似的结构:循环队列,本质上是一样的。在维护环形缓冲区的数据结构中,有头指针和尾指针。写的时候,把输入写到尾指针的位置。写完后,增加tail的指针值;阅读时,从头指针的位置开始阅读。读完后,同样递增head值的指针。这种运行方式更适合简单的单输入单输出场景。就处理一下:当head和tail两个指针相遇时怎么处理。但是在x86操作系统中,在多核+多线程的工作环境下,这样的环形缓冲区无论是功能上还是性能上都无法满足需求。以日志系统为例:在一个应用中,多个线程可能同时调用日志系统的writeAPI接口函数,这就需要线程安全。这样的线程称为前台/前端线程。日志数据存入内存后,最终会输出,如:写入文件系统、通过网络上传到服务器、输出到其他监控系统等。也是一个实现输出操作的线程。如果需要写入文件系统,那么在写入期间,这个线程需要一直将日志数据保存在缓冲区中。这样的线程称为后台/后端线程。但是文件系统的写入速度很慢(毕竟要操作硬盘)。如果此时有前台线程需要写入日志信息,如何处理呢?你不能猛烈的说:后台线程正在写现有的日志数据,存储在硬盘上,内存缓冲区已经被占用了。您是前台的下一个线程,所以请先等待!多线程异步日志:双缓冲机制在本书中,作者规定了几个关键的需求,都与实际业务需求相关:线程安全:多个线程可以并发写入日志,不存在竞争,两个线程的日志信息不会出现横向;高吞吐量;日志消息有多个级别,格式可配置等;为了达到这个目的,作者提出了“双缓冲”(DoubleBuffering)的想法。基本思路是:准备两个buffer:A和B;前端负责将数据(日志信息)填充到缓冲区A;后端负责将bufferB的数据写入文件。当bufferA满了,交换A和B,让后端将bufferA的数据写入文件,前端将新的日志信息填充到bufferB,以此类推。其实很好理解,我们画个图来描述一下:当bufferA满了,交换两个buffer:为什么doublebuffer机制会高效的使用两个bufferbuffer?优点是:大多数时候,前台线程和后台线程不会操作同一个buffer,也就是说前台线程的操作不需要等待后台线程的慢写文件操作(因为有不需要锁定临界区)。还有一点就是:后台线程将缓冲区中的日志信息写入文件系统的频率完全由自己的写入策略决定,避免了每次有新的日志信息触发(唤醒)后端日志线程。例如:您可以根据实际使用场景定义一个刷新率,例如:3秒。只要flush时间到了,即使buffer里的日志信息很少,也应该存入文件系统。也就是说,前端线程不会将每条日志信息单独发送给后端线程,而是将多条信息组合成一个大缓冲区发送给后端,相当于批处理,从而降低了线程唤醒的频率,减少了开销。尽可能的减少Lock时间在刚才的描述中,有这样一句话:在【大部分时间】,前台线程和后台线程不会操作同一个buffer。也就是说,它们仍然有可能在一小部分时间内对同一个缓冲区进行操作。即:当当前stage的writebufferbufferA已满,需要与bufferB交换。交换操作由后台线程执行,具体过程为:唤醒后台线程,将buffer此时B缓冲区为空,因为缓冲区B中的数据在上次进入休眠前已经写入文件系统OK;将缓冲液A与缓冲液B交换;将bufferB中的数据写入文件系统;开始睡觉;第二步:交换缓冲区,只是交换两个指针变量的值,使用C++语言中的swap操作,效率很高。在交换buffer的时候,可能会有前台线程写入log,所以这一步需要在Lock状态下执行。可以看出,在这种双缓冲机制的前后台日志系统中,需要加锁的代码只是交换两个缓冲区的动作,加锁时间极短!这是提高吞吐量的关键!参考代码在示例代码中,作者扩展了双缓冲机制,使用4个缓冲区,可以进一步减少或避免前端线程的等待时间。数据结构如下:这里的nextBuffer_是currentBuffer_的“备胎”。当前台线程发现currentBuffer_不可用时(空间已满,或者正在被后台线程操作),可以立即写入这个“备胎”缓冲区,从而减少前台线程的等待时间。以下是前台线程的编写代码:当前端线程产生日志消息时,会调用append()函数。在这个函数中,如果当前缓冲区(currentBuffer_)的剩余空间足够大,则直接复制(追加)消息,这是最常见的情况。如果当前buffer的剩余空间小于本次日志信息的写入长度,则将其移动到buffer_collection(一个Vector)中,此时向后端线程发送唤醒信号,然后nextBuffer_备胎移动将是currentBuffer_。move是C++中的一个操作,意思是move,不是copy/copy。当然,如果前端写入速度太快,两个buffer一下子就用完了,就得分配一个新的buffer作为当前buffer,这种情况很少见。下面我们看一下后端的代码实现。这里只贴出最关键的临界区的代码,也就是上面说的“小部分时间”的情况:这段代码中最重要的是swap函数。交换了后台使用的缓冲区。当前后台缓冲区交换后,留下临界区,后台线程可以慢慢向文件系统写入数据。另外,这段代码还有一个比较有意思的地方,就是对备胎nextBuffer_的操作:当当前阶段使用的备胎nextBuffer_被消耗完后,后台线程会及时补充新的备胎。哪里还可以继续优化在本章的最后部分,作者提出了一种更严格的情况:在异步日志系统中,使用了全局锁。临界区虽然小,但是如果线程数多,也可能会发生锁争用。影响性能。一种解决方案是使用多个存储桶,例如Java的ConCurrentHashMap。前端线程写入日志时,根据线程ID散列到不同的桶中,减少竞争。这个方案本质上是提供了更多的buffer,给不同的thread分配不同的buffer(根据threadid的hash值)。散列到同一个缓冲区的那些线程也有争用,但争用的概率大大降低。本文转载自微信?《IOT物联网小镇》