「流浪」头像
计算机网络篇8:传输层——序列化与反序列化,TCP 面向字节流的报文边界由应用层自己切封面图

计算机网络篇8:传输层——序列化与反序列化,TCP 面向字节流的报文边界由应用层自己切

在这里插入图片描述


📚 本文收录于「流浪」的系列专栏

🐧 Linux系统⚙️ C++
📊 数据结构与算法🐍 Python
🔗 LangChain & LangGraph🗄️ MySQL 数据库
🌿 Git 工具🌐 计算机网络
🤖 LLM💯 大厂面试、八股
📚 学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言:《计算机网络篇7》把 TCP 的接口按调用顺序过了一遍,服务端 socket、bind、listen、accept,客户端 socket、connect,连上之后统一换成 read/write。接口这一层算是通了,可接口只负责把字节搬来搬去,它不回答另一个问题:一条消息到底长什么样,边界又是谁划的?本篇往接口底下再挖一层,先讲清应用层协议为什么是一组约定、序列化和反序列化在其中干的是什么活,再把 read 和 write 重新理解一遍——它们从头到尾只在本机的缓冲区里做拷贝;最后落到 TCP 面向字节流这件事上,看看报文边界为什么不归 OS 管。


一、应用层协议约定数据怎么排,序列化和反序列化负责拼与拆

1.1 业务代码全部落在应用层,协议是给这些数据立的规矩

1. 平时代码里处理的数据都是结构化的
  1. 平时写业务代码,处理的对象都是一个一个有结构的整体:一条用户信息有名字、年龄、地址,一条聊天消息有发送者、时间、内容
  2. 这些代码全部落在应用层,跑在 TCP 或 UDP 已经铺好的通道之上,字段各归各位,偏移由编译器维护
2. socket 收发的是字节,结构在传输过程中会丢
  1. socket API 在读数据时,是按字符串的形式来收发的
  2. 换句话说,接口层拿到的只是一串字节,谁在哪个位置、各自多长,它一概不问
  3. 那结构体的数据怎么过网络?「把结构体传过去」这句话,在接口层面是落不了地的
3. 缺的这层约定就是协议

1. 双方得事先商量好:这条消息由哪几个字段组成、按什么顺序排、每个字段多长
2. 商量好之后,发送方按约定拼、接收方按约定拆,两边对同一串字节的理解才能一致
3. 商量出来的这组规则就是应用层协议

协议不是什么抽象口号,它就是双方约好的结构化数据怎么排——字段有哪几个、谁在前谁在后、各自占多少字节,都写死了才能互通。

1.2 序列化和反序列化:头像、消息、昵称拼成一条报文再拆回来

1. 三个字段共同构成一个报文
  1. 拿 QQ 群聊天来举例,一条消息发出去,对端要渲染出来的东西不止文字
  2. 头像一个字段,消息一个字段,昵称一个字段
  3. 三条凑在一起才是完整的一条聊天消息,缺任何一条,界面都拼不出正常的样子
2. 发送方把三个字段拼成字符串,接收方再把它拆回三个字段
  1. 发送前先按约定把三个字段拼成一条长字符串,头像放哪个位置、占多少字节,昵称放哪个位置、占多少字节,全由协议定死
  2. 拼好之后,这条长字符串对 socket 来说就是一串再普通不过的字节
  3. 对方收到后按同一份约定从固定位置切,三个字段重新变回头像、消息和昵称,界面才能正常渲染
  4. 发送方拼、接收方拆,这两个动作合起来就是序列化和反序列化,一条报文的两端各做一次

序列化是把结构化的数据按约定拼成一条长字符串,反序列化是把它拆回来——中间跑的都是没有差别的字节。

1.3 序列化和反序列化这一层的价值是解耦

1. 业务代码不用管字节怎么摆
  1. 有了序列化这层,上层只管按自己的结构组织数据
  2. 这些字段怎么拼、按什么顺序摆、每段多长,统统交给序列化去办
  3. 业务逻辑和底下的字节排列之间,从此隔了一道墙
