本文对python支持的几种并发方式进行简单的总结。

Python支持的并发分为多线程并发与多进程并发(异步IO本文不涉及)。概念上来说,多进程并发即运行多个独立的程序,优势在于并发处理的任务都由操作系统管理,不足之处在于程序与各进程之间的通信和数据共享不方便;多线程并发则由程序员管理并发处理的任务,这种并发方式可以方便地在线程间共享数据(前提是不能互斥)。Python对多线程和多进程的支持都比一般编程语言更高级,最小化了需要我们完成的工作。

一.多进程并发

Mark Summerfield指出,对于计算密集型程序,多进程并发优于多线程并发。计算密集型程序指的程序的运行时间大部分消耗在CPU的运算处理过程,而硬盘和内存的读写消耗的时间很短;相对地,IO密集型程序指的则是程序的运行时间大部分消耗在硬盘和内存的读写上,CPU的运算时间很短。

对于多进程并发,python支持两种实现方式,一种是采用进程安全的数据结构:multiprocessing.JoinableQueue,这种数据结构自己管理“加锁”的过程,程序员无需担心“死锁”的问题;python还提供了一种更为优雅而高级的实现方式:采用进程池。下面一一介绍。

1.队列实现——使用multiprocessing.JoinableQueue

multiprocessing是python标准库中支持多进程并发的模块,我们这里采用multiprocessing中的数据结构:JoinableQueue,它本质上仍是一个FIFO的队列,它与一般队列(如queue中的Queue)的区别在于它是多进程安全的,这意味着我们不用担心它的互斥和死锁问题。JoinableQueue主要可以用来存放执行的任务和收集任务的执行结果。举例来看:

1 import multiprocessing
 2 import random
 3 import time
 4 
 5 
 6 def read(q):
 7     while True:
 8         try:
 9             value = q.get()
10             print('Get %s from queue.' % value)
11             time.sleep(random.random())
12         finally:
13             q.task_done()
14 
15 
16 def main():
17     q = multiprocessing.JoinableQueue()
18     pw1 = multiprocessing.Process(target=read, args=(q,))
19     pw2 = multiprocessing.Process(target=read, args=(q,))
20     pw1.daemon = True
21     pw2.daemon = True
22     pw1.start()
23     pw2.start()
24     for c in [chr(ord('A')+i) for i in range(26)]:
25         q.put(c)
26     try:
27         q.join()
28     except KeyboardInterrupt:
29         print("stopped by hand")
30 
31 
32 if __name__ == '__main__':
33     main()

对于windows系统的多进程并发,程序文件里必须含有“入口函数”(如main函数),且结尾处必须调用入口点。例如以if __name__ == '__main__': main()结尾。

在这个最简单的多进程并发例子里,我们用多进程实现将26个字母打印出来。首先定义一个存放任务的JoinableQueue对象,然后实例化两个Process对象(每个对象对应一个子进程),实例化Process对象需要传送target和args参数,target是实现每个任务工作中的具体函数,args是target函数的参数。

pw1.daemon = True
pw2.daemon = True

这两句话将子进程设置为守护进程——主进程结束后随之结束。

pw1.start()
pw2.start()

一旦运行到这两句话,子进程就开始独立于父进程运行了,它会在单独的进程里调用target引用的函数——在这里即read函数,它是一个死循环,将参数q中的数一一读取并打印出来。

value = q.get()

这是多进程并发的要点,q是一个JoinableQueue对象,支持get方法读取第一个元素,如果q中没有元素,进程就会阻塞,直至q中被存入新元素。

因此执行完pw1.start() pw2.start()这两句话后,子进程虽然开始运行了,但很快就堵塞住。

for c in [chr(ord('A')+i) for i in range(26)]:
        q.put(c)

将26个字母依次放入JoinableQueue对象中,这时候两个子进程不再阻塞,开始真正地执行任务。两个子进程都用value = q.get()来读取数据,它们都在修改q对象,而我们并不用担心同步问题,这就是multiProcessing.Joinable数据结构的优势所在——它是多进程安全的,它会自动处理“加锁”的过程。

q.join()方法会查询q中的数据是否已读完——这里指的就是任务是否执行完,如果没有,程序会阻塞住等待q中数据读完才开始继续执行(可以用Ctrl+C强制停止)。

