Перейти до вмісту

UDP і TCP

Сервер працює вже місяць. Одного ранку нові клієнти отримують Connection refused або зависають на підключенні, хоча процес живий, процесор простоює, а канал порожній. У ss -tan двадцять тисяч рядків зі станом TIME-WAIT, і хтось на форумі радить «вимкнути». Або інакше: у тому ж виводі кілька тисяч CLOSE-WAIT, і вони не зникають, скільки не чекай.

У цих двох станів протилежні причини, і за різницею між ними про застосунок можна дізнатися більше, ніж із логів. Щоб її прочитати, треба знати, як TCP встановлює й закриває з’єднання, що таке вікно, чому сегмент може прийти двічі і як ядро вирішує, якому з тисячі сокетів віддати щойно прийнятий байт.

Модуль пояснює транспортний рівень так, як його бачить програміст і адміністратор: порти, два протоколи, рукостискання, вікно, повторні передачі, стани, черги на вході сервера. Перевантаження мережі винесено в модуль 12.

Передумови. Сокет і дескриптор файлу (модуль 3), заголовок IP і MTU (модуль 6). Як один потік обслуговує тисячі з’єднань через epoll, розібрано в модулі 13 курсу «Операційні системи», а тут цього не повторюємо: практична робота з ним — у лабораторній A7 того курсу.

Порти й демультиплексування

Section titled “Порти й демультиплексування”

IP-адреса доставляє пакет на потрібну машину. На машині працює багато процесів, і хтось має вирішити, кому з них призначено цей пакет. Цим займається транспортний рівень, а його інструмент — порт (port): 16-бітне число в заголовку.

Сервіс, що чекає на з’єднання, займає відомий порт (80, 443, 22), а клієнт отримує тимчасовий порт (ephemeral port) з діапазону, який задає net.ipv4.ip_local_port_range. У типовому Linux це 32768–60999 (у вашій системі перевірте sysctl net.ipv4.ip_local_port_range).

TCP-з’єднання однозначно визначає 4-кортеж (4-tuple): адреса й порт відправника плюс адреса й порт отримувача. Одного порту сервера для цього мало.

Клієнт Сервер З’єднання
10.0.0.2:43156 10.0.0.1:9000 перше
10.0.0.2:43160 10.0.0.1:9000 друге, той самий клієнт
10.0.0.3:43156 10.0.0.1:9000 третє, інший клієнт з тим самим номером порту

Усі вони йдуть на порт 9000 одного сервера й різняться лише кортежем. Так сервер і приймає десятки тисяч клієнтів на одному порту: ядро шукає пакет у хеш-таблиці за кортежем і знаходить потрібний сокет. Слухаючий сокет (LISTEN) зустрічає лише перший сегмент нового з’єднання, а далі accept() повертає вже інший, з’єднаний сокет із власним дескриптором.

Звідси обмеження, яке ще знадобиться: якщо клієнт відкриває багато з’єднань до одного сервера й порту, вільними для вибору лишаються тільки тимчасові порти. Їх приблизно 28 тисяч у типовому діапазоні, і це стеля одночасних (і нещодавно закритих, див. TIME_WAIT) з’єднань від однієї адреси до однієї пари «адреса сервера + порт».

Для UDP демультиплексування простіше: непідключений сокет визначається лише локальною адресою й портом, і всі датаграми на цей порт, хоч би від кого, потрапляють до нього. Тому один UDP-сокет обслуговує всіх клієнтів, а хто саме надіслав, програма дізнається з адреси, яку повертає recvfrom().

UDP: транспорт без обіцянок

Section titled “UDP: транспорт без обіцянок”

Заголовок UDP — 8 байтів: порт відправника, порт отримувача, довжина і контрольна сума. Більше в ньому нічого немає. Немає з’єднання, порядку, підтверджень, повторних передач і керування швидкістю. Датаграма пішла, а що з нею сталося далі, ніхто не повідомить.

  • Межі повідомлень зберігаються. Один sendto() — одна датаграма, один recvfrom() — одна датаграма цілком. Якщо буфер занадто малий, хвіст просто відкидається.
  • Ніяких гарантій. Датаграма може загубитися, дублюватися, прийти поза порядком. Розмір обмежений: понад MTU датаграма фрагментується на рівні IP (модуль 6), а втрата будь-якого фрагмента губить усю датаграму.

