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 傳輸二進位資料完全是可行的。最主要的問題在於不同機器使用不同的二進位資料格式。例如,網路二進位順序 採用的是「大端序 big-endian」,所以一個值為 1 的 16 位元整數會表示成兩個 16 進位的位元組 00 01。然而大多數常見的處理器 (x86/AMD64,ARM,RISC-V) 採用的是「小端序 little-endian」,所以相同的 1 會被表示成 01 00。(譯者注:將一個多位數的低位放在較小的位址處,高位放在較大的位址處,則稱小端序;反之則稱大端序。)
Socket 函式庫提供了用於轉換 16 位元和 32 位元整數的函式 - ntohl, htonl, ntohs, htons,其中 "n" 表示 network,"h" 表示 host,"s" 表示 short,"l" 表示 long。當網路的位元組順序和主機位元組順序相同時,這些函式不會做任何操作,但當主機的位元組順序相反時,這些函式會適當的交換位元組順序。
在現今的 64 位元機器中,二進位資料的 ASCII 表示通常會比二進位表示要小,這是因為在很多情況下,大多數整數的值為 0 或者 1。例如,字串形式的 "0" 是兩個位元組,而完整的 64 位元整數則是 8 個位元組。當然,這對固定長度的訊息來說不太適合,需要自行決定。
結束連線
嚴格來說,在關閉 socket 前,你應該使用 shutdown 函式。shutdown 函式是發送給 socket 另一端的一個提醒。根據你傳遞的引數,它可以表示「我不會再發送任何訊息了,但我仍然會持續監聽」,或者是「我不會再繼續監聽了,真讚!」。然而,大多數的 socket 函式庫或程式設計師都習慣忽略這種禮節,因為通常情況下 close 跟 shutdown(); close() 是一樣的。所以在大多數情況下,不需要再特地使用 shutdown 了。
有效使用 shutdown 的一種方式是在類似 HTTP 的交換中,用戶端發送請求後,然後使用 shutdown(1)。這告訴伺服器「這個用戶端已經發送完成,但仍可以接收」。伺服器可以通過接收「零位元組」來檢測 "EOF"。這樣它就可以確定已經接收到完整的請求。伺服器發送回覆,如果 send 成功完成,那麼用戶端確實在持續接收。
Python 更進一步地採取自動關閉的步驟,並且當 socket 被垃圾回收機制回收時,如果需要的話,他會自動執行 close。但依賴這個機制是一個非常不好的習慣,如果你的 socket 在沒有 close 的情況下消失了,那麼另一端的 socket 可能會認為你只是慢了一步,而無期限的等待。請務必 在使用完畢後使用 close 關閉你的 sockets。
Sockets 何時銷毀
使用阻塞式 socket 最糟糕的地方可能是在另一端突然強制關閉(未執行 close)的情況下會發生什麼?你的 socket 很可能會處於阻塞狀態。TCP 是一種可靠的協定,它在放棄連線之前會等待很長很長的時間。如果你正在使用執行緒,整個執行緒基本上已經無法使用。在這方面,你無法做太多事情。只要你不做一些愚蠢的事情,比如在執行阻塞式讀取時持有一個鎖,那麼執行緒並不會消耗太多資源。不要試圖終止執行緒 - 執行緒比行程更有效的部分原因是它們避免了與自動回收資源相關的開銷。換句話說,如果你確實設法終止了執行緒,整個行程可能會出現問題。
非阻塞的 Sockets
如果你已經理解了前面的內容,你已經知道了大部分關於使用 sockets 的機制的所需知識,你仍然會以非常相似的方式使用相同的函式。就這樣而已,如果你做的對,你的程式就會是近乎完美的。
在 Python 中可以使用 socket.setblocking(False) 來設定為非阻塞。在 C 的作法更為複雜(例如,你需要在 BSD 風格的 O_NONBLOCK 和幾乎沒有區別的 POSIX 風格的 O_NDELAY 之間做出選擇,這與 TCP_NODELAY 完全不同),但基本思想是一樣的,你要在建立 socket 後但在使用它之前執行此操作。(實際上,如果你願意的話,你甚至可以來回切換。)
主要的機制差異在於 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 三個列表:第一個列表包含你可能想要嘗試讀取的所有 sockets;第二個包含所有你可能想要嘗試寫入的 sockets,最後一個(通常為空)包含你想要檢查錯誤的 sockets。你應該注意,一個 socket 可以同時存在於多個列表中。select 呼叫是阻塞的,但你可以設置超時。通常這是一個明智的做法 - 除非有充分的理由,否則給它一個很長的超時(比如一分鐘)。
作為回傳,你將獲得三個列表。它們包含實際上可讀取、可寫入和出錯的 sockets。這些列表中的每一個都是你傳入的相應列表的子集(可能為空)。
如果一個 socket 在輸出的可讀列表中,你可以幾乎確定,在這個業務中我們能夠得到的最接近確定的事情是,對該 socket 的 recv 呼叫將會回傳一些內容。對於可寫列表,也是同樣的想法。你將能夠發送一些 內容。也許不是全部,但一些內容總比什麼都沒有好。(實際上,任何比較正常的 socket 都會以可寫的方式回傳 - 這只是意味者「外送網路 (outbound network)」的緩衝空間是可用的。)
如果你有一個「伺服器端」socket,請將其放在 potential_readers 列表中,如果它在可讀列表中出現,你的 accept 呼叫(幾乎可以確定)會成功。如果你建立了一個新的 socket 去 connect 到其他地方,請將它放在 potential_writers 列表中,如果它在可寫列表中出現,那麼他有可能已經連接上了。
實際上,即使是使用阻塞式 socket 的情況下,select 也很方便。這是一種判斷是否會被阻塞的方法之一 - 當緩衝區中有某些內容時, socket 會回傳為可讀。然而,這仍然無法解決判斷另一端是否完成,或者只是忙於其他事情的問題。
可移植性警告:在 Unix 上,select 同時適用於 sockets 和文件。但請不要在 Windows 上嘗試這麼做,在 Windows 上,select 只適用於 sockets。同時,請注意,在 C 語言中,許多更進階的 socket 選項在 Windows 上有不同的實現方式。實際上,在 Windows 上,我通常會使用執行緒(這非常,非常有效)與我的 sockets 一起使用。