Socket 程式設計 HOWTO¶
- 作者:
Gordon McMillan
Socket¶
我將只討論 INET(即 IPv4)Socket,但它們至少佔所有使用中 Socket 的 99%。而且我將只討論 STREAM(即 TCP)Socket——除非你真正知道自己在做什麼(在這種情況下,本 HOWTO 不適合你!),否則你將從 STREAM Socket 獲得比其他任何型別更好的行為和效能。我將嘗試澄清 Socket 的神秘之處,並提供一些關於如何使用阻塞和非阻塞 Socket 的提示。但我將從阻塞 Socket 開始談起。在處理非阻塞 Socket 之前,你需要了解它們的工作原理。
理解這些事物的部分困難在於,“socket”可以根據上下文,意味著許多微妙不同的事物。所以首先,讓我們區分“客戶端”socket(對話的端點)和“伺服器”socket(更像一個交換機操作員)。客戶端應用程式(例如你的瀏覽器)專門使用“客戶端”socket;它正在與之通訊的 Web 伺服器則同時使用“伺服器”socket 和“客戶端”socket。
歷史¶
在各種形式的IPC中,socket 是迄今為止最受歡迎的。在任何給定平臺上,可能存在其他更快的 IPC 形式,但對於跨平臺通訊,socket 幾乎是唯一的選擇。
它們是在 Berkeley 作為 BSD 版本 Unix 的一部分發明的。它們隨著網際網路像野火一樣蔓延開來。這有充分的理由——socket 與 INET 的結合使得與世界各地任意機器的通訊變得 unbelievably 容易(至少與其他方案相比)。
建立 Socket¶
粗略地說,當你點選帶你來到本頁的連結時,你的瀏覽器做了類似以下的事情
# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))
當 connect
完成後,socket s
可用於傳送頁面文字的請求。同一個 socket 將讀取回復,然後被銷燬。沒錯,被銷燬。客戶端 socket 通常只用於一次交換(或一小部分順序交換)。
Web 伺服器中發生的事情要複雜一些。首先,Web 伺服器建立一個“伺服器 socket”
# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
serversocket.listen(5)
有幾點需要注意:我們使用了 socket.gethostname()
,以便該 socket 對外部世界可見。如果使用 s.bind(('localhost', 80))
或 s.bind(('127.0.0.1', 80))
,我們仍然會有一個“伺服器”socket,但它只能在同一臺機器內可見。s.bind(('', 80))
指定該 socket 可透過機器擁有的任何地址訪問。
第二點需要注意的是:低位埠通常保留給“眾所周知”的服務(HTTP、SNMP 等)。如果你只是隨便玩玩,請使用一個較高的數字(四位數)。
最後,傳遞給 listen
的引數告訴 socket 庫我們希望它在拒絕外部連線之前,最多將 5 個連線請求(通常的最大值)排隊。如果其餘程式碼編寫得當,這應該足夠了。
現在我們有了一個“伺服器”socket,監聽 80 埠,我們可以進入 Web 伺服器的主迴圈
while True:
# accept connections from outside
(clientsocket, address) = serversocket.accept()
# now do something with the clientsocket
# in this case, we'll pretend this is a threaded server
ct = make_client_thread(clientsocket)
ct.start()
實際上,這個迴圈有三種通用工作方式:分派一個執行緒來處理 clientsocket
,建立一個新程序來處理 clientsocket
,或者重構此應用程式以使用非阻塞 socket,並透過 select
在我們的“伺服器”socket 和任何活動的 clientsocket
之間進行多路複用。稍後會詳細介紹。現在重要的是要理解這一點:這就是“伺服器”socket 所做的一切。它不傳送任何資料。它不接收任何資料。它只生成“客戶端”socket。每個 clientsocket
都是響應某些*其他*“客戶端”socket 對我們繫結的主機和埠執行 connect()
而建立的。一旦我們建立了那個 clientsocket
,我們就回到監聽更多連線。兩個“客戶端”可以自由地聊天——它們使用的是一些動態分配的埠,當對話結束時這些埠將被回收。
IPC¶
如果你需要在同一臺機器上的兩個程序之間進行快速 IPC,你應該考慮管道或共享記憶體。如果你決定使用 AF_INET socket,將“伺服器”socket 繫結到 'localhost'
。在大多數平臺上,這將繞過幾層網路程式碼,從而大大加快速度。
參見
multiprocessing
將跨平臺 IPC 整合到更高級別的 API 中。
使用 Socket¶
首先要注意的是,Web 瀏覽器的“客戶端”socket 和 Web 伺服器的“客戶端”socket 是完全相同的。也就是說,這是一場“點對點”對話。或者換句話說,*作為設計者,你必須決定對話的禮儀規則*。通常,connect
ing socket 透過傳送請求或登入資訊來開始對話。但這只是一個設計決策——它不是 socket 的規則。
現在有兩種通訊動詞集合可供使用。你可以使用 send
和 recv
,或者將你的客戶端 socket 轉換為一個檔案狀實體並使用 read
和 write
。後者是 Java 呈現其 socket 的方式。我在這裡不會討論它,除了警告你需要在 socket 上使用 flush
。這些是帶緩衝的“檔案”,一個常見的錯誤是 write
一些東西,然後 read
等待回覆。如果沒有 flush
,你可能會永遠等待回覆,因為請求可能仍然在你的輸出緩衝區中。
現在我們遇到了 socket 的主要障礙——send
和 recv
操作的是網路緩衝區。它們不一定能處理你交給它們(或期望它們)的所有位元組,因為它們的主要關注點是處理網路緩衝區。一般來說,當相關的網路緩衝區被填滿(send
)或清空(recv
)時,它們會返回。然後它們會告訴你處理了多少位元組。*你的*責任是再次呼叫它們,直到你的訊息被完全處理。
當 recv
返回 0 位元組時,這意味著另一端已經關閉(或正在關閉)連線。你將不會再收到此連線上的任何資料。永遠不會。你可能仍然可以成功傳送資料;稍後我會詳細討論這一點。
像 HTTP 這樣的協議僅用於一次傳輸的 socket。客戶端傳送請求,然後讀取回復。僅此而已。socket 被丟棄。這意味著客戶端可以透過接收 0 位元組來檢測回覆的結束。
但如果你計劃重複使用你的 socket 進行進一步傳輸,你需要認識到*socket 上沒有*EOT*。*我重複一遍:如果 socket send
或 recv
在處理 0 位元組後返回,則連線已斷開。如果連線*沒有*斷開,你可能會永遠等待 recv
,因為 socket *不會*告訴你暫時沒有更多可讀的內容。現在如果你稍微思考一下,你就會意識到 socket 的一個基本事實:*訊息必須是固定長度的*(糟糕),*或者有分隔符的*(聳肩),*或者指示它們的長度*(好得多),*或者透過關閉連線來結束*。選擇完全取決於你(但有些方式比其他方式更正確)。
假設你不想結束連線,最簡單的解決方案是固定長度的訊息
class MySocket:
"""demonstration class only
- coded for clarity, not efficiency
"""
def __init__(self, sock=None):
if sock is None:
self.sock = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
else:
self.sock = sock
def connect(self, host, port):
self.sock.connect((host, port))
def mysend(self, msg):
totalsent = 0
while totalsent < MSGLEN:
sent = self.sock.send(msg[totalsent:])
if sent == 0:
raise RuntimeError("socket connection broken")
totalsent = totalsent + sent
def myreceive(self):
chunks = []
bytes_recd = 0
while bytes_recd < MSGLEN:
chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
if chunk == b'':
raise RuntimeError("socket connection broken")
chunks.append(chunk)
bytes_recd = bytes_recd + len(chunk)
return b''.join(chunks)
這裡的傳送程式碼幾乎適用於任何訊息方案——在 Python 中你傳送字串,你可以使用 len()
來確定其長度(即使它包含嵌入的 \0
字元)。主要是接收程式碼變得更復雜。(在 C 語言中,情況也好不了多少,只是如果訊息包含嵌入的 \0
,你就不能使用 strlen
。)
最簡單的增強是使訊息的第一個字元成為訊息型別的指示符,並讓型別決定長度。現在你有兩個 recv
——第一個是為了獲取(至少)第一個字元,以便你可以查詢長度,第二個則在一個迴圈中獲取其餘部分。如果你決定走帶分隔符的路線,你將以任意塊大小接收(4096 或 8192 通常與網路緩衝區大小匹配得很好),並掃描你已收到的內容以查詢分隔符。
需要注意的一個複雜情況是:如果你的對話協議允許多個訊息背靠背傳送(沒有某種回覆),並且你將任意塊大小傳遞給 recv
,你可能會讀到後續訊息的開頭。你需要將它放在一邊並保留,直到需要它。
在訊息前加上其長度(例如,用 5 個數字字元表示)會變得更復雜,因為(信不信由你),你可能無法在一次 recv
中收到所有 5 個字元。在摸索中,你可能會僥倖成功;但在高網路負載下,除非你使用兩個 recv
迴圈——第一個確定長度,第二個獲取訊息的資料部分——否則你的程式碼會很快崩潰。很糟糕。這時你還會發現 send
並不總是能一次性發送完所有內容。儘管你已經讀過這些,但你最終還是會被它咬到!
出於節省空間、鍛鍊你的意志(並保持我的競爭地位)的考慮,這些增強留作讀者的練習。讓我們繼續進行清理工作。
二進位制資料¶
透過 socket 傳送二進位制資料是完全可行的。主要問題是並非所有機器都使用相同的二進位制資料格式。例如,網路位元組序是大端序,最高有效位元組在前,所以值為 1
的 16 位整數將是兩個十六進位制位元組 00 01
。然而,大多數常見處理器(x86/AMD64、ARM、RISC-V)是小端序,最低有效位元組在前——同樣的 1
將是 01 00
。
Socket 庫有用於轉換 16 位和 32 位整數的呼叫——ntohl, htonl, ntohs, htons
,其中“n”代表*網路*,“h”代表*主機*,“s”代表*短整型*,“l”代表*長整型*。當網路位元組序是主機位元組序時,這些函式不執行任何操作,但當機器是位元組反轉時,它們會適當地交換位元組。
在 64 位機器的時代,二進位制資料的 ASCII 表示通常比二進位制表示更小。這是因為在驚人的大部分時間裡,大多數整數的值為 0,或者可能是 1。字串 "0"
將是兩個位元組,而一個完整的 64 位整數將是 8 個位元組。當然,這與固定長度訊息不太吻合。抉擇,抉擇。
斷開連線¶
嚴格來說,在 close
一個 socket 之前,你應該對它使用 shutdown
。 shutdown
是對另一端的 socket 的一個建議。根據你傳遞給它的引數,它可以表示“我不再發送了,但我仍然監聽”,或者“我不監聽了,拜拜!”。然而,大多數 socket 庫已經非常習慣於程式設計師忽略使用這個禮節性的步驟,因此通常 close
等同於 shutdown(); close()
。所以在大多數情況下,不需要顯式的 shutdown
。
一種有效使用 shutdown
的方法是在類似 HTTP 的交換中。客戶端傳送請求,然後執行 shutdown(1)
。這告訴伺服器“此客戶端已完成傳送,但仍可接收。”伺服器可以透過接收 0 位元組來檢測“EOF”。它可以假定已收到完整的請求。伺服器傳送回覆。如果 send
成功完成,那麼確實,客戶端仍在接收。
Python 將自動關閉更進一步,並表示當 socket 被垃圾回收時,如果需要,它會自動執行 close
。但依賴這個是非常糟糕的習慣。如果你的 socket 在沒有執行 close
的情況下突然消失,另一端的 socket 可能會無限期地掛起,認為你只是慢。請在完成使用後 close
你的 socket。
當 Socket 死亡時¶
使用阻塞 socket 最糟糕的事情之一可能就是當另一端突然宕機(沒有執行 close
)時會發生什麼。你的 socket 很可能會掛起。TCP 是一種可靠的協議,它會等待很長時間才會放棄連線。如果你正在使用執行緒,整個執行緒基本上就死了。你對此束手無策。只要你沒有做蠢事,比如在執行阻塞讀取時持有鎖,執行緒實際上並沒有消耗太多資源。不要嘗試殺死執行緒——執行緒比程序更高效的部分原因在於它們避免了與資源自動回收相關的開銷。換句話說,如果你真的設法殺死了執行緒,你的整個程序很可能會被搞砸。
非阻塞 Socket¶
如果你已經理解了前面的內容,那麼你已經掌握了使用 socket 的大部分機制。你仍然會以大致相同的方式使用相同的呼叫。只是,如果你做得正確,你的應用程式幾乎會內外顛倒。
在 Python 中,你使用 socket.setblocking(False)
使其變為非阻塞。在 C 中,它更復雜(一方面,你需要選擇 BSD 風格的 O_NONBLOCK
和幾乎難以區分的 POSIX 風格的 O_NDELAY
,這與 TCP_NODELAY
完全不同),但思路完全相同。你在建立 socket 後、使用它之前執行此操作。(實際上,如果你瘋了,你可以來回切換。)
主要的機械差異在於 send
、recv
、connect
和 accept
可以在沒有做任何事情的情況下返回。你當然有多種選擇。你可以檢查返回碼和錯誤碼,並讓自己發瘋。如果你不相信我,試試看。你的應用程式會變得龐大、充滿 bug 並消耗 CPU。所以讓我們跳過那些笨拙的解決方案,把它做對。
使用 select
。
在 C 中,編寫 select
相當複雜。在 Python 中,它很簡單,但它與 C 版本足夠接近,如果你在 Python 中理解了 select
,那麼在 C 中你也不會有什麼麻煩
ready_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout)
你將三個列表傳遞給 select
:第一個包含所有你可能想嘗試讀取的 socket;第二個包含所有你可能想嘗試寫入的 socket;最後一個(通常留空)包含你想要檢查錯誤的 socket。你應該注意一個 socket 可以出現在多個列表中。select
呼叫是阻塞的,但你可以給它一個超時時間。這通常是明智的做法——給它一個相當長的超時時間(比如一分鐘),除非你有充分的理由不這樣做。
作為回報,你將得到三個列表。它們包含實際可讀、可寫和發生錯誤的 socket。每個列表都是你傳入的相應列表的一個子集(可能為空)。
如果一個 socket 在輸出的可讀列表中,那麼你可以*在這個行業中我們所能達到的*幾乎肯定地確定對該 socket 執行 recv
將返回*一些東西*。可寫列表也是同樣的想法。你將能夠傳送*一些東西*。也許不是你想要的所有,但*一些東西*總比沒有好。(實際上,任何相當健康的 socket 都會返回可寫狀態——它只是意味著出站網路緩衝區空間可用。)
如果你有一個“伺服器”socket,請將其放入 potential_readers 列表中。如果它出現在可讀列表中,你的 accept
將(幾乎肯定)成功。如果你建立了一個新的 socket 以 connect
到其他人,請將其放入 potential_writers 列表中。如果它出現在可寫列表中,你很有可能它已經連線成功。
實際上,即使是阻塞型 socket,select
也可能很有用。它是判斷你是否會阻塞的一種方法——當緩衝區中有資料時,socket 會返回可讀狀態。然而,這仍然無助於解決判斷對方是否完成或只是忙於其他事情的問題。
可移植性警告:在 Unix 上,select
對 socket 和檔案都有效。不要在 Windows 上嘗試這樣做。在 Windows 上,select
僅適用於 socket。另請注意,在 C 語言中,許多更高階的 socket 選項在 Windows 上的實現方式不同。事實上,在 Windows 上,我通常會結合線程(執行得非常好)使用我的 socket。