為你揭秘 Python 中的進程、線程、協程、同步、異步、回調
進程和線程究竟是什麽東西?傳統網絡服務模型是如何工作的?協程和線程的關系和區別有哪些?IO 過程在什麽時間發生?
一、我們來介紹一下上下文切換技術
關於一些術語。當我們說“上下文”的時候,指的是程序在執行中的一個狀態。通常我們會調用棧來表示這個狀態。棧——記載了每個調用層級執行了哪裏和執行時的環境情況等所有有關的信息。
當我們說“上下文切換”的時候,表達的是一種從一個上下文切換到另一個上下文執行的技術。而“調度”指的是決定哪個上下文可以獲得接下來的 CPU 時間的方法。
進程
進程是一種古老而典型的上下文系統,每個進程有獨立的地址空間,資源句柄,他們互相之間不發生幹擾。每個進程在內核中都會有一個數據結構進行描述,我們稱其為進程描述符。這些描述符包含了系統管理進程所需的信息,並且放在一個叫做任務隊列的隊列裏面。
很顯然,當新建進程時,我們需要分配新的進程描述符,並且分配新的地址空間(和父地址空間的映射保持一致,但是
進程狀態
忽略去 linux 內核復雜的狀態轉移表,我們實際上可以把進程狀態歸結為三個最主要的狀態:就緒態,運行態,睡眠態。這就是任何一本系統書上都有的三態轉換圖。
就緒和執行可以互相轉換,基本這就是調度的過程。而當執行態程序需要等待某些條件(最典型就是IO)時,就會陷入睡眠態。而條件達成後,一般會自動進入就緒。
阻塞
當進程需要在某個文件句柄上做 IO,這個 fd 又沒有數據給他的時候,就會發生阻塞。具體來說,就是
記錄XX進程阻塞在了XX fd上,然後將進程標記為睡眠態,並調度出去。當 fd 上有數據時(例如對端發送的數據到達),就會喚醒阻塞在fd上的進程。進程會隨後進入就緒隊列,等待合適的時間被調度。
阻塞後的喚醒也是一個很有意思的話題。當多個上下文阻塞在一個 fd 上(雖然不多見,但是後面可以看到一個例子),而且 fd 就緒時,應該喚醒多少個上下文呢?傳統上應當喚醒所有上下文,因為如果僅喚醒一個,而這個上下文又不能消費所有數據時,就會使得其他上下文處於無謂的死鎖中。
但是有個著名的例子——accept,也是使用讀就緒來表示收到的。如果試圖用多個線程來 accept 會生什麽?當有新連接時,所有上下文都會就緒,但是只有第一個可以實際獲得 fd,其他的被調度後又立刻阻塞。這就是驚群問題 thundering herd problem。
現代linux內核已經解決了這個問題,方法驚人的簡單——accept方法加鎖。
(inet_connection_sock.c:inet_csk_wait_for_connect)
線程
線程是一種輕量進程,實際上在 linux 內核中,兩者幾乎沒有差別,除了一點——線程並不產生新的地址空間和資源描述符表,而是復用父進程的。但是無論如何,線程的調度和進程一樣,必須陷入內核態。
二、關於傳統網絡服務模型
進程模型
為每個客戶分配一個進程。優點是業務隔離,在一個進程中出現的錯誤不至於影響整個系統,甚至其他進程。Oracle 傳統上就是進程模型。缺點是進程的分配和釋放有非常高的成本。因此 Oracle 需要連接池來保持連接減少新建和釋放,同時盡量復用連接而不是隨意的新建連接。
線程模型
為每客戶分配一個線程。優點是更輕量,建立和釋放速度更快,而且多個上下文間的通訊速度非常快。缺點是一個線程出現問題容易將整個系統搞崩潰。
我們舉一個例子
py_http_fork_thread.py
在這個例子中,線程模式和進程模式可以輕易的互換。
我們來分析它是如何工作的:
父進程監聽服務端口
在有新連接建立的時候,父進程執行 fork,產生一個子進程副本
如果子進程需要的話,可以 exec (例如 CGI )
父進程執行(理論上應當先執行子進程,因為exec執行的快可以避免 COW )到 accept 後,發生阻塞
上下文調度,內核調度器選擇下一個上下文,如無意外,應當就是剛剛派生的子進程
子進程進程進入讀取處理狀態,阻塞在read調用上,所有上下文均進入睡眠態
隨著 SYN 或者數據報文到來,CPU 會喚醒對應fd上阻塞的上下文( wait_queue ),切換到就緒態,並加入調度隊列
上下文繼續執行到下一個阻塞調用,或者因為時間片耗盡被掛起
評價
同步模型,編寫自然,每個上下文可以當作其他上下文不存在一樣的操作,每次讀取數據可以當作
必然能讀取到。
進程模型自然的隔離了連接。即使程序復雜且易崩潰,也只影響一個連接而不是在整個系統。
生成和釋放開銷很大(效率測試的進程 fork 和線程模式開銷測試),需要考慮復用。
進程模式的多客戶通訊比較麻煩,尤其在共享大量數據的時候。
性能
thread 模式,虛擬機:
1: 909.27 2: 3778.38 3: 4815.37 4: 5000.04 10: 4998.16 50: 4881.93 100: 4603.24 200:3445.12 500: 1778.26 (出現錯誤)
fork 模式,虛擬機:
1: 384.14 2: 435.67 3: 435.17 4: 437.54 10: 383.11 50: 364.03 100: 320.51 (出現錯誤)
thread 模式,物理機:
1: 6942.78 2: 6891.23 3: 6584.38 4: 6517.23 10: 6178.50 50: 4926.91 100: 2377.77
註意:在 python 中,雖然有GIL,但是一個線程陷入到網絡 IO 的時候,GIL 是解鎖的。因此從調用開
始到調用結束,減去 CPU 切換到其他上下文的時間,是可以多線程的。現象是,在此種狀況下可以觀測
到短暫的 python CPU 用量超過100%。
如果執行多個上下文,可以充分利用這段時間。所觀測到的結果就是,只能單核的 python,在小範圍內,其隨著並發數上升,性能居然會跟著上升。如果將這個過程轉移到一臺物理機上執行,那麽基本不
能得出這樣的結論。這主要是因為虛擬機上內核陷入的開銷更高。
三、我們來說一些 C10K 問題
當同時連接數在10K左右時,傳統模型就不再適用。實際上在效率測試報告的線程切換開銷一節可以看到,超過1K後性能就差的一塌糊塗了。
進程模型的問題:
在 C10K 的時候,啟動和關閉這麽多進程是不可接受的開銷。事實上單純的進程 fork 模型在 C1K 時就
應當拋棄了。
Apache 的 prefork 模型,是使用預先分配(pre)的進程池。這些進程是被復用的。但即便是復用,本文所描述的很多問題仍不可避免。
線程模式的問題
從任何測試都可以表明,線程模式比進程模式更耐久一些,性能更好。但是在面對 C10K 還是力不從心
的。問題是,線程模式的問題出在哪裏呢?
關於內存問題
有些人可能認為線程模型的失敗首先在於內存。如果你這麽認為,一定是因為你查閱了非常老的資料,並且沒仔細思考過。你可能看到某些資料說,一個線程棧會消耗8M內存( linux 默認值,ulimit 可以看
到),512個線程棧就會消耗4G內存,而10K個線程就是80G。所以首先要考慮調整棧深度,並考慮爆棧問題。聽起來很有道理,問題是——linux的棧是通過缺頁來分配內存的(How does stack allocation work in Linux?),不是所有棧地址空間都分配了內存。因此,8M是最大消耗,實際的內存消耗只會略大於實際需要的內存(內部損耗,每個在4k以內)。但是內存一旦被分配,就很難回收(除非線程結束),這是線程模式的缺陷。
這個問題提出的前提是,32位下地址空間有限。雖然10K個線程不一定會耗盡內存,但是512個線程一定
會耗盡地址空間。然而這個問題對於目前已經成為主流的64位系統來說根本不存在。
內核陷入開銷的問題
所謂內核陷入開銷,就是指 CPU 從非特權轉向特權,並且做輸入檢查的一些開銷。這些開銷在不同
的系統上差異很大。
線程模型主要通過陷入切換上下文,因此陷入開銷大聽起來有點道理。實際上,這也是不成立的。線程
在什麽時候發生陷入切換?正常情況下,應當是 IO 阻塞的時候。同樣的 IO 量,難道其他模型就不需要陷入了麽?只是非阻塞模型有很大可能直接返回,並不發生上下文切換而已。效率測試報告的基礎調用開銷一節,證實了當代操作系統上內核陷入開銷是非常驚人的小的(10個時鐘周期這個量級)。線程模型的問題在於切換成本高。
熟悉linux內核的應該知道,近代linux調度器經過幾個階段的發展。
linux2.4的調度器
O(1)調度器
CFS
實際上直到 O(1) 調度器的調度復雜度才和隊列長度無關。在此之前,過多的線程會使得開銷隨著線
程數增長(不保證線性)。
O(1) 調度器看起來似乎是完全不隨著線程的影響。但是這個調度器有顯著的缺點——難於理解和維護,
並且在一些情況下會導致交互式程序響應緩慢。
CFS 使用紅黑樹管理就緒隊列。每次調度,上下文狀態轉換,都會查詢或者變更紅黑樹。紅黑樹的開銷
大約是 O(logm),其中m大約為活躍上下文數(準確的說是同優先級上下文數),大約和活躍的客戶數相當。
因此,每當線程試圖讀寫網絡,並遇到阻塞時,都會發生 O(logm) 級別的開銷。而且每次收到報文,喚
醒阻塞在 fd 上的上下文時,同樣要付出 O(logm) 級別的開銷。
O(logm) 的開銷看似並不大,但是卻是一個無法接受的開銷。因為 IO 阻塞是一個經常發生的事情。每
次 IO 阻塞,都會發生開銷。而且決定活躍線程數的是用戶,這不是我們可控制的。更糟糕的是,當性能下降,響應速度下降時。同樣的用戶數下,活躍上下文會上升(因為響應變慢了)。這會進一步拉低性能。
問題的關鍵在於,http 服務並不需要對每個用戶完全公平,偶爾某個用戶的響應時間大大的延長了是可以接受的。在這種情況下,使用紅黑樹去組織待處理 fd 列表(其實是上下文列表),並且反復計算
和調 度,是無謂的開銷。
本文出自 “Python & Golang 學習” 博客,轉載請與作者聯系!
為你揭秘 Python 中的進程、線程、協程、同步、異步、回調