Python進程、線程、回調與協程 總結筆記 適合新手明確基本概念

怎樣讓python在現代的機器上運行的更快,充分利用多個核心,有效地實現并行、并發一直是人們的追求方向。

GIL

  • 談到Python的執行效率就不得不提到GIL。Python的GIL(Global Interpreter Lock)全局解釋器鎖會阻止Python代碼同時在多個處理器核心上運行,因為一個Python解釋器在同一時刻只能運行于一個處理器之中。(CPython有此限制,Cpython是大部分Python程序員所使用的參考實現。而某些Python實現則沒有這一限制,這其中最有名的就是Jython——用Java語言實現的Python)。

1、CPU Bound

  • 但是實際上對于計算密集型程序來說,可以用multiprocessing模塊的多進程實現并行/并發(并發和并行概念參考),每個進程都是獨立的,它們都有自己的Python解釋器實例,所以就不會爭奪GIL了,可以完全利用每個核心。處理速度的提升大致同CPU的核心數成正比。CPU核心數可以用multiprocessing.cpu_count()語句得到。如果同時運行小于CPU核心數的任務,則任務是完全并行執行的,在不需要同步的情況下互不干擾。如果同時運行大于CPU核心數的任務,則至少有個核心要同時運行2個或以上的任務,這樣的并發執行中會帶來任務的切換開銷,降低效率。
    補充:進程是一種古老而典型的上下文系統,每個進程有獨立的地址空間,資源句柄,他們互相之間不發生干擾。很顯然,當新建進程時,我們需要分配新的進程描述符,并且分配新的地址空間(和父地址空間的映射保持一致,但是兩者同時進入COW(寫時復制)狀態。這些過程需要一定的開銷。Pool()連接池用來保持連接,減少新建和釋放,同時盡量復用連接而不是隨意的新建連接,來減少系統開銷。(池的參考
    multiprocessing:左側為多進程,右側為多線程,接口統一

拓展:python3中multiprocessing中使用多參數的技巧,來自stackOverFlow

#!/usr/bin/env python3
from functools import partial
from itertools import repeat
from multiprocessing import Pool, freeze_support
def func(a, b):
    return a + b
def main():
    a_args = [1,2,3]
    second_arg = 1
    with Pool() as pool:
        L = pool.starmap(func, [(1, 1), (2, 1), (3, 1)])
        M = pool.starmap(func, zip(a_args, repeat(second_arg)))
        N = pool.map(partial(func, b=second_arg), a_args)
        assert L == M == N
if __name__=="__main__":
    freeze_support()
    main()
  • python的多線程受制于全局解釋器鎖,使得它們不能真正的執行并行計算,在計算密集型應用面前相當弱勢。甚至于,多核多線程還會比單核多線程慢很多,在多核CPU上,存在嚴重的線程顛簸(thrashing)。
    借用一刀捅死大萌德的說法,多核多線程比單核多線程更差,原因是單核下多線程,每次釋放GIL,喚醒的那個線程都能獲取到GIL鎖,所以能夠無縫執行,但多核下,CPU0釋放GIL后,其他CPU上的線程都會進行競爭,但GIL可能會馬上又被CPU0拿到,導致其他幾個CPU上被喚醒后的線程會醒著等待直到切換時間結束后又進入待調度狀態,導致線程顛簸,降低了系統效率。
    GIL_2cpu

    在文末參考中的《Python的GIL是什么鬼,多線程性能究竟如何》中的《當前GIL設計的缺陷》一節中有一個對于多核多線程缺陷的分析。
    另外即使是單核多線程,由于線程切換開銷,對于計算密度大的應用還可能比單核單線程要慢。

2、I/O Bound

  • 越來越多的程序是IO密集型而非CPU密集型。IO密集型任務,例如爬蟲http服務這些網絡程序,它們將時間花在維持許多低效連接和應對偶發事件上。
    進程的分配和釋放有非常高的成本,對于大量IO事件分配進程來處理是不劃算的。那么我們來考慮線程。線程模式比進程模式更耐久一些,性能更好,但是還是存在實際使用的問題。

補充:線程是一種輕量進程,實際上在linux內核中,兩者幾乎沒有差別,除了一點——線程并不產生新的地址空間和資源描述符表,而是復用父進程的。線程的調度和進程一樣,都必須陷入內核態。

  • 爬蟲是一個典型的異步應用程序:它等待很多應答,但是計算很少,主要時間在I/O上。目標是要在同一時間內爬取盡可能多的頁面。如果給每一個在途請求分發一個線程,當并發的請求數量增多時,它會在用光系統能夠提供的(socket)套接字描述符之前先耗盡內存或其它線程相關的資源。

  • ? 其實從著名的 C10K 問題的時候, 就談到了高并發編程時, 采用多線程(或進程)是一種不可取的解決方案, 核心原因是因為線程(或進程)本質上都是操作系統的資源, 每個線程需要額外占用一定量的內存空間, 在Jesse的系統上每個python線程消耗50K的內存,開啟上萬個線程會導致故障。
    ? 線程由操作系統調度,線程切換時存在內核陷入開銷,但是有說法是當代操作系統上內核陷入開銷是非常驚人的小的(10個時鐘周期這個量級),所以線程的主要問題在于調度切換成本太高,當線程數超過一定數量,操作系統就會不堪重負。而且線程的調度器的實現目的和我們的具體應用程序的調度原則并不就是一致的,例如對于http服務來說,并不需要對于每個用戶完全公平,偶爾某個用戶的響應時間大大延長了是可以接受的,在這種情況下,線程的調度器就實現了一些不必要的效果而浪費了資源。

補充:類似進程池,我們也會使用線程池。簡單解釋就是一個復雜點的程序,會將線程頻繁創建的開銷通過在線程池中保存空閑線程的方式攤銷,然后再從線程池中取出并重用這些線程去處理隨后的任務;這樣和使用socket連接池效果差不多。

  • Python的多線程處在很尷尬的位置,對于CPU密集型任務,它不能真正實現并行,而對于IO密集型任務,它的實現效果也不是很好(在有上述缺陷的情況下還有GIL的限制),但是一定程度上還是有效的,雖然有GIL,但是在IO等待期間線程會釋放解釋器,這樣別的線程就有機會使用解釋器,實現了并發。Python多線程的實現最簡便的就是用上面multiprocessing圖中右側的方法。

  • 此外,線程的搶占式切換容易使它們陷入競態。要加鎖控制同步。

  • 搶占式:現行進程在運行過程中,如果有重要或緊迫的進程到達(其狀態必須為就緒),則現運行進程將被迫放棄處理機,系統將處理機立即分配給新到達的進程。
  • 非搶占式:讓進程運行直到結束或阻塞的調度方式。

  • 于是我們嘗試使用異步IO來避免對大量線程的需求。典型的有Nodejs利用事件驅動來解決高并發問題,其實就是在底層使用了libuv然后通過各種回調函數來注冊事件,當事件觸發時回調函數也被觸發。但是回調的嵌套導致代碼的邏輯結構不清晰。

事件循環是一種等待程序分配事件或消息的編程架構。“當A發生時,執行B”。事件循環被認為是一種循環是因為它不停地收集事件并通過循環它們來處理事件。(監聽)。

from selectors import DefaultSelector, EVENT_WRITE
import socket
selector = DefaultSelector()
sock = socket.socket()
sock.setblocking(False)
# 設置socket為阻塞或非阻塞
# sock.setblocking(True) is equivalent to sock.settimeout(None)
# sock.setblocking(False) is equivalent to sock.settimeout(0.0)
try:
    sock.connect(('xkcd.com', 80))  # 僅僅發送連接請求
except BlockingIOError:
    pass
# 設置socket為非阻塞必然拋出異常
def connected():
    selector.unregister(sock.fileno())
    print('connected!')
# 注冊回調(文件描述符, 事件, 回調函數)
selector.register(sock.fileno(), EVENT_WRITE, connected)
#
def loop():
    while True:
        events = selector.select()
        for event_key, event_mask in events:
            callback = event_key.data
            callback()

我們無視虛假的錯誤(BlockingIOError)然后調用selector.register,并傳入socket文件描述符和一個代表我們等待事件類型的常量。同時我們傳入一個回調函數connected用來在事件發生時被調用。connected回調函數被存儲為event_key.data。下面的循環中調用在select()處暫停了,直到有下一個IO事件發生,當發生時就獲取回調函數并調用。
到此,我們展示了如何開始一個操作,并在該操作的I/O準備好之后執行一個回調。一個異步框架基于兩個我們展示的特性:非阻塞的套接字和事件循環,以此來實現單線程的并發。
我們在這里實現的是并發而不是并行。也就是說,我們創建了一個操作重疊IO的小系統。它能在其它I/O作業還在途時(即I/O還未準備好時)開始新的作業。它并沒有真正利用多核心來執行并行計算。但是它就是被設計來應對高IO問題,而不是CPU密集型問題的。
異步并不就比多線程快,通常它不是的,實際上就python而言,在服務小數量級的很活躍的鏈接時,一個類似于上文的事件循環會一定程度上慢于多線程。在這樣的工作負載下,如果沒有運行時的全局解釋器鎖,多線程將會表現得更好。異步IO適合的是許多緩慢和可睡眠的鏈接,適用于相應的情況。

  • 再進一步,我們嘗試將它拓展為一個異步爬蟲。抓取一個頁面需要一系列的回調。當套接字連接時connected函數被調用,向服務器發送一個GET請求。但是它必須等待服務器的響應,所以它注冊了另一個回調。當這個新注冊的回調被調用時,很可能它并沒有讀完所有的應答內容,它必須再次注冊一個回調,如此以往。函數在需要等待事件的地方必須注冊另一個回調,然后放棄控制權給事件循環。當所有頁面被下載完后,抓取器停止全局事件循環然后程序退出。
    然而這樣的程序的編寫使得異步的問題很明顯:會導致無法控制的面條代碼(指代碼控制結構復雜、混亂而難以理解)。
    我們需要某個方法來表達一系列的計算和IO操作,并且調度多個這樣一系列的動作使它們同時運行。但是沒有多線程的情況下,一系列的操作不能被放在單個函數中:不論何時一個函數開始一個IO操作,它顯式地保存了在未來需要的狀態,然后再返回。我們需要自己完成關于狀態保存的代碼。
    為了解釋上面所述,考慮下我們以傳統方式阻塞套接字的程序:
# 阻塞方式的爬蟲主干代碼
def fetch(url):
    sock = socket.socket()
    sock.connect(('xkcd.com', 80))  # 請求連接,可能會被阻塞
    request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url)
    sock.send(request.encode('ascii'))  # 發送請求內容,可能會被阻塞
    response = b''
    chunk = sock.recv(4096)  # 讀取接收信息,可能會被阻塞
    while chunk:
        response += chunk
        chunk = sock.recv(4096) # 讀取接收信息,可能會被阻塞

    # 解析頁面信息,返回新的鏈接,然后將它們加入待爬取隊列
    links = parse_links(response)
    q.add(links)

在一次socket操作和下一次之間函數記住了什么狀態呢?它擁有套接字,一個URL,還有積累的response。一個運行在線程上的函數使用了編程語言的基本特性來保存臨時狀態在本地變量中,即它的棧上。這個函數還具有一個延續——那就是,它在IO完成后計劃執行的代碼。運行時通過儲存線程的指令指針來記住函數的后續內容。在IO操作之后,你不需要考慮重新加載這些東西。因為它是語言內在的特性。
但是在異步框架中就不能指望它們自動完成。當要等待一個IO時,一個函數必須顯式地保存它的狀態,因為函數在IO完成前已經返回了并且喪失了它的棧幀。為了替代本地變量,我們的基于回調的爬蟲需要存儲了sock和response作為抓取器實例的屬性。為了替代指令指針,它需要通過注冊回調函數connected和read_response來記錄它的后續操作。當應用程序的特性不斷增加時,我們通過回調手動儲存的狀態的復雜性也在增加。這讓人頭痛。

  • 更糟糕的是,當一個回調函數拋出異常時我們無法知道我們從何而來,要去何方。

  • 在關于多線程和異步的效率之爭外,還有關于誰更易出錯的爭論:多線程很容易出現數據的競態,如果你沒有處理好他們之間的同步;但是(在較為復雜的任務中)回調很難調試因為stack ripping(意指程序運行上下文的丟失)。

  • 但是Python中引入的協程消除了這個缺點,兼具了效率和可維護性。(對于爬蟲我們可以使用python3.4之后的asyncio標準庫和一個叫aiohttp的包。)

相比于每個線程的50k的內存消耗和操作系統本身對于線程的硬限制。一個python協程只需要3k的內存(在Jesse的系統上)。Python能夠輕易地開始上萬的協程。

  • 協程的概念很簡單:它是可以暫停和恢復的子程序。線程由操作系統搶占式多任務調度,而協程采用合作式多任務調度:它們自己選擇什么時候暫停,誰下一個運行,亦即線程和進程是由操作系統調度的,而協程的調度由用戶自己控制。它比回調要清晰和簡單。協程是一種更好的高并發解決方案。它將復雜的邏輯和異步都封裝在底層,讓程序員感覺不到異步的存在。協程也叫作用戶級線程*。

    當然,一些實現中,使用callback也有好處——coroutine協程的最小切換開銷也在50ns,而call本身則只有2ns。

  • 協程有很多種實現,即使在Python中也是。Python3.4標準庫中的asyncio基于生成器、一個Future類和yield from 語句構建。而從python3.5開始,協程變成了語言的原生特性。但是理解協程在python3.4中是怎樣利用之前存在的語言工具實現的,是搞定python3.5中的原生協程的基礎。

  • 理解協程可看文末的參考。

補充:協程是為非搶占式多任務產生子程序的計算機程序組件,協程允許不同入口點在不同位置暫停或開始執行程序,亦即可以暫停執行的函數。

  • 協程良好的異常處理和堆棧跟蹤,使其成為好的選擇。不像線程,協程呈現了代碼中可以被中斷的地方和不能的。線程使得局部推理(local reasoning)變得困難,而局部推理可能是軟件開發中最重要的事情了。協程明確地產出(yield)使得不需要去檢查整個系統就能理解一段程序的行為和正確性。

  • 在了解asyncio的協程是如何工作的之后,你可以忘掉大部分細節。機制隱藏在一個短小精悍的接口后面。使用協程編程非常簡單,但是你對于基本概念的掌握使你能夠正確有效地在現代異步環境中編程。

參考


最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372

推薦閱讀更多精彩內容