一个TCP发送缓冲区问题的解析

C语言与CPP编程 2024-11-26 09:02

击上方“C语言与CPP编程”,选择“关注/置顶/星标公众号

干货福利,第一时间送达!

最近有小伙伴说没有收到当天的文章推送,这是因为微信更改了推送机制,导致没有星标公众号的小伙伴刷不到当天推送的文章,无法接收到一些比较实用的知识和资讯。所以建议大家加个星标⭐️,以后就能第一时间收到推送了。

原文:https://segmentfault.com/a/1190000021488755

最近遇到一个问题,简化模型如下:

Client 创建一个 TCP 的 socket,并通过 SO_SNDBUF 选项设置它的发送缓冲区大小为 4096 字节,连接到 Server 后,每 1 秒发送一个 TCP 数据段长度为 1024 的报文。Server 端不调用 recv()。预期的结果分为以下几个阶段:

Phase 1 Server 端的 socket 接收缓冲区未满,所以尽管 Server 不会 recv(),但依然能对 Client 发出的报文回复 ACK;

Phase 2 Server 端的 socket 接收缓冲区被填满了,向 Client 端通告零窗口(Zero Window)。Client 端待发送的数据开始累积在 socket 的发送缓冲区;

Phase 3 Client 端的 socket 的发送缓冲区满了,用户进程阻塞在 send() 上。

实际执行时,表现出来的现象也"基本"符合预期。

不过当我们在 Client 端通过 ss -nt 不时监控 TCP 连接的发送队列长度时,发现这个值竟然从 0 最终增长到 14480,它轻松地超了之前设置的 SO_SNDBUF 值(4096)

# ss -nt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 192.168.183.130:52454 192.168.183.130:14465
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 1024 192.168.183.130:52454 192.168.183.130:14465
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 2048 192.168.183.130:52454 192.168.183.130:14465
......
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 13312 192.168.183.130:52454 192.168.183.130:14465
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 14336 192.168.183.130:52454 192.168.183.130:14465
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 14480 192.168.183.130:52454 192.168.183.130:14465

有必要解释一下这里的 Send-Q 的含义。我们知道,TCP 是的发送过程是受到滑动窗口限制。

这里的 Send-Q 就是发送端滑动窗口的左边沿到所有未发送的报文的总长度。

那么为什么这个值超过了 SO_SNDBUF 呢?

双倍 SO_SNDBUF

当用户通过 SO_SNDBUF 选项设置套接字发送缓冲区时,内核将其记录在 sk->sk_sndbuf 中。

@sock.c: sock_setsockopt
{
case SO_SNDBUF:
.....
sk->sk_sndbuf = mat_x(u32, val * 2, SOCK_MIN_SNDBUF)
}

注意,内核在这里玩了一个小 trick,它在 sk->sk_sndbuf 记录的的不是用户设置的 val, 而是 val 的两倍

也就是说,当 Client 设置 4096 时,内核记录的是 8192 !

那么,为什么内核需要这么做呢?我认为是因为内核用 sk_buff 保存用户数据有额外的开销,比如 sk_buff 结构本身、以及 skb_shared_info 结构,还有 L2、L3、L4 层的首部大小.这些额外开销自然会占据发送方的内存缓冲区,但却不应该是用户需要 care 的,所以内核在这里将这个值翻个倍,保证即使有一半的内存用来存放额外开销,也能保证用户的数据有足够内存存放。

但是,问题现象还不能解释,因为即使是 8192 字节的发送缓冲区内存全部用来存放用户数据(额外开销为 0,当然这是不可能的),也达不到 Send-Q 最后达到的 14480 。

sk_wmem_queued

既然设置了 sk->sk_sndbuf, 那么内核就会在发包时检查当前的发送缓冲区已使用内存值是否超过了这个限制,前者使用 sk->wmem_queued 保存。

需要注意的是,sk->wmem_queued = 待发送数据占用的内存 + 额外开销占用的内存,所以它应该大于 Send-Q

@sock.h 
bool sk_stream_memory_free(const struct sock* sk)
{
if (sk->sk_wmem_queued >= sk->sk_sndbuf) // 如果当前 sk_wmem_queued 超过 sk_sndbuf,则返回 false,表示内存不够了
return false;
.....
}

sk->wmem_queued 是不断变化的,对 TCP socket 来说,当内核将 skb 塞入发送队列后,这个值增加 skb->truesize (truesize 正如其名,是指包含了额外开销后的报文总大小);而当该报文被 ACK 后,这个值减小 skb->truesize。

tcp_sendmsg

以上都是铺垫,让我们来看看 tcp_sendmsg 是怎么做的。总的来说内核会根据发送队列(write queue)是否有待发送的报文,决定是 创建新的 sk_buff,或是将用户数据追加(append)到 write queue 的最后一个 sk_buff

int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
mss_now = tcp_send_mss(sk, &size_goal, flags);

// code committed
while (msg_data_left(msg)) {
int copy = 0;
int max = size_goal;

skb = tcp_write_queue_tail(sk);
if (tcp_send_head(sk)) {
......
copy = max - skb->len;
}

if (copy <= 0) {
/* case 1:alloc new skb */
new_segment:
if (!sk_stream_memory_free(sk))
goto wait_for_sndbuf; // 如果发送缓冲区满了 就阻塞进程 然后睡眠

skb = sk_stream_alloc_skb(sk,
select_size(sk, sg),
sk->sk_allocation,
skb_queue_empty(&sk->sk_write_queue));
}
......
/* case 2:copy msg to last skb */
......
}

Case 1.创建新的 sk_buff

在我们这个问题中,Client 在 Phase 1 是不会累积 sk_buff 的。也就是说,这时每个用户发送的报文都会通过 sk_stream_alloc_skb 创建新的 sk_buff。

在这之前,内核会检查发送缓冲区内存是否已经超过限制,而在Phase 1 ,内核也能通过这个检查。

static inline bool sk_stream_memory_free(const struct sock* sk)
{
if (sk-?sk_wmem_queued >= sk->sk_sndbuf)
return false;
......
}

Case 2.将用户数据追加到最后一个 sk_buff

而在进入 Phase 2 后,Client 的发送缓冲区已经有了累积的 sk_buff,这时,内核就会尝试将用户数据(msg中的内容)追加到 write queue 的最后一个 sk_buff。

需要注意的是,这种搭便车的数据也是有大小限制的,它用 copy 表示

@tcp_sendmsg

int max = size_goal;

copy = max - skb->len;

这里的 size_goal 表示该 sk_buff 最多能容纳的用户数据,减去已经使用的 skb->len, 剩下的就是还可以追加的数据长度。

那么 size_goal 是如何计算的呢?

tcp_sendmsg
|-- tcp_send_mss
|-- tcp_xmit_size_goal

static unsigned int tcp_xmit_size_goal(struct sock* sk, u32 mss_now, int large_allowed)
{
if (!large_allowed || !sk_can_gso(sk))
return mss_now;
.....
size_goal = tp->gso_segs * mss_now;
.....
return max(size_goal, mss_now);
}

继续追踪下去,可以看到,size_goal 跟使用的网卡是否使能了 GSO 功能有关。

  • GSO Enable:size_goal = tp->gso_segs * mss_now
  • GSO Disable: size_goal = mss_now

在我的实验环境中,TCP 连接的有效 mss_now 是 1448 字节,用 systemtap 加了探测点后,发现 size_goal 为 14480 字节!是 mss_now 的整整 10 倍

所以当 Clinet 进入 Phase 2 时,tcp_sendmsg 计算出 copy = 14480 - 1024 = 13456 字节。

可是最后一个 sk_buff 真的能装这么多吗?

在实验环境中,Phase 1 阶段创建的 sk_buff ,其 skb->len = 1024, skb->truesize = 4372 (4096 + 256,这个值的详细来源请看 sk_stream_alloc_skb)

这样看上去,这个 sk_buff 也容纳不下 14480 啊。

再继续看内核的实现,再 skb_copy_to_page_nocache() 拷贝之前,会进行 sk_wmem_schedule()

tcp_sendmsg
{
/* case 2:copy msg to last skb */
......
if (!sk_wmem_schedule(sk, copy))
goto wait_for_memory;

err = skb_copy_to_page_nocache(sk, &msg->msg_iter, skb,
pfrag->page,
pfrag->offset,
copy);
}

而在 sk_wmem_schedule 内部,会进行 sk_buff 的扩容(增大可以存放的用户数据长度).

tcp_sendmsg
|--sk_wmem_schedule
|-- __sk_mem_schedule
__sk_mem_schedule(struct sock* sk, int size, int kind)
{
sk->sk_forward_alloc += amt * SK_MEM_QUANTUM;
allocated = sk_memory_allocated_add(sk, amt, &parent_status);
......
// 后面有一堆检查,比如如果系统内存足够,就不去看他是否超过 sk_sndbuf
}

