閱讀926 返回首頁    go Python


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

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

GIL

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

1、CPU Bound

但是實際上對於計算密集型程序來說,可以用模塊的多進程實現並行/並發(並發和並行概念參考),每個進程都是獨立的,它們都有自己的Python解釋器實例,所以就不會爭奪GIL了,可以完全利用每個核心。處理速度的提升大致同CPU的核心數成正比。CPU核心數可以用語句得到。如果同時運行小於CPU核心數的任務,則任務是完全並行執行的,在不需要同步的情況下互不幹擾。如果同時運行大於CPU核心數的任務,則至少有個核心要同時運行2個或以上的任務,這樣的並發執行中會帶來任務的切換開銷,降低效率。

補充:進程是一種古老而典型的上下文係統,每個進程有獨立的地址空間,資源句柄,他們互相之間不發生幹擾。很顯然,當新建進程時,我們需要分配新的進程描述符,並且分配新的地址空間(和父地址空間的映射保持一致,但是兩者同時進入COW(寫時複製)狀態。這些過程需要一定的開銷。連接池用來保持連接,減少新建和釋放,同時盡量複用連接而不是隨意的新建連接,來減少係統開銷。(池的參考)

multiprocessing:左側為多進程,右側為多線程,接口統一

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

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”。事件循環被認為是一種循環是因為它不停地收集事件並通過循環它們來處理事件。(監聽)。

以socket 連接為例:

我們無視虛假的錯誤()然後調用,並傳入socket文件描述符和一個代表我們等待事件類型的常量。同時我們傳入一個回調函數用來在事件發生時被調用。connected回調函數被存儲為。下麵的循環中調用在處暫停了,直到有下一個IO事件發生,當發生時就獲取回調函數並調用。

到此,我們展示了如何開始一個操作,並在該操作的I/O準備好之後執行一個回調。一個異步框架基於兩個我們展示的特性:非阻塞的套接字和事件循環,以此來實現單線程的並發。

我們在這裏實現的是並發而不是並行。也就是說,我們創建了一個操作重疊IO的小係統。它能在其它I/O作業還在途時(即I/O還未準備好時)開始新的作業。它並沒有真正利用多核心來執行並行計算。但是它就是被設計來應對高IO問題,而不是CPU密集型問題的。

異步並不就比多線程快,通常它不是的,實際上就python而言,在服務小數量級的很活躍的鏈接時,一個類似於上文的事件循環會一定程度上慢於多線程。在這樣的工作負載下,如果沒有運行時的全局解釋器鎖,多線程將會表現得更好。異步IO適合的是許多緩慢和可睡眠的鏈接,適用於相應的情況。

再進一步,我們嚐試將它拓展為一個異步爬蟲。抓取一個頁麵需要一係列的回調。當套接字連接時connected函數被調用,向服務器發送一個GET請求。但是它必須等待服務器的響應,所以它注冊了另一個回調。當這個新注冊的回調被調用時,很可能它並沒有讀完所有的應答內容,它必須再次注冊一個回調,如此以往。函數在需要等待事件的地方必須注冊另一個回調,然後放棄控製權給事件循環。當所有頁麵被下載完後,抓取器停止全局事件循環然後程序退出。

然而這樣的程序的編寫使得異步的問題很明顯:會導致無法控製的麵條代碼(指代碼控製結構複雜、混亂而難以理解)。

我們需要某個方法來表達一係列的計算和IO操作,並且調度多個這樣一係列的動作使它們同時運行。但是沒有多線程的情況下,一係列的操作不能被放在單個函數中:不論何時一個函數開始一個IO操作,它顯式地保存了在未來需要的狀態,然後再返回。我們需要自己完成關於狀態保存的代碼。

為了解釋上麵所述,考慮下我們以傳統方式阻塞套接字的程序:

在一次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的協程是如何工作的之後,你可以忘掉大部分細節。機製隱藏在一個短小精悍的接口後麵。使用協程編程非常簡單,但是你對於基本概念的掌握使你能夠正確有效地在現代異步環境中編程。

最後更新:2017-10-08 15:39:59

  上一篇:go Python 字典操作進階
  下一篇:go Python 學習完基礎語法知識後,如何進一步提高?