[分享] Python Multiprocessing

383 阅读7分钟

Why Multiprocessing?

为了绕过GIL(全局解释器锁)。

那么GIL是个什么东西呢?和垃圾回收和资源管理有关。

Python中对象的管理和引用计数器有密切关系,当一个对象的计数器变为0的时候,那么就意味着该对象可以被GC,在Python中撤销对一个对象的引用有如下两个步骤:

  1. 引用计数值减1

  2. 判断该计数值是否为0,如果为0则销毁该对象

由于这两个步骤并不是原子性的,所以为了避免并发问题,引入了GIL保证对虚拟机内部共享资源的访问互斥。GIL的存在使运用多核计算能力成为障碍,但是让编程模型变的简单。

早在1999年,针对Python1.5,就有补丁尝试来解决GIL在多核计算资源上的弊端,这个补丁尝试使用细粒度的锁来替换GIL。但是效果并不理想,多核多线程的速度提升并没有随着核数的增加而线性增长,反而降低了单线程的执行速度大约40%,因此该方案被放弃。

在Python3.2中,对GIL进行了重新实现。有两个方面的改进:

  1. 使用固定时间而不是固定数量的操作指令来进行线程的强制切换

  2. 线程释放GIL后开始等待,直到某个其他线程获取GIL后再开始尝试获取GIL,避免一个线程一直执行

但是,以上的两个优化对于如今多核计算资源已成为主流的大背景下,都解决不了根本问题。

所以为了绕过GIL的限制,就有了multiprocessing这个模块。

基础概念

定义进程的API很直观:

  1. 进程需要执行的方法 - target

  2. 进程需要执行的方法的入参 - 以args传入

import multiprocessing 
  
def print_cube(num): 
    """ 
    计算立方值 
    """
    print("Cube: {}".format(num * num * num)) 
  
def print_square(num): 
    """ 
    计算平方值
    """
    print("Square: {}".format(num * num)) 
  
if __name__ == "__main__": 
    # 创建进程
    p1 = multiprocessing.Process(target=print_square, args=(10, )) 
    p2 = multiprocessing.Process(target=print_cube, args=(10, )) 
  
    # 启动进程
    p1.start() 
    p2.start() 
  
    # 主进程等待计算进程完成
    p1.join() 
    p2.join() 
  
    print("Done!") 

需要注意的点:

每个进程独立运行,有自己的内存空间,哪怕定义了global的全局变量也不会是进程间共享的

import multiprocessing 
  
# 全局变量
result = [] 
  
def square_list(mylist): 
    """ 
    对给定的list进行平方计算
    """
    global result 
    for num in mylist: 
        # 添加结果到全局变量中
        result.append(num * num) 
    print("Result(in process p1): {}".format(result)) 
  
if __name__ == "__main__": 
    mylist = [1,2,3,4] 
  
    p1 = multiprocessing.Process(target=square_list, args=(mylist,)) 
    p1.start() 
    p1.join() 
  
    # 打印全局变量
    print("Result(in main program): {}".format(result)) 

最后的输出:

Result(in process p1): [1, 4, 9, 16]
Result(in main program): []

image

每个进程都有自己独立的内存空间,global变量也不会互相共享。因为在python中的global变量并不是进程间通信的shared memory。

进程间通信

上面说到了使用global没法实现进程间的通信,但是很显然进程间通信这个诉求是很常见的。

multiprocessing模块提供了几种进程间的通信方式:

  1. 基于共享内存 - Array/Value

  2. 基于Manager(Server Process)

  3. 基于Queue/Pipe

共享内存

import multiprocessing 
  
def square_list(mylist, result, square_sum): 
    """ 
    计算平方值
    """
    for idx, num in enumerate(mylist): 
        # 结果写回共享Array
        result[idx] = num * num 
  
    # 结果写回共享Value
    square_sum.value = sum(result) 

    print("Result(in process p1): {}".format(result[:])) 
    print("Sum of squares(in process p1): {}".format(square_sum.value)) 
  
if __name__ == "__main__": 
    mylist = [1,2,3,4] 
  
    # 创建一个int类型,容量为4的共享内存数组
    # i - int类型
    # d - float类型
    # c - 字符串类型
    result = multiprocessing.Array('i', 4) 
  
    # 创建一个int类型的共享内存变量
    square_sum = multiprocessing.Value('i') 
  
    # 创建进程,注意args中传入了上面初始化的两个共享内存变量
    p1 = multiprocessing.Process(target=square_list, args=(mylist, result, square_sum)) 
  
    p1.start() 
    p1.join() 
  
    print("Result(in main program): {}".format(result[:])) 
    print("Sum of squares(in main program): {}".format(square_sum.value)) 

最终的输出:

Result(in process p1): [1, 4, 9, 16]
Sum of squares(in process p1): 30
Result(in main program): [1, 4, 9, 16]
Sum of squares(in main program): 30

主进程和创建的进程输出一致,表明共享内存变量起到了作用。

image

Manager(Server Process)

基于Manager的共享方式:

  1. 可以支持任意类型

  2. 网络中的多台机器中的进程可以共享,但是速度比共享内存的方式慢

import multiprocessing 
  
def print_records(records): 
    """ 
    打印共享变量
    """
    for record in records: 
        print("Name: {0}\nScore: {1}\n".format(record[0], record[1])) 
  
def insert_record(record, records): 
    """ 
    给共享变量中添加记录
    """
    records.append(record) 
    print("New record added!\n") 
  
