從接觸netty以來,對網(wǎng)絡(luò)編程中的諸多I/O模型存在困惑奢方,直到最近學(xué)習(xí)python搔扁,才漸漸清晰起來。本文主要梳理一下關(guān)于傳統(tǒng)多線程模型蟋字、多路復(fù)用技術(shù)以及select、epoll模式多路復(fù)用的知識點扭勉。
多線程模型
網(wǎng)絡(luò)編程的基本模型是Client-Server模型鹊奖,也就網(wǎng)絡(luò)中兩個進(jìn)程之間相互通信,服務(wù)端提供位置信息涂炎,客戶端向服務(wù)端發(fā)起連接請求忠聚,三次握手成功建立連接之后,雙方就可以通過Socket進(jìn)行通信唱捣。
服務(wù)端:
from socket import *
from threading import Thread
def clientHandler(clientSocket, clientAddress):
"""處理客戶端連接"""
print('與客戶端:%s:%s 建立連接...' % clientAddress)
while True:
receiveMessage = clientSocket.recv(1024)
if receiveMessage:
# 將接受到的信息直接返回
print(receiveMessage.decode('utf-8'))
else:
# 如果客戶端關(guān)閉两蟀,關(guān)閉服務(wù)端連接
clientSocket.close()
print('與客戶端:%s:%s 已斷開連接...'% clientAddress)
break
def main():
# 創(chuàng)建服務(wù)端
serverSocket = socket(AF_INET, SOCK_STREAM)
# 設(shè)置服務(wù)端參數(shù)
serverSocket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
serverSocket.bind(('', 8088))
# 開始監(jiān)聽
serverSocket.listen(10)
try:
while True:
# 等待客戶端接入,此處會阻塞當(dāng)前線程震缭,直到有新的客戶端接入
clientSocket, clientAddress = serverSocket.accept()
# 成功連接后赂毯,會創(chuàng)建新的線程用來處理輸入出
serverThread = Thread(target=clientHandler, args=(clientSocket, clientAddress))
serverThread.start()
finally:
serverSocket.close()
if __name__ == '__main__':
main()
客戶端:
from socket import *
def main():
# 創(chuàng)建scoket
clientSocket = socket(AF_INET, SOCK_STREAM)
serverAddr = ('127.0.0.1', 8088)
# 連接服務(wù)端
clientSocket.connect(serverAddr)
sendMessage = input('輸入要發(fā)送的信息:')
clientSocket.send(bytes(sendMessage, 'utf-8'))
clientSocket.close()
print('客戶端已關(guān)閉')
if __name__ == '__main__':
main()
說明:
- 服務(wù)端主線程負(fù)責(zé)監(jiān)聽客戶端的連接
- 接收到客戶端請求后,便為每一個連接創(chuàng)建一個新的線程
- 處理完成后,關(guān)閉連接党涕,銷毀線程
這就是典型的一請求一應(yīng)答模式烦感,當(dāng)客戶端并發(fā)訪問量增加時,服務(wù)端線程數(shù)與客戶端數(shù)將呈1:1的關(guān)系增加膛堤,這將及其耗費系統(tǒng)的性能手趣。試想有2000個客戶端連接時,服務(wù)端將創(chuàng)建2000個線程用于處理這些連接肥荔,首先要維持這些線程就要耗費大量內(nèi)存绿渣,在做上下文切換的時候可能直接導(dǎo)致系統(tǒng)內(nèi)存耗盡或者當(dāng)前進(jìn)程宕機(jī)。那么能不能只使用少量的線程燕耿,就可以處理這些連接呢中符?
單線程非阻塞
python中可以將socket設(shè)置為非阻塞,看下面代碼:
from socket import *
serverSocket = socket(AF_INET, SOCK_STREAM)
serverSocket.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
serverSocket.bind(('', 8088))
serverSocket.listen(10)
# 將服務(wù)端負(fù)責(zé)監(jiān)聽的socket設(shè)置成非阻塞
serverSocket.setblocking(False)
# 保存新建立的連接
g_clientList = []
while True:
try:
# 將socket設(shè)置成非阻塞后缸棵,如果沒有接收到數(shù)據(jù)舟茶,會拋出異常,需要做下異常處理
clientSocket, clientAddress = serverSocket.accept()
except:
pass
else:
# 如果沒有報錯 表示成功了建立了一個新的連接
print('與客戶端:%s:%s 建立連接...' % clientAddress)
# 將新建立的socket連接設(shè)置成非阻塞堵第,并保存到列表中
clientSocket.setblocking(False)
g_clientList.append((clientSocket, clientAddress))
# 此循環(huán)主要用作輪詢列表中的socket吧凉,處理可以接收數(shù)據(jù)的socket
for clientSocket, clientAddress in g_clientList:
try:
# 如果clientsocket中沒有可接收的數(shù)據(jù),此處會拋出異常
receiveMessage = clientSocket.recv(1024)
except:
pass
else:
if receiveMessage:
print(receiveMessage.decode('utf-8'))
else:
clientSocket.close()
# 關(guān)閉連接后踏志,將該連接從列表中移除阀捅,不再做輪詢
g_clientList.remove((clientSocket, clientAddress))
print('與客戶端:%s:%s 斷開連接...' % clientAddress)
上述代碼實現(xiàn)了單線程的情況下可以處理多個連接,關(guān)鍵點有以下幾點:
- socket不再阻塞當(dāng)前線程
- 維護(hù)了一個list针余,用來存放可用的socket連接
- 輪詢存放socket的集合饲鄙,處理可讀寫的socket連接。
這就是一個簡單版的I/O多路復(fù)用實現(xiàn)圆雁。
多路復(fù)用(Multiplexing)忍级,維基百科上給出的解釋是表示在一個信道上傳輸多路信號或數(shù)據(jù)流的技術(shù)。下圖是維基百科給出的模型圖:
結(jié)合上面的單線程非阻塞例子伪朽,可以理解成多個socket連接復(fù)用一個線程轴咱。
select模式
前面的例子中,使用單線程處理多個socket連接烈涮,可以很大程度地節(jié)約創(chuàng)建線程和線程切換時帶來的系統(tǒng)性能消耗朴肺。但同樣存在一個問題,由于每次都要輪詢所有的socket連接坚洽,這將大量耗費CPU時間戈稿,而且不是所有的socket都處于就緒狀態(tài)(連接、讀讶舰、寫)鞍盗,試想輪詢了2000個連接結(jié)果只有一個scoket在收發(fā)包需了,這無疑浪費了很多CPU性能,那么有沒有方法可以得到這些可用的就緒連接橡疼,只對這些活躍的連接進(jìn)行輪詢呢援所?
這里首先要引入一個概念,叫做文件描述符(file descriptor欣除,fd)住拭,linux中內(nèi)核將所有外部設(shè)備看做一個文件來操作,對一個文件的讀寫操作會調(diào)用內(nèi)核提供的系統(tǒng)命令历帚,返回一個fd滔岳,同樣對socket的操作也會有相應(yīng) 的描述符,描述符是一個數(shù)字挽牢。
所有socket的文件描述符被放入到一個數(shù)組中谱煤,前輩們發(fā)明了一個系統(tǒng)調(diào)用select,select會依次遍歷這個數(shù)組禽拔,如果對應(yīng)的文件描述符處于就緒狀態(tài)刘离,就會返回該描述符。如果遍歷結(jié)束后睹栖,仍沒有一個可用的fd硫惕,它會讓當(dāng)前用戶進(jìn)程睡眠,等到有可用資源的時候再喚醒野来。
下面是select模式多路復(fù)用的簡單實現(xiàn):
from socket import *
from select import *
# 創(chuàng)建服務(wù)端socket恼除,并開啟監(jiān)聽
serverSocket = socket(AF_INET, SOCK_STREAM)
serverSocket.bind(('', 8088))
serverSocket.listen(10)
# 存放socket連接
inputSockets = [serverSocket]
while True:
# select會阻塞等待...
# select方法接收三個類型為列表的參數(shù):
# param1: 檢查該list中是否有socket可以接收數(shù)據(jù)
# param2: 檢查該list中是否有socket可以發(fā)送數(shù)據(jù)
# param3: 檢查該list中是否有socket發(fā)生異常
readableList, writeableList, exceptionalList = select(inputSockets, [], [])
# select方法會返回一個元組,包括:可讀的連接列表曼氛、可寫列表豁辉、異常列表
# 數(shù)據(jù)抵達(dá) 遍歷可寫列表
for soc in readableList:
# 有新的連接,握手成功后舀患,放入列表中
if soc == serverSocket:
client, address = serverSocket.accept()
print('與客戶端:%s:%s 建立連接...' % address)
inputSockets.append(client)
# 有新的數(shù)據(jù)到達(dá)
else:
receiveMessage = soc.recv(1024)
if receiveMessage:
# 如果有數(shù)據(jù)徽级,則打印該數(shù)據(jù)
print(receiveMessage.decode('utf-8'))
else:
# 如果無數(shù)據(jù),從列表中移除該連接聊浅,關(guān)閉連接
inputSockets.remove(soc)
soc.close()
print('與客戶端:%s:%s 斷開連接...' % address)
通過select調(diào)用可以讓用戶程序直接獲取可用的socket連接灰追,相比較于用程序直接輪詢所有socket連接,socket模式在系統(tǒng)內(nèi)核層面實現(xiàn)狗超,效率極高,而且select基本上所有平臺朴下。
但是...
- select單個線程可監(jiān)視的fd數(shù)量存在限制努咐,一般是1024
- 'If a file descriptor being monitored by select() is closed in another thread, the result is unspecified',如果別的線程關(guān)閉了正在被select監(jiān)聽的fd殴胧,結(jié)果將是不可預(yù)測的...
- select采用輪詢的方法渗稍,效率極低
那佩迟,怎么辦!
epoll模式
先來看代碼:
import socket
import select
# 創(chuàng)建server
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('', 8088))
server.listen(10)
# windows不支持
epoll = select.epoll()
# 將創(chuàng)建的套接字添加到epoll的事件監(jiān)聽中
epoll.register(server.fileno(), select.EPOLLIN | select.EPOLLET)
clients = {}
addresses = {}
while True:
# 用來檢測套接字可讀寫的狀態(tài)竿屹,處于可用狀態(tài)的socket會通知epoll
epoll_list = epoll.poll()
# 對事件進(jìn)行判斷
for fd, event in epoll_list:
# 如果負(fù)責(zé)監(jiān)聽的socket被激活
if fd == server.fileno():
client, address = server.accept()
print('與客戶端:%s:%s 建立連接...' % address)
# 將socket信息和address信息保存在字典中
clients[client.fileno()] = client
addresses[client.fileno()] = address
# 向epoll注冊新接入的連接
epoll.register(client.fileno(), select.EPOLLIN | select.EPOLLET)
# 可接收數(shù)據(jù)的事件报强,處理對應(yīng)連接
elif event == select.EPOLLIN:
message = clients[fd].recv(1024)
if message:
print(message.decode('utf-8'))
else:
# 從epoll中解除注冊
epoll.unregister(fd)
clients[fd].close()
print('與客戶端:%s:%s 斷開連接...' % addresses[fd])
不同于select,epoll采用事件回調(diào)的方式拱燃。socket一開始會向epoll注冊事件秉溉,如果socket變?yōu)榭捎脿顟B(tài),則會觸發(fā)事件回調(diào)碗誉,被epoll獲取召嘶。同時epoll也解決了select單個線程所能監(jiān)視的fd數(shù)量有限的問題。
無論服務(wù)端有多少個連接哮缺,epoll關(guān)心的只是那些活躍的連接弄跌,所以epoll的效率較之select也要高出很多。(打個比方尝苇,比如說考勤铛只,是每天把大家集中在一起點一下名,看誰沒來糠溜,還是直接打卡簽到快淳玩?)
總結(jié)
無論是select,還是epoll诵冒,I/O多路復(fù)用的關(guān)鍵維護(hù)了一張fd表凯肋。它把多個socket連接的阻塞,轉(zhuǎn)移到單線程如何從眾多連接中篩選出可用狀態(tài)的fd上汽馋。至于如何使用這張fd表侮东,是單線程還是交給其他的線程去處理,由具體的實際需要決定了豹芯。