UDP тримається поруч із надійнішим TCP, бо надійність не безкоштовна, і не завжди потрібна саме така.

  • Запит-відповідь у один пакет. DNS (модуль 13): встановлювати з’єднання заради одного питання вдвічі дорожче за саме питання. Повторить запит сам застосунок.
  • Дані, що швидко старіють. Голос, відео в реальному часі, ігри: повторно переслана позиція гравця десятисекундної давності нікому не потрібна, а очікування її блокує все, що йде після.
  • Власний транспорт. QUIC (основа HTTP/3, модуль 14) працює поверх UDP: усю надійність і керування перевантаженням він реалізує сам, у просторі користувача, і не залежить від ядер проміжних вузлів.
  • Широкомовлення і групова розсилка. TCP створений для пари вузлів, тому DHCP та подібні протоколи використовують UDP.

Якщо UDP-сокет підключити (connect()), він запам’ятовує співрозмовника і починає отримувати ICMP-помилки: датаграма на закритий порт повертає ICMP «port unreachable», і наступний виклик повертає ECONNREFUSED. Непідключений сокет цієї помилки не побачить, і програма подумає, що просто ніхто не відповів.

TCP: потік байтів з гарантіями

Section titled “TCP: потік байтів з гарантіями”

TCP дає застосунку впорядкований надійний двонапрямний потік байтів. Слово «потік» тут головне: TCP не знає про ваші повідомлення. Чотири write() по 100 байтів можуть прийти одним read() на 400, а один write() на мегабайт розпадеться на сотні сегментів. Межі повідомлень будує застосунок (довжина на початку, роздільник, як у HTTP), і саме цим займається лабораторна A1.

Щоб дати гарантії, TCP тримає в кожного кінця стан: які байти надіслано, які підтверджено, скільки ще готовий прийняти співрозмовник. Стан створюється під час рукостискання.

  1. Клієнт надсилає сегмент із прапорцем SYN і своїм початковим номером послідовності.
  2. Сервер відповідає SYN+ACK: свій початковий номер і підтвердження клієнтського.
  3. Клієнт підтверджує ACK. Відтепер обидва боки готові до обміну даними.

Кожен бік має надіслати свій початковий номер і отримати підтвердження, що його прийняли. Сервер поєднує підтвердження клієнтського номера зі своїм у другому сегменті, тож замість чотирьох повідомлень виходить три. Початкові номери вибираються непередбачувано: так старий сегмент попереднього з’єднання з тим самим кортежем важче прийняти за новий, а підробити з’єднання стороннім важче.

У самому рукостисканні сторони домовляються про параметри, які вже не змінюються: максимальний розмір сегмента (MSS), чи вмикати вибіркові підтвердження (SACK), мітки часу і множник вікна (wscale). Ось справжній захоплений обмін між двома просторами імен на одній машині. -S вимикає відносні номери:

Terminal window
ip netns exec cli tcpdump -nn -S -i v2 tcp
IP 10.0.0.2.43156 > 10.0.0.1.9000: Flags [S], seq 3444928969, win 64240, options [mss 1460,sackOK,TS val 1669925612 ecr 0,nop,wscale 10], length 0
IP 10.0.0.1.9000 > 10.0.0.2.43156: Flags [S.], seq 1590647663, ack 3444928970, win 65160, options [mss 1460,sackOK,TS val 1424475657 ecr 1669925612,nop,wscale 10], length 0
IP 10.0.0.2.43156 > 10.0.0.1.9000: Flags [.], ack 1590647664, win 63, options [nop,nop,TS ...], length 0

S — це SYN, S. — SYN+ACK, крапка сама — просто ACK. Зверніть увагу на ack 3444928970: це початковий номер клієнта плюс один. Сам SYN «займає» один номер послідовності, як і FIN наприкінці. Тому підтвердження на нього дорівнює seq + 1, хоча даних не було. Друге число, win, — вікно отримувача (про нього нижче).

Рукостискання коштує одну затримку в обидва боки (RTT), перш ніж клієнт зможе відправити перший байт даних. Між Києвом і серверною за океаном це кілька десятків мілісекунд, вже втрачених до першого запиту, і ще стільки ж додасть TLS (модуль 15). Тому протоколи й переробляють так, щоб обмінів було менше, як-от повторне використання з’єднань у HTTP (модуль 14).

Номери послідовності і підтвердження

Section titled “Номери послідовності і підтвердження”

