Python學習系列之協程

一、重溫進程&線程

對操作系統來說,線程是最小的執行單元進程是最小的資源管理單元

  • 進程是系統分配資源的最小單位
  • 線程是CPU調度的最小單位
  • 由於默認進程內只有一個線程,所以多核CPU處理多進程就像是一個進程一個核心

進程是系統資源分配的最小單位, 系統由一個個進程(程序)組成 一般情況下,包括文本區域(text region)、數據區域(data region)和堆棧(stack region)

  • 文本區域存儲處理器執行的代碼;
  • 數據區域存儲變量和進程執行期間使用的動態分配的內存;
  • 堆棧區域存儲着活動過程調用的指令和本地變量。

線程共享進程的代碼文件句柄等資源變量等數據內存地址空間。 

  • 線程屬於進程
  • 線程共享進程的內存地址空間
  • 線程幾乎不佔有系統資源
  • 通信問題:   進程相當於一個容器,而線程而是運行在容器裏面的,因此對於容器內的東西,線程是共同享有的,因此線程間的通信可以直接通過全局變量進行通信,但是由此帶來的例如多個線程讀寫同一個地址變量的時候則將帶來不可預期的後果,因此這時候引入了各種鎖的作用,例如互斥鎖等。

 

線程具有五種狀態:

  • 初始化
  • 可運行
  • 運行中
  • 阻塞
  • 銷燬

這五種狀態的轉化關係如下:

 爲什麼需要協程?

線程之間是如何進行協作的呢?最經典的例子就是生產者/消費者模式

若干個生產者線程向隊列中寫入數據,若干個消費者線程從隊列中消費數據。

  • 協程是屬於線程的。協程程序是在線程裏面跑的,因此協程又稱微線程和纖程等
  • 協程沒有線程的上下文切換消耗。協程的調度切換是用戶(程序員)手動切換的,因此更加靈活,因此又叫用戶空間線程.
  • 原子操作性。由於協程是用戶調度的,所以不會出現執行一半的代碼片段被強制中斷了,因此無需原子操作鎖。

二、協程

之前的文章中介紹過多線程,多進程。下面開始介紹協程

2.1 定義

協程,英文Coroutines,是一種比線程更加輕量級的存在。正如一個進程可以擁有多個線程一樣,一個線程也可以擁有多個協程。最重要的是,協程不是被操作系統內核所管理,而完全是由程序所控制(也就是在用戶態執行)。這樣帶來的好處就是性能得到了很大的提升,不會像線程切換那樣消耗資源

協程並不是並行,而是類似於併發。即使在多核CPU上。

Python中的異步IO模塊asyncio就是基本的協程模塊。主要是解決在採用多線程的情況下,運行實現IO密集型任務。

2.2 Python中的實現

協程就是一種用戶態內的上下文切換技術,又稱微線程,纖程一種用戶態的輕量級線程。所謂協程,就是同時開啓多個任務,但一次只順序執行一個。等到所執行的任務遭遇阻塞,就切換到下一個任務繼續執行,以期節省下阻塞所佔用的時間。對協程來說,無需保留鎖,在多個線程之間同步操作,協程自身就會同步,因爲在任意時刻只有一個協程運行。

其實在其他語言中,協程的其實是意義不大的,多線程即可已解決I/O的問題,但是在python因爲他有GIL(Global Interpreter Lock 全局解釋器鎖 )在同一時間只有一個線程在工作,所以:如果一個線程裏面I/O操作特別多,協程就比較適用

