生产者消费者模式

一、概念

       在实际的软件开发过程中,经常会碰到如下场景:某个模块负责产生数据,这些数据由另一个模块来负责处理(此处的模块所广义的,可以是类、函数、线程、进程等)。产生数据的模块,就形象地成为生产者;而处理数据的模块,就成为消费者。生产者/消费者模式,还需要有一个缓冲区处于生产者和消费者之间,作为一个中介。生产者把数据放入缓冲区,消费者从缓冲区取出数据。

1、缓冲区的作用

       解耦。假设生产者和消费者所两个类。如果让生产者直接调用消费者的某个方法,那么生产者对于消费者就会产生依赖(也就算耦合)。将来如果消费者的代码发生变化,可能会影响到生产者。如果两者都依赖于某个缓冲区,彼此不直接依赖,耦合就相应得降低了。对于无缓冲区的强耦合,如果消费者发生变化,可能需要修改生产者代码。

       支持并发。如果生产者直接调用消费者的某个方法,由于函数调用是同步的,在消费者的方法没有返回之前,生产者就会阻塞。而有了缓冲区,生产者将产生的数据放入缓冲区就可以继续执行。

       支持忙闲不均。生产者制造数据快时,消费者来不及处理,未处理的数据可以暂时存在缓冲区中,等消费者慢慢处理。

2、数据单元

       每次生产者放入缓冲区的就是一个数据单元;每次消费者从缓冲区取出的,也是一个数据单元。

       分析数据单元,需要考虑如下几个方面的特性:

       1)关联到业务对象。首先,数据单元必须关联到某种业务对象,这需要开发者深刻理解当前这个生产者/消费者模式所对应的业务逻辑。

       2)完整性。就是在传输过程中,要保证该数据单元的完整。要么整个数据单元被传递到消费者,要么完全没有传递到消费者。

       3)独立性。所谓独立性,就是各个数据单元之间没有互相依赖,某个数据单元传输失败不应该影响已经完成传输的单元,也不应该影响未传输的单元。

       4)颗粒度。数据单元和业务对象很多场合是一一对应的。但有时出于性能等因素考虑,也可能会把N个业务对象打包程一个数据单元,那么N的取值就是颗粒度的考虑。如果N过小,每次放入和取出的业务对象就过少,需要频繁地访问缓冲区。如果N过大,则生产者每次需要等到产生N个业务对象时才会放入缓冲区。

二、队列缓冲区

       不同的缓冲区类型、不同的并发场景对于具体的技术实现有较大的影响。

       最常见的方式所单个生产者对应单个消费者,用队列FIFO作缓冲。

1、线程方式

1)内存分配的性能       

       在线程方式下,生产者和消费者各是一个线程。生产者把数据push进队列头,消费者从队列尾部pop出数据。当队列为空,消费者阻塞;当队列满,生产者阻塞。对于常见的队列实现,每次push,涉及堆内存的分配;每次pop,涉及堆内存的释放。如果频繁地push、pop,内存分配的开销会很大。分配堆内存(new或malloc)会有加锁的开销和用户态/核心态切换的开销。

2)同步和互斥的性能

       由于两个线程共用一个队列,自然会涉及同步、互斥、死锁等问题。很多场合中信号量、互斥量的使用也会有不小的开销,某些情况可能导致用户态/核心态切换。

       在数据量不大的情况下,采用队列缓冲区逻辑清晰、代码简单、维护方便。

2、进程方式

       跨进程的生产者/消费者模式,非常依赖于具体的进程间通讯(IPC)方式。

1)匿名管道

       生产者进程在管道的写端放入数据,消费者进程在管道的读端取出数据。效果类似于队列,但使用管道不需要关心线程安全、内存分配等琐事。

2)套接字

      基于TCP方式的socket通信保证类数据的顺序道道,同样有缓冲的机制,并且跨平台和跨语言。socket方式便于扩展程多对一或一对多。socket可以设置阻塞和非阻塞方法,用起来灵活。socket支持双向通信,有利于消费者反馈信息。

       鉴于跨及其通信的风险,可以在生产者进程和消费者进程内部各自再引入基于线程的“生产者/消费者模式”,如图一。这样可以应对暂时性的网络故障,在故障解除后继续工作;网络故障的处理只影响发送和接收线程,不影响生产线程和消费线程;具体的socket方式只影响发送和接收线程,不影响生产和消费线程;不依赖TCP自身的发送缓冲区和接收缓冲区,默认的TCP缓冲区可能无法满足需求;业务逻辑的变化不影响发送线程和接收线程。

图一 生产者/消费者进程内部引入“生产者/消费者模式”

三、环形缓冲区

       针对前文的队列缓冲区的问题,可以通过环形缓冲区来解决。环形缓冲区有写入端和读出端,也有缓冲区满和空的状态。环形缓冲区可以通过数组或链表实现。

1、读写操作

       环形缓冲区要维护两个索引,分别对应写端和读端。环未满可以写入,环不空可以读取。

       为了判断环形缓冲区空还是满,可以保持一个元素不用,读指针距离写指针还有一个元素间隔时就认为环已满;也可以用额外的变量来解决,如记录当前环中已保存的元素个数。

2、元素的存储

       由于环形缓冲区所为了降低存储空间分配的开销,因此尽量存储值类型的数据,而不要存储指针类型的数据。因为指针类型的数据又会引起存储空间(如堆内存)的分配和释放。

       尽量选择现成的库,没有现成的才手动实现,如C++的boost提供的circular_buffer模板,可以用于并发线程。

3、应用场合

       1)用户并发线程。线程的环形缓冲区也要考虑线程安全的问题。

       2)用于并发进程。适用于进程间环形缓冲的IPC类型,常见的有共享内存和文件。在这两种方式上进行环形缓冲,通常都采用数组的方式实现。程序员实现分配好一个固定长度的存储空间,并手动实现读写操作、判断空和满、元素存储等。共享内存方式性能好,适用于数据流量大的场景,但有些变成语言不支持共享内存。文件方式则可能会受限于磁盘读写的性能,不适用于快速数据传输,但对于某些数据单元很大的场合则值得考虑。

四、双缓冲区

       双缓冲区可以减少同步/互斥的开销。

1、原理

       双缓冲区,就算有两个缓冲区,同一时刻一个用于生产者,一个用于消费者。当俩缓冲区都操作完,进行一次切换,先前被生产者写入的转为消费者读出,先前消费者读取的转为生产者写入。由于生产者和消费者不会同时操作同一个缓冲区,就不需要在读写每一个数据单元的时候都进行同步/互斥操作。属于空间换时间的优化思路。

       但为了防止生产者和消费者同时访问同一个缓冲区,还需要两个互斥锁分别对应两个缓冲区。生产者或消费者如果要操作某个缓冲区,必须先拥有对应的互斥锁。

2、缓冲区的切换

       消费者读取缓冲区A,A为空后,释放锁LA,并尝试获取锁LB。如果生产者把缓冲区B写满,释放锁LB,并尝试获取锁LA。要特别注意应先释放持有的锁,再去获取另一个锁。如果顺序改变,很可能导致死锁。

3、应用场合

       1)用于并发线程,但某些语言或程序库提供了线程安全的缓冲区,会自动为每次的读写进行同步/互斥,双缓冲区就无意义了。

       2)用于并发进程。比较适用的场景是共享内存和文件。其他IPC已经封装了同步/互斥。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值