youyichannel

志于道,据于德,依于仁,游于艺!

0%

为什么说 TCP 是面向字节流协议?

前言:

TCP 是面向字节流的协议,UDP 是面向报文的协议,如何理解「面向字节流」和「面向报文」呢?

之所以会说 TCP 是面向字节流的协议,UDP 是面向报文的协议,是因为操作系统对 TCP 和UDP协议的发送方的机制不同,也就是说问题原因是在发送方。

为什么 UDP 是面向报文的协议?

当用户消息通过 UDP 协议传输时,操作系统并不会对消息进行拆分,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是说每个 UDP 报文就是一条完整用户消息,这样接收方在接收到 UDP 报文后,读取一个 UDP 报文就能够读取到完整的用户消息。

那如果接收到多个 UDP 报文呢,操作系统是如何区分开的?

操作系统在收到 UDP 报文后,会将其插入到队列里,队列里的每一个元素就是一个 UDP 报文,这样当用户调用 recvfrom() 系统调用读取数据时,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。

c_net-udp_message.drawio

为什么 TCP 是面向字节流的协议?

当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个 TCP 报文,也就时一条完整的用户消息被拆分成多个 TCP 报文进行传输。

存在的问题?

此时如果接收方的程序不知道发送方发送的消息的长度,即不知道消息的边界,是无法读取出一个有效的用户消息的。因为用户消息被拆分成多个 TCP 报文后,并不能够像 UDP 报文那样,一个 UDP 报文代表一条完整的用户消息。

【🌰栗子】

发送方准备发送 「Hey!」 和 「What's up man?」两条消息。

发送方调用 send() 函数完成数据的“发送”之后,数据其实并没有真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。

至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,此时并不能认为每次 send 调用发送的数据都会被视作一条完整的消息被发送出去。

考虑实际网络传输过程中的各种影响。假设发送方陆续调用 send 函数先后发送了 「Hey!」 和 「What's up man?」报文,那么实际的消息发送情况可能有如下几种:

1)情况一:这两个消息被分到同一个 TCP 报文

graph LR
    one_tcp_msg["Hey?What's up man?"]

2)情况二:「What's up man?」的部分内容随着 「Hey?」在一个 TCP 报文中发送出去

graph LR
    tcp_msg1["Hey?What's "]
    tcp_msg2["up man?"]

3)情况三:「Hey?」的部分内容随着 TCP 报文被发送出去,另一部分和 「What's up man?」一起随着另一个 TCP 报文发送出去

graph LR
    tcp_msg1["He"]
    tcp_msg2["y?What's up man?"]

类似的情况还有很多。此处需要强调的是我们不知道这两条用户消息是如何进行 TCP 分组传输的。

因此,不能够认为一条用户消息对应一个 TCP 报文,正因为这样,TCP 是面向字节流的协议。

当两条消息的某个部分内容被分到同一个 TCP 报文时,也就是所谓的 TCP 粘包问题,此时如果接收方不知道消息的边界的话,是无法读取出有效的消息的。

如何解决这个问题呢?需要交给 应用程序 来解决。

如何解决 TCP 粘包?

首先需要知道粘包的问题出现的原因,是因为不知道一条用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。

一般有三种分包的方式:

  1. 固定长度消息
  2. 特殊字符作为边界
  3. 自定义消息结构

固定长度消息

最简单,即每条用户消息都是固定长度的。但是这种方式的灵活性不高,实际中很少使用。

特殊字符作为边界

自定义边界,可以在两条用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读取到这个特殊字符,就认为已经读取完一条完整的消息。

比如 HTTP 通过设置回车符、换行符作为 HTTP Header的边界,通过 Content-Length 字段作为 HTTP Body 的边界,这两个方式都是为了解决“粘包”的问题。

⚠️注意:如果消息内容里有这个边界特殊字符,此时需要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。

自定义消息结构

自定义消息结构,有包头和数据组成,其中包头是固定大小的,而且包头里有一个字段来说明数据有多大。

【🌰栗子】

struct {
u_int32_t msg_len;
char msg_data[];
}

当接收方接收到包头的大小后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整到用户消息来处理了。