对Windows系统,调用任务管理器应该可以看到有多个子进程在运行。

2.进程池实现——使用concurrent.futures.ProcessPoolExecutor

Python还支持一种更为优雅的多进程并发方式,直接看例子:

1 import random
 2 import time
 3 import concurrent.futures
 4 
 5 
 6 def read(q):
 7         print('Get %s from queue.' % q)
 8         time.sleep(random.random())
 9 
10 
11 def main():
12     futures = set()
13     with concurrent.futures.ProcessPoolExecutor() as executor:
14         for q in (chr(ord('A')+i) for i in range(26)):
15             future = executor.submit(read, q)
16             futures.add(future)
17         try:
18             for future in concurrent.futures.as_completed(futures):
19                 err = future.exception()
20                 if err is not None:
21                     raise err
22         except KeyboardInterrupt:
23             print("stopped by hand")
24 
25 
26 if __name__ == '__main__':
27     main()

这里我们采用concurrent.futures.ProcessPoolExecutor对象,可以把它想象成一个进程池,子进程往里“填”。我们通过submit方法实例一个Future对象,然后把这里Future对象都填到池——futures里,这里futures是一个set对象。只要进程池里有future,就会开始执行任务。这里的read函数更为简单——只是把一个字符打印并休眠一会而已。

try:
     for future in concurrent.futures.as_completed(futures):

这是等待所有子进程都执行完毕。子进程执行过程中可能抛出异常,err = future.exception()可以收集这些异常,便于后期处理。

可以看出用Future对象处理多进程并发更为简洁,无论是target函数的编写、子进程的启动等等,future对象还可以向使用者汇报其状态,也可以汇报执行结果或执行时的异常。

二.多线程并发

对于IO密集型程序,多线程并发可能要优于多进程并发。因为对于网络通信等IO密集型任务来说,决定程序效率的主要是网络延迟,这时候是使用进程还是线程就没有太大关系了。

1.队列实现——使用queue.Queue

程序与多进程基本一致,只是这里我们不必使用multiProcessing.JoinableQueue对象了,一般的队列(来自queue.Queue)就可以满足要求:

1 import queue
 2 import random
 3 import threading
 4 import time
 5 
 6 
 7 def read(q):
 8     while True:
 9         try:
10             value = q.get()
11             print('Get %s from queue.' % value)
12             time.sleep(random.random())
13         finally:
14             q.task_done()
15 
16 
17 def main():
18     q = queue.Queue()
19     pw1 = threading.Thread(target=read, args=(q,))
20     pw2 = threading.Thread(target=read, args=(q,))
21     pw1.daemon = True
22     pw2.daemon = True
23     pw1.start()
24     pw2.start()
25     for c in [chr(ord('A')+i) for i in range(26)]:
26         q.put(c)
27     try:
28         q.join()
29     except KeyboardInterrupt:
30         print("stopped by hand")
31 
32 
33 if __name__ == '__main__':
34     main()

并且这里我们实例化的是Thread对象,而不是Process对象,程序的其余部分看起来与多进程并没有什么两样。

2. 线程池实现——使用concurrent.futures.ThreadPoolExecutor

直接看例子:

1 import concurrent.futures
 2 import random
 3 import multiprocessing
 4 import time
 5 
 6 
 7 def read(q):
 8     print('Get %s from queue.' % q)
 9     time.sleep(random.random())
10 
11 
12 def main():
13     futures = set()
14     with concurrent.futures.ThreadPoolExecutor(multiprocessing.cpu_count() * 4) as executor:
15         for q in (chr(ord('A') + i) for i in range(26)):
16             future = executor.submit(read, q)
17             futures.add(future)
18     try:
19         for future in concurrent.futures.as_completed(futures):
20             err = future.exception()
21             if err is not None:
22                 raise err
23     except KeyboardInterrupt:
24         print("stopped by hand")
25 
26 
27 if __name__ == '__main__':
28     main()

用ThreadPoolExecutor与用ProcessPoolExecutor看起来没什么区别,只是改了一下签名而已。

不难看出,不管是使用队列还是使用进/线程池,从多进程转化到多线程是十分容易的——仅仅是修改了几个签名而已。当然内部机制完全不同,只是python的封装非常好,使我们可以不用关心这些细节,这正是python优雅之处。