套接字編程指南?

作者

Gordon McMillan

摘要

套接字幾乎無處不在,但是它卻是被誤解最嚴重的技術之一。這是一篇簡單的套接字概述。并不是一篇真正的教程 —— 你需要做更多的事情才能讓它工作起來。其中也并沒有涵蓋細節(jié)(細節(jié)會有很多),但是我希望它能提供足夠的背景知識,讓你像模像樣的開始使用套接字

套接字?

我將只討論關于 INET(比如:IPv4 地址族)的套接字,但是它將覆蓋幾乎 99% 的套接字使用場景。并且我將僅討論 STREAM(比如:TCP)類型的套接字 - 除非你真的知道你在做什么(那么這篇 HOWTO 可能并不適合你),使用 STREAM 類型的套接字將會得到比其它類型更好的表現(xiàn)與性能。我將嘗試揭開套接字的神秘面紗,也會講到一些阻塞與非阻塞套接字的使用。但是我將以阻塞套接字為起點開始討論。只有你了解它是如何工作的以后才能處理非阻塞套接字。

理解這些東西的難點之一在于「套接字」可以表示很多微妙差異的東西,這取決于上下文。所以首先,讓我們先分清楚「客戶端」套接字和「服務端」套接字之間的不同,客戶端套接字表示對話的一端,服務端套接字更像是總機接線員??蛻舳顺绦蛑荒埽ū热纾耗愕臑g覽器)使用「客戶端」套接字;網(wǎng)絡服務器則可以使用「服務端」套接字和「客戶端」套接字來會話

歷史?

目前為止,在各種形式的 IPC 中,套接字是最流行的。在任何指定的平臺上,可能會有其它更快的 IPC 形式,但是就跨平臺通信來說,套接字大概是唯一的玩法

套接字作為 Unix 的 BSD 分支的一部分誕生于 Berkeley。 它們像野火一樣在互聯(lián)網(wǎng)上傳播。 這是有充分理由的 --- 套接字與 INET 的結合讓世界各地的任何機器之間的通信變得令人難以置信的簡單(至少是與其他方案相比)。

創(chuàng)建套接字?

簡略地說,當你點擊帶你來到這個頁面的鏈接時,你的瀏覽器就已經做了下面這幾件事情:

# 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))

當連接完成,套接字可以用來發(fā)送請求來接收頁面上顯示的文字。同樣是這個套接字也會用來讀取響應,最后再被銷毀。是的,被銷毀了。客戶端套接字通常用來做一次交換(或者說一小組序列的交換)。

網(wǎng)絡服務器發(fā)生了什么這個問題就有點復雜了。首頁,服務器創(chuàng)建一個「服務端套接字」:

# 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(),所以套接字將外網(wǎng)可見。如果我們使用的是 s.bind(('localhost', 80)) 或者 s.bind(('127.0.0.1', 80)),也會得到一個「服務端」套接字,但是后者只在同一機器上可見。s.bind(('', 80)) 則指定套接字可以被機器上的任何地址碰巧連接

第二個需要注點是:低端口號通常被一些「常用的」服務(HTTP, SNMP 等)所保留。如果你想把程序跑起來,最好使用一個高位端口號(通常是4位的數(shù)字)。

最后,listen 方法的參數(shù)會告訴套接字庫,我們希望在隊列中累積多達 5 個(通常的最大值)連接請求后再拒絕外部連接。 如果所有其他代碼都準確無誤,這個隊列長度應該是足夠的。

現(xiàn)在我們已經有一個「服務端」套接字,監(jiān)聽了 80 端口,我們可以進入網(wǎng)絡服務器的主循環(huán)了:

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 種方法可以讓這個循環(huán)工作起來 - 調度一個線程來處理 客戶端套接字,或者把這個應用改成使用非阻塞模式套接字,亦或是使用 select 庫來實現(xiàn)「服務端」套接字與任意活動 客戶端套接字 之間的多路復用。稍后會詳細介紹。現(xiàn)在最重要的是理解:這就是一個 服務端 套接字做的 所有 事情。它并沒有發(fā)送任何數(shù)據(jù)。也沒有接收任何數(shù)據(jù)。它只創(chuàng)建「客戶端」套接字。每個 客戶端套接字 都是為了響應某些其它客戶端套接字 connect() 到我們綁定的主機。一旦創(chuàng)建 客戶端套接字 完成,就會返回并監(jiān)聽更多的連接請求?,F(xiàn)個客戶端可以隨意通信 - 它們使用了一些動態(tài)分配的端口,會話結束時端口才會被回收

