理解python併發編程-進程篇

上節說到由於GIL(全局解釋鎖)的問題,多線程並不能充分利用多核處理器,如果是一個CPU計算型的任務,應該使用多進程模塊 multiprocessing 。它的工作方式與線程庫完全不同,但是兩種庫的語法卻非常相似。multiprocessing給每個進程賦予單獨的Python解釋器,這樣就規避了全局解釋鎖所帶來的問題。但是也別高興的太早,因爲你會遇到接下來說到的一些多進程之間通信的問題。

我們首先把上節的例子改成單進程和多進程的方式來對比下性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import time
import multiprocessing
def profile(func):
def wrapper(*args, **kwargs):
import time
start = time.time()
func(*args, **kwargs)
end = time.time()
print 'COST: {}'.format(end - start)
return wrapper
def fib(n):
if n<= 2:
return 1
return fib(n-1) + fib(n-2)
@profile
def nomultiprocess():
fib(35)
fib(35)
@profile
def hasmultiprocess():
jobs = []
for i in range(2):
p = multiprocessing.Process(target=fib, args=(35,))
p.start()
jobs.append(p)
for p in jobs:
p.join()
nomultiprocess()
hasmultiprocess()

運行的結果還不錯:

1
2
3
❯ python profile_process.py
COST: 4.66861510277
COST: 2.5424861908

雖然多進程讓效率差不多翻了倍,但是需要注意,其實這個時間就是2個執行fib(35),最慢的那個進程的執行時間而已。不管怎麼說,GIL的問題算是極大的緩解了。

進程池

有一點要強調:任務的執行週期決定於CPU核數和任務分配算法。上面例子中hasmultiprocess函數的用法非常中規中矩且常見,但是我認爲更好的寫法是使用Pool,也就是對應線程池的進程池:

1
2
3
4
from multiprocessing import Pool
pool = Pool(2)
pool.map(fib, [35] * 2)

其中map方法用起來和內置的map函數一樣,卻有多進程的支持。

PS: 之前在一分鐘讓程序支持隊列和併發,我就提到過使用multiprocessing.Pool實現純Python的MapReduce。有興趣的可以去了解下。

dummy

我之前使用多線程/多進程都使用上面的方式,在好長一段時間裏面對於多進程和多線程之前怎麼選擇都搞得不清楚,偶爾會出現要從多線程改成多進程或者多進程改成多線程的時候,痛苦。看了一些開源項目代碼,我發現了好多人在用multiprocessing.dummy這個子模塊,「dummy」這個詞有「模仿」的意思,它雖然在多進程模塊的代碼中,但是接口和多線程的接口基本一樣。官方文檔中這樣說:

multiprocessing.dummy replicates the API of multiprocess
ing but is no more than a wrapper around the threading
module.

恍然大悟!!!如果分不清任務是CPU密集型還是IO密集型,我就用如下2個方法分別試:

1
2
from multiprocessing import Pool
from multiprocessing.dummy import Pool

哪個速度快就用那個。從此以後我都儘量在寫兼容的方式,這樣在多線程/多進程之間切換非常方便。

在這裏說一個我個人的經驗和技巧:現在,如果一個任務拿不準是CPU密集還是I/O密集型,且沒有其它不能選擇多進程方式的因素,都統一直接上多進程模式。

基於Pipe的parmap

進程間的通信(IPC)常用的是rpc、socket、pipe(管道)和消息隊列(queue)。多進程模塊中涉及到了後面3種。我們先看一個官網給出的,最基本的管道的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
from multiprocessing import Process, Pipe
def f(conn):
conn.send(['hello'])
conn.close()
parent_conn, child_conn = Pipe()
p = Process(target=f, args=(child_conn,))
p.start()
print parent_conn.recv()
p.join()

其中Pipe返回的是管道2邊的對象:「父連接」和「子連接」。當子連接發送一個帶有hello字符串的列表,父連接就會收到,所以parent_conn.recv()就會打印出來。這樣就可以簡單的實現在多進程之間傳輸Python內置的數據結構了。但是先說明,不能被xmlrpclib序列化的對象是不能這麼傳輸的。

上上個例子中提到的hasmultiprocess函數使用了Pool的map方法,用着還不錯。但是在實際的業務中通常要複雜的多,比如下面這個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CalculateFib(object):
@classmethod
def fib(cls, n):
if n<= 2:
return 1
return cls.fib(n-1) + cls.fib(n-2)
def map_run(self):
pool = Pool(2)
print pool.map(self.fib, [35] * 2)
cl = CalculateFib()
cl.map_run()

fib由於某些原因需要放在了類裏面,我們來執行一下:

