Ранее я упоминал статью Netflix Investigation of a Cross-regional Network Performance Issue. В ней разбирается деградация скорости TCP-соединений между дата-центрами и проблема возникла из-за изменения алгоритма расчёта TCP Receive Window в новых версиях ядра.

Как часто бывает, такие материалы оставляют больше вопросов, чем ответов — “know unknown” чистой воды.

Я покопался в исходниках Linux, чтобы лучше понять механизм подсчета TCP Window. Делюсь изысканиями:)

Реализация TCP/IP в Linux для неподготовленного читателя как я, мягко говоря нетривиальна. В ней легче потеряться, чем что-то найти. Поэтому я сосредоточился на одной конкретной вещи: как и где задаётся переменная window_clamp, определяющая максимальную величину TCP Window.

Для исследования я использовал ядро версии 6.1 — дефолтное для Debian 12.

Поехали!


Чуть теории

Размер TCP Window определяется как:

min(cwnd, rwnd)  

где:

  • cwnd (congestion window) — определяется алгоритмами управления перегрузкой (Congestion Control);
  • rwnd (receive window) — зависит от механизма управления потоком (Flow Control).

Если упростить: rwnd определяется свободным местом в буфере сокета, а cwnd учитывает состояние сети.

Цель — вычислить размер TCP Window таким образом, чтобы:

  1. избежать перегрузки сети и приёмника (безопасность).
  2. обеспечить максимальную скорость передачи данных.

Это классический трейдофф между надежностью и производительностью.

Погружаемся

И так, данные поступают в буфер приёмника, подтверждающий ACK улетает обратно отправителю.

Приложение-приёмник асинхронно считывает данные из буфера с помощью системного вызова recvfrom(). На уровне ядра эта операция обрабатывается функцией tcp_recvmsg(), она выполняет три задачи:

  • передаёт данные приложению;
  • очищает буфер сокета;
  • оценивает текущий размер Окна и, если условия позволяют, увеличивает его.

Для изменения размера Окна tcp_recvmsg вызывает tcp_cleanup_rbuf -> __tcp_cleanup_rbuf, где:

...
		__u32 rcv_window_now = tcp_receive_window(tp);

		/* Optimize, __tcp_select_window() is not cheap. */
		if (2*rcv_window_now <= tp->window_clamp) {
			__u32 new_window = __tcp_select_window(sk);
...
  • определяется текущий размер Окна (rcv_window_now);
  • если он вдвое меньше значения window_clamp,
  • вычисляется новое Окно через __tcp_select_window.

__tcp_select_window выполняет множество проверок, включая установку верхней границы Окна через rcv_ssthresh:

...
	if (free_space > tp->rcv_ssthresh)
		free_space = tp->rcv_ssthresh;
...

Таким образом, размер TCP Окна ограничивается одновременно двумя параметрами: rcv_ssthresh и window_clamp.

Window Clamp

Глобальное ограничение на размер TCP-окна (window_clamp) задается:

  • при настройке сокета системным вызовом setsockopt с опцией TCP_WINDOW_CLAMP, см. man 7 tcp;
  • если значение не задано, ядро определяет его автоматически, основываясь на размере буфера.

Я не до конца разобрал все детали, но есть функция tcp_rcv_space_adjust, которая вызывается при каждом чтении данных. Она вычисляет текущий размер буфера.

Внутри нее вызывается tcp_win_from_space:

			/* Make the window clamp follow along.  */
			tp->window_clamp = tcp_win_from_space(sk, rcvbuf);

где и происходит пересчет window_clamp с использованием параметра net.ipv4.tcp_adv_win_scale (по дефолту равным 1):

static inline int tcp_win_from_space(const struct sock *sk, int space)
{
	int tcp_adv_win_scale = READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_adv_win_scale);

	return tcp_adv_win_scale <= 0 ?
		(space>>(-tcp_adv_win_scale)) :
		space - (space>>tcp_adv_win_scale);
}

Для примера:

  • параметр net.ipv4.tcp_adv_win_scale=1;
  • размер буфера приёма (rcvbuf) — 64 КБ.

Тогда формула примет вид:

(space >> 1) = 64 КБ / 2 = 32 КБ

Выводы

  1. абсолютное значение window_clamp зависит от размера буфера приёма, который находится в пределах параметров net.ipv4.tcp_rmem и net.core.rmem_max;
  2. относительное значение window_clamp связано с net.ipv4.tcp_adv_win_scale и rcvbuf. По умолчанию оно составляет 50%.

В новых версиях ядра механизм расчёта window_clamp изменился:

static inline int __tcp_win_from_space(u8 scaling_ratio, int space)
{
    s64 scaled_space = (s64)space * scaling_ratio;

    return scaled_space >> TCP_RMEM_TO_WIN_SCALE;
}

Ключевую роль здесь играет переменная scaling_ratio, которая напрямую зависит от значения TCP_DEFAULT_SCALING_RATIO:

#define TCP_DEFAULT_SCALING_RATIO (1 << (TCP_RMEM_TO_WIN_SCALE - 1))

Инициализация scaling_ratio происходит через tcp_scaling_ratio_init:

static inline void tcp_scaling_ratio_init(struct sock *sk)
{
    tcp_sk(sk)->scaling_ratio = TCP_DEFAULT_SCALING_RATIO;
}

До фикса от Netflix, TCP_DEFAULT_SCALING_RATIO был установлен на уровне 25%. Что и привело к деградации скорости за счет двукратного снижения лимита TCP Window.

Теперь немного понятнее 😉