2. 没有这层抽象,痛点全落在业务代码里
  1. 没有序列化,用户就得自己处理 string,把每个字段手工拼成字符串再 write
  2. 收数据时也一样,得自己从字节流里按位置切出来,再手工拼回结构体
  3. 收发逻辑和业务逻辑搅在一起:加一个字段,拼和拆两处都要改,改漏一处就是线上故障,换门语言还得重写
    在这里插入图片描述
3. 问题落到 write 把字节写到哪儿去了
  1. 既然序列化之后交给 socket 的只是一串字节,那 write 把这串字节写出去,到底发生了什么
  2. 这串字节真的从本机「嗖」地一下飞到了对面吗
  3. 答案比想象的朴素,而且直接决定后面怎么看字节流

序列化把「数据长什么样」和「字节怎么摆」这两件事拆开了,业务代码因此可以只关心前者;没有这层拆解,收发逻辑会渗进每一处业务代码。

1.4 通信双方可以直接传二进制的结构体对象吗

1. 结构体可以原样传,但两端布局必须一致
  1. 把结构体在内存里的字节原样 write 出去,对端拿到之后按同一个结构体类型解读,语法上完全走得通
  2. 但这种可行性挂着一串前提:两端的字节序、类型宽度、字段布局都要严格一致
  3. 只要有一条对不上,解析出来的就是另一个东西
2. 大小端不一致会让同一串字节被反向解释
  1. 同一串字节,大端机器把高地址那端当高位、小端机器把低地址那端当高位,读出来的值天差地别
  2. 一个多字节整数传过去,可能变成完全不同的数
  3. 字段一长就不只是数值错了:解读位置整体错位,后面的字段跟着全乱
  4. 所以跨机器传数值要先转成网络字节序,这一层 《计算机网络篇4》 已经讲过
3. 不同语言对同一份结构的布局不一样
  1. C/S 两端万一用不同语言写,同一份数据的内存大小可能对不上
  2. 就算大小一样,字段的排列顺序和对齐填充规则也可能不同
  3. 结构体在内存里到底怎么摆,是编译器和语言规范决定的,不是协议规定的

直接传结构体能不能成,取决于两端的内存布局是否一致——这个条件在应用层基本不成立,所以协议必须自己定义一套与机器、与语言无关的排列规则。

1.5 OS 内部的协议反而全部传二进制结构体对象

1. 同一个问题在 OS 内部有相反的答案
  1. 应用层不建议传裸结构体,OS 内部的协议却几乎都是二进制结构体对象
  2. 协议栈内部的报文、控制块、头部全是结构体,直接按结构体解读
2. 区别在两端的布局是不是同一套
  1. OS 内部两端是同一份源码(C语言)、同一个编译器、同一套对齐规则,内存布局严格一致,直接按结构体读不会错位
  2. 应用层跨主机、跨语言,布局不保证一致,所以必须换成与机器无关的中立格式

裸结构体不是不能用,而是它的前提是两端布局一致——OS 内部满足这个前提所以敢直接传,应用层不满足,所以必须序列化。


二、重新理解 read 与 write:主机间通信的本质是拷贝

2.1 read 和 write 并不能直接跨网络收发数据

1. 两台主机通信时,两个接口都不跨网络对传
  1. 两台主机在通信时,read 和 write 并不能直接跨网络进行收发
  2. 它们从头到尾只跟本机的内存打交道,网络那一段由 OS 接手
2. write 只是把数据写进 TCP 底层的发送缓冲区
  1. write 的动作是把用户空间的数据拷进 TCP 底层的文件缓冲区,也就是发送缓冲区
  2. 这个缓冲区挂在本机的套接字上,数据到这里就算交出去,什么时候出门、以什么形式出门都不归 write 管
3. write 的本质是拷贝

write 不是「发送」,它只是一次从用户空间到内核发送缓冲区的拷贝,拷贝完成就返回。

2.2 什么时候发、怎么发由 TCP 协议自己决定

1. 数据出门的时机由协议栈掌握
  1. 缓冲区里的数据攒到多少才发、一次发多少字节、对端没收到要不要重发,全是 OS 和 TCP 协议的事
  2. 应用层能做的只是把字节交进去,剩下的时机和数量完全交出去
2. 传输控制协议控制的就是发送的时机与数量
  1. TCP 全称是传输控制协议,控制的正是传输这件事:发多少、什么时候发、出错怎么办
  2. 这个「控制」的立足点就是发送缓冲区,协议栈盯着它做决定

write 返回成功只说明数据进了缓冲区,说明不了对端收没收到。

2.3 read 也只是从接收缓冲区里把数据拷出来

1. read 面对的是本机的接收缓冲区
  1. 数据从对端过来,先落进本机的接收缓冲区,这是 OS 的地盘
  2. read 的动作是从这个缓冲区里把数据拷进用户空间,拷多少返回多少
2. read 和 write 是一对镜像动作
  1. 一个往缓冲区里拷进,一个从缓冲区里拷出,方向相反,本质相同
  2. 两个动作都只发生在自己这台机器上,都不跨网络
3. 收发两个接口都不负责网络传输

read 和 write 一样,本质都是本机内存之间的一次拷贝,网络那一段完全不在它们的职责范围内。

在这里插入图片描述

2.4 通信的本质是把自己的发送缓冲区拷到对端接收缓冲区

1. 一次通信拆开看是四步
  1. 发送方 write:数据从用户空间拷进本机发送缓冲区
  2. 内核按 TCP 的规则把数据从发送缓冲区送出去
  3. 数据到达对端,落进对端的接收缓冲区
  4. 对端 read:数据从接收缓冲区拷进它的用户空间
2. 中间两步应用代码碰不到
  1. 第二、三步由 OS 和协议栈完成,应用层只是触发者
  2. 从应用代码的角度看,数据是从一个缓冲区消失、又在另一个缓冲区出现
3. 所以计算机的世界里,通信即拷贝

主机间通信的本质,就是把发送方发送缓冲区里的数据拷贝到对端的接收缓冲区——中间的网络传输对应用层不可见,它能看到的自始至终只有两次拷贝。

2.5 每个套接字都有一对缓冲区,所以 TCP 通信是全双工的

在这里插入图片描述

1. 一个套接字对应一对缓冲区
  1. 发送缓冲区和接收缓冲区各有一个:一个管发,一个管收
  2. 两个方向的数据各走各的通道,互不抢占
2. 两个方向同时收发,这就是全双工
  1. 双方可以同时在各自的发送缓冲区里写、在各自的接收缓冲区里读,谁都不用等谁空出来
  2. 《计算机网络篇7》里 netstat 看到的同一条连接、两条方向相反的记录,就是这件事在表上的样子

全双工不是协议里写的一句口号,而是两对缓冲区各自独立带来的结果:收发互不抢道,才能同时进行。


三、TCP 面向字节流与 UDP 面向数据报:报文边界归谁保证

3.1 UDP 面向数据报:你发一个,我收一个

1. OS 把你 write 的整串原样打成一个包
  1. 在 UDP 里,OS 会把你写进去的整串数据打成一个包发往接收方
  2. 一次 write 就是一个用户数据报,边界按你的写次数被划出来
2. 对端一次读正好拿到一个完整报文
  1. 对端 read 一次,拿到的就是一个完整报文,不多不少
  2. 上一章那四步拷贝在 UDP 里同样成立,只不过缓冲区里排的是一个一个彼此独立的包
3. 这就叫面向数据报

UDP 替你保住了边界:写几次就是几个包,读一次就是一个包,所以它叫面向数据报的协议。

3.2 TCP 不是一发一收,OS 不关心字节串的内容和边界

1. TCP 里没有「发一个读一个」这回事
  1. TCP 是面向字节流的,这是它和 UDP 在数据形态上最大的分岔
  2. 什么时候发、怎么发、出错了怎么办,全由 OS 决定,应用层插不上手
