寫下這篇小記的原因是想記錄一下自己學習Python Socket程式設計的心路歷程。之前在中專的時間學過一些基礎的Socket程式設計,知道了一些比較基礎的內容比如基礎的socket.bind()
類似簡單方法的使用。編寫了較為基礎的應用程式,例如DNS的使用者端(能夠發出正確請求,但是解析資料沒有成功)。
這次學習呢,是藉著大專中Python網路程式設計課的契機,我決定重新學習一下之前的內容,並且將內容分析整理記錄下來。
由於這是一篇小記,因此它包含了我大量的主觀想法和猜想在其中。讀者可以通過檢視文末的知識總結來刨去我的主觀看法來獲得需要的內容。
那麼為什麼要重新深入學習Socket程式設計呢?因為在之前的學習中我發現,我的寫出的伺服器端程式往往只能服務單個使用者,而不能用於多個使用者,從老師的提醒中我知道了一個東西叫做阻塞。
一開始我也不清楚什麼是阻塞,我便有了個猜想,那既然一個Socket只能服務於一個使用者,那麼阻塞是否就是分隔多個使用者的原因呢?因為當時在我的腦海中,我認為使用者發出的請求資料是像流一般的東西,它們到達了Socket,就像一堆人要進一個門,而他們只能一個一個進,而這個門就是Socket。但當我去查閱相關內容的時候,阻塞的含義與我想象的內容不同。
那麼我們回到正題——什麼是阻塞?
阻塞的概念其實並不只是存在Socket程式設計中,但我們可以用Socket程式設計舉個例子。如同下方的程式碼,當我們建立Socket之後,conn, address = sock.accept()
,這一行,返回了兩個物件,conn
是用於在連線上傳送和接受資料而產生的新的socket
物件,而address
則是繫結到對端通訊端的地址。
當程式執行到data = conn.recv(1024)
時,此時我們作為伺服器端正在等待對端傳送內容,那麼這個等待的時候就處於阻塞狀態。只有當用戶端傳送了內容,有資料返回後,程式才能進行下去。
import socket
data = ''
ip_port = ("localhost", 9999)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0, fileno=None)
sock.bind(ip_port)
sock.listen(1)
conn, address = sock.accept()
while True:
data = conn.recv(1024)
if str(data,encoding="utf-8") == "exit\n":
break
rep = "你輸入的內容是" + str(data, encoding="utf-8")
conn.send(rep.encode("utf-8"))
上為程式碼樣例,下為Netcat
工具測試。
C:\Users\77653>chcp 65001
Active code page: 65001
C:\Users\77653>nc 127.0.0.1 9999
HelloWorld
你輸入的內容是HelloWorld
exit
踩坑點:我個人使用Win11環境,喜歡使用PowerShell的終端,此處我使用Netcat工具進行連線,在CMD下能夠正常顯示中文而在PowerShell中則不能顯示中文。原因可能是PowerShell並不支援原始位元組流。
CMD需要切換字元集為UTF-8才能正常顯示中文,chcp 65001即為切換的命令(臨時命令,如需要永久切換則需要更改登入檔,再次不多贅述。)
在建立Socket的外部巢狀一個迴圈即可完成持續建立Socket,不過同時只能服務一個使用者。
Python Socket庫提供了非阻塞Socket的功能,那麼非阻塞Socket和阻塞Socket有什麼區別呢?conn, address = sock.accept()
當執行到這一行程式碼時,程式會阻塞在這一行等待一個連線,而如果我們使用非阻塞Socket則是會報錯,並繼續向下執行,這意味著我們可以通過try...except
和迴圈來實現一個簡單的伺服器。程式碼如下。
import socket
data = ''
ip_port = ("localhost", 9999)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0, fileno=None)
sock.setblocking(False) #setblocking方法可以設定Socket型別,設定為False則為非阻塞。
sock.bind(ip_port)
sock.listen(1)
while True:
try:
conn, address = sock.accept()
conn.setblocking(False)
while True:
try:
data = conn.recv(1024)
if str(data, encoding="utf-8") == "exit\n":
conn.close()
break
rep = "你輸入的內容是" + str(data, encoding="utf-8")
conn.send(rep.encode("utf-8"))
except BlockingIOError as e:
continue
except BlockingIOError as e:
continue
這樣寫的好處在於迴圈是一直在執行的,不會阻塞在某一個方法中,我們可以在迴圈中執行其他的內容。但這並沒有解決服務多使用者的問題。接下來我們來思考如何服務多使用者。
那麼如何能夠支援多使用者呢,單個Socket只能支援一個使用者,那我們多建立幾個Socket不就好了?那我們如何管理多個Socket呢?有兩種方法,多執行緒或使用select庫。
它可以檢查檔案描述符的讀寫情況,因此我們可以利用它來管理我們的Socket,Socket本質上也屬於檔案,所以也有檔案描述符。具體的程式碼如下。
import select
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('', 8888))
server_socket.listen(5)
print("Listening on port 8888")
read_list = [server_socket]
while True:
readable, writable, errored = select.select(read_list, [], [])
for s in readable:
if s is server_socket:
client_socket, address = server_socket.accept()
read_list.append(client_socket)
print("Connection from", address)
else:
data = s.recv(1024)
if data:
s.send(data)
else:
s.close()
read_list.remove(s)
首先我們在上面的程式碼定義了一個read_list
,並將server_socket放入其中。
select.select()
是程式中的關鍵函數,它需要三個可等待物件的可迭代物件作為引數,然後返回三個列表,分別是可讀列表、可寫列表、錯誤列表。它的作用是檢查檔案描述符的狀態,在不設定可選引數時,它是阻塞的,當出現可讀的檔案描述符時阻塞結束。
那麼server_socket.listen(5)
執行後,程式開始監聽埠,隨後在readable, writable, errored = select.select(read_list, [], [])
阻塞,那麼當我們連線到埠後,server_socket變為可寫狀態,程式將繼續執行。
那麼我們建立的server_socket變為可寫狀態,程式進入到client_socket, address = server_socket.accept()
,這裡我們獲得了client_socket,並被加入了read_list,程式繼續執行回到readable, writable, errored = select.select(read_list, [], [])
阻塞,如果使用者端開始傳送資料,那麼client_socket變為可讀狀態,阻塞結束,client_socket被新增到readable中,進行資料的互動。如果server_socket又收到了一個連線,阻塞取消,將繼續上面client_socket的過程。
此處十分建議自行偵錯程式!
下面是select官方檔案對方法的描述。
select.select(_rlist_, _wlist_, _xlist_[, _timeout_])[]
This is a straightforward interface to the Unix `select()` system call. The first three arguments are iterables of ‘waitable objects’: either integers representing file descriptors or objects with a parameterless method named fileno() returning such an integer:
- _rlist_: wait until ready for reading
- _wlist_: wait until ready for writing
- _xlist_: wait for an 「exceptional condition」 (see the manual page for what your system considers such a condition)
那麼除了使用select方法之外,我們還可以通過多執行緒的方法來控制Socket。以下是一個簡單的多執行緒範例。
import socket
import threading
def user_socket(usersocket):
data = b''
while str(data, encoding="utf-8") != "exit\n":
data = usersocket.recv(1024)
usersocket.send(data)
usersocket.close()
server_address = ('localhost', 9999)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(server_address)
server_socket.listen(1)
print("Listening on port 9999.")
while True:
conn, address = server_socket.accept()
clientsocket = threading.Thread(target=user_socket, args=[conn])
clientsocket.start()
這裡的有一個小小的坑,當我在使用clientsocket = threading.Thread(target=user_socket, args=(conn,))
建立執行緒時,使用了一個偷懶的辦法,就是直接寫target=user_socket(conn)
,這樣是萬萬不可的,這樣會導致程式直接開始呼叫user_socket
函數,並阻塞在這個函數,而原本執行緒是不阻塞的,會導致一系列問題,其次是args=(conn,)
這裡傳入的必須是一個可迭代引數(可以是列表也可以是陣列),但是如果傳入的是args=(conn)
則會產生錯誤,可能只有單個元素的元組被直接認定為Socket物件而不是可迭代物件了。
文章涉及的到的內容如下圖所示。
參考文獻如下: