近期剛學習IO多路復用的知識,還有看了django和flask框架WSGIServer的源碼,對源碼中使用的selector模塊比較好奇,也就去稍微深入看了一下個方面資料和相關視頻及底層實現,梳理出這篇文章。
一、Python中起高可用socket服務端的常用三種方式
在初始我們寫一個socket服務端, 如果要供多人同時連接使用的話,有幾大方式如在接收消息部分使用多線程,使用協程, 或者是多進程實現socket服務端 。
socket客戶端實現, 用於連接測試服務端
import socket import time sc = socket.socket() sc.connect(('127.0.0.1', 8000)) while True: sc.send(b'hello word') data = sc.recv(1024) print(data) time.sleep(1)
1)多進程實現socket服務端
import socket from multiprocessing import Process import time sc = socket.socket() sc.bind(('127.0.0.1', 8000)) sc.listen(5) sc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) def recv_data(conn): while True: data = conn.recv(1024) if data: print(data) conn.sendall(data.upper()) while True: conn, addr = sc.accept() if conn: Process(target=recv_data, args=(conn,)).start() time.sleep(1)
使用多進程實現socket服務端的優缺點
優點:解決單進程單線程無法多客戶端連接的問題
缺點:開多進程消耗的資源比較大,並且操作系統多進程數量有限制
2)多線程實現socket服務端
# 多線程socket服務端
import socket import threading import time sc = socket.socket() sc.bind(('127.0.0.1', 8000)) sc.listen(5) sc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) def recv_data(conn): while True: data = conn.recv(1024) if data: print(data) conn.sendall(data.upper()) while True: conn, addr = sc.accept() if conn: threading.Thread(target=recv_data, args=(conn,)).start() time.sleep(1)
使用多線程實現socket服務端的優缺點
優點: 可以滿足多客戶端連接,實現簡單, 比多進程更小的資源的消耗
缺點: 開多線程耗資源,且線程間的切換有性能消耗,不能無限開
3)使用協程實現socket服務端
import time import socket import gevent from gevent import monkey monkey.patch_all() sc = socket.socket() sc.bind(('127.0.0.1', 8000)) sc.listen(5) sc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) def recv_data(conn): while True: data = conn.recv(1024) if data: print(data) conn.sendall(data.upper()) while True: conn, addr = sc.accept() if conn: gevent.spawn(recv_data, conn) time.sleep(1)
使用協程實現socket服務端優缺點:
優點: 協程是微線程,多個協程在一個線程內切換,占用資源最少,並且在socket這種IO密集型的服務中效率很高,有時速度優於多線程socket實現
缺點:比起多進程和多線程實現是基本沒有缺點,唯一是無法利用多CPU,在計算密集型服務時吃力
以上三種socket服務端的實現方式都存在的缺點是,如果有1W個連接時,單次就會有1W次IO操作,會有操作系統層面的1W次系統調用,會有比較大的系統調用切換的消耗,這就引出我們的IO多路復用。
二、IO多路復用之Select、Poll、Epoll及其區別
1)Select和Poll和Epoll的用法
其實Select和Poll的區別不大,唯一區別是Select對有最大連接數限制1024這個數字是可以修改的,而Poll是基於鏈表結構的沒有最大連接數限制。
import selectors import socket select = selectors.DefaultSelector() def recv_data(conn, mask): data = conn.recv(1024) if data: print(data) conn.sendall(data.upper()) else: select.unregister(conn) conn.close() def accept(sc, mask): conn, addr = sc.accept() conn.setblocking(False) select.register(conn, selectors.EVENT_READ, recv_data) sc = socket.socket() sc.bind(('127.0.0.1', 8000)) sc.listen(5) sc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sc.setblocking(False) select.register(sc, selectors.EVENT_READ, accept) # epoll的話相當於向內核開辟一個空間放文件描述符 while True: read = select.select(timeout=1) # 相當於遍歷文件描述符 for key, mask in read: callback = key.data callback(key.fileobj, mask)
由於我們使用的python自帶的selectors模塊,代碼中 select = selectors.DefaultSelector() 會根據操作系統的不同實例化合適的Select或者Poll或者Epoll。
我們知道很多時刻操作系統的進程和線程的調度策略為: 時間片輪轉調度,也就是每個線程在時間片內被cpu調度執行,根據這基礎我們進行分析,假設我們有1w個線程並發執行(不是並行哦)這樣單位時間內就會調度操作系統內核交互1W次,也就產生了1W次IO,如果把這1W次IO變成1次IO,那性能是不是提升很多,而select和epoll就是這樣做的,它把這1W個文件描述符(或者理解成調用)放到一個數組或者鏈表中,一次傳遞給操作系統內核,然后內核內的線程去循環這個數組,去執行相應的指令,然后執行完畢后,操作系統用戶態再拿回這個文件描述符數組,然后遍歷取其中的結果,這樣就從1W次IO變成了1次IO了。
多線程下的IO模型
select和poll下IO圖解
我們先說select和epoll的優缺點:
優點: 可以減少操作系統用戶態和內核態IO的次數,統一監控多個IO操作,然后遍歷獲取結果。
缺點: 每次都要傳遞一個大的數組列表, 還有每次都要多數組列表進行遍歷獲得結果。
所以在上述缺點的情況下Epoll誕生了:
epoll會在操作系統內核中開辟一個空間,然后每次系統調用就會把新的文件描述,傳遞給內核(只傳遞一次),然后內核會開另一個線程去監控內核中的文件描述符,在有返回結果后,它會結果返回放到另一個空間(文件描述符活躍),此時用戶態只會遍歷活躍狀態的文件描述符,這樣用空間換時間效率提升,主要體現在:內核多個線程並發處理文件描述符,每次只遍歷活躍的文件描述符。
epoll IO模型
至此告一段落,后續還需補充挺多東西,如果操作系統的IO知識:
1、操作系統IO知識,什么是用戶態,內核態
2、操作系統進程線程的調度策略
3、操作系統的系統調用、中斷和異常
4、還有select函數底層實現 可以在linux中用man函數調用查看解釋
5、什么是文件描述符,操作系統中一切皆文件
等等一些列操作系統方面的知識