當前位置:
首頁 > 知識 > 生動理解 Python 的非同步編程

生動理解 Python 的非同步編程

小編說


本文的譯者是Python部落網站課程的學員「河馬哥」(學員群里大家都這麼叫,本人並不是叫這個名字)。河馬哥的譯文語言流暢、文辭通俗,甚至連行文情感的波折起伏都表現得淋漓盡致,做到這些對於一篇技術文章的翻譯來說是十分不容易的。我認為這裡大家應該給他一些掌聲。也鼓勵翻譯社的其他譯者向河馬哥學習:你的譯文好壞,關係著近7萬Python學習者的學習興趣,希望大家努力做出有品質的譯文。

---------下面是譯文------------

非同步編程的 Python 實現,以及應用場景。

和非同步編程相對應的是同步編程,我們剛開始學編程的時候一般寫的代碼都是所謂同步的,就是依次執行每一條指令。

即使加入了條件、循環和函數調用等語法支持,但是從本質上來看,我們認為這種編程方式在同一時間還是只能執行一條指令,完成一個才執行下一個。

下面舉幾個同步編程的例子:

- 批處理程序 一般是採用同步的方式來寫:獲取輸入,處理數據,輸出結果。這些步驟就這麼一環扣一環地執行下去,別無他求。

- 命令行程序 一般都很小,主要是為了方便快捷地完成一些轉換。這種任務通常被分解為一系列小任務,然後按指定順序一步一步執行就好了。

非同步程序 和上面提到的這些就不太一樣了。儘管也是按照一定的步驟執行,區別在於,整個系統不需要等待某一個任務執行完畢就可以進行下一步。

就是說,我們的程序可以在一個(或者多個)任務還在執行過程中就繼續執行下面的步驟。這也意味著當那些(後台)任務一旦完成,我們還得接管回來繼續處理。

為什麼要這麼做呢?簡單來說,因為這種方式可以解決同步編程不好解決或者解決不了的問題。

下面給大家舉幾個有代表性的非同步編程的例子:

簡化的網路伺服器

網路伺服器的基本工作流程跟我們剛才提到的批處理程序也差不多,獲取輸入,處理數據,輸出結果。用同步的方式當然也能讓伺服器運行起來 —— 以一種很刺激又悲催的方式。

為什麼? 網路伺服器的工作任務可不光是完成單獨的一個小流程(輸入,處理,輸出),這還遠遠不夠,它必須能持久穩定的同時處理成百上千個同樣的工作流程。

同步網路伺服器還能改進嗎? 我們或許可以通化優化程序執行效率來加快處理速度。但事實上,在網路伺服器上有很多不以我們意志為轉移的限制條件,就註定了它沒法做出快速響應,也沒法同時應對大量的用戶請求。

到底是哪些限制條件呢? 網路帶寬,文件讀寫速度,資料庫查詢速度,以及其他相關服務的速度等等。這些限制條件有一個共同點——都是讀寫操作。這類操作的速度比我們的 CPU 速度通常會慢好幾個量級。

如果我們的網路伺服器是同步模式的,假如執行了一個數據查詢的步驟(打個比方),在查詢結果返回之前的很長一段時間裡 CPU 基本都處於閑置狀態,完全可以用來干點別的。

對於批處理程序來說,這一點並不關鍵,處理讀寫操作的結果才是關鍵,並且耗時也遠比讀寫操作要多得多。對於這種程序,優化的重點就不是讀寫操作部分,而是處理過程。

其實文件、網路和資料庫的讀寫也不算慢,只是和 CPU 的速度相比顯得很慢。非同步編程技術能夠讓我們充分利用相對較慢的讀寫操作的工作間隙,把 CPU 過剩的能量釋放出來去處理其他任務。

我自己剛接觸非同步的時候,不管是請教別人也好,還是查找資料也好,都遇到同一個問題,他們總是強調代碼的非阻塞性。我覺得這是個誤區。

非阻塞是什麼鬼?阻塞又是什麼鬼?我都不知道這項技術的應用環境和具體效果,直接講這些不著邊兒的術語和概念又有什麼意義呢?

真實的世界就是非同步的

非同步編程(和常規編程思路)有點區別,很容易懵。但是很有意思,因為我們生活的真實世界,以及我們跟這個世界相處的方式,本身就是非同步的。

你應該有過這樣的經歷: 作為一名家長,有時會同時忙活好幾件事兒,結算賬單,洗衣服,同時還要看孩子。

