habrahabr

Интернет бесподобен! Спасибо, TCP

  • вторник, 25 ноября 2025 г. в 00:00:15
https://habr.com/ru/companies/ruvds/articles/967682/

Перед вами детальный разбор TCP — движущей силы интернета, в котором мы шаг за шагом рассмотрим принципы этой технологии на подробных примерах.

Интернет — невероятное изобретение. Людей от него за уши не оттащишь. Вот только есть у этого изобретения проблемы с надёжностью — пакеты теряются, каналы перегружаются, биты путаются, а данные повреждаются. Ох, какой же опасный мир! (Буду писать в духе Крамера).

Хорошо, почему же тогда наши приложения вот так просто работают? Если вы выводили своё приложение в сеть, то процесс вам знаком: socket()/bind() здесь, accept() там, возможно, connect() вон там и, вуаля — данные надёжно текут в обе стороны упорядоченным и целостным потоком.

Сайты (HTTP), сервисы e-mail (SMTP) или удалённый доступ (SSH) — всё это построено на основе TCP и просто работает.

Почему TCP?

Зачем нам нужен TCP? Почему нельзя просто использовать более низкий уровень — IP?

Вспомним структуру сетевого стека: физическое устройство —> канал передачи данных (Ethernet/Wi-Fi и так далее) —> Сеть (IP) —> транспортный уровень (TCP/UDP).

IP (слой 3) работает на уровне хоста, а транспортный уровень (TCP/UDP) — на уровне приложения с использованием портов. IP может доставлять пакеты целевому хосту по его IP-адресу, но как только данные достигают машины, их ещё нужно передать нужному процессу. Каждый процесс «привязывается» к порту — своему адресу в системе устройства. Если взять простую аналогию, то IP-адрес — это дом, а порт — это квартира. Вот в этих «квартирах» и проживают процессы или приложения.

Есть и ещё одна причина необходимости TCP. Если роутер (элемент инфраструктуры, который рядовые пользователи не контролируют) теряет пакеты или испытывает перегрузку, TCP на периферии (на машинах пользователей) может восстанавливать эти пакеты, не требуя участия роутеров. В итоге роутеры сохраняют свою простоту, а надёжность обеспечивается в конечных точках.

Пакеты теряются, повреждаются, дублируются и перепутываются. Так уж работает интернет. TCP же защищает разработчиков от этих проблем. Он обрабатывает повторную передачу, контрольные суммы и кучу других механизмов обеспечения надёжности. Если бы каждому разработчику приходилось реализовывать все их самому, у него бы просто не осталось времени на должное выравнивание флексбоксов — поистине ужасающая альтернативная реальность.

Ну а если серьёзно, то потрясающим делает TCP именно гарантия того, что вопреки ненадёжности сети отправленные и полученные через сокеты данные не повредятся, не дублируются и не перепутаются.

Управление потоком и перегрузкой

Если взглянуть на сетевые коммуникации обобщённо, то по факту в них происходит следующее. Машина А отправляет данные машине В. При этом машине В нужно сперва сохранить полученные данные во временном хранилище, прежде чем передавать их приложению, которое может находиться в режиме сна или быть занято. Называется это временное хранилище буфером приёма и управляется ядром ОС:

sysctl net.ipv4.tcp_rmem => net.ipv4.tcp_rmem = 4096 131072 6291456, минимум 4КБ, по умолчанию 128КБ и максимум 8 МБ.

Проблема же в том, что буфер не безграничен. Если вы передаёте большой файл (сотни МБ или даже ГБ), то можете запросто перегрузить получателя. Значит, у получателя должен быть способ сообщить отправителю, сколько ещё данных он может принять. Этот механизм называется управление потоком, и сегменты TCP включают специальное поле window, в котором указывается, сколько данных получатель в текущий момент готов принять.

