使用Python進行併發編程

線程(Thread

多線程幾乎是每一個程序猿在使用每一種語言時都會首先想到用於解決併發的工具(JS程序員請回避),使用多線程可以有效的利用CPU資源(Python例外)。然而多線程所帶來的程序的複雜度也不可避免,尤其是對競爭資源的同步問題。

然而在python中由於使用了全局解釋鎖(GIL)的原因,代碼並不能同時在多核上併發的運行,也就是說,Python的多線程不能併發,很多人會發現使用多線程來改進自己的Python代碼後,程序的運行效率卻下降了,這是多麼蛋疼的一件事呀!如果想了解更多細節,推薦閱讀這篇文章。實際上使用多線程的編程模型是很困難的,程序員很容易犯錯,這並不是程序員的錯誤,因爲並行思維是反人類的,我們大多數人的思維是串行(精神分裂不討論),而且馮諾依曼設計的計算機架構也是以順序執行爲基礎的。所以如果你總是不能把你的多線程程序搞定,恭喜你,你是個思維正常的程序猿:)

Python提供兩組線程的接口,一組是thread模塊,提供基礎的,低等級(Low Level)接口,使用Function作爲線程的運行體。還有一組是threading模塊,提供更容易使用的基於對象的接口(類似於Java),可以繼承Thread對象來實現線程,還提供了其它一些線程相關的對象,例如Timer,Lock

使用thread模塊的例子

import thread
def worker():
    """thread worker function"""
    print 'Worker'
thread.start_new_thread(worker)

使用threading模塊的例子

import threading
def worker():
    """thread worker function"""
    print 'Worker'
t = threading.Thread(target=worker)
t.start()

 或者Java Style

import threading
class worker(threading.Thread):
    def __init__(self):
        pass
    def run():
        """thread worker function"""
        print 'Worker'
     
t = worker()
t.start()

進程 (Process)

由於前文提到的全局解釋鎖的問題,Python下比較好的並行方式是使用多進程,這樣可以非常有效的使用CPU資源,並實現真正意義上的併發。當然,進程的開銷比線程要大,也就是說如果你要創建數量驚人的併發進程的話,需要考慮一下你的機器是不是有一顆強大的心。

Python的mutliprocess模塊和threading具有類似的接口。

from multiprocessing import Process
 
def worker():
    """thread worker function"""
    print 'Worker'
p = Process(target=worker)
p.start()
p.join()

由於線程共享相同的地址空間和內存,所以線程之間的通信是非常容易的,然而進程之間的通信就要複雜一些了。常見的進程間通信有,管道,消息隊列,Socket接口(TCP/IP)等等。

Python的mutliprocess模塊提供了封裝好的管道和隊列,可以方便的在進程間傳遞消息。

Python進程間的同步使用鎖,這一點喝線程是一樣的。

另外,Python還提供了進程池Pool對象,可以方便的管理和控制線程。

遠程分佈式主機 (Distributed Node)

隨着大數據時代的到臨,摩爾定理在單機上似乎已經失去了效果,數據的計算和處理需要分佈式的計算機網絡來運行,程序並行的運行在多個主機節點上,已經是現在的軟件架構所必需考慮的問題。

遠程主機間的進程間通信有幾種常見的方式

  • TCP/IP

    TCP/IP是所有遠程通信的基礎,然而API比較低級別,使用起來比較繁瑣,所以一般不會考慮

  • 遠程方法調用 Remote Function Call

    RPC是早期的遠程進程間通信的手段。Python下有一個開源的實現RPyC

  • 遠程對象 Remote Object

    遠程對象是更高級別的封裝,程序可以想操作本地對象一樣去操作一個遠程對象在本地的代理。遠程對象最廣爲使用的規範CORBA,CORBA最大的好處是可以在不同語言和平臺中進行通信。當讓不用的語言和平臺還有一些各自的遠程對象實現,例如Java的RMI,MS的DCOM

    Python的開源實現,有許多對遠程對象的支持

  • 消息隊列 Message Queue

    比起RPC或者遠程對象,消息是一種更爲靈活的通信手段,常見的支持Python接口的消息機制有