進程和線程是搶佔式的調度,而協程是協同式的調度,也就是說,協程需要自己做調度。協程看上去也是子程序,但執行過程中,在子程序內部可中斷(不是函數調用,有點類似CPU的中斷,實際上是程序員控制的中斷,然後轉而執行別的子程序在適當的時候再返回來接着執行

       協程可以處於下面四個狀態中的一個。當前狀態可以導入inspect模塊,使用inspect.getgeneratorstate(...) 方法查看,該方法會返回下述字符串中的一個。

  • 'GEN_CREATED'  等待開始執行。

  • 'GEN_RUNNING'  協程正在執行。

  • 'GEN_SUSPENDED' 在yield表達式處暫停。

  • 'GEN_CLOSED'   執行結束。

       Python中利用協程實現生產者-消費者模式: 代碼中創建了一個叫做consumer的協程,並且在主線程中生產數據,協程中消費數據。

其中 yield 是python當中的語法。當協程執行到yield關鍵字時,會暫停在那一行,等到主線程調用send方法發送了數據,協程纔會接到數據繼續執行。 但是,yield讓協程暫停,和線程的阻塞是有本質區別的。協程的暫停完全由程序控制,線程的阻塞狀態是由操作系統內核來進行切換。因此,協程的開銷遠遠小於線程的開銷。

分析:

傳統的生產者-消費者模型是一個線程寫消息,一個線程取消息,通過鎖機制控制隊列和等待,但一不小心就可能死鎖。

如果改用協程,生產者生產消息後,直接通過yield跳轉到消費者開始執行,待消費者執行完畢後,切換回生產者繼續生產,效率極高。

python可以通過 yield/send 的方式實現協程。在python 3.5以後,async/await 成爲了更好的替代方案。

Python協程的發展:

  1. 最初的生成器變形yield/send
  2. 引入@asyncio.coroutine和yield from(Python 3.3 中的 yield from 和 Python 3.4 中的 asyncio )
  3. 在最近的Python3.5版本中引入async/await關鍵字(Python 3.5)

Python中的協程經歷了很長的一段發展歷程。最初的生成器yield和send()語法,然後在Python3.4中加入了asyncio模塊,引入@asyncio.coroutine裝飾器和yield from語法,在Python3.5上又提供了async/await語法,目前正式發佈的Python3.6中asynico也由臨時版改爲了穩定版。

(1)yield/send

當一個函數中包含yield語句時,python會自動將其識別爲一個生成器。這時fib(20)並不會真正調用函數體,而是以函數體生成了一個生成器對象實例。yield在這裏可以保留fib函數的計算現場,暫停fib的計算並將b返回。而將fib放入for…in循環中時,每次循環都會調用next(fib(20)),喚醒生成器,執行到下一個yield語句處,直到拋出StopIteration異常。此異常會被for循環捕獲,導致跳出循環。

發展:

1)在 Python2.2 中,第一次引入了生成器,生成器實現了一種惰性、多次取值的方法,此時還是通過 next 構造生成迭代鏈或 next 進行多次取值。

2)直到在 Python2.5 中,yield 關鍵字被加入到語法中,這時,生成器有了記憶功能,下一次從生成器中取值可以恢復到生成器上次 yield 執行的位置。

3)之前的生成器都是關於如何構造迭代器,在 Python2.5 中生成器還加入了 send 方法,與 yield 搭配使用。我們發現,此時,生成器不僅僅可以 yield 暫停到一個狀態,還可以往它停止的位置通過 send 方法傳入一個值改變其狀態。最初的yield只能返回並暫停函數,並不能實現協程的功能。後來,Python爲它定義了新的功能——接收外部發來的值( send 方法),這樣一個生成器就變成了協程。

def jump_range(up_to):
   step = 0
   while step < up_to:
     jump = yield step
     print("jump", jump)
     if jump is None:
         jump = 1
         step += jump
     print("step", step)

if __name__ == '__main__':
   iterator = jump_range(10)
   print(next(iterator))  # 0
   print(iterator.send(4))  # jump4; step4; 4
   print(next(iterator))  # jump None; step5; 5
   print(iterator.send(-1)) # jump -1; step4; 4

(2)yield from

yield from用於重構生成器。yield from的作用還體現可以像一個管道一樣將send信息傳遞給內層協程,並且處理好了各種異常情況。

asyncio是一個基於事件循環的實現異步I/O的模塊。通過yield from,我們可以將協程asyncio.sleep的控制權交給事件循環,然後掛起當前協程;之後,由事件循環決定何時喚醒asyncio.sleep,接着向後執行代碼。

