python協程系列(二)——協程的通俗理解及yield關鍵字實現協程

python協程系列(二)——協程的通俗理解及yield關鍵字實現協程

python進階教程

機器學習,深度學習

進入正文

python協程系列(二)——python協程的通俗理解以及使用yield關鍵字實現協程

聲明:本文將詳細講解python協程的實現機理,為了徹底的弄明白它到底是怎麼一回事,鑑於篇幅較長,將徹底從最簡單的yield說起從最簡單的生成器開始說起,因為很多看到這樣一句話的時候很懵,即“yield也是一種簡單的協程”,這到底是為什麼呢?本次系列文章“python協程系列文章”將從最簡單的生成器、yield、yield from說起,然後詳細講解asyncio的實現方式。本文主要講解什麼是協程(coroutine),如何用yield實現簡單的協程。

目錄

一 什麼是協程(coroutinr)

二 協程(coroutinr的直觀理解)

2.1 協程的直觀理解

2.2 為什麼yield可以實現協程

2.3 yield實現協程的例子

三 協程的狀態查看

四 yield實現協程的不足之處

五 全文總結

01

什麼是協程(coroutinr)

在學習寫成之前、首先要明確一些概念,比如子程序、函數、併發、異步、多線程等等,這裡就不贅述了。

1、協程定義:

協程,又稱微線程,纖程。英文名Coroutine。協程的概念很早就提出來了,但直到最近幾年才在某些語言(如Lua)中得到廣泛應用。

2、子程序,或者稱為函數。在所有語言中都是層級調用,比如A調用B,B在執行過程中又調用了C,C執行完畢返回,B執行完畢返回,最後是A執行完畢。所以子程序調用是通過棧實現的,一個線程就是執行一個子程序。子程序調用總是一個入口,一次返回,調用順序是明確的。

順序執行的缺點:這裡小夥伴們都應該是分清楚,那就是程序的無休止等待,必須等待一個函數執行完之後才返回結果。

3、多線程。避免順序執行的方式之一是多線程,但是考慮到python語言的特性(GIL鎖),再執行計算密集型的任務時,多線程的執行效果反而變慢,再執行IO密集型的任務時候雖然有不錯的性能提升,但是依然會有線程管理與切換、同步的開銷等等(具體原因這裡不詳細說明,請參見相關的GIL說明)

4、協程。協程有點像多線程,但協程的特點在於是一個線程執行,那和多線程比,協程有何優勢?

優勢一:最大的優勢就是協程極高的執行效率。因為子程序切換不是線程切換,而是由程序自身控制,因此,沒有線程切換的開銷,和多線程比,線程數量越多,協程的性能優勢就越明顯。

優勢二:就是不需要多線程的鎖機制,因為只有一個線程,也不存在同時寫變量衝突,在協程中控制共享資源不加鎖,只需要判斷狀態就好了,所以執行效率比多線程高很多。

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

6、協程與一般函數的不同點

協程看上去也是子程序(函數),但執行過程中,在子程序內部(函數)可中斷,而不是一次性一定要執行完才行,然後轉而執行別的子程序,在適當的時候再返回來接著執行。

02

協程(coroutinr的直觀理解)

協程(coroutinr的直觀理解)

2.1 協程的直觀理解

yield個人認為其實是為了實現協程而出現的。所以如果要解釋清楚什麼是yield,那麼也就必須要先搞懂什麼是協程。首先明確一點:協程是針對單個CPU的,也就是說,講協程講的就是單線程。我們可以通過協程實現類似併發的任務,並且如果只是在一個CPU上的話,使用協程帶來的效率一般都會比使用線程來的高。這是為啥呢?這就要看協程的原理了。