if __name__ == '__main__': 
    with multiprocessing.Manager() as manager: 
        # 在server process内存空间中创建list
        records = manager.list([('Sam', 10), ('Adam', 9), ('Kevin',9)]) 
        new_record = ('Jeff', 8) 
  
        # 创建新的进程
        p1 = multiprocessing.Process(target=insert_record, args=(new_record, records)) 
        p2 = multiprocessing.Process(target=print_records, args=(records,)) 
  
        # 执行插入记录的进程并等待其结束
        p1.start() 
        p1.join() 
  
        # 执行打印记录的进程并等待其结束
        p2.start() 
        p2.join() 

上面演示了Manager的第一个特性 - 可以支持任意的对象类型

什么是Manager

image

工作流程概要:

  1. Manager实现了context manager protocol,所以使用with就会让Manager跑起来,Manager实际上管理了一个Process,如上图所示

  2. 使用manager的list方法构造出一个共享的list,这个list存在于manager管理的process中

  3. 后面在上下文中创建的进程实际上是从此process fork出来的child processes,它们对此共享list是可见的

基于Queue/Pipe

Queue

可以支持任意的Python对象,参与到Queue的进程数量可以大于2个,不存在并发操作问题

import multiprocessing 
  
def square_list(mylist, q): 
    """ 
    计算平方值
    """
    # append squares of mylist to queue 
    for num in mylist: 
        q.put(num * num) 
  
def print_queue(q): 
    """ 
    打印队列元素
    """
    print("Queue elements:") 
    while not q.empty(): 
        print(q.get()) 
    print("Queue is now empty!") 
  
if __name__ == "__main__": 
    mylist = [1,2,3,4] 
  
    q = multiprocessing.Queue() 
  
    p1 = multiprocessing.Process(target=square_list, args=(mylist, q)) 
    p2 = multiprocessing.Process(target=print_queue, args=(q,)) 
  
    # 生产者先执行 
    p1.start() 
    p1.join() 
  
    # 消费者再执行 
    p2.start() 
    p2.join() 

Pipe

可以支持任意的Python对象,参与到Queue的进程数量只能是2个,存在并发操作问题

默认两个端点之间是可以双向通信的,除非在构造Pipe的时候传入了False。当传入False时,返回的第一个端点只能用于接收消息,第二个端点只能用来发送消息

# -*- coding: utf-8 -*-
import multiprocessing


def sender(conn, msgs):
    """
    发送消息后开始监听
    """
    for msg in msgs:
        conn.send(msg)
        print("Sent the message: {}".format(msg))

    # 发送完毕开始监听消息
    while 1:
        msg = conn.recv()
        print("Echo from receiver: ", msg)
        break

    conn.close()


def receiver(conn):
    """
    消息接收,并有条件地发送
    """
    while 1:
        msg = conn.recv()
        if msg == "ECHO":
            conn.send(msg + " from receiver!")
        if msg == "END":
            break
        print("Received the message: {}".format(msg))


if __name__ == "__main__":
    msgs = ["hello", "hey", "hru?", "ECHO", "END"]

    # 创建一个Pipe,返回的两个对象代表Pipe的两个端点
    conn1, conn2 = multiprocessing.Pipe()

    # 创建两个进程,每个进程管理Pipe的一个端点
    p1 = multiprocessing.Process(target=sender, args=(conn1, msgs))
    p2 = multiprocessing.Process(target=receiver, args=(conn2,))

    p1.start()
    p2.start()
    p1.join()
    p2.join()

需要注意,两个进程不能同时对Pipe中的一个端点进行读/写操作,否则数据会因为Race Condition而错乱。原因是Pipe中端点的读写操作并没有机制来协调进程间的同步,但是Queue就不存在这个问题,Queue在读写操作时考虑了进程间的同步。

进程同步和池

进程同步 - 锁

以一个经典的取款存款动作演示锁在进程同步中的用法:

import multiprocessing


# 取款
def withdraw(balance, lock):
    for _ in range(10000):
        lock.acquire()
        balance.value = balance.value - 1
        lock.release()


# 存款
def deposit(balance, lock):
    for _ in range(10000):
        lock.acquire()
        balance.value = balance.value + 1
        lock.release()


def perform_transactions():
    # 初始余额 - 使用共享内存的方式
    balance = multiprocessing.Value('i', 100)

    # 创建一个互斥锁
    lock = multiprocessing.Lock()

    # 创建两个进程分别模拟取款和存款
    p1 = multiprocessing.Process(target=withdraw, args=(balance, lock))
    p2 = multiprocessing.Process(target=deposit, args=(balance, lock))

    p1.start()
    p2.start()

    p1.join()
    p2.join()

    print("Final balance = {}".format(balance.value))


if __name__ == "__main__":
    # 执行10次
    for _ in range(10):
        perform_transactions()

进程池

简单用法示例:

import multiprocessing
import os

# 计算平方值
def square(n):
    print("Worker process id for {0}: {1}".format(n, os.getpid()))
    return (n * n)

if __name__ == "__main__":
    mylist = [1, 2, 3, 4, 5]

    # 创建一个进程池 - 没有指定进程数量则使用:os.cpu_count()
    p = multiprocessing.Pool()

    # 获取计算结果
    result = p.map(square, mylist)
    print(result)

进程池中的一些注意点

  1. 首先进程间通信能避免就避免,因为它开销和平台差异性都比较大,尽量避免资源共享 - 比如可以将进程间通信需求转移到对分布式KV的操作

  2. 需要保证传入到池中的业务参数是可序列化的,否则会报错PicklingError,比如业务方法执行的func需要是顶层或者class下定义的成员方法,不能是装饰器生成的方法

参考链接

Manager共享方式:docs.python.org/3/library/m…

Pipe的用法:docs.python.org/3/library/m…

Multi-Threading vs Multi-Processing:blog.usejournal.com/multithread…