套接字編程指南?
- 作者
Gordon McMillan
摘要
套接字幾乎無(wú)處不在,但是它卻是被誤解最嚴重的技術(shù)之一。這是一篇簡(jiǎn)單的套接字概述。并不是一篇真正的教程 —— 你需要做更多的事情才能讓它工作起來(lái)。其中也并沒(méi)有涵蓋細節(細節會(huì )有很多),但是我希望它能提供足夠的背景知識,讓你像模像樣的開(kāi)始使用套接字
套接字?
我將只討論關(guān)于 INET(比如:IPv4 地址族)的套接字,但是它將覆蓋幾乎 99% 的套接字使用場(chǎng)景。并且我將僅討論 STREAM(比如:TCP)類(lèi)型的套接字 - 除非你真的知道你在做什么(那么這篇 HOWTO 可能并不適合你),使用 STREAM 類(lèi)型的套接字將會(huì )得到比其它類(lèi)型更好的表現與性能。我將嘗試揭開(kāi)套接字的神秘面紗,也會(huì )講到一些阻塞與非阻塞套接字的使用。但是我將以阻塞套接字為起點(diǎn)開(kāi)始討論。只有你了解它是如何工作的以后才能處理非阻塞套接字。
理解這些東西的難點(diǎn)之一在于「套接字」可以表示很多微妙差異的東西,這取決于上下文。所以首先,讓我們先分清楚「客戶(hù)端」套接字和「服務(wù)端」套接字之間的不同,客戶(hù)端套接字表示對話(huà)的一端,服務(wù)端套接字更像是總機接線(xiàn)員??蛻?hù)端程序只能(比如:你的瀏覽器)使用「客戶(hù)端」套接字;網(wǎng)絡(luò )服務(wù)器則可以使用「服務(wù)端」套接字和「客戶(hù)端」套接字來(lái)會(huì )話(huà)
歷史?
目前為止,在各種形式的 IPC 中,套接字是最流行的。在任何指定的平臺上,可能會(huì )有其它更快的 IPC 形式,但是就跨平臺通信來(lái)說(shuō),套接字大概是唯一的玩法
套接字作為 Unix 的 BSD 分支的一部分誕生于 Berkeley。 它們像野火一樣在互聯(lián)網(wǎng)上傳播。 這是有充分理由的 --- 套接字與 INET 的結合讓世界各地的任何機器之間的通信變得令人難以置信的簡(jiǎn)單(至少是與其他方案相比)。
創(chuàng )建套接字?
簡(jiǎn)略地說(shuō),當你點(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))
當連接完成,套接字可以用來(lái)發(fā)送請求來(lái)接收頁(yè)面上顯示的文字。同樣是這個(gè)套接字也會(huì )用來(lái)讀取響應,最后再被銷(xiāo)毀。是的,被銷(xiāo)毀了??蛻?hù)端套接字通常用來(lái)做一次交換(或者說(shuō)一小組序列的交換)。
網(wǎng)絡(luò )服務(wù)器發(fā)生了什么這個(gè)問(wèn)題就有點(diǎn)復雜了。首頁(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ù)端」套接字,但是后者只在同一機器上可見(jiàn)。s.bind(('', 80))
則指定套接字可以被機器上的任何地址碰巧連接
第二個(gè)需要注點(diǎn)是:低端口號通常被一些「常用的」服務(wù)(HTTP, SNMP 等)所保留。如果你想把程序跑起來(lái),最好使用一個(gè)高位端口號(通常是4位的數字)。
最后,listen
方法的參數會(huì )告訴套接字庫,我們希望在隊列中累積多達 5 個(gè)(通常的最大值)連接請求后再拒絕外部連接。 如果所有其他代碼都準確無(wú)誤,這個(gè)隊列長(cháng)度應該是足夠的。
現在我們已經(jīng)有一個(gè)「服務(wù)端」套接字,監聽(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) - 調度一個(gè)線(xiàn)程來(lái)處理 客戶(hù)端套接字
,或者把這個(gè)應用改成使用非阻塞模式套接字,亦或是使用 select
庫來(lái)實(shí)現「服務(wù)端」套接字與任意活動(dòng) 客戶(hù)端套接字
之間的多路復用。稍后會(huì )詳細介紹?,F在最重要的是理解:這就是一個(gè) 服務(wù)端 套接字做的 所有 事情。它并沒(méi)有發(fā)送任何數據。也沒(méi)有接收任何數據。它只創(chuàng )建「客戶(hù)端」套接字。每個(gè) 客戶(hù)端套接字
都是為了響應某些其它客戶(hù)端套接字 connect()
到我們綁定的主機。一旦創(chuàng )建 客戶(hù)端套接字
完成,就會(huì )返回并監聽(tīng)更多的連接請求?,F個(gè)客戶(hù)端可以隨意通信 - 它們使用了一些動(dòng)態(tài)分配的端口,會(huì )話(huà)結束時(shí)端口才會(huì )被回收
進(jìn)程間通信?
如果你需要在同一臺機器上進(jìn)行兩個(gè)進(jìn)程間的快速 IPC 通信,你應該了解管道或者共享內存。如果你決定使用 AF_INET 類(lèi)型的套接字,綁定「服務(wù)端」套接字到 'localhost'
。在大多數平臺,這將會(huì )使用一個(gè)許多網(wǎng)絡(luò )層間的通用快捷方式(本地回環(huán)地址)并且速度會(huì )快很多
參見(jiàn)
multiprocessing
模塊使跨平臺 IPC 通信成為一個(gè)高層的 API
使用一個(gè)套接字?
首先需要注意,瀏覽器的「客戶(hù)端」套接字和網(wǎng)絡(luò )服務(wù)器的「客戶(hù)端」套接字是極為相似的。即這種會(huì )話(huà)是「點(diǎn)對點(diǎn)」的?;蛘咭部梢哉f(shuō) 你作為設計師需要自行決定會(huì )話(huà)的規則和禮節 。通常情況下,連接
套接字通過(guò)發(fā)送一個(gè)請求或者信號來(lái)開(kāi)始一次會(huì )話(huà)。但這屬于設計決定,并不是套接字規則。
現在有兩組用于通信的動(dòng)詞。你可以使用 send
和 recv
,或者你可以把客戶(hù)端套接字改成文件類(lèi)型的形式來(lái)使用 read
和 write
方法。后者是 Java 語(yǔ)言中表示套接字的方法,我將不會(huì )在這兒討論這個(gè),但是要提醒你需要調用套接字的 flush
方法。這些是“緩沖”的文件,一個(gè)經(jīng)常出現的錯誤是 write
一些東西,然后就直接開(kāi)始 read
一個(gè)響應。如果不調用 flush
,你可能會(huì )一直等待這個(gè)響應,因為請求可能還在你的輸出緩沖中。
現在我來(lái)到了套接字的兩個(gè)主要的絆腳石 - send
和 recv
操作網(wǎng)絡(luò )緩沖區。它們并不一定可以處理所有你想要(期望)的字節,因為它們主要關(guān)注點(diǎn)是處理網(wǎng)絡(luò )緩沖。通常,它們在關(guān)聯(lián)的網(wǎng)絡(luò )緩沖區 send
或者清空 recv
時(shí)返回。然后告訴你處理了多少個(gè)字節。你 的責任是一直調用它們直到你所有的消息處理完成。
當 recv
方法返回 0 字節時(shí),就表示另一端已經(jīng)關(guān)閉(或者它所在的進(jìn)程關(guān)閉)了連接。你再也不能從這個(gè)連接上獲取到任何數據了。你可以成功的發(fā)送數據;我將在后面討論這一點(diǎn)。
像 HTTP 這樣的協(xié)議只使用一個(gè)套接字進(jìn)行一次傳輸??蛻?hù)端發(fā)送一個(gè)請求,然后讀取響應。就這么簡(jiǎn)單。套接字會(huì )被銷(xiāo)毀。 表示客戶(hù)端可以通過(guò)接收 0 字節序列表示檢測到響應的結束。
但是如果你打算在隨后來(lái)的傳輸中復用套接字的話(huà),你需要明白 套接字里面是不存在 :abbr:`EOT (傳輸結束)` 的。重復一下:套接字 send
或者 recv
完 0 字節后返回,連接會(huì )中斷。如果連接沒(méi)有被斷開(kāi),你可能會(huì )永遠處于等待 recv
的狀態(tài),因為(就目前來(lái)說(shuō))套接字 不會(huì ) 告訴你不用再讀取了?,F在如果你細心一點(diǎn),你可能會(huì )意識到套接字基本事實(shí):消息必須要么具有固定長(cháng)度,要么可以界定,要么指定了長(cháng)度(比較好的做法),要么以關(guān)閉連接為結束。選擇完全由你而定(這比讓別人定更合理)。
假定你不希望結束連接,那么最簡(jiǎn)單的解決方案就是使用定長(chá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)確定它的長(cháng)度(即使它嵌入了 \0
字符),主要是接收代碼變得更復雜。(在 C 語(yǔ)言中,并沒(méi)有更糟糕,除非消息嵌入了 \0
字符而且你又無(wú)法使用 strlen
)
最簡(jiǎn)單的改進(jìn)是讓消息的第一個(gè)字符表示消息類(lèi)型,由類(lèi)型決定長(cháng)度?,F在你需要兩次 recv
- 第一次?。ㄖ辽伲┑谝粋€(gè)字符來(lái)知曉長(cháng)度,第二次在循環(huán)中獲取剩余所有的消息。如果你決定到分界線(xiàn),你將收到一些任意大小的塊,(4096 或者 8192 通常是比較合適的網(wǎng)絡(luò )緩沖區大?。?,掃描你接收到的分界符
一個(gè)需要意識到的復雜情況是:如果你的會(huì )話(huà)協(xié)議允許多個(gè)消息被發(fā)送回來(lái)(沒(méi)有響應),調用 recv
傳入任意大小的塊,你可能會(huì )因為讀到后續接收的消息而停止讀取。你需要將它放在一邊并保存,直到它需要為止。
以其長(cháng)度(例如,作為5個(gè)數字字符)作為消息前綴時(shí)會(huì )變得更復雜,因為(信不信由你)你可能無(wú)法在一個(gè) recv
中獲得所有5個(gè)字符。在一般使用時(shí),你會(huì )僥幸避免該狀況;但是在高網(wǎng)絡(luò )負載中,除非你使用兩個(gè) recv
循環(huán),否則你的代碼將很快中斷 —— 第一個(gè)用于確定長(cháng)度,第二個(gè)用于獲取消息的數據部分。這很討厭。當你發(fā)現 send
并不總是設法在支持搞定一切時(shí),你也會(huì )有這種感覺(jué)。 盡管已經(jīng)閱讀過(guò)這篇文章,但最終還是會(huì )有所了解!
限于篇幅,建立你的角色,(保持與我的競爭位置),這些改進(jìn)將留給讀者做為練習?,F在讓我們繼續。
二進(jìn)制數據?
通過(guò)套接字傳送二進(jìn)制數據是可行的。主要問(wèn)題在于并非所有機器都用同樣的二進(jìn)制數據格式。比如 Motorola 芯片用兩個(gè)十六進(jìn)制字節 00 01 來(lái)表示一個(gè) 16 位整數值 1。而 Intel 和 DEC 則會(huì )做字節反轉 —— 即用 01 00 來(lái)表示 1。套接字庫要求轉換 16 位和 32 位整數 —— ntohl, htonl, ntohs, htons
其中的「n」表示 network,「h」表示 host,「s」表示 short,「l」表示 long。在網(wǎng)絡(luò )序列就是主機序列時(shí)它們什么都不做,但是如果機器是字節反轉的則會(huì )適當地交換字節序。
在現今的 32 位機器中,二進(jìn)制數據的 ascii 表示往往比二進(jìn)制表示要小。這是因為在非常多的時(shí)候所有 long 的值均為 0 或者 1。字符串形式的 "0" 為兩個(gè)字節,而二進(jìn)制形式則為四個(gè)。當然這不適用于固定長(cháng)度的信息。自行決定,請自行決定。
斷開(kāi)連接?
嚴格地講,你應該在 close
它之前將套接字 shutdown
。 shutdown
是發(fā)送給套接字另一端的一種建議。調用時(shí)參數不同意義也不一樣,它可能意味著(zhù)「我不會(huì )再發(fā)送了,但我仍然會(huì )監聽(tīng)」,或者「我沒(méi)有監聽(tīng)了,真棒!」。然而,大多數套接字庫或者程序員都習慣了忽略使用這種禮節,因為通常情況下 close
與 shutdown(); close()
是一樣的。所以在大多數情況下,不需要顯式的 shutdown
。
高效使用 shutdown
的一種方法是在類(lèi)似 HTTP 的交換中??蛻?hù)端發(fā)送請求,然后執行 shutdown(1)
。 這告訴服務(wù)器“此客戶(hù)端已完成發(fā)送,但仍可以接收”。服務(wù)器可以通過(guò)接收 0 字節來(lái)檢測 “EOF” 。它可以假設它有完整的請求。服務(wù)器發(fā)送回復。如果 send
成功完成,那么客戶(hù)端仍在接收。
Python 進(jìn)一步自動(dòng)關(guān)閉,并說(shuō)當一個(gè)套接字被垃圾收集時(shí),如果需要它會(huì )自動(dòng)執行 close
。但依靠這個(gè)機制是一個(gè)非常壞的習慣。如果你的套接字在沒(méi)有 close
的情況下就消失了,那么另一端的套接字可能會(huì )無(wú)限期地掛起,以為你只是慢了一步。完成后 請 close
你的套接字。
套接字何時(shí)銷(xiāo)毀?
使用阻塞套接字最糟糕的事情可能就是當另一邊下線(xiàn)時(shí)(沒(méi)有 close
)會(huì )發(fā)生什么。你的套接字可能會(huì )掛起。 TCP 是一種可靠的協(xié)議,它會(huì )在放棄連接之前等待很長(cháng)時(shí)間。如果你正在使用線(xiàn)程,那么整個(gè)線(xiàn)程基本上已經(jīng)死了。你無(wú)能為力。只要你沒(méi)有做一些愚蠢的事情,比如在進(jìn)行阻塞讀取時(shí)持有一個(gè)鎖,那么線(xiàn)程并沒(méi)有真正消耗掉資源。 不要 嘗試殺死線(xiàn)程 —— 線(xiàn)程比進(jìn)程更有效的部分原因是它們避免了與自動(dòng)回收資源相關(guān)的開(kāi)銷(xiāo)。換句話(huà)說(shuō),如果你設法殺死線(xiàn)程,你的整個(gè)進(jìn)程很可能被搞壞。
非阻塞的套接字?
如果你已理解上述內容,那么你已經(jīng)了解了使用套接字的機制所需了解的大部分內容。你仍將以相同的方式使用相同的函數調用。 只是,如果你做得對,你的應用程序幾乎是由內到外的。
在 Python 中是使用 socket.setblocking(False)
來(lái)設置非阻塞。 在 C 中的做法更為復雜(例如,你需要在 BSD 風(fēng)格的 O_NONBLOCK
和幾乎無(wú)區別的 POSIX 風(fēng)格的 O_NDELAY
之間作出選擇,這與 TCP_NODELAY
完全不一樣),但其思路實(shí)際上是相同的。 你要在創(chuàng )建套接字之后但在使用它之前執行此操作。 (實(shí)際上,如果你是瘋子的話(huà)也可以反復進(jìn)行切換。)
主要的機制差異是 send
、 recv
、 connect
和 accept
可以在沒(méi)有做任何事情的情況下返回。 你(當然)有很多選擇。你可以檢查返回代碼和錯誤代碼,通常會(huì )讓自己發(fā)瘋。如果你不相信我,請嘗試一下。你的應用程序將變得越來(lái)越大、越來(lái)越 Bug 、吸干 CPU。因此,讓我們跳過(guò)腦死亡的解決方案并做正確的事。
使用 select
庫
在 C 中,編碼 select
相當復雜。 在 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ě)入的所有套接字,以及要檢查錯誤的最后一個(gè)(通常為空)。你應該注意,套接字可以進(jìn)入多個(gè)列表。 select
調用是阻塞的,但你可以給它一個(gè)超時(shí)。這通常是一件明智的事情 —— 給它一個(gè)很長(cháng)的超時(shí)(比如一分鐘),除非你有充分的理由不這樣做。
作為返回,你將獲得三個(gè)列表。它們包含實(shí)際可讀、可寫(xiě)和有錯誤的套接字。 這些列表中的每一個(gè)都是你傳入的相應列表的子集(可能為空)。
如果一個(gè)套接字在輸出可讀列表中,那么你可以像我們一樣接近這個(gè)業(yè)務(wù),那個(gè)套接字上的 recv
將返回 一些內容 ??蓪?xiě)列表的也相同,你將能夠發(fā)送 一些內容 。 也許不是你想要的全部,但 有些東西 比沒(méi)有東西更好。 (實(shí)際上,任何合理健康的套接字都將以可寫(xiě)方式返回 —— 它只是意味著(zhù)出站網(wǎng)絡(luò )緩沖區空間可用。)
如果你有一個(gè)“服務(wù)器”套接字,請將其放在 potential_readers 列表中。如果它出現在可讀列表中,那么你的 accept
(幾乎肯定)會(huì )起作用。如果你已經(jīng)創(chuàng )建了一個(gè)新的套接字 connect
其他人,請將它放在 potential_writers 列表中。如果它出現在可寫(xiě)列表中,那么它有可能已連接。
實(shí)際上,即使使用阻塞套接字, select
也很方便。這是確定是否阻塞的一種方法 —— 當緩沖區中存在某些內容時(shí),套接字返回為可讀。然而,這仍然無(wú)助于確定另一端是否完成或者只是忙于其他事情的問(wèn)題。
可移植性警告 :在 Unix 上, select
適用于套接字和文件。 不要在 Windows 上嘗試。在 Windows 上, select
僅適用于套接字。另請注意,在 C 中,許多更高級的套接字選項在 Windows 上的執行方式不同。事實(shí)上,在 Windows 上我通常在使用我的套接字使用線(xiàn)程(非常非常好)。