Номер послідовності (sequence number) у заголовку позначає перший байт даних у сегменті, а не сам сегмент: лічильник рахує байти. Підтвердження (acknowledgement number) означає: «усі байти з меншими номерами я отримав, наступним чекаю саме цей». Це кумулятивне підтвердження. Якщо прийшли байти 0–999 і 2000–2999, а 1000–1999 загубилися, отримувач і далі підтверджуватиме 1000 (відправник побачить дублікати ACK).

Одне кумулятивне число мало що каже про те, що прийшло за дірою, і для цього з’явилися вибіркові підтвердження (selective acknowledgements, SACK): у сегменті ACK отримувач додатково перелічує діапазони, які в нього вже є. Відправник тоді повторює лише справжню дірку. Ви бачили sackOK у рукостисканні: обидва боки погодилися.

Керування потоком: вікно отримувача

Section titled “Керування потоком: вікно отримувача”

Швидкий відправник може заповнити буфер повільного отримувача. У кожному сегменті отримувач пише, скільки вільного місця в його буфері прийому: це вікно оголошене отримувачем (receive window). Відправник не має права тримати «в польоті» (надіслано, але ще не підтверджено) більше цього.

Поле вікна в заголовку 16-бітне, тож без доповнення воно обмежене 65 535 байтами, а це на швидких каналах з великою затримкою нікчемно мало (добуток пропускної здатності на затримку, модуль 2). Тож під час рукостискання сторони домовляються за wscale про множник: wscale 10 означає, що значення поля треба помножити на 2¹⁰. У захопленні вище після рукостискання так і стоїть win 63: справжнє вікно 63 × 1024 = 64 512 байтів. Множник фіксується у SYN і далі не змінюється; якщо його прибрав файрвол чи проміжний пристрій, вікно лишається 64 КіБ, і швидкість падає невпізнанно.

Вікно змінюється протягом з’єднання: застосунок повільно читає з сокета, буфер заповнюється, вікно в ACK зменшується. Коли воно дійшло до нуля, відправник зупиняється й час від часу надсилає крихітний пробний сегмент, щоб дізнатися, чи не відкрилося вікно знову.

Ковзне вікно відправника: підтверджені байти, надіслані непідтверджені, дозволені до відправлення і забороненіпідтвердженонадіслано,ще не підтвердженоможна слати заразпоки не можнаSND.UNASND.NXTSND.UNA + вікновікно відправлення = min(вікно отримувача, вікно перевантаження)ACK зсуває ліву межу вправо, і вікно ковзає вперед
Вікно відправлення має праву межу: SND.UNA плюс менше з двох чисел, вікна отримувача і вікна перевантаження (про друге в модулі 12). Кожен новий ACK зсуває ліву межу й відкриває місце для нових байтів.

Швидкість відправника обмежує і вікно отримувача (скільки готовий прийняти співрозмовник), і вікно перевантаження (скільки витримає мережа між вами, модуль 12). Діє менше з них.

Якщо ACK на сегмент не приходить, відправник мусить його повторити. Складність у тому, коли саме: поспішиш, і в мережі з’являться зайві копії, забаришся, і з’єднання простоює.

Відправник вимірює RTT кожного підтвердженого сегмента, згладжує його й рахує таймер повторної передачі (retransmission timeout, RTO), дещо більший за типовий RTT з запасом на розкид. Початкові значення й формула — RFC 6298. На Linux RTO ніколи не падає нижче 200 мс: у виводі ss -ti нижче цілком локальне з’єднання з RTT в соті частки мілісекунди має rto:204. Після кожної невдалої спроби таймер подвоюється (експоненціальний відступ), тому сервер, що зник із мережі, виявляється не одразу.

Є й швидший шлях. Якщо приходять три дублікати того самого ACK, відправник робить висновок, що один сегмент загубився, а наступні дійшли, і повторює його негайно, не чекаючи RTO. Це швидка повторна передача (fast retransmit). З SACK відправник ще й точно знає, що повторювати.

Скільки ядро терпить мовчання, для встановлених з’єднань задає net.ipv4.tcp_retries2 (типово 15 спроб, за man 7 tcp від 13 до 30 хвилин залежно від RTO), а для нового підключення net.ipv4.tcp_syn_retries (типово 6, тобто близько двох хвилин, коли connect() нарешті здається). Через це «сервер не відповідає» на клієнті так довго тримає програму.