發展:

1)在 Python3.3 中,生成器又引入了 yield from 關鍵字,yield from 實現了在生成器內調用另外生成器的功能,可以輕易的重構生成器,比如將多個生成器連接在一起執行。

def gen_3():
   yield 3

def gen_234():
   yield 2
   yield from gen_3()
   yield 4

def main():
   yield 1
   yield from gen_234()
   yield 5

for element in main():
   print(element)  # 1,2,3,4,5

 

從圖中可以看出 yield from 的特點。使用 itertools.chain 可以以生成器爲最小組合進行鏈式組合,使用 itertools.cycle 可以對單獨一個生成器首尾相接,構造一個循環鏈。使用 yield from 時可以在生成器中從其他生成器 yield 一個值,這樣不同的生成器之間可以互相通信,這樣構造出的生成鏈更加複雜,但生成鏈最小組合子的粒度卻精細至單個 yield 對象。

2)短暫的asynico.coroutine 與yield from

有了Python3.3中引入的yield from 這項工具,Python3.4 中新加入了asyncio庫,並提供了一個默認的event loop。Python3.4有了足夠的基礎工具進行異步併發編程。

asyncio是Python 3.4版本引入的標準庫,直接內置了對異步IO的支持。asyncio的異步操作,需要在coroutine中通過yield from完成。

併發編程同時執行多條獨立的邏輯流,每個協程都有獨立的棧空間,即使它們是都工作在同個線程中的。以下是一個示例代碼:

@asyncio.coroutine
def test(i):
	print("test_1",i)
	r=yield from asyncio.sleep(1)
	print("test_2",i)
loop=asyncio.get_event_loop()
tasks=[test(i) for i in range(5)]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()


test_1 3
test_1 4
test_1 0
test_1 1
test_1 2
test_2 3
test_2 0
test_2 2
test_2 4
test_2 1

說明:從運行結果可以看到,跟gevent達到的效果一樣,也是在遇到IO操作時進行切換(所以先輸出test_1,等test_1輸出完再輸出test_2)。

Python3.4中,asyncio.coroutine 裝飾器是用來將函數轉換爲協程的語法,這也是 Python 第一次提供的生成器協程 。只有通過該裝飾器,生成器才能實現協程接口。使用協程時,你需要使用 yield from 關鍵字將一個 asyncio.Future 對象向下傳遞給事件循環,當這個 Future 對象還未就緒時,該協程就暫時掛起以處理其他任務。一旦 Future 對象完成,事件循環將會偵測到狀態變化,會將 Future 對象的結果通過 send 方法方法返回給生成器協程,然後生成器恢復工作。

asyncio說明:

  @asyncio.coroutine:asyncio模塊中的裝飾器,用於將一個生成器聲明爲協程。可以把一個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了,因此可以實現併發執行。

從Python3.4開始asyncio模塊加入到了標準庫,通過asyncio我們可以輕鬆實現協程來完成異步IO操作。asyncio是一個基於事件循環的異步IO模塊,通過yield from,我們可以將協程asyncio.sleep()的控制權交給事件循環,然後掛起當前協程;之後,由事件循環決定何時喚醒asyncio.sleep,接着向後執行代碼。

(3)async/await

在Python3.5中引入的async和await就不難理解了:可以將他們理解成asyncio.coroutine/yield from的完美替身。當然,從Python設計的角度來說,async/await讓協程表面上獨立於生成器而存在,不再使用yield語法,將細節都隱藏於asyncio模塊之下,語法更清晰明瞭。

幾個重要概念:

  • event_loop:事件循環,相當於一個無限循環,我們可以把一些函數註冊到這個事件循環上,當滿足條件發生的時候,就會調用對應的處理方法。
  • coroutine:協程,在 Python 中常指代爲協程對象類型,我們可以將協程對象註冊到時間循環中,它會被事件循環調用。我們可以使用 async 關鍵字來定義一個方法,這個方法在調用時不會立即被執行,而是返回一個協程對象。
  • task:任務,它是對協程對象的進一步封裝,包含了任務的各個狀態。
  • future:代表將來執行或沒有執行的任務的結果,實際上和 task 沒有本質區別。

