Why Multiprocessing?
为了绕过GIL(全局解释器锁)。
那么GIL是个什么东西呢?和垃圾回收和资源管理有关。
Python中对象的管理和引用计数器有密切关系,当一个对象的计数器变为0的时候,那么就意味着该对象可以被GC,在Python中撤销对一个对象的引用有如下两个步骤:
-
引用计数值减1
-
判断该计数值是否为0,如果为0则销毁该对象
由于这两个步骤并不是原子性的,所以为了避免并发问题,引入了GIL保证对虚拟机内部共享资源的访问互斥。GIL的存在使运用多核计算能力成为障碍,但是让编程模型变的简单。
早在1999年,针对Python1.5,就有补丁尝试来解决GIL在多核计算资源上的弊端,这个补丁尝试使用细粒度的锁来替换GIL。但是效果并不理想,多核多线程的速度提升并没有随着核数的增加而线性增长,反而降低了单线程的执行速度大约40%,因此该方案被放弃。
在Python3.2中,对GIL进行了重新实现。有两个方面的改进:
-
使用固定时间而不是固定数量的操作指令来进行线程的强制切换
-
线程释放GIL后开始等待,直到某个其他线程获取GIL后再开始尝试获取GIL,避免一个线程一直执行
但是,以上的两个优化对于如今多核计算资源已成为主流的大背景下,都解决不了根本问题。
所以为了绕过GIL的限制,就有了multiprocessing这个模块。
基础概念
定义进程的API很直观:
-
进程需要执行的方法 - target
-
进程需要执行的方法的入参 - 以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): []
每个进程都有自己独立的内存空间,global变量也不会互相共享。因为在python中的global变量并不是进程间通信的shared memory。
进程间通信
上面说到了使用global没法实现进程间的通信,但是很显然进程间通信这个诉求是很常见的。
multiprocessing模块提供了几种进程间的通信方式:
-
基于共享内存 - Array/Value
-
基于Manager(Server Process)
-
基于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
主进程和创建的进程输出一致,表明共享内存变量起到了作用。
Manager(Server Process)
基于Manager的共享方式:
-
可以支持任意类型
-
网络中的多台机器中的进程可以共享,但是速度比共享内存的方式慢
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
工作流程概要:
-
Manager实现了context manager protocol,所以使用with就会让Manager跑起来,Manager实际上管理了一个Process,如上图所示
-
使用manager的list方法构造出一个共享的list,这个list存在于manager管理的process中
-
后面在上下文中创建的进程实际上是从此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)
进程池中的一些注意点
-
首先进程间通信能避免就避免,因为它开销和平台差异性都比较大,尽量避免资源共享 - 比如可以将进程间通信需求转移到对分布式KV的操作
-
需要保证传入到池中的业务参数是可序列化的,否则会报错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…