套接字程式設計 HOWTO¶
- 作者:
Gordon McMillan
套接字¶
我只會討論 INET(即 IPv4)套接字,但它們至少佔所用套接字的 99%。我只會討論 STREAM(即 TCP)套接字 - 除非您真的知道自己在做什麼(在這種情況下,本 HOWTO 不適合您!),否則您將從 STREAM 套接字獲得比其他任何東西更好的行為和效能。我將嘗試澄清套接字是什麼的奧秘,以及一些關於如何使用阻塞和非阻塞套接字的提示。但我將從討論阻塞套接字開始。在處理非阻塞套接字之前,您需要了解它們的工作原理。
理解這些東西的部分問題在於,“套接字”在不同的上下文中可能意味著許多略有不同的東西。因此,首先,讓我們區分“客戶端”套接字(對話的端點)和“伺服器”套接字(更像是交換機操作員)。客戶端應用程式(例如您的瀏覽器)專門使用“客戶端”套接字;與之對話的 Web 伺服器同時使用“伺服器”套接字和“客戶端”套接字。
歷史¶
在各種形式的 IPC 中,套接字是迄今為止最流行的。在任何給定的平臺上,可能還有其他形式的 IPC 更快,但對於跨平臺通訊,套接字幾乎是唯一的選擇。
它們是在伯克利作為 Unix 的 BSD 版本的一部分發明的。它們隨著網際網路的傳播而迅速傳播。這有很好的理由 - 套接字與 INET 的結合使得與世界各地任意機器的通訊變得難以置信地容易(至少與其他方案相比)。
建立套接字¶
粗略地說,當您點選將您帶到此頁面的連結時,您的瀏覽器執行了類似以下的操作
# 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
完成時,套接字 s
可用於傳送頁面文字的請求。同一個套接字將讀取回復,然後被銷燬。沒錯,銷燬。客戶端套接字通常僅用於一次交換(或一小組順序交換)。
Web 伺服器中發生的事情有點複雜。首先,Web 伺服器建立一個“伺服器套接字”
# 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()
,以便套接字對外部世界可見。如果我們使用 s.bind(('localhost', 80))
或 s.bind(('127.0.0.1', 80))
,我們仍然會有一個“伺服器”套接字,但該套接字僅在同一臺機器內可見。s.bind(('', 80))
指定該套接字可透過機器碰巧擁有的任何地址訪問。
第二件需要注意的事情:低埠號通常保留給“眾所周知”的服務(HTTP、SNMP 等)。如果您在玩遊戲,請使用一個不錯的較大的數字(4 位數)。
最後,listen
的引數告訴套接字型檔,我們希望它在拒絕外部連線之前,最多排隊 5 個連線請求(正常最大值)。如果其餘程式碼編寫正確,那應該足夠了。
現在我們有了一個在埠 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 = client_thread(clientsocket)
ct.run()
實際上,此迴圈有 3 種通用工作方式 - 分派一個執行緒來處理 clientsocket
,建立一個新程序來處理 clientsocket
,或者重構此應用程式以使用非阻塞套接字,並在我們的“伺服器”套接字和任何活動 clientsocket
之間使用 select
進行多路複用。稍後會詳細介紹。現在需要理解的重要一點是:這是“伺服器”套接字所做的全部事情。它不傳送任何資料。它不接收任何資料。它只是產生“客戶端”套接字。每個 clientsocket
都是為了響應一些其他“客戶端”套接字對我們繫結的主機和埠執行 connect()
而建立的。一旦我們建立了 clientsocket
,我們就會回到監聽更多連線。兩個“客戶端”可以自由聊天 - 它們使用一些動態分配的埠,這些埠將在對話結束後回收。
IPC¶
如果您需要在同一臺機器上的兩個程序之間進行快速 IPC,則應研究管道或共享記憶體。如果您決定使用 AF_INET 套接字,請將“伺服器”套接字繫結到 'localhost'
。在大多數平臺上,這將繞過幾層網路程式碼,並且速度會快得多。
另請參閱
multiprocessing
將跨平臺 IPC 整合到更高級別的 API 中。
使用套接字¶
首先要注意的是,Web 瀏覽器的“客戶端”套接字和 Web 伺服器的“客戶端”套接字是相同的。也就是說,這是一個“對等”對話。或者換句話說,作為設計者,您必須決定對話的禮儀規則。通常,connect
連線的套接字透過傳送請求或註冊開始對話。但這是一個設計決策 - 而不是套接字的規則。
現在有兩種動詞可用於通訊。您可以使用 send
和 recv
,或者可以將客戶端套接字轉換為類似檔案的野獸並使用 read
和 write
。後者是 Java 提供其套接字的方式。我在這裡不打算討論它,除非警告您需要在套接字上使用 flush
。這些是緩衝的“檔案”,一個常見的錯誤是 write
某些內容,然後 read
以獲取回覆。如果沒有 flush
,您可能會永遠等待回覆,因為請求可能仍然在您的輸出緩衝區中。
現在我們遇到了套接字的主要障礙 - send
和 recv
在網路緩衝區上執行。它們不一定處理您交給它們(或期望它們)的所有位元組,因為它們的主要重點是處理網路緩衝區。通常,當相關的網路緩衝區被填充 (send
) 或清空 (recv
) 時,它們會返回。然後,它們會告訴您它們處理了多少位元組。您有責任再次呼叫它們,直到您的訊息被完全處理。
當 recv
返回 0 位元組時,表示另一側已關閉(或正在關閉)連線。您將不會在此連線上收到任何更多資料。永遠。您可能能夠成功傳送資料;稍後我會詳細介紹。
像 HTTP 這樣的協議僅使用套接字進行一次傳輸。客戶端傳送請求,然後讀取回復。就是這樣。套接字被丟棄。這意味著客戶端可以透過接收 0 位元組來檢測回覆的結尾。
但是,如果您計劃重複使用套接字進行進一步傳輸,則需要意識到套接字上沒有 EOT。我再說一遍:如果套接字 send
或 recv
在處理 0 位元組後返回,則連線已斷開。如果連線沒有斷開,您可能會永遠等待 recv
,因為套接字不會告訴您(現在)沒有更多內容要讀取。現在,如果您稍微考慮一下,您就會意識到套接字的基本真理:訊息必須是固定長度(呸),或被分隔(聳聳肩),或指示它們有多長(好得多),或透過關閉連線結束。選擇完全取決於您(但某些方式比其他方式更正確)。
假設您不想結束連線,最簡單的解決方案是固定長度的訊息
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
並不總是能夠一次性處理完所有內容的時候。儘管您已經閱讀過此內容,但最終您還是會被它坑到!
為了節省空間,鍛鍊您的性格(並保持我的競爭地位),這些改進留給讀者作為練習。讓我們繼續清理。
二進位制資料¶
在套接字上傳送二進位制資料是完全有可能的。主要問題是並非所有機器都使用相同的二進位制資料格式。例如,網路位元組序是大端位元組序,最高有效位元組在前,因此值為 1
的 16 位整數將是兩個十六進位制位元組 00 01
。但是,大多數常見的處理器(x86/AMD64、ARM、RISC-V)都是小端位元組序,最低有效位元組在前 - 同一個 1
將是 01 00
。
套接字型檔具有用於轉換 16 位和 32 位整數的呼叫 - ntohl, htonl, ntohs, htons
,其中 “n” 表示網路,“h” 表示主機,“s” 表示短,“l” 表示長。如果網路位元組序與主機位元組序相同,這些呼叫將不執行任何操作,但是如果機器的位元組序相反,則這些呼叫會適當地交換位元組順序。
在當今的 64 位機器中,二進位制資料的 ASCII 表示形式通常比二進位制表示形式更小。這是因為令人驚訝的是,大多數時候,大多數整數的值為 0,或者可能為 1。字串 "0"
將是兩個位元組,而完整的 64 位整數將是 8 個位元組。當然,這與固定長度的訊息不太匹配。決策,決策。
斷開連線¶
嚴格來說,您應該在 close
套接字之前對其使用 shutdown
。shutdown
是對另一端套接字的建議。根據您傳遞的引數,它可以表示“我不會再發送任何東西,但我仍然會監聽”,或者“我不監聽了,趕緊滾蛋!”。但是,大多數套接字型檔都已經習慣了程式設計師忽略使用此禮節,因此通常 close
與 shutdown(); close()
相同。因此,在大多數情況下,不需要顯式 shutdown
。
有效使用 shutdown
的一種方法是在類似 HTTP 的交換中。客戶端傳送請求,然後執行 shutdown(1)
。這會告訴伺服器“此客戶端已完成傳送,但仍可以接收。”伺服器可以透過接收 0 位元組來檢測“EOF”。它可以假設它擁有完整的請求。伺服器傳送回覆。如果 send
成功完成,則說明客戶端確實仍在接收。
Python 將自動關閉更進一步,並表示當套接字被垃圾回收時,如果需要,它將自動執行 close
。但是依賴這一點是一個非常不好的習慣。如果您的套接字在沒有執行 close
的情況下就消失了,另一端的套接字可能會無限期地掛起,認為您只是速度很慢。請在您完成操作後 close
您的套接字。
套接字死亡時¶
使用阻塞套接字可能最糟糕的事情是當另一端硬關閉(沒有執行 close
)時會發生什麼。您的套接字很可能會掛起。TCP 是一種可靠的協議,它會等待很長時間才會放棄連線。如果您正在使用執行緒,則整個執行緒實際上已經死亡。您對此無能為力。只要您沒有做任何愚蠢的事情,例如在執行阻塞讀取時持有鎖,該執行緒實際上並沒有消耗太多資源。不要嘗試殺死該執行緒 - 執行緒比程序更高效的原因之一是它們避免了與資源自動回收相關的開銷。換句話說,如果您確實設法殺死了該執行緒,則您的整個程序很可能會崩潰。
非阻塞套接字¶
如果您理解了前面的內容,您已經瞭解了使用套接字的大部分機制。您仍然會以大致相同的方式使用相同的呼叫。只是,如果您做得對,您的應用程式將幾乎是內外顛倒的。
在 Python 中,您可以使用 socket.setblocking(False)
使其變為非阻塞。在 C 語言中,它更復雜(一方面,您需要在 BSD 風格的 O_NONBLOCK
和幾乎無法區分的 POSIX 風格的 O_NDELAY
之間進行選擇,這與 TCP_NODELAY
完全不同),但它是完全相同的想法。您在建立套接字後但在使用套接字之前執行此操作。(實際上,如果您瘋了,您可以來回切換。)
主要機制區別在於 send
、recv
、connect
和 accept
可能會在沒有執行任何操作的情況下返回。您(當然)有許多選擇。您可以檢查返回程式碼和錯誤程式碼,並且通常會把自己逼瘋。如果您不相信我,請嘗試一下。您的應用程式將變得龐大、有錯誤並佔用 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
傳遞三個列表:第一個列表包含您可能想要嘗試讀取的所有套接字;第二個列表包含您可能想要嘗試寫入的所有套接字,最後一個列表(通常留空)包含您想要檢查是否有錯誤的套接字。您應該注意,一個套接字可以出現在多個列表中。select
呼叫是阻塞的,但您可以為其設定超時。這通常是明智之舉 - 給它一個很好的長超時(例如一分鐘),除非您有充分的理由不這樣做。
作為回報,您將獲得三個列表。它們包含實際可讀、可寫和有錯誤的套接字。這些列表中的每一個都是您傳入的相應列表的子集(可能為空)。
如果套接字在輸出的可讀列表中,您可以非常確定該套接字上的 recv
將返回某些內容。可寫列表的概念相同。您將能夠傳送某些內容。也許不是您想要的所有內容,但某些內容總比沒有好。(實際上,任何執行良好的套接字都會作為可寫返回 - 這僅表示有可用的出站網路緩衝區空間。)
如果您有一個“伺服器”套接字,請將其放入 potential_readers 列表中。如果它出現在可讀列表中,則您的 accept
將(幾乎可以肯定地)工作。如果您建立了一個新的套接字以 connect
到其他人,請將其放入 potential_writers 列表中。如果它出現在可寫列表中,則您有很大的機會它已連線。
實際上,即使使用阻塞套接字,select
也很方便。這是確定您是否會阻塞的一種方法 - 當緩衝區中有內容時,套接字會作為可讀返回。但是,這仍然無助於解決確定另一端是否完成,或者只是忙於其他事情的問題。
可移植性警告:在 Unix 系統上,select
可以同時用於套接字和檔案。不要在 Windows 上嘗試這樣做。在 Windows 上,select
僅適用於套接字。 還要注意,在 C 語言中,許多更高階的套接字選項在 Windows 上的實現方式是不同的。實際上,在 Windows 上,我通常將執行緒(效果非常好)與我的套接字一起使用。