Що лежить у заголовку TCP

Section titled “Що лежить у заголовку TCP”

Мінімальний заголовок TCP — 20 байтів, удвічі й більше за UDP, і кожне поле з’явилося заради якогось з механізмів цього розділу.

Поле Навіщо
порт відправника, порт отримувача демультиплексування
номер послідовності порядок і місце сегмента в потоці
номер підтвердження кумулятивний ACK
прапорці SYN, ACK, FIN, RST та інші керують життєвим циклом з’єднання
вікно керування потоком (разом із wscale)
контрольна сума виявляє пошкодження заголовка й даних
опції MSS, wscale, SACK, мітки часу

Контрольна сума ловить випадкові спотворення, але не захищає від зловмисника: його сегмент матиме правильну суму. Захист від підробки дають лише випадкові початкові номери (проти стороннього) і шифрування вище (TLS, модуль 15).

Половина з’єднання: shutdown

Section titled “Половина з’єднання: shutdown”

close() закриває дескриптор цілком: бік більше нічого не надішле й нічого не прочитає. Але два напрямки TCP незалежні, і виклик shutdown(fd, SHUT_WR) закриває лише запис: надсилається FIN, а читати можна далі. Це потрібно, коли клієнт має сказати «запит закінчився, більше даних не буде», чекаючи відповіді на нього. Протоколи без довжини повідомлення (найпростіші, на зразок «читай до кінця потоку») на цьому й тримаються. Бік, що отримав FIN, бачить read(), який повертає 0: так виглядає кінець потоку.

Тиша на лінії: хто помітить, що співрозмовник зник

Section titled “Тиша на лінії: хто помітить, що співрозмовник зник”

TCP шле пакети лише тоді, коли є що слати. Якщо обидва боки мовчать, а на шляху тим часом перезавантажили NAT або витягли кабель, жоден не дізнається про це, бо нічого не відправляв. З’єднання у виводі ss виглядає цілком ESTABLISHED, хоч насправді мертве. Якщо ж мовчання порушить бік, що пише, він дістане помилку лише після всіх повторів із подвоєнням RTO (див. вище).

Для таких випадків у сокета є опція SO_KEEPALIVE: після довгого простою ядро надсилає порожній пробний сегмент, і відсутність відповіді розриває з’єднання. Вона вимкнена за замовчуванням, а її таймери вимірюються годинами (net.ipv4.tcp_keepalive_time), тому застосунки, що дбають про швидке виявлення, додають власний heartbeat на рівні протоколу або змінюють таймери для свого сокета. Окрім того, стан NAT і файрволів, через які йде з’єднання, живе обмежений час: довге мовчання без keepalive вони просто забувають (модуль 9), і наступний пакет отримує RST або тишу.

З’єднання проходить через цілий набір станів, і кожен видно в ss.

Спрощена діаграма станів TCP: встановлення з’єднання, закриття з боку ініціатора (TIME_WAIT) і з боку співрозмовника (CLOSE_WAIT)connect() / SYNlisten()отримано SYN / SYN+ACKSYN+ACK / ACKотримано ACKclose() / FINотримано ACKотримано FIN / ACKотримано FIN / ACKclose() / FINчерез 2×MSLотримано ACKCLOSEDSYN_SENTLISTENSYN_RCVDESTABLISHEDFIN_WAIT_1FIN_WAIT_2TIME_WAITCLOSE_WAITLAST_ACKCLOSEDзакриває першимзакривається у відповідьTIME_WAIT лишається на боці, який надіслав FIN першим; CLOSE_WAIT тримається, доки застосунок не викличе close()
Спрощена діаграма: без одночасного закриття (CLOSING) і скидань. Ліва колонка — бік, що закриває першим. Штриховою рамкою виділено два стани, які найчастіше цікавлять адміністратора.

Головне на діаграмі — закриття. Кожен напрямок потоку закривається окремо: той, хто закінчив передавати, шле FIN, і співрозмовник підтверджує його. Тому повне закриття — це чотири сегменти (у захопленні вище їх три, бо сервер об’єднав підтвердження й свій FIN в один; так буває, коли застосунок закриває відразу). Бік, що надіслав FIN першим, називають активним, інший пасивним. Від цього залежить, у який кінцевий стан потрапить кожен.

TIME_WAIT: навіщо він і на чиєму боці

Section titled “TIME_WAIT: навіщо він і на чиєму боці”

