寫在前面
世界是複雜的,每一種思想都是爲了解決某些現實問題而簡化成的模型,想解決就得先面對,面對就需要選擇角度,角度決定了模型的質量, 喜歡此UP主湯質看本質的哲學科普,其中簡潔又不失細節的介紹了人類解決問題的思路,以及由概念搭建的思維模型對人類解決問題的重要性與限制.也認識到學習的本質就是: 認識獲取(瞭解概念) -> 知識學習(建立模型) -> 技能訓練(實踐)
閱讀也好, 學習也好, 妨礙我們「理解」的障礙主要有兩個:
- 高度抽象的概念
- 「模型」無法關聯現象
也就是說 概念明確 + 關係明確, 才能構成「模型」, 對照「現象」, 形成「理解」。
在理解編程知識時可以關鍵歸納爲兩點:理解核心概念羣+使用場景思考與故事化講述
這裏特別推薦碼農翻身中大話編程式的科普:
併發模型
併發思想的一些探尋併發之痛 Thread, Goroutine, Actor中有較好的總結:
陳力就列, 不能者止 能幹活的代碼片段就放在線程裏, 如果幹不了活(需要等待, 被阻塞等), 就摘下來。通俗的說就是不要佔着茅坑不拉屎, 如果拉不出來, 需要醞釀下, 先把茅坑讓出來, 因爲茅坑是稀缺資源。
要做到這點一般有兩種方案:
異步回調方案
典型如NodeJS, 遇到阻塞的情況, 比如網絡調用, 則註冊一個回調方法(其實還包括了一些上下文數據對象)給IO調度器(linux下是libev, 調度器在另外的線程裏), 當前線程就被釋放了, 去幹別的事情了。等數據準備好, 調度器會將結果傳遞給回調方法然後執行, 執行其實不在原來發起請求的線程裏了, 但對用戶來說無感知。但這種方式的問題就是很容易遇到callback hell, 因爲所有的阻塞操作都必須異步, 否則系統就卡死了。還有就是異步的方式有點違反人類思維習慣, 人類還是習慣同步的方式。GreenThread/Coroutine/Fiber方案
這種方案其實和上面的方案本質上區別不大, 關鍵在於回調上下文的保存以及執行機制。爲了解決回調方法帶來的難題, 這種方案的思路是寫代碼的時候還是按順序寫, 但遇到IO等阻塞調用時, 將當前的代碼片段暫停, 保存上下文, 讓出當前線程。等IO事件回來, 然後再找個線程讓當前代碼片段恢復上下文繼續執行, 寫代碼的時候感覺好像是同步的, 彷彿在同一個線程完成的, 但實際上系統可能切換了線程, 但對程序無感。
GreenThread
* 用戶空間 首先是在用戶空間, 避免內核態和用戶態的切換導致的成本。
* 由語言或者框架層調度
* 更小的棧空間允許創建大量實例(百萬級別)
幾個概念
* Continuation 這個概念不熟悉FP編程的人可能不太熟悉, 不過這裏可以簡單的顧名思義, 可以理解爲讓我們的程序可以暫停, 然後下次調用繼續(contine)從上次暫停的地方開始的一種機制。相當於程序調用多了一種入口。
* Coroutine 是Continuation的一種實現, 一般表現爲語言層面的組件或者類庫。主要提供yield, resume機制。
* Fiber 和Coroutine其實是一體兩面的, 主要是從系統層面描述, 可以理解成Coroutine運行之後的東西就是Fiber。
Goroutine其實就是前面GreenThread系列解決方案的一種演進和實現。
程序員修神之路--分佈式高併發下Actor模型如此優秀中說:
傳統多數流行的語言併發是基於多線程之間的共享內存, 使用同步方法防止寫爭奪, Actors使用消息模型, 每個Actor在同一時間處理最多一個消息, 可以發送消息給其他Actor, 保證了單獨寫原則。從而巧妙避免了多線程寫爭奪。和共享數據方式相比, 消息傳遞機制最大的優點就是不會產生數據競爭狀態。實現消息傳遞有兩種常見的類型:基於channel(golang爲典型代表)的消息傳遞和基於Actor(erlang爲代表)的消息傳遞。二者的格言都是:"Don’t communicate by sharing memory, share memory by communicating"
進程通信模型
每個進程各自有不同的用戶地址空間,任何一個進程的全局變量在另一個進程中都看不到, 所以進程之間要交換數據必須通過內核,在內核中開闢一塊緩衝區,進程A把數據從用戶空間拷到內核緩衝區,進程B再從內核緩衝區把數據讀走,內核提供的這種機制稱爲進程間通信。
進程間幾種通信方式:
管道:速度慢, 容量有限, 只有父子進程能通訊
FIFO:任何進程間都能通訊, 但速度慢
消息隊列:容量受到系統限制, 且要注意第一次讀的時候, 要考慮上一次沒有讀完數據的問題
信號量:不能傳遞複雜消息, 只能用來同步 5.共享內存區:能夠很容易控制容量, 速度快, 但要保持同步, 比如一個進程在寫的時候, 另一個進程要注意讀寫的問題, 相當於線程中的線程安全, 當然, 共享內存區同樣可以用作線程間通訊, 不過沒這個必要, 線程間本來就已經共享了同一進程內的一塊內存
Socket通信(又名客戶機服務器系統)
Python 爲進程通信提供了兩種機制:
Queue:一個進程向 Queue 中放入數據, 另一個進程從 Queue 中讀取數據。如multiprocessing.Queue()
Pipe:Pipe 代表連接兩個進程的管道。程序在調用 Pipe() 函數時會產生兩個連接端, 分別交給通信的兩個進程, 接下來進程既可從該連接端讀取數據, 也可向該連接端寫入數據。如multiprocessing.Pipe()
方式有很多種,其他模型中也都能在此找到影子.
CSP模型
CSP(communicating sequential processes)模型裏消息和Channel是主體
也就是說發送方需要關心自己的消息類型以及應該寫到哪個Channel, 但不需要關心誰消費了它, 以及有多少個消費者。
Golang是自己解決的通信問題, 從概念上就當消息隊列理解, 但是技術上, golang用的不是linux的消息隊列.
Actor模型
Actor模型是1973年提出的一個分佈式併發編程模式, 在Erlang語言中得到廣泛支持和應用。
Actor模型和CSP模型的區別CSP模型和Actor模型是兩門非常復古且外形接近的併發模型。但CSP與Actor有以下幾點比較大的區別:
- CSP並不Focus發送消息的實體/Task, 而是關注發送消息時消息所使用的載體, 即channel。
- 在Actor的設計中, Actor與信箱是耦合的, 而在CSP中channel是作爲first-class獨立存在的。
- 另外一點在於, Actor中有明確的send/receive的關係, 而channel中並不區分這樣的關係, 執行塊可以任意選擇發送或者取出消息。
Go併發
以上的鋪墊應該對併發涉及到的概念有清晰的認識,也能發現這些概念都不是go或python原創的,這裏有較好的總結
Go/Python/Erlang編程語言對比分析及示例 說:
Go的很多語言特性借鑑與它的三個祖先:C, Pascal和CSP。Go的語法、數據類型、控制流等繼承於C, Go的包、面對對象等思想來源於Pascal分支, 而Go最大的語言特色, 基於管道通信的協程併發模型, 則借鑑於CSP分支。
不要用共享內存來通信, 要用通信來共享內存大概是golang在推廣中最容易被人提及的了,類似python之禪一樣.
Golang調度器有三個主要數據結構。
G (goroutine) 協程, 被Golang語言本身管理的線程
舉例來說, func main() { go other() }, 這段代碼創建了兩個goroutine,
一個是main, 另一個是other, 注意main本身也是一個goroutine.
goroutine的新建, 休眠, 恢復, 停止都受到go運行時的管理.
goroutine執行異步操作時會進入休眠狀態, 待操作完成後再恢復, 無需佔用系統線程,
goroutine新建或恢復時會添加到運行隊列, 等待M取出並運行.
M (machine) 操作系統的線程, 被操作系統管理的, 原生線程
M可以運行兩種代碼:
go代碼, 即goroutine, M運行go代碼需要一個P
原生代碼, 例如阻塞的syscall, M運行原生代碼不需要P
M會從運行隊列中取出G, 然後運行G, 如果G運行完畢或者進入休眠狀態, 則從運行隊列中取出下一個G運行, 周而復始.
有時候G需要調用一些無法避免阻塞的原生代碼, 這時M會釋放持有的P並進入阻塞狀態, 其他M會取得這個P並繼續運行隊列中的G.
go需要保證有足夠的M可以運行G, 不讓CPU閒着, 也需要保證M的數量不能過多.
P (process) 調度的上下文, 運行在M上的調度器。
P是process的頭文字, 代表M運行G所需要的資源.
一些講解協程的文章把P理解爲cpu核心, 其實這是錯誤的.
雖然P的數量默認等於cpu核心數, 但可以通過環境變量GOMAXPROC修改, 在實際運行時P跟cpu核心並無任何關聯.
P也可以理解爲控制go代碼的並行度的機制,
如果P的數量等於1, 代表當前最多只能有一個線程(M)執行go代碼,
如果P的數量等於2, 代表當前最多只能有兩個線程(M)執行go代碼.
執行原生代碼的線程數量不受P控制.
因爲同一時間只有一個線程(M)可以擁有P, P中的數據都是鎖自由(lock free)的, 讀寫這些數據的效率會非常的高.
G的狀態
- 空閒中(_Gidle): 表示G剛剛新建, 仍未初始化
- 待運行(_Grunnable): 表示G在運行隊列中, 等待M取出並運行
- 運行中(_Grunning): 表示M正在運行這個G, 這時候M會擁有一個P
- 系統調用中(_Gsyscall): 表示M正在運行這個G發起的系統調用, 這時候M並不擁有P
- 等待中(_Gwaiting): 表示G在等待某些條件完成, 這時候G不在運行也不在運行隊列中(可能在channel的等待隊列中)
- 已中止(_Gdead): 表示G未被使用, 可能已執行完畢(並在freelist中等待下次複用)
- 棧複製中(_Gcopystack): 表示G正在獲取一個新的棧空間並把原來的內容複製過去(用於防止GC掃描)
M的狀態
M並沒有像G和P一樣的狀態標記, 但可以認爲一個M有以下的狀態:
- 自旋中(spinning): M正在從運行隊列獲取G, 這時候M會擁有一個P
- 執行go代碼中: M正在執行go代碼, 這時候M會擁有一個P
- 執行原生代碼中: M正在執行原生代碼或者阻塞的syscall, 這時M並不擁有P
- 休眠中: M發現無待運行的G時會進入休眠, 並添加到空閒M鏈表中, 這時M並不擁有P
- 自旋中(spinning)這個狀態非常重要, 是否需要喚醒或者創建新的M取決於當前自旋中的M的數量.
P的狀態
- 空閒中(_Pidle): 當M發現無待運行的G時會進入休眠, 這時M擁有的P會變爲空閒並加到空閒P鏈表中
- 運行中(_Prunning): 當M擁有了一個P後, 這個P的狀態就會變爲運行中, M運行G會使用這個P中的資源
- 系統調用中(_Psyscall): 當go調用原生代碼, 原生代碼又反過來調用go代碼時, 使用的P會變爲此狀態
- GC停止中(_Pgcstop): 當gc停止了整個世界(STW)時, P會變爲此狀態
- 已中止(_Pdead): 當P的數量在運行時改變, 且數量減少時多餘的P會變爲此狀態
Golang 的協程本質上其實就是對 IO 事件的封裝, 並且通過語言級的支持讓異步的代碼看上去像同步執行的一樣。
Python併發
本段落涉及的代碼基本是對深入理解Python異步編程(上) 的註解,之前也學習過yield,也總結了幾次,
但之前都沒有把事件循環聯繫進來,感性的知道python中的協程就是靠:"事件循環 + 回調",其中細節一直沒深入看,asyncio源碼也看過幾次,也是走馬觀花.這次偶然看到這麼有系統且有示例代碼輔助的文章,所以下面的東西很多都來自此文章以及對其代碼的註解.
在asyncio正式轉正前,就有很多人和庫嘗試了其他方式,如:
- stackless 的通道(channel)
- 能夠在微進程之間交換信息。channel.send + channel.receive
- 能夠控制運行的流程。
- yield和greenlet
- 都可以實現協程,不過每一次都要人爲的去指向下一個該執行的協程, 顯得太過麻煩
- gevent
- gevent每次遇到io操作, 需要耗時等待時, 會自動跳到下一個協程繼續執行。邪惡的猴子補丁(Monkey patching)在這種情況下, gevent能夠修改標準庫裏面大部分的阻塞式系統調用, 包括socket、ssl、threading和 select等模塊, 而變爲協作式運行。
理解yield以及yield from
先了解 py3.3 -> py3.8 之間的異步方式演進,建議使用官方yield例子,在idea中debug調試運行,着重看函數中yield處中斷執行後又如何被恢復,其實主要就是通過next或send讓函數恢復執行.然後就是找到那些next和send以及是被怎麼推動的
總結來說,協程就是對可以中斷/恢復執行的函數的調度.
題外話閱讀源碼的三種境界
yield 的四種常見用法:
1. 生成器
2. 用於定義上下文管理器
3. 協程
4. 配合 from 形成 yield from 用於消費子生成器並傳遞消息
這四種用法, 其實都源於 yield 所具有的暫停的特性, 也就說程序在運行到 yield 所在的位置 result = yield expr 時, 先執行 yield expr 將產生的值返回給調用生成器的 caller
, 然後暫停, 等待 caller 再次激活並恢復程序的執行。而根據恢復程序使用的方法不同, yield expr 表達式的結果值 result 也會跟着變化。
如果使用 next() 來調用, 則 yield 表達式的值 result 是 None;如果使用 send() 來調用, 則 yield 表達式的值 result 是通過 send 函數傳送的值。
用 yield from 改進生成器協程
yield from 一方面可以迭代地消耗生成器, 另一方面則建立了一條雙向通道, 把最外層的調用方與最內層的子生成器連接起來, 並自動地處理異常, 接收子生成器返回的值。
yield from 更多地被用於協程, 而 await 關鍵字的引入會大大減少 yield from 的使用頻率。
實現yield from語法的僞代碼如下:
"""
_i:子生成器, 同時也是一個迭代器
_y:子生成器生產的值
_r:yield from 表達式最終的值
_s:調用方通過send()發送的值
_e:異常對象
"""
#簡化版
_i = iter(EXPR)
try:
_y = next(_i)
except StopIteration as _e:
_r = _e.value
else:
while 1:
try:
_s = yield _y
except StopIteration as _e:
_r = _e.value
break
RESULT = _r
#完整版
_i = iter(EXPR)
try:
_y = next(_i)
except StopIteration as _e:
_r = _e.value
else:
while 1:
try:
_s = yield _y
except GeneratorExit as _e:
try:
_m = _i.close
except AttributeError:
pass
else:
_m()
raise _e
except BaseException as _e:
_x = sys.exc_info()
try:
_m = _i.throw
except AttributeError:
raise _e
else:
try:
_y = _m(*_x)
except StopIteration as _e:
_r = _e.value
break
else:
try:
if _s is None:
_y = next(_i)
else:
_y = _i.send(_s)
except StopIteration as _e:
_r = _e.value
break
RESULT = _r
通過示例來理解
參考 yield_to_from.py,劃分一下方便理解:
1、調用方:調用委派生成器的客戶端(調用方)代碼
2、委託生成器:包含yield from 表達式的生成器函數
3、子生成器:yield from 後面加的生成器函數
有不清晰的地方,就在IDE中debug下,着重來看包含yield的函數之間的跳轉,以及yield from存在的意義.
n = m = 5
flag = "stop" # 子生成器停止信號,此例子中是有調用者控制,也可以改寫成子生成器控制,調用者檢查到信號還停止迭代子生成器.
"""
1、調用方:調用委派生成器的客戶端(調用方)代碼
2、委託生成器:包含yield from 表達式的生成器函數
3、子生成器:yield from 後面加的生成器函數
重點:yield讓函數中斷執行,next或send讓函數恢復執行,使用debug查看各個函數間的跳轉,或者直接運行,看print打印.
"""
def gen(): # 子生成器
print("start 子生成器")
# for k in range(n): # 有限子生成器
k = "k"
while True: # 無限子生成器
print("子生成器--要返回的值:", k)
t = yield k # 1.運行到這裏就會停下來,切換到其他地方,等待send或next觸發後再從此處繼續執行 2.yield功能相當於golang中的chan,可接受可發送
print("子生成器--接受到的值:", t)
if t is flag:
break
print("end 子生成器")
return "這就是result" # 生成器退出時, 生成器(或子生成器)中的return expr表達式會出發StopIteration(expr)異常拋出
def proxy_gen(): # 委託生成器--類似go-chan
# 在調用方與子生成器之間建立一個雙向通道,調用方可以通過send()直接發送消息給子生成器,而子生成器yield的值,也是直接返回給調用方
# while True:
result = yield from gen()
print("委託生成器result:", result)
yield result
def main1(): # 調用方1--不通過proxy_gen迭代子生成器
g = gen() # 子生成器
print(g.send(None))
print(g.send(1)) # 發送1到子生成器中
print(next(g))
try:
print(g.send(flag)) # 不使用委託器 子生成器的停止信號就得手動處理
except StopIteration as e:
print("StopIteration")
print("子生成器return的值:", e.value)
def main2(): # 調用方2--常用迭代
g = proxy_gen()
g.send(None) # 需要先激活子生成器,否則會報錯 TypeError: can't send non-None value to a just-started generator
for k in range(m):
print("調用方--要發送的值:", k)
print("調用方--接受到的值:", g.send(k))
print("--------------------")
g.send(flag) # 針對無限子生成器的停止信號
def main3(): # 調用方3--死循環
g = proxy_gen()
g.send(None) # 需要先激活子生成器,否則會報錯 TypeError: can't send non-None value to a just-started generator
for k in g: # for調用能完整的遍歷生成器,遍歷的時候已經調用了__next__,相當於g.send(None)
print("調用方--接受到的值:", k)
print("調用方--要發送的值:", g.send("m"))
print("調用方--接受到的值:", k)
print("--------------------")
print("*********************")
main1()
print("*********************")
main2()
print("*********************")
main3()
print("*********************")
文字描述
包含yield語句的函數就是一個生成器對象, 調用一個生成器函數, 返回的是一個迭代器對象。迭代器Iterator表示的是一個數據流, 迭代器可以被next()函數調用並不斷返回下一個數據, 直到沒有數據時拋出StopIteration錯誤。迭代器控制生成器函數的執行, 當函數開始運行, 執行到第一個yield語句時暫停, 將yield表達式後的表達式的值返回給調用者。
在生成器函數暫停時, 其現階段的狀態都被保存下來, 包括生成器函數局部變量當前綁定的值、指令指針、函數內部執行堆棧以及任何異常狀態的處理。當生成器函數再次被調用時則直接從上次暫停的yield表達式處接着運行, 直到遇到下一個yield語句, 或者沒有遇到yield語句則運行結束。
需要說明的是, 在函數重新運行時, 其實上次暫停處的yield表達式會先接收一個值作爲結果, 然後才接着運行直到碰到下一個yield表達式。
如果調用者使用next函數或者__next__()方法, 則默認返回給yield表達式None值;使用send()方法則傳遞一個值作爲yield表達式的結果。
對於簡單的迭代器, yield from iterable本質上等於for item in iterable: yield item的縮寫版(iterable 也可以是generator),yield 和 send(next)成對出現,有點類似於go中的chan,彼此通知對方數據到位請繼續執行下去
一般將yield from視爲提供了一個調用者和子生成器之間的透明的雙向通道。包括從子生成器獲取數據以及向子生成器傳送數據。
- generator.__next__():啓動或從上個yield表達式處恢復生成器運行;當生成器被__next__()方法恢復運行時, 當前yield表達式被賦值爲None;for循環和next()函數都隱式調用__next__()。
- generator.send(value):恢復並返回值給生成器函數;返回給生成器函數的值將賦予當前的yield表達式, 並向調用者返回下一個yield表達式產生的值。如果要啓動生成器函數, 則用send(None)。
- StopIteration異常是特殊的,迭代完生成器都會拋出,但都會被自動catch住,讓生成器之後的代碼繼續運行
總結:
- 子生成器產出的值都直接傳給委派生成器的調用方(即客戶端代碼)
- 使用send()方法發送給委派生成器的值都直接傳給子生成器。如果發送的值爲None,那麼會給委派調用子生成器的__next__()方法。如果發送的值不是None,那麼會調用子生成器的send方法, 如果調用的方法拋出StopIteration異常, 那麼委派生成器恢復運行, 任何其他異常都會向上冒泡, 傳給委派生成器
- 生成器退出時, 生成器(或子生成器)中的return expr表達式會出發StopIteration(expr)異常拋出
- 子生成器可能只是一個迭代器, 並不是一個作爲協程的生成器, 所以它不支持.throw()和.close()方法,即可能會產生AttributeError 異常。
- yield from表達式的值是子生成器終止時傳給StopIteration異常的第一個參數。yield from 結構的另外兩個特性與異常和終止有關。
- 傳入委派生成器的異常, 除了GeneratorExit之外都傳給子生成器的throw()方法。如果調用throw()方法時拋出StopIteration異常, 委派生成器恢復運行。StopIteration之外的異常會向上冒泡, 傳給委派生成器
- 如果把GeneratorExit異常傳入委派生成器, 或者在委派生成器上調用close()方法, 那麼在子生成器上調用clsoe()方法, 如果它有的話。如果調用close()方法導致異常拋出, 那麼異常會向上冒泡, 傳給委派生成器, 否則委派生成器拋出GeneratorExit異常
asyncio
asyncio是Python 3.4 試驗性引入的異步I/O框架(PEP 3156), 提供了基於協程做異步I/O編寫單線程併發代碼的基礎設施。其核心組件有事件循環(Event Loop)、協程(Coroutine)、任務(Task)、未來對象(Future)以及其他一些擴充和輔助性質的模塊。
實現原理:
事件循環+回調
有一個任務調度器(event loop), 然後可以用async def定義異步函數作爲任務邏輯, 通過create_task接口把任務掛到event loop上。
event loop的運行過程應該是個不停循環的過程, 不停查看等待類別有沒有可以執行的任務, 如果有的話執行任務, 直到碰到await之類的主動讓出event loop的函數, 如此反覆。
若是看源碼的你就會發現使用yield和yield from實現協程也會用到類似EventLoop,Future,Future,Coroutine的東西,這在下面的示例部分再次看到.
- Eventloop 是asyncio應用的核心, 是中央總控。Eventloop實例提供了註冊、取消和執行任務和回調的方法。
- Future 是結果存儲+回調管理器
- Coroutine 使用生成器技術來替代連續的多個回調
- Task 負責將Coroutine接口和Future、EventLoop接口對接起來, 同時它自己也是一個Future.
對比生成器版的協程, 使用asyncio庫後變化很大:
* 沒有了yield 或 yield from, 而是async/await
* 沒有了自造的loop(), 取而代之的是asyncio.get_event_loop()
* 無需自己在socket上做異步操作, 不用顯式地註冊和註銷事件, aiohttp庫已經代勞
* 沒有了顯式的 Future 和 Task, asyncio已封裝
更少量的代碼, 更優雅的設計
示例註解
分別使用yield,yield from,asyncio 模擬協程,併發的爬幾個url的代碼.
__doc__ = '如何使用yield完成協程(簡化版的asyncio)'
import socket
from selectors import DefaultSelector, EVENT_WRITE, EVENT_READ
selector = DefaultSelector()
stopped = False
host = "127.0.0.1" # 自建一個簡單服務,模擬一個設置每個請求需要等待1s才返回結果
port = 5000
urls_todo = {'/', '/1', '/2', '/3', '/4', '/5', '/6', '/7', '/8', '/9'}
# urls_todo = {'/'}
# 在單線程內做協作式多任務調度
# 要異步,必回調
# 但爲了避免地獄式回調,將回調一拆爲三,回調鏈變成了Future-Task-Coroutine
# 下面的註解都是爲了能方便理解Future-Task-Coroutine之間的互動以及怎麼串起來的.
"""
無鏈式調用
selector的回調裏只管給future設置值, 不再關心業務邏輯
loop 內回調callback()不再關注是誰觸發了事件,因爲協程能夠保存自己的狀態, 知道自己的future是哪個。也不用關心到底要設置什麼值, 因爲要設置什麼值也是協程內安排的。
已趨近於同步代碼的結構
無需程序員在多個協程之間維護狀態, 例如哪個纔是自己的sock
"""
"""
1.創建Crawler 實例;
2.調用fetch方法, 會創建socket連接和在selector上註冊可寫事件;
3.fetch內並無阻塞操作, 該方法立即返回;
4.重複上述3個步驟, 將10個不同的下載任務都加入事件循環;
5.啓動事件循環, 進入第1輪循環, 阻塞在事件監聽上;
6.當某個下載任務EVENT_WRITE被觸發, 回調其connected方法, 第一輪事件循環結束;
7.進入第2輪事件循環, 當某個下載任務有事件觸發, 執行其回調函數;此時已經不能推測是哪個事件發生, 因爲有可能是上次connected裏的EVENT_READ先被觸發, 也可能是其他某個任務的EVENT_WRITE被觸發;(此時, 原來在一個下載任務上會阻塞的那段時間被利用起來執行另一個下載任務了)
8.循環往復, 直至所有下載任務被處理完成
9.退出事件循環, 結束整個下載程序
"""
# 異步調用執行完的時候, 就把結果放在它裏面。這種對象稱之爲未來對象。
# 暫存task執行的結果和回調
class Future:
def __init__(self):
self.result = None
self._callbacks = []
def add_done_callback(self, fn): # 各階段的回調
self._callbacks.append(fn)
def set_result(self, result):
self.result = result # 調用結果,b'http請求的結果字符'
for fn in self._callbacks: # 重要,回調函數集
fn(self) # Task.step
class Task:
def __init__(self, coro):
self.coro = coro # Crawler(url).fetch()
f = Future()
# f.set_result(None) # 感覺這句不是很必要
self.step(f) # 預激活
def step(self, future): # 管理fetch生成器: 第一次的激活/暫停後的恢復執行/以及配合set_result循環調用
try:
# send會進入到coro執行, 即fetch, 直到下次yield
# next_future 爲yield返回的對象,也就是下一次要調用的Future對象
next_future = self.coro.send(future.result) # __init__中的第一次step,將fetch運行到的82行的yield,
# 返回EVENT_WRITE時的事件回調要用的future,然後等事件觸發,由select調用on_connected,進而繼續future中的回調
except StopIteration:
return
next_future.add_done_callback(self.step) # 這裏需要重點理解,爲下一次要調用的Future對象,註冊下一次的step,供on_readable調用
# Coroutine yield實現的協程
class Crawler:
def __init__(self, url):
self.url = url
self.response = b''
def fetch(self): # 函數內有了yield表達式,就是生成器了,生成器需要先調用next()迭代一次或者是先send(None)啓動,遇到yield之後便暫停
sock = socket.socket()
sock.setblocking(False)
try:
sock.connect((host, port))
except BlockingIOError:
pass
f = Future() # 每到一個io事件都註冊一個對應的Future
def on_connected():
# pass # 若沒有f.set_result,會報錯KeyError: '236 (FD 236) is already registered'
f.set_result(None) # 必要語句,還涉及到恢復回調
selector.register(sock.fileno(), EVENT_WRITE, on_connected) # 連接io寫事件
yield f # 註冊完就yield出去,等待事件觸發
selector.unregister(sock.fileno())
get = 'GET {0} HTTP/1.0\r\nHost: example.com\r\n\r\n'.format(self.url) # self.url 區分每個協程
sock.send(get.encode('ascii'))
global stopped
while True:
f = Future()
def on_readable():
f.set_result(sock.recv(4096)) # 可讀的情況下,讀取4096個bytes暫存給Future,執行回調,使生成器繼續執行下去
selector.register(sock.fileno(), EVENT_READ, on_readable) # io讀事件
chunk = yield f # 返回f,並接受step中send進來的future.result值,也就是暫存的請求返回字符
selector.unregister(sock.fileno())
if chunk:
self.response += chunk
else:
urls_todo.remove(self.url)
if not urls_todo:
stopped = True
break
print("result:", self.response)
def loop():
while not stopped:
# 阻塞, 直到一個事件發生
events = selector.select()
for event_key, event_mask in events: # 監聽事件,觸發回調,推動協程運行下去
callback = event_key.data # 就是 on_connected,和 on_readable
callback()
if __name__ == '__main__':
import time
start = time.time()
for url in urls_todo:
crawler = Crawler(url)
Task(crawler.fetch())
loop()
print(time.time() - start)
__doc__ = '如何使用yield from完成協程(簡化版的asyncio)'
import socket
from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE
selector = DefaultSelector()
stopped = False
host = "127.0.0.1" # 自建一個簡單服務,模擬一個設置每個請求需要等待1s才返回結果
port = 5000
urls_todo = {'/', '/1', '/2', '/3', '/4', '/5', '/6', '/7', '/8', '/9'}
# urls_todo = {'/'}
# 在單線程內做協作式多任務調度
# 要異步,必回調
# 但爲了避免地獄式回調,將回調一拆爲三,回調鏈變成了Future-Task-Coroutine
# 下面的註解都是爲了能方便理解Future-Task-Coroutine之間的互動以及怎麼串起來的.
"""
無鏈式調用
selector的回調裏只管給future設置值, 不再關心業務邏輯
loop 內回調callback()不再關注是誰觸發了事件,因爲協程能夠保存自己的狀態, 知道自己的future是哪個。也不用關心到底要設置什麼值, 因爲要設置什麼值也是協程內安排的。
已趨近於同步代碼的結構
無需程序員在多個協程之間維護狀態, 例如哪個纔是自己的sock
"""
"""
1.創建Crawler 實例;
2.調用fetch方法, 會創建socket連接和在selector上註冊可寫事件;
3.fetch內並無阻塞操作, 該方法立即返回;
4.重複上述3個步驟, 將10個不同的下載任務都加入事件循環;
5.啓動事件循環, 進入第1輪循環, 阻塞在事件監聽上;
6.當某個下載任務EVENT_WRITE被觸發, 回調其connected方法, 第一輪事件循環結束;
7.進入第2輪事件循環, 當某個下載任務有事件觸發, 執行其回調函數;此時已經不能推測是哪個事件發生, 因爲有可能是上次connected裏的EVENT_READ先被觸發, 也可能是其他某個任務的EVENT_WRITE被觸發;(此時, 原來在一個下載任務上會阻塞的那段時間被利用起來執行另一個下載任務了)
8.循環往復, 直至所有下載任務被處理完成
9.退出事件循環, 結束整個下載程序
"""
# 結果保存, 每一個處需要異步的地方都會調用, 保持狀態和callback
# 程序得知道當前所處的狀態, 而且要將這個狀態在不同的回調之間延續下去。
class Future:
def __init__(self):
self.result = None # 重要參數1
self._callbacks = [] # 重要參數2
def add_done_callback(self, fn): # 各階段的回調
self._callbacks.append(fn)
def set_result(self, result):
self.result = result # 調用結果,b'http請求的結果字符'
for fn in self._callbacks:
fn(self) # 執行Task.step
def __iter__(self):
"""
yield的出現使得__iter__函數變成一個生成器, 生成器本身就有next方法, 所以不需要額外實現。
yield from x語句首先調用iter(x)獲取一個迭代器(生成器也是迭代器)
"""
yield self # 外面使用yield from把f實例本身返回
return self.result # 在Task.step中send(result)的時候再次調用這個生成器, 但是此時會拋出stopInteration異常, 並且把self.result返回
# 激活包裝的生成器, 以及在生成器yield後恢復執行繼續下面的代碼
class Task:
def __init__(self, coro): # Crawler(url).fetch()
self.coro = coro
f = Future()
# f.set_result(None)
self.step(f) # 激活Task包裹的生成器
def step(self, future):
try:
# next_future = self.coro.send(future.result)
next_future = self.coro.send(None) # 驅動future
# next_future = future.send(None) # 這樣是錯誤的
# __init__中的第一次step,將fetch運行到的82行的yield,
# 返回EVENT_WRITE時的事件回調要用的future,然後等事件觸發,由select調用on_connected,進而繼續future中的回調
except StopIteration:
return
next_future.add_done_callback(self.step) # 這裏需要重點理解,爲下一次要調用的Future對象,註冊下一次的step,供on_readable調用
# 異步就是可以暫定的函數, 函數間切換的調度靠事件循環,yield 正好可以中斷函數運行
# Coroutine yield實現的協程
# 將yield_demo.py中的Crawler進行了拆解,並使用yield from
class Crawler:
def __init__(self, url):
self.url = url
self.response = b""
def fetch(self): # 委託生成器,參考yield_to_from.py
global stopped
sock = socket.socket()
yield from connect(sock, (host, port))
get = "GET {0} HTTP/1.0\r\nHost:example.com\r\n\r\n".format(self.url)
sock.send(get.encode('ascii'))
self.response = yield from read_all(sock)
print(self.response)
urls_todo.remove(self.url)
if not urls_todo:
stopped = True
# 連接事件的子協程:註冊+回調
def connect(sock, address):
f = Future()
sock.setblocking(False)
try:
sock.connect(address)
except BlockingIOError:
pass
def on_connected():
f.set_result(None)
selector.register(sock.fileno(), EVENT_WRITE, on_connected)
yield from f # f需要可迭代,需要新增Future.__iter__
selector.unregister(sock.fileno())
# 可讀事件的子協程:註冊+回調
def read(sock):
f = Future()
def on_readable():
f.set_result(sock.recv(4096))
selector.register(sock.fileno(), EVENT_READ, on_readable) # 註冊一個文件對象以監聽其IO事件;
"""
此處的chunck接收的是f中return的f.result, 同時會跑出一個stopIteration的異常, 只不過被yield from處理了。
這裏也可直接寫成chunck = yiled f
"""
chunck = yield from f # f需要可迭代,需要新增Future.__iter__
selector.unregister(sock.fileno()) # 從selection中註銷文件對象, 即從監聽列表中移除它; 文件對象應該在關閉前註銷.
return chunck
# 委託生成器,參考yield_to_from.py,生成器的嵌套
def read_all(sock):
response = []
chunk = yield from read(sock)
while chunk:
response.append(chunk)
chunk = yield from read(sock) # yield from來解決生成器裏玩生成器的問題
result = b"".join(response)
print("result:", result) # 打印下結果吧
return result
# 事件驅動, 讓所有之前註冊的callback運行起來
def loop():
while not stopped:
events = selector.select()
for event_key, event_mask in events: # 監聽事件,觸發回調,推動協程運行下去
callback = event_key.data # data就是 on_connected,和 on_readable
callback()
if __name__ == "__main__":
import time
start = time.time()
for url in urls_todo:
crawler = Crawler(url)
Task(crawler.fetch()) # 將各生成器和對應的callback註冊到事件循環loop中, 並激活生成器
loop()
print(time.time() - start)
__doc__ = "使用asyncio"
import asyncio
import aiohttp
host = 'http://127.0.0.1:5000'
urls_todo = {'/', '/1', '/2', '/3', '/4', '/5', '/6', '/7', '/8', '/9'}
loop = asyncio.get_event_loop()
async def fetch(url):
async with aiohttp.ClientSession(loop=loop) as session:
async with session.get(url) as response:
response = await response.read()
print("result:", response)
return response
if __name__ == '__main__':
import time
start = time.time()
tasks = [fetch(host + url) for url in urls_todo]
loop.run_until_complete(asyncio.gather(*tasks))
print(time.time() - start)
到這裏基本python的協程改進歷史就說完了,下面就是對比goroutine與asyncio.
這裏python協程與go協程的區別有我以前寫的一個簡單對比,下面的一些東西是補充和聯想.
對比select
python的select是基於eventloop的,是檢測事件是否可讀/可寫,可以說是協程的心臟了.
# 事件驅動, 讓所有之前註冊的callback運行起來
def loop():
while not stopped:
events = selector.select()
for event_key, event_mask in events: # 監聽事件,觸發回調,推動協程運行下去
callback = event_key.data # data就是 on_connected,和 on_readable
callback()
go中的select是檢測channel是否可讀,可寫,避免goroutine不必要的阻塞.
select {
case v1 := <-c1:
fmt.Printf("received %v from c1\n", v1)
case v2 := <-c2:
fmt.Printf("received %v from c2\n", v1)
case c3 <- 23:
fmt.Printf("sent %v to c3\n", 23)
default:
fmt.Printf("no one was ready to communicate\n")
}
對比chan與yield
var ch chan ElementType
ch := make(chan int)
ch <- value //寫入
value := <-ch //讀取
def step(self, future): # 管理fetch生成器: 第一次的激活/暫停後的恢復執行/以及配合set_result循環調用
try:
# send會進入到coro執行, 即fetch, 直到下次yield
# next_future 爲yield返回的對象,也就是下一次要調用的Future對象
next_future = self.coro.send(future.result) # __init__中的第一次step,將fetch運行到的82行的yield,
# 返回EVENT_WRITE時的事件回調要用的future,然後等事件觸發,由select調用on_connected,進而繼續future中的回調
except StopIteration:
return
next_future.add_done_callback(self.step) # 這裏需要重點理解,爲下一次要調用的Future對象,註冊下一次的step,供on_readable調用
while True:
f = Future()
def on_readable():
f.set_result(sock.recv(4096)) # 可讀的情況下,讀取4096個bytes暫存給Future,執行回調,使生成器繼續執行下去
selector.register(sock.fileno(), EVENT_READ, on_readable) # io讀事件
chunk = yield f # 返回f,並接受step中send進來的future.result值,也就是暫存的請求返回字符
selector.unregister(sock.fileno())
if chunk:
self.response += chunk
else:
urls_todo.remove(self.url)
if not urls_todo:
stopped = True
break
print("result:", self.response)
chunk = yield f,返回f,並接受step中send進來的值,yield暫停子生成器函數的運行把cpu的使用權讓出去,對比chan等待其他chan時處於等待中狀態(_Gwaiting),是不是有點 chan 的味道了.
子生成器中包含多個yield和帶緩存的chan,是不是也有相似呢?
python是單線程中調度多個協程,而go是多個進程中調度多個協程,感覺yield和chan是有異曲同工之妙的.
一點延伸
維特根斯坦說「在語言中顯示自身的東西, 我們無法用語言來表示它」, 這句話不太好理解, 請允許我做一個不負責任的類比。比如計算機編程, 邏輯相當於機器語言或者彙編語言, 反正是比較底層的那種;人的語言相當於高級編程語言, 類似java和python;我們的生活就是軟件的圖形界面。如果你是一個工程師, 你一定是順着理解這件事的——機器語言一定是基礎啊, 它是一切得以運作絕對前提啊。維特根斯坦會說, 幼稚!我當年也是這麼想的他說, 必須倒過來理解。因爲人和圖形界面的交互, 纔會有高級語言的各種安排, 纔會有機器語言的各種運作。爲什麼?因爲人才是一切的尺度, 人這個主體和軟件界面產生交互模式(人和生活), 最終決定了你那些0和1的意義(語言和邏輯)。維特根斯坦那句話的意思是, 你從圖形界面的維度能解釋爲什麼這行代碼要這樣寫, 但你在這行代碼的維度解釋不了它爲什麼會被寫成這樣, 在人與圖形界面交互的過程中, 這段字符承載的意義遠超過這段字符本身所顯示的全部, 代碼的意義在於使用, 「語言的意義也在於使用」, Meaning is use!
簡單理解, 維特根斯坦的整個邏輯是:底層原理能解釋表層現象, 但反過來卻不行。表層最多能描述底層。
比如, 人性能解釋商業爲什麼是那個樣子的, 但商業卻不能解釋人性爲什麼是那個樣子, 商業只能從它所在的側面描述人性是什麼樣的, 因爲商業形式就是被人性塑造的。之前, 我們以爲代碼是底層, 圖形是表層。其實, 圖形纔是底層, 代碼是表層, 這裏的意思是, 生活能解釋語言, 語言卻只能描述生活。語言妄圖解釋生活, 表層妄圖解釋底層的結果就是哲學的出現。
這樣就造就了一個可悲的事實,即人類對自然的認識永遠只能無限的接近真理, 卻永遠無法探究到所謂的本源, 認識自然的過程其實都是在盲人摸象。
從實際出發, 不同問題用不同方法, 一個模型是否可靠, 看的從來不是理論或模型是否高明, 檢驗真理的唯一標準只有一條, 就是實踐, 自己動手去嘗試證實。
參考資料
深入理解Python異步編程(上) # 十分期待後續的中與下.
python3.6異步IO包asyncio部分核心源碼思路梳理