await 的行爲類似 yield from,但是它們異步等待的對象並不一致,yield from 等待的是一個生成器對象,而await接收的是定義了__await__方法的 awaitable 對象。在 Python 中,協程也是 awaitable 對象,collections.abc.Coroutine 對象繼承自 collections.abc.Awaitable。

爲了簡化並更好地標識異步IO,從Python 3.5開始引入了新的語法async和await,可以讓coroutine的代碼更簡潔易讀。
  請注意,async和await是針對coroutine的新語法,要使用新的語法,只需要做兩步簡單的替換:

  • 把@asyncio.coroutine替換爲async;
  • 把yield from替換爲await。
import asyncio
async def test(i):
	print("test_1",i)
	await asyncio.sleep(1)
	print("test_2",i)
loop=asyncio.get_event_loop()
tasks=[test(i) for i in range(5)]
loop.run_until_complete(asyncio.wait(tasks))
loop.close()
import asyncio
 
async def execute(x):
    print('Number:', x)
 
coroutine = execute(1)
print('Coroutine:', coroutine)
print('After calling execute')
 
loop = asyncio.get_event_loop()
loop.run_until_complete(coroutine) # 將協程註冊到事件循環 loop 中,然後啓動
print('After calling loop')


Coroutine: <coroutine object execute at 0x1034cf830>
After calling execute
Number: 1
After calling loop

async 定義的方法就會變成一個無法直接執行的 coroutine 對象,必須將其註冊到事件循環中纔可以執行。 

從 Python 語言發展的角度來說,async/await 並非是多麼偉大的改進,只是引進了其他語言中成熟的語義,協程的基石還是在於 eventloop 庫的發展,以及生成器的完善

從結構原理而言,asyncio 實質擔當的角色是一個異步框架async/await 是爲異步框架提供的 API,因爲使用者目前並不能脫離 asyncio 或其他異步庫使用 async/await 編寫協程代碼。即使用戶可以避免顯式地實例化事件循環,比如支持 asyncio/await 語法的協程網絡庫 curio,但是脫離了 eventloop 如心臟般的驅動作用,async/await 關鍵字本身也毫無作用。

asyncio模塊

基於協程的異步IO模塊。asyncio的使用可分三步走:

  1. 創建事件循環
  2. 指定循環模式並運行
  3. 關閉循環

通常我們使用asyncio.get_event_loop()方法創建一個循環。

運行循環有兩種方法:一是調用run_until_complete()方法,二是調用run_forever()方法。run_until_complete()內置add_done_callback回調函數,run_forever()則可以自定義add_done_callback()。

使用run_until_complete()方法:

import asyncio

async def func(future):
    await asyncio.sleep(1)
    future.set_result('Future is done!')

if __name__ == '__main__':

    loop = asyncio.get_event_loop()
    future = asyncio.Future()
    asyncio.ensure_future(func(future))
    print(loop.is_running())   # 查看當前狀態時循環是否已經啓動
    loop.run_until_complete(future)
    print(future.result())
    loop.close()

使用run_forever()方法:

import asyncio

async def func(future):
    await asyncio.sleep(1)
    future.set_result('Future is done!')

def call_result(future):
    print(future.result())
    loop.stop()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    future = asyncio.Future()
    asyncio.ensure_future(func(future))
    future.add_done_callback(call_result)        # 注意這行
    try:
        loop.run_forever()
    finally:
        loop.close()

協程池

作用:控制協程數量

 

2.3 應用

2.3.1 第三方庫

使用greenlet模塊。Python的 greenlet就相當於手動切換,去執行別的子程序,在“別的子程序”中又主動切換回來。很多知名的網絡併發框架如eventlet,gevent都是基於它實現的。使用switch()方法切換協程,也比”yield”, “next/send”組合要直觀的多。

基本語法及參數:

greenlet(run=None, parent=None)

