带外数据说明
TCP 的带外数据可传输一字节内容,实际上带外数据和其他数据是一起发送,一起接收。区别在于:
对于发送端:
发送带外数据,会将当前发送缓冲区待发送的 TCP 报文 header 设置 flag 的 URG 标志和紧急指针 Urgent pointer 的值,仅仅如此而已。带外数据的位置为该次发送带外数据调用的最后一个字节。
对于接收端:
接收端,则是读取接口的行为的差异。默认情况下,带外数据需要专用的 socket API 才能读取,recv、recvmfg、recvfrom。当然,发送也需要send、sendto、sendmsg才行。
带外数据的通知方式为发送 SIGURG 信号,首先需要设置文件描述符所属的进程,并注册 SIGURG 信号的处理函数。
OOB
数据完全解析
接收端一直等待,发送端先后 3 次发送,每次发送 10 字节。第 20 字节为带外数据。
图 1 带外数据初探
左边为接收端,右边为发送端。
ss
命令可以在接收端查查看 30 字节的内容,不过使用 ioctl
的 FIONREAD
选项查看只有 19 字节。
发送端进行第 3 和 第 4 次发送,第3次发送的最后一个字节是带外数据。
图 2 接收端带外数据只能有一个
此时接收端使用 ss
查看是有 50 字节,使用 ioctl
的 FIONREAD
选项查看的是 39 字节,所以 FIONREAD
准确的含义是表示本次最多能够读取的字节数,并不是缓冲区中的所有未读取的长度。
接收端进行 2 次读取,第一次只能读取 39 字节,第二次读取了剩余的 50 字节。
图 3 接收端数据读取
第一次只能读取 39 字节,暗含了 ioctl
的 FIONREAD
的返回值。第二次读取了 10 字节。
图 4 整个过程在发送端抓包
抓包中的 seq
和 urg
就能定位带外数据的位置:urg
相对于同个包的 seq
就是带外数据的位置。
带外数据只能有一份
无论是发送端还是接收端,都只能有一个字节的带外数.
如果一个新的带外数据达到,已经在内核接收缓冲区中的带外数据成为普通数据。
对于发送端,如果已经有带外数据在内核缓冲区中未被发送,新的带外数据写入内核,那么之前的带外数据成为普通数据。
图 5 发送端内核缓冲区,已有的带外数据会被取代成为普通数据
发送端连续调用两次发送包含带外数据的数据包,从抓包结果来看,第 9 个包已经调整了 Urg
的值,说明此时发送缓冲区里,只有最新写入的带外数据才作为带外数据标识,之前的成为普通数据。
图 6 图 5 中的操作对应的抓包
2 个有意思的地方
-
只要当前未有效传输的数据(即进程A发送的数据没有被进程B接收)中包含带外数据(无论是带外数据在发送端的缓冲区,还是在接收端的缓冲区),只要接收端已经被通知了有带外数据,那么使用
MSG_OOB
标志去获取,如果带外数据还在发送端的缓冲区,直接非阻塞的返回EAGAIN Resource temporarily unavailable
错误。MSG_OOB
标志读取带外数据总是非阻塞的。 这也是合理的,因为在包含带外数据的数据包达到之前,接收端可能已经进入紧急状态。但是用户态其实没法知道带外数据是否真的到达,所以如果此时读取代码数据是阻塞的,一旦接收缓冲区满,无法接收新的数据,带外数据包永远不会被接收,这将导致接收端一直阻塞下去。 -
如果当前没有带外数据或者已经被获取,使用
MSG_OOB
标志去获取,返回EINVAL Invalid argument
错误。
对于接收端,如果带外数据在内核缓冲区中未被读取,又接收了新的带外数据,那么之前的带外数据成为普通数据。
某一个特定位置的带外数据在发送端写入到内核缓冲区,之后发送的数据包在这个带外数据传输前,都将标志这个带外数据。但是,接收端对于一个特定位置的带外数据,只会在收到第一个带有该带外数据标记的包时产生一次信号。
带外数据对于 recv
行为的影响
不读取带外数据
相当于带外数据把缓冲区分隔成3部分。第一次 recv
操作能只能把带外数据之前的所有数据读完,达到带外数据的位置。再次使用 recv
能够所有剩余的数据。
假设接收端缓冲区现在有 8 个字节 1111b222
,其中 b
表示带外数据。那么第一次 recv(100)
能读取1111
这 4 个字节;接收缓冲区中剩余 4 个字节;在此使用 recv(100)
将读取剩下的 222
这 3 个字节。
设置 MSG_OOB
标志读取带外数据
带外数据需要传递 MSG_OOB
标志才能读取,否则将跳过带外数据。图中左边接收端记录了整个过程。
图 7 带外数据的 recv
方式之 MSG_OOB
标志
一旦带外数据在接收缓冲区当中,无论其在接收缓冲区中的位置,此时就能使用带 MSG_OOB
标志的 recv
进行读取。几个有意思的地方在于:
-
带外数据只能读一次,第二次报
Invalid argument
错误。 -
缓冲区中的第一个数据是带外数据,使用
FIONREAD
得到的结果是 0,但是recv
读取带外数据位置后的 3 个字节。
设置 SO_OOBINLINE
套接字属性读取带外数据
设置了 FIONREAD
套接字属性,带外数据将和带外数据之后的数据一起被不带任何标志的 recv
正常读取。图 6 左边的记录了整个过程。
图 8 带外数据的 recv
方式之 SO_OOBINLINE
套接字属性
-
设置了
SO_OOBINLINE
套接字属性,FIONREAD
返回的是包含带外数据的整个缓冲区的长度,但是读取依旧在带外数据处中断,需要 2 次才能读完。所以之前FIONREAD
准确的含义是表示本次最多能够读取的字节数 的说法也不完全正确。
总结
带外数据最多有 2 份,此时的情况是接收端缓冲区和发送端缓冲区各有一份并且发送端新的带外数据通知没有达到接收端。一旦发送端新的带外数据通知到达接收端,即使发送端的带外数据还未传输,那么接收端在收到通知时,将更新带外数据的信息:将发送端还未发送的带外数据才视为新的带外数据,之前本地的变成普通数据。
问答
问
如果接收端缓冲区有 5 个字节的内容,最后一个时带外数据。使用不带任何标志的 recv
去读取,立马回返回 4 个字节。此时再次用不带任何标志的 recv
去读取,回发生什么?
答
因为不带任何标志的 recv
会去读取带外数据的位置之后的数据,所以如果没有新的数据到来,recv
将一直阻塞。
代码
发送端
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
client.connect(("192.168.1.236", 7777))
client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
接收端
import socket, time, signal, fcntl, os, array, termios
global_int_count = 0
def handler(sig, frame):
global global_int_count
global_int_count += 1
print(global_int_count, sig, frame)
#c : socket.socket
def get_noread(c):
buf = array.array("i", [0xff])
fcntl.ioctl(c, termios.FIONREAD, buf)
print(buf)
serv = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
serv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serv.bind(("0.0.0.0", 7777))
serv.listen(1024)
client, addr = serv.accept()
client.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 640)
#client.setsockopt(SOL_SOCKET, SO_OOBINLINE, 1)
print(client.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF))
fcntl.fcntl(client, fcntl.F_SETOWN, os.getpid()) #一定需要设置,才能收到信号
fcntl.fcntl(client, fcntl.F_SETSIG, signal.SIGURG) ### 没法通过 SETSIG 设置其他信号
#设置信号处理函数
signal.signal(signal.SIGURG, handler)