套接字編程指南?

作者

Gordon McMillan

摘要

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

套接字?

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

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

歷史?

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

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

創(chuàng)建套接字?

簡(jiǎn)略地說(shuō),當(dāng)你點(diǎn)擊帶你來(lái)到這個(gè)頁(yè)面的鏈接時(shí),你的瀏覽器就已經(jī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))

當(dāng)連接完成,套接字可以用來(lái)發(fā)送請(qǐng)求來(lái)接收頁(yè)面上顯示的文字。同樣是這個(gè)套接字也會(huì)用來(lái)讀取響應(yīng),最后再被銷毀。是的,被銷毀了??蛻舳颂捉幼滞ǔS脕?lái)做一次交換(或者說(shuō)一小組序列的交換)。

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

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

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

最后,listen 方法的參數(shù)會(huì)告訴套接字庫(kù),我們希望在隊(duì)列中累積多達(dá) 5 個(gè)(通常的最大值)連接請(qǐng)求后再拒絕外部連接。 如果所有其他代碼都準(zhǔn)確無(wú)誤,這個(gè)隊(duì)列長(zhǎng)度應(yīng)該是足夠的。

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

進(jìn)程間通信?

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

參見(jiàn)

multiprocessing 模塊使跨平臺(tái) IPC 通信成為一個(gè)高層的 API

使用一個(gè)套接字?

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

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

現(xiàn)在我來(lái)到了套接字的兩個(gè)主要的絆腳石 - sendrecv 操作網(wǎng)絡(luò)緩沖區(qū)。它們并不一定可以處理所有你想要(期望)的字節(jié),因?yàn)樗鼈冎饕P(guān)注點(diǎn)是處理網(wǎng)絡(luò)緩沖。通常,它們?cè)陉P(guān)聯(lián)的網(wǎng)絡(luò)緩沖區(qū) send 或者清空 recv 時(shí)返回。然后告訴你處理了多少個(gè)字節(jié)。 的責(zé)任是一直調(diào)用它們直到你所有的消息處理完成。

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

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

但是如果你打算在隨后來(lái)的傳輸中復(fù)用套接字的話,你需要明白 套接字里面是不存在 :abbr:`EOT (傳輸結(jié)束)` 的。重復(fù)一下:套接字 send 或者 recv 完 0 字節(jié)后返回,連接會(huì)中斷。如果連接沒(méi)有被斷開(kāi),你可能會(huì)永遠(yuǎn)處于等待 recv 的狀態(tài),因?yàn)椋ň湍壳皝?lái)說(shuō))套接字 不會(huì) 告訴你不用再讀取了?,F(xiàn)在如果你細(xì)心一點(diǎn),你可能會(huì)意識(shí)到套接字基本事實(shí):消息必須要么具有固定長(zhǎng)度,要么可以界定,要么指定了長(zhǎng)度(比較好的做法),要么以關(guān)閉連接為結(jié)束。選擇完全由你而定(這比讓別人定更合理)。

假定你不希望結(jié)束連接,那么最簡(jiǎn)單的解決方案就是使用定長(zhǎng)消息:

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() 方法來(lái)確定它的長(zhǎng)度(即使它嵌入了 \0 字符),主要是接收代碼變得更復(fù)雜。(在 C 語(yǔ)言中,并沒(méi)有更糟糕,除非消息嵌入了 \0 字符而且你又無(wú)法使用 strlen

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

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

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

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

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

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

在現(xiàn)今的 32 位機(jī)器中,二進(jìn)制數(shù)據(jù)的 ascii 表示往往比二進(jìn)制表示要小。這是因?yàn)樵诜浅6嗟臅r(shí)候所有 long 的值均為 0 或者 1。字符串形式的 "0" 為兩個(gè)字節(jié),而二進(jìn)制形式則為四個(gè)。當(dāng)然這不適用于固定長(zhǎng)度的信息。自行決定,請(qǐng)自行決定。

斷開(kāi)連接?

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

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

Python 進(jìn)一步自動(dòng)關(guān)閉,并說(shuō)當(dāng)一個(gè)套接字被垃圾收集時(shí),如果需要它會(huì)自動(dòng)執(zhí)行 close 。但依靠這個(gè)機(jī)制是一個(gè)非常壞的習(xí)慣。如果你的套接字在沒(méi)有 close 的情況下就消失了,那么另一端的套接字可能會(huì)無(wú)限期地掛起,以為你只是慢了一步。完成后 請(qǐng) close 你的套接字。

套接字何時(shí)銷毀?

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

非阻塞的套接字?

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

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

主要的機(jī)制差異是 sendrecv 、 connectaccept 可以在沒(méi)有做任何事情的情況下返回。 你(當(dāng)然)有很多選擇。你可以檢查返回代碼和錯(cuò)誤代碼,通常會(huì)讓自己發(fā)瘋。如果你不相信我,請(qǐng)嘗試一下。你的應(yīng)用程序?qū)⒆兊迷絹?lái)越大、越來(lái)越 Bug 、吸干 CPU。因此,讓我們跳過(guò)腦死亡的解決方案并做正確的事。

使用 select 庫(kù)

在 C 中,編碼 select 相當(dāng)復(fù)雜。 在 Python 中,它是很簡(jiǎn)單,但它與 C 版本足夠接近,如果你在 Python 中理解 select ,那么在 C 中你會(huì)幾乎不會(huì)遇到麻煩:

ready_to_read, ready_to_write, in_error = \
               select.select(
                  potential_readers,
                  potential_writers,
                  potential_errs,
                  timeout)

你傳遞給 select 三個(gè)列表:第一個(gè)包含你可能想要嘗試讀取的所有套接字;第二個(gè)是你可能想要嘗試寫(xiě)入的所有套接字,以及要檢查錯(cuò)誤的最后一個(gè)(通常為空)。你應(yīng)該注意,套接字可以進(jìn)入多個(gè)列表。 select 調(diào)用是阻塞的,但你可以給它一個(gè)超時(shí)。這通常是一件明智的事情 —— 給它一個(gè)很長(zhǎng)的超時(shí)(比如一分鐘),除非你有充分的理由不這樣做。

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

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

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

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

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