【python內功修煉011】:深入淺出了解python協程

一、引子

一般來說,線程下的程序執行路徑都是層級調用, 比如A(子程序/函數)調用B,B在執行過程中又調用了C,C執行完畢返回,B執行完畢返回,最後是A執行完畢。

如下實例:


def A():
    print('A1')
    print('A2')
    print('A3')

def B():
    print('B1')
    print('B2')
    print('B3')
    
    
if __name__ == '__main__':
    A()
    B()

結果:

A1
A2
A3
B1
B2
B3

結論:

子程序被調用都是順序執行的,一個線程按照順序調用一個或多個子程序,總是一個入口,一次返回。

協程和線程區別(概念層)

協程看上去也是線程,但在執行過程中,在子程序內部可中斷,然後轉而執行別的子程序,在適當的時候再返回來接着執行。

如下實例:

注意:在一個子程序中中斷,去執行其他子程序,不是函數調用,有點類似CPU的中斷。比如子程序A、B:

def eat():
    print('喫第一口飯')
    print('喫第二口飯')
    print('喫第三口飯')

def play():
    print('玩一下手機')
    print('玩二下手機')
    print('玩三下手機')
    
    
if __name__ == '__main__':
    A()
    B()

假設由協程執行,我們來模擬喫一口飯玩一下手機的場景,結果可能是:

喫第一口飯
玩一下手機
喫第二口飯
玩二下手機
喫第三口飯
玩三下手機

提示:

但是在eat中是沒有調用play的,所以協程的調用比函數調用理解起來要難一些。

從某種意義上 看起來eat和play的執行有點像多線程,但協程的特點在於是一個線程執行

二、協程介紹

2.1 協程誕生的背景

對於單線程下,我們不可避免程序中出現IO操作,但如果我們能在自己的程序中(即用戶程序級別,而非操作系統級別)控制單線程下的多個任務能在一個任務遇到IO阻塞時就切換到另外一個任務去計算,這樣就保證了該線程能夠最大限度地處於就緒態,即隨時都可以被CPU執行的狀態,相當於我們在用戶程序級別將自己的IO操作最大限度地隱藏起來,從而可以迷惑操作系統,讓其看到:

該線程好像是一直在計算,IO比較少,從而更多的將CPU的執行權限分配給我們的線程。

協程的本質就是在單線程下,由用戶自己控制一個任務遇到IO阻塞了就切換另外一個任務去執行,以此來提升效率。爲了實現它,我們需要找尋一種可以同時滿足以下條件的解決方案:

#1. 可以控制多個任務之間的切換,切換之前將任務的狀態保存下來,以便重新運行時,可以基於暫停的位置繼續執行。
#2. 作爲1的補充:可以檢測io操作,在遇到io操作的情況下才發生切換

2.2 官方解釋

協程,又稱微線程,纖程。英文名Coroutine ,是一種用戶態的輕量級線程。

協程的調度完全由用戶控制。協程擁有自己的寄存器上下文和棧。協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧,直接操作棧則基本沒有內核切換的開銷,可以不加鎖的訪問全局變量,所以上下文的切換非常快。

2.3 自己的理解

協程本質上就是一個線程,以前線程任務的切換是由操作系統控制的,遇到I/O自動切換,現在我們用協程的目的就是較少操作系統切換的開銷(開關線程,創建寄存器、堆棧等,在他們之間進行切換等),在我們自己的程序裏面來控制任務的切換。

2.4 協程的優缺點

優點:

執行效率極高,因爲子程序切換(函數)不是線程切換,由程序自身控制,沒有切換線程的開銷。所以與多線程相比,線程的數量越多,協程性能的優勢越明顯。

不需要多線程的鎖機制,因爲只有一個線程,也不存在同時寫變量衝突,在控制共享資源時也不需要加鎖,因此執行效率高很多。

缺點:

多個任務一旦有一個阻塞沒有切換,整個線程都阻塞在原地
該線程內的其他的任務都不能執行了

一旦引入協程,就需要檢測單線程下所有的IO行爲,
實現遇到IO就切換,少一個都不行,以爲一旦一個任務阻塞了,整個線程就阻塞了,
其他的任務即便是可以計算,但是也無法運行了

