python线程通信与生产者消费者模式

2,463 阅读11分钟

本文首发于知乎

本文主要讲解生产者消费者模式,它基于线程之间的通信。

生产者消费者模式是指一部分程序用于生产数据,一部分程序用于处理数据,两部分分别放在两个线程中来运行。

举几个例子

  • 一个程序专门往列表中添加数字,另一个程序专门提取数字进行处理,二者共同维护这样一个列表
  • 一个程序去抓取待爬取的url,另一个程序专门解析url将数据存储到文件中,这相当于维护一个url队列
  • 维护ip池,一个程序在消耗ip进行爬虫,另一个程序看ip不够用了就启动开始抓取

我们可以想象到,这种情况不使用并发机制(如多线程)是难以实现的。如果程序线性运行,只能做到先把所有url抓取到列表中,再遍历列表解析数据;或者解析的过程中将新抓到的url加入列表,但是列表的增添和删减并不是同时发生的。对于更复杂的机制,线程程序更是难以做到,比如维护url列表,当列表长度大于100时停止填入,小于50时再启动开始填入。

本文结构

本文思路如下

  • 首先,两个线程维护同一个列表,需要使用锁保证对资源修改时不会出错
  • threading模块提供了Condition对象专门处理生产者消费者问题
  • 但是为了呈现由浅入深的过程,我们先用普通锁来实现这个过程,通过考虑程序的不足,再使用Condition来解决,让读者更清楚Condition的用处
  • 下一步,python中的queue模块封装了Condition的特性,为我们提供了一个方便易用的队列结构。用queue可以让我们不需要了解锁是如何设置的细节
  • 线程安全的概念解释
  • 这个过程其实就是线程之间的通信,除了Condition,再补充一种通信方式Event

本文分为下面几个部分

  • Lock与Condition的对比
  • 生产者与消费者的相互等待
  • Queue
  • 线程安全
  • Event

Lock与Condition的对比

下面我们实现这样一个过程

  • 维护一个整数列表integer_list,共有两个线程
  • Producer类对应一个线程,功能:随机产生一个整数,加入整数列表之中
  • Consumer类对应一个线程,功能:从整数列表中pop掉一个整数
  • 通过time.sleep来表示两个线程运行速度,设置成Producer产生的速度没有Consumer消耗的快

代码如下

import time
import threading
import random
class Producer(threading.Thread):
# 产生随机数,将其加入整数列表
def __init__(self, lock, integer_list):
threading.Thread.__init__(self)
self.lock = lock
self.integer_list = integer_list
def run(self):
while True: # 一直尝试获得锁来添加整数
random_integer = random.randint(0, 100)
with self.lock:
self.integer_list.append(random_integer)
print('integer list add integer {}'.format(random_integer))
time.sleep(1.2 * random.random()) # sleep随机时间,通过乘1.2来减慢生产的速度
class Consumer(threading.Thread):
def __init__(self, lock, integer_list):
threading.Thread.__init__(self)
self.lock = lock
self.integer_list = integer_list
def run(self):
while True: # 一直尝试去消耗整数
with self.lock:
if self.integer_list: # 只有列表中有元素才pop
integer = self.integer_list.pop()
print('integer list lose integer {}'.format(integer))
time.sleep(random.random())
else:
print('there is no integer in the list')
def main():
integer_list = []
lock = threading.Lock()
th1 = Producer(lock, integer_list)
th2 = Consumer(lock, integer_list)
th1.start()
th2.start()
if __name__ == '__main__':
main()

程序会无休止地运行下去,一个产生,另一个消耗,截取前面一部分运行结果如下

integer list add integer 100
integer list lose integer 100
there is no integer in the list
there is no integer in the list
... 几百行一样的 ...
there is no integer in the list
integer list add integer 81
integer list lose integer 81
there is no integer in the list
there is no integer in the list
there is no integer in the list
......

我们可以看到,整数每次产生都会被迅速消耗掉,消费者没有东西可以处理,但是依然不停地询问是否有东西可以处理(while True),这样不断地询问会比较浪费CPU等资源(特别是询问之后不只是print而是加入计算等)。

如果可以在第一次查询到列表为空的时候就开始等待,直到列表不为空(收到通知而不是一遍一遍地查询),资源开销就可以节省很多。Condition对象就可以解决这个问题,它与一般锁的区别在于,除了可以acquire release,还多了两个方法wait notify,下面我们来看一下上面过程如何用Condition来实现

