在 Python 中关于并发/并行的实现方式(库)有很多,于是我打算做个总结方便今后查阅。其实查阅文档,别的不好说,对于 Python,最好的方式就是查阅官方文档,除了少了些例子,几乎是完美的,所以本文只会列出常用的方法与属性。
基础概念
并行
Python学习交流群:1004391443
并行(parallelism),就是同时执行的意思,所以单线程永远无法达到并行状态,利用多线程和多进程即可。但是 Python 的多线程由于存在著名的 GIL,无法让两个线程真正“同时运行“,所以实际上是无法到达并行状态的。就像 2 个人同时吃 2 个包子,最后两个包子同时被吃完。
并发
并发(concurrency),整体上来看多个任务同时进行,但是一个时间点只有一个任务在执行。就像 1 个人同时吃 2 个包子,一次一边咬一口,最后差不多是两个包子同时被吃完。
多进程
多线程
线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
实现方式
多进程
fork
Unix/Linux 操作系统提供了一个 fork() 系统调用,它非常特殊。普通的函数,调用一次,返回一次,但是 fork() 调用一次,返回两次 ,因为操作系统自动把当前进程(父进程)复制了一份(子进程),然后,分别在父进程和子进程内返回。子进程永远返回 0 ,而父进程返回子进程的 ID。这样做的理由是,一个父进程可以 fork 出很多子进程,所以,父进程要记下每个子进程的 ID,而子进程只需要调用 getpid() 就可以拿到父进程的 ID。
而在 Python 中, os 模块封装了常见的系统调用,其中就包括 fork ,可以在 Python 程序中轻松创建子进程:
import os print('Process (%s) start...' % os.getpid()) # Only works on Unix/Linux/Mac: pid = os.fork() if pid == 0: print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid())) else: print('I (%s) just created a child process (%s).' % (os.getpid(), pid))
结果:
Process (876) start... I (876) just created a child process (877). I am child process (877) and my parent is 876.
有了 fork ,一个进程在接到新任务时就可以复制出一个子进程来处理新任务,常见的 Apache 服务器就是由父进程监听端口,每当有新的 http 请求时,就 fork 出子进程来处理新的 http 请求。
subprocess
有时候,子进程并不是代码自身,而是一个外部进程。Python 标准库中的 subprocess 库就可以 fork 一个子进程,来运行一个外部的程序,还可以接管子进程的输入和输出。
subprocess 中定义了多个函数,它们以不同的方式创建子进程(call、check_call、check_output、Popen 等等),根据不同需要来选取即可。另外 subprocess 还提供了一些接管标准流(standard stream)和打通管道(pipe)的工具,从而在进程间使用文本通信。
下面的例子演示了如何在 Python 代码中运行命令 nslookup www.python.org ,这和命令行直接运行的效果是一样的:
import subprocess print('$ nslookup www.python.org') r = subprocess.call(['nslookup', 'www.python.org']) print('Exit code:', r)
结果
$ nslookup www.python.org Server: 192.168.19.4 Address: 192.168.19.4#53 Non-authoritative answer: www.python.org canonical name = python.map.fastly.net. Name: python.map.fastly.net Address: 199.27.79.223 Exit code: 0
multiprocessing
如果要编写多进程的服务程序,Unix/Linux 无疑是正确的选择,由于 Windows 没有 fork 调用,上面的代码在 Windows 上无法运行。但是 Python 是跨平台的,自然也应该提供一个跨平台的多进程支持。于是就出现了 multiprocessing 。
multiprocessing 模块就是跨平台的多进程模块。在 Unix/Linux 下, multiprocessing 模块封装了 fork() ,使我们不需要关注 fork() 的细节。由于 Windows 没有 fork 调用,因此, multiprocessing 需要“模拟”出 fork 的效果,父进程所有 Python 对象都必须通过 pickle 序列化再传到子进程去所有。
所以,如果 multiprocessing 在 Windows 下调用失败了,要先考虑是不是 pickle 失败了。
multiprocessing 模块常见的使用方式:
multiprocessing 模块提供了一个 Process 类来代表一个进程对象:
创建进程的类
Process([group [, target [, name [, args [, kwargs]]]]])
描述:
注意:
参数:
args=('arg1', ) kwargs={'name': 'hexin', 'age': 18}
方法:
属性:
p.daemon p.name p.pid
示例:
注意:在 windows 中使用 Process() 必须放到 if __name__ == '__main__': 下
下面的例子演示了启动一个子进程并等待其结束:
from multiprocessing import Process import os # 子进程要执行的代码 def run_proc(name): print('Run child process %s (%s)...' % (name, os.getpid())) print('Parent process %s.' % os.getpid()) p = Process(target=run_proc, args=('test',)) print('Child process will start.') p.start() p.join() print('Child process end.')
创建子进程时,只需要传入一个执行函数和函数的参数,创建一个 Process 实例,用 start() 方法启动,这样创建进程比 fork() 还要简单。
join() 方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步。
当然,也可以使用类,继承 Process 来创建子进程:
import time import random from multiprocessing import Process class MyProcess(Process): def __init__(self,name): super().__init__() self.name = name def run(self): print('%s running' %self.name) time.sleep(random.randrange(1,5)) print('%s stop' %self.name) p1 = MyProcess('1') p2 = MyProcess('2') p3 = MyProcess('3') p4 = MyProcess('4') p1.start() # start 会自动调用 run p2.start() p3.start() p4.start() print('主线程')
结果
1 running 2 running 主线程 3 running 4 running 1 stop 4 stop 2 stop 3 stop
Pool([numprocess [,initializer [, initargs]]])
那么问题来了,开多进程的目的是为了并发,如果有多核,通常有几个核就开几个进程,进程开启过多,效率反而会下降(开启进程是需要占用系统资源的,而且开启多余核数目的进程也无法做到并行),但很明显需要并发执行的任务常常远大于核数,这时我们就可以通过维护一个进程池来控制进程数目,比如 httpd 的进程模式,规定最小进程数和最大进程数等。
当进程数目不大时,可以直接利用 multiprocessing 中的 Process 类手动创建多个进程,如果数量很大,就需要使用进程池。Pool 类可以提供指定数量的进程,供用户调用,当有新的请求提交到 Pool 中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,就重用进程池中的进程。
参数:
方法:
其他方法:
apply_async() 和 map_async() 的返回值是 AsyncResul ,这个实例具有以下方法
例子:
from multiprocessing import Pool import os, time, random def long_time_task(name): print('Run task %s (%s)...' % (name, os.getpid())) start = time.time() time.sleep(random.random() * 3) end = time.time() print('Task %s runs %0.2f seconds.' % (name, (end - start))) print('Parent process %s.' % os.getpid()) p = Pool(4) for i in range(5): p.apply_async(long_time_task, args=(i,)) print('Waiting for all subprocesses done...') p.close() p.join() print('All subprocesses done.')
结果
Parent process 669. Waiting for all subprocesses done... Run task 0 (671)... Run task 1 (672)... Run task 2 (673)... Run task 3 (674)... Task 2 runs 0.14 seconds. Run task 4 (673)... Task 1 runs 0.27 seconds. Task 3 runs 0.86 seconds. Task 0 runs 1.41 seconds. Task 4 runs 1.91 seconds. All subprocesses done.
注意输出的结果,task 0 , 1 , 2 , 3 是立刻执行的,而 task 4 要等待前面某个 task 完成后才执行,这是因为 Pool 的默认大小在我的电脑上是 4,因此,最多同时执行 4 个进程。这是 Pool 有意设计的限制,并不是操作系统的限制。如果改成: p = Pool(5) 就可以同时跑 5 个进程(但是就不是并行了,而是并发)。由于 Pool 的默认大小是 CPU 的核数,如果你 不幸 拥有 8 核 CPU,你要提交至少 9 个子进程才能看到上面的等待效果(逃)。
又一个例子:
提交任务,并在主进程中拿到结果(之前的 Process 是执行任务,结果放到队列里,现在可以在主进程中直接拿到结果)
from multiprocessing import Pool import time def work(n): print('开工啦...') time.sleep(3) return n ** 2 q = Pool() # 异步 apply_async 用法:如果使用异步提交的任务,主进程需要使用 join,等待进程池内任务都处理完,然后可以用 get 收集结果,否则,主进程结束,进程池可能还没来得及执行,也就跟着一起结束了 res = q.apply_async(work, args=(2,)) q.close() q.join() #join 在 close 之后调用 print(res.get()) # 同步 apply 用法:主进程一直等 apply 提交的任务结束后才继续执行后续代码 # res = q.apply(work, args=(2,)) # print(res)
结果
开工啦... 4
对 Pool 对象调用 join() 方法会等待所有子进程执行完毕,调用 join() 之前必须先调用 close() ,调用 close() 之后就不能继续添加新的 Process 了。