Ранее я упоминал статью 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 таким образом, чтобы:
- избежать перегрузки сети и приёмника (безопасность).
- обеспечить максимальную скорость передачи данных.
Это классический трейдофф между надежностью и производительностью.
Погружаемся
И так, данные поступают в буфер приёмника, подтверждающий 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 КБ
Выводы
- абсолютное значение
window_clamp
зависит от размера буфера приёма, который находится в пределах параметровnet.ipv4.tcp_rmem
иnet.core.rmem_max
; - относительное значение
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.
Теперь немного понятнее 😉