Python HOWTOs 官方文件:Socket 程式設計

高世界發表於2015-08-03

摘要

幾乎所有地方都用到了sockets,但它們可能是被嚴重誤解的技術之一。本文是關於Sockets的概述。它並不是一篇教程——要讓sockets執行起來,你仍舊需要做點工作。本文並沒有涵蓋全部的要點(而這樣的要點有很多),但我希望它可以給你足夠的背景知識,讓你能像樣地使用sockets。

Sockets

我只打算談談INET(比如IPv4)sockets,但是 99%使用中的sockets都是它。而且我只談流(比如TCP)——除非你真的知道你在幹什麼(這樣的話本HOWTO不適合你啦),使用流socket要比別的更穩定,效能更好。我將揭開socket是什麼的神祕面紗、還有關於如何使用阻塞和非阻塞sockets的提示。但是,我會先從阻塞sockets開始談起,在處理非阻塞sockets之前,你得知道它們(阻塞sockets)是如何工作的。

理解這些事情麻煩之一是,根據不同上下文,socket可以代表很多略有不同的東西。所以首先,我們們先區分一下“客戶端”socket——會話的一個終端,和“伺服器”socket,(伺服器socket)更像一個接線員。客戶端應用程式(比如說瀏覽器)只使用“客戶端”sockets;web伺服器在通訊時使用“伺服器”sockets和客戶端sockets這兩者。

歷史

在各種各樣的IPC裡,sockets是目前最流行的。對於任意指定平臺,可能有其他的IPC更快,但對跨平臺通訊而言,sockets是唯一的一個。

作為BSD風格的Unix的一部分,它們被創造於伯克利。它們像烈火般蔓延般在網際網路上傳播。因為——和INET的結合使得世界上任意機器通訊變得難以置信地簡單(至少跟其他方案比)。

建立一個socket

大體來講,當你點選了把你帶到這個頁面的連結時,你的瀏覽器做了類似以下的事:

連線完成後,請求頁面的文字時就可以使用sockets傳送請求了。接著它讀取響應,然後銷燬。對,就是銷燬。客戶端sockets一般只用來做一次資料交換(或一個少量的有序的資料交換)。

web伺服器那邊更復雜些。首先,web伺服器建立一個”伺服器socket”:

有幾件事要注意:我們使用socket.gethostname(),這樣外面就能訪問到socket了。如果我們用的是s.bind(('localhost', 80))s.bind('127.0.0.1', 80),我們仍然得到一個“伺服器”socket,但它只能在同一臺機器上訪問它了。s.bind(('', 80))意味著socket可以被這臺機器擁有的任意地址訪問。

第二個要注意的是:小數值的埠號一般留給“眾所周知”的服務(HTTP, SNMP等)。如果你隨便玩玩,用一個更好的大數值吧(4位)。

最後,傳給listen的引數告訴socket庫,在拒絕外面的連線之前,我們想讓它最多有5個連線請求(通常最多就這麼大)佇列。如果後面的程式碼寫得正確,應該就夠了。

現在我們有了「伺服器」socket了,監聽在80埠,我們可以進入web伺服器主迴圈了:

實際上在這個迴圈裡有3種通用做法 —— 分發一個執行緒來處理clientsocket,建立一個新程式來處理clientsocket,或者重構本應用,使用非阻塞sockets,然後用select多路複用”伺服器”socket和活動的clientsockets。以後再詳細說這個。現在要理解一個要點:“伺服器”socket只做這件事。它不傳送任何資料。也不接收任何資料。它只生產客戶端sockets。每當別的客戶端socket使用connect()連線我們繫結的主機和埠時,都會生成一個clientsocket。一生成clientsocket,我們就返回去監聽更多的連線。兩個”客戶端”總是可以通訊—— 它們用的動態分配的埠會在通訊結束時被回收掉。

程式間通訊(IPC)

如果你需要在同一臺機器上快速地程式間通訊,你應該看一下管道或者共享記憶體。如果你確實想用AF_INET sockets,那就把”伺服器”socket繫結到’localhost’。在大多數平臺上,這麼做會繞過很多網路層,從而變快很多。

參見:multiprocessing把跨平臺程式間通訊封裝成了更高階別的API。

使用Socket

首先要注意到的事情是,web瀏覽器的”客戶端”socket和web伺服器的”客戶端”socket是同一個東西。也就是說,這是一個對等網路(p2p)通訊。或者換句話說,作為一個設計者,你必須決定通訊的規則。通常,連線socket通過傳送請求或登入來啟動通訊。但這是你設計的 —— 不是sockets的規定。