Ещё одна проблема — это перегрузка самой сети, даже когда получатель располагает достаточным объёмом буфера. Ваши возможности здесь ограничиваются самым слабым каналом — некоторые каналы переносят гигабиты данных, другие лишь мегабиты. Если вы не учтёте в настройке самый медленный канал, то перегрузка неизбежна.

Занятный факт. В 1986 году пропускная способность интернета могла падать от нескольких десятков КБ/с до 40 бит/с (да, с ума сойти). Назвали это явление коллапс сети из-за перегрузки (congestion collapse). Когда пакеты терялись, и системы пытались отправить их повторно, ситуация только усугублялась — возникал порочный круг. Чтобы это исправить, в TCP встроили модели управления перегрузкой «play nice» и «back off», которые помогают интернету не удушить себя до полного отказа.

Пример кода: простой TCP-сервер

В случае низкоуровневых механизмов вроде TCP следует оперировать примерами на C. Они показывают всё как есть.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <signal.h>

int sockfd = -1, clientfd = -1;
void handle_sigint(int sig) {
    printf("\nCtrl+C caught, shutting down...\n");
    if (clientfd != -1) close(clientfd);
    if (sockfd != -1) close(sockfd);
    exit(0);
}

int main() {
    signal(SIGINT, handle_sigint);
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    // SO_REUSEADDR для принудительного привязывания к порту, даже если связанный с ним предыдущий сокет ещё закрывается (TIME_WAIT)
    setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(8080), .sin_addr.s_addr = INADDR_ANY };
    bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
    listen(sockfd, 5);
    printf("Listening on 8080...\n");

    clientfd = accept(sockfd, NULL, NULL);
    char buf[1024], out[2048];
    int n;
    while ((n = recv(clientfd, buf, sizeof(buf) - 1, 0)) > 0) {
        buf[n] = '\0';
        int m = snprintf(out, sizeof(out), "you sent: %s", buf);
        printf("response %s %d\n", out, m);
        send(clientfd, out, m, 0);
    }
    close(clientfd); close(sockfd);
}

Этот код создаст TCP-сервер, который получает от клиента сообщения и возвращает их с префиксом you sent:.

# Компиляция и запуск сервера
gcc -o server server.c && ./server
# Подключение клиента.
telnet 127.0.0.1 8080
# hi
# you sent: hi

127.0.0.1 (localhost) можно заменить удалённым IP — на работу это не повлияет.

В коде использовались следующие примитивы и функции, являющиеся частью реализации сокетов Беркли (которые были внедрены в BDS 4.2):

  • SOCKET: создание конечной точки (структуры в ядре).

  • BIND: привязка к порту.

  • LISTEN: подготовка к установке соединения и указание размера очереди ожидания (при превышении этого размера пакеты отбрасываются).

  • ACCEPT: принятие входящего подключения (TCP-сервер).

  • CONNECT: попытка подключения (TCP-клиент).

  • SEND: отправка данных.

  • RECEIVE: получение данных.

  • CLOSE: закрытие соединения.

В примере выше мы используем взаимодействие клиент-сервер по схеме запрос-ответ. Но после отправки сообщения можно добавить следующий процесс:

send(clientfd, out, m, 0);
sleep(5);
const char *msg = "not a response, just doing my thing\n";
send(clientfd, msg, strlen(msg), 0);

Компилируем, запускаем и используем telnet:

client here
you sent: client here
client again
not a response, just doing my thing
you sent: client again

Я ввёл в терминал telnet сначала client here, а потом client again. В итоге сервер вернул лишь you sent: client here, после чего ушёл в сон. Вторая строка client again терпеливо дожидалась своей очереди в буфере приёма. Сервер ответил not a response, just doing my thing, после чего подхватил мой второй TCP-пакет и вернул уже you sent: client again.

Это полноценное дуплексное соединение. Каждая сторона отправляет то, что хочет, просто в начале одна из них слушает, а другая подключается. Дальнейшая схема взаимодействия не обязательно должна соответствовать паттерну запрос-ответ.

