2009年4月16日

架构设计:生产者/消费者模式[4]:双缓冲区

  “双缓冲区”是一个应用很广的手法。该手法用得最多的地方想必是屏幕绘制相关的领域(主要是为了减少屏幕闪烁)。另外,在设备驱动和工控方面,双缓冲也经常被使用。不过今天要聊的,并不是针对上述的某个具体领域,而是侧重于并发方面的同步/互斥开销。另外提醒一下,双缓冲方式和前面提到的队列缓冲、环形缓冲是可以结合使用滴。

★为啥要双缓冲区?


  记得前几天在介绍队列缓冲区时,提及了普通队列缓冲区的两个性能问题:“内存分配的开销”和“同步/互斥的开销”(健忘的同学,先回去看看那个帖子复习一下)。“内存分配的开销”已经在介绍环形缓冲区的时候解决了,而今天要介绍的双缓冲区,就是冲着同步/互斥的开销来的。
  为了防止有人给咱扣上“过度设计”的大帽子,又得来一个事先声明:只有当同步或互斥的开销非常明显的时候,你才应该考虑双缓冲区的使用。否则的话,大伙儿还是老老实实用最基本、最简单的队列缓冲区吧。

★双缓冲区的原理


  前面说了一通废话,现在开始切入正题,说说具体实现。
  所谓“双缓冲区”,故名思义就是要有俩缓冲区(简称 A 和 B)。这俩缓冲区,总是一个用于生产者,另一个用于消费者。当俩缓冲区都操作完,再进行一次切换(先前被生产者写入的转为消费者读出,先前消费者读取的转为生产者写入)。由于生产者和消费者不会同时操作同一个缓冲区(不发生冲突),所以就不需要在读写每一个数据单元的时候都进行同步/互斥操作。(顺便提一下,这又一次展现了【空间换时间】的优化思路)
  但是光有俩缓冲区还不够。为了真正做到“不冲突”,还得再搞两个互斥锁(简称 La 和 Lb),分别对应俩缓冲区。生产者或消费者如果要操作某个缓冲区,必须先拥有对应的互斥锁。补充一句:要达到“不冲突”的效果,其实可以有多种搞法,今天只是挑一个简单的来聊。

★双缓冲区的几种状态


  为了加深某些同学的理解,再描述一下双缓冲区的几种状态。

◇俩缓冲区都在使用的状态(并发读写)


  大多数情况下,生产者和消费者都处于并发读写状态。不妨设生产者写入 A,消费者读取 B。在这种状态下,生产者拥有锁 La;同样的,消费者拥有锁 Lb。由于俩缓冲区都是处于独占状态,因此每次读写缓冲区中的元素(数据单元)都【不需要】再进行加锁、解锁操作。这是节约开销的主要来源。

◇单个缓冲区空闲的状态


  由于两个并发实体的速度会有差异,必然会出现一个缓冲区已经操作完,而另一个尚未操作完。不妨假设生产者快于消费者。
  在这种情况下,当生产者把 A 写满的时候,生产者要先释放 La(表示它已经不再操作 A),然后尝试获取 Lb。由于 B 还没有被读空,Lb 还被消费者持有,所以生产者进入发呆(Suspend)状态。

◇缓冲区的切换


  接着上面的话题。
  过了若干时间,消费者终于把 B 读完。这时候,消费者也要先释放 Lb,然后尝试获取 La。由于 La 刚才已经被生产者释放,所以消费者能立即拥有 La 并开始读取 A 的数据。而由于 Lb 被消费者释放,所以刚才发呆的生产者会缓过神来(Resume)并拥有 Lb,然后生产者继续往 B 写入数据。
  经过上述几个步骤,俩缓冲区完成了对调,变为:生产者写入 B,消费者读取 A。

★可能的并发问题


  本来单个缓冲区的生产者/消费者问题就已经是教科书的经典问题了,现在搞出俩缓冲区,所以就更加耗费脑细胞了。一不小心,就会搞出些并发的Bug,而且并发的Bug还很难调试和测试(这也就是为啥不要轻易使用该玩意儿的原因)。

◇死锁的问题


  假如把前面介绍的操作步骤调换一下顺序:生产者或消费者在操作完当前的缓冲区之后,先去获取另一个缓冲区的锁,再来释放当前缓冲区的锁。那会咋样捏?
  一旦两个并发实体【同时】处理完各自缓冲区,然后【同时】去获取对方拥有的锁,那就会出现典型的死锁(死锁的详细解释参见“这里”)场景。它俩从此陷入万劫不复的境地。

★应用场景


  介绍完并发问题,按照本系列的惯例,最后再来介绍一下双缓冲区在某些场合的应用。

◇用于并发线程


  在线程方式下,首先要考虑的是缓冲区的类型:到底用队列方式还是环形方式。这方面的选择依据在介绍环形缓冲区的时候已经阐述过了,此处不再啰嗦(省去不少口水)。
  另一个需要注意的是,某些编程语言或者程序库提供了的线程安全的缓冲区(比如 JDK 1.5 引入的 ArrayBlockingQueue)。由于这种缓冲区会自动为每次的读写进行同步/互斥,所以就把双缓冲的优势抵消掉了。因此,大伙儿在进行缓冲区选型的时候要避开这类缓冲区。

◇用于并发进程


  在进程间使用双缓冲,先得考察不同 IPC 类型的特点。由于今天讨论双缓冲的目的是降低同步/互斥的开销,对于那些已经封装了同步/互斥的 IPC 类型,就没太大必要再去搞双缓冲了(单凭这条就足以让好多种 IPC 出局)。剩下的 IPC 类型中,比较适合用双缓冲的主要是:共享内存和文件。非常凑巧,这两个玩意儿的特点和适用范围在环形缓冲区的帖子里面也已经介绍过了,俺又可以节省不少口水 :)

回到本系列的目录

9 条评论:

  1. 。。。谢谢啦。。。

    回复删除
  2. 双缓冲区这个很妙!

    回复删除
  3. TO 楼上的网友
    俺头一次听说这玩意儿,也觉得挺妙 :)

    回复删除
  4. 双缓冲也属于环形缓冲的一种吧?只不过双缓冲是2个,而环形的可能是3、4、5或更多。

    回复删除
    回复
    1. 从结构上来说,可以把双缓冲看成是只有2个 item 的环形缓冲。
      不过捏,两者的目的不太一样。
      双缓冲的主要是降低同步/互斥的开销。
      而环形缓冲,主要是是降低内存分配/释放的开销。

      删除
  5. 看完这个系列,非常感谢!

    回复删除
  6. 感觉在讨论死锁那块,有点误导用户,实际上有生产者线程(锁LA)和消费者线程(锁LB),完成双buffer切换的往往是指定一个线程(要么是生产者,要么是消费者,要么是第三个线程),也就是说只有一类线程发起切换buffer的操作,所以不会存在死锁现象。

    回复删除
  7. 双缓冲区使用两个固定的缓冲区,来回切换,好像同时也降低了内存分配/释放的开销。
    博主能否再写写一些IT方面的博客,近来IT类的文章销声匿迹了。

    回复删除