Python協程的引入與原理分析 - IO多路複用

1、概念

協程,又稱微線程,纖程。英文名Coroutine

協程的概念應該是從進程和線程演變而來的,他們都是獨立的執行一段代碼,但是不同是線程比進程要輕量級,協程比線程還要輕量級。多線程在同一個進程中執行,而協程通常也是在一個線程當中執行。

我們都知道Python由於GIL原因,其線程效率並不高,並且在*nix系統中,創建線程的開銷並不比進程小,因此在併發操作時,多線程的效率還是受到了很大制約的。所以後來人們發現通過yield來中斷代碼片段的執行,同時交出了cpu的使用權,於是協程的概念產生了。在Python3.4正式引入了協程的概念

線程是系統級別的它們由操作系統調度,而協程則是程序級別的由程序根據需要自己調度。在一個線程中會有很多函數,我們把這些函數稱爲子程序,在子程序執行過程中可以中斷去執行別的子程序,而別的子程序也可以中斷回來繼續執行之前的子程序,這個過程就稱爲協程。也就是說在同一線程內一段代碼在執行過程中會中斷然後跳轉執行別的代碼,接着在之前中斷的地方繼續開始執行,類似與yield操作。

協程擁有自己的寄存器上下文和棧。協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧。因此:協程能保留上一次調用時的狀態(即所有局部狀態的一個特定組合),每次過程重入時,就相當於進入上一次調用的狀態,換種說法:進入上一次離開時所處邏輯流的位置。

協程的優點:

(1)無需線程上下文切換的開銷,協程避免了無意義的調度,由此可以提高性能(但也因此,程序員必須自己承擔調度的責任,
同時,協程也失去了標準線程使用多CPU的能力)
(2)無需原子操作鎖定及同步的開銷
(3)方便切換控制流,簡化編程模型
(4)高併發+高擴展性+低成本:一個CPU支持上萬的協程都不是問題。所以很適合用於高併發處理。

協程的缺點:

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

2、實現線程

1、yield實現協程效果

def consumer(name):
    print('開始喫包子...')
    while True:
        print('\033[31;1m[consumer]%s需要包子\033[0m'%name)
        bone = yield   #接收send發送的數據
        print('\033[31;1m[%s]吃了%s個包子\033[0m'%(name,bone))
def producer(obj1):
    obj1.send(None)   #必須先發送None
    for i in range(3):
        print('\033[32;1m[producer]\033[0m正在做%s個包子'%i)
        obj1.send(i)


if __name__ == '__main__':
    con1 = consumer('消費者A')  #創建消費者對象
    producer(con1)

-----------------------------------------------------------
#output:
開始喫包子...
[consumer]消費者A需要包子
[producer]正在做0個包子
[消費者A]吃了0個包子
[consumer]消費者A需要包子
[producer]正在做1個包子
[消費者A]吃了1個包子
[consumer]消費者A需要包子
[producer]正在做2個包子
[消費者A]吃了2個包子
[consumer]消費者A需要包子

2、greenlet模塊 實現程序間切換執行

import greenlet

def A():
    print('a.....')
    g2.switch()  #切換至B
    print('a....2')
    g2.switch()
def B():
    print('b.....')
    g1.switch()  #切換至A
    print('b....2')

g1 = greenlet.greenlet(A) #啓動一個線程
g2 = greenlet.greenlet(B)
g1.switch()

3、gevent實現協程

Gevent 是一個第三方庫,可以輕鬆通過gevent實現協程程,在gevent中用到的主要模式是Greenlet, 它是以C擴展模塊形式接入Python的輕量級協程。 Greenlet全部運行在主程序操作系統進程的內部,但它們被協作式地調度。

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

import gevent

def foo():
    print('running in foo')
    gevent.sleep(2)
    print('com back from bar in to foo')
def bar():
    print('running in bar')
    gevent.sleep(2)
    print('com back from foo in to bar')

