[ING] 设计模式之生产者-消费者

Posted by 阿呆 on 2019-01-03

本为为部分参考加总结思考的形式
为了尊重原作者的劳动成果,请移步至:https://program-think.blogspot.com/2009/03/producer-consumer-pattern-1-data.html


        

简介

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

like this ->

img

Why Buffer?

解耦

生产者和消费者之间不再有直接的联系,方便修改相应的实现,耦合相应地降低了

就好比寄信,邮箱是个很好的缓冲区,有的人可能会问,直接给邮递员的人不是很好么?其实不然,首先你得认识谁是邮递员,万一哪天邮递员换人了,你还得重新认识一下(相当于消费者变化导致修改生产者代码),再者,你还需要知道邮递员有没有上班,总之,弊端很多。

支持并发(Concurrency)

生产者直接调用消费者的某个方法,还有一个弊端,由于函数调用是同步的(或者阻塞的),在消费者的方法没有返回之前,生产者只好一直等在那里,万一消费者处理数据很慢,生产者就会白白糟蹋大好时光.

使用生产者/消费者模式后,生产者和消费者可以是两个独立的并发主体(常见的并发类型有线程与进程,后面会讲到两种情况下的应用)

其实当初这个模式,主要就是用来处理并发问题的

支持忙闲不均

将未能及时处理的数据放在缓存区内,等空闲的时候再来处理

More conceptions

前面让我们对 生产者/消费者模式 有了个大概的了解,接下来谈一些具体的编程问题。首先要搞明白一个问题,如何确定数据单元?只有把数据单元分析清楚,后面的技术设计才好搞。

数据单元

定义

简单地来说,每次生产者放在缓冲区的,就是一个数据单元;每次消费者从缓冲区取出的,也是一个数据单元。对于寄信的例子来说,我们可以把每封信当成一个数据单元。不过仅仅这么介绍太过于简单。下面我们来严格地分析一下,数据单元需要具备哪些特性

数据单元的特性

1.关联性
首先,数据单元必须关联到某种业务对象,再考虑问题的时候,你必须深刻地理解当前这个生产者/消费者模式所对应地[业务逻辑],当其中包含地业务层次繁多、类型各异地情况下,就不易做出决策了。虽然这一步有难度,但是很重要,如果选错了业务对象,编码难度会陡然上升

2.完整性
传输过程中,要保证该数据单元的完整,要么整个数据单元被传递到消费者,要么完全没用传递到消费者。不允许出现部分传递的情形。
对于寄信来说,你不能把半封信放入信箱,邮递员也不可以只拿出信的一部分

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

为什么会出现传输失败呢?
缓冲区溢出,丢失的部分不会影响到已经传输的或者后续的。寄信而言,某封信丢了,不会影响后续信件的送达,当然也不会影响到已经送到的信件

4.颗粒度
前面提到,数据单元需要关联到某种业务对象。那么数据单元和业务对象是否要一一对应呢?很多场合确实是一一对应;不过有时候出于性能的问题,也可能会把N个业务对象打包成一个数据单元,那么,这个N该如何取值就是颗粒度的问题了。

颗粒度的大小很有讲究,太大的颗粒度可能会浪费空间(我觉得还有实时性),太小的颗粒度则会影响时间性能,颗粒度的权衡要基于多方面的考虑

队列缓冲区

由于不同的缓冲区类型、不同的并发场景对于具体的技术实现有较大的影响。为了深入浅出、便于大伙理解,咱们先来介绍最传统、最常见的方式;也就是单个生产者对应单个消费者,当中用**队列(FIFO)**作缓冲

进程还是线程,是一个问题,后面对各种缓冲区类型的介绍都会同时提及进程及线程方式

线程方式

先来说下并发线程中使用队列的例子,以及相关优缺点

内存分配的性能

在线程方式下,生产者和消费者各自是一个线程。生产者把数据写入队列头(push),消费者从队列尾部读出数据(pop)。当队列为空,消费者就稍息,当队列满,生产者就稍息。

存在的问题:push与pop涉及到堆内存的分配和释放,当生产者和消费者都很勤快,频繁地push、pop,那内存分配的开销就很大

解决办法:环形缓冲区

同步和互斥的性能

由于两个线程公用一个队列,自然就会涉及到线程间如何同步、互斥、死锁等等劳心费神的事情。好在操作系统这么课程对此有详细的介绍。可以自己去了解一下。