目前通訊有兩套動作可用。你可以使用sendrecv,或者把客戶端socket轉成類似檔案的東西,然後使用readwrite。Java給它的socket提供的就是後一種方式。在這我不打算講它,但是要提醒你,你需要對sockets使用flush。sockets帶有是緩衝的”檔案”,一個常見的錯誤就是寫入了一些東西,然後去讀取響應。但是如果沒有flush,你可能要永遠地等下去了,因為請求可能還在輸出緩衝裡。

現在我們們看看sockets的主要難點吧 —— 網路緩衝的sendrecv操作。它們並不一定處理你傳遞給它們(或期望從其得到)的所有的位元組,因為它們的注意力主要集中在處理網路緩衝。通常,當分配的網路緩衝被填充(send)了或空(recv)了,它們就會返回。告訴你處理了多少位元組。當訊息被完全處理後你需要再次呼叫它們。

recv返回0位元組時,意味著另一端已經關閉(或者正在關閉)了連線。從這個連線上你再也接收不到資料了。但你可能可以成功傳送資料;等下我會詳細講這點。

像HTTP這樣的協議每次傳輸都只使用一個socket。客戶端傳送請求,然後讀取回復。就這樣。然後丟棄socket。就是說客戶端接收到0位元組就知道回覆結束了。

但是,如果你打算在以後的傳輸中重用socket,你就得知道scoket裡是沒有EOT的。我再重複一遍:如果socket sendrecv返回0位元組,那這個連線就斷開了。如果連線沒有斷開,就會永遠的等下去,因為socket不會告訴你沒有東西可讀(目前)。現在如果你多想一下,你就會發現一個基本的事實:訊息必須是固定長度的(呸),或帶分隔符的(聳肩),或指示長度(好多啦),或者當連線關閉時結束。任你選擇,(但有的方式比別的好)。

假設你不想關閉連線,最簡單的解決方案是使用固定長度的訊息:

這裡的傳送程式碼適用於任何訊息模式 —— 使用Python傳送字串,可以使用len()來判斷字串長度(甚至當字串包含字元時)。多數情況下接收程式碼比較複雜。(使用C的話,不會太壞,除了當訊息包含時你不能使用strlen

最簡單的改進是讓訊息的第一個字元表示訊息型別,並讓型別來決定長度。現在你需要進行兩次recv了 —— 首先(至少)要獲取第一個字元,這樣你就能知道長度了,然後迴圈獲取剩下的。如果你打算使用分隔符,就得使用一個大小任意的塊來接收資料,(4096或8192通常比較適合網路緩衝大小),然後從接收到的資料裡搜尋分隔符。

有個麻煩的事要注意:如果你的通訊協議允許連續傳送多個訊息(不需要某種回覆),然後傳任意大的塊給recv,你可能會讀到下一個訊息的頭。你必須把它先存起來,直到需要用到它的時候再使用。

在訊息前加個表示它的長度(就是說,5個數字型別的字元)的字首更復雜些,因為(信不信由你)一次recv可能不能全部讀夠5字元。在測試時,你可能能僥倖避免;但在網路繁忙時,你的程式碼很快就會掛掉,除非你使用兩個recv迴圈 —— 第一個用來決定長度,第二個讀取訊息的資料部分。好“噁心”。同樣“噁心”的是,你會發現send也不能一次性解決。儘管你已經讀了本文,最後還會在這上面栽跟頭的。

為了節省篇幅,塑造你的人格,(並且保持我自己的競爭地位),這些改進會作為練習留給讀者解決。讓我們繼續。

二進位制資料

完全可以通過socket傳送二進位制資料。主要的問題在於,不是所有的機器都使用相同的二進位制格式。舉個例子,摩托羅拉的晶片使用兩個十六進位制位元組00 01來表示16位的整數1。然而,英特爾和DEC,位元組就反過來了 —— 同樣是1就用01 00表示。socket庫必須呼叫ntohl, htonl, ntohs, htons把轉換16和32位整數。這裡的”n”表示網路,”h”表示主機,”s”表示短整型,”l”表示長整型。當網路位元組序和主機位元組序相同時,它們什麼也不做,否則,它們就相應的交換位元組。

對於現如今的32位機器,使用ascii表示二進位制資料通常比二進位制表示法要小。這是因為在資料傳輸的大量時間裡,資料流的內容要麼是0,要麼是1。用字元表示“0”需要兩個位元組,而用二進位制表示要4個位元組。當然,這種情況對於固定長度的訊息並不適用。所以在選擇資料表示法時一定要好好考慮.

斷開連線

嚴格來說,在關閉socket之前,你應該先shutdown它。shutdown就是給另一端的socket一個通知。它可以表示“我不會再傳送資料啦,但我還在接收呢”,或者“我不在接收啦,解放啦!”,取決於傳遞給它的引數。然而,大多數socket庫,對程式設計師忽略這個禮節已經很習慣了,通常close就跟先shutdown(); close()一樣。所以,在大多數情況下,一個明確的shutdown就不需要了。

有效的使用shutdown的一種方式是在類HTTP通訊中。客戶端傳送一個請求,然後呼叫shutdown(1)。這樣就告訴伺服器“這個客戶端已經傳送結束了,但還在接收呢。”了。伺服器可以通過接收到0位元組來判斷“EOF”。它(伺服器)就可以認為它(客戶端)已經完成了請求。然後伺服器傳送一個回覆。如果成功傳送完成後,客戶端實際上仍然在接收。

Python把這個自動shutdown的傳統更進一步,也就是說,當一個socket被垃圾回收時,如果需要,它會自動close。但依賴這個是個非常壞的習慣。如果socket沒有呼叫close就消失了,另一端的socket就會一直掛起,它會認為你只是變慢了而已。當結束時請close掉socket。

當sockets掛掉的時候

可能使用阻塞socket最壞的事就是遇到另一端的socket掛了(沒有呼叫close)。你的socket就很可能被掛起。TCP是可靠的協議,它會等待很久,直到放棄了這個連線。如果你是使用執行緒,整個執行緒就死了。你幫不了什麼忙。只要你沒有做什麼蠢事,比如在阻塞讀的時候鎖,執行緒就不會消耗太多的資源。不要嘗試去殺死執行緒——部分原因是,執行緒比程式高效,執行緒避免了分配自動回收的資源的開銷。換句話說,如果你設法去結束執行緒,你的整個程式可能會被弄糟。

非阻塞socket

如果你已經理解了前面說的,你就知道了使用socket的原理。你還是以非常相似的方式去呼叫相同的函式。事實上,if you do it right, your app will be almost inside-out.

在Python裡,要用socket.setblocking(0)來設定非阻塞。在C裡,更復雜了,(首先,你要從BSD風格的O_NONBLOCK和幾乎難以分辨的Posix風格的O_NDELAY選擇,O_NDELAYTCP_NODELAY完全不同),但它的原理一致。你要在建立socket之後,使用之前做這件事。(實際上,如果你已經抓狂了,你可以轉回去再看看。)

主要的區別是,send, recv, connectaccept會在未完成前返回。你(當然)有很多選擇。你可以檢查返回值和錯誤碼,一般這樣做會讓你抓狂的。不信你找個時間試試。你的應用會變得越來越臃腫,bug不斷,還浪費CPU。所以,我們們跳過這個愚蠢的方案用正確的吧。

那就是用select。

在C裡,使用select相當複雜。在Python裡,它是塊甜點,但它跟C版本的很像,如果你理解了Python裡的select,在C裡你也不會有太大困難:

傳給select三個列表:第一個包含你想要讀的所有socket;第二個包含你想要寫的所有socket;最後一個(通常置空)包含那些你想要檢查錯誤的socket。應當注意,一個socket可以在多個列表裡。select呼叫是阻塞的,但可以給它一個超時設定。一般明智的做法是——給它一個合理長的超時時間(比如一分鐘),除非有個更好的原因讓你不這樣做。

在返回值裡,就能取到三個列表啦。它們包含了確實可讀,可寫和出錯的socket。每一個列表(可能為空)都是相應傳入的列表的子集。

如果有個socket在輸出的可讀列表中,你幾乎可以肯定對這個socket呼叫recv會返回些東西。同理可證可寫列表可以send些東西。也許不能recvsend你想要的全部,但聊勝於無。(實際上,任何正常的socket將作為可寫的socket被返回——這隻表示出口網路緩衝空間是可用的。)

如果你有一個“伺服器”socket,把它放入可能可讀列表裡。如果返回的可讀列表中有它,accept(幾乎必然)可成功呼叫。如果你建立了一個連線別人的新的socket,把它放入可能可寫列表裡,如果它在可寫列表裡出現了,表示它已經連線上了。

實際上,select對於阻塞socket也很方便好用。它是判斷是否阻塞的一種方式——socket會在緩衝裡有資料時返回可讀。然而,這並不能解決這個問題:判斷另一端是否完成,或忙於處理別的事。

可移植警告:在Unix上,select能處理socket和file。在Windows上不要嘗試這個。在Windows上,select只能處理socket。另外說下在C裡,很多socket高階選項在Windows上是有區別的。事實上,在Windows上,我通常使用執行緒處理socket(它工作得非常,非常好)。

相關文章