计算机网络篇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. 双方得事先商量好:这条消息由哪几个字段组成、按什么顺序排、每个字段多长

  1. 商量好之后,发送方按约定拼、接收方按约定拆,两边对同一串字节的理解才能一致

  2. 商量出来的这组规则就是应用层协议

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

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. 序列化拼、反序列化拆,两个函数各管一头
c 复制代码
// 协议约定:昵称和消息用 '|' 拼成一条字符串
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,拿到的只是拷贝成功的字节数
c 复制代码
char buf[128];
int n = serialize(&m, buf, sizeof(buf));   // 结构体变成一条字符串
write(sockfd, buf, n);                     // 拷进发送缓冲区就返回,什么时候发不归它管
2. 接收端 read 从接收缓冲区拷出,拿到的可能只有半条
c 复制代码
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 一次就等于收到一条完整消息」当成了理所当然?

相关推荐
EnCi Zheng2 小时前
PostgreSQL 实战指南:安装配置、数据类型、索引选型、MVCC 事务与高可用备份全解析
数据库·sql·postgresql·索引·1024程序员节
choumin2 小时前
C++ std::bind的作用和用法
1024程序员节·std bind·函数参数绑定
职豚求职小程序刷题2 小时前
中国旅游集团27校招在线测评攻略(附练习题库)
1024程序员节
淡淡的香烟2 小时前
Flutter实现多语言切换
android·flutter·1024程序员节
DogDaoDao2 小时前
数字信号处理:数字域(时域)与频域的关系与作用——从傅里叶变换到 FFT 的完整推演
数字信号处理·1024程序员节·傅里叶变换·卷积·dct变换·时域·频域
Percep_gan2 小时前
调用天翼云的对象存储ZOS,很详细
java·1024程序员节
JulyLi20192 小时前
yolo检测适配昇腾Atlas300IDuo笔记
yolo·1024程序员节
遇雪长安2 小时前
MCU 固件启动即崩,bootloader 如何发现
1024程序员节
Zevalin爱灰灰2 小时前
Linux入门第一部分(Ubuntu入门篇) 第二章 浅尝Ubuntu(二)
linux·嵌入式·1024程序员节