Python協程詳解

uplemon發表於2019-09-23

簡介

通常我們認為執行緒是輕量級的程式,因此我們也把協程理解為輕量級的執行緒即微執行緒。

通常在Python中我們進行併發程式設計一般都是使用多執行緒或者多程式來實現的,對於計算型任務由於GIL的存在我們通常使用多程式來實現,而對於IO型任務我們可以通過執行緒排程來讓執行緒在執行IO任務時讓出GIL,從而實現表面上的併發。其實對於IO型任務我們還有一種選擇就是協程,協程是執行在單執行緒當中的"併發",協程相比多執行緒一大優勢就是省去了多執行緒之間的切換開銷,獲得了更大的執行效率。

協程,又稱微執行緒,纖程,英文名Coroutine。協程的作用是在執行函式A時可以隨時中斷去執行函式B,然後中斷函式B繼續執行函式A(可以自由切換)。但這一過程並不是函式呼叫,這一整個過程看似像多執行緒,然而協程只有一個執行緒執行。

那協程有什麼優勢呢?

  • 執行效率極高,因為子程式切換(函式)不是執行緒切換,由程式自身控制,沒有切換執行緒的開銷。所以與多執行緒相比,執行緒的數量越多,協程效能的優勢越明顯。
  • 不需要多執行緒的鎖機制,因為只有一個執行緒,也不存在同時寫變數衝突,在控制共享資源時也不需要加鎖,因此執行效率高很多。

協程可以處理IO密集型程式的效率問題,但是處理CPU密集型不是它的長處,如要充分發揮CPU利用率可以結合多程式+協程。

Python中的協程經歷了很長的一段發展歷程。其大概經歷瞭如下三個階段:

  • 最初的生成器變形yield/send
  • 引入@asyncio.coroutine和yield from
  • 引入async/await關鍵字

上述是協程概念和優勢的一些簡介,感覺會比較抽象,Python2.x對協程的支援比較有限,生成器yield實現了一部分但不完全,gevent模組倒是有比較好的實現;Python3.4加入了asyncio模組,在Python3.5中又提供了async/await語法層面的支援,Python3.6中asyncio模組更加完善和穩定。接下來我們圍繞這些內容詳細闡述一下。

Python2.x協程

python2.x實現協程的方式有:

  • yield + send
  • gevent (見後續章節)

yield + send(利用生成器實現協程)

我們通過“生產者-消費者”模型來看一下協程的應用,生產者生產訊息後,直接通過yield跳轉到消費者開始執行,待消費者執行完畢後,切換回生產者繼續生產。

#-*- coding:utf8 -*-
def consumer():
    r = ''
    while True:
    	n = yield r
    	if not n:
    	    return
    	print('[CONSUMER]Consuming %s...' % n)
    	r = '200 OK'

def producer(c):
    # 啟動生成器
    c.send(None)
    n = 0
    while n < 5:
    	n = n + 1
    	print('[PRODUCER]Producing %s...' % n)
    	r = c.send(n)
    	print('[PRODUCER]Consumer return: %s' % r)
    c.close()

if __name__ == '__main__':
    c = consumer()
    producer(c)
複製程式碼

send(msg)next()的區別在於send可以傳遞引數給yield表示式,這時傳遞的引數會作為yield表示式的值,而yield的引數是返回給呼叫者的值。換句話說,就是send可以強行修改上一個yield表示式的值。比如函式中有一個yield賦值a = yield 5,第一次迭代到這裡會返回5,a還沒有賦值。第二次迭代時,使用send(10),那麼就是強行修改yield 5表示式的值為10,本來是5的,結果a = 10send(msg)next()都有返回值,它們的返回值是當前迭代遇到yield時,yield後面表示式的值,其實就是當前迭代中yield後面的引數。第一次呼叫send時必須是send(None),否則會報錯,之所以為None是因為這時候還沒有一個yield表示式可以用來賦值。上述例子執行之後輸出結果如下:

[PRODUCER]Producing 1...
[CONSUMER]Consuming 1...
[PRODUCER]Consumer return: 200 OK
[PRODUCER]Producing 2...
[CONSUMER]Consuming 2...
[PRODUCER]Consumer return: 200 OK
[PRODUCER]Producing 3...
[CONSUMER]Consuming 3...
[PRODUCER]Consumer return: 200 OK
[PRODUCER]Producing 4...
[CONSUMER]Consuming 4...
[PRODUCER]Consumer return: 200 OK
[PRODUCER]Producing 5...
[CONSUMER]Consuming 5...
[PRODUCER]Consumer return: 200 OK
複製程式碼

Python3.x協程

除了Python2.x中協程的實現方式,Python3.x還提供瞭如下方式實現協程:

  • asyncio + yield from (python3.4+)
  • asyncio + async/await (python3.5+)

Python3.4以後引入了asyncio模組,可以很好的支援協程。

asyncio + yield from

asyncio是Python3.4版本引入的標準庫,直接內建了對非同步IO的支援。asyncio的非同步操作,需要在coroutine中通過yield from完成。看如下程式碼(需要在Python3.4以後版本使用):

#-*- coding:utf8 -*-
import asyncio

@asyncio.coroutine
def test(i):
    print('test_1', i)
    r = yield from asyncio.sleep(1)
    print('test_2', i)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    tasks = [test(i) for i in range(3)]
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()
複製程式碼