1
2
3
4
5
6
7
8
9
10
❯ python parmap.py
Exception in thread Thread-1:
Traceback (most recent call last):
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.py", line 810, in __bootstrap_inner
self.run()
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/threading.py", line 763, in run
self.__target(*self.__args, **self.__kwargs)
File "/Library/Python/2.7/site-packages/multiprocessing-2.6.2.1-py2.7-macosx-10.9-intel.egg/multiprocessing/pool.py", line 225, in _handle_tasks
put(task)
PicklingError: Can't pickle <type 'instancemethod'>: attribute lookup __builtin__.instancemethod failed

歐歐,出錯了。解決方案有很多。我們先演示一個使用管道處理的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from multiprocessing import Pool, Process, Pipe
from itertools import izip
def spawn(f):
def func(pipe, item):
pipe.send(f(item))
pipe.close()
return func
def parmap(f, items):
pipe = [Pipe() for _ in items]
proc = [Process(target=spawn(f),
args=(child, item))
for item, (parent, child) in izip(items, pipe)]
[p.start() for p in proc]
[p.join() for p in proc]
return [parent.recv() for (parent, child) in pipe]
class CalculateFib(object):
...
def parmap_run(self):
print parmap(self.fib, [35] * 2)
cl = CalculateFib()
cl.parmap_run()

這個parmap的作用就是對每個要處理的單元(在這裏就是一次 fib(35))創建一個管道,在子進程中,子連接執行完傳輸給父連接。

它確實可以滿足一些場景。但是我們能看到,它並沒有用進程池,也就是一個要處理的單元就會創建一個進程,這顯然不合理。

隊列

多線程有Queue模塊實現隊列,多進程模塊也包含了Queue類,它是線程和進程安全的。現在我們給下面的生產者/消費者的例子添加點難度,也就是用2個隊列:一個隊列用於存儲待完成的任務,另外一個用於存儲任務完成後的結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import time
from multiprocessing import Process, JoinableQueue, Queue
from random import random
tasks_queue = JoinableQueue()
results_queue = Queue()
def double(n):
return n * 2
def producer(in_queue):
while 1:
wt = random()
time.sleep(wt)
in_queue.put((double, wt))
if wt > 0.9:
in_queue.put(None)
print 'stop producer'
break
def consumer(in_queue, out_queue):
while 1:
task = in_queue.get()
if task is None:
break
func, arg = task
result = func(arg)
in_queue.task_done()
out_queue.put(result)
processes = []
p = Process(target=producer, args=(tasks_queue,))
p.start()
processes.append(p)
p = Process(target=consumer, args=(tasks_queue, results_queue))
p.start()
processes.append(p)
tasks_queue.join()
for p in processes:
p.join()
while 1:
if results_queue.empty():
break
result = results_queue.get()
print 'Result:', result

咋眼看去,和線程的那個隊列例子已經變化很多了:

  1. 生產者已經不會持續的生產任務了,如果隨機到的結果大於0.9就會給任務隊列tasks_queue put一個None,然後把循環結束掉
  2. 消費者如果收到一個值爲None的任務,就結束,否則執行從tasks_queue獲取的任務,並把結果put進results_queue
  3. 生產者和消費者都結束後(又join方法保證),從results_queue挨個獲取執行結果並打印出來

進程的Queue類並不支持task_done和join方法,需要使用特別的JoinableQueue,而蒐集結果的隊列results_queue使用Queue就足夠了。

回到上個CalculateFib的例子,我們用隊列再對parmap改造一下,讓它支持指定進程池的大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from multiprocessing import Queue, Process, cpu_count
def apply_func(f, q_in, q_out):
while not q_in.empty():
i, item = q_in.get()
q_out.put((i, f(item)))
def parmap(f, items, nprocs = cpu_count()):
q_in, q_out = Queue(), Queue()
proc = [Process(target=apply_func, args=(f, q_in, q_out))
for _ in range(nprocs)]
sent = [q_in.put((i, item)) for i, item in enumerate(items)]
[p.start() for p in proc]
res = [q_out.get() for _ in sent]
[p.join() for p in proc]
return [item for _, item in sorted(res)]

其中使用enumerate就是爲了保留待執行任務的順序,在最後排序用到。

同步機制

multiprocessing的Lock、Condition、Event、RLock、Semaphore等同步原語和threading模塊的機制是一樣的,用法也類似,限於篇幅,就不一一的展開了。

進程間共享狀態

multiprocessing提供的在進程間共享狀態的方式有2種:

共享內存

