Python Gevent

參考資料

Python腳本的執行效率一直來說並不是很高,特別是Python下的多線程機制,長久以來一直被人們詬病。很多人都在思考如何讓Python執行的更快一些,其中典型的方式包括:

  • 將複雜的代碼轉由C語言完成
  • 多進程併發執行
  • 多線程完成IO操作

然後,人們討論的更多的則是Gevent的協程機制。在理解Gevent之前,我們需要弄明白幾個基本的概念:進程(Process)、線程(Thread)、協程(Coroutine)、計算機IO方式等。

協程

進程和線程都是操作系統中的模型,操作系統通過進程和線程這兩種模型來執行程序。進程是操作系統分配資源(如CPU、內存等)和調度的基本單位,可以將其看作是包含系統分配的資源和執行流兩部分,通過進程模型,操作系統可以靈活地管理程序的執行。線程是執行流,一般而言一個進程只包含一個執行流,也就是說一個進程只包含一個線程,通過線程模型,一個進程可以擁有多個執行流,進而提供程序的處理能力。

協程coroutine其實是corporate routine的縮寫,直譯爲協同的例程,簡稱爲協程。在Linux中線程是輕量級的進程,因此也將協程coroutine稱爲輕量級的線程,又稱爲微線程。協程簡單的理解就是程序的執行流,在暫停和再次執行的過程中可以記錄當前的狀態,在暫停後需要再次執行時可以從暫停前的狀態繼續執行。協程暫停執行時,可以調度到另一個協程執行,這兩個協程之間的關係是對等的。

協程和生成器generator的概念很像,生成器也可以保存當前執行狀態並再次運行時恢復之前的狀態,不過區別在於協程暫停時可以調度到另一個協程執行,而生成器暫停時會由它的調用者繼續執行。

協程的調度由使用者所決定,而不像進程和線程那樣由操作系統進行調度,Gevent中的協程在暫停時會執行一個稱爲Hub的協程,由Hub選擇處於等待執行狀態的協程繼續執行流。

線程是搶佔式的調度,多個線程並行執行時搶佔共同的系統資源,而協程則是協同式的調度。其實Greenlet並非一種真正的併發機制,而是在同一線程內的不同函數的執行代碼塊之間進行切換,實施“你運行一會兒,我運行一會兒”,在進行切換時必須制定何時切換以及切換到哪兒。

進程與協程

進程與協程有什麼異同點呢?

進程與協程都可以看作是一種執行流,執行流可以掛起,後續可以在掛起的位置恢復執行。

例如:在Linux的Shell中執行Hello程序

開始時Shell進程在運行並等待命令行的輸入,當執行Hello程序時,Shell通過系統調用來執行請求,此時系統調用會將控制權傳遞給操作系統,操作系統保存Shell進程的上下文並創建Hello進程以及上下文並將控制權交給Hello進程。當Hello進程終止後操作系統恢復Shell進程的上下文,並將控制權傳回給Shell進程,Shell進程繼續等待下一個命令的輸入。

進程與協程相同點

當掛起一個執行流時,此時需要保存兩樣東西,其一是棧,其實在切換前局部變量以及函數的調用都需要保存否則將無法恢復,其二是寄存器狀態,寄存器狀態用於當執行流恢復後需要執行什麼。寄存器和棧的結合可以理解爲上下文,上下文切換的理解是CPU看上去像是在並行執行多個進程,這其實是通過CPU在進程間切換來實現的,操作系統實現這種交錯執行的機制稱爲上下文切換。操作系統保存跟蹤進程運行所需的所有狀態信息,這種狀態就是上下文。在任意時刻操作系統只能執行一個進程代碼,當操作系統決定將控制權從當前進程轉移到某個進程時,就會進行上下文切換,也就是保存當前進程的上下文,並恢復新進程的上下文。然後將控制權傳遞給新進程,新進程從從它上次停止的地方開始。

進程與協程的不同點在於

  • 執行流的調用者不同,進程是內核調度,而協程是用戶態調度,也就是說進程的上下文是在內核態中保存並恢復的,而協程是在用戶態保存恢復的,很顯然用戶態的代價要跟低。
  • 進程會被強佔,而協程不會。也就是說協程如果不主動讓出CPU,那麼其他協程就沒有執行的機會。
  • 對內存的佔用不同,協程只需要4KB的棧空間就足夠了,而進程佔用的內存要大的多。
  • 從操作系統角度講,多協程的程序是單進程單協程的。

線程與協程

線程與協程有什麼異同點呢?

協程也被稱爲微線程,線程之間上下文切換的成本相對於協程而言是比較高的,尤其是開啓的線程較多的時候。而協程的切換成本則比較低。另外,同樣線程的切換更多的是依靠操作系統來控制,而協程的執行則是由用戶自己來控制。

計算機IO方式

根據計算機組成原理,計算機中IO的控制方式包括程序查詢方式、程序中斷方式、DMA方式、通道方式。目前計算機採用DMA和通道方式進行IO控制,這樣在進行IO操作時,CPU可以儘量不參與到這個過程中而去執行其它的操作。由於IO操作一般都比較耗時,採用DMA和通道方式可以將CPU從IO過程中解放出來,從而提高系統的效率。

在程序運行過程中一把會遇到兩種類型的IO,即當前機器的磁盤IO和網絡IO,這兩種IO操作一般會阻塞程序的執行,浪費CPU時間,因爲此時程序分配到了時間片,在該時間片內程序是獨佔CPU資源,由於IO被阻塞CPU沒有被其他程序享有進而被浪費。因此在編寫高性能程序時,IO是需要重點關注的。

目前可以通過如下途徑解決因IO帶來的效率問題:

  • 減少IO次數,也就是優化程序的結構,將需要讀寫的數據彙集在一起進行一次性讀寫。
  • 提高硬件的IO速度,比如使用SSD磁盤。
  • IO時不阻塞當前執行流,由DMA控制器或通道負責IO操作,CPU繼續執行程序的其他部分或執行其他程序。

大多涉及到IO的高性能庫一般都是通過第三種途徑解決IO的性能瓶頸,例如Tornado的異步操作,而Gevent正是基於Greenlet的協程。Gevent實現了Python標準庫中一些阻塞庫的非阻塞版本,如socketosselect等,可以使用這些非阻塞的庫替代Python中阻塞的庫。

網絡IO模型

  • 阻塞式單線程

最基本的IO模型,只有在處理完畢一個請求後才能處理下一個請求,缺點是效能差,如果有請求阻塞住,會讓服務無法繼續接受請求,但這種模型編寫代碼相對比較簡單,在應對訪問量不大的情況下非常適用。

  • 阻塞式多線程

針對於單線程接受請求數量有限的缺點,一個很自然的想法是給每個請求開一個線程去處理,這樣做的好處是能夠接受更多的請求。缺點是當線程產生到一定數量之後,進程之間需要大量上下文切換的操作,此時會佔用CPU大量的時間,不過這樣處理的話編寫代碼的難度稍高於單進程的情況。

  • 非阻塞式事件驅動

爲了解決多線程的問題,有一種做法是利用循環來檢查是否有網絡IO事件的發生,以便決定如何來進行處理,比如Reactor設計模式。這種做法的好處是進一步降低了CPU的資源消耗,缺點是這樣做會讓程序難以編寫,因爲請求接受後的處理過程由Reactor來決定,使得程序的執行流程難以把握。當接收到一個請求後如果涉及到阻塞的操作,這個請求的處理過程會停下來去接受另一個請求,程序執行的流程不會像線性程序那樣直觀,比如Twisted框架就是採用此種模型的典型案例。

  • 非阻塞式協程

非阻塞式協程是爲了解決事件驅動模型執行流程不直觀的問題,在本質上也是事件驅動的,但加入了協程的概念。

Gevent

Gevent是一種基於協程的Python網絡庫,使用Greenlet提供並封裝了libevent事件循環的高層同步API,使開發者在不改變編程習慣的同時,以同步的方式編寫異步IO代碼。簡單來說,Gevent是基於libev和Greenlet的一個異步的Python框架。

libev是一個高性能的事件循環event loop實現。事件循環(IO多路複用)是解決阻塞問題實現併發的一種方式。事件循環event loop會捕獲並處理IO事件的變化,當遇到阻塞時就會跳出,當阻塞結束時則會繼續。這一點依賴於操作系統底層的select函數及其升級版pollepoll。而Greenlet則是一個Python的協程管理和切換的模塊,通過Greenlet可以顯式地在不同的任務之間進行切換。

Libev

Gevent的基本原理來自於libevent&libev,熟悉C語言的同學應該對這個lib不陌生。本質上libevent或者說libev都是一種事件驅動模型。這種模型對於提高CPU的運行效率,增強用戶的併發訪問非常有效。但因爲其本身是一種事件機制,所以編寫起來有些繞,並不是很直觀。因此爲了修正這個問題,有人引入了用戶態上下文切換機制,也就是說,如果代碼中引入了帶IO阻塞的代碼時,lib本身會自動完成上下文的切換,全程用戶都是沒有察覺的,這又是Gevent的由來。

Libev是高性能事件循環模型的網絡庫,包含大量新特性,是繼libevent之後的一套全新的網絡庫。libev追求的目標是速度更快、bug更少、特性更多、體積更小。和libevent類似,可以作爲其替代者,提供更高的性能且無需複雜的配置。

libev機制提供了對指定文件描述符發生時調用回調函數的機制,libev是一個事件循環器,向libev註冊感興趣的事件,比如Socket可讀事件,libev會對所註冊的事件的源進行管理,並在事件發生時出發相應的程序。

Yield

Python對協程的支持是非常有限的,使用生成器generator中的yield可以一定程序上實現協程。比如傳統的生產者-消費者模型,即一個線程寫消息一個線程讀消息,通過鎖機制控制隊列和等待,但一不小心就可能出現死鎖。如果改用協程,生產者生產消息後直接通過yield跳轉到消費者並開始執行,等消費者執行完畢後再切換回生產者繼續生產,這樣做效率極高。

 

$ vim test.py

 

#! /usr/bin/env python3
# -*- coding:utf-8 -*-

import time

def consumer():
    r = ""
    while True:
        n = yield r
        if not n:
            return
        print("consumer %s"%n)
        r = "200 OK"

def producer(c):
    c.__next__()
    n = 0
    while n < 3:
        n = n + 1
        print("producer %s"%n)
        r = c.send(n)
        print("producer return %s\n"%r)
    c.close()

if __name__ == "__main__":
    c = consumer()
    producer(c)

 

$ python3 test.py
producer 1
consumer 1
producer return 200 OK

producer 2
consumer 2
producer return 200 OK

producer 3
consumer 3
producer return 200 OK

代碼分析:首先調用c.__next__()啓動生成器,一旦生產出東西,則通過c.send(n)切換到消費者consumer來執行,消費者consumer通過yield獲取到消息後處理,然後通過yield將結果傳回。生產者producer獲取到消費者consumer處理的結果後繼續生產下一條消息。整個過程無鎖,由一個線程執行,生產者和消費者協作完成任務,所以稱之爲協程。

Python通過yield提供了對協程的基本支持,但並不完全。而第三方的Gevent爲Python提供了比較完善的協程支持,Gevent是第三方庫,可通過Greenlet實現協程。另外,Python中由於GIL的存在導致多線程一直不是很好用,相比之下,協程的優勢就更加突出了。

Greenlet

Greenlet是指使用一個任務調度器和一些生成器或協程實現協作式用戶空間多線程的一種僞併發機制,也就是所謂的微線程。Greenlet機制的主要思想是生成器函數或協程函數中的yield語句掛起函數的執行,直到稍後使用next()send()操作進行恢復爲止。可以使用一個調度器循環在一組生成器函數在將協作多個任務。

既然Gevent使用的是Greenlet,因此需要理解Greenlet的工作原理:每個協程都有一個parent,最頂層的協程是man thread或者是當前線程,每個協程遇到IO時會見控制權交給最頂層的協程,它會檢測到哪個協程的IO Event已經完成並將控制權交給它。

Greenlet

Greenlet的基本思路是:當一個Greenlet遇到IO操作時,比如訪問網絡時會自動切換到其它的Greenlet,等到IO操作完成,再在適當的時候切換回來繼續執行。由於IO操作非常耗時,經常會使程序處於等待狀態,有了Gevent自動切換協程,就保證總有Greenlet在運行,而不是等待IO。由於切換是在IO操作時自動完成,所以Gevent需要修改Python自帶的標準庫,這一過程在啓動時通過monkey patch猴子補丁來完成。

Swich

一個Greenlet是一個很小的獨立微線程,可以把它想象成一個堆棧幀,棧底是初始調用,棧頂是當前Greenlet的暫停位置,使用Greenlet創建一堆這樣的堆棧,然後在它們之間跳轉執行。跳轉並不是絕對的,因爲一個Greenlet必須選擇跳轉到選擇好的另一個Greenlet,這會讓前一個掛起,而後一個恢復,兩個Greenlet之間的跳轉又被稱之爲切換switch。當創建一個Greenlet時它會得到一個初始化過的空堆棧,當第一次切換到它時會啓動指定的函數,然後切換跳出Greenlet,當最終棧底函數結束時,Greenlet的堆棧又會變成空的,而Greenlet也就死掉了。當然,Greenlet也會因爲一個未捕獲的異常而死掉。

Monkey-patching

Monkey-patching猴子補丁這個叫法源自於Zope框架,大家在修正Zope的Bug時經常會在程序後追加更新部分,這些被稱作“雜牌軍補丁(guerillapatch)”,後來guerilla逐漸寫成了gorllia(猩猩),再後來就寫成了monkey(猴子),所以猴子補丁的叫法就這麼莫名其妙的得來了。之後在動態語言中,不改變源代碼而對功能進行追加和變更就統稱爲“猴子補丁”。所以猴子補丁並不是Python中專有的,猴子補丁充分利用了動態語言的靈活性,可以對現有語言API進行追加、替換、修改,甚至性能優化等。使用猴子補丁的方式Gevent能夠修改標準庫中大部分的阻塞式系統調用,包括socketsslthreadingselect等模塊,使其變爲協作式運行。

Monkey-patching猴子補丁是將標準庫中大部分的阻塞式調用替換成非阻塞的方式,包括socketsslthreadingselecthttplib等。通過monkey.path_xxx()函數來打補丁,根據Gevent文檔中的建議,應當將猴子補丁的代碼儘可能早的被調用,這樣可以避免一些奇怪的異常。

使用Gevent的性能要比傳統的線程高,但不得不說的一個坑是如果使用Monkey-patching猴子補丁,Gevent將直接修改標準庫中大部分的阻塞式調用,包括socketsslthreadingselect等模塊,而變爲協作式運行。但無法保證在複雜的生產環境中那些地方使用標準庫因補丁而出現的奇怪問題。另外是第三方庫的支持,需要確保項目中使用到的其他網絡庫也必須使用純Python或明確支持Gevent。

Gevent應該在什麼場景中使用呢?

Gevent的優勢在於可以通過同步的邏輯實現併發操作,大大降低編寫並行或併發程序的難度。另外,在一個進程中使用Gevent可以有效地避免對臨界資源的互斥訪問。如果程序中涉及到較多的IO,可以使用Gevent替代多線程來提高程序的效率,但是由於Gevent中的協程的調度是由使用者而非操作系統決定的,Gevent主要解決的問題是IO問題,通過提高IO-bound類型的程序的效率,另外由於是在一個進程中實現協程,而操作性i同是以進程爲單位分配處理資源的(一個進程分配一個處理機)。因此,Gevent並不適合對任務延遲有要求的場景,比如交互式程序中。也不適用於CPU-bound類型的任務和需要使用多處理機的場景(通過運行多個進程,每個進程內實現協程來解決這個問題。)。

安裝使用

Ubuntu系統下可通過apt-get安裝gevent

 

$ sudo apt-get install python-gevent

如果使用的Python3的版本,安裝如下:

 

$ sudo apt-get install python3-gevent

也可以直接使用Python的包管理工具pip命令進行安裝,不過需要注意版本與權限。

 

$ pip install gevent

入門案例:使用Gevent控制程序執行順序

 

$ vim test.py

 

#! /usr/bin/env python3
# -*- coding:utf-8 -*-

import gevent
from gevent import monkey

monkey.patch_socket()

def fn(n):
    for i in range(n):
        print(gevent.getcurrent(), i)
# 創建協程對象
greenlet1 = gevent.spawn(fn, 3)
greenlet2 = gevent.spawn(fn, 3)
# 等待greenlet1執行結束
greenlet1.join()
greenlet2.join()
# 獲取fn的返回值
# print(greenlet1.value)

 

$ python3 test.py
<Greenlet at 0x7ff0b1f6e508: fn(3)> 0
<Greenlet at 0x7ff0b1f6e508: fn(3)> 1
<Greenlet at 0x7ff0b1f6e508: fn(3)> 2
<Greenlet at 0x7ff0b1f6ea60: fn(3)> 0
<Greenlet at 0x7ff0b1f6ea60: fn(3)> 1
<Greenlet at 0x7ff0b1f6ea60: fn(3)> 2

根據執行結果可知:greenlet是依次運行而不是交替運行的,如果要讓greenlet交替運行則需要通過gevent.sleep()交出控制權。
green.spawn會啓動所有協程,協程都是運行在同一個線程之中的,所以協程不能夠跨線程同步數據。

 

$ vim test.py

 

#! /usr/bin/env python3
# -*- coding:utf-8 -*-

import gevent
from gevent import monkey

monkey.patch_socket()

def fn(n):
    for i in range(n):
        print(gevent.getcurrent(), i)
        gevent.sleep(0)

greenlet1 = gevent.spawn(fn, 3)
greenlet2 = gevent.spawn(fn, 3)
# 合併兩步操作
gevent.joinall([greenlet1, greenlet2])

 

$ python3 test.py
<Greenlet at 0x7f53b09a1508: fn(3)> 0
<Greenlet at 0x7f53b09a1a60: fn(3)> 0
<Greenlet at 0x7f53b09a1508: fn(3)> 1
<Greenlet at 0x7f53b09a1a60: fn(3)> 1
<Greenlet at 0x7f53b09a1508: fn(3)> 2
<Greenlet at 0x7f53b09a1a60: fn(3)> 2

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

在Gevent中gevent.sleep()模擬的是Gevent可以識別的IO阻塞,若使用time.sleep()或其他阻塞,Gevent是不能夠直接識別的,需要使用猴子補丁,注意猴子補丁必須放在被打補丁的前面,如timesocket模塊之前。

 

#! /usr/bin/env python3
# -*- coding:utf-8 -*-

import gevent
from gevent import monkey
monkey.patch_all()
import time

def fn(n):
    for i in range(n):
        print(gevent.getcurrent(), i)
        time.sleep(1)

greenlet1 = gevent.spawn(fn, 3)
greenlet2 = gevent.spawn(fn, 3)
gevent.joinall([greenlet1, greenlet2])

案例:Greenlet模塊內部使用了協程的概念,在單線程內需要手動調用switch函數切換協程。

 

$ vim test.py

 

#! /usr/bin/env python3
# -*- coding:utf-8 -*-

from greenlet import greenlet

def eat(name):
    print("%s eat 1"%name)
    g2.switch("egon")
    print("%s eat 2"%name)
    g2.switch()

def play(name):
    print("%s play 1"%name)
    g1.switch()
    print("%s play 2"%name)

g1 = greenlet(eat)
g2 = greenlet(play)
g1.switch("egon")

 

$ python3 test.py
egon eat 1
egon play 1
egon eat 2
egon play 2

根據上述代碼可以發現,協程一旦遇到IO操作就會自動切換到其它協程,使用yield是無法實現的。

例如:

 

$ vim test.py

 

#! /usr/bin/env python3
# -*- coding:utf-8 -*-

import gevent
from gevent import socket

urls = [
    "www.baidu.com",
    "www.python.org",
    "www.example.com"
]

jobs = [
    gevent.spawn(socket.gethostbyname, url) for url in urls        
]

gevent.joinall(jobs, timeout=2)

result = [
    job.value for job in jobs        
]

print(result)

 

$ python3 test.py
['112.80.248.75', '151.101.108.223', '93.184.216.34']

使用gevent.spawn函數spawn引發一些任務jobs,再通過gevent.joinall將所有任務jobs加入到爲協程執行隊列中等待其完成,同時設置超時時間爲2秒。執行後的結果通過檢查gevent.greenlet.value值來收集。

gevent.socket.gethostbyname函數與標準的socket.gethostbyname有着相同的接口,但不會阻塞整個解釋器,因此會使其他Greenlet跟隨着無阻塞的請求而執行。

猴子補丁

 

from gevent import monkey

patch_all

 

patch_all(
  socket = True,
  dns = True,
  time = True,
  select = True,
  thread = True,
  os = True,
  ssl = True,
  httplib = False,
  subprocess = True,
  sys = False,
  aggressive = True,
  Event = False,
  builtins = True,
  signal = True
)

Gevent的Monkey可以爲socket、dns、time、select、thread、os、ssl、httplib、subprocess、sys、aggressive、Event、builtins、signal模塊打上的補丁,打上補丁後他們就是非阻塞的了。



作者:JunChow520
鏈接:https://www.jianshu.com/p/73ccb425a710
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

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