進程間通信?

如果你需要在同一臺機器上進行兩個進程間的快速 IPC 通信,你應該了解管道或者共享內存。如果你決定使用 AF_INET 類型的套接字,綁定「服務端」套接字到 'localhost' 。在大多數(shù)平臺,這將會使用一個許多網(wǎng)絡層間的通用快捷方式(本地回環(huán)地址)并且速度會快很多

參見

multiprocessing 模塊使跨平臺 IPC 通信成為一個高層的 API

使用一個套接字?

首先需要注意,瀏覽器的「客戶端」套接字和網(wǎng)絡服務器的「客戶端」套接字是極為相似的。即這種會話是「點對點」的?;蛘咭部梢哉f 你作為設計師需要自行決定會話的規(guī)則和禮節(jié) 。通常情況下,連接 套接字通過發(fā)送一個請求或者信號來開始一次會話。但這屬于設計決定,并不是套接字規(guī)則。

現(xiàn)在有兩組用于通信的動詞。你可以使用 sendrecv ,或者你可以把客戶端套接字改成文件類型的形式來使用 readwrite 方法。后者是 Java 語言中表示套接字的方法,我將不會在這兒討論這個,但是要提醒你需要調用套接字的 flush 方法。這些是“緩沖”的文件,一個經常出現(xiàn)的錯誤是 write 一些東西,然后就直接開始 read 一個響應。如果不調用 flush ,你可能會一直等待這個響應,因為請求可能還在你的輸出緩沖中。

現(xiàn)在我來到了套接字的兩個主要的絆腳石 - sendrecv 操作網(wǎng)絡緩沖區(qū)。它們并不一定可以處理所有你想要(期望)的字節(jié),因為它們主要關注點是處理網(wǎng)絡緩沖。通常,它們在關聯(lián)的網(wǎng)絡緩沖區(qū) send 或者清空 recv 時返回。然后告訴你處理了多少個字節(jié)。 的責任是一直調用它們直到你所有的消息處理完成。

recv 方法返回 0 字節(jié)時,就表示另一端已經關閉(或者它所在的進程關閉)了連接。你再也不能從這個連接上獲取到任何數(shù)據(jù)了。你可以成功的發(fā)送數(shù)據(jù);我將在后面討論這一點。

像 HTTP 這樣的協(xié)議只使用一個套接字進行一次傳輸??蛻舳税l(fā)送一個請求,然后讀取響應。就這么簡單。套接字會被銷毀。 表示客戶端可以通過接收 0 字節(jié)序列表示檢測到響應的結束。

但是如果你打算在隨后來的傳輸中復用套接字的話,你需要明白 套接字里面是不存在 :abbr:`EOT (傳輸結束)` 的。重復一下:套接字 send 或者 recv 完 0 字節(jié)后返回,連接會中斷。如果連接沒有被斷開,你可能會永遠處于等待 recv 的狀態(tài),因為(就目前來說)套接字 不會 告訴你不用再讀取了。現(xiàn)在如果你細心一點,你可能會意識到套接字基本事實:消息必須要么具有固定長度,要么可以界定,要么指定了長度(比較好的做法),要么以關閉連接為結束。選擇完全由你而定(這比讓別人定更合理)。

假定你不希望結束連接,那么最簡單的解決方案就是使用定長消息:

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)

發(fā)送分部代碼幾乎可用于任何消息傳遞方案 —— 在 Python 中你發(fā)送字符串,可以使用 len() 方法來確定它的長度(即使它嵌入了 \0 字符),主要是接收代碼變得更復雜。(在 C 語言中,并沒有更糟糕,除非消息嵌入了 \0 字符而且你又無法使用 strlen

最簡單的改進是讓消息的第一個字符表示消息類型,由類型決定長度?,F(xiàn)在你需要兩次 recv- 第一次?。ㄖ辽伲┑谝粋€字符來知曉長度,第二次在循環(huán)中獲取剩余所有的消息。如果你決定到分界線,你將收到一些任意大小的塊,(4096 或者 8192 通常是比較合適的網(wǎng)絡緩沖區(qū)大?。?,掃描你接收到的分界符

一個需要意識到的復雜情況是:如果你的會話協(xié)議允許多個消息被發(fā)送回來(沒有響應),調用 recv 傳入任意大小的塊,你可能會因為讀到后續(xù)接收的消息而停止讀取。你需要將它放在一邊并保存,直到它需要為止。

以其長度(例如,作為5個數(shù)字字符)作為消息前綴時會變得更復雜,因為(信不信由你)你可能無法在一個 recv 中獲得所有5個字符。在一般使用時,你會僥幸避免該狀況;但是在高網(wǎng)絡負載中,除非你使用兩個 recv 循環(huán),否則你的代碼將很快中斷 —— 第一個用于確定長度,第二個用于獲取消息的數(shù)據(jù)部分。這很討厭。當你發(fā)現(xiàn) send 并不總是設法在支持搞定一切時,你也會有這種感覺。 盡管已經閱讀過這篇文章,但最終還是會有所了解!

限于篇幅,建立你的角色,(保持與我的競爭位置),這些改進將留給讀者做為練習?,F(xiàn)在讓我們繼續(xù)。

二進制數(shù)據(jù)?

通過套接字傳送二進制數(shù)據(jù)是可行的。主要問題在于并非所有機器都用同樣的二進制數(shù)據(jù)格式。比如 Motorola 芯片用兩個十六進制字節(jié) 00 01 來表示一個 16 位整數(shù)值 1。而 Intel 和 DEC 則會做字節(jié)反轉 —— 即用 01 00 來表示 1。套接字庫要求轉換 16 位和 32 位整數(shù) —— ntohl, htonl, ntohs, htons 其中的「n」表示 network,「h」表示 host,「s」表示 short,「l」表示 long。在網(wǎng)絡序列就是主機序列時它們什么都不做,但是如果機器是字節(jié)反轉的則會適當?shù)亟粨Q字節(jié)序。

在現(xiàn)今的 32 位機器中,二進制數(shù)據(jù)的 ascii 表示往往比二進制表示要小。這是因為在非常多的時候所有 long 的值均為 0 或者 1。字符串形式的 "0" 為兩個字節(jié),而二進制形式則為四個。當然這不適用于固定長度的信息。自行決定,請自行決定。

斷開連接?

嚴格地講,你應該在 close 它之前將套接字 shutdown 。 shutdown 是發(fā)送給套接字另一端的一種建議。調用時參數(shù)不同意義也不一樣,它可能意味著「我不會再發(fā)送了,但我仍然會監(jiān)聽」,或者「我沒有監(jiān)聽了,真棒!」。然而,大多數(shù)套接字庫或者程序員都習慣了忽略使用這種禮節(jié),因為通常情況下 closeshutdown(); close() 是一樣的。所以在大多數(shù)情況下,不需要顯式的 shutdown 。

高效使用 shutdown 的一種方法是在類似 HTTP 的交換中。客戶端發(fā)送請求,然后執(zhí)行 shutdown(1) 。 這告訴服務器“此客戶端已完成發(fā)送,但仍可以接收”。服務器可以通過接收 0 字節(jié)來檢測 “EOF” 。它可以假設它有完整的請求。服務器發(fā)送回復。如果 send 成功完成,那么客戶端仍在接收。

Python 進一步自動關閉,并說當一個套接字被垃圾收集時,如果需要它會自動執(zhí)行 close 。但依靠這個機制是一個非常壞的習慣。如果你的套接字在沒有 close 的情況下就消失了,那么另一端的套接字可能會無限期地掛起,以為你只是慢了一步。完成后 close 你的套接字。

套接字何時銷毀?

使用阻塞套接字最糟糕的事情可能就是當另一邊下線時(沒有 close )會發(fā)生什么。你的套接字可能會掛起。 TCP 是一種可靠的協(xié)議,它會在放棄連接之前等待很長時間。如果你正在使用線程,那么整個線程基本上已經死了。你無能為力。只要你沒有做一些愚蠢的事情,比如在進行阻塞讀取時持有一個鎖,那么線程并沒有真正消耗掉資源。 不要 嘗試殺死線程 —— 線程比進程更有效的部分原因是它們避免了與自動回收資源相關的開銷。換句話說,如果你設法殺死線程,你的整個進程很可能被搞壞。

非阻塞的套接字?

如果你已理解上述內容,那么你已經了解了使用套接字的機制所需了解的大部分內容。你仍將以相同的方式使用相同的函數(shù)調用。 只是,如果你做得對,你的應用程序幾乎是由內到外的。

在 Python 中是使用 socket.setblocking(False) 來設置非阻塞。 在 C 中的做法更為復雜(例如,你需要在 BSD 風格的 O_NONBLOCK 和幾乎無區(qū)別的 POSIX 風格的 O_NDELAY 之間作出選擇,這與 TCP_NODELAY 完全不一樣),但其思路實際上是相同的。 你要在創(chuàng)建套接字之后但在使用它之前執(zhí)行此操作。 (實際上,如果你是瘋子的話也可以反復進行切換。)

主要的機制差異是 send 、 recv 、 connectaccept 可以在沒有做任何事情的情況下返回。 你(當然)有很多選擇。你可以檢查返回代碼和錯誤代碼,通常會讓自己發(fā)瘋。如果你不相信我,請嘗試一下。你的應用程序將變得越來越大、越來越 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 三個列表:第一個包含你可能想要嘗試讀取的所有套接字;第二個是你可能想要嘗試寫入的所有套接字,以及要檢查錯誤的最后一個(通常為空)。你應該注意,套接字可以進入多個列表。 select 調用是阻塞的,但你可以給它一個超時。這通常是一件明智的事情 —— 給它一個很長的超時(比如一分鐘),除非你有充分的理由不這樣做。

作為返回,你將獲得三個列表。它們包含實際可讀、可寫和有錯誤的套接字。 這些列表中的每一個都是你傳入的相應列表的子集(可能為空)。

如果一個套接字在輸出可讀列表中,那么你可以像我們一樣接近這個業(yè)務,那個套接字上的 recv 將返回 一些內容 。可寫列表的也相同,你將能夠發(fā)送 一些內容 。 也許不是你想要的全部,但 有些東西 比沒有東西更好。 (實際上,任何合理健康的套接字都將以可寫方式返回 —— 它只是意味著出站網(wǎng)絡緩沖區(qū)空間可用。)

如果你有一個“服務器”套接字,請將其放在 potential_readers 列表中。如果它出現(xiàn)在可讀列表中,那么你的 accept (幾乎肯定)會起作用。如果你已經創(chuàng)建了一個新的套接字 connect 其他人,請將它放在 potential_writers 列表中。如果它出現(xiàn)在可寫列表中,那么它有可能已連接。

實際上,即使使用阻塞套接字, select 也很方便。這是確定是否阻塞的一種方法 —— 當緩沖區(qū)中存在某些內容時,套接字返回為可讀。然而,這仍然無助于確定另一端是否完成或者只是忙于其他事情的問題。

可移植性警告 :在 Unix 上, select 適用于套接字和文件。 不要在 Windows 上嘗試。在 Windows 上, select 僅適用于套接字。另請注意,在 C 中,許多更高級的套接字選項在 Windows 上的執(zhí)行方式不同。事實上,在 Windows 上我通常在使用我的套接字使用線程(非常非常好)。