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协议簇。其对比如下:
我们平常浏览网站所使用的http协议,收发邮件用的smtp与imap,都是基于 socket API 构建的。
一个 socket,包含两个必要组成部分:
地址与协议可以确定一个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 里面用(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。
|
|
|
|
上面代码有一点需要注意:server 端的 socket 设置了SO_REUSEADDR
为1,目的是可以立即使用处于TIME_WAIT
状态的socket,那么TIME_WAIT
又是什么意思呢?后面在讲解 tcp 状态机时再做详细介绍。
UDP socket
UDP 版的 socket server 的代码在进行bind
后,无需调用listen
方法。
|
|
|
|
常见陷阱
忽略返回值
本文中的 echo server 示例因为篇幅限制,也忽略了返回值。网络通信是个非常复杂的问题,通常无法保障通信双方的网络状态,很有可能在发送/接收数据时失败或部分失败。所以有必要对发送/接收函数的返回值进行检查。本文中的 tcp echo client 发送数据时,正确写法应该如下:
|
|
同理,接收数据时也应该检查返回值:
|
|
send/recv
操作的是网络缓冲区的数据,它们不必处理传入的所有数据。
可以通过下面的方式设置缓冲区大小。
s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, buffer_size) # 发送
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, buffer_size) # 接受
误认为 TCP 具有 framing
TCP 不提供 framing,这使得其很适合于传输数据流。这是其与 UDP 的重要区别之一。UDP 是一个面向消息的协议,能保持一条消息在发送者与接受者之间的完备性。
代码示例参考:framing_assumptions
TCP 的状态机
在前面echo server 的示例中,提到了TIME_WAIT状态,为了正式介绍其概念,需要了解下 TCP 从生成到结束的状态机。(图片来源)
这个状图转移图非常非常关键,也比较复杂,总共涉及了 11 种状态。我自己为了方便记忆,对这个图进行了拆解,仔细分析这个图,可以得出这样一个结论:
连接的打开与关闭有被动(passive)与主动(active)两种情况。主动关闭时,涉及到的状态转移最多,包括FIN_WAIT_1、FIN_WAIT_2、CLOSING、TIME_WAIT。(是不是有种 no zuo no die 的感觉)
此外,由于 TCP 是可靠的传输协议,所以每次发送一个数据包后,都需要得到对方的确认(ACK),有了上面这两个知识后,再来看下面的图:(图片来源)
我们重点分析上图中链接断开的过程,其中主动关闭端为 Client,被动关闭端为 Server 。
- Client 调用
close
方法的同时,会向 Server 发送一个 FIN,然后自己处于 FIN_WAIT_1 状态,在收到 server ACK 回应后变为 FIN_WAIT_2 - Server 收到 FIN 后,向 Client 回复 ACK 确认,状态变化为 CLOSE_WAIT,然后开始进行一些清理工作
- 在 Server 清理工作完成后,会调用
close
方法,这时向 Client 发送 FIN 信号,状态变化为 LAST_ACK - Client 接收到 FIN 后,状态由 FIN_WAIT_2 变化为 TIME_WAIT,同时向 Server 回复 ACK
- Server 收到 ACK 后,状态变化为 CLOSE,表明 Server 端的 socket 已经关闭
- 处于 TIME_WAIT 状态的 Client 不会立刻转为 CLOSED 状态,而是需要等待 2MSL(max segment life,一个数据包在网络传输中最大的生命周期),以确保 Server 能够收到最后发出的 ACK。如果 Server 没有收到最后的 ACK,那么 Server 就会重新发送 FIN,所以处于TIME_WAIT的 Client 会再次发送一个 ACK 信号,这么一来(FIN来)一回(ACK),正好是两个 MSL 的时间。如果等待的时间小于 2MSL,那么新的 socket 就可以收到之前连接的数据。
上面是正常逻辑时的关闭顺序,如果任意一步出现问题都会导致 Socket 状态变化出现问题,下面说几种常见的问题:
- 在上述过程第二步,回复完 ACK 后,如果忘记调用 CLOSE 方法,那么 Server 端在会一直处于 CLOSE_TIME 状态,处于 FIN_WAIT_2 状态的 Client 端会在 60 秒后超时,直接关闭。这个问题的具体案例可参考《This is strictly a violation of the TCP specification》
- 前面 echo server 的示例也说明了,处于 TIME_WAIT 并不是说一定不能使用,可以通过设置 socket 的
SO_REUSEADDR
属性以达到不用等待 2MSL 的时间就可以复用socket 的目的,当然,这仅仅适用于测试环境,正常情况下不要修改这个属性。
实战
HTTP UA
http 协议是如今万维网的基石,可以通过 socket API 来简单模拟一个浏览器(UA)是如何解析 HTTP 协议数据的。
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 很类似。只是其连接地址为本地文件而已。
代码示例参考:uds_server.py、uds_client.py
ping
ping 命令作为检测网络联通性最常用的工具,其适用的传输协议既不是TCP,也不是 UDP,而是 ICMP。 ICMP 消息(messages)通常用于诊断 IP 协议产生的错误,traceroute 命令也是基于 ICMP 协议实现。利用 Python raw sockets API 可以模拟发送 ICMP 消息,实现类似 ping 的功能。
代码示例参考:ping.py
netstat vs ss
netstat 与 ss 都是类 Unix 系统上查看 Socket 信息的命令。netstat 是比较老牌的命令,常用的选择有
-t
,只显示 tcp 连接-u
,只显示 udp 连接-n
,不用解析hostname,用 IP 显示主机,可以加快执行速度-p
,查看连接的进程信息-l
,只显示监听的连接
ss 是新兴的命令,其选项和 netstat 差不多,主要区别是能够进行过滤(通过state
与exclude
关键字)。
|
|
这两个命令更多用法可以参考:
总结
我们的生活已经离不开网络,平时的开发也充斥着各种复杂的网络应用,从最基本的数据库,到各种分布式系统,不论其应用层怎么复杂,其底层传输数据的的协议簇是一致的。Socket 这一概念我们很少直接与其打交道,但是当我们的系统出现问题时,往往是对底层的协议认识不足造成的,希望这篇文章能对大家编程网络方面的程序有所帮助。
参考
- Socket Programming HOWTO
- TCP: About FIN_WAIT_2, TIME_WAIT and CLOSE_WAIT
- Five pitfalls of Linux sockets programming
- Programming Linux sockets, Part 1: Using TCP/IP
- http://stackoverflow.com/questions/10328675/how-to-know-content-length
- What’s The Difference Between The OSI Seven-Layer Network Model And TCP/IP?
- TCP 的那些事儿(上)
- Coping with the TCP TIME-WAIT state on busy Linux servers