協程的原理很簡單,打個比方就能講明白了:假設說有十個人去食堂打飯,這個食堂比較窮,只有一個打飯的窗口,並且也只有一個打飯阿姨,那麼打飯就只能一個一個排隊來打咯。這十個人胃口很大,每個人都要點5個菜,但這十個人又有個毛病就是做事情都猶豫不決,所以點菜的時候就會站在那裡,每點一個菜後都會想下一個菜點啥,因此後面的人等的很著急呀。這樣一直站著也不是個事情吧,所以打菜的阿姨看到某個人猶豫5秒後就開始吼一聲,會讓他排到隊伍最後去,先讓別人打菜,等輪到他的時候他也差不多想好吃啥了。這確實是個不錯的方法,但也有一個缺點,那就是打菜的阿姨會等每個人5秒鐘,如果那個人在5秒內沒有做出決定吃啥,其實這5秒就是浪費了。一個人點一個菜就是浪費5秒,十個人每個人點5個菜可就浪費的多啦(菜都涼了要)。那咋辦呢?

這個時候阿姨發話了:大家都是學生,學生就要自覺,我以後也不主動讓你們排到最後去了,如果你們覺得自己會猶豫不決,就自己主動點直接點一個菜就站後面去,等下次排到的時候也差不多想好吃啥了。這個方法果然有效,大家點了菜後想的第一件事情不是下一個菜吃啥,而是自己會不會猶豫,如果會猶豫那直接排到隊伍後面去,如果不會的話就直接接著點菜就行了。這樣一來整個隊伍沒有任何時間是浪費的,效率自然就高了。

這個例子裡的排隊阿姨的那聲吼就是我們的CPU中斷,用於切換上下文。每個打飯的學生就是一個task。而每個人自己決定自己要不要讓出窗口的這種行為,其實就是我們協程的核心思想。

在用線程的時候,其實雖然CPU把時間給了你,你也不一定有活幹,比如你要等IO、等信號啥的,這些時間CPU給了你你也沒用呀。

在用協程的時候,CPU就不來分配時間了,時間由你們自己決定,你覺得幹這件事情很耗時,要等IO啥的,你就幹一會歇一會,等到等IO的時候就主動讓出CPU,讓別人上去幹活,別人也是講道理的,幹一會也會把時間讓給你。協程就是使用了這種思想,讓編程者控制各個任務的運行順序,從而最大可能的發揮CPU的性能。

協程(coroutinr的直觀理解)

2.2 為什麼yield可以實現協程

在Python中,協程通過yield實現。因為當一個函數中有yield存在的時候,這個函數是生成器,那麼當你調用這個函數的時候,你在函數體中寫的代碼並沒有被執行,而是隻返回了一個生成器對象,這個需要特別注意。然後,你的代碼將會在每次使用這個生成器的時候被執行。

前面講過yield表達式的兩個關鍵作用:①返回一個值、②接收調用者的參數

“調用者”與“被調用者”之間的通信是通過send()進行聯繫的

正是因為yield實現的生成器具備“中斷等待的功能”,才使得yield可以實現協程。

協程(coroutinr的直觀理解)

2.3 yield實現協程的例子

(1)實例一:

生產者-消費者模型。這裡不討論生產者-消費者模式到底有什麼用,這裡要實現的就是簡單的函數調用。代碼如下:

def consumer():

r = ''

while True:

n = yield r #執行的中斷點

if not n:

return

print('[消費者] 正在消費:{0}'.format(n))

r = '200 人民幣'

def produce(c):

c.send(None) #啟動消費者(生成器)——實際上是函數調用,只不過生成器不是直接象函數那般調用的

n = 0

while n < 5:

n = n + 1

print('[生產者] 正在生產:{0}'.format(n))

r = c.send(n) #給消費者傳入值——實際上也是函數調用

print('[生產者] 消費者返回:{0}'.format(r))

print('-------------------------------------------------')

c.close()

c = consumer()#構造一個生成器

produce(c)

'''運行結果為:

[生產者] 正在生產:1

[消費者] 正在消費:1

[生產者] 消費者返回:200 人民幣

-------------------------------------------------

[生產者] 正在生產:2

[消費者] 正在消費:2

[生產者] 消費者返回:200 人民幣

-------------------------------------------------

[生產者] 正在生產:3

[消費者] 正在消費:3

[生產者] 消費者返回:200 人民幣

-------------------------------------------------

[生產者] 正在生產:4

[消費者] 正在消費:4

[生產者] 消費者返回:200 人民幣

-------------------------------------------------

[生產者] 正在生產:5

[消費者] 正在消費:5

[生產者] 消費者返回:200 人民幣

-------------------------------------------------

'''