gevent.joinall([      #創建線程並行執行程序,碰到IO就切換
    gevent.spawn(foo),
    gevent.spawn(bar),
])

線程函數同步與異步比較:

import gevent
def task(pid):
    gevent.sleep(1)
    print('task %s done'%pid)

def synchronous():  #同步一個線程執行函數
    for i in range(1,10):
        task(i)
def asynchronous(): #異步一個線程執行函數
    threads = [gevent.spawn(task,i) for i in range(10)]
    gevent.joinall(threads)

print('synchronous:')
synchronous()   #同步執行時要等待執行完後再執行
print('asynchronous:')
asynchronous()  #異步時遇到等待則會切換執行

爬蟲異步IO阻塞切換:

from urllib import request
import gevent,time
from gevent import monkey

monkey.patch_all()   #將程序中所有IO操作做上標記使程序非阻塞狀態
def url_request(url):
    print('get:%s'%url)
    resp = request.urlopen(url)
    data = resp.read()
    print('%s bytes received from %s'%(len(data),url))

async_time_start = time.time() #開始時間
gevent.joinall([
    gevent.spawn(url_request,'https://www.python.org/'),
    gevent.spawn(url_request,'https://www.nginx.org/'),
    gevent.spawn(url_request,'https://www.ibm.com'),
])
print('haoshi:',time.time()-async_time_start) #總用時

 3、事件驅動

事件驅動編程是一種編程範式,這裏程序的執行流由外部事件來決定。它的特點是包含一個事件循環,當外部事件發生時使用回調機制來觸發相應的處理,另外兩種常用的編程範式是(單線程)同步以及多線程編程。

服務器處理模型的程序時,有以下幾種模型:

  1. 每收到一個請求,創建一個新的進程,來處理該請求;
  2. 每收到一個請求,創建一個新的線程,來處理該請求;
  3. 每收到一個請求,放入一個事件列表,讓主進程通過非阻塞I/O方式來處理請求
  • 第1種方法,由於創建新的進程的開銷比較大,所以,會導致服務器性能比較差,但實現比較簡單。
  • 第2種方式,由於要涉及到線程的同步,有可能會面臨死鎖等問題。
  • 第3種方式,在寫應用程序代碼時,邏輯比前面兩種都複雜。

綜合考慮各方面因素,一般普遍認爲第(3)種方式是大多數網絡服務器採用的方式

讓我們用例子來比較和對比一下單線程、多線程以及事件驅動編程模型。下圖展示了隨着時間的推移,這三種模式下程序所做的工作。這個程序有3個任務需要完成,每個任務都在等待I/O操作時阻塞自身。阻塞在I/O操作上所花費的時間已經用灰色框標示出來了。

在單線程同步模型中,任務按照順序執行。如果某個任務因爲I/O而阻塞,其他所有的任務都必須等待,直到它完成之後它們才能依次執行。這種明確的執行順序和串行化處理的行爲是很容易推斷得出的。如果任務之間並沒有互相依賴的關係,但仍然需要互相等待的話這就使得程序不必要的降低了運行速度。

在多線程版本中,這3個任務分別在獨立的線程中執行。這些線程由操作系統來管理,在多處理器系統上可以並行處理,或者在單處理器系統上交錯執行。這使得當某個線程阻塞在某個資源的同時其他線程得以繼續執行。與完成類似功能的同步程序相比,這種方式更有效率,但程序員必須寫代碼來保護共享資源,防止其被多個線程同時訪問。多線程程序更加難以推斷,因爲這類程序不得不通過線程同步機制如鎖、可重入函數、線程局部存儲或者其他機制來處理線程安全問題,如果實現不當就會導致出現微妙且令人痛不欲生的bug。

在事件驅動版本的程序中,3個任務交錯執行,但仍然在一個單獨的線程控制中。當處理I/O或者其他昂貴的操作時,註冊一個回調到事件循環中,然後當I/O操作完成時繼續執行。回調描述了該如何處理某個事件。事件循環輪詢所有的事件,當事件到來時將它們分配給等待處理事件的回調函數。這種方式讓程序儘可能的得以執行而不需要用到額外的線程。事件驅動型程序比多線程程序更容易推斷出行爲,因爲程序員不需要關心線程安全問題。

