Python 開源非同步併發框架的未來

fantix發表於2014-04-16

呵呵,這個標題有點大,其實只是想從零開始介紹一下非同步的基礎,以及 Python 開源非同步併發框架的發展和互操作性。

另外,這是我在 OSTC 2014 做的一個同題演講,幻燈片在這裡,歡迎拍磚。


開源

Python 是開源的,介紹的這幾個框架 TwistedTornadoGeventtulip 也都是開源的,最後這個演講是在開源大會弄的,所以標題裡肯定少不了開源。另外,我的 gevent3 專案也是開源的——貌似不少同學被我起的極品名字給搞混了,特別說明一下,gevent3 雖然有跟 Gevent 一樣的介面外貌,但底層卻是 tulip 驅動的(考慮把名字改回 gulip 之類的);請區別於將來會支援 Python 3 的 Gevent 1.1。

非阻塞

先上一段程式碼。請原諒我用 Python 程式碼充當虛擬碼了,但 Python 的語法實在是太簡單了,忍不住啊。

import socket
s = socket.socket()
s.connect((`www.google.com`, 80))
print("We are connected to %s:%d" % s.getpeername())

這是很簡單的一個客戶端 TCP 連線程式。假如網路狀況不是很好,執行這段程式時,我們很有可能要等個幾秒鐘,才能看到 We are connected 的輸出字樣。

對於這樣的程式碼,我們就可以說程式阻塞在了 connect() 的呼叫上;而這樣的函式我們叫做阻塞式的。

那麼非阻塞呢?還是看一段程式碼。

import socket
s = socket.socket()
s.setblocking(0)

try:
    s.connect((`www.google.com`, 80))
except socket.error as e:
    print(str(e))
    i = 0
    while True:
        try:
            print("We are connected to %s:%d" % s.getpeername())
            break
        except:
            print("Let`s do some math while waiting: %d" % i)
            i += 1
else:
    print("We are connected to %s:%d" % s.getpeername())

這一下程式碼就多了——但是並不複雜。

首先看一開始的變化,多了一句 s.setblocking(0)。這是說,將這個 socket 物件變成非阻塞式的。這樣一來,接下來的許多本應阻塞的呼叫將不會阻塞。

比如 connect()。非阻塞的 connect() 呼叫將會立即結束,而不管這個 TCP 連線是否真正建立了——如果 TCP 連線還沒有完成握手,那麼 connect() 會丟擲一個異常說“開始連了,彆著急一會兒就好”;否則(應該沒有否則)就會“正常”地走 try...else 的路線。

抓到這個異常之後呢,我們就可以充分利用這段原本要阻塞的時間,在連線完全建立之前做一些有意義的事情——比如數數。我這裡網路條件還湊合,一般情況下數到一萬多的時候就能跟 Google 連上了。

非同步

可以看得出來,阻塞和非阻塞是說函式呼叫的,呼叫了之後要等到底層完事兒了之後才能繼續的叫做阻塞;呼叫了之後,要麼立即返回,要麼立即拋異常,這就是非阻塞。

而與之如影隨行的一對兒概念——同步和非同步——則說的是一段程式的執行處理方式。一般情況下,阻塞式的呼叫都可以叫做同步,但非阻塞式的呼叫不一定是非同步的。怎麼講呢,我們還是來看幾個例子。

while server.running:
    request = server.receive()
    response = handle(request)
    server.send(response)

這片程式碼片段示意的是同步的處理方式。可以看得出來,接收請求、處理請求、傳送響應依次執行,前一個任務完成了才會做下一個;最外面還有一個 while 迴圈,使之不斷地收請求發響應,且是傳送完上一個響應之後才會接收下一個請求。請注意,我們並沒有看到 receive() 等函式的實現細節,他們在底層可以是阻塞的,也可以是非阻塞的,這都不會影響我們看到的這片程式碼片段是同步的。

那麼非同步的程式碼看上去是什麼樣的呢?請允許我用 Twisted 風格的程式碼來展示,因為非同步的程式碼太“扭曲”了:

while server.running:
    deferred = server.receive()
    deferred.addCallback(on_request)

def on_request(request):
    deferred = handle(request)
    deferred.addCallback(on_response)

def on_response(response):
    server.send(response)