Иллюзорный HTTP-сервер

Теперь создадим очень простой сервер HTTP/1.1 (более поздние версии уже сложнее).

    // Всё то же самое.
    printf("Listening on 8080...\n");
    int i = 1;
    while (1) {
        clientfd = accept(sockfd, NULL, NULL);
        char buf[1024], out[2048];
        int n;
        while ((n = recv(clientfd, buf, sizeof(buf) - 1, 0)) > 0) {
            buf[n] = '\0';
            int body_len = snprintf(out, sizeof(out), "[%d] Yo, I am a legit web server\n", i++);

            char header[256];
            int header_len = snprintf(
                header, sizeof(header),
                "HTTP/1.1 200 OK\r\n"
                "Content-Type: text/plain\r\n"
                "Content-Length: %d\r\n"
                "Connection: close\r\n"
                "\r\n",
                body_len
            );
            printf("header: %s\n", header);
            printf("out: %s\n", out);
            send(clientfd, header, header_len, 0);
            send(clientfd, out, body_len, 0);
            break;   // Один запрос на соединение.
        }
        close(clientfd);
    }
~ curl localhost:8080                                                                                                 
[1] Yo, I am a legit web server
~ curl localhost:8080
[2] Yo, I am a legit web server

Здесь с помощью i отслеживается количество запросов. Мы устанавливаем TCP-соединение и возвращаем HTTP-заголовки, ожидаемые HTTP-клиентом (TCP-пиром, если быть точнее). Реальный HTTP-сервер возвращал бы подобающий HTML, CSS и JS-код, обрабатывая много разных опций и заголовков. Но внутренне это простой процесс, использующий наш надёжный и стабильный TCP.

Фактическое распределение байтов

  0                   <----- 32 bits ------>                     
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |        Source Port              |     Destination Port        |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                        Sequence Number                        |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                    Acknowledgment Number                      |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 | Header|Rese-|   Flags   |       Window Size                   |
 | Len   |rved |           |                                     |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |       Checksum                  |     Urgent Pointer          |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                    Options (if any)                           |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
 |                    Data (Payload)                             |
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Каждый TCP-сегмент находится внутри IP-пакета и имеет над собой заголовок. В процессе обмена данными задействовано два порта — отправки и назначения — адрес каждого из которых занимает в заголовке 16 бит. Отсюда и ограничение на общее количество портов в 64k.

Каждое соединение транспортного уровня представляет кортеж из пяти элементов (TCP/UDP, src IP, src port, dst IP, dst port).

Sequence Number и Acknowledgment Number

Надёжность TCP определяется двумя ключевыми полями: Sequence Number (номер последовательности), указывающим, какие байты последовательности несёт в себе сегмент, и Acknowledgement Number (номер подтверждения), указывающим, какие байты были получены. Sequence Number позволяет получателю видеть порядок данных, находить и переставлять выпавшие из этого порядка сегменты и обнаруживать потери. В TCP используется кумулятивное подтверждение — значение ACK равное 100 означает, что были получены байты от 0 до 99. Если байты от 100 до 120 утеряны, и поступают последующие байты, то ACK будет оставаться 100, пока не будут получены недостающие данные.

1. A --> B: Send [Seq=0-99]
2. B --> A: Send [Seq=0-49]

3. B --> A: Получает от A [0-99] --> отправляет ACK=100
4. A --> B: Получает от B [0-49] --> отправляет ACK=50

5. A --> B: Send [Seq=100-199]   --- потеряны ---
6. B --> A: Send [Seq=50-99]     --- потеряны ---

7. A --> B: Send [Seq=200-299]
   B получает данные --> видит в них пробел (отсутствуют пакеты 100-199) --> отправляет ACK=100

8. B --> A: Send [Seq=100-149]
   A получает данные --> видит в них пробел (отсутствуют пакеты 50-99) --> отправляет ACK=50

9. A --> B: Send [Seq=300-399]
   B всё ещё не получил 100-199 --> отправляет ACK=100

10. B --> A: Send [Seq=150-199]
    A всё ещё не получил 50-99 --> отправляет ACK=50

11. A --> B: Retransmit [Seq=100-199]
    B получает данные --> и теперь у него все пакеты 0-399 --> отправляет ACK=400

12. B --> A: Retransmit [Seq=50-99]
    A получает данные --> и теперь у него все пакеты 0-199 --> отправляет ACK=200

Длина заголовка показывает, сколько 4-байтовых слов в нём содержится. Она нужна, так как поле Options имеет переменную длину, а значит, и заголовок тоже.

TCP-флаги

В соединении также применяется 8 флагов, размером один бит каждый. Опишу наиболее важные из них.

SYN — используется для установки подключения. ACK — указывает на валидность номера подтверждения.

Это два основных флага, отвечающих за настройку соединения. Зачем устанавливать соединение? Чтобы обнаруживать выбившиеся из последовательности или повторяющиеся сегменты, необходимо отслеживать, какие были отправлены, а какие получены — то есть сохранять состояние соединения.

SYN и ACK задействуются при известном трёхстороннем квитировании:

  • A —> B: SYN (хочу подключиться)

  • B —> A: SYN + ACK (получил твой SYN, тоже хочу подключиться!)

  • A —> B: ACK (принял, соединение установлено!)

Флаг FIN сообщает о закрытии соединения и также использует квитирование:

  • X —> Y: FIN (хочу отключиться)

  • Y —> X: ACK (получил твой FIN, как пожелаешь!)

  • Y —> X: FIN (тоже хочу отключиться — иногда отправляется с предыдущим ACK)

  • X —> Y: ACK (принял!)

Обычно это 4-х стороннее (иногда трёхстороннее) завершающее квитирование.

RST — флаг сброса. Он указывает на ошибку или принудительное отключение — в этом случае соединение тут же закрывается. Операционная система отправляет RST, если ни один процесс не прослушивает порт, или прослушивающий процесс падает. Также существует атака TCP Reset, когда промежуточный участник соединения внедряет RST для его закрытия (используется некоторыми файерволами).

Window

Это поле упоминалось при обсуждении управления потоком. Напомню, что оно указывает, сколько байтов принимающий ожидает получить после номера подтверждения.

В примере выше выполнение ss (Socket Statistics) возвращает информацию о TCP-соединении.

ss -tlpmi
// State    Recv-Q   Send-Q       Local Address:Port           Peer Address:Port   Process    
// LISTEN   0        5                  0.0.0.0:http-alt            0.0.0.0:*       users:(("server",pid=1113,fd=3))
// 	 skmem:(r0,rb131072,t0,tb16384,f0,w0,o0,bl0,d0) cubic cwnd:10

rb131072 (128KB) — это размер буфера приёма, а tb16384 (16КБ) — размер буфера передачи, в котором данные ожидают своей отправки по сети. В Send-Q указываются байты, которые ещё не были подтверждены удалённым хостом, а в Recv-Q — байты, которые были получены, но приложением ещё не прочитаны (например, вторая строка в сессии telnet выше, ожидающая пробуждения сервера).

Checksum

Checksum (контрольная сумма) используется для обеспечения надёжности. Все 16-битные слова в TCP-сегменте складываются вместе, и полученный результат сравнивается с контрольной суммой. Если они не совпадают, значит, какие-то биты были повреждены. В таком случае требуется повторная передача.

Заключение

Не перестаю поражаться, как всё это надёжно и непрерывно работает — сеть, интернет. Считаные десятилетия назад отправка нескольких килобайт была чуть ли не подвигом, а сегодня стриминг с разрешением 4К уже норма. Слава всем тем, кто, не жалея сил, сделал и продолжает делать это возможным! 

Видео на YouTube