當程序中有許多任務,且任務之間高度獨立(它們不需要互相通信,或等待彼此)而且在等待事件到來時,某些任務會阻塞時事件驅動模型時個很好的選擇;當應用程序需要在任務間共享可變的數據時,事件驅動模式可以更好的在單線程下處理。

網絡應用程序通常都是上述特點,這使得它們能夠很好的契合事件驅動編程模型。

此處要提出一個問題,就是,上面的事件驅動模型中,只要一遇到IO就註冊一個事件,然後主程序就可以繼續幹其它的事情了,只到io處理完畢後,繼續恢復之前中斷的任務,這本質上是怎麼實現的呢?這就涉及到select\poll\epoll異步IO


4、IO多路複用

sllect, poll, epoll都是IO多路複用的機制。IO多路複用就是通過這樣一種機制:一個進程可以監聽多個描述符,一旦某個描述符就緒(一般是讀就緒和寫就緒),能夠通知程序進行相應的操作。但select,poll,epoll本質上都是同步IO,因爲他們都需要在讀寫事件就緒後自己負責進行讀寫(即將數據從內核空間拷貝到應用緩存)。也就是說這個讀寫過程是阻塞的。而異步IO則無需自己負責讀寫,異步IO的實現會負責把數據從內核拷貝到用戶空間。

select   

select函數監聽的文件描述符分三類:writefds、readfds、和exceptfds。調用後select函數會阻塞,直到描述符就緒
(有數據可讀、寫、或者有except)或者超時(timeout指定等待時間,如果立即返回則設置爲null),函數返回。當select函數返回
後,可以通過遍歷fdset,來找到就緒的描述符。

優點:良好的跨平臺性(幾乎所有的平臺都支持)
缺點:單個進程能夠監聽的文件描述符數量存在最大限制,在linux上一般爲1024,可以通過修改宏定義甚至重新編譯內核來提升,
但是這樣也會造成效率降低。

poll  

不同於select使用三個位圖來表示fdset的方式,poll使用的是pollfd的指針實現

   pollfd結構包含了要監聽的event和發生的event,不再使用select“參數-值”傳遞的方式。同時pollfd並沒有最大數量限制(但
是數量過大之後性能也是會下降)。和select函數一樣,poll返回後,需要輪詢pollfd來獲取就緒的描述符。

    從上面看,select和poll都需要在返回後,通過遍歷文件描述符來獲取已經就緒的socket。事實上,同時連接的大量客戶端在
同一時刻可能只有很少的處於就緒狀態,因此隨着監視的描述符數量的增長,其效率也會下降。

epoll

epoll是在linux2.6內核中國提出的,(windows不支持),是之前的select和poll增強版。相對於select和poll來說,epoll更加
靈活,沒有描述符的限制。epoll使用一個文件描述符管理多個描述符,將用戶關係的文件描述符的時間存放到內核的一個時間表中。這
樣在用戶控件和內核控件的coppy只需要一次。

如何選擇?

①在併發高同時連接活躍度不是很高的請看下,epoll比select好(網站或web系統中,用戶請求一個頁面後隨時可能會關閉)

②併發性不高,同時連接很活躍,select比epoll好。(比如說遊戲中數據一但連接了就會一直活躍,不會中斷)

 省略章節:由於在用到select的時候需要嵌套多層回調函數,然後印發一系列的問題,如可讀性差,共享狀態管理困難,出現異常排查複雜,於是引入協程,既操作簡單,速度又快。


對於上面的問題,我們希望去解決這樣幾個問題:

  1. 採用同步的方式去編寫異步的代碼,使代碼的可讀性高,更簡便。
  2. 使用單線程去切換任務(就像單線程間函數之間的切換那樣,速度超快)

   (1):線程是由操作系統切換的,單線程的切換意味着我們需要程序員自己去調度任務。

   (2):不需要鎖,併發性高,如果單線程內切換函數,性能遠高於線程切換,併發性更高。