讓我來大概地解釋一下。為了實現非同步,這裡的 receive()handle() 都必須是非阻塞的。在 Twisted 中非阻塞的函式會立即返回一個 Deferred 物件,通過給 Deferred 物件新增回撥函式,我們可以實現在這件事情真正完成之後,執行回撥函式中定義的接下來要做的事兒。

看到扭曲的程度了吧。先接收一個請求——等等,你不一定立即就能接收到。好吧,等到接收到了的時候(on_request),我們把這個請求送去處理,然後——等等,處理不一定馬上能完成。那好吧,等到處理完成之後(on_response),我們再把這個響應傳送回去。說實話,我沒忍心寫,其實傳送也不會立即完成……

雖然上面這段程式碼示例有些過份,仍有一些可以變得更簡潔的地方,但是這對於大型專案中非同步程式碼的描述並不失真。難道用所謂的非同步框架寫程式碼都會是這麼扭曲麼?

前面我們說的非同步只是非同步編碼——從編寫程式碼的方式上來判斷。而通常說的非同步框架,往往還會展現給使用者一些同步的介面(後面還會提到),在框架內部,這些介面也都是用非阻塞的非同步程式碼來實現的。對於這樣的框架,我們仍然叫他們非同步框架——總不能叫非阻塞框架,或是同步框架吧。

另外,非同步編碼也不一定就非要扭曲人性,還是有很多專案可以簡潔明瞭地編寫非同步程式碼的,只不過對於程式設計師的要求會比編寫同步程式碼稍高一些罷了。

併發與並行

好了,讓我們先把糾結的非同步放下,來看看另外兩個容易混淆的概念。

估計您已經從視訊裡聽了我辦港澳通行證的慘痛經歷了,這裡就不重複了,但仍然用這個例子來解釋一下併發和並行的概念吧。

並行的概念著重於處理端,也就是辦理通行證的工作人員。有 5 個視窗開放,就意味著同一時間可以有 5 個業務可以得到並行的處理。對於計算機來說,並行勢必要有多顆處理器,真正從物理上可以並行地處理多個任務;單 CPU 用多執行緒實現的叫做分時多工——也許超執行緒除外。

相對於並行著重於處理端,併發的概念則是關於請求端,也就是關於使用者的。當我們談及朝陽區出入境辦證大廳的併發量的時候,我們是在說該大廳在某一時刻能容納的前來辦證的人數,最大併發量說白了就是大廳裡能站下多少人——包括正在辦的和排隊的。

包括排隊的?那往大廳外面使勁兒排唄,這併發量豈不是無限大了?

與併發一起的還有很重要的一個概念,就是處理時間。如果一味追求併發量,勢必會導致處理時間的大幅上升,大量請求多半時間在排隊,這樣並不能算是一個高效的系統設計。所以在系統資源到達瓶頸的時候,也許限制併發量,拒絕一些請求也許是一個明智的選擇。

併發並不是不關心處理端,只不過多核並行或者單核分時多工都能實現併發,而且在實踐中這兩種實現方法往往會同時使用。多核並行實現的併發,其任務排程主要由作業系統完成,我們接下來著重關心一下單執行緒併發的任務排程問題。

事件驅動的單執行緒併發

只有一個執行緒,用阻塞呼叫是肯定無法實現併發的——除非把每次僅服務一個客戶叫做“併發量為 1 的併發”。所以,我們必然會用到非阻塞呼叫。

請回憶一下前面我們演示非阻塞呼叫的那個例子,我們在等待連線建立的過程中,做了一些其他的有意義的事情,一旦連線建立成功,我們會接著之前做一些關於連線的事情——輸出對方的地址。現在我們試著擴充套件這個例子,實現併發連線——我們同時啟動 100 個 TCP 連線,任何一個連線成功了就立即輸出對方地址。一開始我們可以這麼寫:

import socket
sockets = {}
for i in range(100):
    s = socket.socket()
    sockets[s.fileno()] = s
    s.setblocking(0)
    try:
        s.connect((`www.google.com`, 80))
    except:
        pass

我們將這 100 個 socket 物件按照他們的檔案描述符儲存在了一個叫做 sockets 的字典裡,並且一一呼叫了非阻塞的 connect() 函式。

