Python 实现生产者-消费者问题(进程、线程、协程)

Python 并发 专栏收录该内容
0 篇文章 0 订阅

Python 实现生产者-消费者问题(进程、线程、协程)

生产者-消费者问题

生产者消费者问题描述如下:

一组生产者进程和一组消费者进程共享一个初始为空、固定大小为 n 的缓冲区
生产者的工作是生成一个产品,只有缓冲区不为满时,生产者才能把产品放入到缓冲区,否则必须等待
消费者的工作是消费一个产品,只有缓冲区不为空时,消费者才能从缓冲区中取出产品,否则必须等待

如果仅有一个生产者进程和一个消费者进程,我们可以假定生产者每次生产一个产品,就把该产品添加到缓冲区尾部(如果此时缓冲区不为满),假定消费者每次从缓冲区头部取出产品进行消费(如果此时缓冲区不为空),则两个进程之间虽然共享缓冲区,但操作的位置并不一样,并没有临界区特性,在不使用变量(临界区资源)记录产品个数的情况下,可以同时进行读写操作

如果不止一个的生产者进程或者不止一个的消费者进程,那么生产者之间或者消费者之间对缓冲区操作的位置是一样的,具有临界区特性,不能同时被一个以上的生产者或者一个以上的消费者访问(或者说不可进行写操作)

显然,这个问题其实就是对临界区资源(产品存放的缓冲区,记录产品个数的变量等)的访问控制,不能让两个进程同时对临界区资源进行写操作(某些特殊情况下,不能同时进行读操作)

对此,我们可以使用如下方法限制对临界区的访问: 对临界区资源是否被某个进程占用进行标记。当某个进程进入临界区时,标记临界区资源的状态为被占用,其他需要访问临界区的进程则需要等待。直至占用该临界区资源的进程结束访问,退出临界区,此时把标记该成未被占用,等待下一个进程进入临界区

至于怎么选择下一个进入临界区的进程(如果不止一个进程要访问临界区),怎样避免出现饥饿和死锁,这里就不多描述。因为我们后面使用到的方法,并不需要考虑这些问题

多线程

要说并发,我 可能会首选线程

线程是系统调度的最小单位,对比起进程,它更加轻便
多个线程在同一个进程中运行,创建一个新线程不需要复制堆内存、代码段等可以在线程间共享的资源,这无疑大大地减少了时间和空间的开销
而且,因为共享内存,要实现线程之间的通信十分方便(如果你用 C 写过进程共享内存,那你很大可能会赞同)

Python 中有多个可以实现多线程的库,我这里选用的是 threading 库,这里面提供有线程间的互斥锁threading.Lock
threading.Lock是不被某个特定线程所拥有的,一个线程获取了锁,之后任意线程(包括这个获取了锁的线程本身)尝试获取锁,都会失败

Lock 对象的两个主要方法:

lock.acquire(blocking=True, timeout=-1)
该方法用于尝试获取锁,成功返回 True,失败返回 False
blocking 参数,决定没有成功获取锁时是否堵塞线程,默认为 True,阻塞线程
timeout 参数,等待获取锁的时间,默认为 -1,表示一直等待
timeout 为正数时,blocking 应当设为 True,等待时间不为 0 表示阻塞线程

lock.release() 用于释放锁

我们用一个线程表示一个生产者或者一个消费者,使用多线程实现生产消费的过程,代码如下:

# 生产者消费者问题 --- 多线程实现

import time
import random
from threading import Thread, Lock

lock = Lock()  # 锁

n = 10
buf = []

class producer(Thread):
	def __init__(self, num):
		super().__init__()
		self.num = num
		self.run_flag = True
	
	def run(self):
		count = 0
		while self.run_flag:
			t = random.uniform(0.1, 1.2)
			time.sleep(t)  # 模拟生产时间
			p = chr(random.randint(65, 90))  # 产品是随机生成的大写的英文字母
			with lock:
				if len(buf) < n:
					print('生产者 {} 花费时间 {:.3f} 生成一个产品 {}'.format(self.num, t, p))
					buf.append(p)  # 加入缓冲区
					print('--------------', buf, '---------------')
				else:
					count += 1
			if count == 7:
				self.stop()
		print(f'生产者 {self.num} 退出')

	def stop(self):
		self.run_flag = False


class consumer(Thread):
	def __init__(self, num):
		super().__init__()
		self.num = num
		self.run_flag = True
	
	def run(self):
		count = 0
		while self.run_flag:
			t = random.uniform(0.1, 1.2)
			time.sleep(t)  # 模拟消费时间
			with lock:
				if len(buf) > 0:
					print('消费者 {} 花费时间 {:.3f} 消费一个产品 {}'.format(self.num, t, buf[0]))
					del buf[0]  # 移出缓冲区
					print('--------------', buf, '---------------')
				else:
					count += 1
			if count == 4:
				self.stop()
		print(f'消费者 {self.num} 退出')

	def stop(self):
		self.run_flag = False