2. OS 只看字节,不看内容也不认边界
  1. 送进发送缓冲区的那串字节,OS 并不关心它是什么内容
  2. 也不关心你在哪里断了句、哪几个字符合起来算一条消息
  3. 对它来说,这就是一段要按序送到的字节
3. 写和读的次数之间没有任何约定

写进缓冲区的次数和读出来的次数之间没有任何约定——TCP 保证的是字节按序到达,不是报文按次数对应。

3.3 一个完整报文可能只被发出去一部分

1. 上层以为发了一条消息,路上可能只走了一半
  1. 上层序列化之后写进去的是一个完整报文,OS 在发送时会因为对端接收缓冲区的情况,只把其中一部分先发过去
  2. 剩下那部分等下次再发,或者等对端读走一些、腾出空间之后接着发
  3. 从对端应用层的视角看,手里拿到的不是一条完整报文
2. 拿到半条就反序列化,解析必然出错
  1. 按约定从固定位置切字段,切出来的东西自然不对
  2. 长度字段读出来是半个数、内容被拦腰截断,各种错位都可能出现
3. 凑齐完整报文是应用层自己的责任
  1. 在 TCP 中,要读到完整报文由接收方应用层自己决定
  2. 读到的不是完整报文,就等后续字节到了,和前面那段拼成一条完整报文,再去反序列化

反序列化的前提是手上有一条完整报文;在 TCP 里凑齐这条报文不归 OS 管,得应用层自己等、自己攒。

3.4 接收缓冲区满的时候,发送方还在拷,接收方一次全拿走

1. 缓冲区装不下的报文只能等,攒下的会挨着排好
  1. 对端的接收缓冲区被前面没读走的数据占满,新报文进不去
  2. 发送方这边可能已经拷了不止一份报文进去,它们在缓冲区里挨着排好,中间没有任何分隔标记
2. 接收方read 时一次就把缓冲区里的数据全取走
  1. read 的语义是「有多少取多少,最多取你给的那个上限」
  2. 攒了三条报文,一次 read 就能全部拿走

收发速度不匹配时,缓冲区先攒后放——攒进去的和放出来的都不保持报文边界,这就是字节流在缓冲区层面的样子。

3.5 面向字节流的两个后果:边界自己切、收发次数不对等

1. 不存在「一次 read 一条报文」的保证
  1. 连续发的两条报文可能被一次读到,这叫作粘包
  2. 一条报文也可能被分几次读到,这叫作半包
  3. 两个现象说的其实是同一件事:字节流里没有天生的报文边界
  4. 它不是 TCP 的缺陷,而是面向字节流的必然后果
2. 收发次数不对等是常态
  1. 一端 write 三次,对端可能 read 一次就全拿到
  2. 一端 write 一次,对端也可能 read 三次才拿完
  3. 次数对不上是常态,对上才是碰巧
3. 边界和次数都只能在应用层解决

粘包和半包不是两个要单独修的 bug,而是「字节流没有边界」这一个事实的两种表现——边界在应用层划定,两个现象一起消失。

3.6 报文边界要靠应用层自己约定

1. 报文边界有三种常见的划法
  1. 定长:每个报文长度固定,收满这个长度就取出去解析,不够就先存着
    • 好处是实现最简单,凑够字节数就必然是一条完整报文
    • 代价是内容长度一变就废,不足的部分还得填充补位
  2. 分隔符:约定一个特殊符号作为报文结束标志,遇到它就切一刀
    • 好处是文本类协议读起来最直观,HTTP 头用的就是这一套
    • 代价是内容里出现这个符号时必须转义,否则会被误认成报文结尾
  3. 长度字段:在报文头部固定几个字节写清后面的内容有多少字节,先读头、再按长度读体
    • 好处是二进制协议里最通用,内容里出现什么字节都不影响切分
    • 代价是多一道工序:得先把报文头读全,才知道后面该收多少字节
      在这里插入图片描述
      在这里插入图片描述
