并发编程:当IO复用遇到消息队列

    在并发编程中,多线程模型占有举足轻重的地位,而消息队列,又是多线程编程中解决数据竞争的一个神兵利器。通过一个多线程安全的队列,可以简单而高效的实现线程之间的交互,同时不会引入太复杂的编码逻辑。

    而IO复用,不管是 select,poll 还是 epoll,解决的都是同一个问题---在一个线程内非阻塞的去处理多个 IO 事件(值得注意的是,非阻塞IO与异步IO不是一个概念,其中亦有区别,但是具体的区别不在此处展开,大家可以查阅相关资料),IO复用对于单个 IO 事件的处理效率没有任何提升,但是在单线程的情况下却可以做到最大程度的减少所有 IO 事件的总等待时间。

    这两项技术在当今的并发编程中都随处可见,今天的文章将这两个武器组合起来,为消费者线程在在单消费者多生产者的情景下,高效轮询多个生产者的消息队列提供了一个解决思路(代码源于 Python Cookbook)。

    首先让我详细阐述下给定的生产场景:在一个单进程多线程的程序中,有多个生成线程(producer)和一个消费线程(consumer),生产者与消费者之间通过阻塞的线程安全的队列来通信。现在我们如何来实现消费者高效轮询多个队列呢?

    当然其实有时候可以通过换个思路简单的避过这种需求,比如,不是每个生产者都写到一个单独的队列,而是所有生产者都写入消费者的指定的唯一一个队列(可以是这个消费者的一个成员,可以是一个全局变量,都无所谓)。但是在 c++ 这样的静态语言中,如果消费者要处理超过一种生产者的数据(当然这其实不是一个很好的设计,如果真的有这样,这个消费者的逻辑也该拆分为多个,然后以 pipeline 的方式组合起来),则无法简单的通过一个队列将多种生产者生成的数据传递过来(当然也可以通过一些技巧比如 void * 指针外加转型克服)。但是不管这个给定的场景是否合理,姑且让咱们看下如何解决这个需求。

    一个直观的解法是,消费者线程中有一个 for 循环,调用每个队列的 get() 方法(这是一个阻塞的过程)。显然这个办法无法让人满意,这样可能会带来无尽的延迟,效率及其低下,是根本不可用的解法。

    如果咱们把操作队列也看为 IO 操作,那么 IO 复用岂不是可以将咱们拯救于水火?可是 IO 复用的操作对象是套接字啊,这和队列可八竿子打不着啊,这咋整?

    答案其实很简单,也很巧妙,咱们使用一个 PollableQueue!至于这个名字是怎么来的,不妨先看下代码(因为本篇文章是通过 Python Cookbook 学到的,所以代码为 Python,但是不代表其他语言无法实现):

from Queue import Queue
import socket

class PollableQueue(Queue):
    def __init__(self):
        Queue.__init__(self)
        self._sendsocket, self._recvsocket = socket.socketpair()

    def fileno(self):
        return self._recvsocket.fileno()

    def put(self, item):
        Queue.put(self, item);
        self._sendsocket.send(b'x')

    def get(self):
        self._recvsocket.recv(1)
        return Queue.get(self)

    上述代码从 Queue 派生出一个子类 PollableQueue(后面简称PQ),其构造函数在构造过程中为 PQ 创建了一对管道文件描述符(socketpair,分别对应 pipe 的输入与输出)。其 put 函数在正常的 Queue.put() 后,向 pipe 的写段写入一个字符,而 get 函数在调用 Queue.get() 之前,先从 pipe 读端读取了一个字节。还有一个非常重要的方法是 fileno(), 这个函数返回读端的套接字对应的文件描述符。这个接口是为了 select 模块使用的,也正是这个接口,让咱们的这个子类当的起 Pollable 这个称谓。

    这就是本篇文章给出的答案,这个技巧多线程编程中得到了广泛的应用,在很多开源软件中都可以看到其身影。背后的道理都是通过 IO 复用配合消息队列,在一个线程内处理对多个阻塞队列的轮询。

    下面让我们结合一下使用代码,来一窥究竟吧!

import select
import threading


def consumer(queues):
    while True:
        can_read, _, _ = select.select(queues, [], [])
        for r in can_read:
            item = r.get()
            print 'Got ', item


def producer(queue):
    for i in range(1, 10):
        queue.put(i)


queues = [PollableQueue() for i in range(4)]
t = threading.Thread(target=consumer, args=(queues,))
t.start()
for i in range(4):
    p = threading.Thread(target=producer, args=(queues[i],))
    p.start()

    上面的代码中,有1个消费者(为了简化代码,没有退出这个消费者线程,而是使用了死循环,这样的代码是无法使用在生产环境的,此处只是为了演示),4个生产者,生产者启动时,将各自的队列作为参数传入线程函数,而消费者则将队列 list 作为参数传入线程函数。

    解决代码就是消费线程中的 select 调用,诸位先随我看一下这个 select.select 函数吧:

select(...)
    select(rlist, wlist, xlist[, timeout]) -> (rlist, wlist, xlist)

    Wait until one or more file descriptors are ready for some kind of I/O.
    The first three arguments are sequences of file descriptors to be waited for
:
    rlist -- wait until ready for reading    
    wlist -- wait until ready for writing
    xlist -- wait for an ``exceptional condition''
    If only one kind of condition is required, pass [] for the other lists.
    A file descriptor is either a socket or file object, or a small integer
    gotten from a fileno() method call on one of those.    
    The return value is a tuple of three lists corresponding to the first three
    arguments; each contains the subset of the corresponding file descriptors
    that are ready.

    可以看到,任何定义了 fileno 方法且可以返回一个合理的文件描述符的对象都可以与 select 同志进行亲密无间的合作。通过在 PQ 中将一对文件描述符与队列绑定,我们成功的将对队列的轮询,zhu为了处理多个 IO 事件,而这样,威力无穷的 IO 复用就有了用武之地。

    在消费者线程中,select (不管其底层到底是基于 select、poll 还是 epoll)多个队列,当队列中有新数据入队(put 方法被调用,像与队列绑定的写端文件写入字符),则对应的队列会在 can_read 中返回,然后直接调用队列的 get 方法便不会发生阻塞在空队列的情况。

    可以看到借助 PollableQueue,我们很容易的实现了单个消费者高效轮询多个阻塞队列的需求。有兴趣的读者,可以考虑一下这样的需求如何在 c/c++ 这样的语言中实现,这个比 Python 要复杂一点,但是相信必然难不倒聪明勤奋的程序猿和程序媛!

    鉴于自身水平有限,文章中有难免有些错误,欢迎大家指出。也希望大家可以积极留言,与笔者一起讨论编程的那些事。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值