阿里妹導讀:作為在日常開發生產中非常實用的語言,有必要掌握一些python用法,比如爬蟲、網路請求等場景,很是實用。但python是單執行緒的,如何提高python的處理速度,是一個很重要的問題,這個問題的一個關鍵技術,叫協程。本篇文章,講講python協程的理解與使用,主要是針對網路請求這個模組做一個梳理,希望能幫到有需要的同學。
在理解協程這個概念及其作用場景前,先要了解幾個基本的關於作業系統的概念,主要是程式、執行緒、同步、非同步、阻塞、非阻塞,瞭解這幾個概念,不僅是對協程這個場景,諸如訊息佇列、快取等,都有一定的幫助。接下來,編者就自己的理解和網上查詢的材料,做一個總結。在面試的時候,我們都會記住一個概念,程式是系統資源分配的最小單位。是的,系統由一個個程式,也就是程式組成的,一般情況下,分為文字區域、資料區域和堆疊區域。文字區域儲存處理器執行的程式碼(機器碼),通常來說,這是一個只讀區域,防止執行的程式被意外修改。資料區域儲存所有的變數和動態分配的記憶體,又細分為初始化的資料區(所有初始化的全域性、靜態、常量,以及外部變數)和為初始化的資料區(初始化為0的全域性變數和靜態變數),初始化的變數最初儲存在文字區,程式啟動後被拷貝到初始化的資料區。堆疊區域儲存著活動過程呼叫的指令和本地變數,在地址空間裡,棧區緊連著堆區,他們的增長方向相反,記憶體是線性的,所以我們程式碼放在低地址的地方,由低向高增長,棧區大小不可預測,隨開隨用,因此放在高地址的地方,由高向低增長。當堆和棧指標重合的時候,意味著記憶體耗盡,造成記憶體溢位。程式的建立和銷燬都是相對於系統資源,非常消耗資源,是一種比較昂貴的操作。程式為了自身能得到執行,必須要搶佔式的爭奪CPU。對於單核CPU來說,在同一時間只能執行一個程式的程式碼,所以在單核CPU上實現多程式,是通過CPU快速的切換不同程式,看上去就像是多個程式在同時進行。由於程式間是隔離的,各自擁有自己的記憶體記憶體資源,相比於執行緒的共同共享記憶體來說,相對安全,不同程式之間的資料只能通過 IPC(Inter-Process Communication) 進行通訊共享。執行緒是CPU排程的最小單位。如果程式是一個容器,執行緒就是執行在容器裡面的程式,執行緒是屬於程式的,同個程式的多個執行緒共享程式的記憶體地址空間。執行緒間的通訊可以直接通過全域性變數進行通訊,所以相對來說,執行緒間通訊是不太安全的,因此引入了各種鎖的場景,不在這裡闡述。當一個執行緒崩潰了,會導致整個程式也崩潰了,即其他執行緒也掛了, 但多程式而不會,一個程式掛了,另一個程式依然照樣執行。在多核作業系統中,預設程式內只有一個執行緒,所以對多程式的處理就像是一個程式一個核心。同步和非同步關注的是訊息通訊機制,所謂同步,就是在發出一個函式呼叫時,在沒有得到結果之前,該呼叫不會返回。一旦呼叫返回,就立即得到執行的返回值,即呼叫者主動等待呼叫結果。所謂非同步,就是在請求發出去後,這個呼叫就立即返回,沒有返回結果,通過回撥等方式告知該呼叫的實際結果。同步的請求,需要主動讀寫資料,並且等待結果;非同步的請求,呼叫者不會立刻得到結果。而是在呼叫發出後,被呼叫者通過狀態、通知來通知呼叫者,或通過回撥函式處理這個呼叫。阻塞和非阻塞關注的是程式在等待呼叫結果(訊息,返回值)時的狀態。阻塞呼叫是指呼叫結果返回之前,當前執行緒會被掛起。呼叫執行緒只有在得到結果之後才會返回。非阻塞呼叫指在不能立刻得到結果之前,該呼叫不會阻塞當前執行緒。所以,區分的條件在於,程式/執行緒要訪問的資料是否就緒,程式/執行緒是否需要等待。非阻塞一般通過多路複用實現,多路複用有 select、poll、epoll幾種實現方式。協程是屬於執行緒的,又稱微執行緒,纖程,英文名Coroutine。舉個例子,在執行函式A時,我希望隨時中斷去執行函式B,然後中斷B的執行,切換回來執行A。這就是協程的作用,由呼叫者自由切換。這個切換過程並不是等同於函式呼叫,因為它沒有呼叫語句。執行方式與多執行緒類似,但是協程只有一個執行緒執行。協程的優點是執行效率非常高,因為協程的切換由程式自身控制,不需要切換執行緒,即沒有切換執行緒的開銷。同時,由於只有一個執行緒,不存在衝突問題,不需要依賴鎖(加鎖與釋放鎖存在很多資源消耗)。協程主要的使用場景在於處理IO密集型程式,解決效率問題,不適用於CPU密集型程式的處理。然而實際場景中這兩種場景非常多,如果要充分發揮CPU利用率,可以結合多程式+協程的方式。後續我們會講到結合點。根據wikipedia的定義,協程是一個無優先順序的子程式排程元件,允許子程式在特點的地方掛起恢復。所以理論上,只要記憶體足夠,一個執行緒中可以有任意多個協程,但同一時刻只能有一個協程在執行,多個協程分享該執行緒分配到的計算機資源。協程是為了充分發揮非同步呼叫的優勢,非同步操作則是為了避免IO操作阻塞執行緒。1)現代主流的作業系統幾乎都是分時作業系統,即一臺計算機採用時間片輪轉的方式為多個使用者服務,系統資源分配的基本單位是程式,CPU排程的基本單位是執行緒。
2)執行時記憶體空間分為變數區,棧區,堆區。記憶體地址分配上,堆區從低地到高,棧區從高往低。
3)計算機執行時一條條指令讀取執行,執行到當前指令時,下一條指令的地址在指令暫存器的IP中,ESP寄存值指向當前棧頂地址,EBP指向當前活動棧幀的基地址。
4)系統發生函式呼叫時操作為:先將入參從右往左依次壓棧,然後把返回地址壓棧,最後將當前EBP暫存器的值壓棧,修改ESP暫存器的值,在棧區分配當前函式區域性變數所需的空間。
5)協程的上下文包含屬於當前協程的棧區和暫存器裡面存放的值。
在python3.3中,通過關鍵字yield from使用協程,在3.5中,引入了關於協程的語法糖async和await,我們主要看async/await的原理解析。其中,事件迴圈是一個核心所在,編寫過 js的同學,會對事件迴圈Eventloop更加了解, 事件迴圈是一種等待程式分配事件或訊息的程式設計架構(維基百科)。在python中,asyncio.coroutine 修飾器用來標記作為協程的函式, 這裡的協程是和asyncio及其事件迴圈一起使用的,而在後續的發展中,async/await被使用的越來越廣泛。async/await是使用python協程的關鍵,從結構上來看,asyncio 實質上是一個非同步框架,async/await 是為非同步框架提供的 API已方便使用者呼叫,所以使用者要想使用async/await 編寫協程程式碼,目前必須機遇 asyncio 或其他非同步庫。在實際開發編寫非同步程式碼時,為了避免太多的回撥方法導致的回撥地獄,但又需要獲取非同步呼叫的返回結果結果,聰明的語言設計者設計了一個 叫Future的物件,封裝了與loop 的互動行為。其大致執行過程為:程式啟動後,通過add_done_callback 方法向 epoll 註冊回撥函式,當 result 屬性得到返回值後,主動執行之前註冊的回撥函式,向上傳遞給 coroutine。這個Future物件為asyncio.Future。但是,要想取得返回值,程式必須恢復恢復工作狀態,而由於Future 物件本身的生存週期比較短,每一次註冊回撥、產生事件、觸發回撥過程後工作可能已經完成,所以用 Future 向生成器 send result 並不合適。所以這裡又引入一個新的物件 Task,儲存在Future 物件中,對生成器協程進行狀態管理。Python 裡另一個 Future 物件是 concurrent.futures.Future,與 asyncio.Future 互不相容,容易產生混淆。區別點在於,concurrent.futures 是執行緒級的 Future 物件,當使用 concurrent.futures.Executor 進行多執行緒程式設計時,該物件用於在不同的 thread 之間傳遞結果。上文中提到,Task是維護生成器協程狀態處理執行邏輯的的任務物件,Task 中有一個_step 方法,負責生成器協程與 EventLoop 互動過程的狀態遷移,整個過程可以理解為:Task向協程 send 一個值,恢復其工作狀態。當協程執行到斷點後,得到新的Future物件,再處理 future 與 loop 的回撥註冊過程。在日常開發中,會有一個誤區,認為每個執行緒都可以有一個獨立的 loop。實際執行時,主執行緒才能通過 asyncio.get_event_loop() 建立一個新的 loop,而在其他執行緒時,使用 get_event_loop() 卻會拋錯。正確的做法為通過 asyncio.set_event_loop() ,將當前執行緒與 主執行緒的loop 顯式繫結。Loop有一個很大的缺陷,就是 loop 的執行狀態不受 Python 程式碼控制,所以在業務處理中,無法穩定的將協程擴充到多執行緒中執行。介紹完概念和原理,我來看看如何使用,這裡,舉一個實際場景的例子,來看看如何使用python的協程。外部接收一些檔案,每個檔案裡有一組資料,其中,這組資料需要通過http的方式,發向第三方平臺,並獲得結果。由於同一個檔案的每一組資料沒有前後的處理邏輯,在之前通過Requests庫傳送的網路請求,序列執行,下一組資料的傳送需要等待上一組資料的返回,顯得整個檔案的處理時間長,這種請求方式,完全可以由協程來實現。為了更方便的配合協程發請求,我們使用aiohttp庫來代替requests庫,關於aiohttp,這裡不做過多剖析,僅做下簡單介紹。aiohttp是asyncio和Python的非同步HTTP客戶端/伺服器,由於是非同步的,經常用在服務區端接收請求,和客戶端爬蟲應用,發起非同步請求,這裡我們主要用來發請求。aiohttp支援客戶端和HTTP伺服器,可以實現單執行緒併發IO操作,無需使用Callback Hell即可支援Server WebSockets和Client WebSockets,且具有中介軟體。直接上程式碼了,talk is cheap, show me the code~import aiohttp
import asyncio
from inspect import isfunction
import time
import logger
@logging_utils.exception(logger)
def request(pool, data_list):
loop = asyncio.get_event_loop()
loop.run_until_complete(exec(pool, data_list))
async def exec(pool, data_list):
tasks = []
sem = asyncio.Semaphore(pool)
for item in data_list:
tasks.append(
control_sem(sem,
item.get("method", "GET"),
item.get("url"),
item.get("data"),
item.get("headers"),
item.get("callback")))
await asyncio.wait(tasks)
async def control_sem(sem, method, url, data, headers, callback):
async with sem:
count = 0
flag = False
while not flag and count < 4:
flag = await fetch(method, url, data, headers, callback)
count = count + 1
print("flag:{},count:{}".format(flag, count))
if count == 4 and not flag:
raise Exception('EAS service not responding after 4 times of retry.')
async def fetch(method, url, data, headers, callback):
async with aiohttp.request(method, url=url, data=data, headers=headers) as resp:
try:
json = await resp.read()
print(json)
if resp.status != 200:
return False
if isfunction(callback):
callback(json)
return True
except Exception as e:
print(e)
這裡,我們封裝了對外傳送批量請求的request方法,接收一次性傳送的資料多少,和資料綜合,在外部使用時,只需要構建好網路請求物件的資料,設定好請求池大小即可,同時,設定了重試功能,進行了4次重試,防止在網路抖動的時候,單個資料的網路請求傳送失敗。在使用協程重構網路請求模組之後,當資料量在1000的時候,由之前的816s,提升到424s,快了一倍,且請求池大小加大的時候,效果更明顯,由於第三方平臺同時建立連線的資料限制,我們設定了40的閥值。可以看到,優化的程度很顯著。人生苦短,我用python。協程好不好,誰用誰知道。如果有類似的場景,可以考慮啟用,或者其他場景,歡迎留言討論。理解async/await:
https://segmentfault.com/a/1190000015488033?spm=ata.13261165.0.0.57d41b119Uyp8t
協程概念,原理(c++和node.js實現)
https://cnodejs.org/topic/58ddd7a303d476b42d34c911?spm=ata.13261165.0.0.57d41b119Uyp8t