提示:

  1. python的線程屬於內核級別的,即由操作系統控制調度(如單線程遇到IO或執行時間過長就會被迫交出CPU執行權限,切換其他線程運行)
  2. 單線程內開啓協程,一旦遇到IO,就會從應用程序級別(而非操作系統)控制切換,以此來提升效率(!!!非IO操作的切換與效率無關)

2.5 基於yield來進行協程的模擬


      本節的主題是基於單線程來實現併發,即只用一個主線程(很明顯可利用的cpu只有一個)情況下實現併發,
爲此我們需要先回顧下併發的本質:切換+保存狀態
     cpu正在運行一個任務,會在兩種情況下切走去執行其他的任務(切換由操作系統強制控制),一種情況是
     該任務發生了阻塞,``另外一種情況是該任務計算的時間過長或有一個優先級更高的程序替代了它

在這裏插入圖片描述

PS:在介紹進程理論時,提及進程的三種執行狀態,而線程纔是執行單位,所以也可以將上圖理解爲線程的三種狀態

yield本身就是一種在單線程下可以保存任務運行狀態的方法

1、 yield可以保存當前函數狀態,形成一個阻塞,yield的狀態保存與操作系統的保存線程狀態很像,但是yield是代碼級別控制的,更輕量級

2、 send 可以把一個函數的結果傳給另外一個函數,以此實現單線程內程序之間的切換 。

其中第二種情況並不能提升效率,只是爲了讓CPU能夠雨露均沾,實現看起來所有任務都被“同時”執行的效果,如果多個任務都是純計算的,這種切換反而會降低效率。爲此我們可以基於yield來驗證。yield本身就是一種在單線程下可以保存任務運行狀態的方法,我們來簡單複習一下:

串行執行的代碼

# 串行執行
class Test_serial:

    def f1(self):
        ' 生成數據,得到f1和 '
        return sum([i for i in range(10000000)])

    def f2(self):
        '生成數據得到f2和'
        return sum([i for i in range(10000000)])

    def main(self):
        self.sum = self.f1() + self.f2()
        print(self.sum)

if __name__ == '__main__':
    start = time.time()
    g = Test_serial()
    g.main()
    stop = time.time()
    print('總計用時:', stop - start)

    '''
   
    結果:
    99999990000000
	總計用時: 2.5535383224487305

    '''

通過yield實現任務切換+保存線程

#  將串行執行代碼,通過yield 改爲併發執行
# ===============================================================
class Test_yield:


    def f1(self):
        '''任務1:接收數據,做加法 '''
        self.sum = 0
        while True:
            x = yield # 通過yield 拿到f2傳的值
            self.sum = self.sum + 2*x
            

    def f2(self):
        '''任務2:生產數據 '''
        g = self.f1()  # 得到f1生成器對象
        next(g)  #找到了f1函數的yield位置
        for i in range(10000000):
            g.send(i)  # #給yield傳值,然後再循環給下一個yield傳值,並且多了切換的程序,比直接串行執行還多了一些步驟,導致執行效率反而更低了。

    def main(self):
        self.f1()
        self.f2()
        print(self.sum)

if __name__ == '__main__':
    start1 = time.time()
    g = Test_yield() 
    g.main()
    stop1 = time.time()
    print('總計用時:', stop1 - start1)
    
    
    '''
   
    結果:
    99999990000000
	總計用時: 3.0752406120300293

    '''
    
#基於yield保存狀態,實現兩個任務直接來回切換,即併發的效果
#PS:如果每個任務中都加上打印,那麼明顯地看到兩個任務的打印是你一次我一次,即併發執行的.

三、greenlet模塊

如果單個線程內有多個任務需要切換,使用yield比較麻煩(需要先初始化,然後調用send),在python中提供的greenlet模塊能夠簡單快速的實現多個任務的直接切換。

3.1 常用方法


# Greenlet對象
from gevent import Greenlet
 
# Greenlet對象創建
job = Greenlet(target0, 3)
Greenlet.spawn() # 創建一個協程並啓動
Greenlet.spawn_later(seconds=3) # 延時啓動
 
# 協程啓動
job.start() # 將協程加入循環並啓動協程
job.start_later(3) # 延時啓動
 
# 等待任務完成
job.join() # 等待任務完成
job.get() # 獲取協程返回的值
 