if __name__ == '__main__':
	pros = []
	cons = []
	for i in range(3):
		p = producer(i)
		p.start()
		pros.append(p)
	for i in range(2):
		c = consumer(i)
		c.start()
		cons.append(c)
	for p in pros:
		p.join()
	for c in cons:
		c.join()

避免出现死循环,我设置了一个退出条件:
当一个生产者因为缓冲区满而需要等待的次数够 7 次(为什么是 7?别问,随便设的),那就结束生产,线程退出执行队列
当一个消费者因为缓冲区空而需要等待的次数够 4 次,就结束消费,线程退出

说到这里,有一个需要注意的,如果生产者遇到缓冲区满,则会丢弃当次生成的产品,在下一次重新生成新的产品
这显然有点不符合环保意识,这是不值得提倡的!应当被抵制!……

好了,回到重点
在这个代码里,我加了一把锁,当有线程准备访问临界区资源(产品存放的缓冲区)时,就尝试获取锁。如果成功获取锁则进入临界区,继续访问操作(生产者放入产品,消费者取出产品),等待操作结束,退出临界区,再释放锁。每一次只有一个线程可以获取锁,确保同一时间只有一个线程可以访问临界区资源

但其实,因为 GIL 的存在,不需要再加锁,也可以对临界区资源的访问进行限制

GIL

GIL 全称 Global Interpreter Lock,即全局解释器锁
简单来说,把 Python 解释器(默认是 CPython 版本)作为共享资源,GIL 就是一个保护解释器资源的锁,它确保在 Python 的虚拟机中同一时刻只有一个线程在执行

因为 GIL 这把大锁,每次都只有一个线程在运行,所以上面我们其实不需要再额外加锁。当然,这也是因为我们这个代码里的单个生产和消费的过程都是原子操作,不会被线程调度打断

GIL 保证了多线程的安全运行,开发者几乎不用担心多个线程间数据一致性和状态同步的问题。但显然,即便你的计算机是多核 cpu(现在差不多都是),Python 的多线程也无法把多个处理器都运用上,它不能实现真正的并行,无法发挥多核的优势,这无疑是一笔很大的浪费

进程池

既然 Python 的多线程有所限制,我们不妨再试试进程并发

在 Unix/Linux 中,我们使用 fork() 创建一个子进程,Python 中的 os 模块就有 fork() 的调用
但 Windows 并没有 fork() 调用,所以我们需要在 Python 虚拟机模拟一个 fork() 的调用效果

Python 提供了 multiprocessing 库,该库模块包装了底层的机制,支持在 Windows 平台上的 Python 多进程编程
同时,multiprocessing 还提供了进程间的通信机制,比如一会我们准备用到的 Queue

我们这里选用 multiprocessing 模块中的 Pool 类来实现多进程并发,Pool 生成的进程池,可以在多个处理器上执行,实现并行

在进程池中通信,要使用 Manager 类中的 Queue,加锁也同样要使用 Manager 类中的 Lock

代码如下:

# 生产者消费者_进程池

import time
import random
from multiprocessing import Pool, Manager

def producer(num, buf, lock):
	run_flag = True
	count = 0
	while run_flag:
		t = random.uniform(0.1, 1.2)
		time.sleep(t)  # 模拟生产时间
		p = chr(random.randint(65, 90))  # 产品是随机生成的大写的英文字母
		with lock:
			f = open('mess.txt', 'a')
			if not buf.full():
				#print('生产者 {} 花费时间 {:.3f} 生成一个产品 {}'.format(num, t, p))
				buf.put(p)  # 加入缓冲区
				#print(f'此时,缓冲区中的产品个数为 {buf.qsize()}')
				f.write('生产者 {} 花费时间 {:.3f} 生成一个产品 {}\n'.format(num, t, p))
				f.write(f'此时,缓冲区中的产品个数为 {buf.qsize()}\n')
				f.close()
			else:
				count += 1
		if count == 5:
			run_flag = False
	return f'生产者 {num} 退出'

