1、協程
協程,又稱微線程,是一種用戶態的輕量級線程。
協程擁有自己的寄存器上下文和棧。協程調度切換時,將寄存器上下文和棧保存到其他地方,在切回來的時候,恢復先前保存的寄存器上下文和棧。因此:
協程能保留上一次調用時的狀態(即所有局部狀態的一個特定組合),每次過程重入時,就相當於進入上一次調用的狀態,換種說法:進入上一次離開時所處邏輯流的位置。
協程的標準定義:
- 必須在只有一個單線程裏實現併發
- 修改共享數據不需加鎖
- 用戶程序裏自己保存多個控制流的上下文棧
- 一個協程遇到IO操作自動切換到其它協程
協程的優點:
- 無需線程上下文切換的開銷
- 無需原子操作鎖定及同步的開銷
- “原子操作(atomic operation)是不需要synchronized”,所謂原子操作是指不會被線程調度機制打斷的操作;這種操作一旦開始,就一直運行到結束,中間不會有任何 context switch (切換到另一個線程)。原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序是不可以被打亂,或者切割掉只執行部分。視作整體是原子性的核心。
- 方便切換控制流,簡化編程模型
- 高併發+高擴展性+低成本:一個CPU支持上萬的協程都不是問題。所以很適合用於高併發處理
協程的缺點:
- 無法利用多核資源:協程的本質是個單線程,它不能同時將 單個CPU 的多個核用上,協程需要和進程配合才能運行在多CPU上.當然我們日常所編寫的絕大部分應用都沒有這個必要,除非是cpu密集型應用。
- 進行阻塞(Blocking)操作(如IO時)會阻塞掉整個程序
使用yield實現最簡單的協程的例子
import time
def consumer(name):
print("--->starting eating baozi...")
while True:
new_baozi = yield
print("[%s] is eating baozi %s" % (name,new_baozi))
#time.sleep(1)
def producer():
r = con.__next__()
r = con2.__next__()
n = 0
while n < 5:
n +=1
con.send(n)
con2.send(n)
print("\033[32;1m[producer]\033[0m is making baozi %s" %n )
if __name__ == '__main__':
con = consumer("c1")
con2 = consumer("c2")
p = producer()
2、Greenlet協程
greenlet是一個用C實現的封裝了的協程模塊,相比與python自帶的yield,它可以使你在任意函數之間隨意切換(手動切換),而不需把這個函數先聲明爲generator
from greenlet import greenlet
def test1():
print(12)
gr2.switch()
print(34)
gr2.switch()
def test2():
print(56)
gr1.switch()
print(78)
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
但好像還沒有解決一個問題,就是遇到IO操作,需要手動切換,那如何自動切換?
3、Gevent協程
gevent不需要手動切換,可以實現自動切換
# Author: 73
import gevent
def func1():
print("func1 start...")
gevent.sleep(2)
print("func1 end...")
def func2():
print("func2 start...")
gevent.sleep(1)
print("func2 end...")
def func3():
print("func3 start...")
gevent.sleep(0)
print("func3 end...")
gevent.joinall([
gevent.spawn(func1),
gevent.spawn(func2),
gevent.spawn(func3),
])
'''
func1 start...
func2 start...
func3 start...
func3 end...
func2 end...
func1 end...
Process finished with exit code 0
'''
通過gevent的異步功能,實現一個簡單的爬取網頁的功能
下面程序的重要部分是將f函數封裝到Greenlet內部線程的gevent.spawn。 初始化的greenlet列表存放在數組threads中,此數組被傳給gevent.joinall 函數,後者阻塞當前流程,並執行所有給定的greenlet。執行流程只會在 所有greenlet執行完後纔會繼續向下走
遇到IO阻塞時會自動切換任務
# Author: 73
import gevent,time
from urllib.request import urlopen
from gevent import monkey
'''
存在一個問題:
gevent檢測不到urllib進行了io操作
所以需要monkey
'''
monkey.patch_all() # 把當前程序的所有的io操作給我單獨做上標記
def f(url):
print('GET: %s' % url)
resp = urlopen(url)
data = resp.read()
# f = open("url.txt", "wb")
# f.write(data)
# f.close()
print('%d bytes received from %s.' % (len(data), url))
start_time = time.time()
urls = [
'https://www.csdn.net',
'https://www.baidu.com/',
'https://github.com/',
]
for i in urls:
f(i)
print("同步cost: ", (time.time()-start_time))
start_time = time.time()
gevent.joinall([
gevent.spawn(f, 'https://www.csdn.net'),
gevent.spawn(f, 'https://www.baidu.com/'),
gevent.spawn(f, 'https://github.com/'),
])
print("異步cost: ", (time.time()-start_time))
通過gevent實現單線程下的多socket併發
server
# Author: 73
import gevent
from gevent import monkey
monkey.patch_all()
import socket
def server(port):
s = socket.socket()
s.bind(("0.0.0.0", port))
s.listen(10)
while 1:
conn, addr = s.accept()
gevent.spawn(handle_request, conn)
def handle_request(conn):
try:
while 1:
data = conn.recv(1024)
if not data:
conn.shutdown(socket.SHUT_WR)
print("recv", data)
conn.send(data)
except Exception as ex:
print(ex)
finally:
conn.close()
if __name__ == "__main__":
server(8000)
client
# Author: 73
import socket
HOST = "localhost"
PORT = 8000
c = socket.socket()
c.connect((HOST, PORT))
while 1:
msg = (input("input msg: "))
c.sendall(msg.encode("utf-8"))
data = c.recv(1024)
print(data)
c.close()
到這裏我們已經實現了自動切換的功能,但還有一個問題,比如我們的一個協程遇到IO操作,切到別的協程去了,那麼我們怎麼知道什麼時候該切回來呢?
4、事件驅動與IO
一般,寫服務器處理模型的程序時,有以下幾種模型:
- 每收到一個請求,創建一個新的進程,來處理該請求;
- 每收到一個請求,創建一個新的線程,來處理該請求;
- 每收到一個請求,放入一個事件列表,讓主進程通過非阻塞I/O方式來處理請求
第1種方法,創建新的進程的開銷比較大,會導致服務器性能比較差,但實現比較簡單。
第2種方式,由於要涉及到線程的同步,有可能會面臨死鎖等問題。
第3種方式,在寫應用程序代碼時,邏輯比前面兩種都複雜。
綜合考慮各方面因素,一般普遍認爲第3種方式是大多數網絡服務器採用的方式
事件驅動模型
在UI編程中,常常要對鼠標點擊進行相應,那麼如何獲得鼠標點擊呢?
方式一:創建一個線程,該線程一直循環檢測是否有鼠標點擊
- 缺點:CPU資源浪費,可能鼠標點擊的頻率非常小,但是掃描線程還是會一直循環檢測,這會造成很多的CPU資源浪費
方式二:事件驅動模型
目前大部分的UI編程都是事件驅動模型,如很多UI平臺都會提供onClick()事件,這個事件就代表鼠標按下事件。事件驅動模型大體思路如下:
- 有一個事件(消息)隊列;
- 鼠標按下時,往這個隊列中增加一個點擊事件(消息);
- 有個循環,不斷從隊列取出事件,根據不同的事件,調用不同的函數,如onClick()、onKeyDown()等;
- 事件(消息)一般都各自保存各自的處理函數指針,這樣,每個消息都有獨立的處理函數;
事件驅動編程是一種編程範式,這裏程序的執行流由外部事件來決定。它的特點是包含一個事件循環,當外部事件發生時使用回調機制來觸發相應的處理。另外兩種常見的編程範式是(單線程)同步 以及 多線程編程。
下圖展示了隨着時間的推移,這三種模式下程序所做的工作。這個程序有3個任務需要完成,每個任務都在等待I/O操作時阻塞自身。阻塞在I/O操作上所花費的時間已經用灰色框標示出來了。
在單線程同步模型中,任務按照順序執行。如果某個任務因爲I/O而阻塞,其他所有的任務都必須等待,直到它完成之後它們才能依次執行。這種明確的執行順序和串行化處理的行爲是很容易推斷得出的。如果任務之間並沒有互相依賴的關係,但仍然需要互相等待的話這就使得程序不必要的降低了運行速度。
在多線程版本中,這3個任務分別在獨立的線程中執行。這些線程由操作系統來管理,在多處理器系統上可以並行處理,或者在單處理器系統上交錯執行。這使得當某個線程阻塞在某個資源的同時其他線程得以繼續執行。與完成類似功能的同步程序相比,這種方式更有效率,但程序員必須寫代碼來保護共享資源,防止其被多個線程同時訪問。多線程程序更加難以推斷,因爲這類程序不得不通過線程同步機制如鎖、可重入函數、線程局部存儲或者其他機制來處理線程安全問題,如果實現不當就會導致出現微妙且令人痛不欲生的bug。
在事件驅動版本的程序中,3個任務交錯執行,但仍然在一個單獨的線程控制中。當處理I/O或者其他昂貴的操作時,註冊一個回調到事件循環中,然後當I/O操作完成時繼續執行。回調描述了該如何處理某個事件。事件循環輪詢所有的事件,當事件到來時將它們分配給等待處理事件的回調函數。這種方式讓程序儘可能的得以執行而不需要用到額外的線程。事件驅動型程序比多線程程序更容易推斷出行爲,因爲程序員不需要關心線程安全問題。
當我們面對如下的環境時,事件驅動模型通常是一個好的選擇:
- 程序中有許多任務,而且…
- 任務之間高度獨立(因此它們不需要互相通信,或者等待彼此)而且…
- 在等待事件到來時,某些任務會阻塞。
當應用程序需要在任務間共享可變的數據時,這也是一個不錯的選擇,因爲這裏不需要採用同步處理。
網絡應用程序通常都有上述這些特點,這使得它們能夠很好的契合事件驅動編程模型。
5、 IO模式
基本概念
用戶空間與內核空間
現在操作系統都是採用虛擬存儲器,那麼對32位操作系統而言,它的尋址空間(虛擬存儲空間)爲4G(2的32次方)。操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。爲了保證用戶進程不能直接操作內核(kernel),保證內核的安全,操心繫統將虛擬空間劃分爲兩部分,一部分爲內核空間,一部分爲用戶空間。針對linux操作系統而言,將最高的1G字節(從虛擬地址0xC0000000到0xFFFFFFFF),供內核使用,稱爲內核空間,而將較低的3G字節(從虛擬地址0x00000000到0xBFFFFFFF),供各個進程使用,稱爲用戶空間。
進程切換
爲了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程的執行。這種行爲被稱爲進程切換。因此可以說,任何進程都是在操作系統內核的支持下運行的,是與內核緊密相關的。
從一個進程的運行轉到另一個進程上運行,這個過程中經過下面這些變化:
- 保存處理機上下文,包括程序計數器和其他寄存器。
- 更新PCB信息。
- 把進程的PCB移入相應的隊列,如就緒、在某事件阻塞等隊列。
- 選擇另一個進程執行,並更新其PCB。
- 更新內存管理的數據結構。
- 恢復處理機上下文。
注:PCB通常是系統內存佔用區中的一個連續存區,它存放着操作系統用於描述進程情況及控制進程運行所需的全部信息
進程的阻塞
正在執行的進程,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語(Block),使自己由運行狀態變爲阻塞狀態。可見,進程的阻塞是進程自身的一種主動行爲,也因此只有處於運行態的進程(獲得CPU),纔可能將其轉爲阻塞狀態。當進程進入阻塞狀態,是不佔用CPU資源的。
文件描述符fd
文件描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。
文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核爲每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞着文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。
緩存 I/O
緩存 I/O 又被稱作標準 I/O,大多數文件系統的默認 I/O 操作都是緩存 I/O。在 Linux 的緩存 I/O 機制中,操作系統會將 I/O 的數據緩存在文件系統的頁緩存( page cache )中,也就是說,數據會先被拷貝到操作系統內核的緩衝區中,然後纔會從操作系統內核的緩衝區拷貝到應用程序的地址空間。
緩存 I/O 的缺點:
- 數據在傳輸過程中需要在應用程序地址空間和內核進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷是非常大的。
5種IO模式
對於一次IO訪問(以read舉例),數據會先被拷貝到操作系統內核的緩衝區中,然後纔會從操作系統內核的緩衝區拷貝到應用程序的地址空間。所以說,當一個read操作發生時,它會經歷兩個階段:
- 等待數據準備 (Waiting for the data to be ready)
- 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)
正式因爲這兩個階段,linux系統產生了下面五種網絡模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路複用( IO multiplexing)
- 信號驅動 I/O( signal driven IO)
- 異步 I/O(asynchronous IO)
阻塞 I/O(blocking IO)
在linux中,默認情況下所有的socket都是blocking,一個典型的讀操作流程大概是這樣:
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據(對於網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來)。這個過程需要等待,也就是說數據被拷貝到操作系統內核的緩衝區中是需要一個過程的。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,然後kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
所以,blocking IO的特點就是在IO執行的兩個階段都被block了。
非阻塞 I/O(nonblocking IO)
linux下,可以通過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操作時,流程是這個樣子:
當用戶進程發出read操作時,如果kernel中的數據還沒有準備好,那麼它並不會block用戶進程,而是立刻返回一個error。從用戶進程角度講 ,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個error時,它就知道數據還沒有準備好,於是它可以再次發送read操作。一旦kernel中的數據準備好了,並且又再次收到了用戶進程的system call,那麼它馬上就將數據拷貝到了用戶內存,然後返回。
所以,nonblocking IO的特點是用戶進程需要不斷的主動詢問kernel數據好了沒有。
I/O 多路複用( IO multiplexing)
IO multiplexing主要包括select、poll、epoll。select/epoll的好處就在於單個process就可以同時處理多個網絡連接的IO。它的基本原理就是select,poll,epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。
當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。
所以,I/O 多路複用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函數就可以返回。
異步 I/O(asynchronous IO)
用戶進程發起read操作之後,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之後,首先它會立刻返回,所以不會對用戶進程產生任何block。然後,kernel會等待數據準備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel會給用戶進程發送一個signal,告訴它read操作完成了。
小結
blocking和non-blocking的區別
調用blocking IO會一直block住對應的進程直到操作完成,而non-blocking IO在kernel還準備數據的情況下會立刻返回。
synchronous IO和asynchronous IO的區別
synchronous IO做"IO operation"的時候會將process阻塞。按照這個定義,之前所述的blocking IO,non-blocking IO,IO multiplexing都屬於synchronous IO。
而asynchronous IO則不一樣,當進程發起IO 操作之後,就直接返回再也不理睬了,直到kernel發送一個信號,告訴進程說IO完成。在這整個過程中,進程完全沒有被block。
6、I/O 多路複用之select、poll、epoll
select
select最早於1983年出現在4.2BSD中,它通過一個select()系統調用來監視多個文件描述符的數組,當select()返回後,該數組中就緒的文件描述符便會被內核修改標誌位,使得進程可以獲得這些文件描述符從而進行後續的讀寫操作。
select目前幾乎在所有的平臺上支持,其良好跨平臺支持也是它的一個優點,事實上從現在看來,這也是它所剩不多的優點之一。
select的一個缺點在於單個進程能夠監視的文件描述符的數量存在最大限制,在Linux上一般爲1024,不過可以通過修改宏定義甚至重新編譯內核的方式提升這一限制。
另外,select()所維護的存儲大量文件描述符的數據結構,隨着文件描述符數量的增大,其複製的開銷也線性增長。同時,由於網絡響應時間的延遲使得大量TCP連接處於非活躍狀態,但調用select()會對所有socket進行一次線性掃描,所以這也浪費了一定的開銷。
==poll ==
poll在1986年誕生於System V Release 3,它和select在本質上沒有多大差別,但是poll沒有最大文件描述符數量的限制。
poll和select同樣存在一個缺點就是,包含大量文件描述符的數組被整體複製於用戶態和內核的地址空間之間,而不論這些文件描述符是否就緒,它的開銷隨着文件描述符數量的增加而線性增大。
另外,select()和poll()將就緒的文件描述符告訴進程後,如果進程沒有對其進行IO操作,那麼下次調用select()和poll()的時候將再次報告這些文件描述符,所以它們一般不會丟失就緒的消息,這種方式稱爲水平觸發(Level Triggered)。
epoll
直到Linux2.6纔出現了由內核直接支持的實現方法,那就是epoll,它幾乎具備了之前所說的一切優點,被公認爲Linux2.6下性能最好的多路I/O就緒通知方法。
epoll可以同時支持水平觸發和邊緣觸發(Edge Triggered,只告訴進程哪些文件描述符剛剛變爲就緒狀態,它只說一遍,如果我們沒有採取行動,那麼它將不會再次告知,這種方式稱爲邊緣觸發),理論上邊緣觸發的性能要更高一些,但是代碼實現相當複雜。
epoll同樣只告知那些就緒的文件描述符,而且當我們調用epoll_wait()獲得就緒文件描述符時,返回的不是實際的描述符,而是一個代表就緒描述符數量的值,你只需要去epoll指定的一個數組中依次取得相應數量的文件描述符即可,這裏也使用了內存映射(mmap)技術,這樣便徹底省掉了這些文件描述符在系統調用時複製的開銷。
另一個本質的改進在於epoll採用基於事件的就緒通知方式。在select/poll中,進程只有在調用一定的方法後,內核纔對所有監視的文件描述符進行掃描,而epoll事先通過epoll_ctl()來註冊一個文件描述符,一旦基於某個文件描述符就緒時,內核會採用類似callback的回調機制,迅速激活這個文件描述符,當進程調用epoll_wait()時便得到通知。
用select實現多併發的socketserver
# Author: 73
import socket, select, queue
server = socket.socket()
server.bind(("localhost", 8001))
server.listen(1000)
server.setblocking(False) # 不阻塞
inputs = [server]
outputs = []
msg_dic = {}
while 1:
readable, writeable, exceptional = select.select(inputs, outputs, inputs)
for r in readable:
if r is server: # 代表一個新連接
conn, addr = server.accept()
print("來了個新連接:", conn)
inputs.append(conn) # 是因爲這個新建立的連接還沒發數據過來,現在就接收的話程序就報錯;要想實現這個客戶端發數據來的時候,server端能知道,就要讓select再監測這個conn
msg_dic[conn] = queue.Queue() # 初始化一個隊列,後面存要返回給這個客戶端的數據
else:
data = r.recv(1024)
if not data:
continue
print(data)
#r.send(data)
msg_dic[r].put(data)
outputs.append(r) # 放入返回的連接隊列裏
print("send done...")
for w in writeable: #要返回給客戶端的連接列表
data_to_client = msg_dic[w].get()
w.send(data_to_client)
outputs.remove(w) # 保證下次循環writeable的時候,不返回這個已經處理完的連接
for e in exceptional:
if e in outputs:
outputs.remove(e)
inputs.remove(e)
del msg_dic[e]
上面的功能其實有封裝好的類,可以直接拿來調用,就是selector模塊
該模塊默認用epoll,但是如果系統(比如windows)不支持epoll,那麼它就會用select
import selectors
import socket
sel = selectors.DefaultSelector()
def accept(sock, mask):
conn, addr = sock.accept() # Should be ready
print('accepted', conn, 'from', addr)
conn.setblocking(False)
sel.register(conn, selectors.EVENT_READ, read)
def read(conn, mask):
data = conn.recv(1000) # Should be ready
if data:
print('echoing', repr(data), 'to', conn)
conn.send(data) # Hope it won't block
else:
print('closing', conn)
sel.unregister(conn)
conn.close()
sock = socket.socket()
sock.bind(('localhost', 10000))
sock.listen(100)
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, accept)
while True:
events = sel.select()
for key, mask in events:
callback = key.data
callback(key.fileobj, mask)
參考文檔:
https://www.cnblogs.com/alex3714/p/4372426.html
https://www.cnblogs.com/alex3714/articles/5876749.html