我們自己這麼做的時候甚至是下意識的,但是現在,我要把它分解開來:

- 結算賬單是我們需要完成的一項任務,可以視為一個同步的任務,一項一項結算,直到全部算完。

- 有時,我們算著算著賬就要停一下,去把烘乾機里的衣服取出來,再把洗衣機里洗好的衣服扔進去繼續烘,然後洗衣機還要再洗一缸。這些任務完成的過程就是非同步的。

- 洗衣機和烘乾機分別都是同步任務,但是我們只需要啟動機器,後面的大部分工作他們可以自主完成,這個時候我們就可以回去繼續算我們的賬了。這時就是非同步任務了,洗衣機和烘乾機可以獨立工作,等完成了就用蜂鳴器通知我們過去處理。

- 看孩子也是非同步的。一旦開始玩遊戲就基本不用管他們了。等到肚子餓了或者碰傷了他們就會哭著喊著找爸爸媽媽,這時我們再去處理。孩子對於我們來說在很長一段時間裡都處於高優先順序,比其它那些算賬、洗衣服什麼的優先順序都高。

這個例子闡述了阻塞和非阻塞兩種情況。我們去洗衣機的時候,CPU (家長)就會處於忙碌狀態,就沒法再同時忙別的事兒了(看孩子)。

這些活也用不了多少時間,也沒什麼問題。我們啟動洗衣機和烘乾機之後,就可以回去忙其它事情了,這時洗衣服這個任務就是非同步的,因為我們可以不用管它,去做其它事兒,等到它們完成了設定的任務就會用蜂鳴器通知我們。

作為一個正常人,我們天生就是這樣,自然而然的就會同時兼顧好多事情。對於一個程序員來說,怎麼才能把這種行為模式轉化為相應的代碼才是我們要說的重點。

下面我先介紹一種大家比較熟悉的代碼思路:

腦洞1:「批處理」家長模式

想像一下如果是同步模式,要怎麼來完成這些任務。作為一名稱職的家長,在這種情況下肯定首先要看好孩子,除非其它事情主動來找我,否則就一直看著孩子。顯然,結賬和洗衣服什麼的(肯定不會主動來找我們)就都不用幹了。

我們也可以調整任務的優先順序,但是在同步模式下,一次還是只能幹一件事兒,然後一個接一個干。這就跟我們上面提到的同步模式的網路伺服器差不多,不是說不能這麼干,就是沒什麼好下場,誰干誰知道。

除非孩子們睡下,不然啥也別想干,等他們都睡了,黃花菜都涼了。要是真敢這麼干,要不了幾個星期,家長都得瘋。

腦洞2:「輪詢」家長模式

我們換個思路,改為輪詢模式。家長定期中斷當前任務,查看一下有沒有其它活還要干。

開啟這種模式後,我們可以設置每隔 15 分鐘中斷一下。然後就每隔 15 分鐘去看看洗衣機、烘乾機或者孩子們哪些需要自己來處理,如果有,就去把那件事兒做掉,如果都相安無事,就回去接著結算賬單,等著下一次輪詢間隔。

這個方案也不是不行,任務肯定能完成,但是會導致一些問題。一方面,有些任務儘管在一段時間內肯定完不成,仍要佔用 CPU (家長)很多不必要的時間,比如洗衣機和烘乾機。另一方面,這些任務也有可能在輪詢間隔內完成,然而不能被及時發現和處理,除非等到下一個輪詢周期。一些高優先順序的任務,比如看孩子,恐怕會因為這麼長的周期而導致一些嚴重的事故。

我們當然可以通過縮短輪詢間隔來解決這個問題,不過 CPU 就會耗費更多的時間來切換任務,從而降低性能。還是那句老話,要是真敢這麼干,要不了幾個星期,家長又得瘋一回。

腦洞3:「線程」家長模式

家長都有個口頭禪,」恨不得一個人掰成兩半用「。我們可以用線程在代碼里掰出一大堆家長來。

如果我們把所有的任務視為一個整體,就可以把任務細化分配到多個線程里,然後給每個任務線程複製一個家長。這樣每個任務都有一個家長去做,看孩子、看烘乾機、看洗衣機、做結算,每個任務都獨立完成。聽起來很不錯哦。

事實真的如此美妙嗎?由於我們要明白的告訴每個家長(CPU)要做的工作,這裡面很容易出問題,因為所有的家長在完成任務的過程中會共享所有資源。

