漫漫长路,终于到了我们比较熟悉的 TCP 一层了,但很快就会发现上面这一大堆内容什么 NIC、中断、IP 路由之类的加起来可能都没有 TCP 一层的内容复杂,单是 goto 都比别的地方用的都多。因为 TCP 有状态,不同状态下收不到不同数据会有不同的行为,就导致了这个复杂度。为了不陷入 TCP 各种细节逻辑中,我们还是先只看最简单的连接处在 ESTABLISHED 状态的收消息过程。TCP 数据还分为 Normal 和 Urgent 两种,两种类型数据在处理过程中并不相同,为了简单起见,这里只大致介绍 Normal 的数据接收过程。

先推荐一本书叫做 《TCP/IP Architecture, Design and Implementation in Linux》 ,对 TCP 收消息这块逻辑讲的比较清楚,带着你理内核代码。如果有兴趣深究这块看这本书挺好的。

正常来说 TCP 收消息过程会涉及三个队列:

  1. Backlog Queue sk->sk_backlog
  2. Prequeue tp->ucopy.prequeue
  3. Receive Queue sk->sk_receive_queue

当然还有个 out of order queue tp->out_of_order_queue,先不管它,就先只看最简单的逻辑,不然会在复杂的 TCP 逻辑中迷失的。上述三个队列在处理数据的时候是序号大的队列优先级更高,先处理完序号大的队列之后才会处理序号小的队列。tcp_v4_rcv会负责将收到的数据包在上面三个队列之间做分配:

1
2
3
4
5
6
7
8
9
10
11
12
bh_lock_sock_nested(sk);
ret = 0;
if (!sock_owned_by_user(sk)) {
if (!tcp_prequeue(sk, skb))
ret = tcp_v4_do_rcv(sk, skb);
} else if (unlikely(sk_add_backlog(sk, skb,
sk->sk_rcvbuf + sk->sk_sndbuf))) {
bh_unlock_sock(sk);
NET_INC_STATS_BH(net, LINUX_MIB_TCPBACKLOGDROP);
goto discard_and_relse;
}
bh_unlock_sock(sk);

看到先是判断 socket 是否被 user 占用,如果被占用了,说明 User 正在读 socket 的数据,会操作 receivq queue 和 prequeue,为了避免并发操作 Socket,此时将数据包放入 backlog 队列中,如果没有成功放入 backlog 队列比如 backlog 队列已经满了,则会丢弃数据包并更新 TcpBacklogDrop 计数。

如果数据包到的时候 User 没有占用 Socket 则先尝试将数据包放入 Prequeue,如果因为一些原因(稍后会说)放入失败的话就将数据包传入 tcp_v4_do_rcv。在 tcp_v4_do_rcv 中如果连接已经处在 ESTABLISHED 状态,会走所谓的 Fast Path (过会在 tcp_rcv_established 内还会有个 Fast Path,都是为了在满足一些条件的情况下,跳过一些逻辑的优化),将数据包经由调用 tcp_rcv_established放入 Receive Queue。如果是连接没有处在 ESTABLISHED,说明可能当前 sk_buff 内有 TCP 状态控制相关指令,也可能还携带有数据。所以需要先到 rcp_rcv_state_process 经过一轮 TCP 状态转换,转换完之后再处理 sk_buff 内的数据。

tcp_rcv_established 内,从注释能看到 tcp_rcv_established 也有 Fast Path 和 Slow Path 之分,满足一大堆条件比如 Socket buffer 是否足够,TCP window 是否足够,是否不是 Urgent Data,数据是否单向流动(指当前机器上的这个 TCP 连接要么一直发数据,要么一直收数据)等之后,就能走 Fast Path,好处是更少的检查,更短的处理路径,从而能处理的更快。在 tcp_rcv_established 内无论是 Fast Path 还是 Slow Path,其功能都是将 sk_buff 拷贝到 User Space 或放入 receive queue。如果 tcp_rcv_established 是在 User Space 内调用,则满足数据有序的条件之后就会直接拷贝到 User Space,这条路径稍后再说,目前我们还一直处在软中断路径中。如果是软中断内调用 tcp_rcv_established 则会将 sk_buff 放入 Receive Queue,如果数据包是乱序到达,则将数据包放到 Out of order 队列

Receive Queue 可以认为是 softIRQ 和 User Space 的分界线,softIRQ 负责将数据放入队列,用户调用读取数据的系统调用后会读取队列数据。

Receive Queue

与 Backlog 和 Prequeue 的不同点在于,放入 Receive Queue 的 sk_buff 都是已经被处理过的,抹去了所有 Protocol Header 信息,只有 sk_buff 中真正有用的会拷贝到 User space 的数据会放入 Receive Queue。并且,Receive Queue 中的数据一定是符合 TCP 序列的,所以才能被直接拷贝到 User Space。而其它两个队列入队的时候都还有 Header 信息,还未经过处理,而且可能包含乱序的数据。

这里是入队过程,后续还有出队的过程,在 Receive Queue 出队部分会看到 tcp_v4_do_rcvtcp_rcv_established不光是在 softIRQ 内可能会执行,User Space 下也可能会执行。在 tcp_rcv_established 内如果发现用户进程正在读取 Socket,设置的 Receiver 刚好是当前进程(说明是 User Space 调用的 tcp_rcv_established,因为 Receiver 就是 Current 进程,并且数据的 SEQ 表明其刚好是下一个需要的数据包,则会先尝试将该 sk_buff 直接拷贝到 User Space。因为满足上面各种条件后,这个正在处理的数据包就一定是当前 TCP 连接上正在等待的下一个数据包,所以能直接将 sk_buff 拷贝到 User Space 不会产生乱序。如果不满足上面一堆条件,不能直接拷贝到 User Space,则会将 sk_buff 内的 TCP Header 抹去,并通过 __skb_queue_tail 将数据放入 sk_receive_queue。

Prequeue

如果没有开启 net.ipv4.tcp_low_latency 并且用户进程设置了 TCP Receiver Task 的话,说明有个 User 进程正在读 Socket (因为只有正在读 Socket 时才会设置 tp.ucopy.task)且还没有读够所需数据,正在 sleep 等待读入的数据。此时会将 sk_buff 放入 Prequeue。

一般进入 Prequeue 的 sk_buff 是不进行处理的,还保留有 tcp header 等信息,sk_buff 直接放入 Prequeue,处理工作交给用户进程完成。但是当 sk_buff 放入 Prequeue 后发现 socket 占用的内存超过了 sk_rcvbuf 的限制,则需要立即将所有 Prequeue 内的 sk_buff 出队,并通过 tcp_v4_do_rcv 开始处理,最终 sk_buff 会被放入 Receive Queue。每个被处理的 sk_buff 都会更新 TCPPrequeueDropped 计数。因为会将 TCP Header 去除,所以占用的内存大小会少一些,如果还是超过 sk_rcvbuf,则会丢弃数据包。

如果放入 Prequeue 的 sk_buff 是 Prequeue 内第一个元素,则一方面会重置 ACK 回复时间,延迟 ACK 回复;另一方面会唤醒正在 sleep 等待数据到来的 User 进程。

Backlog

如果收到数据包时,Socket 正在被 User 占用,可能 User 正在读取数据,会操作 Receive Queue 和 Prequeue,所以新收到的数据就暂时不放入这些队列中,而是放入 Backlog 里如果 Backlog 满了 会将数据包丢弃。

出队处理

上面都是数据包入队的过程,下面看看数据包出队的过程。

用户调用 read 系统调用从 Socket 上读取数据后最终会走到 tcp_recvmsg,在读取数据前先会将 Socket 上锁,之后计算期望读取的最小数据量。用户设置的期望读取数据量是 len,但是不一定非要读那么多数据才返回,系统有个 SO_RCVLOWAT 配置,表示如果当前没有 len 这么多数据时,就 block 住至少等待读到 SO_RCVLOWAT 这么多数据的时候才能返回。SO_RCVLOWAT 最少是 1 字节。SO_RCVLOWAT 的结果会存入 target 变量。

处理数据是在一个大的 do while 循环内完成,用于从 Receive quque 或 Prequeue 上循环的读取数据。优先处理 Receive Queue,从 sk->sk_receive_queue 上每取下一个 sk_buff,就拷贝到 User Space。因为 Receive Queue 上的数据都是符合当前 TCP 连接序列要求的,所以能这么直接拷贝,不会出现乱序。如果读到足够的数据超过 len 即用户期望的数据量,就退出循环直接返回,即使 Receive Queue 处理不完也会跳出循环。如果将 Receive Queue 处理完都一直读取不到 len 这么多数据,但至少读到了 target 这么多数据,也跳出循环去处理 Backlog 队列,不 Block 等待数据。

如果 Receive Queue 处理完没有读到 len 这么多数据,也没读到 target 这么多数据,并且开启了 Prequeue 机制,则要配置一个 Receiver Task,因为 Receiver Task 刚刚配置,所以 Prequeue 一定是空的,先不会进入 Prequeue 出队逻辑。因为还未读到 target 这么多的数据所以会 Block 住,等待数据到来Block Sleep 之前会释放 Socket 锁,从而在 Block 之后新来的数据不用进入 Backlog。用户进程占有 Socket 锁期间到来的数据都存在 Backlog 队列,只要用户进程释放 Socket 锁释放之前就会检查 Backlog 队列是否已经有数据,有数据的话会进行处理将数据从 Backlog 读出来通过 tcp_v4_do_rcv 放入 Receive Queue。因为这块逻辑是用户进程在执行,Backlog 内读出来的数据如果符合 TCP 序列是能直接拷贝到 User Space 的,拷贝过去后会更新 TCPDirectCopyFromBacklog 计数。

可以仔细体会一下上面处理数据的顺序,设计的还是挺精巧的,一共三个队列,之间还有优先级,必须高优先级的处理完,才能处理低优先级的。或者也许叫优先级不对,总之是个处理的先后顺序。

用户进程 Block 之后,Socket 的锁也释放了,Backlog 数据也处理完了,之前看到过 Prequeue 的入队逻辑会检查这个 Receiver Task,在 Receiver Task 被配置后新来的数据会全部进入 Prequeue,此时 Receive Queue 内的数据是之前 Backlog 内的数据。第一个新数据来的时候就会将 Block 住的用户进程唤醒。

用户进程被唤醒后还是按照顺序,先处理 Receive Queue,处理完之后再处理 Prequeue 上的数据,将 Prequeue 的数据取出来之后调用 tcp_v4_do_rcvsk_backlog_rcv 指向的就是 tcp_v4_do_rcvtcp_prequeue_process 内每处理一个 sk_buff 会更新 TCPPrequeued 计数。在 tcp_prequeue_process 内处理完 Prequeue 后会因为此时是用户进程执行的 tcp_prequeue_process 所以会将从 Prequeue 内拷贝到 User Space 的数据量更新在 TCPDirectCopyFromPrequeue 计数内。前面说过,如果数据包放入 Prequeue 的时候 Prequeue 已经满了,就会在 softIRQ 环境内直接将 Prequeue 内所有正在排队的数据进行处理,并且会更新 TCPPrequeueDropped 而不是 TCPDirectCopyFromPrequeue。

接着说出队过程。因为 Receiver Task 已经被设置并且一直未取消,所以只要读取的数据量小于 target 就一直在 while 内循环,总体就是:

  1. 处理 Receive Queue
  2. 处理 Prequeue
  3. 处理 Backlog
  4. Block
  5. 回到 1

如果 Receive Queue、Prequeue 处理完获取到了超过 target 的数据,则释放 Socket 锁去处理 Backlog,但在 release_lock 内处理完 Backlog 后会立即再次加锁,再次开始 Receive Queue 的处理,尽力读取到用户期望的 len 这么多数据。如果此时 Receive Queue 处理完,且 backlog 没数据,则退出循环

Prequeue 除了上面说的 User 进程会处理之外,在 ACK delay Handler 内也会处理,场景是这样。虽然 Prequeue 入队第一个数据后会去唤醒被 block 的进程,但如果当前机器负载过重,可能执行了唤醒但是目标进程很久都没被唤醒起来,此时延迟的 ACK 执行的时候会负责处理 Prequeue 内排队的数据。在 tcp_delack_timer 内如果 Socket 未被 User 进程占用,则会调用 tcp_delack_timeer_handler,能看到在这个 handler 内会从 Prequeue 取 sk_buff 下来,放入 sk_backlog_rcvtcp_v4_do_rcv 内处理。这种情况下会更新:TCPSchedulerFailed 计数。正常情况下这个计数应该是 0,系统不该忙到都该回复 ACK 了还唤不醒目标进程。在 netstat -s 中能看到这个统计:

1
4971 times receiver scheduled too late for direct processing

如果在 tcp_delack_timer 内 Socket 被 User 进程占用,则会更新 TCPSchedulerFailed 计数并延迟 ACK 的回复。

为什么要有 Prequeue

看到 Prequeue 是个可选项,默认是开启的但能通过 net.ipv4.tcp_low_latency来关闭。有这个选项存在就说明 Prequeue 存在的理由不像 Receive Queue 和 Backlog 一样那么明确可靠。所以我们需要看看 Prequeue 存在的原因。

有个关于 Prequeue 作用的讨论在这里: Linux Kernel - TCP prequeue performance,可以参考一下。

如果关闭 Prequeue,我们知道如果 Socket 没有被 User 占用,收到的 sk_buff 会直接调用 tcp_v4_do_rcv 进行处理,放入 Receive Queue,这一切都会在 softIRQ 的 context 中执行,最关键的是在放入 Receive Queue 后会回复 ack,而实际此时用户进程并没有实际收到数据,离用户进程起来处理数据还有一段时间。这就导致对端收到 ack 后认为对方能很快处理数据从而会发的更快,直到对方 Receive Queue 满了之后突然不再回复 ack,开始丢包。而一般情况下 TCP 连接对性能影响最大的就是丢包,重传,所以需要尽可能避免上述情况的发生。这种情形下,ack 相当于是只送达了对方机器就被回复了,而没有送到目标进程。

有了 Prequeue 之后,ack 会有两种回复方式,一种是用户进程被唤醒将 Prequeue 数据读入 Receive Queue 后回复 ack,这种时候数据是确认送达用户进程了。另一种是用户进程迟迟无法被唤醒,延迟 ack 的定时器被触发而回复 ack,这样也能减慢 ack 回复速度让对端知道这边处理性能有点跟不上,要慢点发数据。两种方式都能减少或避免之前说的问题,这也是 Prequeue 存在的意义。

但是对于体量小延迟又要求高的数据包,Prequeue 的存在又会增加延迟。原因是如果关闭了 Prequeue 机制,每来一条数据都要经过 tcp_v4_do_rcv 的处理,上面我们只看了一下 Fast Path,但能走 Fast Path 的要求还是比较苛刻的,不能有乱序到达,数据只能是单向,要么单向收要么单向发等等条件,只要有一条不满足就要走 Slow Path。Slow Path 内各种检查会更多,更麻烦一些。除了检查还一个耗时的是计算 checksum。如果没有 Prequeue 则这些逻辑全部要在 softIRQ context 内完成。在 User 进程被唤醒前可能只能放很少的数据到 Receive Queue 内。而有了 Prequeue 后,softIRQ 内只需要将数据包放入队列,不做任何检查和处理,接着就能处理下一个数据包,等到用户进程被唤醒后能从 Prequeue 批量处理数据。

不过 Prequeue 是 Linux 特有的机制,近些年因为 NIC 会自动计算 checksum,不需要在收到数据过程中再计算了,所以 Prequeue 存在的意义基本只是延迟 ack 回复到用户进程内这一个。开启它实际对延迟增加并不明显: the myth of /proc/sys/net/ipv4/tcp_low_latencyWhat is the linux kernel parameter tcp_low_latency?在 IPV6 内更是去掉了这个机制。

Socket Buffer 管理

sk_rcvbuf

从上面队列的描述我们发现这几个队列都是简单的链表,都没看到队列长度的限制,没有限制则数据不断到来的时候一定可能会出现收到的数据占满系统内存的情况,所以这几个队列长度肯定都是有限制的。而限制的方法是通过限制给 Socket 分配的最大占用内存量来实现的。每个 Socket 系统都会分配一个最大内存使用量,Socket 内除了收消息过程因为数据排队会占用这个最大分配的内存量配额外,发消息过程也会有排队,也会占用这个内存最大使用量配额。也就是说收发消息过程是共用这个内存使用量的。Socket 当前分配的内存使用量由 sk->sk_forward_alloc 记录。

Socket 的 sk_forward_alloc 不是一开始就分配的,而是在收到数据包放入接收队列后 sk_buff 的大小会算入 Socket 的内存使用量。发出的消息进入发出队列的时候也会算入 sk_forward_alloc。等 sk_buff 从 Socket 中取出来被处理之后,Socket 的 sk_forward_alloc 就减小了。稍后会看一下接收过程中 sk_forward_alloc 的变化过程。

为了避免收发消息过程相互影响,比如出现用户进程长时间不处理 Socket 收到的数据导致大量数据在 Socket 内排队,将 Socket 内存额度全部占满而无法发消息,分别有 sk->sk_rcvbuf 和 sk->sndbuf 限制收发消息最大占用内存量。

对收消息过程来说,Socket 占用内存量就是 Receive Queue、Prequeue、Backlog、Out of order 队列内排队的 sk_buff 占用内存总数。当数据被拉取到 User Space 后,就不再占用 Socket 的内存。这里有几个需要注意的,一个是发送过程和接收过程共用分配的 Socket 内存总量 sk_forward_alloc。对收消息过程来说,Receive Queue、Prequeue、Backlog、Out of order 共同占用的内存量不能超过 sk->sk_rcvbuf。如果用户进程处理消息较慢,大量消息在 Receive Queue、Prequeue、Backlog 内排队,则 Out of order 队列的大小会受到限制,而 Out of order 队列大小会影响 TCP Receive Window 的大小,从而在用户进程处理消息慢的时候能通过减小 Receive Window 让对端减慢发消息速度。

一般来说 Socket 的 sk_rcvbuf 受到两个配置的控制:

1
2
sysctl -w net.core.rmem_max=8388608
sysctl -w net.core.rmem_default=8388608

rmem_default 是 Socket 初始时默认的 sk_rcvbuf 大小,如果你不希望用系统默认值,想为某个特殊的 Socket 单独设置 sk_rcvbuf 的大小,则能通过调用 setsockopt传递 SO_RCVBUF 设置单个 Socket 的 sk_rcvbuf 值,但是设置的值不能超过 rmem_max 上限。不过可以通过配置 SO_RCVBUFFORCE 来强制设置 sk_rcvbuf 为超过 rmem_max 的值。

net.ipv4.tcp_rmem

对于 TCP 连接来说稍微特别一些,除了 sk_rcvbuf 的限制之外,TCP 还有自己的一套 Socket 接收 Buffer 的限制机制,能根据系统当前所有 TCP 连接占用的总内存量判断系统压力级别,来决定是否能为某个 Socket 继续分配接收 Buffer。这里要区分清楚的是 sk_rcvbuf 是 Socket 接收 buffer 分配的上限,而 Socket 当前实际分配的接收 buffer 大小是 sk_rmem_alloc 记录。连接每次收到一个 sk_buff 放入 Socket 队列之后,就会增加 sk_rmem_alloc 并减少 sk_forward_alloc 的值,sk_forward_alloc 不够的时候就需要向系统申请配额。如果系统上只有一个连接,那 Socket 分配的接收 Buffer 没有达到 sk_rcvbuf 之前,系统可能都会允许给这个连接继续分配接收 buffer。但是如果系统上有几百万连接,占用了大量的内存,每个连接都分为 sk_rcvbuf 这么多接收 Buffer 的话系统可能会支撑不住,所以 TCP 的接收 Buffer 的限制机制就是在 Socket 的接收 Buffer 还未到达 sk_rcvbuf 之前就根据当前系统负载情况,在负载特别大的时候拒绝 Socket 扩大接收 buffer 的申请。

跟 tcp 连接的这个接收 Buffer 限制机制相关的配置是 net.ipv4.tcp_rmem ,是个数组,有三个值分别是 min, default, max,给 TCP Socket 分配 sk_rcvbuf 时会根据系统当前压力级别从 min, default, max 三个值中选择,用以控制 Socket 接收 Buffer 的大小。

min、default、max 的默认值在 TCP 层初始化的时候设置

net.ipv4.tcp_moderate_rcvbuf

TCP 有个自动调节 sk_rcvbuf 的机制,在 net.ipv4.tcp_moderate_rcvbuf 置位后开启,默认是开启的。TCP 连接建立完毕进入 ESTABLISHED 状态后会立即调整 Socket 内各种 buffer 大小,其中包括 sk_rcvbuf 和 sk_sndbuf,还会初始化 TCP 各种 window。如果默认的 sk_rcvbuf 过小会自动进行扩大。其主要是依据 TCP Receive Window 的大小在做调节。

tcp_recvmsg 内每处理完一个 sk_buff 就会通过调用 tcp_rcv_space_adjust 调节 User receive space 大小,并且如果 net.ipv4.tcp_moderate_rcvbuf开启的话就会调节 sk_rcvbuf 的大小

如果用户通过 SO_RCVBUF 设置了 sk_rcvbuf,则会在 Socket 内设置 SOCK_RCVBUF_LOCK,从而跳过每个可能会自动设置 sk_rcvbuf 的地方:linux/net/ipv4/tcp_input.c - Elixir - Free Electronslinux/net/ipv4/tcp_input.c - Elixir - Free Electrons

在好些地方看到说 net.ipv4.tcp_moderate_rcvbuf 设置后 net.ipv4.tcp_rmem 就不起作用了。实际从上面机制看到这两个完全是不同的配置,一个管理的是 sk_rcvbuf 是 Socket 接收 buffer 的上限,net.ipv4.tcp_rmem则是 Socket 分配内存大小,并不太一样。

收消息过程的内存分配

先看 Backlog 比较简单,tcp_v4_rcv 内入队 Backlog 时会将 sk->sk_rcvbuf 、 sk->sk_sndbuf 之和作为 limit 参数传入 sk_add_backlog 并会在一开始就判断 Backlog 是否满了sk_rcvqueues_full 实现相当于是在判断:

1
sk->sk_backlog.len + atomic_read(&sk->sk_rmem_alloc) 是否大于 sk_rcvbuf + sk_sndbuf

为什么这里 limit 是 sk_rcvbuf + sk_sndbuf 可能得在看完发消息过程后找到答案。

如果 Backlog 满了就丢弃 sk_buff,没有满会将 sk_buff 加入 Backlog 并将 sk_buff 的大小加入 sk_backlog.len。Backlog 出队时会将 sk_backlog.len 设置为 0

再看 Prequeue 入队时候更新的是 tp->ucopy.memory,当 tp->ucopy.memory 大于 sk_rcvbuf 的时候就认为 prequeue 满了,会立即清理 prequeue。清理完后会将 tp->ucopy.memory 设置为 0。在 Prequeue 的另一个出队的地方 tcp_prequeue_process 内也有同样的逻辑,Prequeue 清理完后会设置 tp->ucopy.memory 为 0

最后是 Receive queue,sk_buff 放入 Receive queue 后会设置当前 Socket 为该 sk_buff 的 owner,在 skb_set_owner_r会将数据包大小更新到 sk_rmem_alloc 中,并且会从 sk_forward_alloc 中将分配给 sk_rmem_alloc 的内存减去

如果 sk_forward_alloc 目前没有足够的内存,则不会这么顺利的将 sk_buff 放入 Receive queue。需要走到 tcp_data_queue分配 sk_forward_alloc。如果此时 receive queue 是空的,则强制分配内存给 sk_forward_alloc,分配的时候会将 sk_buff 的大小圆整到 page size 的倍数。如果 receive queue 不是空,则通过 tcp_try_rmem_schedule 来尝试分配内存。可以看到如果 sk_rmem_alloc 已经大于 sk_rcvbuf,就不会尝试再新分配内存,而是直接开始 tcp_prune_queue 清理 Receive queue 以尝试挤出一点内存空间。如果 sk_rmem_alloc 还未大于 sk_rcvbuf,则进入 sk_rmem_schedule 来分配内存。sk_rmem_schedule 又会调用 __sk_mem_schedule 来完成内存分配,会进行各种检查,检查主要涉及到 net.ipv4.tcp_mem 这个配置,和 net.ipv4.tcp_rmem 一样,net.ipv4.tcp_mem 也是个数组,有三个值分别是 low, pressure, high 例如:

1
net.ipv4.tcp_mem = 769401 1025869 1538802

注意其单位是 Page 数,不是字节数。作用就是判断当前系统 TCP 连接占用的内存总数处在什么级别,从而在分配内存的时候决定是否允许分配。

__sk_mem_schedule 内基本逻辑如下:

  1. 根据需要分配的内存 size 圆整并换算为 Page 数;
  2. 将 sk_forward_alloc 增加 Page 数量乘以 Page 大小;
  3. 更新系统所有 TCP 连接占用内存的统计;
  4. 如果当前 TCP 连接占用内存总数(包括第 2 步这个刚分配过的内存)小于 tcp_mem 0,则直接允许分配。如果当前系统处在 presure 状态则切换回 low 状态;
  5. 如果当前 TCP 连接占用内存总数大于 tcp_mem 1,则系统进入 presurre 状态,并且还要继续后续判断;
  6. 如果当前 TCP 连接占用内存总数大于 tcp_mem 2,则直接放弃内存分配,会在 tcp_try_rmem_schedule 内开始 prune queue;
  7. 能走到这里当前 TCP 连接占用内存总数一定是在 tcp_mem 0 ~ 2 之间,如果已分配内存数 sk_rmem_alloc 小于 tcp_rmem[0] 则允许分配,之前说过不管连接是否处在 pressure 都允许 Socket 的接收 buffer 至少分配 tcp_rmem[0] 这么多内存;
  8. 如果当前不在 pressure 状态,则表示 TCP 连接占用内存总数在 tcp_mem 0 ~ 1 之间,则允许分配;
  9. 如果当前在 pressure 状态,则表示 TCP 连接占用内存总数在 tcp_mem 1 ~ 2 之间,如果当前系统所有 socket 分配的内存都是当前 socket 这么多,占用的内存总数是否会超过 tcp_mem 2,如果不超过则也允许分配;
  10. 其它情况都不允许分配,会回退 sk_forward_alloc 上增加的内存数;

__sk_mem_schedule 内前 7 步都比较直观,很容易看明白。但sk_has_memory_pressure 这里可能会开始有疑惑。实际上这里判断的不是 Socket 是否曾经进入过 pressure 状态,而是说当前这个 Socket 是否有 memory pressure 这个标志位。有的协议可能没有,有的协议有。TCP 协议是有的,所以这里 sk_has_memory_pressure 对 TCP 的 Socket 来说是一定存在的。它实际判断的是 struct sock_common 的 struct proto是否有初始化过 memory_pressure 这个字段。而对 TCP Socket 来说 struct proto 指向的是 tcp_proto,它是有 memory_pressure 字段的,不是 NULL,指向的是一个 int 变量

从 struct tcp_proto 我们也能看出来 TCP Socket 是怎么执行 enter memory pressure 的,它实际调用的是 tcp.c 内的 tcp_enter_memory_pressure,而 tcp_enter_memory_pressure 就是将上述说的 tcp_memory_pressure 这个 int 从初始的 0 设置为 1,就完成了 Socket 进入 pressure 状态的标记。不同的协议可能有不同的进入 memory pressure 的方式,所以 Linux 这里是通过协议实现自己的 struct proto,在 struct proto 内注册各种回调函数,完成不同协议执行不同逻辑这个功能的。

上面第 9 步可能也需要再体会一下,它是先通过调用 sk_sockets_allocated_read_positivetcp_sockets_allocated 读取了当前系统下分配了多少个 TCP Socket。这一步看到也是通过 struct proto 内注册来实现只读取 TCP Socket 数量的。读到当前系统 Socket 数量后,就假设这些 Socket 占用的内存都是当前正在分配内存的 Socket 这么多,那 TCP Socket 占用的内存总数是否会超过 tcp_mem 2 的限制。

Receive queue 内存释放

将 sk_buff 放入 Socket 的 Receive Queue 并增加 Socket 分配的 sk_rmem_alloc 时,我们会为 sk_buff 设置 destructor。在 sk_buff 从 Receive Queue 出队的时候会调用 sk_eat_skb,之后调用 __kfree_skb,再到 skb_release_all,再到 skb_release_head_state ,最终调用到 skb->destructor。skb->destructor 指向的是 sock_rfree,在 sock_rfree 内看到将 sk_buff 的大小从 sk_rmem_alloc 中减去,并加到 sk_forward_alloc 上。完成了 Socket 内存的释放。

各种参考

Potential Performance Bottleneck in Linux TCP International Journal of Communication Systems, John Wiley & Sons Ltd, 2006
https://blog.packagecloud.io/eng/2016/06/22/monitoring-tuning-linux-networking-stack-receiving-data/
Queueing in the Linux Network Stack | Linux Journal
http://www.linuxvox.com/2009/11/what-is-the-linux-kernel-parameter-tcp_low_latency/
linux/scaling.txt at master · torvalds/linux · GitHub
TCP/IP Architecture, Design and Implementation in Linux
Linux服务器丢包故障的解决思路及引申的TCP/IP协议栈理论 | SDNLAB | 专注网络创新技术
高性能网络编程7–tcp连接的内存使用 - 陶辉的专栏 - CSDN博客