例如我們在做爬蟲的時候:

def get_url(url):
    html = get_html(url) # 此處網絡下載IO操作比較耗時,希望切換到另一個函數去執行
    infos = parse_html(html)

def get_html(url):  # 下載url中的html
    pass

def parse_html(html): # 解析網頁
    pass

意味着我們需要一個可以暫停的函數,對於此函數可以向暫停的地方穿入值。(回憶我們的生成器函數就可以滿足這兩個條件)所以就引入了協程。


生成器進階

  • 生成器不僅可以產出值,還可以接收值,用send()方法。注意:在調用send()發送非None值之前必須先啓動生成器,可以用①next()②send(None)兩種方式激活
  • close()方法。
  •  調用throw()方法。用於拋出一個異常。該異常可以捕捉忽略。

特別注意:調用close.()之後, 生成器在往下運行的時候就會產生出一個GeneratorExit,單數如果用try捕獲異常的話,就算捕獲了遇到後面還有yield的話,還是不能往下運行了,因爲一旦調用close方法生成器就終止運行了(如果還有next,就會會產生一個異常)所以我們不要去try捕捉該異常。(此注意可以先忽略)

yield from 功能總結:

1、子生成器生產的值,都是直接給調用方;調用發通過.send()發送的值都是直接傳遞給子生成器,如果傳遞None,會調用子
生成器的next()方法,如果不是None,會調用子生成器的sen()方法。
2、子生成器退出的時候,最後的return EXPR,會觸發一個StopIteration(EXPR)異常
3、yield from 表達式的值,是子生成器終止時,傳遞給StopIteration異常的第一個參數。
4、如果調用的時候出現了StopIteration異常,委託方生成器恢復運行,同時其他的異常向上冒泡。
5、傳入委託生成器的異常裏,除了GeneratorExit之後,其他所有異常全部傳遞給子生成器的.throw()方法;如果調用.throw()的
時候出現StopIteration異常,那麼就恢復委託生成器的運行,其他的異常全部向上冒泡
6、如果在委託生成器上調用.close()或傳入GeneratorExit異常,會調用子生成器的.close()方法,沒有就不調用,如果在調用
.close()時候拋出了異常,那麼就向上冒泡,否則的話委託生成器跑出GeneratorExit 異常。 

相關概念

  • 併發:指一個時間段內,有幾個程序在同一個cpu上運行,但是任意時刻只有一個程序在cpu上運行。比如說在一秒內cpu切換了100個進程,就可以認爲cpu的併發是100。
  • 並行:值任意時刻點上,有多個程序同時運行在cpu上,可以理解爲多個cpu,每個cpu獨立運行自己程序,互不干擾。並行數量和cpu數量是一致的。

我們平時常說的高併發而不是高並行,是因爲cpu的數量是有限的,不可以增加。

形象的理解:cpu對應一個人,程序對應喝茶,人要喝茶需要四個步驟(可以對應程序需要開啓四個線程):1燒水,2備茶葉,3洗茶杯,4泡茶。

併發方式:燒水的同時做好2備茶葉,3洗茶杯,等水燒好之後執行4泡茶。因爲1、23事件同時進行,會比順序執行1234要省時間。

並行方式:叫來四個人(開啓四個進程),分別執行任務1234,整個程序執行時間取決於耗時最多的步驟。

  • 同步 (注意同步和異步只是針對於I/O操作來講的)值調用IO操作時,必須等待IO操作完成後纔開始新的的調用方式。
  • 異步 指調用IO操作時,不必等待IO操作完成就開始新的的調用方式。
  • 阻塞  指調用函數的時候,當前線程被掛起。
  • 非阻塞  指調用函數的時候,當前線程不會被掛起,而是立即返回。

協程與進程、線程的關係

                           

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