61 Socket:高级主题
部分读和部分写
套接字上面可能发生部分读和部分写,书中提供了 writen() 和 readn() 函数来保证读写完数据,接口和 write() / read() 一样,可以借鉴一下这种思路。
shutdown()
函数
可以指定关闭 socket 的读 / 写 / 读写。如果不用 shutdown()
,打开的 socket(文件描述)只会在所有指向它的文件描述符都关闭了之后才会被关闭。
SHUT_RD
:关闭读端。在 UNIX 域流套接字上面执行了这个操作之后,对端应用程序写入会受到 SIGPIPE 信号(或者 EPIPE 错误,如果屏蔽或处理了这个信号)。对于 TCP 套接字,关闭读端没有意义。SHUT_WR
:关闭写端。读端能继续读,读完之后能看到 EOF,写端不能再写(SIGPIPE 和 EPIPE)。SHUT_RDWR
:相当于先执行SHUT_RD
再执行SHUT_WR
。
recv()
和 send()
接口相比 read()
和 write()
多了一个 flags 参数,能在 socket 操作上面对本次读写实现更多的功能控制。
sendfile()
sendfile()
能将文件(fd)的指定范围(offset 和 count)的数据直接发送到套接字中。与先读数据后写入相比,这样的操作不需要经过用户缓冲区,能直接在内核缓冲区完成传输。
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *_Nullable offset,
size_t count);
写入的文件描述符 out_fd 必须是套接字,读取的文件 in_fd 必须是可以用 mmap()
操作的(一般是普通文件)。
在 Linux 2.6.33 之后,out_fd 不再必须是一个 socket,可以是任何文件。在 Linux 5.1.2 之后,如果 out_fd 是一个 pipe,那么 sendfile()
就等同 splice(2),受到它的规则限制。
Tip
在 2.6.16 版本之后,Linux 提供了 splice()
、vmsplice()
、tee()
系统调用。这些函数的功能比 sendfile()
要多。
Linux 4.5 以后还有一个函数是 copy_file_range()
,也能避免经过用户缓冲区。
网友 ITwitchToo 在 https://www.reddit.com/r/kernel/comments/4b5czd/what_is_the_difference_between_splice_sendfile/ 中说:
As Josef Bacik said,
copy_file_range()
is useful for copying one file to another (within the same filesystem) without actually copying anything until either file is modified (copy-on-write or COW).
splice()
only works if one of the file descriptors refer to a pipe. So you can use for e.g. socket-to-pipe or pipe-to-file without copying the data into userspace. But you can’t do file-to-file copies with it.
sendfile()
only works if the source file descriptor refers to something that can bemmap()
ed (i.e. mostly normal files) and before 2.6.33 the destination must be a socket.
TCP_CORK
选项
当在 TCP 套接字上启用了 TCP_CORK 选项后,之后所有的输出都会缓冲到一个单独的 TCP 报文段中,直到满足以下条件为止:已达到报文段的大小上限、取消了 TCP_CORK 选项、套接字 被关闭,或者当启用 TCP_CORK 后,从写入第一个字节开始已经经历了 200 毫秒。
可以用 setsockopt()
来启用或关闭 TCP_CORK 选项。有了这个选项,我们在传输 html 的时候就能先用 write()
写 HTTP 响应头,再用 sendfile()
传输 html 文件(如果页面文件存在磁盘上),然后让消息一并发送(而不是分成两个包发送)。
获取套接字地址
getsockname()
和 getpeername()
这两个系统调用可以分别获取套接字地址和对端地址(流式套接字连接中的对端的套接字)。当套接字发生了隐式绑定时,我们不知道套接字的地址,就需要用这两个系统调用来查询。
隐式绑定发生在:
- 在 TCP 套接字上执行
connect()
或者listen()
,但是没有执行bind()
。 - 在 UDP 套接字上没有执行
bind()
,首次执行sendto()
时。 - 在调用
bind()
时指定的端口号为 0,内核会自动分配一个合适的端口号。
深入探讨 TCP 协议
报文格式
略。
TCP 连接建立和断开的流程
图中粗线是客户端的路径,虚线是服务器的路径。还有一些路径因为比较少见没有被画出。
有以下观察:
- 服务器和客户端都是在发送了 SYN 之后才进入
SYN_*
状态的。 - 主动打开 / 关闭都是指客户端,被动打开 / 关闭都是指服务器。服务器被动关闭有可能是客户端主动关闭导致的结果。
- 这张图没有指出服务器主动关闭连接的状态变化路径,只有客户端主动关闭、服务器被动关闭的路径。
- 客户端在 FIN_WAIT1 状态先后或同时接收到 FIN 和 SYN 后进入 TIME_WAIT 状态。
TCP 连接的过程被称为 3 次握手。
客户端进入 TIME_WAIT 状态后,等待 2 个 MSL 之后就会进入 CLOSED 状态。
TCP 的半双工关闭
前面讨论的是 TCP 的全双工关闭,也就是应用程序通过 close()
将 TCP 套接字的读取和写入端都关闭了。但是我们也可以只用 shutdown()
来只关闭读或者写一侧。
如果用 SHUT_WR,那么就不能再往套接字里面写东西了,但是仍能接收对侧的数据。
如果用 SHUT_RD,那么在没有数据时会返回 EOF(而不是阻塞,在 Linux 等实现上有这样的行为),但是有新数据时仍然能读到,这和我们的预期不符。在 BSD 等实现中,SHUT_RD 会导致以后读不出来数据,但是这无法阻止写入方继续写数据,最终会导致数据写满、写入方被阻塞。(在 UNIX 域套接字中,关闭读取侧会导致写入方写入时收到 SIGPIPE 信号和 EPIPE 错误码。)由于不可移植,不建议使用 SHUT_RD 来操作 TCP 的关闭。
为什么有 TIME_WAIT 状态
TIME_WAIT 状态下,客户端等待 2 个 MSL 后关闭连接。MSL 是 IP 报文在超过 TTL 限制(8 位,最大 255 跳)前可在网络中生存的最大估计时间。BSD 的套接字实现假设 MSL 为 30 秒,而 Linux 遵循了 BSD 规范。因而,Linux 上的 TIME_WAIT 状态将持续 60 秒。(但是,RFC 1122 建议 MSL 的值为 2 分钟,如果有其他系统遵循了这个规范,那么 TIME_WAIT 状态将持续 4 分钟。)
观察 TCP 关闭连接的示意图(在上面),客户端发出 ACK 之后就会进入 TIME_WAIT 状态。如果这个 ACK 丢了,服务器会重传 FIN 回来,客户端需要等待一会来接收重传的 FIN 并发 ACK 响应。这样一来一回就是 2 MSL。如果服务器发送 FIN 时客户端侧已经关闭,根据 TCP 的规定,服务器会收到 RST,这被视为一种错误,客户端多等一段时间就能减少这种错误的发生。
另外一方面,还要确保之前连接的报文都过期才能(可靠地)断开连接。TCP 是有重传的,所以可能会收到重复的报文,客户端在 TIME_WAIT 状态占着连接,因此不能创建标识相同的新连接(IP + 端口),这样才不会让旧报文被误以为是新连接中的报文。
套接字监控
netstat
略,现在可以用 ss
了,更快。
tcpdump
:监视 TCP 流量
书上的例子是:tcpdump -t -N 'port 55555'
。有可能需要加上 sudo
。书上还提到了 wireshark 有类似的功能。
套接字选项
系统调用 getsockopt(2) 和 setsockopt(2) 可以获取和设置套接字的选项。比如可以用 getsockopt(2) 来获取套接字的类型,这在子进程中可能会有用,因为子进程不知道继承来的文件描述符对应的套接字类型。
SO_REUSEADDR 套接字选项
这个选项有多种用途,书上只关心“避免 TCP 服务器重启时,套接字不能再绑定到同一个端口上的错误(EADDRINUSE)”这一个用途。
当之前连接到客户端的服务器因为 close() 或者崩溃(e.g. 被信号杀死)执行了主动关闭时,TCP 结点要等待两个 MSL 之后才会完全关闭连接,这个时候之前的 TCP 端口被占用,不能被新的 socket 绑定。这个问题一般不会出现在客户端上,因为客户端可以使用临时端口号,并不一定要绑定在之前的端口上。
TCP 连接的标识是 4 元组:本地 IP、本地端口、对端 IP、对端端口。但是大多数实现(包括 Linux)的要求更加严格,如果本地有任何可以匹配到本地端口的 TCP 连接,那么本地端口不能被重用。通过在套接字绑定之前用 setsockopt(2) 设置其 SO_REUSEADDR 选项就能使得套接字可以绑定在尚有连接的本地端口上,很多服务器程序都是这么做的。
书中的列表 61-4:
int sockfd, optval;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
errExit("socket");
optval = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval,
sizeof(optval)) == -1)
errExit("socket");
if (bind(sockfd, &addr, addrlen) == -1)
errExit("bind");
if (listen(sockfd, backlog) == -1)
errExit("listen");
套接字选项的继承
在 accept()
接收连接之后,返回的新套接字是否会继承之前套接字的属性?
在 Linux 上,返回的新套接字会继承大多数套接字属性,但是有一些属性不会继承:(1)文件描述符标记,可以用 fcntl()
的 F_SETFL
修改,比如 FD_CLOEXEC
;(2)打开的文件描述的标记,可以用 fcntl()
的 F_SETFD
修改,比如 O_NONBLOCK
和 O_ASYNC
;(3)和信号驱动 I/O 相关的文件描述符属性,可以用 fcntl()
的 F_SETOWN
和 F_SETSIG
操作。
在有一些实现上,上面的部分属性比如 O_NONBLOCK
和 O_ASYNC
会被继承到新的文件描述符上,但是这种行为并不是可移植的。最好是在获取到新的套接字文件描述符之后显式设置其属性。
套接字高级功能
带外(out-of-band)数据
带外数据是流式套接字的一种高级特性,允许发送端将传输的数据标记为高优先级。由于 TCP 本身没有办法指明紧急数据序列的长度,所以 TCP 上的带外数据只能有 1 个字节,新的带外数据会覆盖旧的带外数据(就像标准信号一样不排队)。在发送和接收带外数据时,send()
和 recv()
中要加上 MSG_OOB 标记。
有很多应用都使用了带外数据,比如 telnet、rlogin、ftp 能使用带外数据来传输控制指令,及时停止之前的传输的命令。
有些 UNIX 实现的 UNIX 域流式套接字也支持带外数据,但是 Linux 不支持。现在已经不提倡使用带外数据了,因为有些情况下它可能是不可靠的。如果要区分优先级,更通用的方法是维护两个连接,而不是使用同一个。
sendmsg()
和 recvmsg()
系统调用
套接字 I/O 中最为通用的两种方法。除了能实现 send()
/ sendto()
/ recv()
/ recvfrom()
的功能之外,还能实现:
- 类似于
readv()
和writev()
的分散聚合 I/O(scatter-gather I/O)。 - 传输特定于域的辅助信息。
Linux 2.6.33 版新增了一个系统调用 recvmmsg()
。该系统调用类似于 recvmsg()
,但允许在单个系统调用中接收多个数据报。Linux 3.0 新增了系统调用 sendmmsg()
。
特定于域的辅助信息举例:
- UNIX 域上,可以通过
sendmsg()
将文件描述的相关信息发送给其他进程。这样其他进程就可以得到一个新的文件描述符,这个文件描述符指向和另一进程打开文件相同的文件描述。 - UNIX 域上,接收端还能通过辅助信息验证发送端的进程凭证(用户 ID、组 ID、进程 ID),这样就能检查发送端的身份。发送者传递的身份由内核检查,如果发送者没有特权,则必须传递自己的真实身份。详细内容可以查阅 unix(7)。
顺序数据包套接字
英文是 sequenced-packet sockets,对应 SOCK_SEQPACKET 这个选项。基本上和流式套接字相同,但是保留了消息边界,一次只读取一条消息。从内核 2.6.4 开始,Linux 在 UNIX 域套接字上面支持了 SOCK_SEQPACKET。
TCP 和 UDP 不支持 SOCK_SEQPACKET,但是 SCTP 协议支持了 SOCK_SEQPACKET。
SCTP 以及 DCCP 传输层协议
SCTP(流式传输协议)和 TCP 很像,但是保留了消息边界,而且能在一条连接上支持多条数据流。在 Linux 2.6 版本开始支持。
DCCP(数据报拥塞控制协议)和 UDP 很像,但是提供了拥塞控制能力。在 Linux 2.6.14 版本开始支持。