【最佳实践】生产者和消费者模式中的双缓冲技术
【这篇文章说了啥】
这篇文章主要介绍了在生产者-消费者模式中,生产和消费之间有大量数据需要交互时的一个高效率的解决方案。
【问题引入】
1. 问题场景
在设计模式中,生产者-消费者模式肯定是排在前面位置的,在实际开发过程中,也常常需要使用这个模式。
在讲解设计模式的书籍中,只会从抽象的角度对生产者-消费者模式进行讲解。面对实际的编程问题,还需要具体问题具体分析。
这里给出一个使用场景,你可以停下来思考一下该如何解决问题:
你需要实现一个日志库,每一个线程调用日志库API函数来写入日志信息,所有的日志信息需要持久化到本地的文件系统。
咱们来分析一下,有哪些问题需要解决:
(1)多线程调用日志库的API,所以API函数必须是线程安全的。
(2)日志信息无法预计频率,要考虑波峰和波谷。
(3)日志信息需要持久化到本地文件系统中,涉及到文件操作,而文件的IO操作速度相对于CPU来说是很慢的。
(4)每一条日志信息不应该是立刻写入到文件,而是应该缓存在内存中,当数据量积累到一定的大小再写入到文件(当然,也要考虑定时写入文件)。
2. 解决思路
咱们再回到生产者-消费者模式上。书籍上在介绍这种模式时,一般都是同步模式,即:
生产者产生一个数据后通知消费者,然后等待数据被“消费”;
消费者收到生产者的通知后,“消费”数据,然后再通知生产者继续生产。
生产和消费交替执行,所以我称之为同步模式。
但是,在上面所说的日志系统中,显然不能用同步模式。因为日志的产生速度与写文件速度是无法预估的,例如:在某个场景下日志的产生速度非常快,而在另一个场景下日志的产生速度特别慢。
对于这样的需求,生产者(日志的产生)和消费者(把日志写入文件)速度不匹配,显然应该使用不同的线程来执行。此时,你是不是立刻想到使用消息队列来进行数据缓冲,不就解决了这个速度不匹配的问题?也就是这样:
生产者:往队列的头部插入日志信息(入队)
消费者:从队列的尾部读取日志信息(出队)
当然,你还考虑到因为他们是不同的线程,在操作同一个队列时,需要用一个锁(Mutex)来保护消息队列。大概就是这个样子:
看起来很完美,但是有一个问题:消费者从消息队列每读取一条日志信息就,写入文件系统,但是写文件操作是很耗时的。频繁的从消息队列中获取数据,而且每次都要上锁,一定会对生产者的写日志效率产生影响,因为生产者也要对消息队列上锁才能把日志信息插入队列的头部,如果此时消息队列正好被消费者锁住了,那么生产者就必须伤心的等待了~~这样就会很大影响到日志系统整体的吞吐率。
3. 使用双缓冲
既然消费者的写文件速度比较慢,一定不能影响了生产者的写入效率,所以我们可以用两个消息队列来分别存储:正在写入的日志信息,正在读取的日志信息,也就是所谓的“双缓冲”技术。用图画出来就是这个样子:
注意上图中,内存中用来缓存日志的空间不一定要用消息队列,因为日志信息往往都是字符串类型,直接用一块连续的堆或栈的空间来存储就可以了,所以图中就用“缓冲区1”、“缓冲区2”来表示。
在这个模型中,生产者向缓冲区1中写日志信息;而消费者从缓冲区2中读取日志信息,这样的话,消费者的写文件操作无论怎么慢都不会影响到生产者产生日志了。
此时,你肯定要说:缓冲区1和缓冲区2是两个独立的内存空间,当缓冲区1被写满后如何把其中的内容“复制”到缓冲区2中呢?
好问题,这也是日志系统实现高吞吐率的关键地方!
4. 缓冲区交换
最直觉的想法就是在某个时刻(比如:缓冲区1写满了,缓冲区2空了,定时),把缓冲器1中的内容用memcpy或者其他的系统函数,复制到缓冲区2中。 当然在复制数据的过程中需要对这两个缓冲区都上锁,在临界区完成复制或者移动操作,而且这个移动操作要尽可能的快,这样才能对生产者和消费者产生最小的影响。但是如果数据量比较大,复制操作还是比较耗时。
再仔细想想,其实我们需要的不是真正的“复制”操作,而是有一个地方让生产者存放产生的数据,同样的有一个地方让消费者读取数据,只要达成这个目的就可以了。
还有一个更好的方法,就是直接交换两个缓冲区的地址。我们只需要把生产者在写入数据时的指向缓冲区1的指针重新指向缓冲区2, 把消费者读取数据时指向的缓冲区2的指针重新指向缓冲区1,这样就达到了交换缓冲区的目的了。
交换缓冲区之前:生产者向缓冲区1中写日志,消费者从缓冲区2中读日志。
交换缓冲区之后:生产者向缓冲区2中写日志,消费者从缓冲区1中读日志。
在执行交换操作的时候,也需要对这两个缓冲区上锁。但是在这个临界区操作的是:交换两个指针所指向的缓冲区空间,所以执行速度会非常快。
具体到语言层面,对于C来说就是交换两个4字节的地址,对于C++来说可以利用容器类型的swap函数。
这样画图更好理解:
图中:左侧是执行交换操作之前的样子,右侧是执行交换操作之后的样子。可以看到生产者和消费者在任意时刻操作的都是不同的缓冲区,所以不存在相互影响,而且也达到了快速交换内容的目的。
通过这样的双缓冲技术实现的日志系统,实际测试下来发现,吞吐率比很多开源的日志库要高很多。大家如果有兴趣,可以简单测试一下。
【总结】
写到这里,我想表达的内容基本结束了。
你可能还有其他的一些疑点,比如:什么时候交换缓冲区?写入缓冲区满了之后怎么处理?这些就属于另一个话题了。
在这个实际的使用场景中,通过双缓冲技术,很好地解决了生产者和消费者之间的异步操作和速度不匹配问题,提高了日志系统的整体吞吐率。