在遠程主機上執行併發和本地的多進程並沒有非常大的差異,都需要解決進程間通信的問題。當然對遠程進程的管理和協調比起本地要複雜。

Python下有許多開源的框架來支持分佈式的併發,提供有效的管理手段包括:

  • Celery 

    Celery是一個非常成熟的Python分佈式框架,可以在分佈式的系統中,異步的執行任務,並提供有效的管理和調度功能。參考這裏

  • SCOOP

    SCOOP (Scalable COncurrent Operations in Python)提供簡單易用的分佈式調用接口,使用Future接口來進行併發。

  • Dispy

    相比起Celery和SCOOP,Dispy提供更爲輕量級的分佈式並行服務

  • PP 

    PP (Parallel Python)是另外一個輕量級的Python並行服務, 參考這裏

  • Asyncoro

    Asyncoro是另一個利用Generator實現分佈式併發的Python框架,

當然還有許多其它的系統,我沒有一一列出

另外,許多的分佈式系統多提供了對Python接口的支持,例如Spark

 

僞線程 (Pseudo-Thread)

還有一種併發手段並不常見,我們可以稱之爲僞線程,就是看上去像是線程,使用的接口類似線程接口,但是實際使用非線程的方式,對應的線程開銷也不存的。

  • greenlet 

    greenlet提供輕量級的coroutines來支持進程內的併發。

    greenlet是Stackless的一個副產品,使用tasklet來支持一中被稱之爲微線程(mirco-thread)的技術,這裏是一個使用greenlet的僞線程的例子

  • from greenlet import greenlet
     
    def test1():
        print 12
        gr2.switch()
        print 34
         
    def test2():
        print 56
        gr1.switch()
        print 78
         
    gr1 = greenlet(test1)
    gr2 = greenlet(test2)
    gr1.switch()

    運行以上程序得到如下結果:

  • 12
    56
    34

    僞線程gr1 switch會打印12,然後調用gr2 switch得到56,然後switch回到gr1,打印34,然後僞線程gr1結束,程序退出,所以78永遠不會被打印。通過這個例子我們可以看出,使用僞線程,我們可以有效的控制程序的執行流程,但是僞線程並不存在真正意義上的併發。

    eventlet,gevent和concurence都是基於greenlet提供併發的。

  • eventlet http://eventlet.net/

  • eventlet是一個提供網絡調用併發的Python庫,使用者可以以非阻塞的方式累調用阻塞的IO操作。

import eventlet
from eventlet.green import urllib2
 
urls = ['http://www.google.com', 'http://www.example.com', 'http://www.python.org']
 
def fetch(url):
    return urllib2.urlopen(url).read()
 
pool = eventlet.GreenPool()
 
for body in pool.imap(fetch, urls):
    print("got body", len(body))

執行結果如下

('got body', 17629)
('got body', 1270)
('got body', 46949)

eventlet爲了支持generator的操作對urllib2做了修改,接口和urllib2是一致的。這裏的GreenPool和Python的Pool接口一致。

gevent和eventlet類似,關於它們的差異大家可以參考這篇文章

import gevent
from gevent import socket
urls = ['www.google.com', 'www.example.com', 'www.python.org']
jobs = [gevent.spawn(socket.gethostbyname, url) for url in urls]
gevent.joinall(jobs, timeout=2)
 
print [job.value for job in jobs]

執行結果如下:

['206.169.145.226', '93.184.216.34', '23.235.39.223']
  • concurence https://github.com/concurrence/concurrence

concurence是另外一個利用greenlet提供網絡併發的開源庫,我沒有用過,大家可以自己嘗試一下。

實戰運用

通常需要用到併發的場合有兩種,一種是計算密集型,也就是說你的程序需要大量的CPU資源;另一種是IO密集型,程序可能有大量的讀寫操作,包括讀寫文件,收發網絡請求等等。

計算密集型

對應計算密集型的應用,我們選用著名的蒙特卡洛算法來計算PI值。

蒙特卡洛算法利用統計學原理來模擬計算圓周率,在一個正方形中,一個隨機的點落在1/4圓的區域(紅色點)的概率與其面積成正比。也就該概率 p = Pi * R*R /4  : R* R , 其中R是正方形的邊長,圓的半徑。也就是說該概率是圓周率的1/4, 利用這個結論,只要我們模擬出點落在四分之一圓上的概率就可以知道圓周率了,爲了得到這個概率,我們可以通過大量的實驗,也就是生成大量的點,看看這個點在哪個區域,然後統計出結果。

基本算法如下:

from math import hypot
from random import random
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))

這裏test方法做了n(tries)次試驗,返回落在四分之一圓中的點的個數。判斷方法是檢查該點到圓心的距離,如果小於R則是在圓上。

通過大量的併發,我們可以快速的運行多次試驗,試驗的次數越多,結果越接近真實的圓周率。

這裏給出不同併發方法的程序代碼

  • 非併發

    我們先在單線程,但進程運行,看看性能如何

from math import hypot
from random import random
import eventlet
import time
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))
 
def calcPi(nbFutures, tries):
    ts = time.time()
    result = map(test, [tries] * nbFutures)
     
    ret = 4. * sum(result) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
print calcPi(3000,4000)

多線程 thread

爲了使用線程池,我們用multiprocessing的dummy包,它是對多線程的一個封裝。注意這裏代碼雖然一個字的沒有提到線程,但它千真萬確是多線程。

通過測試我們開(jing)心(ya)的發現,果然不出所料,當線程池爲1是,它的運行結果和沒有併發時一樣,當我們把線程池數字設置爲5時,耗時幾乎是沒有併發的2倍,我的測試數據從5秒到9秒。所以對於計算密集型的任務,還是放棄多線程吧。

from multiprocessing.dummy import Pool
 
from math import hypot
from random import random
import time
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))
 
def calcPi(nbFutures, tries):
    ts = time.time()
    p = Pool(1)
    result = p.map(test, [tries] * nbFutures)
    ret = 4. * sum(result) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
if __name__ == '__main__':
    p = Pool()
    print("pi = {}".format(calcPi(3000, 4000)))

多進程 multiprocess

理論上對於計算密集型的任務,使用多進程併發比較合適,在以下的例子中,進程池的規模設置爲5,修改進程池的大小可以看到對結果的影響,當進程池設置爲1時,和多線程的結果所需的時間類似,因爲這時候並不存在併發;當設置爲2時,響應時間有了明顯的改進,是之前沒有併發的一半;然而繼續擴大進程池對性能影響並不大,甚至有所下降,也許我的Apple Air的CPU只有兩個核?

當心,如果你設置一個非常大的進程池,你會遇到 Resource temporarily unavailable的錯誤,系統並不能支持創建太多的進程,畢竟資源是有限的。

from multiprocessing import Pool
 
from math import hypot
from random import random
import time
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))
 
def calcPi(nbFutures, tries):
    ts = time.time()
    p = Pool(5)
    result = p.map(test, [tries] * nbFutures)
    ret = 4. * sum(result) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
if __name__ == '__main__':
    print("pi = {}".format(calcPi(3000, 4000)))

gevent (僞線程)

不論是gevent還是eventlet,因爲不存在實際的併發,響應時間和沒有併發區別不大,這個和測試結果一致。

import gevent
from math import hypot
from random import random
import time
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))
 
def calcPi(nbFutures, tries):
    ts = time.time()
    jobs = [gevent.spawn(test, t) for t in [tries] * nbFutures]
    gevent.joinall(jobs, timeout=2)
    ret = 4. * sum([job.value for job in jobs]) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