这会儿要细谈的是,同步和互斥的性能开销。在很多场合中,诸如信号量、互斥量等玩意的使用也是有不小的开销的(某些情况下,也可能导致用户态/核心态切换)
解决办法请看后续的"双缓冲区"

适用于队列的场合

刚才讲的都是使用队列的一些缺点,难道队列方式就一无是处?非也,由于队列是很常见的数据结构,大部分编程语言都内置了队列的支持,有些语言甚至提供了线程安全的队列(比如JDK1.5中引入的 ArrayBlockingQueue),因此,开发人员可以捡现成,避免了重新造轮子

所以,假如你的数据量不够大,采用队列缓冲区的好处还是很明显的:逻辑清晰,代码简单,易于维护,比较符合 KISS原则(Keep it simple and stupid)

进程方式

说完了线程的方式,再来介绍基于进程的并发。
跨进程的生产者/消费者模式,非常依赖于进程间通讯的方式。而IPC的种类名目繁多,不便于挨个列举,读者可以自己去了解一下。因此咱们就选择几种跨平台、且编程语言支持较多的IPC方式说事。

匿名管道

感觉管道是最像队列的IPC类型,生产者进程在管道的写端放入数据;消费者进程在管道的读端取出数据。整个的效果和线程中使用队列非常类似,区别在于使用管道就无需操心线程安全,内存分配等琐事(操作系统暗中都帮你搞定了)

管道又分命名管道匿名管道,今天主要聊匿名管道,因为命名管道在不同的操作系统下差异较大,除了操作系统的差异,对于有些编程语言来说,命名管道是无法使用的,所以俺一般不推荐这玩意

其实匿名管道在不同平台上的API接口,也是有差异的(比如Win32的CreatePipe和Posix的pipe,用法就很不一样),但是我们可以仅使用标准输入和输出(以下简称stdio)来进行数据的流入流出,然后,利用shell的管道符把生产者进程和消费者进程关联起来。实际上,很多操作系统(尤其是POSIX风格的)自带的命令都充分利用了这个特性来实现数据的传输(比如 more、grep 等)

这么干有以下几个好处
1.基本上所有操作系统都支持在shell方式下使用管道符,因此很容易实现跨平台
2.大部分语言都能操作stdio,因此跨编程语言也就容易实现
3.刚才已经提到,管道方式省却了线程安全方面的琐事,有利于降低并发、调试成本

缺点

1.生产者进程和消费者进程必须得在同一台主机上,无法跨机器通讯。这个缺点比较明显。

2.在一对一的情况下,这种方式挺合用。但如果要扩展到一对多或者多对一,那就有点棘手了。所以这种方式的扩展性要打个折扣。假如今后要考虑类似的扩展,这个缺点就比较明显。

3.由于管道是 shell 创建的,对于两边的进程不可见(程序看到的只是 stdio)。在某些情况下,导致程序不便于对管道进行操纵(比如调整管道缓冲区尺寸)。这个缺点不太明显。

4.最后,这种方式只能单向传数据。好在大多数情况下,消费者进程不需要传数据给生产者进程。万一你确实需要信息反馈(从消费者到生产者),那就费劲了。可能得考虑换种 IPC 方式。

顺便补充几个注意事项

1.对stdio进行读写操作是以阻塞方式进行的,比如管道中没有数据,消费者进程的读操作就会一直停在那儿,直到管道中重新有数据

2.由于stdio内部带有自己的缓冲区(这缓冲区和管道缓冲区是两码事),有时候会导致一些不太爽的现象(比如生产者进程输出了数据,但消费者进程没有立即读到)。

Socket(TCP方式)

基于TCP的socket通讯是又一个类似于队列的IPC方式,它同样保证了数据的顺序到达;同样有缓冲机制。而且这玩意儿也是跨平台和跨语言的,和刚才介绍的shell管道符方式类似。socket相比于shell管道符的方式,有啥有点呢?

1.Socket 方式可以跨机器(便于实现分布式),这是主要优点
2.Socket 方式便于将来扩展成为多对一或者一对多,这也是主要优点
3.Socket 可以设置阻塞和非阻塞方法,用起来比较灵活,这是次要优点
4.Socket 支持双向通讯,有利于消费者反馈信息

TO BE CONTINUE

下面这个文章从宏观上,以一个具体的例子来分析生产者-观察者模式:https://www.infoq.cn/article/producers-and-consumers-mode