參數”run”就是其要調用的方法;參數”parent”定義了該協程對象的父協程,也就是說,greenlet協程之間是可以有父子關係的。如果不設或設爲空,則其父協程就是程序默認的”main”主協程。這個”main”協程不需要用戶創建,它所對應的方法就是主程序,而所有用戶創建的協程都是其子孫。大家可以把greenlet協程集看作一顆樹,樹的根節點就是”main”,上例中的”gr1″和”gr2″就是其兩個字節點。在子協程執行完畢後,會自動返回父協程。

示例: 

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()


12
56
34

Gevent 是一個第三方庫。在Python2中推薦使用,比原生yield容易很多。其可以輕鬆通過gevent實現併發同步或異步編程,在gevent中用到的主要模式是Greenlet, 它是以C擴展模塊形式接入Python的輕量級協程。 Greenlet全部運行在主程序操作系統進程的內部,但它們被協作式地調度。

gevent會主動識別程序內部的IO操作,當子程序遇到IO後,切換到別的子程序。如果所有的子程序都進入IO,則阻塞。

 當一個greenlet遇到IO操作時,比如訪問網絡,就自動切換到其他的greenlet,等到IO操作完成,再在適當的時候切換回來繼續執行。由於IO操作非常耗時,經常使程序處於等待狀態,有了gevent爲我們自動切換協程,就保證總有greenlet在運行,而不是等待IO。

爬蟲示例:

import gevent
from gevent import monkey;monkey.patch_all()
import urllib2
def get_body(i):
	print "start",i
	urllib2.urlopen("http://cn.bing.com")
	print "end",i
tasks=[gevent.spawn(get_body,i) for i in range(3)]
gevent.joinall(tasks)


start 0
start 1
start 2
end 2
end 0
end 1

從結果來看,多線程與協程的效果一樣,都是達到了IO阻塞時切換的功能。不同的是,多線程切換的是線程(線程間切換),協程切換的是上下文(可以理解爲執行的函數)。而切換線程的開銷明顯是要大於切換上下文的開銷,因此當線程越多,協程的效率就越比多線程的高。(多進程的切換開銷更大) 

Gevent使用說明:

  • monkey可以使一些阻塞的模塊變得不阻塞,機制:遇到IO操作則自動切換,手動切換可以用gevent.sleep(0)(將爬蟲代碼換成這個,效果一樣可以達到切換上下文)
  • gevent.spawn 啓動協程,參數爲函數名稱,參數名稱
  • gevent.joinall 停止協程

2.3.2 生成器的實現

比如xrange。

2.3.3 異步爬蟲

  • grequests (requests模塊的異步化)
  • 爬蟲模塊+gevent(比較推薦這個)
  • aiohttp (這個貌似資料不多,目前我也不太會用)
  • asyncio內置爬蟲功能 (這個也比較難用)

2.4 優點和缺點

優點:

  • 無需線程上下文切換的開銷
  • 無需原子操作定及同步的開銷
  • 方便切換控制流,簡化編程模型
  • 高併發+高擴展性+低成本:一個CPU支持上萬的協程都不是問題。所以很適合用於高併發處理。

缺點:

  • 無法利用多核資源:協程的本質是個單線程,它不能同時將單個CPU 的多個核用上,協程需要和進程配合才能運行在多CPU上.當然我們日常所編寫的絕大部分應用都沒有這個必要,除非是cpu密集型應用。
  • 進行阻塞(Blocking)操作(如IO時)會阻塞掉整個程序

三、總結

(1)因爲協程是一個線程執行,那怎麼利用多核CPU呢?最簡單的方法是多進程+協程,既充分利用多核,又充分發揮協程的高效率,可獲得極高的性能。

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

(2)在Python中,協程基本可以用來代替多線程。PyCon 2018 上,來自 Facebook 的 John Reese 介紹了 asyncio 和 multiprocessing 各自的特點,並開發了一個新的庫,叫做 aiomultiprocess。一方面我們使用異步協程來防止阻塞,另一方面我們使用 multiprocessing 來利用多核成倍加速,節省時間其實還是非常可觀的。

(3)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章