print calcPi(3000,4000)

eventlet (僞線程)

from math import hypot
from random import random
import eventlet
import time
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))
 
def calcPi(nbFutures, tries):
    ts = time.time()
    pool = eventlet.GreenPool()
    result = pool.imap(test, [tries] * nbFutures)
     
    ret = 4. * sum(result) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
print calcPi(3000,4000)
  • SCOOP

SCOOP中的Future接口符合PEP-3148的定義,也就是在Python3中提供的Future接口。

在缺省的SCOOP配置環境下(單機,4個Worker),併發的性能有提高,但是不如兩個進程池配置的多進程。

from math import hypot
from random import random
from scoop import futures
 
import time
 
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))
 
def calcPi(nbFutures, tries):
    ts = time.time()
    expr = futures.map(test, [tries] * nbFutures)
    ret = 4. * sum(expr) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
if __name__ == "__main__":
    print("pi = {}".format(calcPi(3000, 4000)))
  • Celery

任務代碼

from celery import Celery
 
from math import hypot
from random import random
  
app = Celery('tasks', backend='amqp', broker='amqp://guest@localhost//')
app.conf.CELERY_RESULT_BACKEND = 'db+sqlite:///results.sqlite'
  
@app.task
def test(tries):
    return sum(hypot(random(), random()) < 1 for _ in range(tries))

客戶端代碼

from celery import group
from tasks import test
 
import time
 
def calcPi(nbFutures, tries):
    ts = time.time()
    result = group(test.s(tries) for i in xrange(nbFutures))().get()
     
    ret = 4. * sum(result) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
print calcPi(3000, 4000)

使用Celery做併發的測試結果出乎意料(環境是單機,4frefork的併發,消息broker是rabbitMQ),是所有測試用例裏最糟糕的,響應時間是沒有併發的5~6倍。這也許是因爲控制協調的開銷太大。對於這樣的計算任務,Celery也許不是一個好的選擇。

  • asyncoro

    Asyncoro的測試結果和非併發保持一致。

import asyncoro
 
from math import hypot
from random import random
import time
 
def test(tries):
    yield sum(hypot(random(), random()) < 1 for _ in range(tries))
 
 
def calcPi(nbFutures, tries):
    ts = time.time()
    coros = [ asyncoro.Coro(test,t) for t in [tries] * nbFutures]
    ret = 4. * sum([job.value() for job in coros]) / float(nbFutures * tries)
    span = time.time() - ts
    print "time spend ", span
    return ret
 
print calcPi(3000,4000)

IO密集型

IO密集型的任務是另一種常見的用例,例如網絡WEB服務器就是一個例子,每秒鐘能處理多少個請求時WEB服務器的重要指標。

我們就以網頁讀取作爲最簡單的例子

from math import hypot
import time
import urllib2
 
urls = ['http://www.google.com', 'http://www.example.com', 'http://www.python.org']
 
def test(url):
    return urllib2.urlopen(url).read()
 
def testIO(nbFutures):
    ts = time.time()
    map(test, urls * nbFutures)
 
    span = time.time() - ts
    print "time spend ", span
 
testIO(10)

在不同併發庫下的代碼,由於比較類似,我就不一一列出。大家可以參考計算密集型中代碼做參考。

通過測試我們可以發現,對於IO密集型的任務,使用多線程,或者是多進程都可以有效的提高程序的效率,而使用僞線程性能提升非常顯著,eventlet比沒有併發的情況下,響應時間從9秒提高到0.03秒。同時eventlet/gevent提供了非阻塞的異步調用模式,非常方便。這裏推薦使用線程或者僞線程,因爲在響應時間類似的情況下,線程和僞線程消耗的資源更少。

 

總結

Python提供了不同的併發方式,對應於不同的場景,我們需要選擇不同的方式進行併發。選擇合適的方式,不但要對該方法的原理有所瞭解,還應該做一些測試和試驗,數據纔是你做選擇的最好參考。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章