Socket 編程實戰
Socket 在英文中的含義為“(連接兩個物品的)凹槽”,像 the eye socket ,意為“眼窩”,此外還有“插座”的意思。在計算機科學中,socket 通常是指一個連接的兩個端點,這里的連接可以是同一機器上的,像 unix domain socket ,也可以是不同機器上的,像 network socket 。
本文著重介紹現在用的最多的 network socket,包括其在網絡模型中的位置、API 的編程范式、常見錯誤等方面,最后用 Python 語言中的 socket API 實現幾個實際的例子。Socket 中文一般翻譯為“套接字”,不得不說這是個讓人摸不著頭腦的翻譯,我也沒想到啥“信達雅”的翻譯,所以本文直接用其英文表述。本文中所有代碼均可在 socket.py 倉庫中找到。
概述
Socket 作為一種通用的技術規范,首次是由 Berkeley 大學在 1983 為 4.2BSD Unix 提供的,后來逐漸演化為 POSIX 標準。Socket API 是由操作系統提供的一個編程接口,讓應用程序可以控制使用 socket 技術。Unix 哲學中有一條 一切皆為文件 ,所以 socket 和 file 的 API 使用很類似:可以進行 read 、 write 、 open 、 close 等操作。
現在的網絡系統是分層的,理論上有 OSI模型 ,工業界有 TCP/IP協議簇 。其對比如下:
每層上都有其相應的協議,socket API 不屬于TCP/IP協議簇,只是操作系統提供的一個用于網絡編程的接口,工作在應用層與傳輸層之間:
我們平常瀏覽網站所使用的http協議,收發郵件用的smtp與imap,都是基于 socket API 構建的。
一個 socket,包含兩個必要組成部分:
-
地址,由 ip 與 端口組成,像 192.168.0.1:80 。
-
協議,socket 所是用的傳輸協議,目前有三種: TCP 、 UDP 、 raw IP 。
地址與協議可以確定一個socket;一臺機器上,只允許存在一個同樣的socket。TCP 端口 53 的 socket 與 UDP 端口 53 的 socket 是兩個不同的 socket。
根據 socket 傳輸數據方式的不同(使用協議不同),可以分為以下三種:
-
Stream sockets ,也稱為“面向連接”的 socket,使用 TCP 協議。實際通信前需要進行連接,傳輸的數據沒有特定的結構,所以高層協議需要自己去界定數據的分隔符,但其優勢是數據是可靠的。
-
Datagram sockets ,也稱為“無連接”的 socket,使用 UDP 協議。實際通信前不需要連接,一個優勢時 UDP 的數據包自身是可分割的(self-delimiting),也就是說每個數據包就標示了數據的開始與結束,其劣勢是數據不可靠。
-
Raw sockets ,通常用在路由器或其他網絡設備中,這種 socket 不經過TCP/IP協議簇中的傳輸層(transport layer),直接由網絡層(Internet layer)通向應用層(Application layer),所以這時的數據包就不會包含 tcp 或 udp 頭信息。
Python socket API
Python 里面用 (ip, port) 的元組來表示 socket 的地址屬性,用 AF_* 來表示協議類型。
數據通信有兩組動詞可供選擇: send/recv 或 read/write 。 read/write 方式也是 Java 采用的方式,這里不會對這種方式進行過多的解釋,但是需要注意的是:
read/write 操作的具有 buffer 的“文件”,所以在進行讀寫后需要調用 flush 方法去真正發送或讀取數據,否則數據會一直停留在緩沖區內。
TCP socket
TCP socket 由于在通向前需要建立連接,所以其模式較 UDP socket 負責些。具體如下:
每個API 的具體含義這里不在贅述,可以查看 手冊 ,這里給出 Python 語言的實現的 echo server。
# echo_server.py
# coding=utf8
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 設置 SO_REUSEADDR 后,可以立即使用 TIME_WAIT 狀態的 socket
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', 5500))
sock.listen(5)
def handler(client_sock, addr):
print('new client from %s:%s' % addr)
msg = client_sock.recv(1024)
client_sock.send(msg)
client_sock.close()
print('client[%s:%s] socket closed' % addr)
if __name__ == '__main__':
while 1:
client_sock, addr = sock.accept()
handler(client_sock, addr)
# echo_client.py
# coding=utf8
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('', 5500))
sock.send('hello socket world')
print sock.recv(1024)
上面簡單的echo server 代碼中有一點需要注意的是:server 端的 socket 設置了 SO_REUSEADDR 為1,目的是可以立即使用處于 TIME_WAIT 狀態的socket,那么 TIME_WAIT 又是什么意思呢?后面在講解 tcp 狀態變更圖時再做詳細介紹。
UDP socket
UDP socket server 端代碼在進行 bind 后,無需調用 listen 方法。
# udp_echo_server.py
# coding=utf8
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 設置 SO_REUSEADDR 后,可以立即使用 TIME_WAIT 狀態的 socket
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', 5500))
# 沒有調用 listen
if __name__ == '__main__':
while 1:
data, addr = sock.recvfrom(1024)
print('new client from %s:%s' % addr)
sock.sendto(data, addr)
# udp_echo_client.py
# coding=utf8
import socket
udp_server_addr = ('', 5500)
if __name__ == '__main__':
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
data_to_sent = 'hello udp socket'
try:
sent = sock.sendto(data_to_sent, udp_server_addr)
data, server = sock.recvfrom(1024)
print('receive data:[%s] from %s:%s' % ((data,) + server))
finally:
sock.close()
常見陷阱
忽略返回值
本文中的 echo server 示例因為篇幅限制,也忽略了返回值。網絡通信是個非常復雜的問題,通常無法保障通信雙方的網絡狀態,很有可能在發送/接收數據時失敗或部分失敗。所以有必要對發送/接收函數的返回值進行檢查。本文中的 tcp echo client 發送數據時,正確寫法應該如下:
total_send = 0
content_length = len(data_to_sent)
while total_send < content_length:
sent = sock.send(data_to_sent[total_send:])
if sent == 0:
raise RuntimeError("socket connection broken")
total_send += total_send + sent
send/recv 操作的是網絡緩沖區的數據,它們不必處理傳入的所有數據。
一般來說,當網絡緩沖區填滿時, send函數 就返回了;當網絡緩沖區被清空時, recv 函數 就返回。
當 recv 函數返回0時,意味著對端已經關閉。
可以通過下面的方式設置緩沖區大小。
s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, buffer_size)
認為 TCP 具有 framing
TCP 不提供 framing,這使得其很適合于傳輸數據流。這是其與 UDP 的重要區別之一。UDP 是一個面向消息的協議,能保持一條消息在發送者與接受者之間的完備性。
代碼示例參考: framing_assumptions
TCP 的狀態機
在前面echo server 的示例中,提到了TIME_WAIT狀態,為了正式介紹其概念,需要了解下 TCP 從生成到結束的狀態機器。
這個狀圖轉移圖非常非常關鍵,也比較復雜,我自己為了方便記憶,對這個圖進行了拆解,仔細分析這個圖,可以得出這樣一個結論,連接的打開與關閉都有被動(passive)與主動(active)兩種,主動關閉時,涉及到的狀態轉移最多,包括FIN_WAIT_1、FIN_WAIT_2、CLOSING、TIME_WAIT。
此外,由于 TCP 是可靠的傳輸協議,所以每次發送一個數據包后,都需要得到對方的確認(ACK),有了上面這兩個知識后,再來看下面的圖:
-
在主動關閉連接的 socket 調用 close 方法的同時,會向被動關閉端發送一個 FIN
-
對端收到FIN后,會向主動關閉端發送ACK進行確認,這時被動關閉端處于 CLOSE_WAIT 狀態
-
當被動關閉端調用 close 方法進行關閉的同時向主動關閉端發送 FIN 信號,接收到 FIN 的主動關閉端這時就處于 TIME_WAIT 狀態
-
這時主動關閉端不會立刻轉為 CLOSED 狀態,而是需要等待 2MSL(max segment life,一個數據包在網絡傳輸中最大的生命周期),以確保被動關閉端能夠收到最后發出的 ACK。如果被動關閉端沒有收到最后的 ACK,那么被動關閉端就會重新發送 FIN,所以處于TIME_WAIT的主動關閉端會再次發送一個 ACK 信號,這么一來(FIN來)一回(ACK),正好是兩個 MSL 的時間。如果等待的時間小于 2MSL,那么新的socket就可以收到之前連接的數據。
前面 echo server 的示例也說明了,處于 TIME_WAIT 并不是說一定不能使用,可以通過設置 socket 的 SO_REUSEADDR 屬性以達到不用等待 2MSL 的時間就可以復用socket 的目的,當然,這僅僅適用于測試環境,正常情況下不要修改這個屬性。
實戰
HTTP UA
http 協議是如今萬維網的基石,可以通過 socket API 來簡單模擬一個瀏覽器(UA)是如何解析 HTTP 協議數據的。
#coding=utf8
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
baidu_ip = socket.gethostbyname('baidu.com')
sock.connect((baidu_ip, 80))
print('connected to %s' % baidu_ip)
req_msg = [
'GET / HTTP/1.1',
'User-Agent: curl/7.37.1',
'Host: baidu.com',
'Accept: */*',
]
delimiter = '\r\n'
sock.send(delimiter.join(req_msg))
sock.send(delimiter)
sock.send(delimiter)
print('%sreceived%s' % ('-'*20, '-'*20))
http_response = sock.recv(4096)
print(http_response)
運行上面的代碼可以得到下面的輸出
--------------------received--------------------
HTTP/1.1 200 OK
Date: Tue, 01 Nov 2016 12:16:53 GMT
Server: Apache
Last-Modified: Tue, 12 Jan 2010 13:48:00 GMT
ETag: "51-47cf7e6ee8400"
Accept-Ranges: bytes
Content-Length: 81
Cache-Control: max-age=86400
Expires: Wed, 02 Nov 2016 12:16:53 GMT
Connection: Keep-Alive
Content-Type: text/html
<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>
http_response 是通過直接調用 recv(4096) 得到的,萬一真正的返回大于這個值怎么辦?我們前面知道了 TCP 協議是面向流的,它本身并不關心消息的內容,需要應用程序自己去界定消息的邊界,對于應用層的 HTTP 協議來說,有幾種情況,最簡單的一種時通過解析返回值頭部的 Content-Length 屬性,這樣就知道 body 的大小了,對于 HTTP 1.1版本,支持 Transfer-Encoding: chunked 傳輸,對于這種格式,這里不在展開講解,大家只需要知道, TCP 協議本身無法區分消息體就可以了。對這塊感興趣的可以查看 CPython 核心模塊 http.client
Unix_domain_socket
UDS 用于同一機器上不同進程通信的一種機制,其API適用與 network socket 很類似。只是其連接地址為本地文件而已。
ping
ping 命令作為檢測網絡聯通性最常用的工具,其適用的傳輸協議既不是TCP,也不是 UDP,而是 ICMP,利用 raw sockets,我們可以適用純 Python 代碼來實現其功能。
netstat vs ss
netstat 與 ss 是類 Unix 系統上查看 Socket 信息的命令。
netstat 是比較老牌的命令,我常用的選擇有
-
-t ,只顯示 tcp 連接
-
-u ,只顯示 udp 連接
-
-n ,不用解析hostname,用 IP 顯示主機,可以加快執行速度
-
-p ,查看連接的進程信息
-
-l ,只顯示監聽的連接
ss 是新興的命令,其選項和 netstat 差不多,主要區別是能夠進行過濾(通過 state 與 exclude 關鍵字)。
$ ss -o state time-wait -n | head
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 0 10.200.181.220:2222 10.200.180.28:12865 timer:(timewait,33sec,0)
0 0 127.0.0.1:45977 127.0.0.1:3306 timer:(timewait,46sec,0)
0 0 127.0.0.1:45945 127.0.0.1:3306 timer:(timewait,6.621ms,0)
0 0 10.200.181.220:2222 10.200.180.28:12280 timer:(timewait,12sec,0)
0 0 10.200.181.220:2222 10.200.180.28:35045 timer:(timewait,43sec,0)
0 0 10.200.181.220:2222 10.200.180.28:42675 timer:(timewait,46sec,0)
0 0 127.0.0.1:45949 127.0.0.1:3306 timer:(timewait,11sec,0)
0 0 127.0.0.1:45954 127.0.0.1:3306 timer:(timewait,21sec,0)
0 0 ::ffff:127.0.0.1:3306 ::ffff:127.0.0.1:45964 timer:(timewait,31sec,0)
總結
我們的生活已經離不開網絡,平時的開發也充斥著各種復雜的網絡應用,從最基本的數據庫,到各種分布式系統,不論其應用層怎么復雜,其底層傳輸數據的的協議簇是一致的。Socket 這一概念我們很少直接與其打交道,但是當我們的系統出現問題時,往往是對底層的協議認識不足造成的,希望這篇文章能對大家編程網絡方面的程序有所幫助。
參考
來自:https://segmentfault.com/a/1190000007350218