`
saybody
  • 浏览: 867361 次
  • 性别: Icon_minigender_2
  • 来自: 西安
文章分类
社区版块
存档分类
最新评论

架构设计:生产者/消费者模式[2]:队列缓冲区

阅读更多

  经过前面两个帖子的铺垫,今天终于开始聊一些具体的编程技术了。由于不同的缓冲区类型、不同的并发场景对于具体的技术实现有较大的影响。为了深入浅出、便于大伙儿理解,咱们先来介绍最传统、最常见的方式。也就是单个生产者对应单个消费者,当中用队列 (FIFO)作缓冲。<!-- program-think-->
  关于并发的场景,在之前的帖子“进程还线程?是一个问题! ”中,已经专门论述了进程和线程各自的优缺点,两者皆不可偏废。所以,后面对各种缓冲区类型的介绍都会同时提及进程方式和线程方式。

  ★线程方式
  先来说一下并发线程中使用队列的例子,以及相关的优缺点。
  ◇内存分配的性能
  在线程方式下,生产者和消费者各自是一个线程。生产者把数据写入队列头(以下简称push),消费者从队列尾部读出数据(以下简称pop)。当队列为空,消费者就稍息(稍事休息);当队列满(达到最大长度),生产者就稍息。整个流程并不复杂。
  那么,上述过程会有什么问题捏?一个主要的问题是关于内存分配的性能开销。对于常见的队列实现:在每次push时,可能涉及到堆内存 的分配;在每次pop时,可能涉及堆内存 的释放。假如生产者和消费者都很勤快,频繁地push、pop,那内存分配的开销就很可观了。对于内存分配的开销,用Java的同学可以参见前几天的帖子“Java性能优化[1] ”;对于用C/C++的同学,想必对OS底层机制会更清楚,应该知道分配堆内存 (new或malloc)会有加锁的开销和用户态/核心态切换 的开销。
  那该怎么办捏?请听下文分解,关于“生产者/消费者模式[3]:环形缓冲区 ”。
  ◇同步和互斥的性能
  另外,由于两个线程共用一个队列,自然就会涉及到线程间诸如同步啊、互斥啊、死锁啊等等劳心费神的事情。好在"操作系统"这门课程对此有详细介绍,学过的同学应该还有点印象吧?对于没学过这门课的同学,也不必难过,网上相关的介绍挺多的(比如"这里 "),大伙自己去瞅一瞅。关于这方面的细节,咱今天就不多啰嗦了。
  这会儿要细谈的是,同步和互斥的性能开销。在很多场合中,诸如信号量、互斥量等玩意儿的使用也是有不小的开销的(某些情况下,也可能导致用户态/核心态切换)。如果像刚才所说,生产者和消费者都很勤快,那这些开销也不容小觑啊。
  这又该咋办捏?请听下文的下文分解,关于“生产者/消费者模式[4]:双缓冲区 ”。
  ◇适用于队列的场合
  刚才尽批判了队列的缺点,难道队列方式就一无是处?非也。由于队列是很常见的数据结构,大部分编程语言都内置了队列的支持(具体介绍见"这里 "),有些语言甚至提供了线程安全的队列(比如JDK 1.5引入的ArrayBlockingQueue )。因此,开发人员可以捡现成,避免了重新发明轮子。
  所以,假如你的数据流量不是很大,采用队列缓冲区的好处还是很明显的:逻辑清晰、代码简单、维护方便。比较符合KISS原则。

  ★进程方式
  说完了线程的方式,再来介绍基于进程的并发。
  跨进程的生产者/消费者模式,非常依赖于具体的进程间通讯(IPC)方式。而IPC的种类名目繁多,不便于挨个列举(毕竟口水有限)。因此咱们挑选几种跨平台、且编程语言支持较多的IPC方式来说事儿。
  ◇匿名管道
  感觉管道是最像队列的IPC类型。生产者进程在管道的写端 放入数据;消费者进程在管道的读端取出数据。整个的效果和线程中使用队列非常类似,区别在于使用管道就无需操心线程安全、内存分配等琐事(操作系统暗中都帮你搞定了)。
  管道又分命名管道匿名管道 两种,今天主要聊匿名管道。因为命名管道在不同的操作系统下差异较大(比如Win32和POSIX,在命名管道的API接口和功能实现上都有较大差异;有些平台不支持命名管道,比如Windows CE)。除了操作系统的问题,对于有些编程语言(比如Java)来说,命名管道是无法使用的。所以我一般不推荐使用这玩意儿。
  其实匿名管道在不同平台上的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支持双向通讯,有利于消费者反馈信息。
  当然有利就有弊。相对于上述shell管道的方式,使用SOCKET在编程上会更复杂一些。好在前人已经做了大量的工作,搞出很多SOCKET通讯库和框架给大伙儿用(比如C++的ACE 库、Python的Twisted )。借助于这些第三方的库和框架,SOCKET方式用起来还是比较爽的。由于具体的网络通讯库该怎么用不是本系列的重点,此处就不细说了。
  虽然TCP在很多方面比UDP可靠,但鉴于跨机器通讯先天的不可预料性(比如网线可能被某傻X给拔错了,网络的忙闲波动可能很大),在程序设计上我们还是要多留一手。具体该如何做捏?可以在生产者进程 和消费者进程 内部各自再引入基于线程 的"生产者/消费者模式"。这话听着像绕口令,为了便于理解,画张图给大伙儿瞅一瞅。

不见图、请

  这么做的关键点在于把代码分为两部分:生产线程和消费线程属于和业务逻辑相关的代码(和通讯逻辑无关);发送线程和接收线程属于通讯相关的代码(和业务逻辑无关)。
  这样的好处是很明显的,具体如下:
  1、能够应对暂时性 的网络故障。并且在网络故障解除后,能够继续工作。
  2、网络故障的应对处理方式(比如断开后的尝试重连),只影响发送和接收线程,不会影响生产线程和消费线程(业务逻辑部分)。
  3、具体的SOCKET方式(阻塞和非阻塞)只影响发送和接收线程,不影响生产线程和消费线程(业务逻辑部分)。
  4、不依赖TCP自身的发送缓冲区和接收缓冲区。(默认的TCP缓冲区的大小可能无法满足实际要求)
  5、业务逻辑的变化(比如业务需求变更)不影响发送线程和接收线程。
  针对上述的最后一条,再多啰嗦几句。如果整个业务系统中有多个进程是采用上述的模式,那或许可以重构一把:在业务逻辑代码和通讯逻辑代码之间切一刀,把业务逻辑无关的部分封装成一个通讯中间件(说中间件 显得比较牛X :-)。如果大伙儿对这玩意儿有兴趣,以后专门开个帖子聊。
  下一个帖子 ,咱们来介绍一下环形缓冲区的话题。


版权声明
本博客所有的原创文章,作者皆保留版权。转载必须包含本声明,保持本文完整,并以超链接形式注明作者编程随想 和本文原始地址:

http://program-think.blogspot.com/2009/03/producer-consumer-pattern-2-queue.html

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics