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

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

image

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 是的发送过程是受到滑动窗口限制。

image

这里的 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;

全文完

我的个人主页
网址二维码.png

Image placeholder
DeanQin
未设置
  92人点赞

没有讨论,发表一下自己的看法吧

推荐文章
一个 TCP 接收缓冲区问题的解析

文本作为一个TCP发送缓冲区问题的解析的姊妹篇存在。这次说的是接收缓冲区的问题。问题模型Clinet与Server之间建立一条TCP连接,Server通过SO_RCVBUF选项设置连接的接收缓冲区为2

一文学会Java死锁和CPU 100% 问题的排查技巧

00本文简介作为一名搞技术的程序猿或者是攻城狮,想必你应该是对下面这两个问题有所了解,说不定你在实际的工作或者面试就有遇到过:第一个问题:Java死锁如何排查和解决?第二个问题:服务器CPU占用率高达

系统运行缓慢,CPU 100%,以及Full GC次数过多问题的排查思路

来源:http://t.cn/EI9JdBu处理过线上问题的同学基本上都会遇到系统突然运行缓慢,CPU100%,以及FullGC次数过多的问题。当然,这些问题的最终导致的直观现象就是系统运行缓慢,并且

Kafka 如何优化内存缓冲机制造成的频繁 GC 问题?

目录1、Kafka的客户端缓冲机制2、内存缓冲造成的频繁GC问题3、Kafka设计者实现的缓冲池机制4、总结一下“ 这篇文章,给大家聊一个硬核的技术知识,我们通过Kafka内核源码中的一些设计思想,来

JVM CPU Profiler技术原理及源码深度解析

本文介绍了JVM平台上CPUProfiler的实现原理,希望能帮助读者在使用类似工具的同时也能清楚其内部的技术实现。引言研发人员在遇到线上报警或需要优化系统性能时,常常需要分析程序运行行为和性能瓶颈。

基于Tcp协议与基于Http协议的RPC简介笔记

前言:之前对于RPC方面的学习多限于对RMI原理的学习,直到今天在看陈康贤前辈的《大型分布式网站架构-设计与实践》这本书的时候,才发现原来RPC可以基于TCP协议也可以基于HTTP协议(这里所说的TC

pymysql fetchone () , fetchall () , fetchmany ()

最近在用python操作mysql数据库时,碰到了下面这两个函数,标记一下: 1.定义 1.1fetchone(): 返回单个的元组,也就是一条记录(row),如果没有结果则返回None 1.2fet

由Linux内核bug引起SSH登录缓慢问题的排查与解决

今年7月,有一位用户反馈,使用该镜像创建出的快杰云主机每次启动时,第一次SSH登录会很慢,有时候需几十秒甚至几分钟才能登录成功,影响了使用体验。经过排查,定位到是Linux内核随机数熵池初始化慢的原因

关于面试题:[1, 2, 3].map(parseInt)问题的剖析

一、前言最近有小伙伴在公号中咨询了胡哥这道面试题,窃以为是比较有意思的一道面试题,于此分享给各位小伙伴。先把答案给了各位,和你理解的一样吗?![1,2,3].map(parseInt)//[1,NaN

笨办法学 Linux 学习处理文件,`pwd`,`ls`,`cp`,`mv`,`rm`,`touch`

Bash:处理文件,pwd,ls,cp,mv,rm,touch 在Linux中,一切都是文件。但是什么是文件?现在完全可以说,它是一个包含一些信息的对象。它通常定义如下: 计算机文件是用于存储信息的

树莓派 4 正式发布!硬件性能大提升:CPU提升3倍,支持USB3.0、蓝牙5.0、千兆以太网、4G LPDDR4、H.265

本文转自|EETOP树莓派(RaspberryPi)基金会,6月24日正式发布了RaspberryPi4ModelB。树莓派是全球知名的基本计算微型电脑,深受全球开发者、编程者、极客等人士的追捧和喜爱

用 Go 构建一个 SQL 解析器

在本文中,小编将向大家简单介绍如何在Go中构造LL(1)解析器,并应用于解析SQL查询。希望大家能用Go对简单的解析器算法有一个了解和简单应用。摘要本文旨在简单介绍如何在Go中构造LL(1)解析器,在

RTSP-ONVIF协议安防视频监控流媒体服务解决方案EasyNVR在Windows重启时提示“进程意外终止”问题解析

什么是ONVIFOpenNetworkVideoInterfaceForum,开放型网络视频接口论坛,以公开、开放的原则共同制定开放性行业标准。是一个提供开放网络视频接口的论坛组织。ONVIF规范描述

[Java 程序员眼中的 Linux] Linux 下常用压缩文件的解压、压缩

Linux下常用压缩文件的解压、压缩 常用压缩包解压命令整理 Linux后缀为.tar.gz格式的文件-解压 命令:tarzxvfXXXXXX.tar.gz Linux后缀为.bz2格式的文件-解压

【转】vue mounted 调用两次的解决办法

因为售后项目是vue框架,但是对vue没有什么实战经验,就一直从头排bug。最后发现当我把上面初始化方法注释,发现就请求结果是一位数值。就意识到是否mounted调用两次?网上一搜索果然验证了想法。下

Mac 跑代码报 Illegal key size 错误的解决方法

异常原因:如果密钥大于128,会抛出java.security.InvalidKeyException:Illegalkeysize异常.因为密钥长度是受限制的,java运行时环境读到的是受限的pol

做机器学习项目数据不够?这里有5个不错的解决办法

许多开展人工智能项目的公司都具有出色的业务理念,但是当企业AI团队发现自己没有足够多的数据时,就会慢慢变得十分沮丧……不过,这个问题的解决方案还是有的。本文将简要介绍其中一些经笔者实践证明确实有效的办

ES6系列之变量的解构赋值

1.什么是解构?ES6允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构。它在语法上比ES5所提供的更加简洁、紧凑、清晰。它不仅能减少你的代码量,还能从根本上改变你的编码方式。2.数

基于Go的语义解析开源库FMR,“屠榜”模型外的NLP利器

如何合理地表示语言的内在意义?这是自然语言处理业界中长久以来悬而未决的一个命题。 在2013年分布式词向量表示(DistributedRepresentation)出现之前,one-hot是最常用的

从 simplemde 写入 + inline-attachment 图片拖拽上传 到 parsedown 解析

###准备工作安装富文本编辑器sparksuite/simplemde-markdown-editor yarnaddsimplemde--save 安装markedjs/marked,在JS中解析

SQL 查询语句的执行顺序解析

代理搭建-shadowsockshttps://www.jetbrains.com/shop/eform/opens...https://blog.csdn.net/lianghecai5217131

微信支付退款解析 对加密串 B 做 AES-256-ECB 解密(PKCS7Padding)

微信支付退款解析对加密串B做AES-256-ECB解密(PKCS7Padding)1.微信支付文档 https://pay.weixin.qq.com/wiki/doc/api/H5....解密方式解

TPC-C解析系列03_TPC-C基准测试之SQL优化

TPC-C是一个非常严苛的基准测试模型,考验的是一个完备的关系数据库系统全链路的能力。这也是为什么在TPC-C的榜单前列,出现的永远只是大家熟知的那几家在业界有着几十年积累、从关系数据库理论开始发展就

TPC-C解析系列05_TPC-C基准测试之存储优化

TPC-C规范要求被测数据库的性能(tpmC)与数据量成正比。TPC-C的基本数据单元是仓库(warehouse),每个仓库的数据量通常在70MB左右(与具体实现有关)。TPC-C规定每个仓库所获得的

TPC-C解析系列01_TPC-C benchmark测试介绍

作者:阳振坤2019.10导语:自从蚂蚁金服自研数据库OceanBase获得TPC-C测试第一名后,引起了行业内外大量关注,我们衷心的感谢大家对OceanBase的支持与厚爱,也虚心听取外界的意见和建