Після останнього ACK активний бік не переходить одразу в CLOSED, а тримає запис про з’єднання ще довго. Стандарт вимагає чекати дві максимальні тривалості життя сегмента (2×MSL), а Linux замість обчислення бере фіксовані 60 секунд. Чекати доводиться ось чому.

  1. Останній ACK міг загубитися. Тоді пасивний бік, не діставши підтвердження свого FIN, повторить його. Якщо активний бік уже забув про з’єднання, він відповів би RST («я тебе не знаю»), і пасивний бік побачив би помилку замість чистого закриття. У TIME_WAIT активний бік може ще раз підтвердити.
  2. Запізнілі сегменти старого з’єднання. Мережа може затримати пакет на кілька секунд. Якщо за ці секунди нове з’єднання з тим самим кортежем уже відкрито, чужий пакет із правильними номерами міг би потрапити в новий потік. TIME_WAIT не дає створити з’єднання з тим самим кортежем, доки старі сегменти не вичерпали свого життя.

Ось те саме на живому прикладі. Сервер (у просторі імен srv) закрив з’єднання першим, і TIME-WAIT лишився саме в нього, а у клієнта запису вже нема:

Terminal window
ip netns exec srv ss -tan
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 5 10.0.0.1:9000 0.0.0.0:*
TIME-WAIT 0 0 10.0.0.1:9000 10.0.0.2:52938

Тобто TIME_WAIT лишається на боці того, хто закрив першим. Це не завжди клієнт. HTTP-сервер, який після відповіді закриває з’єднання сам (HTTP/1.0 або завершення keep-alive за таймаутом), збирає TIME_WAIT у себе. Клієнт, що відкриває й закриває тисячі короткоживучих з’єднань до одного сервера, збирає їх у себе й впирається в тимчасові порти.

Самі по собі TIME_WAIT нормальні: вони не тримають дескрипторів і майже не тримають пам’яті. Проблемою вони стають лише тоді, коли кінчаються тимчасові порти або кортежі. У такому разі можна:

  • повторно використовувати з’єднання (keep-alive, пул), щоб їх було менше;
  • розширити діапазон портів або додати ще адрес сервера чи клієнта, щоб розширити простір кортежів;
  • net.ipv4.tcp_tw_reuse дозволяє вихідним з’єднанням повторно використати сокет у TIME_WAIT (це безпечно завдяки міткам часу). Значення за замовчуванням у нових ядрах 2 (дозволено лише для loopback).

CLOSE_WAIT: симптом помилки в застосунку

Section titled “CLOSE_WAIT: симптом помилки в застосунку”

CLOSE_WAIT виникає на пасивному боці. Співрозмовник закрив свій напрямок і прислав FIN, ядро вже підтвердило його, але застосунок сам ще не викликав close() на своєму кінці. Ядро чекатиме вічно: таймера, який закрив би такий сокет за вас, немає, бо вирішувати, коли закінчилась робота, має програма.

Якщо CLOSE_WAIT накопичуються, у коді завжди є помилка: забули закрити сокет на шляху помилки, загубили дескриптор у пулі, потік, що мав закрити з’єднання, завис. Демонстрація: сервер приймає з’єднання й «забуває» їх закрити, поки клієнти вже пішли:

Terminal window
ip netns exec srv ss -tanp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 5 10.0.0.1:9000 0.0.0.0:* users:(("python3",pid=2089,fd=3))
CLOSE-WAIT 0 0 10.0.0.1:9000 10.0.0.2:56130 users:(("python3",pid=2089,fd=5))
CLOSE-WAIT 0 0 10.0.0.1:9000 10.0.0.2:56126 users:(("python3",pid=2089,fd=4))

Опція -p показує процес і номер дескриптора: тепер відомо, чий це баг. На клієнті ті самі з’єднання застрягли у FIN-WAIT-2: він відправив FIN, отримав на нього ACK і чекає на FIN від сервера, який ніколи не прийде. Тут клієнт уже завершився, тож сокети осиротілі (orphan): жоден процес їх не тримає, і ядро зрештою прибере їх через tcp_fin_timeout; доки клієнтський процес живий і тримає сокет, стан лишається.

Швидка діагностика однією командою: скільки з’єднань у кожному стані.

Terminal window
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn

Багато TIME-WAIT — зазвичай наслідок короткоживучих з’єднань, і шукати треба на боці, що закриває першим. Багато CLOSE-WAIT — витік у застосунку на цьому самому хості.

Сегмент із прапорцем RST (reset) означає «цього з’єднання нема, забудь про нього». Його надсилають у кількох ситуаціях:

  • підключення до порту, на якому ніхто не слухає: ядро відповідає RST, а клієнт бачить Connection refused (ECONNREFUSED);
  • сегмент прийшов у з’єднання, про яке приймач нічого не знає (наприклад, після перезавантаження сервера або після TIME_WAIT, якого не було);
  • застосунок навмисно перервав з’єднання (SO_LINGER з нульовим таймаутом), щоб не залишати TIME_WAIT;
  • проміжний пристрій (файрвол, балансувальник) вирішив розірвати потік.
Terminal window
ip netns exec cli python3 -c "
import socket
try: socket.create_connection(('10.0.0.1', 9999))
except Exception as e: print(e)"
[Errno 111] Connection refused

Відрізняйте це від іншого симптому. Якщо файрвол мовчки відкидає (DROP) SYN, відповіді нема взагалі, і клієнт чекатиме на connect() аж до вичерпання повторів SYN. Тож швидка відмова означає «хост живий, порт закритий», а зависання — «десь по дорозі мовчки відкидають» (модуль 16).

Nagle і відкладене підтвердження

Section titled “Nagle і відкладене підтвердження”

Кожна з цих оптимізацій розумна сама по собі, а разом вони дають найпопулярніше «таємниче затримання на 40 мс».

Алгоритм Нейґла (Nagle). Якщо є непідтверджені дані, TCP не відправляє маленький сегмент, а накопичує нові байти, доки не прийде ACK або доки не набереться повний сегмент. Мета — не засмічувати мережу заголовками по 40 байтів на кожен натиснутий символ.

Відкладене підтвердження (delayed ACK). Приймач не підтверджує кожен сегмент одразу, а трохи чекає: може, з’явиться відповідь, на якій підтвердження поїде безкоштовно, або прийде ще один сегмент. У Linux мінімальна затримка близько 40 мс (у виводі ss -ti це поле ato:40).

Разом вони дають клінч. Застосунок пише запит двома викликами write(): заголовок і тіло. Перший сегмент іде одразу, другий Нейґл тримає, бо перший ще не підтверджений. Приймач не підтверджує перший, бо чекає, що щось надійде. Обидва чекають на іншого, аж доки не спливе 40 мс.

Лікування: збирати запит в один write()/writev() або вимкнути Нейґла на сокеті опцією TCP_NODELAY. Кожен серйозний клієнт HTTP і RPC її вмикає. Але це не універсальні ліки: якщо ви пишете дрібними шматками, TCP_NODELAY змусить слати кожен окремим сегментом.

Сервер: SO_REUSEADDR, backlog і черга accept

Section titled “Сервер: SO_REUSEADDR, backlog і черга accept”

Перезапуск сервера, який закривав з’єднання першим, наштовхується на TIME_WAIT: локальний порт 9000 усе ще фігурує в кортежах старих з’єднань, і bind() без спеціальної опції завершується помилкою:

$ python3 ru.py 0 # без SO_REUSEADDR
[Errno 98] Address already in use
$ python3 ru.py 1 # з SO_REUSEADDR
bind ok

SO_REUSEADDR дозволяє прив’язатись до порту, на якому лишаються лише TIME_WAIT. Двох слухачів на одну адресу й порт він не дозволяє (це вміє інша опція, SO_REUSEPORT, вона потрібна, щоб кілька процесів слухали один порт і ядро розподіляло між ними нові з’єднання). Практичне правило: майже кожен TCP-сервер має вмикати SO_REUSEADDR перед bind().

Коли приходить SYN, ядро не чекає на accept(). Воно саме проводить рукостискання, а готові з’єднання складає в чергу, з якої accept() їх забирає. Насправді черг дві:

  1. Черга неповних з’єднань: SYN прийшов, SYN+ACK надіслано, останнього ACK ще нема. Її розмір обмежує net.ipv4.tcp_max_syn_backlog, а коли вона переповнюється (наприклад, під час SYN-флуду), спрацьовують SYN-cookies (net.ipv4.tcp_syncookies), що дають змогу не пам’ятати неповні з’єднання.
  2. Черга готових з’єднань (accept queue): рукостискання завершено, але застосунок ще не викликав accept(). Її розмір — менше з аргумента listen(backlog) і net.core.somaxconn.

