Python開發——15.協程與I/O模型
一、協程(Coroutine)
1.知識背景
協程又稱微線程,是一種用戶態的輕量級線程。子程序,或者稱為函數,在所有語言中都是層級調用,比如A調用B,B在執行過程中又調用了C,C執行完畢返回,B執行完畢返回,最後是A執行完畢。所以子程序調用是通過棧實現的,一個線程就是執行一個子程序。子程序調用總是一個入口,一次返回,調用順序是明確的。而協程的調用和子程序不同。協程看上去也是子程序,但執行過程中,在子程序內部可中斷,然後轉而執行別的子程序,在適當的時候再返回來接著執行。因為協程擁有自己的寄存器上下文和棧,協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧。因此,協程能進入上一次離開時所處邏輯流的位置。
2.優缺點
優點
(1)最大的優勢就是協程極高的執行效率。因為子程序切換不是線程切換,而是由程序自身控制,因此,沒有線程切換的開銷,和多線程比,線程數量越多,協程的性能優勢就越明顯。
(2)不需要多線程的鎖機制,因為只有一個線程,也不存在同時寫變量沖突,在協程中控制共享資源不加鎖,只需要判斷狀態就好了,所以執行效率比多線程高很多。
(3)無需線程上下文切換的開銷,無需操作鎖定及同步的開銷,方便切換控制流,簡化編程模型,高並發+高擴展性+低成本,一個CPU支持上萬的協程都不是問題,所以很適合用於高並發處理。
基於此,利用多核CPU的最簡單的方法就是多進程+協程。既能充分利用多核,又能獲得極高的性能
缺點:(1)協程的本質是個單線程,它不能同時將 單個CPU 的多個核用上,協程需要和進程配合才能運行在多CPU上,進行阻塞(Blocking)操作(如IO時)會阻塞掉整個程序
3.yield與協程
協程的關鍵在於什麽時候切換
import time,queue
def consumer(name):
r = ""
print("%s ready to eat baozi"%name)
while True:
new_baozi = yield
print("%s is eaing baozi %s"%(name,new_baozi))
time.sleep( 1)
def producer():
r = con.__next__()
r = con2.__next__()
n = 0
while True:
time.sleep(1)
print("producer is making baozi %s and %s"%(n,n+1))
con.send(n)
con2.send(n+1)
n += 2
if __name__ == ‘__main__‘:
con = consumer("c1")
con2 = consumer("c2")
producer()
4.greenlet
greenlet是一個用C實現的協程模塊,相比於Python自帶的yield,它可以在任意函數之間隨意切換
from greenlet import greenlet
def func1():
print(12)
gr2.switch()
print(34)
def func2():
print(56)
gr1.switch()
print(78)
gr1.switch()
if __name__ == ‘__main__‘:
gr1 = greenlet(func1)
gr2 = greenlet(func2)
gr2.switch()
5.gevent
當一個greenlet遇到IO操作時,比如訪問網絡,就自動切換到其他的greenlet,等到IO操作完成,再在適當的時候切換回來繼續執行。由於IO操作非常耗時,經常使程序處於等待狀態,有了gevent為我們自動切換協程,就保證總有greenlet在運行,而不是等待IO
gevent執行到IO操作時,會自動切換
import gevent
import requests,time
start = time.time()
def func(url):
print("GET:%s"%url)
resp = requests.get(url)
data = resp.text
print("%s bytes received from %s"%(len(data),url))
# gevent.joinall([
# gevent.spawn(func,"https://nba.hupu.com/"),
# gevent.spawn(func, "http://tj.58.com/"),
# gevent.spawn(func, "https://www.baidu.com/"),
# gevent.spawn(func, "http://sports.qq.com/nba/")
# ])
func("https://nba.hupu.com/")
func("http://tj.58.com/")
func("https://www.baidu.com/")
func("http://sports.qq.com/nba/")
print("costtime",time.time()-start)
爬網頁
import gevent
import requests,time
start = time.time()
def func(url):
print("GET:%s"%url)
resp = requests.get(url)
data = resp.text
f = open("new","w",encoding="utf-8")
f.write(data)
func("https://nba.hupu.com/")
二、IO模型
1.事件驅動模型
(1)定義
事件驅動模型是一種編程範式,這個程序的執行流由外部事件來決定,特點是包含一個事件循環,當外部事件發生時使用回調機制來觸發相應的處理
(2)區別
傳統的編程模式
開始--->代碼塊A--->代碼塊B--->代碼塊C--->代碼塊D--->......--->結束
它的控制流程是由輸入數據和編寫的程序決定的
事件驅動模型
開始--->初始化--->等待
事件驅動程序的等待則完全不知道,也不強制用戶輸入或者幹什麽。只要某一事件發生,那程序就會做出相應的“反應”。這些事件包括:輸入信息、鼠標、敲擊鍵盤上某個鍵還有系統內部定時器觸發。
(3)實例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<p onclick="fun()">點擊這裏</p>
<script type="text/javascript">
function fun() {alert("大嘴!")
}
</script>
</body>
</html>
(4)如何獲得鼠標點擊?
a.創建線程循環檢測是否有鼠標點擊
缺點:
- 掃描線程會一直循環檢測,造成很多的CPU資源浪費
- 當既要掃描鼠標點擊,還要掃描鍵盤是否按下時,如果掃描鼠標時阻塞了,那麽永遠不會去掃描鍵盤
- 如果一個循環需要掃描的設備非常多,又會引來響應時間的問題
b.事件驅動模型
- 有一個事件(消息)隊列
- 鼠標按下時,往這個隊列中增加一個點擊事件(消息)
- 有個循環,不斷從隊列取出事件,根據不同的事件,調用不同的函數,如onClick()、onKeyDown()等
- 事件(消息)一般都各自保存各自的處理函數指針,這樣,每個消息都有獨立的處理函數
2.背景知識
(1)用戶空間和內核空間
為了保證內核的安全,操作系統將虛擬空間劃分為兩部分:一部分為內核空間,另一部分為用戶空間。CPU的指令集,通過0和1 決定是用戶態,還是內核態,0代表內核態(1g),1代表用戶態(3g)
內核態:操作系統內核只能運作於cpu的內核態,這種狀態意味著可以執行cpu所有的指令,對計算機硬件資源有著完全的控制權限,並且可以控制cpu工作狀態由內核態轉成用戶態。
用戶態:應用程序只能運作於cpu的用戶態,這種狀態意味著只能執行cpu所有的指令的一小部分(或者稱為所有指令的一個子集),這一小部分指令對計算機的硬件資源沒有訪問權限(比如I/O),並且不能控制由用戶態轉成內核態。
(2)進程切換
為了控制進程的執行,內核必須有能力掛起正在CPU上執行的進程,並恢復以前掛起的某個進程的執行,這種行為就被稱為進程切換,進程切換很消耗資源。
(3)進程的阻塞
正在執行的進程,由於期待的某些事件未發生(如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等),則由系統自動執行阻塞原語(Block),使自己由運行狀態變為阻塞狀態。
進程的阻塞是進程自身的一種主動行為,也因此只有處於運行態的進程(獲得CPU),才可能將其轉為阻塞狀態,當進程進入阻塞狀態,是不占用CPU資源的。
(4)文件描述符
文件描述符(File descriptor)是一個用於表述指向文件引用的抽象化概念。在形式上是一個非負整數。它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核會向進程返回一個文件描述符。文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。
(5)緩存I/O
緩存 I/O 又被稱作標準 I/O,大多數文件系統的默認 I/O 操作都是緩存 I/O。在 Linux 的緩存 I/O 機制中,數據會先被拷貝到操作系統內核的緩沖區中,然後從操作系統內核的緩沖區拷貝到應用程序的地址空間。
缺點:數據在傳輸過程中需要在應用程序地址空間和內核進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷是非常大的。
3.network IO
常用的有五種:阻塞(blocking)IO、非阻塞(non-blocking)IO、同步(synchronous)IO、異步(asynchronous)IO和信號驅動(Signal-driven)IO,其中信號驅動IO實際用的不多
對於一個network IO,它會涉及到兩個系統對象,一個是調用這個IO的process (or thread),另一個就是系統內核(kernel)。
比如當一個read操作發生時,會經歷兩個階段:a.等待數據準備 (Waiting for the data to be ready);b.將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)
(1)blocking IO(阻塞IO模型)
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據。對於network IO來說,很多時候數據在一開始還沒有到達(比如,還沒有收到一個完整的UDP包),這個時候kernel就要等待足夠的數據到來。而在用戶進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,然後kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
所以,blocking IO的特點就是在IO執行的兩個階段都被block了。
實例:
server端
import socket
sk = socket.socket()
sk.bind(("10.10.27.37",8080))
sk.listen(5)
while True:
conn,addr = sk.accept()
while True:
conn.send("hello,client".encode("utf-8"))
data = conn.recv(1024)
print(data.decode("utf-8"))
client端
import socket
sk = socket.socket()
sk.connect(("10.10.27.37",8080))
while True:
data = sk.recv(1024)
print(data.decode("utf-8"))
sk.send("hello server".encode("utf-8"))
(2)non-blocking IO(非阻塞IO模型)
當用戶進程發出read操作時,如果kernel中的數據還沒有準備好,並不會block用戶進程,而是立刻返回一個error。從用戶進程角度講,它發起一個read操作後,會馬上就得到了一個結果。用戶進程判斷結果是一個error時,會再次發送read操作。一旦kernel中的數據準備好了,並且又再次收到了用戶進程的system call,就將數據拷貝到了用戶內存,然後返回。所以,用戶進程其實是需要不斷的主動詢問kernel數據好了沒有。
進程在返回之後,可以幹點別的事情,然後再發起recvform系統調用。重復的過程,通常被稱之為輪詢。輪詢檢查內核數據,直到數據準備好,再拷貝數據到進程,進行數據處理。但拷貝數據整個過程,進程仍然是屬於阻塞的狀態。
優點:“後臺” 可以有多個任務在同時執行。
缺點:任務完成的響應延遲增大了,因為每過一段時間才去輪詢一次read操作,而任務可能在兩次輪詢之間的任意時間完成。這會導致整體數據吞吐量的降低。
實例
server端
import time
import socket
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
sk.bind(("10.10.27.37",8080))
sk.listen(5)
sk.setblocking(False)
while True:
try:
print("waiting client connection....")
conn,addr = sk.accept()
print("address",addr)
client_message = conn.recv(1024)
print(client_message.decode("utf-8"))
conn.close()
except Exception as e:
print(e)
time.sleep(4)
client端
import socket,time
sk = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
while True:
sk.connect(("10.10.27.37", 8080))
print("已連接")
sk.sendall("hello server".encode("utf-8"))
time.sleep(2)
break
(3)IO multiplexing(IO多路復用)
IO多路復用的基本原理就是select/epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。
工作流程:當用戶進程調用了select,那麽整個進程會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。同時可以監聽多個連接,用的是單線程,利用空閑時間實現並發。
用select的優勢在於它可以同時處理多個connection。如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好。
IO多路復用的三種方式:
-
select--->效率最低,有最大描述符限制,在linux為1024。(Windows、Mac、Linux)
-
poll---->和select一樣,沒有最大描述符限制。(Mac,Linux)
-
epoll--->效率最高,沒有最大描述符限制,支持水平觸發與邊緣觸發。(Linux)
IO多路復用的兩種觸發方式:水平觸發和邊緣觸發
水平觸發:只有高電平或低電平的時候觸發
邊緣觸發:只在電平變化的時候觸發
實例
server端
import socket
import select
sk = socket.socket()
sk.bind(("10.10.27.37",8080))
sk.listen(5)
sk.setblocking(False)
inputs = [sk,]
while True:
r,w,e = select.select(inputs,[],[],5)
for obj in r:
if obj == sk:
conn,addr = obj.accept()
print("conn",conn)
inputs.append(conn)
else:
data_byte = obj.recv(1024)
print(data_byte.decode("utf-8"))
inp = input("回答%s客戶>>>"%inputs.index(obj))
obj.sendall(inp.encode("utf-8"))
print(">>>",r)
client端
import socket
sk = socket.socket()
sk.connect(("10.10.27.37",8080))
while True:
inp = input(">>>").strip()
sk.send(inp.encode("utf-8"))
data = sk.recv(1024)
print(data.decode("utf-8"))
(4)Asynchronous I/O(異步IO)
用戶進程發起read操作之後,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之後,首先它會立刻返回,所以不會對用戶進程產生任何block。然後,kernel會等待數據準備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel會給用戶進程發送一個signal,告訴它read操作完成了。
(5)IO模型比較分析
Python開發——15.協程與I/O模型