2. 三种划法解决的是同一件事
  1. 都是用一个双方都认的规则,把「无边界的字节流」重新切回「一条一条的报文」
  2. 选哪种取决于内容特点:纯文本可以用分隔符;二进制、内容里什么字节都可能出现的,基本都走长度字段
3. 封包拆包属于工程实现

定长、分隔符、长度字段这三条路,本质都是给字节流补一个应用层自己认得的边界——协议设计的一大半工作就是在做这件事。


四、一段代码串起三件事:序列化和反序列化、write 和接收缓冲区

4.1 序列化把结构体拼成字符串,反序列化再把它拆回来

1. 协议就是那个分隔符
  1. 结构体里放两个字段:昵称和消息正文,各占固定长度
  2. 协议约定用 | 把两个字段拼起来,接收方见到 | 就知道昵称到哪儿结束,这个约定在两端写死
2. 序列化拼、反序列化拆,两个函数各管一头
// 协议约定:昵称和消息用 '|' 拼成一条字符串
struct chat_msg {
    char nick[NICK_LEN];   // 昵称
    char text[TEXT_LEN];   // 消息正文
};

// 序列化:结构体 → 一条长字符串,返回拼出来的长度
int serialize(const struct chat_msg *m, char *buf, int size)
{
    return snprintf(buf, size, "%s|%s", m->nick, m->text);
}

// 反序列化:长字符串 → 结构体
// 找不到分隔符说明这条报文还没收全,返回 -1 让上层等下一次
int deserialize(const char *buf, struct chat_msg *m)
{
    const char *sep = strchr(buf, '|');
    if (sep == NULL)
        return -1;
    memcpy(m->nick, buf, sep - buf);       // 分隔符之前是昵称
    m->nick[sep - buf] = '\0';
    strcpy(m->text, sep + 1);              // 分隔符之后是消息正文
    return 0;
}

4.2 交给 write 之后,数据只进了发送缓冲区

1. 发送端调用 write,拿到的只是拷贝成功的字节数
char buf[128];
int n = serialize(&m, buf, sizeof(buf));   // 结构体变成一条字符串
write(sockfd, buf, n);                     // 拷进发送缓冲区就返回,什么时候发不归它管
2. 接收端 read 从接收缓冲区拷出,拿到的可能只有半条
char rbuf[128];
int r = read(sockfd, rbuf, sizeof(rbuf));  // 从接收缓冲区拷出来,r 可能小于 n
if (deserialize(rbuf, &m2) < 0) {
    // 半包:这段先留在自己的缓冲区里,等下一次 read 拼齐再拆
}
3. 三个动作在同一条链上,缺一环消息就到不了

序列化按约定拼、write 只负责拷贝、read 可能只拿到半条——三段代码串起来,正好是前面三章。


五、全篇总结

1. 序列化按约定拼,反序列化按约定拆
  1. 业务代码处理的数据都是结构化的,socket 收发的只是一串字节
  2. 协议定死字段有哪几个、谁在前谁在后、各占多少字节
  3. 序列化按约定拼、反序列化按约定拆,把业务逻辑和字节排列隔开
  4. 跨机器不能直接传裸结构体:两端大小端可能相反,不同语言的字段布局也对不上
2. read 和 write 的本质是拷贝
  1. 两个接口都不跨网络收发数据,自始至终只跟本机缓冲区打交道
  2. write 把数据拷进发送缓冲区,返回的字节数是拷进去多少
  3. 什么时候发、怎么发由 TCP 协议决定,read 再从接收缓冲区把数据拷出来
  4. 一次通信就是发送缓冲区到对端接收缓冲区,两对缓冲区撑起全双工
3. TCP 面向字节流,报文边界归应用层自己切
  1. UDP 面向数据报:写几次就是几个包,读一次就是一个包
  2. TCP 既不认内容也不认边界,一份报文可能只被发出去一部分
  3. 收发次数不对等是常态,粘包和半包是同一件事的两种表现
  4. 边界靠应用层自己约定:定长、分隔符、长度字段