通过这种方式,内核可以让 sk->wmem_queued 在超过 sk->sndbuf 的限制。

我并不觉得这样是优雅而合理的行为,因为它让用户设置的 SO_SNDBUF 形同虚设!那么我可以增么修改呢?

  • 关掉网卡 GSO 特性
  • 修改内核代码, 将检查发送缓冲区限制移动到 while 循环的开头。

while (msg_data_left(msg)) {
int copy = 0;
int max = size_goal;

+ if (!sk_stream_memory_free(sk))
+ goto wait_for_sndbuf;

skb = tcp_write_queue_tail(sk);
if (tcp_send_head(sk)) {
if (skb->ip_summed == CHECKSUM_NONE)
max = mss_now;
copy = max - skb->len;
}

if (copy <= 0) {
new_segment:
/* Allocate new segment. If the interface is SG,
* allocate skb fitting to single page.
*/
- if (!sk_stream_memory_free(sk))

- goto wait_for_sndbuf;
EOF

你好,我是飞宇。日常分享C/C++、计算机学习经验、工作体会,欢迎点击此处查看我以前的学习笔记&经验&分享的资源。

我组建了一些社群一起交流,群里有大牛也有小白,如果你有意可以一起进群交流。

欢迎你添加我的微信,我拉你进技术交流群。此外,我也会经常在微信上分享一些计算机学习经验以及工作体验,还有一些内推机会

加个微信,打开另一扇窗

经常遇到有读者后台私信想要一些编程学习资源,这里分享 1T 的编程电子书、C/C++开发手册、Github上182K+的架构路线图、LeetCode算法刷题笔记等精品学习资料,点击下方公众号会回复"编程"即可免费领取~

感谢你的分享,点赞,在看三  



C语言与CPP编程 C语言/C++开发,C语言/C++基础知识,C语言/C++学习路线,C语言/C++进阶,数据结构;算法;python;计算机基础等
评论
  • 这篇内容主要讨论三个基本问题,硅电容是什么,为什么要使用硅电容,如何正确使用硅电容?1.  硅电容是什么首先我们需要了解电容是什么?物理学上电容的概念指的是给定电位差下自由电荷的储藏量,记为C,单位是F,指的是容纳电荷的能力,C=εS/d=ε0εrS/4πkd(真空)=Q/U。百度百科上电容器的概念指的是两个相互靠近的导体,中间夹一层不导电的绝缘介质。通过观察电容本身的定义公式中可以看到,在各个变量中比较能够改变的就是εr,S和d,也就是介质的介电常数,金属板有效相对面积以及距离。当前
    知白 2025-01-06 12:04 170浏览
  • 根据Global Info Research项目团队最新调研,预计2030年全球封闭式电机产值达到1425百万美元,2024-2030年期间年复合增长率CAGR为3.4%。 封闭式电机是一种电动机,其外壳设计为密闭结构,通常用于要求较高的防护等级的应用场合。封闭式电机可以有效防止外部灰尘、水分和其他污染物进入内部,从而保护电机的内部组件,延长其使用寿命。 环洋市场咨询机构出版的调研分析报告【全球封闭式电机行业总体规模、主要厂商及IPO上市调研报告,2025-2031】研究全球封闭式电机总体规
    GIRtina 2025-01-06 11:10 104浏览
  • 根据环洋市场咨询(Global Info Research)项目团队最新调研,预计2030年全球无人机锂电池产值达到2457百万美元,2024-2030年期间年复合增长率CAGR为9.6%。 无人机锂电池是无人机动力系统中存储并释放能量的部分。无人机使用的动力电池,大多数是锂聚合物电池,相较其他电池,锂聚合物电池具有较高的能量密度,较长寿命,同时也具有良好的放电特性和安全性。 全球无人机锂电池核心厂商有宁德新能源科技、欣旺达、鹏辉能源、深圳格瑞普和EaglePicher等,前五大厂商占有全球
    GIRtina 2025-01-07 11:02 68浏览
  • 大模型的赋能是指利用大型机器学习模型(如深度学习模型)来增强或改进各种应用和服务。这种技术在许多领域都显示出了巨大的潜力,包括但不限于以下几个方面: 1. 企业服务:大模型可以用于构建智能客服系统、知识库问答系统等,提升企业的服务质量和运营效率。 2. 教育服务:在教育领域,大模型被应用于个性化学习、智能辅导、作业批改等,帮助教师减轻工作负担,提高教学质量。 3. 工业智能化:大模型有助于解决工业领域的复杂性和不确定性问题,尽管在认知能力方面尚未完全具备专家级的复杂决策能力。 4. 消费
    丙丁先生 2025-01-07 09:25 80浏览
  • 彼得·德鲁克被誉为“现代管理学之父”,他的管理思想影响了无数企业和管理者。然而,关于他的书籍分类,一种流行的说法令人感到困惑:德鲁克一生写了39本书,其中15本是关于管理的,而其中“专门写工商企业或为企业管理者写的”只有两本——《为成果而管理》和《创新与企业家精神》。这样的表述广为流传,但深入探讨后却发现并不完全准确。让我们一起重新审视这一说法,解析其中的矛盾与根源,进而重新认识德鲁克的管理思想及其著作的真正价值。从《创新与企业家精神》看德鲁克的视角《创新与企业家精神》通常被认为是一本专为企业管
    优思学院 2025-01-06 12:03 116浏览
  • 本文介绍Linux系统更换开机logo方法教程,通用RK3566、RK3568、RK3588、RK3576等开发板,触觉智能RK3562开发板演示,搭载4核A53处理器,主频高达2.0GHz;内置独立1Tops算力NPU,可应用于物联网网关、平板电脑、智能家居、教育电子、工业显示与控制等行业。制作图片开机logo图片制作注意事项(1)图片必须为bmp格式;(2)图片大小不能大于4MB;(3)BMP位深最大是32,建议设置为8;(4)图片名称为logo.bmp和logo_kernel.bmp;开机
    Industio_触觉智能 2025-01-06 10:43 87浏览
  • 在智能家居领域中,Wi-Fi、蓝牙、Zigbee、Thread与Z-Wave等无线通信协议是构建短距物联局域网的关键手段,它们常在实际应用中交叉运用,以满足智能家居生态系统多样化的功能需求。然而,这些协议之间并未遵循统一的互通标准,缺乏直接的互操作性,在进行组网时需要引入额外的网关作为“翻译桥梁”,极大地增加了系统的复杂性。 同时,Apple HomeKit、SamSung SmartThings、Amazon Alexa、Google Home等主流智能家居平台为了提升市占率与消费者
    华普微HOPERF 2025-01-06 17:23 141浏览
  • By Toradex 秦海1). 简介嵌入式平台设备基于Yocto Linux 在开发后期量产前期,为了安全以及提高启动速度等考虑,希望将 ARM 处理器平台的 Debug Console 输出关闭,本文就基于 NXP i.MX8MP ARM 处理器平台来演示相关流程。 本文所示例的平台来自于 Toradex Verdin i.MX8MP 嵌入式平台。  2. 准备a). Verdin i.MX8MP ARM核心版配合Dahlia载板并
    hai.qin_651820742 2025-01-07 14:52 44浏览
  • 村田是目前全球量产硅电容的领先企业,其在2016年收购了法国IPDiA头部硅电容器公司,并于2023年6月宣布投资约100亿日元将硅电容产能提升两倍。以下内容主要来自村田官网信息整理,村田高密度硅电容器采用半导体MOS工艺开发,并使用3D结构来大幅增加电极表面,因此在给定的占位面积内增加了静电容量。村田的硅技术以嵌入非结晶基板的单片结构为基础(单层MIM和多层MIM—MIM是指金属 / 绝缘体/ 金属) 村田硅电容采用先进3D拓扑结构在100um内,使开发的有效静电容量面积相当于80个
    知白 2025-01-07 15:02 75浏览
  • 每日可见的315MHz和433MHz遥控模块,你能分清楚吗?众所周知,一套遥控设备主要由发射部分和接收部分组成,发射器可以将控制者的控制按键经过编码,调制到射频信号上面,然后经天线发射出无线信号。而接收器是将天线接收到的无线信号进行解码,从而得到与控制按键相对应的信号,然后再去控制相应的设备工作。当前,常见的遥控设备主要分为红外遥控与无线电遥控两大类,其主要区别为所采用的载波频率及其应用场景不一致。红外遥控设备所采用的射频信号频率一般为38kHz,通常应用在电视、投影仪等设备中;而无线电遥控设备
    华普微HOPERF 2025-01-06 15:29 125浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