Python实现生产者消费者模型-多进程与多线程处理

一、背景

生产者消费者模型是一个很有意思的、高效率的模型,在任何多步骤、大规模(数据)需要处理的场景都可以使用。

想想我们要实现一个小型工厂,工人们是cpu、GPU等,每个工人要干几种不同的活儿。工人之间形成流水线,工人自己的工作也是有先后顺序。根据一定的条件,半成品在工人之间转移以加工,工人也要先忙完一个活儿才能释放自己的精力去再做另一个活儿;不过工人不用线性去完成手里的工作,如果有某个活儿一时半会儿做不了,就先去做其他的活儿,这样到最后剩下的就是还没有做的活儿,如果时间久了还没有做完,可能就是有问题的活儿,可以采取其他措施,比如强行不做了等。

我要怎么做才能实现呢?很有意思的技术。

中间“废话”比较多,直接到最后看代码。

二、设计思路:概念和行动

设计流程,遵循 概念集合 + 行动集合 的理念。

2.1 概念集合

  1. 车间:某种类型工人的组织。
  2. 半成品仓库:要交给工人去进一步加工的物品集合。
  3. 成品库:由工人加工完成后的物品集合。
  4. 仓库管理员:需要不停检查半成品仓库当前库存的人,并检测是否仓库物品已经全部收到,然后告知所有人后边不会有新的物品。
  5. 工人:实现半成品到成品的执行任务。
  6. 流水线:送进来半成品并运走成品。
  7. 终止信号:来自上游的终止信号。

2.2 行动集合

  1. 干活:不同车间的工人,有不同的任务执行方法。
  2. 取半成品:从流水线上取半成品。
  3. 放成品:把做完的成品放到流水线上。
  4. 发终止信号:车间内所有工人的工作完成后,并收到了上游的终止信号后,给下游车间发送终止信号。
  5. 停止干活:收到停止信号且当前工作都完成了,就不干了。
  6. 丢弃当前任务:当前任务可能由于一些原因,耗费了很久还没有完成,那就丢弃,继续接其他单子。
  7. 检查上游流水线:看看是否还有东西在上游流水线,若有就得继续干活。

2.3 每个概念与概念、概念与行动的关系

2.3.1 车间

  1. 检查上游流水线
  2. 分配工人工作
  3. 让工人把成品送到下游流水线
  4. 检查是否收到终止信号
  5. 等工人完成所有上游流水线后,并在接收到终止信号后,发送下游终止信号。
  6. 过程中:就是等终止信号。

2.3.2 工人

  1. 从上游流水线获取半成品
  2. 加工半成品
  3. 把成品放入到下游流水线
  4. 检查是否收到终止信号
  5. 检查是否上游流水线是否还有东西
  6. 停止工作

2.4 工厂图解

  1. 车间工人干活
半成品库流水线
车间1-工人1
车间1-工人2
车间1-工人...
成品库流水线

对于更复杂的流程,基本是在这个框架上叠加更多层的 半成品->工人->成品 这个框架,以及多个半成品库组合成一个汇总的半成品库给到组装工人去组装罢了。

  1. 车间收到终止信号
上游终止信号
等待所有worker停工
发送下游终止信号
  1. 工人收到终止信号
上游终止信号
半成品流水线为空
半成品流水线非空
停止工作
完成当前工作

3. Python工具

我准备用python代码来抽象并实现这样一个概念-行动集合体。为了方便复用,可以通过以下几个方式来使用代码:

  1. 继承:继承我写的基类,根据实际业务需要,重写必要的函数来实现具体的功能。
  2. 实例化:通过输入一些处理函数,实例化具体的类。用户只要按照要求写好处理函数,经过组装即可。

3.1 进程和线程基础知识

这里简单讲一下进程和线程的概念和区别。
在操作系统中,进程是资源分配的基本单位,而线程是CPU调度的基本单位。同一个进程内的多个线程,共享该进程的地址空间、包括进程的代码段、数据段、堆内存等资源。因此,进程中的成员变量对于该进程内的所有线程都是可见的,线程可以直接访问和修改这些成员变量,从而实现线程间的通信。

而进程之间的空间是独立,所以往往进程间通信比较麻烦,需要用到Multiprocessing库里的类,这个类是基于操作系统底层来实现共享的。

3.2 多进程的python实现

每个进程用来模拟一个工人。

# 常用的库是Multiprocessing.Process类,常见的做法是继承该类,并改写run()函数。运行时使用 start()函数会自动调用run函数;默认子进程启动和运行不会阻塞主进程;如要等子进程完成后再运行主进程,可以使用join()函数。
# 对于共享数据,需要在类初始化时,将共享数据作为参数进行输入,作为成员变量。

Import multiprocessing as mp

shared_queue = mp.Queue()

Class SampleProcess(mp.Process):
    def __init__(self, shared_queue):
        super(SampleProcess).__init__()
        self.shared_queue = shared_queue
        
    def run(self, **args):
        pass