比如說,看著烘乾機的家長 A 發現衣服幹了,就要把衣服取出來。假如在家長 A 正在取衣服的過程中,另一個看著洗衣機的家長 B 剛好發現洗衣機也洗完了,就打算使用烘乾機,這樣才能把衣服從洗衣機里取出來放到烘乾機去烘。然後家長 A 取完衣服之後,也打算使用洗衣機,把衣服從洗衣機取出來放到烘乾機去烘。

這時,兩個家長就陷入了僵局(所謂死鎖)。

兩個人都控制著自己的資源,同時請求控制對方的資源,並且都妄想對方先釋放資源給自己(之後才能釋放自己的資源)。這就是使用線程時程序員們必須要解決的問題。

還有一個問題也是使用線程時可能遇到的。假設出現了意外,一個孩子受傷了,照看他的家長需要帶他去急診,他處理的很及時,畢竟這個家長是專門負責照看孩子的。但是到了醫院該家長要填一張大面額的支票來付醫療費。

但是,家裡那個負責結算賬目的家長並不知道這張支票的事,於是家裡的賬戶就透支了。因為所有的家長實例都運行在同一個程序當中,家裡的錢(賬本)是大家共享的,我們必須讓照看孩子的家長及時通知管賬的家長。或者提出一個鎖定機制,確保同一種資源同一個時間只能被一個家長使用和改動。

這些問題在線程模式下也不是不能解決,只不過比較複雜,最頭疼的是,出了錯也不易察覺。

Python 實現

下面我們就要開始將上面這些腦洞方案用 Python 來實現。

你可以在 Github 代碼庫 下載全部示例。

所有示例在 Python 3.6.1 下測試通過,示例代碼依賴列表 包含了全部依賴文件。

強烈建議開啟 Python 虛擬環境 運行示例,以免受系統 Python 環境干擾。

例1:同步編程

第1個例子我們設計了一個任務,它要從指定隊列里依次領受並完成一項工作。在這個例子里工作也很簡單,就是從隊列里順次讀取一個數字,然後開始數數,數到這個數就停。同時,每數一次就列印一句話,數完之後列印總共數了幾個數。這個程序提供了一個簡單的多任務處理隊列數據的範例。

生動理解 Python 的非同步編程

程序里的任務其實就是一個函數,它接收兩個參數,一個字元串,一個隊列。執行的時候先檢查隊列里有沒有要處理的工作(一個數字),如果有,就把它從隊列里取出來,然後開始數數,數到頭了就顯示出來。不斷重複這個過程,一直到把列表裡的工作全部做完,然後退出程序。

程序運行之後我們得到一個詳細的狀態列表,從中可以看到所有工作都是 Task One 完成的。等它忙完了,Task Two 才得以運行,但是到那時列表裡的工作也沒了,所以 Task Two 無事可做,只好列印一個狀態聲明就直接退出了。在這段代碼里沒有設置任何機制來幫助這兩個任務和諧地切換和共存。

例2:簡單的合作並發編程

這個例子是上例的升級版,加入了 Python 的生成器,這樣就可以讓兩個任務進行。在任務函數執行部分加入了 yield 關鍵字,這意味著循環執行到這一句就會退出,同時保存上下文環境,等到需要的時候再繼續運行。下面代碼中 # run the tasks 的地方就利用了這個特性,使用 t.next 方法來喚醒之前的任務,讓它在剛才 yield 的地方繼續。

這是合作並發的一種方式。程序交出當前環境的控制權,讓其它任務得以執行。在本例中,這麼做可以讓主程序中同時運行兩個任務,並且共享同一個隊列數據資源。雖然是個不錯的方案,但是想得到和上面例子一樣的運行結果,還存在很多要改進的地方。

生動理解 Python 的非同步編程

程序運行信息顯示兩個任務確實同時在執行,都從隊列中獲取了數據並處理。這算是我們想要的效果,兩個任務都工作正常,各在隊列里處理了兩個數據。但是我再強調一遍,要實現這個結果還有很多工作要做。

這個例子實現的技巧是使用了 yield 語句將任務函數轉換為生成器,以便切換的時候保存上下文。也正是利用這一點才實現了兩個不同的任務實例之間的切換。

例3:協作並發與阻塞調用編程

再次升級程序,在任務循環的主體部分加入 time.sleep(1) 這個函數,其它部分跟上例一樣。這個函數設置了每次循環中有 1 秒的延遲,以此來模擬日常任務中速度較慢的 IO 操作的效果。