# 任務中斷和判斷任務狀態
job.dead() # 判斷協程是否死亡
job.kill() # 殺死正在運行的協程並喚醒其他的協程,這個協程將不會再執行,可以
job.ready() # 任務完成返回一個真值
job.successful() # 任務成功完成返回真值,否則拋出錯誤
 
# 獲取屬性
job.loop # 時間循環對象
job.value # 獲取返回的值
 
# 捕捉異常
job.exception # 如果運行有錯誤,獲取它
job.exc_info # 錯誤的詳細信息
 
# 設置回調函數
job.rawlink(back) # 普通回調,將job對象作爲回調函數的參數
job.unlink() # 刪除回調函數
# 執行成功的回調函數
job.link_value(back)
# 執行失敗的回調函數
job.link_exception(back)

3.2 代碼示例

'''

利用greenlet實現喫一口飯玩一下手機功能
'''

from greenlet import greenlet

def eat(name):
    print('%s:喫第一口飯' % name)
    g2.switch('金鞍少年')  # 切換到play代碼塊內 第一行
    print('%s:喫第二口飯' % name)
    g2.switch()  # 切換到play代碼塊內 第三行


def play(name):
    print('%s: 玩一下手機' % name)
    g1.switch()  # 切換到eat代碼塊內第三行
    print('%s: 玩二下手機' % name)


if __name__ == '__main__':
    g1 = greenlet(eat)
    g2 = greenlet(play)

    g1.switch('金鞍少年')  # 在第一次switch時傳入參數,以後都不需要

打印結果:

金鞍少年:喫第一口飯
金鞍少年: 玩一下手機
金鞍少年:喫第二口飯
金鞍少年: 玩二下手機

模擬greenlet遇到IO:

'''

利用greenlet實現喫一口飯玩一下手機功能
'''

from greenlet import greenlet
import time


def eat(name):
    
    print('%s:喫第一口飯' % name)
    time.sleep(10000)  # 模擬遇到 IO
    g2.switch('金鞍少年')  # 切換到play代碼塊內 第一行
    print('%s:喫第二口飯' % name)
    g2.switch()  # 切換到play代碼塊內 第三行


def play(name):
    print('%s: 玩一下手機' % name)
    g1.switch()  # 切換到eat代碼塊內第三行
    print('%s: 玩二下手機' % name)


if __name__ == '__main__':
    g1 = greenlet(eat)
    g2 = greenlet(play)

    g1.switch('金鞍少年')  # 在第一次switch時傳入參數,以後都不需要
    

結果

greenlet只是提供了一種比yield更加便捷的切換方式,當切到一個任務執行時如果遇到IO,那就原地阻塞,仍然是沒有解決遇到IO自動切換來提升效率的問題。

四、Gevent模塊

4.1 Gevent介紹

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

4.2 Gevent用法

語法 作用
gevent.spawn(cls, *args, **kwargs) 創建一個普通的Greenlet對象並切換
gevent.spawn_later(seconds=3) 延時創建一個普通的Greenlet對象並切換
gevent.spawn_raw() 創建的協程對象屬於一個組
gevent.getcurrent() 返回當前正在執行的greenlet
gevent.joinall(jobs) 將協程任務添加到事件循環,接收一個任務列表
gevent.wait() 可以替代join函數等待循環結束,也可以傳入協程對象列表
gevent.kill() 殺死一個協程
gevent.killall() 殺死一個協程列表裏的所有協程
monkey.patch_all() 非常重要,會自動將python的一些標準模塊替換成gevent**框架*

4.3 簡單示例

import gevent 
import time

def eat(name):
    print('%s:喫第一口飯' % name)
    gevent.sleep(2)  # 模擬阻塞
    print('%s:喫第二口飯' % name)

def play(name):
    print('%s: 玩一下手機' % name)
    gevent.sleep(2)  # 模擬阻塞
    print('%s: 玩二下手機' % name)


if __name__ == '__main__':
    g1 = gevent.spawn(eat, '金鞍少年')
    g2 = gevent.spawn(play, '金鞍少年')
    gevent.joinall([g1, g2])  # 等同於g1.join(),g2.join()
    
   '''
   	金鞍少年:喫第一口飯
	金鞍少年: 玩一下手機
	金鞍少年:喫第二口飯
	金鞍少年: 玩二下手機
   ''' 

gevent模塊不能識別它本身以外的所有的IO行爲,但是它內部封裝了一個模塊,能夠幫助我們識別所有的IO行爲