# 启动子进程,这默认是异步的,即子进程的结果不会阻塞主进程,主进程的代码会继续运行。
child_process = SampleProcess(shared_queue)
child_process.start()

# 若要等待子进程或者子线程完成后再进行主进程的代码,则用join()方法。

for worker in workers:
    worker.join()
    
print("all worker has done")

# 获取子进程的结果,除了使用共享数据的方法之外,还可以手工来获取每个子进程的结果,用类似的多线程的进程池的方法。

import concurrent.futures as cf

def task(x):
    return x ** 2

with cf.ProcessPoolExecutor() as executor:
    data = [1, 2, 4]
    futures = [executor.submit(task, i) for i in data]
    for future in cf.as_complted(futures):
        try:
            result = future.result(timeout=10)
            print("work done, the result is %s" % result)
        except cf.TimeoutError as e:
            print("超时了")
        except Exceptions as e:
            print("进程遇到其他错误: %s" % e)


3.3 多线程的python实现

每个线程用来模拟每个工人要做的工作。通常,每个工人要去查看半成品仓库的信息,有活儿就干,没有就等着。所以至少可以分成两类任务,一种是具体干活的任务,一种是检查状态的任务,两者有一定的先后关系。

此外,工人可以同时干多个相同或者不同的、无先后顺序之分的活儿,谁先干完都行。

# 我比较了一下,建议使用concurrent.futures这个包更方便。它用来管理线程池子,不同的线程可以都放到这个池子里进行管理,不用手动的去管理每个线程的开始、运行和销毁。并且也更容易获取每个线程的运行结果。

import concurrent.futures
import threading

# 锁和共享变量要在同一个“层级”,即它们的作用域要能够覆盖到所有需要访问到该变量的线程。比如,可以同时定义在一个Process子类中。
# 如果可能要跨进程跨线程来做,那么就要定义在更大的层级内。
shared_variable = 0
lock = threading.Lock()

def task():
    global shared_variable
    with lock:
        shared_variable += 1
    return shared_variable
    
# 创建 excutor这个线程管理器
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
    # 提交一个线程任务并记录到队列中
    futures = [executor.submit(task, i) for i in range(5)]
    # as_completed会返回一个迭代器,把先完成的任务返回,未完成的,则会阻塞直至完成。
    for future in concurrent.futures.as_completed(futures):
        # 线程执行过程中可能会出错,需要有回收机制。
        try:
            # 有时候我们不希望一个线程无限期执行而影响全局代码无法继续,可以设置一个超时时间。
            result = future.result(timeout=3)
            print(f"Task result: {
     
     result}"
        # 超时错误的情况
        except concurrent.futures.TimeoutError:
            print("任务执行超时,返回None")
            result = None
        # 其他错误的情况
        except Exception as e:
            print(f"An error occured: {
     
     e}"")


3.4 共享数据

共享的队列、数据、数列、事件等,用来模拟各种仓库、指令等,用于信息传递。

import multiprocessing as mp

# 共享队列,相当于list,可以存放各种类型的数据,不过为了处理方便,最好是同一个类型的数据。队列是先进先出的。栈是后进先出的。

# 创建一个可共享的队列并设置最大不超过20个元素,否则可能会阻塞或者报异常
data_queue = mp.Queue(maxsize=20)
# 往队列里添加元素item;block为True时,意味着队列满了就会阻塞等待,如果设置为False,就会抛出Full异常,默认为True;timeout仅在block为True时有效,如果设置了timeout,则当队列满了的时候,阻塞超过了timeout时间就会抛出mp.queues.Full异常。
data_queue.put(item=XX, block=True, timeout=XX秒)

# 提取数据,block参数如果设置为True,则当队列为空时,会阻塞,False则直接抛出Empty异常,默认为True;Timeout同样是Block为True时,阻塞时长超过了timeout时,就抛出mp.queues.Empty异常。
result = data_queue.get(timeout=XX秒)

# 共享单个数值: 共享值的数据类型,int缩写为i,double缩写为d,float应该缩写为f;*args表示用于初始化共享值的参数,取决于具体的数据类型;lock则默认为True,表示自动为共享值创建一个锁,以确保线程安全;如果设置为False,则不会创建锁,需要手动管理同步。
# data_value = mp.Value(typecode_or_type, *args, lock=True)
data_value = mp.Value('int', 2)

# 修改共享值
data_value.value = 10

# 多进程访问时要上锁
def increment(shared_value):
    # 获取锁
    lock = shared_value.get_lock()
    with lock:
        # 修改共享值
        shared_value.value += 1
    print(f"当前值: {
     
     shared_value.value}")
    

# 如果要手动上锁
# 创建一个共享的整数变量,初始值为 0,不自动创建锁
shared_int = multiprocessing.Value('i', 0, lock=False)
# 手动创建一个锁
lock = multiprocessing.Lock()

def increment(shared_value, lock):
    # 获取锁
    with lock:
        # 修改共享值
        shared_value.value += 1
    print(f"当前值: {
     
     shared_value.value}")
    
    
