搞定python多執行緒和多程式

morra發表於2017-02-24

1 概念梳理:

1.1 執行緒

1.1.1 什麼是執行緒

執行緒是作業系統能夠進行運算排程的最小單位。它被包含在程式之中,是程式中的實際運作單位。一條執行緒指的是程式中一個單一順序的控制流,一個程式中可以併發多個執行緒,每條執行緒並行執行不同的任務。一個執行緒是一個execution context(執行上下文),即一個cpu執行時所需要的一串指令。

1.1.2 執行緒的工作方式

假設你正在讀一本書,沒有讀完,你想休息一下,但是你想在回來時恢復到當時讀的具體進度。有一個方法就是記下頁數、行數與字數這三個數值,這些數值就是execution context。如果你的室友在你休息的時候,使用相同的方法讀這本書。你和她只需要這三個數字記下來就可以在交替的時間共同閱讀這本書了。

執行緒的工作方式與此類似。CPU會給你一個在同一時間能夠做多個運算的幻覺,實際上它在每個運算上只花了極少的時間,本質上CPU同一時刻只幹了一件事。它能這樣做就是因為它有每個運算的execution context。就像你能夠和你朋友共享同一本書一樣,多工也能共享同一塊CPU。

1.2 程式

一個程式的執行例項就是一個程式。每一個程式提供執行程式所需的所有資源。(程式本質上是資源的集合)

一個程式有一個虛擬的地址空間、可執行的程式碼、作業系統的介面、安全的上下文(記錄啟動該程式的使用者和許可權等等)、唯一的程式ID、環境變數、優先順序類、最小和最大的工作空間(記憶體空間),還要有至少一個執行緒。

每一個程式啟動時都會最先產生一個執行緒,即主執行緒。然後主執行緒會再建立其他的子執行緒。