解釋分析:

第一步:在produce(c)函數中,調用了c.send(None)啟動了生成器,這相當於是調用consumer(),但是如果consumer是一個普通函數而不是生成器,就要等到consumer執行完了,主動權才會重新回到producer手裡。但就是因為consumer是生成器,所以第一次遇到yield暫停;接著執行produce()中接下來的代碼,從運行結果看,確實打印出了[生產者] 正在生產 1 ,當程序運行至c.send(n)時,再次調用生成器並且通過yield傳遞了參數(n = 1),這個時候,進入consumer()函數先前在yield停下的地方,繼續向後執行,所以打印出[消費者] 正在消費 1。

第二步:[消費者] 正在消費 1 這句話被打印出來之後,接下consumer()函數中此時 r 被賦值為’200 人民幣’,接著consumer()函數里面的第一次循環結束,進入第二次循環,又遇到yield, 所以consumer()函數又暫停並且返回變量 r 的值,consumer()函數暫停,此時程序又進入produce(c)函數中接著執行。

第三步:由於先前produce(c)函數接著第一次循環中c.send(n)處相當於是調用消費者consumer(),跳入到了consumer()裡面去執行,現在consumer暫停,producer重新我有主動權,故而繼續往下執行打印出[生產者] 消費者返回: 200 人民幣,然後producer的第一次循環結束,並進行第二次循環,打印出[生產者] 正在生產 1,然後,又調用c.send(n) 又調用消費者consumer,將控制權交給consumer,如此循環回到第一步!

(2)實例二:

import time

#定義一個消費者,他有名字name

#因為裡面有yield,本質上是一個生成器

def consumer(name):

print(f'{name} 準備吃包子啦!,呼籲店小二')

while True:

baozi=yield #接收send傳的值,並將值賦值給變量baozi

print(f'包子 {baozi+1} 來了,被 {name} 吃了!')

#定義一個生產者,生產包子的店家,店家有一個名字name,並且有兩個顧客c1 c2

def producer(name,c1,c2):

next(c1) #啟動生成器c1

next(c2) #啟動生成器c2

print(f'{name} 開始準備做包子啦!')

for i in range(5):

time.sleep(1)

print(f'做了第{i+1}包子,分成兩半,你們一人一半')

c1.send(i)

c2.send(i)

print('------------------------------------')

c1=consumer('張三') #把函數變成一個生成器

c2=consumer('李四')

producer('店小二',c1,c2)

'''運行結果為:

張三 準備吃包子啦!,呼籲店小二

李四 準備吃包子啦!,呼籲店小二

店小二 開始準備做包子啦!

做了第1包子,分成兩半,你們一人一半

包子 1 來了,被 張三 吃了!

包子 1 來了,被 李四 吃了!

------------------------------------

做了第2包子,分成兩半,你們一人一半

包子 2 來了,被 張三 吃了!

包子 2 來了,被 李四 吃了!

------------------------------------

做了第3包子,分成兩半,你們一人一半

包子 3 來了,被 張三 吃了!

包子 3 來了,被 李四 吃了!

------------------------------------

做了第4包子,分成兩半,你們一人一半

包子 4 來了,被 張三 吃了!

包子 4 來了,被 李四 吃了!

------------------------------------

做了第5包子,分成兩半,你們一人一半

包子 5 來了,被 張三 吃了!

包子 5 來了,被 李四 吃了!

------------------------------------

'''

運行過程分析:

第一步:啟動生成器c1,c2.c1先運行,運行到第一個循環的yield,暫停,然後c2運行,也運行到第一個yield暫停,打印得到

張三 準備吃包子啦!,呼籲店小二

李四 準備吃包子啦!,呼籲店小二

第二步:現在相當於兩個顧客等著吃包子,控制權交給店小二生產包子,於是打印出 店小二 開始準備做包子啦!,並且進入producer的第一個循環,花了1秒鐘,生產第一個包子,然後將其一分為二,打印出:做了第1包子,分成兩半,你們一人一半。