# 使用mp.Evaent来设置共享开关。它有两种状态,set和clear。初始状态下,默认是 clear状态。
shared_event = mp.Event()
# 将状态改为set
shared_event.set()
# 将状态改为clear
shared_event.clear()
# 检查状态是否为set
if shared_event.is_set():
    print("it's set")
else:
    print("it's clear")
# 等待开关被set;如果被set,则返回True;如果没有被set,会被阻塞;若设定有timeout,则在阻塞指定时间内被set,就返回True,并不再阻塞;否则,超出时间后返回False。
shared_event.wait(timeout=None)


4. 代码示例

4.1 原型代码

我们创建了基类用于继承或者定制实例化来实现工厂模型。代码可以直接拷贝到py文件中运行。

import multiprocessing as mp
from queue import Empty
import concurrent.futures as cf
import time

# 定义一个工人,应该继承自一个进程,输入半成品队列,输出成品队列
# 这个Worker是个基类,应该被不同工种的工人来继承,重写process_task方法,处理任务。
class Worker(mp.Process):
    def __init__(self, task_queue: mp.Queue, result_queue: mp.Queue, end_signal: mp.Event):
        mp.Process.__init__(self)
        self.task_queue = task_queue
        self.result_queue = result_queue
        self.end_signal = end_signal
        self.task_seq = 0   # 记录当前完成的是第几个任务,用于演示多线程和锁。# 这里要用multiprocessing的lock,而不能是threading的lock,因为无法被子进程序列化。
        self.thread_lock = mp.Lock()

    def run(self):
        # 重写父类的run方法
        # 工人停止工作的条件是以下两点同时满足:
        # 1. 收到停止信号;
        # 2. 没有任务了。
        while not (self.end_signal.is_set() and self.task_queue.empty()):
            # 如果有任务,就取出来,处理,然后放入结果队列
            try:
                thread_tasks = []

                with cf.ThreadPoolExecutor(max_workers=12) as executor:
                    for i in range(12):
                        # 每12个任务建成一个batch
                        try:
                            task = self.get_task(timeout=1)
                            thread_tasks.append(executor.submit(self.process_task, task))
                        except Empty:
                            print("1s内暂时没接到更多活了,就先处理手头的工作")
                            break
                    
在 C# 中,`ConcurrentQueue<T>` 并没有直接提供 `Clear` 方法来清空队列。这是因为 `ConcurrentQueue<T>` 是为多线程环境设计的,而直接清空队列可能会导致线程安全问题。为了清空一个 `ConcurrentQueue<T>`,可以通过以下方法实现: ### 方法一:创建一个新的 `ConcurrentQueue<T>` 通过创建一个新的 `ConcurrentQueue<T>` 实例,可以有效地“清空”旧的队列。 ```csharp using System; using System.Collections.Concurrent; public class Program { public static void Main() { ConcurrentQueue<int> queue = new ConcurrentQueue<int>(); queue.Enqueue(100); queue.Enqueue(200); queue.Enqueue(300); Console.WriteLine("Before Clear:"); PrintQueue(queue); // 创建一个新的 ConcurrentQueue 实例 queue = new ConcurrentQueue<int>(); Console.WriteLine("After Clear:"); PrintQueue(queue); } private static void PrintQueue(ConcurrentQueue<int> queue) { while (queue.TryDequeue(out int item)) { Console.WriteLine(item); } } } ``` 这种方法简单且线程安全,但需要注意的是,这会改变原始队列的引用[^1]。 --- ### 方法二:通过循环尝试移除所有元素 可以使用 `TryDequeue` 方法不断尝试从队列中移除元素,直到队列为空为止。 ```csharp using System; using System.Collections.Concurrent; public class Program { public static void Main() { ConcurrentQueue<int> queue = new ConcurrentQueue<int>(); queue.Enqueue(100); queue.Enqueue(200); queue.Enqueue(300); Console.WriteLine("Before Clear:"); PrintQueue(queue); // 循环移除所有元素 while (queue.TryDequeue(out _)) { } Console.WriteLine("After Clear:"); PrintQueue(queue); } private static void PrintQueue(ConcurrentQueue<int> queue) { foreach (var item in queue) { Console.WriteLine(item); } } } ``` 这种方法不会改变队列的引用,但在多线程环境中需要特别注意其他线程可能正在向队列中添加元素[^2]。 --- ### 注意事项 - 如果在多线程环境中使用 `ConcurrentQueue<T>`,确保清空操作不会影响其他线程的行为。 - 清空队列时,可以选择适合项目需求的方法。如果需要保持队列引用不变,推荐使用 `TryDequeue` 的方式;如果可以接受更改引用,则直接创建新的实例更为高效[^3]。 --- ### 示例输出 假设初始队列为 `{100, 200, 300}`,执行上述代码后,队列将被清空,并输出如下内容: ``` Before Clear: 100 200 300 After Clear: ``` ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值