除此之外我還使用了一個計時器工具,用來在列印運行日誌的時候顯示開始和消耗的時間。

生動理解 Python 的非同步編程

從程序運行狀態來看,跟之前一樣,它也能讓兩個任務同時進行,從隊列里獲取並處理數據。但是,由於模擬了讀寫操作造成的延遲效果,我們可以看到,使用協作並發的方式並沒有給我們帶來什麼好處,每一處延遲都拖慢了整個程序執行的進度,高速的 CPU 只能閑在一邊默默的看它龜爬。

例4:協作並發與非阻塞調用編程(協程)

下面我們給程序做一個大升級。首先在程序中引入一個叫做 協程非同步 的庫。接下來引用它提供的一個叫做 monkey 的模塊。

該模塊里有一個 patch_all 方法。這個東西到底是幹什麼用的呢?簡單來說,有了它就能把其它各種庫里採用阻塞模式(同步模式)運行的代碼統統修補(patch_all 就是統統修補的意思)為非同步模式。

說的太簡單了,有的人可能不太理解。對我們的示常式序來說,就是我們模擬的讀寫操作本來會導致整個程序暫停來等待它執行完畢的,現在有了這個 patch_all 就不用等了,它能讓系統繼續運行。請注意,上例中的 yield 語句現在已經不寫了。

再來看,如果 time.sleep(1) 這個函數在協程的作用下不再佔用系統控制權,那麼控制權現在交給誰了呢?我們使用協程的時候,它會自動開啟一個事件循環的線程。對我們來說就跟之前例3中使用多任務的循環差不多。等到延遲的部分執行完畢後,就接著執行延遲下面的語句。這麼做的好處就是 CPU 在延遲的過程中被釋放出來可以去做其它事情,不再需要陪著它乾等了。

我們之前寫的多任務循環也可以刪掉了,任務列表加入兩個 gevent.spawn 函數。它們負責啟動兩個協程線程(我們稱為 greenlets),這是一種輕量級的微線程,可以用協程的方式來切換上下文,不需要像普通線程那樣由系統來切換。

接下來,在所有任務都啟動之後我們調用了 gevent.joinall(tasks) 方法,這麼做是為了讓程序等待兩個任務全部完成。如果不寫這句,程序就會一直往下執行列印語句,然後就結束了。

生動理解 Python 的非同步編程

程序運行狀態表明,兩個任務同時啟動了,然後同時等待我們模擬的讀寫延遲。這證實了我們使用的 time.sleep(1) 函數沒有導致整個程序中斷,其它任務的運行沒有受到影響。

程序結尾的總耗時基本是上例總耗時的一半。這就是非同步帶來的優勢。

有了協程的 greenlets 方法和上下文切換控制,我們就可以採用非阻塞的方式並發運行兩個甚至多個讀寫操作,即使任務之間的轉換比較複雜也能得心應手。


英文原文:https://dbader.org/blog/understanding-asynchronous-programming-in-python#.
譯者:WDatou

例5:同步(阻塞)模式的 HTTP 下載

這次升級程序我們要舊瓶裝新酒,一方面加入真正的讀寫任務,按照指定網址列表發起 HTTP 請求並獲取頁面內容,另一方面停止非同步模式,退回之前的同步阻塞模式。

這裡要引用 requests 庫 來實現真正的 HTTP 請求,同時將之前的數字列表替換成網址列表。新的任務就不再是數數了,而是要把隊列里的網址內容載入進來,然後顯示耗時。

生動理解 Python 的非同步編程

這裡還是採用之前的 yield方法將任務函數變成生成器,保存上下文以便切換任務。

每個任務依次從隊列中讀取網址,然後訪問該網址,最後顯示耗時。

上面的例子中使用 yield 的時候是可以讓多任務一起執行的,但是這次不一樣,每個網路請求在獲得頁面返回內容之前 CPU 都是阻塞狀態。請留意最下面的總耗時,在下個例子里我們會再提到。

例6:協程非同步(非阻塞)模式的 HTTP 下載

這次我們把協程的方案也放進來。還記得我剛才說過,使用協程庫里的 monkey.patch_all 方法可以把任何同步代碼轉換為非同步,當然也包括這個任務里用到的 requests 庫。

現在 requests.get(url) 函數可以通過協程的事件循環來切換上下文,轉換成非阻塞模式了,也就不用寫 yield 了。在任務執行部分,我們使用協程來啟動兩個任務,最後用 joinall 方法等待它們執行完畢。