第三步:此時producer店小二調用send()函數,相當於將包子給兩位客人,這個時候先執行c1.send(),即先把包子給c1,然後c1獲得了控制權,打印出包子 1 來了,被 張三 吃了!然後他吃完進入第二次循環遇見了yield,又暫停。控制權重新回到producer手上,他再執行c2.send(),將包子給c2,c2掌握控制權,於是打印出 包子 1 來了,被 李四 吃了!它在進入第二次循環,遇到yield,然後又暫停了,控制權重新回到producer店小二手中,店小二打印出一段虛線,然後進入第二次循環,重新花了1秒鐘,又做了一個包子,一次這樣下去。

(3)實例三:

def average():

total = 0.0 #數字的總和

count = 0 #數字的個數

avg = None #平均值

while True:

num = yield avg

total += num

count += 1

avg = total/count

#定義一個函數,通過這個函數向average函數發送數值

def sender(generator):

print(next(generator)) #啟動生成器

print(generator.send(10)) # 10

print(generator.send(20)) # 15

print(generator.send(30)) # 20

print(generator.send(40)) # 25

g = average()

sender(g)

'''運行結果為:

None

10.0

15.0

20.0

25.0

'''

運行步驟和上面類似。

03

協程的狀態查看

我們都知道,協程是可以暫停等待、然後又恢復的生成器函數,那麼我又沒有什麼方法查看一個協程到底是處於什麼狀態呢?協程有四種狀態,它們分別是:

協程有四種狀態,分別是

GEN_CREATED:等待執行,即還沒有進入協程

GEN_RUNNING:解釋器執行(這個只有在使用多線程時才能查看到他的狀態,而協程是單線程的)

GEN_SUSPENDED:在yield表達式處暫停(協程在暫停等待的時候的狀態)

GEN_CLOSED:執行結束(協程執行結束了之後的狀態)

怎麼查看呢?

協程的狀態可以用inspect.getgeneratorstate()函數來確定,實例如下:

from inspect import getgeneratorstate #一定要導入

from time import sleep

def my_generator():

for i in range(3):

sleep(0.5)

x = yield i + 1

g=my_generator() #創建一個生成器對象

def main(generator):

try:

print("生成器初始狀態為:{0}".format(getgeneratorstate(g)))

next(g) #激活生成器

print("生成器初始狀態為:{0}".format(getgeneratorstate(g)))

g.send(100)

print("生成器初始狀態為:{0}".format(getgeneratorstate(g)))

next(g)

print("生成器初始狀態為:{0}".format(getgeneratorstate(g)))

next(g)

except StopIteration:

print('全部迭代完畢了')

print("生成器初始狀態為:{0}".format(getgeneratorstate(g)))

main(g)

'''運行結果為:

生成器初始狀態為:GEN_CREATED

生成器初始狀態為:GEN_SUSPENDED

生成器初始狀態為:GEN_SUSPENDED

生成器初始狀態為:GEN_SUSPENDED

全部迭代完畢了

生成器初始狀態為:GEN_CLOSED

'''

04

yield實現協程的不足之處

(1)協程函數的返回值不是特別方便獲取,為什麼參見上一篇文章,只能夠通過出發StopIteration異常,然後通過該異常的value屬性獲取;

(2)Python的生成器是協程coroutine的一種形式,但它的侷限性在於只能向它的直接調用者每次yield一個值。這意味著那些包含yield的代碼不能想其他代碼那樣被分離出來放到一個單獨的函數中。這也正是yield from要解決的。

python協程系列(二)——協程的通俗理解及yield關鍵字實現協程
python協程系列(二)——協程的通俗理解及yield關鍵字實現協程
python協程系列(二)——協程的通俗理解及yield關鍵字實現協程

05

全文總結

從某些角度來理解,協程其實就是一個可以暫停執行的函數,並且可以恢復繼續執行。那麼yield已經可以暫停執行了,如果在暫停後有辦法把一些 value 發回到暫停執行的函數中,那麼 Python 就有了『協程』。於是在PEP 342中,添加了 “把東西發送到已經暫停的生成器中” 的方法,這個方法就是send()。


分享到:


相關文章: