1. 程式人生 > >day035協程、IO多路複用

day035協程、IO多路複用

 

本節內容:

1、協程(重點:gevent)
2、IO多路複用

一、協程

1、引子

本節的主題是基於單執行緒來實現併發,即只用一個主執行緒(很明顯可利用的cpu只有一個)情況下實現併發,
為此我們需要先回顧下併發的本質:切換+儲存狀態

  cpu正在執行一個任務,會在兩種情況下切走去執行其他的任務(切換由作業系統強制控制),
一種情況是該任務發生了阻塞,另外一種情況是該任務計算的時間過長或有一個優先順序更高的程式替代了它

  協程本質上就是一個執行緒,以前執行緒任務的切換是由作業系統控制的,遇到I/O自動切換,
現在我們用協程的目的就是較少作業系統切換的開銷(開關執行緒,建立暫存器、堆疊等,在他們之間進行切換等),
在我們自己的程式裡面來控制任務的切換。

  ps:在介紹程序理論時,提及程序的三種執行狀態,而執行緒才是執行單位,所以也可以將上圖理解為執行緒的三種狀態

一:其中第二種情況並不能提升效率,只是為了讓cpu能夠雨露均沾,實現看起來所有任務都被“同時”執行的效果,

如果多個任務都是純計算的,這種切換反而會降低效率。
為此我們可以基於yield來驗證。
yield本身就是一種在單執行緒下可以儲存任務執行狀態的方法,我們來簡單複習一下:

#1 yiled可以儲存狀態,yield的狀態儲存與作業系統的儲存執行緒狀態很像,但是yield是程式碼級別控制的,更輕量級
#2 send可以把一個函式的結果傳給另外一個函式,以此實現單執行緒內程式之間的切換

通過yield實現任務切換+儲存狀態

import time

def func1(): for i in range(11): #yield print('這是我第%s次列印啦' % i) time.sleep(1) def func2(): g = func1() #next(g) for k in range(10): print('哈哈,我第%s次列印了' % k) time.sleep(1) #next(g) #不寫yield,下面兩個任務是執行完func1裡面所有的程式才會執行func2裡面的程式,有了yield,我們實現了兩個任務的切換+儲存狀態 func1() func2() 
Python Copy

單純的切換反而會降低執行效率

#基於yield併發執行,多工之間來回切換,這就是個簡單的協程的體現,但是他能夠節省I/O時間嗎?不能
import time
def consumer(): '''任務1:接收資料,處理資料''' while True: x=yield # time.sleep(1) #發現什麼?只是進行了切換,但是並沒有節省I/O時間 print('處理了資料:',x) def producer(): '''任務2:生產資料''' g=consumer() next(g) #找到了consumer函式的yield位置 for i in range(3): # for i in range(10000000): g.send(i) #給yield傳值,然後再迴圈給下一個yield傳值,並且多了切換的程式,比直接序列執行還多了一些步驟,導致執行效率反而更低了。 print('傳送了資料:',i) start=time.time() #基於yield儲存狀態,實現兩個任務直接來回切換,即併發的效果 #PS:如果每個任務中都加上列印,那麼明顯地看到兩個任務的列印是你一次我一次,即併發執行的. producer() #我在當前執行緒中只執行了這個函式,但是通過這個函式裡面的send切換了另外一個任務 stop=time.time() # 序列執行的方式 # res=producer() # consumer(res) # stop=time.time() print(stop-start) 
Python Copy

2、協程就是告訴Cpython直譯器,你不是nb嗎,不是搞了個GIL鎖嗎,

那好,我就自己搞成一個執行緒讓你去執行,省去你切換執行緒的時間,我自己切換比你切換要快很多,避免了很多的開銷,
對於單執行緒下,我們不可避免程式中出現io操作,但如果我們能在自己的程式中(即使用者程式級別,而非作業系統級別)
控制單執行緒下的多個任務能在一個任務遇到io阻塞時就切換到另外一個任務去計算,
這樣就保證了該執行緒能夠最大限度地處於就緒態,即隨時都可以被cpu執行的狀態,
相當於我們在使用者程式級別將自己的io操作最大限度地隱藏起來,從而可以迷惑作業系統,
讓其看到:該執行緒好像是一直在計算,io比較少,從而更多的將cpu的執行許可權分配給我們的執行緒。

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

#1. 可以控制多個任務之間的切換,切換之前將任務的狀態儲存下來,以便重新執行時,可以基於暫停的位置繼續執行。

#2. 作為1的補充:可以檢測io操作,在遇到io操作的情況下才發生切換

3、協程介紹

協程:是單執行緒下的併發,又稱微執行緒,纖程。英文名Coroutine。
一句話說明什麼是執行緒:協程是一種使用者態的輕量級執行緒,即協程是由使用者程式自己控制排程的。

對比作業系統控制執行緒的切換,使用者在單執行緒內控制協程的切換
協程在作業系統上是沒有這個概念的,是程式設計師們自己叫的

1.需要強調的是:

1. python的執行緒屬於核心級別的,即由作業系統控制排程
(如單執行緒遇到io或執行時間過長就會被迫交出cpu執行許可權,切換其他執行緒執行)

2. 單執行緒內開啟協程,一旦遇到io,就會從應用程式級別(而非作業系統)控制切換,
以此來提升效率(!!!非io操作的切換與效率無關)

2.優點如下:

1. 協程的切換開銷更小,屬於程式級別的切換,作業系統完全感知不到,因而更加輕量級
2. 單執行緒內就可以實現併發的效果,最大限度地利用cpu

3.缺點如下:

1. 協程的本質是單執行緒下,無法利用多核,
可以是一個程式開啟多個程序,每個程序內開啟多個執行緒,每個執行緒內開啟協程
2. 協程指的是單個執行緒,因而一旦協程出現阻塞,將會阻塞整個執行緒

4.總結協程特點:

1、必須在只有一個單執行緒裡實現併發
2、修改共享資料不需加鎖
3、使用者程式裡自己儲存多個控制流的上下文棧
4、附加:一個協程遇到IO操作自動切換到其它協程
(如何實現檢測IO,yield、greenlet都無法實現,就用到了gevent模組(select機制))

4、Greenlet

如果我們在單個執行緒內有20個任務,要想實現在多個任務之間切換,
使用yield生成器的方式過於麻煩(需要先得到初始化一次的生成器,然後再呼叫send。。。非常麻煩),
而使用greenlet模組可以非常簡單地實現這20個任務直接的切換

#安裝
pip3 install greenlet

1.生成器版的任務切換

import time

def func(): for i in range(10): print("來這裡") yield # 通過yield記錄上一次執行的位置 time.sleep(1) print("%s號完成了" % i) def func1(): g = func() next(g) for i in range(10): time.sleep(1) print("%s號在忙" % i) next(g) # func1() 
Python Copy

2.效率對比

單純的切換(在沒有io的情況下或者沒有重複開闢記憶體空間的操作),反而會降低程式的執行速度

效率對比

#順序執行
import time
def f1(): res=1 for i in range(100000000): res+=i def f2(): res=1 for i in range(100000000): res*=i start=time.time() f1() f2() stop=time.time() print('run time is %s' %(stop-start)) #10.985628366470337 #切換 from greenlet import greenlet import time def f1(): res=1 for i in range(100000000): res+=i g2.switch() def f2(): res=1 for i in range(100000000): res*=i g1.switch() start=time.time() g1=greenlet(f1) g2=greenlet(f2) g1.switch() stop=time.time() print('run time is %s' %(stop-start)) # 52.763017892837524 
Python Copy

3.greenlet只是提供了一種便捷的切換方式,並沒有提升效率

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

上面這個圖,是協程真正的意義,雖然沒有規避固有的I/O時間,但是我們使用這個時間來做別的事情了,
一般在工作中我們都是程序+執行緒+協程的方式來實現併發,以達到最好的併發效果,
如果是4核的cpu,一般起5個程序,每個程序中20個執行緒(5倍cpu數量),每個執行緒可以起500個協程, 大規模爬取頁面的時候,等待網路延遲的時間的時候,我們就可以用協程去實現併發。 併發數量 = 5 * 20 * 500 = 50000個併發,這是一般一個4cpu的機器最大的併發數。 nginx在負載均衡的時候最大承載量就是5w個 單執行緒裡的這20個任務的程式碼通常會既有計算操作又有阻塞操作,我們完全可以在執行任務1時遇到阻塞, 就利用阻塞的時間去執行任務2。。。。如此,才能提高效率,這就用到了Gevent模組。 
Python Copy

5、Gevent介紹

#安裝
pip3 install gevent

  Gevent 是一個第三方庫,可以輕鬆通過gevent實現併發同步或非同步程式設計,
在gevent中用到的主要模式是Greenlet,
它是以C擴充套件模組形式接入Python的輕量級協程。
Greenlet全部執行在主程式作業系統程序的內部,但它們被協作式地排程。

1、用法

g1=gevent.spawn(func,1,2,3,x=4,y=5)建立一個協程物件g1,spawn括號內第一個引數是函式名, 如eat,後面可以有多個引數,可以是位置實參或關鍵字實參,都是傳給函式eat的,spawn是非同步提交任務 g2=gevent.spawn(func2) g1.join() #等待g1結束,上面只是建立協程物件,這個join才是去執行;如果不寫會因為主程式碼太快,而沒有被執行 g2.join() #等待g2結束 有人測試的時候會發現,不寫第二個join也能執行g2,是的,協程幫你切換執行了, 但是你會發現,如果g2裡面的任務執行的時間長,但是不寫join的話,就不會執行完等到g2剩下的任務了 #或者上述兩步合作一步:gevent.joinall([g1,g2]) g1.value#拿到func1的返回值 
Python Copy

2、gevent的簡單使用

from gevent import monkey; monkey.patch_all() import time import gevent def func1(n): print("xxxxx", n) gevent.sleep(2) # time.sleep(2) # 沒有使用monkey,模組不能識別其他io切換,monkey模組的作用是標記出來 print("eeeee", n) def func2(m): print("11111", m) gevent.sleep(2) # time.sleep(2) print("222222", m) start_time = time.time() g1 = gevent.spawn(func1, "李白") g2 = gevent.spawn(func2, "疏影") # g1.join() # g2.join() gevent.joinall([g1,g2]) # 實現了單執行緒的併發(執行緒裡面的協程),節省了時間 end_time = time.time() print("執行時間", end_time - start_time) print("主任務結束") ```` #### 上例gevent.sleep(2)模擬的是gevent可以識別的io阻塞, 而time.sleep(2)或其他的阻塞,gevent是不能直接識別的需要用下面一行程式碼,打補丁,就可以識別了 from gevent import monkey;monkey.patch_all()必須放到被打補丁者的前面,如time,socket模組之前 可以直接理解為:必須放在最檔案開頭,才能標記後面的io阻塞 或者我們乾脆記憶成:要用gevent,需要將from gevent import monkey;monkey.patch_all()放到檔案的開頭 ```python 我們可以用threading.current_thread().getName()來檢視每個g1和g2, 檢視的結果為DummyThread-n,即假執行緒,虛擬執行緒,其實都在一個執行緒裡面 程序執行緒的任務切換是由作業系統自行切換的,你自己不能控制 協程是通過自己的程式(程式碼)來進行切換的,自己能夠控制, 只有遇到協程模組能夠識別的IO操作的時候,程式才會進行任務切換, 實現併發效果,如果所有程式都沒有IO操作,那麼就基本屬於序列執行了。 
Python Copy

3、 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執行完後才會繼續向下走。 
Python Copy

4、Gevent之應用舉例一

協程應用:爬蟲

from gevent import monkey;monkey.patch_all() import gevent import requests import time def get_page(url): print('GET: %s' %url) response=requests.get(url) if response.status_code == 200: print('%d bytes received from %s' %(len(response.text),url)) start_time=time.time() gevent.joinall([ gevent.spawn(get_page,'https://www.python.org/'), gevent.spawn(get_page,'https://www.yahoo.com/'), gevent.spawn(get_page,'https://github.com/'), ]) stop_time=time.time() print('run time is %s' %(stop_time-start_time)) 
Python Copy
將上面的程式最後加上一段序列的程式碼看看效率:
如果你的程式不需要太高的效率,那就不用什麼併發啊協程啊之類的東西。
print('--------------------------------')
s = time.time() requests.get('https://www.python.org/') requests.get('https://www.yahoo.com/') requests.get('https://github.com/') t = time.time() print('序列時間>>',t-s) 
Python Copy

5、Gevent之應用舉例二

通過gevent實現單執行緒下的socket併發(from gevent import monkey;monkey.patch_all()
一定要放到匯入socket模組之前,否則gevent無法識別socket的阻塞)


一個網路請求裡面經過多個時間延遲time

服務端

from gevent import monkey;monkey.patch_all() from socket import * import gevent #如果不想用money.patch_all()打補丁,可以用gevent自帶的socket # from gevent import socket # s=socket.socket() def server(server_ip,port): s=socket(AF_INET,SOCK_STREAM) s.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) s.bind((server_ip,port)) s.listen(5) while True: conn,addr=s.accept() gevent.spawn(talk,conn,addr) def talk(conn,addr): try: while True: res=conn.recv(1024) print('client %s:%s msg: %s' %(addr[0],addr[1],res)) conn.send(res.upper()) except Exception as e: print(e) finally: conn.close() if __name__ == '__main__': server('127.0.0.1',8080) 
Python Copy

客戶端

from socket import *

client=socket(AF_INET,SOCK_STREAM) client.connect(('127.0.0.1',8080)) while True: msg=input('>>: ').strip() if not msg:continue client.send(msg.encode('utf-8')) msg=client.recv(1024) 
Python Copy

多執行緒併發多個客戶端,去請求上面的服務端是沒問題的

from threading import Thread
from socket import * import threading def client(server_ip,port): c=socket(AF_INET,SOCK_STREAM) #套接字物件一定要加到函式內,即區域性名稱空間內,放在函式外則被所有執行緒共享,則大家公用一個套接字物件,那麼客戶端埠永遠一樣了 c.connect((server_ip,port)) count=0 while True: c.send(('%s say hello %s' %(threading.current_thread().getName(),count)).encode('utf-8')) msg=c.recv(1024) print(msg.decode('utf-8')) count+=1 if __name__ == '__main__': for i in range(500): t=Thread(target=client,args=('127.0.0.1',8080)) t.start() 多執行緒併發多個客戶端,去請求上面的服務端是沒問題的 
Python Copy

二、IO多路複用

同步(synchronous) IO和非同步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分別是什麼,
到底有什麼區別?這個問題其實不同的人給出的答案都可能不同,
比如wiki,就認為asynchronous IO和non-blocking IO是一個東西。
這其實是因為不同的人的知識背景不同,並且在討論這個問題的時候上下文(context)也不相同。
所以,為了更好的回答這個問題,我先限定一下本文的上下文。

  本文討論的背景是Linux環境下的network IO。
本文最重要的參考文獻是Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,
6.2節“I/O Models ”,Stevens在這節中詳細說明了各種IO的特點和區別,
如果英文夠好的話,推薦直接閱讀。
Stevens的文風是有名的深入淺出,所以不用擔心看不懂。本文中的流程圖也是擷取自參考文獻。

1、Stevens在文章中一共比較了五種IO Model:

* blocking IO          阻塞IO

  * nonblocking IO 非阻塞IO
  * IO multiplexing IO多路複用
  * signal driven IO 訊號驅動IO(不常見,不講)
  * asynchronous IO 非同步IO
  由signal driven IO(訊號驅動IO)在實際中並不常用,所以主要介紹其餘四種IO Model。

  再說一下IO發生時涉及的物件和步驟。對於一個network IO (這裡我們以read、recv舉例),它會涉及到兩個系統物件,一個是呼叫這個IO的process (or thread),另一個就是系統核心(kernel)。當一個read/recv讀資料的操作發生時,該操作會經歷兩個階段:

1)等待資料準備 (Waiting for the data to be ready)
2)將資料從核心拷貝到程序中(Copying the data from the kernel to the process)

  記住這兩點很重要,因為這些IO模型的區別就是在兩個階段上各有不同的情況。

1.補充:
#1、輸入操作:read、readv、recv、recvfrom、recvmsg共5個函式,
如果會阻塞狀態,則會經理wait data和copy data兩個階段,如果設定為非阻塞則在wait 不到data時丟擲異常

#2、輸出操作:write、writev、send、sendto、sendmsg共5個函式,
在傳送緩衝區滿了會阻塞在原地,如果設定為非阻塞,則會丟擲異常

#3、接收外來連結:accept,與輸入操作類似

#4、發起外出連結:connect,與輸出操作類似
Python Copy

2、阻塞IO(blocking IO)

就是我們平常寫的input,的程式碼,這樣的阻塞
在linux中,預設情況下所有的socket都是blocking,
一個典型的讀操作流程大概是這樣:(recvfrom和tcp裡面的recv在這些IO模型裡面是一樣的)


上面的圖形分析:兩個階段的阻塞
所以,blocking IO的特點就是在IO執行的兩個階段(等待資料和拷貝資料兩個階段)都被block了。

  這裡我們回顧一下同步/非同步/阻塞/非阻塞:

    同步:提交一個任務之後要等待這個任務執行完畢

    非同步:只管提交任務,不等待這個任務執行完畢就可以去做其他的事情

    阻塞:recv、recvfrom、accept,執行緒階段 執行狀態–>阻塞狀態–>就緒

    非阻塞:沒有阻塞狀態

在一個執行緒的IO模型中,我們recv的地方阻塞,我們就開啟多執行緒,
但是不管你開啟多少個執行緒,這個recv的時間是不是沒有被規避掉,
不管是多執行緒還是多程序都沒有規避掉這個IO時間。

3、非阻塞IO

Linux下,可以通過設定socket使其變為non-blocking。
當對一個non-blocking socket執行讀操作時,流程是這個樣子:

在非阻塞式IO中,使用者程序其實是需要不斷的主動詢問kernel資料準備好了沒有。

雖然我們上面的程式碼通過設定非阻塞,規避了IO操作,但是非阻塞IO模型絕不被推薦。

非阻塞IO模型服務端

import time
import socket

server = socket.socket() ip_port = ("127.0.0.1", 8001) server.bind(ip_port) server.listen() server.setblocking(False) # 設定不阻塞,把會阻塞的地方變得不阻塞,但是會報錯,要用try捕獲異常 conn_list = [] # 存放服務端物件,已經連線的管道物件 while 1: while 1: # 不停的輪詢核心, try: conn,addr = server.accept() # 使用了setblocking後,這裡不會阻塞,會有協程一直輪詢核心, conn_list.append(conn) # 將 break except BlockingIOError: time.sleep(0.2) print("還沒有接收到連線請求") print("你好") time.sleep(5) for sock in conn_list: print(sock) # 管道物件 while 1: try: from_cilent_msg = conn.recv(1024).decode("utf-8") print(from_cilent_msg) conn.send(b"hello") break except BlockingIOError: time.sleep(0.2) # while迴圈很耗記憶體,用sleep緩衝一下 print("沒有接收到任何訊息") 
Python Copy

非阻塞IO模型客戶端

import socket

cilent = socket.socket() ip_port = ("127.0.0.1", 8001) cilent.connect(ip_port) cilent_msg = input("請輸入訊息>>>>") cilent.send(cilent_msg.encode("utf-8")) from_server_msg = cilent.recv(1024).decode("utf-8") print(from_server_msg