Коли друга черга повна, ядро скидає нові з’єднання, і клієнти бачать не відмову, а зависання й повторні SYN. Цю картину видно в ss -ltn: у слухаючого сокета Recv-Q — скільки з’єднань зараз чекає на accept(), Send-Q — максимум черги. Експеримент: слухач із listen(1), який ніколи не викликає accept(), і шість клієнтів.

Terminal window
ip netns exec srv ss -ltn sport = :9001
ip netns exec cli ss -tan
ip netns exec srv nstat -az TcpExtListenOverflows TcpExtListenDrops
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 2 1 10.0.0.1:9001 0.0.0.0:*
ESTAB 0 0 10.0.0.2:35058 10.0.0.1:9001
ESTAB 0 0 10.0.0.2:35068 10.0.0.1:9001
SYN-SENT 0 1 10.0.0.2:35078 10.0.0.1:9001
SYN-SENT 0 1 10.0.0.2:35090 10.0.0.1:9001
SYN-SENT 0 1 10.0.0.2:35104 10.0.0.1:9001
SYN-SENT 0 1 10.0.0.2:35110 10.0.0.1:9001
TcpExtListenOverflows 8
TcpExtListenDrops 8

Send-Q слухача — 1, тобто обіцяний backlog, а в черзі опинилося 2: Linux допускає на одне з’єднання більше за backlog. Двоє клієнтів вважають, що вони підключені, четверо застрягли в SYN-SENT. Лічильники ListenOverflows і ListenDrops у nstat підтверджують: ядро відкидало саме через переповнення. Тож сервер має швидко викликати accept() (тобто не робити в тому ж потоці довгу роботу, що й у модулі 13 курсу ОС), а backlog навантаженого сервера має вимірюватися сотнями.

Інструмент №1 — ss (socket statistics), сучасна заміна netstat.

Terminal window
ss -tan # усі TCP-сокети, числові адреси й порти
ss -tanp # плюс процес і дескриптор (потрібен root для чужих)
ss -tan state time-wait | wc -l
ss -ltn # лише слухаючі; Recv-Q/Send-Q мають особливий зміст
ss -s # зведення за станами

Опції запам’ятати легко: t — TCP, u — UDP, a — усі стани, n — не розв’язувати імена, p — процеси, l — лише слухаючі.

Внутрішній стан з’єднання, який видно лише зсередини ядра, дає -i:

Terminal window
ip netns exec cli ss -tin state established # вивід скорочено й переформатовано
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 0 10.0.0.2:56134 10.0.0.1:9000
ts sack bbr wscale:10,10 rto:204 rtt:0.022/0.012 mss:1448 pmtu:1500
cwnd:11 bytes_sent:1000 bytes_acked:1001 segs_out:3 segs_in:2
send 5792000000bps lastsnd:988 lastack:988 pacing_rate 13242140208bps
delivered:2 app_limited rcv_space:14480 snd_wnd:68608

Що тут читати:

  • wscale:10,10 — множники вікна обох боків, узгоджені в SYN (див. вище);
  • rto і rtt — поточний таймер повторної передачі (мс) і виміряний час обороту з розкидом (мс);
  • mss, pmtu — розмір сегмента й MTU шляху;
  • cwnd — вікно перевантаження, у сегментах; воно відповідає за швидкість мережі (модуль 12);
  • bytes_sent і bytes_acked — надіслано й підтверджено (одиничка різниці — сам SYN);
  • snd_wnd — вікно, яке оголосив отримувач;
  • retrans:x/y з’являється, коли були повторні передачі (у нашому чистому експерименті його нема), а app_limited каже, що швидкість обмежує не мережа, а сам застосунок, який нічого не пише.

За таким виводом уже можна сказати, чому повільно: якщо snd_wnd мале, винен отримувач, якщо cwnd маленький і є retrans, то мережа, якщо app_limited — застосунок.

Діагностика: від симптому до причини

Section titled “Діагностика: від симптому до причини”

Більшість розмов про «мережа глючить» закінчується однією з цих картин, і кожну можна розпізнати командою.