import time
import threading
import random
class Producer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
random_integer = random.randint(0, 100)
with self.condition:
self.integer_list.append(random_integer)
print('integer list add integer {}'.format(random_integer))
self.condition.notify()
time.sleep(1.2 * random.random())
class Consumer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
with self.condition:
if self.integer_list:
integer = self.integer_list.pop()
print('integer list lose integer {}'.format(integer))
time.sleep(random.random())
else:
print('there is no integer in the list')
self.condition.wait()
def main():
integer_list = []
condition = threading.Condition()
th1 = Producer(condition, integer_list)
th2 = Consumer(condition, integer_list)
th1.start()
th2.start()
if __name__ == '__main__':
main()

相比于LockCondition只有两个变化

  • 在生产出整数时notify通知wait的线程可以继续了
  • 消费者查询到列表为空时调用wait等待通知(notify

这样结果就井然有序

integer list add integer 7
integer list lose integer 7
there is no integer in the list
integer list add integer 98
integer list lose integer 98
there is no integer in the list
integer list add integer 84
integer list lose integer 84
.....

生产者与消费者的相互等待

上面是最基本的使用,下面我们多实现一个功能:生产者一次产生三个数,在列表数量大于5的时候停止生产,小于4的时候再开始

import time
import threading
import random
class Producer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
with self.condition:
if len(self.integer_list) > 5:
print('Producer start waiting')
self.condition.wait()
else:
for _ in range(3):
self.integer_list.append(random.randint(0, 100))
print('now {} after add '.format(self.integer_list))
self.condition.notify()
time.sleep(random.random())
class Consumer(threading.Thread):
def __init__(self, condition, integer_list):
threading.Thread.__init__(self)
self.condition = condition
self.integer_list = integer_list
def run(self):
while True:
with self.condition:
if self.integer_list:
integer = self.integer_list.pop()
print('all {} lose {}'.format(self.integer_list, integer))
time.sleep(random.random())
if len(self.integer_list) < 4:
self.condition.notify()
print("Producer don't need to wait")
else:
print('there is no integer in the list')
self.condition.wait()
def main():
integer_list = []
condition = threading.Condition()
th1 = Producer(condition, integer_list)
th2 = Consumer(condition, integer_list)
th1.start()
th2.start()
if __name__ == '__main__':
main()

可以看下面的结果体会消长过程

now [33, 94, 68] after add
all [33, 94] lose 68
Producer don't need to wait
now [33, 94, 53, 4, 95] after add
all [33, 94, 53, 4] lose 95
all [33, 94, 53] lose 4
Producer don't need to wait
now [33, 94, 53, 27, 36, 42] after add
all [33, 94, 53, 27, 36] lose 42
all [33, 94, 53, 27] lose 36
all [33, 94, 53] lose 27
Producer don't need to wait
now [33, 94, 53, 79, 30, 22] after add
all [33, 94, 53, 79, 30] lose 22
all [33, 94, 53, 79] lose 30
now [33, 94, 53, 79, 60, 17, 34] after add
all [33, 94, 53, 79, 60, 17] lose 34
all [33, 94, 53, 79, 60] lose 17
now [33, 94, 53, 79, 60, 70, 76, 21] after add
all [33, 94, 53, 79, 60, 70, 76] lose 21
Producer start waiting
all [33, 94, 53, 79, 60, 70] lose 76
all [33, 94, 53, 79, 60] lose 70
all [33, 94, 53, 79] lose 60
all [33, 94, 53] lose 79
Producer don't need to wait
all [33, 94] lose 53
Producer don'
t need to wait
all [33] lose 94
Producer don't need to wait
all [] lose 33
Producer don'
t need to wait
there is no integer in the list
now [16, 67, 23] after add
all [16, 67] lose 23
Producer don't need to wait
now [16, 67, 49, 62, 50] after add

Queue

queue模块内部实现了Condition,我们可以非常方便地使用生产者消费者模式

import time
import threading
import random
from queue import Queue
class Producer(threading.Thread):
def __init__(self, queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
random_integer = random.randint(0, 100)
self.queue.put(random_integer)
print('add {}'.format(random_integer))
time.sleep(random.random())
class Consumer(threading.Thread):
def __init__(self, queue):
threading.Thread.__init__(self)
self.queue = queue
def run(self):
while True:
get_integer = self.queue.get()
print('lose {}'.format(get_integer))
time.sleep(random.random())
def main():
queue = Queue()
th1 = Producer(queue)
th2 = Consumer(queue)
th1.start()
th2.start()
if __name__ == '__main__':
main()

Queue

  • get方法会移除并赋值(相当于list中的pop),但是它在队列为空的时候会被阻塞(wait)
  • put方法是往里面添加值
  • 如果想设置队列最大长度,初始化时这样做queue = Queue(10)指定最大长度,超过这个长度就会被阻塞(wait)

使用Queue,全程不需要显式地调用锁,非常简单易用。不过内置的queue有一个缺点在于不是可迭代对象,不能对它循环也不能查看其中的值,可以通过构造一个新的类来实现,详见这里

下面消防之前Condition方法,用Queue实现生产者一次加3个,消费者一次消耗1个,每次都返回当前队列内容,改写代码如下

import time
import threading
import random
from queue import Queue
# 为了能查看队列数据,继承Queue定义一个类
class ListQueue(Queue):
def _init(self, maxsize):
self.maxsize = maxsize
self.queue = [] # 将数据存储方式改为list
def _put(self, item):
self.queue.append(item)
def _get(self):
return self.queue.pop()
class Producer(threading.Thread):
def __init__(self, myqueue):
threading.Thread.__init__(self)
self.myqueue = myqueue
def run(self):
while True:
for _ in range(3): # 一个线程加入3个,注意:条件锁时上在了put上而不是整个循环上
self.myqueue.put(random.randint(0, 100))
print('now {} after add '.format(self.myqueue.queue))
time.sleep(random.random())
class Consumer(threading.Thread):
def __init__(self, myqueue):
threading.Thread.__init__(self)
self.myqueue = myqueue
def run(self):
while True:
get_integer = self.myqueue.get()
print('lose {}'.format(get_integer), 'now total', self.myqueue.queue)
time.sleep(random.random())
def main():
queue = ListQueue(5)
th1 = Producer(queue)
th2 = Consumer(queue)
th1.start()
th2.start()
if __name__ == '__main__':
main()

得到结果如下

now [79, 39, 64] after add
lose 64 now total [79, 39]
now [79, 39, 9, 42, 14] after add
lose 14 now total [79, 39, 9, 42]
lose 42 now total [79, 39, 9]
lose 27 now total [79, 39, 9, 78]
now [79, 39, 9, 78, 30] after add
lose 30 now total [79, 39, 9, 78]
lose 21 now total [79, 39, 9, 78]
lose 100 now total [79, 39, 9, 78]
now [79, 39, 9, 78, 90] after add
lose 90 now total [79, 39, 9, 78]
lose 72 now total [79, 39, 9, 78]
lose 5 now total [79, 39, 9, 78]

上面限制队列最大为5,有以下细节需要注意

  • 首先ListQueue类的构造:因为Queue类的源代码中,put是调用了_putget调用_get_init也是一样,所以我们重写这三个方法就将数据存储的类型和存取方式改变了。而其他部分锁的设计都没有变,也可以正常使用。改变之后我们就可以通过调用self.myqueue.queue来访问这个列表数据
  • 输出结果很怪异,并不是我们想要的。这是因为Queue类的源代码中,如果队列数量达到了maxsize,则put的操作wait,而put一次插入一个元素,所以经常插入一个等一次,循环无法一次运行完,而print是在插入三个之后才有的,所以很多时候其实加进去值了却没有在运行结果中显示,所以结果看起来比较怪异。所以要想灵活使用还是要自己来定义锁的位置,不能简单依靠queue

另外,queue模块中有其他类,分别实现先进先出、先进后出、优先级等队列,还有一些异常等,可以参考这篇文章官网

线程安全

讲到了Queue就提一提线程安全。线程安全其实就可以理解成线程同步。

官方定义是:指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。

我们常常提到的说法是,某某某是线程安全的,比如queue.Queue是线程安全的,而list不是。

根本原因在于前者实现了锁原语,而后者没有。

原语指由若干个机器指令构成的完成某种特定功能的一段程序,具有不可分割性;即原语的执行必须是连续的,在执行过程中不允许被中断。

queue.Queue是线程安全的,即指对他进行写入和提取的操作不会被中断而导致错误,这也是在实现生产者消费者模式时,使用List就要特意去加锁,而用这个队列就不用的原因。

Event

EventCondition的区别在于:Condition = Event + Lock,所以Event非常简单,只是一个没有带锁的Condition,也是满足一定条件等待或者执行,这里不想说很多,只举一个简单的例子来看一下

import threading
import time
class MyThread(threading.Thread):
def __init__(self, event):
threading.Thread.__init__(self)
self.event = event
def run(self):
print('first')
self.event.wait()
print('after wait')
event = threading.Event()
MyThread(event).start()
print('before set')
time.sleep(1)
event.set()

可以看到结果

first
before set

先出现,1s之后才出现

after wait

欢迎关注我的知乎专栏

专栏主页:python编程

专栏目录:目录

版本说明:软件及包版本说明