與程式相關的資源包括:

  • 記憶體頁(同一個程式中的所有執行緒共享同一個記憶體空間
  • 檔案描述符(e.g. open sockets)
  • 安全憑證(e.g.啟動該程式的使用者ID)

1.3 程式與執行緒區別

1.同一個程式中的執行緒共享同一記憶體空間,但是程式之間是獨立的。
2.同一個程式中的所有執行緒的資料是共享的(程式通訊),程式之間的資料是獨立的。
3.對主執行緒的修改可能會影響其他執行緒的行為,但是父程式的修改(除了刪除以外)不會影響其他子程式。
4.執行緒是一個上下文的執行指令,而程式則是與運算相關的一簇資源。
5.同一個程式的執行緒之間可以直接通訊,但是程式之間的交流需要藉助中間代理來實現。
6.建立新的執行緒很容易,但是建立新的程式需要對父程式做一次複製。
7.一個執行緒可以操作同一程式的其他執行緒,但是程式只能操作其子程式。
8.執行緒啟動速度快,程式啟動速度慢(但是兩者執行速度沒有可比性)。

2 多執行緒

2.1 執行緒常用方法

方法 註釋
start() 執行緒準備就緒,等待CPU排程
setName() 為執行緒設定名稱
getName() 獲取執行緒名稱
setDaemon(True) 設定為守護執行緒
join() 逐個執行每個執行緒,執行完畢後繼續往下執行
run() 執行緒被cpu排程後自動執行執行緒物件的run方法,如果想自定義執行緒類,直接重寫run方法就行了
2.1.1 Thread類

1.普通建立方式

import threading
import time

def run(n):
    print("task", n)
    time.sleep(1)
    print('2s')
    time.sleep(1)
    print('1s')
    time.sleep(1)
    print('0s')
    time.sleep(1)

t1 = threading.Thread(target=run, args=("t1",))
t2 = threading.Thread(target=run, args=("t2",))
t1.start()
t2.start()

"""
task t1
task t2
2s
2s
1s
1s
0s
0s
"""

2.繼承threading.Thread來自定義執行緒類
其本質是重構Thread類中的run方法

import threading
import time


class MyThread(threading.Thread):
    def __init__(self, n):
        super(MyThread, self).__init__()  # 重構run函式必須要寫
        self.n = n

    def run(self):
        print("task", self.n)
        time.sleep(1)
        print('2s')
        time.sleep(1)
        print('1s')
        time.sleep(1)
        print('0s')
        time.sleep(1)


if __name__ == "__main__":
    t1 = MyThread("t1")
    t2 = MyThread("t2")

    t1.start()
    t2.start()
2.1.2 計運算元執行緒執行的時間

注:sleep的時候是不會佔用cpu的,在sleep的時候作業系統會把執行緒暫時掛起。

join()  #等此執行緒執行完後,再執行其他執行緒或主執行緒
threading.current_thread()      #輸出當前執行緒
import threading
import time

def run(n):
    print("task", n,threading.current_thread())    #輸出當前的執行緒
    time.sleep(1)
    print('3s')
    time.sleep(1)
    print('2s')
    time.sleep(1)
    print('1s')

strat_time = time.time()

t_obj = []   #定義列表用於存放子執行緒例項

for i in range(3):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.start()
    t_obj.append(t)
    
"""
由主執行緒生成的三個子執行緒
task t-0 <Thread(Thread-1, started 44828)>
task t-1 <Thread(Thread-2, started 42804)>
task t-2 <Thread(Thread-3, started 41384)>
"""

for tmp in t_obj:
    t.join()            #為每個子執行緒新增join之後,主執行緒就會等這些子執行緒執行完之後再執行。

print("cost:", time.time() - strat_time) #主執行緒

print(threading.current_thread())       #輸出當前執行緒
"""
<_MainThread(MainThread, started 43740)>
"""

2.1.3 統計當前活躍的執行緒數

由於主執行緒比子執行緒快很多,當主執行緒執行active_count()時,其他子執行緒都還沒執行完畢,因此利用主執行緒統計的活躍的執行緒數num = sub_num(子執行緒數量)+1(主執行緒本身)

import threading
import time

def run(n):
    print("task", n)    
    time.sleep(1)       #此時子執行緒停1s

for i in range(3):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.start()

time.sleep(0.5)     #主執行緒停0.5秒
print(threading.active_count()) #輸出當前活躍的執行緒數

"""
task t-0
task t-1
task t-2
4
"""

由於主執行緒比子執行緒慢很多,當主執行緒執行active_count()時,其他子執行緒都已經執行完畢,因此利用主執行緒統計的活躍的執行緒數num = 1(主執行緒本身)

import threading
import time


def run(n):
    print("task", n)
    time.sleep(0.5)       #此時子執行緒停0.5s


for i in range(3):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.start()

time.sleep(1)     #主執行緒停1秒
print(threading.active_count()) #輸出活躍的執行緒數
"""
task t-0
task t-1
task t-2
1
"""

此外我們還能發現在python內部預設會等待最後一個程式執行完後再執行exit(),或者說python內部在此時有一個隱藏的join()。

2.2 守護程式

我們看下面這個例子,這裡使用setDaemon(True)把所有的子執行緒都變成了主執行緒的守護執行緒,因此當主程式結束後,子執行緒也會隨之結束。所以當主執行緒結束後,整個程式就退出了。

import threading
import time

def run(n):
    print("task", n)
    time.sleep(1)       #此時子執行緒停1s
    print('3')
    time.sleep(1)
    print('2')
    time.sleep(1)
    print('1')

for i in range(3):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.setDaemon(True)   #把子程式設定為守護執行緒,必須在start()之前設定
    t.start()

time.sleep(0.5)     #主執行緒停0.5秒
print(threading.active_count()) #輸出活躍的執行緒數
"""
task t-0
task t-1
task t-2
4

Process finished with exit code 0
"""

2.3 GIL

在非python環境中,單核情況下,同時只能有一個任務執行。多核時可以支援多個執行緒同時執行。但是在python中,無論有多少核,同時只能執行一個執行緒。究其原因,這就是由於GIL的存在導致的。

GIL的全稱是Global Interpreter Lock(全域性直譯器鎖),來源是python設計之初的考慮,為了資料安全所做的決定。某個執行緒想要執行,必須先拿到GIL,我們可以把GIL看作是“通行證”,並且在一個python程式中,GIL只有一個。拿不到通行證的執行緒,就不允許進入CPU執行。GIL只在cpython中才有,因為cpython呼叫的是c語言的原生執行緒,所以他不能直接操作cpu,只能利用GIL保證同一時間只能有一個執行緒拿到資料。而在pypy和jpython中是沒有GIL的。

Python多執行緒的工作過程:
python在使用多執行緒的時候,呼叫的是c語言的原生執行緒。

  1. 拿到公共資料
  2. 申請gil
  3. python直譯器呼叫os原生執行緒
  4. os操作cpu執行運算
  5. 當該執行緒執行時間到後,無論運算是否已經執行完,gil都被要求釋放
  6. 進而由其他程式重複上面的過程
  7. 等其他程式執行完後,又會切換到之前的執行緒(從他記錄的上下文繼續執行)
    整個過程是每個執行緒執行自己的運算,當執行時間到就進行切換(context switch)。
  • python針對不同型別的程式碼執行效率也是不同的:

    1、CPU密集型程式碼(各種迴圈處理、計算等等),在這種情況下,由於計算工作多,ticks計數很快就會達到閾值,然後觸發GIL的釋放與再競爭(多個執行緒來回切換當然是需要消耗資源的),所以python下的多執行緒對CPU密集型程式碼並不友好。
    2、IO密集型程式碼(檔案處理、網路爬蟲等涉及檔案讀寫的操作),多執行緒能夠有效提升效率(單執行緒下有IO操作會進行IO等待,造成不必要的時間浪費,而開啟多執行緒能線上程A等待時,自動切換到執行緒B,可以不浪費CPU的資源,從而能提升程式執行效率)。所以python的多執行緒對IO密集型程式碼比較友好。

  • 使用建議?

    python下想要充分利用多核CPU,就用多程式。因為每個程式有各自獨立的GIL,互不干擾,這樣就可以真正意義上的並行執行,在python中,多程式的執行效率優於多執行緒(僅僅針對多核CPU而言)。

  • GIL在python中的版本差異:

    1、在python2.x裡,GIL的釋放邏輯是當前執行緒遇見IO操作或者ticks計數達到100時進行釋放。(ticks可以看作是python自身的一個計數器,專門做用於GIL,每次釋放後歸零,這個計數可以通過sys.setcheckinterval 來調整)。而每次釋放GIL鎖,執行緒進行鎖競爭、切換執行緒,會消耗資源。並且由於GIL鎖存在,python裡一個程式永遠只能同時執行一個執行緒(拿到GIL的執行緒才能執行),這就是為什麼在多核CPU上,python的多執行緒效率並不高。
    2、在python3.x中,GIL不使用ticks計數,改為使用計時器(執行時間達到閾值後,當前執行緒釋放GIL),這樣對CPU密集型程式更加友好,但依然沒有解決GIL導致的同一時間只能執行一個執行緒的問題,所以效率依然不盡如人意。

2.4 執行緒鎖

由於執行緒之間是進行隨機排程,並且每個執行緒可能只執行n條執行之後,當多個執行緒同時修改同一條資料時可能會出現髒資料,所以,出現了執行緒鎖,即同一時刻允許一個執行緒執行操作。執行緒鎖用於鎖定資源,你可以定義多個鎖, 像下面的程式碼, 當你需要獨佔某一資源時,任何一個鎖都可以鎖這個資源,就好比你用不同的鎖都可以把相同的一個門鎖住是一個道理。

由於執行緒之間是進行隨機排程,如果有多個執行緒同時操作一個物件,如果沒有很好地保護該物件,會造成程式結果的不可預期,我們也稱此為“執行緒不安全”。

#實測:在python2.7、mac os下,執行以下程式碼可能會產生髒資料。但是在python3中就不一定會出現下面的問題。

import threading
import time

def run(n):
    global num
    num += 1

num = 0
t_obj = [] 

for i in range(20000):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.start()
    t_obj.append(t)

for t in t_obj:
    t.join()

print "num:", num
"""
產生髒資料後的執行結果:
num: 19999
"""

2.5 互斥鎖(mutex)

為了方式上面情況的發生,就出現了互斥鎖(Lock)

import threading
import time


def run(n):
    lock.acquire()  #獲取鎖
    global num
    num += 1
    lock.release()  #釋放鎖

lock = threading.Lock()     #例項化一個鎖物件

num = 0
t_obj = []  

for i in range(20000):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.start()
    t_obj.append(t)

for t in t_obj:
    t.join()

print "num:", num

2.6 遞迴鎖

RLcok類的用法和Lock類一模一樣,但它支援巢狀,,在多個鎖沒有釋放的時候一般會使用使用RLcok類。

import threading
import time
   
gl_num = 0
   
lock = threading.RLock()
   
def Func():
    lock.acquire()
    global gl_num
    gl_num +=1
    time.sleep(1)
    print gl_num
    lock.release()
       
for i in range(10):
    t = threading.Thread(target=Func)
    t.start()

2.7 訊號量(BoundedSemaphore類)

互斥鎖同時只允許一個執行緒更改資料,而Semaphore是同時允許一定數量的執行緒更改資料 ,比如廁所有3個坑,那最多隻允許3個人上廁所,後面的人只能等裡面有人出來了才能再進去。

import threading
import time


def run(n):
    semaphore.acquire()   #加鎖
    time.sleep(1)
    print("run the thread:%s\n" % n)
    semaphore.release()     #釋放


num = 0
semaphore = threading.BoundedSemaphore(5)  # 最多允許5個執行緒同時執行

for i in range(22):
    t = threading.Thread(target=run, args=("t-%s" % i,))
    t.start()

while threading.active_count() != 1:
    pass  # print threading.active_count()
else:
    print('-----all threads done-----')

2.8 事件(Event類)

python執行緒的事件用於主執行緒控制其他執行緒的執行,事件是一個簡單的執行緒同步物件,其主要提供以下幾個方法:

方法 註釋
clear 將flag設定為“False”
set 將flag設定為“True”
is_set 判斷是否設定了flag
wait 會一直監聽flag,如果沒有檢測到flag就一直處於阻塞狀態

事件處理的機制:全域性定義了一個“Flag”,當flag值為“False”,那麼event.wait()就會阻塞,當flag值為“True”,那麼event.wait()便不再阻塞。

#利用Event類模擬紅綠燈
import threading
import time

event = threading.Event()


def lighter():
    count = 0
    event.set()     #初始值為綠燈
    while True:
        if 5 < count <=10 :
            event.clear()  # 紅燈,清除標誌位
            print("\33[41;1mred light is on...\033[0m")
        elif count > 10:
            event.set()  # 綠燈,設定標誌位
            count = 0
        else:
            print("\33[42;1mgreen light is on...\033[0m")

        time.sleep(1)
        count += 1

def car(name):
    while True:
        if event.is_set():      #判斷是否設定了標誌位
            print("[%s] running..."%name)
            time.sleep(1)
        else:
            print("[%s] sees red light,waiting..."%name)
            event.wait()
            print("[%s] green light is on,start going..."%name)

light = threading.Thread(target=lighter,)
light.start()

car = threading.Thread(target=car,args=("MINI",))
car.start()

2.9 條件(Condition類)

使得執行緒等待,只有滿足某條件時,才釋放n個執行緒

2.10 定時器(Timer類)

定時器,指定n秒後執行某操作

from threading import Timer
 
 
def hello():
    print("hello, world")
 
t = Timer(1, hello)
t.start()  # after 1 seconds, "hello, world" will be printed

3 多程式

在linux中,每個程式都是由父程式提供的。每啟動一個子程式就從父程式克隆一份資料,但是程式之間的資料本身是不能共享的。

from multiprocessing import Process
import time
def f(name):
    time.sleep(2)
    print('hello', name)
 
if __name__ == '__main__':
    p = Process(target=f, args=('bob',))
    p.start()
    p.join()
from multiprocessing import Process
import os
 
def info(title):
    print(title)
    print('module name:', __name__)
    print('parent process:', os.getppid())  #獲取父程式id
    print('process id:', os.getpid())   #獲取自己的程式id
    print("\n\n")
 
def f(name):
    info('\033[31;1mfunction f\033[0m')
    print('hello', name)
 
if __name__ == '__main__':
    info('\033[32;1mmain process line\033[0m')
    p = Process(target=f, args=('bob',))
    p.start()
    p.join()

3.1 程式間通訊

由於程式之間資料是不共享的,所以不會出現多執行緒GIL帶來的問題。多程式之間的通訊通過Queue()或Pipe()來實現

3.1.1 Queue()

使用方法跟threading裡的queue差不多

from multiprocessing import Process, Queue
 
def f(q):
    q.put([42, None, 'hello'])
 
if __name__ == '__main__':
    q = Queue()
    p = Process(target=f, args=(q,))
    p.start()
    print(q.get())    # prints "[42, None, 'hello']"
    p.join()
3.1.2 Pipe()

Pipe的本質是程式之間的資料傳遞,而不是資料共享,這和socket有點像。pipe()返回兩個連線物件分別表示管道的兩端,每端都有send()和recv()方法。如果兩個程式試圖在同一時間的同一端進行讀取和寫入那麼,這可能會損壞管道中的資料。

from multiprocessing import Process, Pipe
 
def f(conn):
    conn.send([42, None, 'hello'])
    conn.close()
 
if __name__ == '__main__':
    parent_conn, child_conn = Pipe() 
    p = Process(target=f, args=(child_conn,))
    p.start()
    print(parent_conn.recv())   # prints "[42, None, 'hello']"
    p.join()

3.2 Manager

通過Manager可實現程式間資料的共享。Manager()返回的manager物件會通過一個服務程式,來使其他程式通過代理的方式操作python物件。manager物件支援 list, dict, Namespace, Lock, RLock, Semaphore, BoundedSemaphore, Condition, Event, Barrier, Queue, Value ,Array.

from multiprocessing import Process, Manager
 
def f(d, l):
    d[1] = '1'
    d['2'] = 2
    d[0.25] = None
    l.append(1)
    print(l)
 
if __name__ == '__main__':
    with Manager() as manager:
        d = manager.dict()
 
        l = manager.list(range(5))
        p_list = []
        for i in range(10):
            p = Process(target=f, args=(d, l))
            p.start()
            p_list.append(p)
        for res in p_list:
            res.join()
 
        print(d)
        print(l)

3.3 程式鎖(程式同步)

資料輸出的時候保證不同程式的輸出內容在同一塊螢幕正常顯示,防止資料亂序的情況。
Without using the lock output from the different processes is liable to get all mixed up.

from multiprocessing import Process, Lock
 
def f(l, i):
    l.acquire()
    try:
        print('hello world', i)
    finally:
        l.release()
 
if __name__ == '__main__':
    lock = Lock()
 
    for num in range(10):
        Process(target=f, args=(lock, num)).start()

3.4 程式池

由於程式啟動的開銷比較大,使用多程式的時候會導致大量記憶體空間被消耗。為了防止這種情況發生可以使用程式池,(由於啟動執行緒的開銷比較小,所以不需要執行緒池這種概念,多執行緒只會頻繁得切換cpu導致系統變慢,並不會佔用過多的記憶體空間)

程式池中常用方法:
apply() 同步執行(序列)
apply_async() 非同步執行(並行)
terminate() 立刻關閉程式池
join() 主程式等待所有子程式執行完畢。必須在close或terminate()之後。
close() 等待所有程式結束後,才關閉程式池。

from  multiprocessing import Process,Pool
import time
 
def Foo(i):
    time.sleep(2)
    return i+100
 
def Bar(arg):
    print('-->exec done:',arg)
 
pool = Pool(5)  #允許程式池同時放入5個程式
 
for i in range(10):
    pool.apply_async(func=Foo, args=(i,),callback=Bar)  #func子程式執行完後,才會執行callback,否則callback不執行(而且callback是由父程式來執行了)
    #pool.apply(func=Foo, args=(i,))
 
print('end')
pool.close()
pool.join() #主程式等待所有子程式執行完畢。必須在close()或terminate()之後。

程式池內部維護一個程式序列,當使用時,去程式池中獲取一個程式,如果程式池序列中沒有可供使用的程式,那麼程式就會等待,直到程式池中有可用程式為止。在上面的程式中產生了10個程式,但是隻能有5同時被放入程式池,剩下的都被暫時掛起,並不佔用記憶體空間,等前面的五個程式執行完後,再執行剩下5個程式。

4 補充:協程

執行緒和程式的操作是由程式觸發系統介面,最後的執行者是系統,它本質上是作業系統提供的功能。而協程的操作則是程式設計師指定的,在python中通過yield,人為的實現併發處理。

協程存在的意義:對於多執行緒應用,CPU通過切片的方式來切換執行緒間的執行,執行緒切換時需要耗時。協程,則只使用一個執行緒,分解一個執行緒成為多個“微執行緒”,在一個執行緒中規定某個程式碼塊的執行順序。

協程的適用場景:當程式中存在大量不需要CPU的操作時(IO)。
常用第三方模組gevent和greenlet。(本質上,gevent是對greenlet的高階封裝,因此一般用它就行,這是一個相當高效的模組。)

4.1 greenlet

from greenlet import greenlet

def test1():
    print(12)
    gr2.switch()
    print(34)
    gr2.switch()

def test2():
    print(56)
    gr1.switch()
    print(78)

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()

實際上,greenlet就是通過switch方法在不同的任務之間進行切換。

4.2 gevent

from gevent import monkey; monkey.patch_all()
import gevent
import requests

def f(url):
    print('GET: %s' % url)
    resp = requests.get(url)
    data = resp.text
    print('%d bytes received from %s.' % (len(data), url))

gevent.joinall([
        gevent.spawn(f, 'https://www.python.org/'),
        gevent.spawn(f, 'https://www.yahoo.com/'),
        gevent.spawn(f, 'https://github.com/'),
])

通過joinall將任務f和它的引數進行統一排程,實現單執行緒中的協程。程式碼封裝層次很高,實際使用只需要瞭解它的幾個主要方法即可。

相關文章