主要通過Value或者Array來實現。常見的共享的有以下幾種:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
In : from multiprocessing.sharedctypes import typecode_to_type
In : typecode_to_type
Out:
{'B': ctypes.c_ubyte,
'H': ctypes.c_ushort,
'I': ctypes.c_uint,
'L': ctypes.c_ulong,
'b': ctypes.c_byte,
'c': ctypes.c_char,
'd': ctypes.c_double,
'f': ctypes.c_float,
'h': ctypes.c_short,
'i': ctypes.c_int,
'l': ctypes.c_long,
'u': ctypes.c_wchar}

而且共享的時候還可以給Value或者Array傳遞lock參數來決定是否帶鎖,如果不指定默認爲RLock。

我們看一個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from multiprocessing import Process, Lock
from multiprocessing.sharedctypes import Value, Array
from ctypes import Structure, c_bool, c_double
lock = Lock()
class Point(Structure):
_fields_ = [('x', c_double), ('y', c_double)]
def modify(n, b, s, arr, A):
n.value **= 2
b.value = True
s.value = s.value.upper()
arr[0] = 10
for a in A:
a.x **= 2
a.y **= 2
n = Value('i', 7)
b = Value(c_bool, False, lock=False)
s = Array('c', 'hello world', lock=lock)
arr = Array('i', range(5), lock=True)
A = Array(Point, [(1.875, -6.25), (-5.75, 2.0)], lock=lock)
p = Process(target=modify, args=(n, b, s, arr, A))
p.start()
p.join()
print n.value
print b.value
print s.value
print arr[:]
print [(a.x, a.y) for a in A]

主要是爲了演示用法。有2點需要注意:

  1. 並不是只支持typecode_to_type中指定那些類型,只要在ctypes裏面的類型就可以。
  2. arr是一個int的數組,但是和array模塊生成的數組以及list是不一樣的,它是一個SynchronizedArray對象,支持的方法很有限,比如append/extend等方法是沒有的。

輸出結果如下:

1
2
3
4
5
6
❯ python shared_memory.py
49
True
HELLO WORLD
[10, 1, 2, 3, 4]
[(3.515625, 39.0625), (33.0625, 4.0)]

服務器進程

一個multiprocessing.Manager對象會控制一個服務器進程,其他進程可以通過代理的方式來訪問這個服務器進程。
常見的共享方式有以下幾種:

  1. Namespace。創建一個可分享的命名空間。
  2. Value/Array。和上面共享ctypes對象的方式一樣。
  3. dict/list。創建一個可分享的dict/list,支持對應數據結構的方法。
  4. Condition/Event/Lock/Queue/Semaphore。創建一個可分享的對應同步原語的對象。

看一個例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from multiprocessing import Manager, Process
def modify(ns, lproxy, dproxy):
ns.a **= 2
lproxy.extend(['b', 'c'])
dproxy['b'] = 0
manager = Manager()
ns = manager.Namespace()
ns.a = 1
lproxy = manager.list()
lproxy.append('a')
dproxy = manager.dict()
dproxy['b'] = 2
p = Process(target=modify, args=(ns, lproxy, dproxy))
p.start()
print 'PID:', p.pid
p.join()
print ns.a
print lproxy
print dproxy

在id爲8341的進程中就可以修改共享狀態了:

1
2
3
4
5
❯ python manager.py
PID: 8341
1
['a', 'b', 'c']
{'b': 0}

分佈式的進程間通信

有時候沒有必要捨近求遠的選擇更復雜的方案,其實使用Manager和Queue就可以實現簡單的分佈式的不同服務器的不同進程間的通信(C/S模式)。

首先在遠程服務器上寫如下的一個程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from multiprocessing.managers import BaseManager
host = '127.0.0.1'
port = 9030
authkey = 'secret'
shared_list = []
class RemoteManager(BaseManager):
pass
RemoteManager.register('get_list', callable=lambda: shared_list)
mgr = RemoteManager(address=(host, port), authkey=authkey)
server = mgr.get_server()
server.serve_forever()

現在希望其他代理可以修改和獲取到shared_list的值,那麼寫這麼一個客戶端程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from multiprocessing.managers import BaseManager
host = '127.0.0.1'
port = 9030
authkey = 'secret'
class RemoteManager(BaseManager):
pass
RemoteManager.register('get_list')
mgr = RemoteManager(address=(host, port), authkey=authkey)
mgr.connect()
l = mgr.get_list()
print l
l.append(1)
print mgr.get_list()

注意,在client上的註冊沒有添加callable參數。


原文鏈接:http://www.dongwm.com/archives/使用Python進行併發編程-進程篇/

發佈了80 篇原創文章 · 獲贊 217 · 訪問量 88萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章