python的進程與線程
- 2019 年 10 月 3 日
- 筆記
進程、線程的含義?
1.什麼是進程?
進程是指運行中的應用程序,每個進程都有自己獨立的地址空間(內存空間)。比如用戶點擊桌面的IE瀏覽器,就啟動了一個進程,操作系統就會為該進程分配獨立的地址空間。當用戶再次點擊IE瀏覽器,又啟動了一個進程,操作系統將為新的進程分配新的獨立的地址空間。多進程就是“多任務”,就像使用電腦時同時打開瀏覽器上網、打開播放器聽歌、後台還默默運行着殺毒軟件一樣。現代操作系統如Mac OS X,UNIX,Linux,Windows等都支持多進程,每啟動一個進程,操作系統便為該進程分配一個獨立的內存空間。
2.什麼是線程?
線程是進程中的一個實體,是被系統獨立調度和分派的基本單位。一個進程可以有一個線程,也可以有多個線程。
線程自己不擁有獨立的系統資源,只擁有一點在運行中必不可少的資源,它可與同屬一個進程的其它線程共享當前進程所擁有的全部資源。
一個線程可以創建和撤消另一個線程,同一進程中的多個線程之間可以並發執行。
線程有就緒(runnable)、阻塞(blocked)和運行(running)三種基本狀態以及新建(new)和死亡(dead)狀態。
為什麼要有多進程和多線程?
每個進程至少要干一件事,比如一個編輯器既要打字輸入同時又要檢測打錯的拼寫有時候還要區分一些關鍵字高亮顯示,它們同屬於編輯器這個進程,我們把編輯器作為一個進程,而以上這些工作就是它的子任務,如何實現他們同時工作呢?就是讓每個子任務即線程短暫運行交替執行,由於它們彼此之間交替太快了,看起來就像同時運行一樣。(真正的多線程需要多核CPU才能實現)
當我們要讓一個python程序執行多個任務時,我們可以用多個進程或多個線程來完成我們的任務,他們之間彼此同時交替進行甚至一個任務依賴於另一個任務執行的結果,他們需要相互通信和協調,所以我們就需要用到多進程和多線程編程了。
實現多進程和多線程
1.多進程
linux下可使用os模塊的fork()。
Unix/Linux操作系統提供了一個fork()系統調用,它非常特殊。普通的函數調用,調用一次,返回一次,但是fork()調用一次,返回兩次,因為操作系統自動把當前進程(稱為父進程)複製了一份(稱為子進程),然後,分別在父進程和子進程內返回。
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))
windows下可以使用multiprocessing模塊
multiprocessing模塊提供了一個Process類來代表一個進程對象,下面的例子演示了啟動一個子進程並等待其結束:
from multiprocessing import Process import os # 子進程要執行的代碼 def run_proc(name): print('Run child process %s (%s)...' % (name, os.getpid())) if __name__=='__main__': 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()方法啟動。
join()方法可以等待子進程結束後再繼續往下運行,通常用於進程間的同步。
Pool
如果要啟動大量的子進程,可以用進程池的方式批量創建子進程:
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))) if __name__=='__main__': 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.')
對Pool對象調用join()方法會等待所有子進程執行完畢,調用join()之前必須先調用close(),調用close()之後就不能繼續添加新的Process了。
子進程
很多時候,子進程並不是自身,而是一個外部進程。我們創建了子進程後,還需要控制子進程的輸入和輸出。
subprocess模塊可以讓我們非常方便地啟動一個子進程,然後控制其輸入和輸出。
下面的例子演示了如何在Python代碼中運行命令nslookup www.python.org,這和命令行直接運行的效果是一樣的:
import subprocess print('$ nslookup www.python.org') r = subprocess.call(['nslookup', 'www.python.org']) print('Exit code:', r)
2.多線程
使用threading模塊實現多線程,Python的線程是真正的Posix Thread,而不是模擬出來的線程。
import time, threading def loop(): print('線程 %s 在運行' % threading.current_thread().name) n = 0 while n < 5: n = n + 1 print('線程 %s >>> %s' % (threading.current_thread().name, n)) time.sleep(1) print('線程 %s 結束.' % threading.current_thread().name) print('線程 %s 在運行' % threading.current_thread().name) t = threading.Thread(target=loop, name='子線程1') t2 = threading.Thread(target=loop, name='子線程2') t.start() t2.start() t.join() t2.join() print('線程 %s 結束.' % threading.current_thread().name)
或者
import time, threading
num=0 lock = threading.Lock() def action_one(): global num for i in range(3): lock.acquire() try: print("線程1 %d"%num) num+=1 time.sleep(1) finally: lock.release() def action_two(): global num for i in range(3): lock.acquire() try: print("線程2 %d"%num) num+=1 time.sleep(1) finally: lock.release() t1 = threading.Thread(target=action_one, name='子線程1') t2 = threading.Thread(target=action_two, name='子線程2') t1.start() t2.start() t1.join() t2.join()
進程之間和線程之間的相互協調
1.進程間的通信:
Process之間肯定是需要通信的,操作系統提供了很多機制來實現進程間的通信。Python的multiprocessing模塊包裝了底層的機制,提供了Queue、Pipes等多種方式來交換數據。
以Queue為例,在父進程中創建兩個子進程,一個往Queue里寫數據,一個從Queue里讀數據:
from multiprocessing import Process, Queue import os, time, random # 寫數據進程執行的代碼: def write(q): print('Process to write: %s' % os.getpid()) for value in ['A', 'B', 'C']: print('Put %s to queue...' % value) q.put(value) time.sleep(random.random()) # 讀數據進程執行的代碼: def read(q): print('Process to read: %s' % os.getpid()) while True: value = q.get(True) print('Get %s from queue.' % value) if __name__=='__main__': # 父進程創建Queue,並傳給各個子進程: q = Queue() pw = Process(target=write, args=(q,)) pr = Process(target=read, args=(q,)) # 啟動子進程pw,寫入: pw.start() # 啟動子進程pr,讀取: pr.start() # 等待pw結束: pw.join() # pr進程里是死循環,無法等待其結束,只能強行終止: pr.terminate()
在Unix/Linux下,multiprocessing模塊封裝了fork()調用,使我們不需要關注fork()的細節。由於Windows沒有fork調用,因此,multiprocessing需要“模擬”出fork的效果,父進程所有Python對象都必須通過pickle序列化再傳到子進程去,所有,如果multiprocessing在Windows下調用失敗了,要先考慮是不是pickle失敗了。
2.線程間通信
1.Queue
使用線程隊列有一個要注意的問題是,向隊列中添加數據項時並不會複製此數據項,線程間通信實際上是在線程間傳遞對象引用。如果你擔心對象的共享狀態,那你最好只傳遞不可修改的數據結構(如:整型、字符串或者元組)或者一個對象的深拷貝。
Queue 對象提供一些在當前上下文很有用的附加特性。比如在創建 Queue 對象時提供可選的 size 參數來限制可以添加到隊列中的元素數量。對於“生產者”與“消費者”速度有差異的情況,為隊列中的元素數量添加上限是有意義的。比如,一個“生產者”產生項目的速度比“消費者”“消費”的速度快,那麼使用固定大小的隊列就可以在隊列已滿的時候阻塞隊列,以免未預期的連鎖效應擴散整個程序造成死鎖或者程序運行失常。在通信的線程之間進行“流量控制”是一個看起來容易實現起來困難的問題。如果你發現自己曾經試圖通過擺弄隊列大小來解決一個問題,這也許就標誌着你的程序可能存在脆弱設計或者固有的可伸縮問題。 get() 和 put() 方法都支持非阻塞方式和設定超時。
import queue
q = queue.Queue() try: data = q.get(block=False) except queue.Empty: ... try: q.put(item, block=False) except queue.Full: ... try: data = q.get(timeout=5.0) except queue.Empty: ...
def producer(q): ... try: q.put(item, block=False) except queue.Full: log.warning('queued item %r discarded!', item) _running = True
def consumer(q): while _running: try: item = q.get(timeout=5.0) # Process item ... except queue.Empty: pass
最後,有 q.qsize() , q.full() , q.empty() 等實用方法可以獲取一個隊列的當前大小和狀態。但要注意,這些方法都不是線程安全的。可能你對一個隊列使用empty() 判斷出這個隊列為空,但同時另外一個線程可能已經向這個隊列中插入一個數據項。所以,你最好不要在你的代碼中使用這些方法。
為了避免出現死鎖的情況,使用鎖機制的程序應該設定為每個線程一次只允許獲取一個鎖。如果不能這樣做的話,你就需要更高級的死鎖避免機制。在 threading 庫中還提供了其他的同步原語,比如 RLock 和 Semaphore 對象。
Queue提供的方法:
task_done()
意味着之前入隊的一個任務已經完成。由隊列的消費者線程調用。每一個get()調用得到一個任務,接下來的task_done()調用告訴隊列該任務已經處理完畢。
如果當前一個join()正在阻塞,它將在隊列中的所有任務都處理完時恢復執行(即每一個由put()調用入隊的任務都有一個對應的task_done()調用)。
join()
阻塞調用線程,直到隊列中的所有任務被處理掉。
只要有數據被加入隊列,未完成的任務數就會增加。當消費者線程調用task_done()(意味着有消費者取得任務並完成任務),未完成的任務數就會減少。當未完成的任務數降到0,join()解除阻塞。
put(item[, block[, timeout]])
將item放入隊列中。
1.如果可選的參數block為True且timeout為空對象(默認的情況,阻塞調用,無超時)。
2.如果timeout是個正整數,阻塞調用進程最多timeout秒,如果一直無空空間可用,拋出Full異常(帶超時的阻塞調用)。
3.如果block為False,如果有空閑空間可用將數據放入隊列,否則立即拋出Full異常
其非阻塞版本為put_nowait等同於put(item, False)
get([block[, timeout]])
從隊列中移除並返回一個數據。block跟timeout參數同put方法
其非阻塞方法為get_nowait()相當與get(False)
empty()
如果隊列為空,返回True,反之返回False
2.同步機制Event
線程的一個關鍵特性是每個線程都是獨立運行且狀態不可預測。如果程序中的其他線程需要通過斷某個線程的狀態來確定自己下一步的操作,這時線程同步問題就會變得非常棘手。為了解決這些問題,我們需要使用 threading 庫中的 Event 對象。
Event 對象包含一個可由線程設置的信號標誌,它允許線程等待某些事件的發生。在初始情況下,event 對象中的信號標誌被設置假。如果有線程等待一個 event 對象,而這個 event 對象的標誌為假,那麼這個線程將會被一直阻塞直至該標誌為真。一個線程如果將一個 event 對象的信號標誌設置為真,它將喚醒所有等待個 event 對象的線程。如果一個線程等待一個已經被設置為真的 event 對象,那麼它將忽略這個事件,繼續執行。
from threading import Thread, Event import time def countdown(n, start_evt): print('countdown is starting...') start_evt.set() while n > 0: print('T-minus', n) n -= 1 time.sleep(5) start_evt = Event() # 可通過Event 判斷線程的是否已運行 t = Thread(target=countdown, args=(10, start_evt)) t.start() print('launching countdown...') start_evt.wait() # 等待countdown執行 # event 對象的一個重要特點是當它被設置為真時會喚醒所有等待它的線程 print('countdown is running...')
Semaphore(信號量)
在多線程編程中,為了防止不同的線程同時對一個公用的資源(比如全部變量)進行修改,需要進行同時訪問的數量(通常是1)的限制。信號量同步基於內部計數器,每調用一次acquire(),計數器減1;每調用一次release(),計數器加1.當計數器為0時,acquire()調用被阻塞。
from threading import Semaphore, Lock, RLock, Condition, Event, Thread import time # 信號量 sema = Semaphore(3) #限制同時能訪問資源的數量為3 def foo(tid): with sema: print('{} acquire sema'.format(tid)) time.sleep(1) print('{} release sema'.format(tid)) threads = [] for i in range(5): t = Thread(target=foo, args=(i,)) threads.append(t) t.start() for t in threads: t.join()
Lock(鎖)
互斥鎖為資源引入一個狀態:鎖定/非鎖定。某個線程要更改共享數據時,先將其鎖定,此時資源的狀態為“鎖定”,其他線程不能更改;直到該線程釋放資源,將資源的狀態變成“非鎖定”,其他的線程才能再次鎖定該資源。
互斥鎖保證了每次只有一個線程進行寫入操作,從而保證了多線程情況下數據的正確性。
#創建鎖 mutex = threading.Lock() #鎖定 mutex.acquire([timeout]) #釋放 mutex.release()
RLock(可重入鎖)
為了支持在同一線程中多次請求同一資源,python提供了“可重入鎖”:threading.RLock。RLock內部維護着一個Lock和一個counter變量,counter記錄了acquire的次數,從而使得資源可以被多次acquire。直到一個線程所有的acquire都被release,其他的線程才能獲得資源。
import threading import time class MyThread(threading.Thread): def run(self): global num time.sleep(1) if mutex.acquire(1): num = num+1 msg = self.name+' set num to '+str(num) print msg mutex.acquire() mutex.release() mutex.release()
num = 0 mutex = threading.RLock()
def test(): for i in range(5): t = MyThread() t.start()
if __name__ == '__main__': test()
Condition(條件變量)
Condition被稱為條件變量,除了提供與Lock類似的acquire和release方法外,還提供了wait和notify方法。線程首先acquire一個條件變量,然後判斷一些條件。如果條件不滿足則wait;如果條件滿足,進行一些處理改變條件後,通過notify方法通知其他線程,其他處於wait狀態的線程接到通知後會重新判斷條件。不斷的重複這一過程,從而解決複雜的同步問題。
可以認為Condition對象維護了一個鎖(Lock/RLock)和一個waiting池。線程通過acquire獲得Condition對象,當調用wait方法時,線程會釋放Condition內部的鎖並進入blocked狀態,同時在waiting池中記錄這個線程。當調用notify方法時,Condition對象會從waiting池中挑選一個線程,通知其調用acquire方法嘗試取到鎖。
Condition對象的構造函數可以接受一個Lock/RLock對象作為參數,如果沒有指定,則Condition對象會在內部自行創建一個RLock。
除了notify方法外,Condition對象還提供了notifyAll方法,可以通知waiting池中的所有線程嘗試acquire內部鎖。由於上述機制,處於waiting狀態的線程只能通過notify方法喚醒,所以notifyAll的作用在於防止有線程永遠處於沉默狀態。
import threading import time class Producer: def run(self): global count while True: if con.acquire(): if count > 1000: con.wait() else: count += 100 msg = threading.current_thread().name + ' produce 100, count=' + str(count) print(msg) con.notify() # 通知 waiting線程池中的線程 con.release() time.sleep(1) count = 0 con = threading.Condition() class Consumer: def run(self): global count while True: if con.acquire(): if count < 100: con.wait() else: count -= 3 msg = threading.current_thread().name + ' consumer 3, count=' + str(count) print(msg) con.notify() con.release() time.sleep(3) producer = Producer()
進程和線程的比較
1.穩定性
多進程模式最大的優點就是穩定性高,因為一個子進程崩潰了它擁有自己獨立的內存空間,不會影響主進程和其他子進程(主進程崩掉,子進程也難逃厄運)。多進程模式的缺點是創建進程的代價大,在Unix/Linux系統下,用fork調用還行,在Windows下創建進程開銷巨大。另外,操作系統能同時運行的進程數也是有限的,在內存和CPU的限制下,如果有幾千個進程同時運行,操作系統連調度都會成問題。
多線程模式通常比多進程快,多線程模式致命的缺點就是任何一個線程掛掉都可能直接造成整個進程崩潰,因為所有線程共享進程的內存。
2.切換開銷
首先上下文切換就是從當前執行任務切換到另一個任務執行的過程。但是,為了確保下次能從正確的位置繼續執行,在切換之前,會保存上一個任務的狀態。
操作系統在切換進程或者線程時需要先保存當前執行的現場環境(CPU寄存器狀態、內存頁等),然後,把新任務的執行環境準備好(恢復上次的寄存器狀態,切換內存頁等),才能開始執行。這個切換過程雖然很快,但是也需要耗費時間。
但是線程的切換虛擬空間內存是相同的,但是進程切換的虛擬空間內存則是不同的。所以線程上下文切換比進程上下文切換快的多。同時,這兩種上下文切換的處理都是通過操作系統內核來完成的。內核的這種切換過程伴隨的最顯著的性能損耗是將寄存器中的內容切換出。
3.計算密集型和IO密集型下的選擇
我們可以把任務分為計算密集型和IO密集型。
計算密集型任務的特點是要進行大量的計算,消耗CPU資源。IO密集型任務的特點是涉及到網絡、磁盤IO,這類任務的特點是CPU消耗很少,任務的大部分時間都在等待IO操作完成
對比維度 |
多進程 |
多線程 |
總結 |
數據共享、同步 |
數據共享複雜,需要用IPC;數據是分開的,同步簡單 |
因為共享進程數據,數據共享簡單,但也是因為這個原因導致同步複雜 |
各有優勢 |
內存、CPU |
佔用內存多,切換複雜,CPU利用率低 |
佔用內存少,切換簡單,CPU利用率高 |
線程佔優 |
創建銷毀、切換 |
創建銷毀、切換複雜,速度慢 |
創建銷毀、切換簡單,速度很快 |
線程佔優 |
編程、調試 |
編程簡單,調試簡單 |
編程複雜,調試複雜 |
進程佔優 |
可靠性 |
進程間不會互相影響 |
一個線程掛掉將導致整個進程掛掉 |
進程佔優 |
分佈式 |
適應於多核、多機分佈式;如果一台機器不夠,擴展到多台機器比較簡單 |
適應於多核分佈式 |
進程佔優 |
(1)需要頻繁創建銷毀的優先用線程
原因請看上面的對比。
這種原則最常見的應用就是Web服務器了,來一個連接建立一個線程,斷了就銷毀線程,要是用進程,創建和銷毀的代價是很難承受的
(2)需要進行大量計算的優先使用線程
所謂大量計算,當然就是要耗費很多CPU,切換頻繁了,這種情況下線程是最合適的。
這種原則最常見的是圖像處理、算法處理。
(3)強相關的處理用線程,弱相關的處理用進程
什麼叫強相關、弱相關?理論上很難定義,給個簡單的例子就明白了。
一般的Server需要完成如下任務:消息收發、消息處理。“消息收發”和“消息處理”就是弱相關的任務,而“消息處理”裏面可能又分為“消息解碼”、“業務處理”,這兩個任務相對來說相關性就要強多了。因此“消息收發”和“消息處理”可以分進程設計,“消息解碼”、“業務處理”可以分線程設計。
當然這種劃分方式不是一成不變的,也可以根據實際情況進行調整。
(4)可能要擴展到多機分佈的用進程,多核分佈的用線程
原因請看上面對比。
我的博客即將同步至騰訊雲+社區,邀請大家一同入駐:https://cloud.tencent.com/developer/support-plan?invite_code=2qkn8bupvco4o