可是,接下來怎麼寫呢?難道要重複呼叫每一個 socket 物件的 getpeername() 函式,直到他們都正確返回了為止?CPU 消耗太大了吧。

作業系統給我們提供了一些介面,專門用於這類問題的:select 及其升級版 epoll(Linux) 和 kqueue(*BSD 和 Mac OS X),他們通常也被統稱為 select 函式。select 是一種阻塞呼叫,專門用於從一些檔案描述符中,選出那些有新事件到達的描述符,其中事件包括可讀、可寫和出錯。換句話講呢,就是監視給出的 socket,任何一個有動靜了就立即返回有動靜的描述符。

比如前面這個例子裡,我們希望在任何一個連線成功建立的時候,輸出該連線的目的地址。於是接下來就可以這麼寫:

import select
while sockets:
    fds = select.select([], list(sockets.keys()), [])[1]
    for fd in fds:
        s = sockets.pop(fd)
        print("%d connected to %s:%d" % ((fd,) + s.getpeername()))

也就是說,每次迴圈,我們都會從剩餘的連線中,選出一些可寫的 socket 物件——那意味著連線已經成功建立了,然後將他們的目標地址輸出出來。

這就是一個很簡單的事件驅動的非同步併發了,雖然我們只是建立了 100 個 TCP 連線,但我們併發了,是事件驅動的了,而且我們非同步地呼叫了後續的操作——輸出目的地址。

非同步併發不過如此,而已。

框架

只用 socketselect 來寫一個非同步 web 伺服器也行,只不過會出一兩條人命而已。雖然是開玩笑,但是我們多數情況下還是會選擇使用一些現有的框架。

何謂框架呢,其實就是把上一小節的例子程式碼給拆開,一部分是僅包含 www.google.comprint() 的所謂使用者程式碼,另一部分就是所有剩下的叫做框架的東西。比如這樣:

import socket

sockets = {}
for i in range(   ):
    s = 
    sockets[s.fileno()] = s
    s.setblocking(0)
    try:
        s.       (              )
    except:
        pass

import select
while sockets:
    fds = select.select([], list(sockets.keys()), [])[ ]
    for fd in fds:
        s = sockets.pop(fd)
             (            s.          )

當然這段程式碼並不是一個框架,因為它根本無法執行。但是我們可以通過它看到一個非同步框架應該有的東西:

  1. 用於建立與框架契合的、非阻塞的 I/O 物件的介面
  2. 有一個主迴圈,使用者可以啟動它
  3. 使用者可以在關心的事件發生時,執行自己的程式碼

回撥函式和 Tornado

讓我們以 Tornado 為例,來看一下最基本的非同步框架是怎麼用的——雖然 Tornado 並不僅限於此。

sock = socket.socket()
sock.setblocking(0)
sock.bind((“”, 80))
sock.listen(128)

def on_conn(fd, events):
    conn, address = sock.accept()
    conn.send(b’Hello’)

io_loop = ioloop.IOLoop.instance()
io_loop.add_handler(sock.fileno(), on_conn, io_loop.READ)
io_loop.start()

這是一個簡單的伺服器程式,它會向每一個連進來的客戶端傳送一句問候。其中 add_handler() 的呼叫就是——我認為—— Tornado 的經典用法,也就是註冊回撥函式。當有連線進來的時候,Tornado 就會根據要求來呼叫 on_conn(),後者隨即會與客戶端連線並送上問候。

Twisted 和封裝……和回撥函式

Twisted 裡是各種封裝,通過 Transport 將 socket 物件封裝的更隱蔽,通過 Protocol 來實現使用者協議的封裝,像這樣:

from twisted.internet import protocol, reactor

class Echo(protocol.Protocol):
    def dataReceived(self, data):
        self.transport.write(data)

class EchoFactory(protocol.Factory):
    def buildProtocol(self, addr):
        return Echo()

reactor.listenTCP(1234, EchoFactory())
reactor.run()

對於回撥函式,Twisted 則發明了著名的 Deferred 用以實現事件源與回撥函式的分離,其實本質上沒有區別,只是在寫法上略有不同,這裡就不多說了。

同步地非同步

正如前面提到的,非同步的編碼方式——無論是 Tornado 的回撥函式,還是 TwistedDeferred——想要用的出彩,需要程式設計師有相對較高的心理素質和職業修養。那如果能正常地、用同步的方式來編寫非同步執行的程式碼呢?

藉助 Python 的 generator 功能TwistedTornado 紛紛提供了這樣的功能。比如下面這一段 Twisted 的程式碼(請關注開頭的修飾器和程式碼中的 yield):

@defer.inlineCallbacks
def main(endpoint, username="alice", password=“secret”):
    endpoint = endpoints.clientFromString(reactor, strport)
    factory = protocol.Factory()
    factory.protocol = imap4.IMAP4Client
    try:
        client = yield endpoint.connect(factory)
        yield client.login(username, password)
        yield client.select(`INBOX`)
        info = yield client.fetchEnvelope(imap4.MessageSet(1))
        print `First message subject:`, info[1][`ENVELOPE`][1]
    except:
        print "IMAP4 client interaction failed"
        failure.Failure().printTraceback()
task.react(main, sys.argv[1:])

這裡的第一個 yield 中,endpoint.connect() 返回的是一個 Deferred 物件,其回撥函式的引數才是前面的 client 物件。通過 yieldinlineCallbacks 修飾器的配合,我們就把回撥函式和 main 函式揉在了一起,後面那三個 yield 也是如此,這樣的程式碼看上去是同步的,執行的底層實則是非同步的。Tornado 也有類似的用法,這裡就不多說了。

神奇的 yield!在這裡到底發生了什麼事情呢?我管它叫做非同步切換,具體的程式碼可以看 inlineCallbacks 的實現。簡單來說呢,yield 之前,connect() 在主迴圈裡註冊了一個關於連線創立的事件監聽,然後通過 yield 把事件的處理權交給了 inlineCallbacks,同時將當前函式的執行狀態掛起(yield 的功能,可以把棧儲存下來),切換到 inlineCallbaks 裡繼續執行,而 inlineCallbacks 則會返回至主迴圈,繼續執行別的非同步任務,直至前述事件發生且主迴圈排到了該事件,主迴圈會呼叫 inlineCallbacks 裡的回撥函式,後者會將之前掛起的執行狀態恢復,這樣 client 就被賦上了正確的值。

總的來看,在 yield 的時候,當前執行流程會被暫停以等待事件,別的執行流程會插進來執行,直至事件發生後,當前執行流程才有可能恢復執行。這非常類似於作業系統裡面的任務排程,所以我管它叫做非同步切換,只不過這種切換是主動進行的,而不是作業系統強制的。所以,如果你不 yield 交出執行權,別的執行流程永遠沒有辦法被執行到,這也是單執行緒非同步併發的一個需要注意的點。另外,單執行緒非同步併發需要有足夠的非同步切換才能做到近似公平的排程,所以非常適合 I/O 密集型的運算,而 CPU 密集型的運算在這裡往往會遇到比較嚴重的問題。

隱式的非同步切換

在寫單執行緒非同步程式碼的時候,切記不要混合呼叫底層會阻塞的程式碼,因為那樣會阻塞整個執行緒,導致所有併發的處理時間增加,最終會導致嚴重的效能問題。如果有一些阻塞的、同步的遺留程式碼,那該如何是好呢?答案是:把它們統一改成非阻塞的,或者使用多執行緒/多程式來處理。可是,如果要改成非阻塞的形式,那得加多少 yield 呀!

沒關係,還有隱式的非同步切換呢。通常我們把這種需要顯式地寫 yield 的程式碼叫做顯式的非同步切換,與之相對的就是隱式的非同步切換。比如下面這段程式碼,我說它有隱式的非同步切換,您信嗎?

import socket
s = socket.socket()
s.connect((`www.google.com`, 80))
print("We are connected to %s:%d" % s.getpeername())

這不就是文章一開頭的那個例子嘛。別急,如果在最前面加這麼兩句,情況就完全不一樣了:

from gevent import monkey
monkey.patch_all()

Gevent 就是隱式的非同步切換的代表。通過所謂的 monkey patch,Gevent 把系統庫裡的 socket 等模組,替換成了 Gevent 自己提供的相應的非阻塞模組。這樣,上面的程式碼就變成(底層)非同步的了。考慮到 monkey patch 的侵入性,您也可以考慮直接使用 Gevent 提供的模組,比如這樣:

from gevent import socket

Gevent 這樣的隱式的非同步切換有個好處很明顯,就是可以很容易地將阻塞式的遺留程式碼遷移到 Gevent 上來,而不需要額外修改大量程式碼,這對於需要非同步併發支援的許多大型現有專案來說,無疑是為數不多的幾個選擇之一——比如說 Django

但是,有不少人也認為,隱式的非同步切換的代價太大——倒不是說它的效能有多差,而是這種寫法把非同步切換隱藏的太深了,不知道什麼時候就切換到別的地方去執行了。這樣帶來的直接問題就是——跟常規共享狀態的多執行緒程式設計一樣——我們很難保證在一段程式的執行過程中,某些本地狀態不會被別的程式碼修改,再加上狀態同步的代價,隱式的非同步切換並不被特別看好。如果非得要用,記得儘量少共享狀態,多用佇列來實現資訊傳遞,然後小心編碼,仔細檢查。

綠色的 Gevent

Gevent 之所以能實現隱式的非同步切換,主要歸功於 GreenletGreenletStackless Python 的一個分專案,用於在標準 CPython 中實現微執行緒(也稱協程、綠色執行緒)。

Python 中的 Greenlet 跟常規執行緒類似,也是會在獨立的空間中執行一段程式碼,也有自己獨立的棧空間。不同的是:

  1. Greenlet 並不啟動任何作業系統的執行緒,是綠色產品
  2. Greenlet 任務之間的排程需要每個微執行緒裡的程式碼自己顯式地實現

用官方的一個例子演示一下這兩個特點吧:

from greenlet import greenlet

def test1():
    print 12
    gr2.switch()
    print 34

def test2():
    print 56
    gr1.switch()
    print 78

gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()

這個例子裡一共有三個微執行緒,分別是 main(也就是最外層預設的主微執行緒,自動建立的)、gr1gr2。程式一直順序執行,直至最後一句 gr1.switch(),由 main 微執行緒切換至 gr1gr1 輸出 12 之後,又切換至 gr2;接著 gr2 輸出 56 後,又切換回 gr1 之前的切出點,繼續輸出 34;這是 gr1 結束了,系統會自動切換回 gr1 的父微執行緒——也就是 main 的最後一句 switch() 返回,至此整個程式結束。注意,78 並沒有機會被輸出。

Gevent 的主迴圈叫做 Hub,跑在一個單獨的 greenlet 裡。使用者的程式從 main greenlet 開始執行,直至第一個非同步切換。此時,Gevent 會把當前微執行緒——也就是 main ——與非同步事件做一個關聯,然後切換到 HubHub 於是開始運轉,當某些事件發生時,Gevent 就會切換到相應關聯的 greenlet 來執行,直至他們結束返回 Hub,或者主動切換回 Hub。比如 main 等待的事件發生了,Hub 就會切到 main 上執行——當然,如果這時 main 結束了,就不會像其他 greenlet 一樣再返回 Hub 了。

所以,greenlet 和 generator、Deferred 一樣,其實都是用來實現回撥封裝的一些工具,所以前面提到過的一些非同步併發的注意事項,Gevent 也都適用。

互操作性存在的問題

多種框架的存在,說好聽了是百花齊放各顯神通、競爭才有發展,說難聽了就是碎片化、選擇恐懼症和維護代價巨大。比如說,同樣是一個 Python 的 PostgreSQL 連線適配程式,有支援 Twistedtxpostgres,有支援 Tornadomomoko,還有 Gevent 需要的 psycogreen——有啥話我們不能一氣兒說完呢?如果上游的 psycopg 更新了,這麼多的介面卡,是不是得要跟著更新哪。

再一個問題就是遺留程式碼。如果一個專案一直在用 Twisted,有一天老闆拿著張光碟說給我把這個弄上去,開啟一看全都是 .pyc 檔案,木有原始碼——直接呼叫會有之前提到的阻塞主執行緒的問題,扔到執行緒池裡做又不甘心。如果能在 Twisted 裡用 Gevent 就好了(現在確實可以,不過會替換 Twisted 的一部分)。

未來

asyncio 這個專案其實叫做 tulip,主要開發也都在那裡,因為要進 Python 標準庫了,所以才幾經周折選了 asyncio 這麼一個名字。asyncio 是 Python 作者的一個新專案,要求至少是 Python 3.3(手動安裝),Python 3.4 裡它就已經是標準庫的一部分了。

之所以要求 Python 3.3,是因為 asyncio 的微執行緒依賴於 Python 3.3 的新語法:yield from。區別於 yieldyield from co 實現了類似於這樣的功能:

for x in co:
    yield x

這裡說“類似”,是因為實際情況要比這複雜很多,但意思是一樣的:將內層迭代器的元素無縫地合併到外層的迭代器裡。有了這個,asyncio 就可以很容易地做微執行緒的巢狀了——也就是在一個微執行緒裡面等待另一個結束返回結果。

asyncio 作為又一個非同步併發框架,與其他現有框架差別並不大:主迴圈類似於 Twisted 的 reactor,Future 對回撥函式進行封裝類似於 Deferred,可選的微執行緒類似於 inlineCallbacks,基於 yield from 的顯式的非同步切換類似於 yield,這裡就不多介紹了,總的來看非常像 Twisted。但是呢,它能進入標準庫,還是有原因的。

互操作性

asyncio 作為參考實現,與其規格文件 PEP 3156 是一起做出來的,蟒爹在做的過程中尤其關注了互操作性。

比如 asyncio 的主迴圈就是可以任意替換的,任何滿足 asyncio 主迴圈介面要求的核心都可以被安裝上去。為了做到這一點,PEP 3156 定義了嚴格的主迴圈介面,將 asyncio 的框架程式碼部分與主迴圈核心完全分離。這樣一來,許多現有框架加個殼就可以支援 asyncio 了——不用改現有程式碼,寫一個現有主迴圈介面到 asyncio 主迴圈介面的適配層,替換掉 asyncio 自帶的主迴圈,這樣 asyncio 的程式碼就可以跑在現有框架上面了。

另一個方向也是行得通的。PEP 3156 同樣定義了豐富而清晰的使用者介面,我們可以使用這些介面來實現一個現有框架的主迴圈替代品,這樣就可以在不替換 asyncio 已有主迴圈的前提下,將別的框架的程式碼嫁接到 asyncio 上來。比如說我的 gevent3 就是這麼一個例子,我將 Gevent 中原有的 libev 程式碼刪掉,用 asyncio 實現了一份 Gevent Hub,這樣,gevent 的程式碼就可以跑在 asyncio 框架上了。更令人興奮的是,如果 asyncio 使用的主迴圈核心又恰好是比如說 Twisted,那麼原先分別依賴 GeventTwisted 的程式碼,現在就可以跑在一起了,甚至互相呼叫也是可以的。

比如下面一段示例程式碼就演示了三個框架的融合:

import asyncio
import gevent  ## gevent3
import redis
from gevent import socket
from redis import connection
from twisted.web import server, resource
from twisted.internet import reactor


asyncio.set_event_loop(some_twisted_wrapper)


class GreenInetConnection(connection.Connection):
    def _connect(self):
        #noinspection PyUnresolvedReferences
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(self.socket_timeout)
        sock.connect((self.host, self.port))
        return sock


class HelloResource(resource.Resource):
    isLeaf = True

    def render_GET(self, request):
        gevent.spawn(self.green_GET, request)
        return server.NOT_DONE_YET

    def green_GET(self, request):
        r = redis.StrictRedis(
            connection_pool=connection.ConnectionPool(
                connection_class=GreenInetConnection))
        numberRequests = r.incr("numberRequests")
        request.setHeader("content-type", "text/plain")
        request.write("I am request #" + str(numberRequests) + "
")
        request.finish()


reactor.listenTCP(8080, server.Site(HelloResource()))
asyncio.run_forever()

程式碼演示了一個簡單的 Twisted web 伺服器,使用 Gevent 來處理邏輯,asyncio 則起到了牽線搭橋的作用。

雖然目前這段程式碼還不能執行,但是我相信在不久的將來,這種程度的互操作性終將實現。

更新:gevent3 專案已改名為 tulipcore(連結仍然有效),第一個 alpha 版本已經發布至 pypi.python.org。

相關文章