(1)背景
(1.1)减少锁的冲突
在生产者-消费者模式中,我们常常会使用到队列,这个队列在多个线程共享访问时存在互斥和竞争操作, 意味着每次访问都要加锁。用一个缓冲区,生产者和消费者需要先获取到缓冲区的锁才能进行put和get操作,每一次put和get都需要获取一次锁,这需要大量的同步与互斥操作,十分损耗性能。
如果采用双缓冲区的话,一个缓冲区bufferA用于生产者执行put操作,一个缓冲区bufferB用于消费者执行get操作;生产者线程和消费者线程在使用各自的缓冲区之前都需要先获取到缓冲区对应的锁,才能进行操作;
生产者和消费者各自使用自己独立的缓冲区,那么就不存在同一个缓冲区被put的同时进行get操作;
(1.2)读写速度不一
比如,在网络传输过程中数据的接收,有时可能数据来的太快来不及接收导致数据丢失。这是由于“发送者”和“接收者”速度不一致所致,在他们之间安排一个或多个缓冲区来存放来不及接收的数据,让速度较慢的“接收者”可以慢慢地取完数据不至于丢失。
再如,计算机中的三级缓存结构:外存(硬盘)、内存、高速缓存(介于CPU和内存之间,可能由多级)。从左到右他们的存储容量不断减小,但速度不断提升,当然价格也是越来越贵。作为“生产者”的 CPU 处理速度很快,而内存存取速度相对CPU较慢,如果直接在内存中存取数据,他们的速度不一致会导致 CPU 能力下降。因此在他们之间又增加的高速缓存来作为缓冲区平衡二者速度上的差异。
在图形图像显示过程中,计算机从显示缓冲区取数据然后显示,很多图形的操作都很复杂需要大量的计算,很难访问一次显示缓冲区就能写入待显示的完整图形数据,通常需要多次访问显示缓冲区,每次访问时写入最新计算的图形数据。而这样造成的后果是一个需要复杂计算的图形,你看到的效果可能是一部分一部分地显示出来的,造成很大的闪烁不连贯。而使用双缓冲,可以使你先将计算的中间结果存放在另一个缓冲区中,但全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区。
例1 中使用双缓冲是为了防止数据丢失,
例2 中使用双缓冲是为了提高 CPU 的处理效率;
例3使用双缓冲是为了防止显示图形时的闪烁延迟等不良体验。
(2) 原理
所谓“双缓冲区”,故名思义就是要有俩缓冲区(简称 A 和 B)。这俩缓冲区,总是一个用于生产者,另一个用于消费者。当俩缓冲区都操作完,再进行一次切换(先前被生产者写入的转为消费者读出,先前消费者读取的转为生产者写入)。由于生产者和消费者不会同时操作同一个缓冲区(不发生冲突),所以就不需要在读写每一个数据单元的时候都进行同步/互斥操作。(空间换时间的优化思路) 但是光有俩缓冲区还不够。为了真正做到“不冲突”,还得再搞两个互斥锁(简称 La 和 Lb),分别对应俩缓冲区。生产者或消费者如果要操作某个缓冲区,必须先拥有对应的互斥锁。
2.1) 缓冲区的切换
如果bufferA被put满了,那么生产者释放bufferA的锁,并等待消费者释放bufferB的锁;当bufferB被take空了,消费者释放bufferB的锁,此时生产者获取到bufferB的锁,对bufferB进行put;消费者获取到bufferA的锁,对bufferA进行take,那么就完成了一次缓冲区的切换;
(3)锁
在双缓冲队列中,锁除了起到保护数据安全的作用来,还要承担线程调度的任务。
双队列交换位置和任务入队列都需要对当前队列进行操作,因此,他们是互斥的操作。 消费操作放在单独的线程中,在没有任务进来时,需要将线程置为等待状态。 使用两个信号量,来调度入列队和交换队列的操作。同时,我们还需要一个信号量,在没有任务入队列时,阻塞整个消费线程。
(4)双缓冲区的几种状态 A》俩缓冲区都在使用的状态(并发读写) 大多数情况下,生产者和消费者都处于并发读写状态。不妨设生产者写入 A,消费者读取 B。在这种状态下,生产者拥有锁 La;同样的,消费者拥有锁 Lb。由于俩缓冲区都是处于独占状态,因此每次读写缓冲区中的元素(数据单元)都【不需要】再进行加锁、解锁操作。这是节约开销的主要来源。 B》单个缓冲区空闲 由于两个并发实体的速度会有差异,必然会出现一个缓冲区已经操作完,而另一个尚未操作完。不妨假设生产者快于消费者。在这种情况下,当生产者把 A 写满的时候,生产者要先释放 La(表示它已经不再操作 A),然后尝试获取 Lb。由于 B 还没有被读空,Lb 还被消费者持有,所以生产者进入发呆(Suspend)状态。 C》缓冲区的切换 接着上面的话题。过了若干时间,消费者终于把 B 读完。这时候,消费者也要先释放 Lb,然后尝试获取 La。由于 La 刚才已经被生产者释放,所以消费者能立即拥有 La 并开始读取 A 的数据。而由于 Lb 被消费者释放,所以刚才发呆的生产者会缓过神来(Resume)并拥有 Lb,然后生产者继续往 B 写入数据。 经过上述几个步骤,俩缓冲区完成了对调,变为:生产者写入 B,消费者读取 A。
(4)潜在问题: 由于双缓冲区是为了避免每次读写的时候不用进行同步与互斥操作,所以对于一些本来就是线程安全的类例如arrayblockingqueue就不适合作为双缓冲区,因为他们内部已经实现了每次读写操作的时候进行加锁和释放
(5)应用
1)共享内存和共享文件 2)逻辑处理线程和IO处理线程分离。
I/0处理线程负责网络数据的发送和接收,连接的建立和维护。 逻辑处理线程处理从IO线程接收到的包。