六、文末面试题

6.1 推导题(本篇浅讲,答案带过)

1. 为什么跨主机通信不建议直接传结构体的二进制?

答(推导):结构体在内存里的字节排布是编译器和语言规范决定的,两台机器之间不保证一致。大小端不同,多字节字段会被反向解释;用不同语言写,同一份结构的类型宽度、字段顺序、对齐填充规则都可能不同。只要有一条对不上,对端解析出来的就是另一个东西。所以跨主机传数据要先序列化成一套与机器、语言无关的约定格式。

2. write 返回成功,是不是代表对端已经收到了?

答(推导):不是。write 的动作只是把用户空间的数据拷进本机的发送缓冲区,返回的字节数是「拷进去多少」,不是「对端收到多少」。数据什么时候真正发出去、分几次发、发丢了怎么办,都由 TCP 协议栈自己决定。所以 write 成功只能说明字节交给了内核,要确认对端收到得靠应用层自己的应答机制。

3. TCP 为什么不保证一次 write 对应一次 read?粘包是 TCP 的缺陷吗?

答(推导):因为 TCP 面向字节流,它保证的只是字节按序、可靠地到达,不保留报文边界。发送端受发送缓冲区和报文段长度的限制会把数据切开,接收端 read 也只是从接收缓冲区里舀,能舀出多少取决于缓冲区里当前有多少。所以两端次数对不上是常态,粘包和半包是同一件事的两种表现,属于字节流的固有性质,不是缺陷——边界要由应用层用自己的协议划定。

6.2 真题(带过)

1. 什么是 TCP 粘包和半包?怎么解决?

答(推导 · 已对照解析,转述):粘包是发送方连续发的多个数据包被接收方一次收到,半包是一个数据包被分成多次收到,两者根源都是 TCP 面向字节流、不保留消息边界。常见解决方式是应用层自己定边界:定长包、以特殊字符作结束标志(如 \r\n),以及最常用的包头加包体——包头里放一个长度字段表明包体多大,先读包头再按长度读包体。
【真题·转述自 TCP 协议如何解决粘包、半包问题】

2. send/write 的返回值代表什么?

答(推导 · 已对照解析,转述):返回 n 表示内核接受了 n 个字节放进发送缓冲区,仅此而已,不代表对端收到了,更不代表对端应用处理了。而且 n 可能小于传入的长度,也就是短写,在非阻塞模式或发送缓冲区快满的时候尤其常见,所以正确的写法是记住偏移量循环发,非阻塞下遇到 EAGAIN 就把剩余数据存进应用层发送缓冲、注册可写事件,等可写时继续。
【真题·转述自 网络:面试速记】

3. 什么是网络字节序?为什么需要 htonl、htons?

答(推导 · 已对照解析,转述):网络字节序是 TCP/IP 统一规定的大端序,也就是多字节数值的高位字节先发。不同架构的机器内存里存多字节整数的顺序可能相反,x86 是小端,如果直接把本机字节序的端口号、IP 发出去,对端按大端解读就会得到完全不同的值。所以发送前要用 htonl、htons 从主机字节序转成网络字节序,收到后再用 ntohl、ntohs 转回来;在大端机器上这几个函数是空操作,但代码里照样要写,才具有可移植性。
【真题·转述自 Linux网络编程核心:Socket、字节序与TCP/UDP实战解析】


这一篇把 read 和 write 的真实动作摊开了:它们不负责网络传输,从头到尾只是本机缓冲区之间的拷贝,通信的样子就是把发送缓冲区里的数据挪到对端接收缓冲区。TCP 面向字节流,报文边界从来不在 OS 手里——粘包半包不是缺陷是常态,边界得靠你自己的协议去划定。评论区聊聊:你第一次写 TCP 收发时,是不是也把「read 一次就等于收到一条完整消息」当成了理所当然?

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2502_94387000/article/details/168245463

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--