4.4 猴子補丁 Monkey的用法

這個補丁是Gevent模塊最需要注意的問題,有了它,纔會讓Gevent模塊發揮它的作用。我們往往使用Gevent是爲了實現網絡通信的高併發,但是,Gevent直接修改標準庫裏面大部分的阻塞式系統調用,包括socket、ssl、threading和 select等模塊,而變爲協作式運行。但是我們無法保證你在複雜的生產環境中有哪些地方使用這些標準庫會由於打了補丁而出現奇怪的問題。

一種方法是使用gevent下的socket模塊,我們可以通過”from gevent import socket”來導入。不過更常用的方法是使用猴子布丁(Monkey patching)。使用猴子補丁褒貶不一,但是官網上還是建議使用”patch_all()”,而且在程序的第一行就執行。

from gevent import monkey; monkey.patch_socket()

實例

#    使用猴子補丁
from gevent import monkey; monkey.patch_socket()
import gevent
import time

def eat(name):
    print('%s:喫第一口飯' % name)
    time.sleep(2)  # 模擬阻塞
    print('%s:喫第二口飯' % name)



def play(name):
    print('%s: 玩一下手機' % name)
    time.sleep(2)  # 模擬阻塞
    print('%s: 玩二下手機' % name)


if __name__ == '__main__':
    g1 = gevent.spawn(eat, '金鞍少年')
    g2 = gevent.spawn(play, '金鞍少年')
    gevent.joinall([g1, g2])  # 等同於g1.join(),g2.join()


'''
 可以用threading.current_thread().getName()來查看每個g1和g2,查看的結果爲DummyThread-n,即假線程 
'''

模擬網絡請求IO

# 模擬網絡請求IO
from gevent import monkey; monkey.patch_socket()
import gevent
import socket

urls = ['www.baidu.com', 'www.gevent.org', 'www.python.org']
jobs = [gevent.spawn(socket.gethostbyname, url) for url in urls]
gevent.joinall(jobs, timeout=5)

print([job.value for job in jobs])

4.5 Gevent之同步與異步

from gevent import spawn,joinall,monkey;monkey.patch_all()

import time
def task(pid):
    """
    Some non-deterministic task
    """
    time.sleep(0.5)
    print('Task %s done' % pid)


def synchronous():
    for i in range(10):
        task(i)

def asynchronous():
    g_l=[spawn(task,i) for i in range(10)]
    joinall(g_l)

if __name__ == '__main__':
    print('Synchronous:')
    synchronous()

    print('Asynchronous:')
    asynchronous()

    

總結:

上面程序的重要部分是將task函數封裝到Greenlet內部線程的gevent.spawn。 初始化的greenlet列表存放在數組threads中,此數組被傳給gevent.joinall 函數,後者阻塞當前流程,並執行所有給定的greenlet。執行流程只會在 所有greenlet執行完後纔會繼續向下走。

五、 用gevent實現單線程下的socket併發

服務端:

from gevent import monkey;monkey.patch_all()
import socket
import gevent
class server:

    def __init__(self, address, backlog=5):
        self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server.bind(address)
        self.server.listen(backlog)

    def connects(self):
        print('服務端已就緒!')
        while True:  # 鏈接循環
            conn, client_addr = self.server.accept()
            gevent.spawn(self.talk, conn, client_addr)

    # 通信循環
    def talk(self, conn,client_addr):
        try:
            while True:
                res = conn.recv(1024)
                print('client %s:%s msg: %s' %(client_addr[0],client_addr[1],res))
                conn.send(res.upper())
        except Exception as e:
            print(e)
        finally:
            conn.close()


if __name__ == '__main__':
    obj = server(('127.0.0.1', 8833), 5)
    obj.connects()

服務端:

import socket
from threading import Thread,current_thread

def clients(server_ip,port):
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect((server_ip, port))

    count = 0
    while True:
        client.send(('%s say hello %s' % (current_thread().getName(), count)).encode('utf-8'))
        msg = client.recv(1024)
        print(msg.decode('utf-8'))
        count += 1


if __name__ == '__main__':
    for i in range(100):  # 開啓100個線程,模擬併發效果
        T = Thread(target=clients, args=('127.0.0.1', 8833,))
        T.start()
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章