@asyncio.coroutine把一個generator標記為coroutine型別,然後就把這個coroutine扔到EventLoop中執行。test()會首先列印出test_1,然後yield from語法可以讓我們方便地呼叫另一個generator。由於asyncio.sleep()也是一個coroutine,所以執行緒不會等待asyncio.sleep(),而是直接中斷並執行下一個訊息迴圈。當asyncio.sleep()返回時,執行緒就可以從yield from拿到返回值(此處是None),然後接著執行下一行語句。把asyncio.sleep(1)看成是一個耗時1秒的IO操作,在此期間主執行緒並未等待,而是去執行EventLoop中其他可以執行的coroutine了,因此可以實現併發執行。

asyncio + async/await

為了簡化並更好地標識非同步IO,從Python3.5開始引入了新的語法async和await,可以讓coroutine的程式碼更簡潔易讀。請注意,async和await是coroutine的新語法,使用新語法只需要做兩步簡單的替換:

  • 把@asyncio.coroutine替換為async
  • 把yield from替換為await

看如下程式碼(在Python3.5以上版本使用):

#-*- coding:utf8 -*-
import asyncio

async def test(i):
    print('test_1', i)
    await asyncio.sleep(1)
    print('test_2', i)
    
if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    tasks = [test(i) for i in range(3)]
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()
複製程式碼

執行結果與之前一致。與前一節相比,這裡只是把yield from換成了await,@asyncio.coroutine換成了async,其餘不變。

Gevent

Gevent是一個基於Greenlet實現的網路庫,通過greenlet實現協程。基本思想是一個greenlet就認為是一個協程,當一個greenlet遇到IO操作的時候,比如訪問網路,就會自動切換到其他的greenlet,等到IO操作完成,再在適當的時候切換回來繼續執行。由於IO操作非常耗時,經常使程式處於等待狀態,有了gevent為我們自動切換協程,就保證總有greenlet在執行,而不是等待IO操作。

Greenlet是作為一個C擴充套件模組,它封裝了libevent事件迴圈的API,可以讓開發者在不改變程式設計習慣的同時,用同步的方式寫非同步IO的程式碼。

#-*- coding:utf8 -*-
import gevent

def test(n):
    for i in range(n):
        print(gevent.getcurrent(), i)

if __name__ == '__main__':
    g1 = gevent.spawn(test, 3)
    g2 = gevent.spawn(test, 3)
    g3 = gevent.spawn(test, 3)
    
    g1.join()
    g2.join()
    g3.join()
複製程式碼

執行結果:

<Greenlet at 0x10a6eea60: test(3)> 0
<Greenlet at 0x10a6eea60: test(3)> 1
<Greenlet at 0x10a6eea60: test(3)> 2
<Greenlet at 0x10a6eed58: test(3)> 0
<Greenlet at 0x10a6eed58: test(3)> 1
<Greenlet at 0x10a6eed58: test(3)> 2
<Greenlet at 0x10a6eedf0: test(3)> 0
<Greenlet at 0x10a6eedf0: test(3)> 1
<Greenlet at 0x10a6eedf0: test(3)> 2
複製程式碼

可以看到3個greenlet是依次執行而不是交替執行。要讓greenlet交替執行,可以通過gevent.sleep()交出控制權:

def test(n):
    for i in range(n):
        print(gevent.getcurrent(), i)
        gevent.sleep(1)
複製程式碼

執行結果:

<Greenlet at 0x10382da60: test(3)> 0
<Greenlet at 0x10382dd58: test(3)> 0
<Greenlet at 0x10382ddf0: test(3)> 0
<Greenlet at 0x10382da60: test(3)> 1
<Greenlet at 0x10382dd58: test(3)> 1
<Greenlet at 0x10382ddf0: test(3)> 1
<Greenlet at 0x10382da60: test(3)> 2
<Greenlet at 0x10382dd58: test(3)> 2
<Greenlet at 0x10382ddf0: test(3)> 2
複製程式碼

當然在實際的程式碼裡,我們不會用gevent.sleep()去切換協程,而是在執行到IO操作時gevent會自動完成,所以gevent需要將Python自帶的一些標準庫的執行方式由阻塞式呼叫變為協作式執行。這一過程在啟動時通過monkey patch完成:

#-*- coding:utf8 -*-
from gevent import monkey; monkey.patch_all()
from urllib import request
import gevent

def test(url):
    print('Get: %s' % url)
    response = request.urlopen(url)
    content = response.read().decode('utf8')
    print('%d bytes received from %s.' % (len(content), url))
    
if __name__ == '__main__':
    gevent.joinall([
    	gevent.spawn(test, 'http://httpbin.org/ip'),
    	gevent.spawn(test, 'http://httpbin.org/uuid'),
    	gevent.spawn(test, 'http://httpbin.org/user-agent')
    ])
複製程式碼

執行結果:

Get: http://httpbin.org/ip
Get: http://httpbin.org/uuid
Get: http://httpbin.org/user-agent
53 bytes received from http://httpbin.org/uuid.
40 bytes received from http://httpbin.org/user-agent.
31 bytes received from http://httpbin.org/ip.
複製程式碼

從結果看,3個網路操作是併發執行的,而且結束順序不同,但只有一個執行緒。

總結

至此Python中的協程就介紹完畢了,示例程式中都是以sleep代表非同步IO的,在實際專案中可以使用協程非同步的讀寫網路、讀寫檔案、渲染介面等,而在等待協程完成的同時,CPU還可以進行其他的計算,協程的作用正在於此。那麼協程和多執行緒的差異在哪裡呢?多執行緒的切換需要靠作業系統來完成,當執行緒越來越多時切換的成本會很高,而協程是在一個執行緒內切換的,切換過程由我們自己控制,因此開銷小很多,這就是協程和多執行緒的根本差異。

相關文章