生動理解 Python 的非同步編程

仔細看看結尾的總耗時和每個網址分別耗時,顯爾易見,總耗時小於每個網址的單獨耗時之和。

因為每個請求都是非同步的,這就幫助我們有效的利用了 CPU 資源來同時處理多個請求。

例7:基於 Twisted 的非同步(非阻塞) HTTP 下載

下面這個例子我們改用 Twisted 框架 來實現剛才協程模塊的任務,用非阻塞模式下載網頁內容。

Twisted 非常強大,它採用完全不同的方法來創建非同步程序。協程用修改模塊的方法將同步轉換為非同步,Twisted 則提供了自己的一套函數和方法來實現同樣的效果。

例6 中是用修補 requests.get(url) 的方式獲取頁面,現在我們改用 Twisted 提供的 getPage(url) 方法。

在下面的代碼中,裝飾器 @defer.inlineCallbacks 和 yield getPage(url) 一起使用,是為了將上下文切換到 Twisted 的事件循環當中。

在協程中事件循環是隱式的,而在 Twisted 中是通過程序結尾的 reactor.run 語句來顯式調用的。

生動理解 Python 的非同步編程

這個運行結果和前面用協程方法得到的一樣,總耗時小於每個網址訪問單獨耗時的總和。

例8:基於 Twisted 回調方法的非同步(非阻塞)HTTP 下載

這個例子我仍然使用 Twisted 這個庫,只不過換了一個比較傳統的方式。

比如說,上面我們採用了 @defer.inlineCallbacks / yield的編碼形式,現在我們要改成顯式回調。所謂回調函數就是一個傳遞給系統的用來響應某個事件函數。下面例子中的 success_callback 函數就是傳遞給 Twisted 用來響應 getPage(url) 事件的回調函數。

注意,這個例子里的 my_task 任務函數已經不再需要 @defer.inlineCallbacks 裝飾器了。另外,這個函數還生成了一個延遲變數,我們簡稱它為 d ,它是 getPage(url) 函數的返回值。

延遲變數是 Twisted 處理非同步編程的方式,也是回調函數所必需的。一旦延遲被「觸發」(也就是getPage(url) 完成時),就立刻調用回調函數,並傳入指定參數。

生動理解 Python 的非同步編程

程序運行結果和上面兩個示例一樣,總耗時小於每個訪問分別耗時之和。

你想用協程還是Twisted的方式來實現都可以,因人而異。兩種方案都提供了強大的功能幫助開發者來創建非同步代碼。

結論

希望以上內容能幫助你了解掌握非同步編程的應用場景和工作方式。如果你要計算 Pi 的小數點後 100 萬位,那恐怕非同步起不了什麼作用。

然而,如果你要實現一個伺服器,或者其它一些需要大量讀寫操作的代碼,那你肯定能感受到什麼叫質的飛躍。這項技術非常強大,一定能讓你的程序性能上一個新台階。

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 Python部落 的精彩文章:

TAG:Python部落 |

您可能感興趣

創作靈感∣葡萄牙插畫師Antonio Soares 生動寫實作品
葡萄牙插畫師Antonio Soares 生動寫實作品
Peggy Macnamara 的野生動物水彩畫!
Alberta Ferretti時裝系列雪紡和皮革融為一體合力創造出的生動
Emanuel Ungaro荷葉元素生動蔓延在設計中誇張動感的領口
四方框框|Ond?ej Prosicky~「高傲」的野生動物肖像攝影圖集
繪畫-身臨其境般的生動,速寫大師ramon casas
麋鹿森林 Mark Bridger的野生動物攝影作品
以色列CA Technologies創新辦公空間設計,活潑生動,充滿活力
每日聽力口語素材 | What did you do on Saturday? 周末安排和野生動植物以及藝術
【繪畫】身臨其境般的生動,速寫大師(ramon casas)
Louis Vuitton:嬌小生動身姿,演繹優雅風情!
DeepMind加入動物保護行列,用AI分析野生動物行為
波札那的野生動物 | Zack Seckler
野生動物畫家Sauleseja動物油畫作品
畫風穩健、形象生動的義大利當代超寫實主義畫家Ventrone作品欣賞
Yes 首爾現場〉SF9ShowCase 青春揚溢時尚生動
德州野生動物保護區守護員Texas Game Warden
「繪畫」nanimalart寵物和野生動物自學成才的藝術家
專註於動物精神:來自野生動物藝術家 Eric Sweet 動物繪畫作品