def consumer(num, buf, lock):
	run_flag = True
	count = 0
	while run_flag:
		t = random.uniform(0.1, 1.2)
		time.sleep(t)  # 模拟消费时间
		with lock:
			f = open('mess.txt', 'a')
			if not buf.empty():
				p = buf.get()  # 从缓冲区中读取并删除
				#print('消费者 {} 花费时间 {:.3f} 消费一个产品 {}'.format(num, t, p))
				#print(f'此时,缓冲区中的产品个数为 {buf.qsize()}')
				f.write('消费者 {} 花费时间 {:.3f} 消费一个产品 {}\n'.format(num, t, p))
				f.write(f'此时,缓冲区中的产品个数为 {buf.qsize()}\n')
				f.close()
			else:
				count += 1
		if count == 3:
			run_flag = False
	return f'消费者 {num} 退出'


if __name__ == '__main__':
	manager = Manager()  # 进程池中使用队列需要 Manager 类
	n = 10
	buf = manager.Queue(n)  # 通信队列
	lock = manager.Lock()  # 锁

	pool = Pool()  # 进程池,省略参数,默认使用 cpu 数量

	proces = []
	for i in range(2):
		proces.append(pool.apply_async(producer, (i, buf, lock,)))
		proces.append(pool.apply_async(consumer, (i, buf, lock,)))

	pool.close()  # 不再加入新进程
	pool.join()

	for it in proces:
		print(it.get())

因为 Windows 没有 fork() 调用,我们在模拟 fork() 创建出来的子进程中,使用 print 输出可能会出现一些小问题(它有可能不是按照时间同步的顺序输出),所以我把输出信息全部写入到文件中,每次写入后 flush

协程

前面说过,因为 GIL 的存在,Python 多线程是串行的(同时只有一个线程在工作)
虽然是串行,但每个线程之间都需要进行切换,这也是需要花销时间的
对于那些需要频繁进行线程切换的程序(IO密集型)来说,单单是切换线程所花费的时间就是一笔大开销

有没有好的改进方法?

到这里就不得不介绍一下协程了
可以说,协程之于线程,仿若线程之于进程
协程是运行在同一个线程中的一组事件,由人工进行调度(系统可以调度的最小单位是线程,而协程比线程更小),事件切换所花销的时间几乎可以忽略不计
简单说,协程是在单进程单线程中就可以实现并发的大利器

在 Python 中可以实现协程的库也有很多,我这里用的是 asyncio
asyncio 中,使用 event_loop 事件循环作为执行队列
使用 create_task 可以把准备运行的事件封装,然后注册到事件循环中,进行调度
每当一个正在运行的事件遇到需要等待的操作时,就可以使用 await 把 cpu 使用权让出,由 event_loop 决定下一个运行的事件(循环加 1),该事件获取 cpu 使用权

同样,一个事件表示一个生产者或者一个消费者,实现生产消费过程
使用协程的代码如下:

# 生产者消费者 --- asyncio 协程实现

import random
import asyncio

n = 10
buf = []

async def producer(num):
	run_flag = True
	count = 0
	while run_flag:
		t = random.uniform(0.1, 1.2)
		await asyncio.sleep(t)  # 模拟生产时间
		p = chr(random.randint(65, 90))  # 产品是随机生成的大写的英文字母
		if len(buf) < n:
			print('生产者 {} 花费时间 {:.3f} 生成一个产品 {}'.format(num, t, p))
			buf.append(p)  # 加入缓冲区
			print('--------------', buf, '---------------')
		else:
			count += 1
		if count == 5:
			run_flag = False
	print(f'生产者 {num} 退出')

async def consumer(num):
	run_flag = True
	count = 0
	while run_flag:
		t = random.uniform(0.1, 1.2)
		await asyncio.sleep(t)  # 模拟消费时间
		if len(buf) > 0:
			print('消费者 {} 花费时间 {:.3f} 消费一个产品 {}'.format(num, t, buf[0]))
			del buf[0]  # 移出缓冲区
			print('--------------', buf, '---------------')
		else:
			count += 1
		if count == 3:
			run_flag = False
	print(f'消费者 {num} 退出')


if __name__ == '__main__':
	loop = asyncio.get_event_loop()  # 创建一个事件循环,并设为当前循环
	pros = [loop.create_task(producer(i)) for i in range(3)]  # 生产者事件组
	cons = [loop.create_task(consumer(i)) for i in range(2)]  # 消费者事件组
	tasks = pros + cons
	print(tasks)  # 查看事件列表
	loop.run_until_complete(asyncio.wait(tasks))  # 把事件列表加入到循环中运行,并等待至所有事件运行结束
	print(tasks)  # 再看一次
	loop.close()

咦!
暂时就先这样吧
后续又补充再修改
……

  • 3
    点赞
  • 3
    评论
  • 10
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

打赏
文章很值,打赏犒劳作者一下
相关推荐
©️2020 CSDN 皮肤主题: 书香水墨 设计师:CSDN官方博客 返回首页

打赏

Bcdfxg

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值