如何讓 python 處理速度翻倍?內含程式碼

阿里技術發表於2019-12-25

如何讓 python 處理速度翻倍?內含程式碼

阿里妹導讀:作為在日常開發生產中非常實用的語言,有必要掌握一些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

async/await是使用python協程的關鍵,從結構上來看,asyncio 實質上是一個非同步框架,async/await 是為非同步框架提供的 API已方便使用者呼叫,所以使用者要想使用async/await 編寫協程程式碼,目前必須機遇 asyncio 或其他非同步庫。
 
Future

在實際開發編寫非同步程式碼時,為了避免太多的回撥方法導致的回撥地獄,但又需要獲取非同步呼叫的返回結果結果,聰明的語言設計者設計了一個 叫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是維護生成器協程狀態處理執行邏輯的的任務物件,Task 中有一個_step 方法,負責生成器協程與 EventLoop 互動過程的狀態遷移,整個過程可以理解為:Task向協程 send 一個值,恢復其工作狀態。當協程執行到斷點後,得到新的Future物件,再處理 future 與 loop 的回撥註冊過程。
 
Loop

在日常開發中,會有一個誤區,認為每個執行緒都可以有一個獨立的 loop。實際執行時,主執行緒才能通過 asyncio.get_event_loop() 建立一個新的 loop,而在其他執行緒時,使用 get_event_loop() 卻會拋錯。正確的做法為通過 asyncio.set_event_loop() ,將當前執行緒與 主執行緒的loop 顯式繫結。

Loop有一個很大的缺陷,就是 loop 的執行狀態不受 Python 程式碼控制,所以在業務處理中,無法穩定的將協程擴充到多執行緒中執行。
 
總結

如何讓 python 處理速度翻倍?內含程式碼

                           
 
實戰篇

介紹完概念和原理,我來看看如何使用,這裡,舉一個實際場景的例子,來看看如何使用python的協程。

場景

外部接收一些檔案,每個檔案裡有一組資料,其中,這組資料需要通過http的方式,發向第三方平臺,並獲得結果。
 
分析

由於同一個檔案的每一組資料沒有前後的處理邏輯,在之前通過Requests庫傳送的網路請求,序列執行,下一組資料的傳送需要等待上一組資料的返回,顯得整個檔案的處理時間長,這種請求方式,完全可以由協程來實現。

為了更方便的配合協程發請求,我們使用aiohttp庫來代替requests庫,關於aiohttp,這裡不做過多剖析,僅做下簡單介紹。

aiohttp

aiohttp是asyncio和Python的非同步HTTP客戶端/伺服器,由於是非同步的,經常用在服務區端接收請求,和客戶端爬蟲應用,發起非同步請求,這裡我們主要用來發請求。

aiohttp支援客戶端和HTTP伺服器,可以實現單執行緒併發IO操作,無需使用Callback Hell即可支援Server WebSockets和Client WebSockets,且具有中介軟體。
 
程式碼實現

直接上程式碼了,talk is cheap, show me the code~
import aiohttpimport asynciofrom inspect import isfunctionimport timeimport 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

相關文章