Що бачите Найімовірніше Куди дивитись
connect() одразу Connection refused на тому кінці ніхто не слухає порт ss -ltn на сервері: чи є LISTEN, на якій адресі (127.0.0.1 чи 0.0.0.0)
connect() висить до двох хвилин SYN мовчки відкидається: файрвол, маршрут, хост вимкнено tcpdump на обох кінцях: чи доходить SYN, чи повертається SYN+ACK
частина клієнтів підключається, частина зависає, у nstat росте ListenOverflows переповнена accept-черга ss -ltn: Recv-Q слухача впирається в Send-Q; швидше викликайте accept(), збільште backlog
тисячі CLOSE-WAIT застосунок не закриває сокети ss -tanp: який процес і які дескриптори
тисячі TIME-WAIT, а нові з’єднання не створюються кінчаються тимчасові порти повторне використання з’єднань, ширший діапазон портів
запити повільні, канал вільний вікно, затримка, Nagle ss -ti: snd_wnd, cwnd, retrans; tcpdump із часовими мітками

Порядок дій завжди той самий. Спершу ss на обох кінцях: у якому стані сокет і що в його чергах. Потім tcpdump там, де ss залишив сумнів: що реально пройшло по дроту. Лише тоді гіпотези про «мережу» чи «застосунок».

Сервер, що слухає 127.0.0.1, недосяжний ззовні, хоча ss -ltn показує LISTEN. Тож дивіться й на адресу, до якої прив’язаний слухач: 127.0.0.1:8080 означає «лише з цієї машини», 0.0.0.0:8080 або *:8080 — «з будь-якого інтерфейсу». Це найчастіша причина «порт відкритий, а не працює».

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«TCP гарантує доставку». TCP гарантує, що байти прийдуть у порядку й без пошкоджень або що ви дізнаєтесь про розрив. Успішний write() означає лише, що ядро прийняло байти у свій буфер, а не що співрозмовник їх прочитав. Гарантію «обробив» дає тільки підтвердження на рівні протоколу застосунку.

«Один send() — один recv()». Це UDP. TCP — потік байтів: recv() може повернути частину повідомлення або кілька повідомлень зліплених. Цикл читання до потрібної довжини обов’язковий.

«Багато TIME_WAIT означає, що сервер зламаний». Це нормальний слід закритих з’єднань, і хворобою він стає тільки за нестачі портів. Причину шукайте в тому, хто закриває першим, і в частоті нових з’єднань, а не в «вимкненні» стану.

«CLOSE_WAIT зникне сам». Ні. Ядро не закриває такий сокет: це справа застосунку. Скільки їх, стільки й не закритих ним дескрипторів, які рано чи пізно дійдуть до EMFILE.

«Порт закритий, отже пакетів нема». Порт закритий → приходить RST (швидка відмова). Порт «відфільтрований» → приходить тиша. Це різні картини, і за ними видно різну несправність.

«UDP швидший за TCP». Пакет у мережі йде однаково. UDP не має рукостискання й чекання, але і захисту від перевантаження, тому безрозсудна відправка за UDP лише збільшує втрати. Виграш дає відсутність зайвого очікування, а не швидкість.

Перевір себе

1. Веб-сервер приймає 20 000 одночасних з’єднань на порту 443. Як ядро відрізняє їх одне від одного?
2. На сервері тисячі сокетів у CLOSE_WAIT, і кількість росте. Що це найімовірніше означає?
3. Клієнт відкриває й закриває тисячі коротких з’єднань до одного сервера й раптом отримує «Cannot assign requested address». Що сталося?
4. connect() на порт, де нічого не слухає, повертається за мілісекунди з ECONNREFUSED. А до сусіднього хоста той самий connect() висить дві хвилини. Чому різниця?
5. Клієнт пише запит двома викликами write() (заголовок, тіло), і кожен запит стабільно тягнеться на ~40 мс довше, ніж RTT. Що вкаже на причину?
6. Що насправді дає SO_REUSEADDR?
7. Навіщо оголошене вікно масштабується через wscale, узгоджене в SYN?
  • Kurose, Ross, Computer Networking: A Top-Down Approach, розділ про транспортний рівень
  • W. Richard Stevens, TCP/IP Illustrated, Vol. 1, розділи про TCP
  • RFC 9293 (Transmission Control Protocol) і RFC 768 (User Datagram Protocol)
  • RFC 6298 (Computing TCP’s Retransmission Timer)
  • man 7 tcp, man 7 udp, man 7 socket, man 8 ss, man 8 nstat
  • Документація ядра: Documentation/networking/ip-sysctl.rst
  • Beej’s Guide to Network Programming