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

A1. Echo-сервер і клієнт

базовийспирається на модуль 3, модуль 11

Echo-сервер повертає те, що отримав, і на вигляд це п’ять рядків коду. На практиці на ньому вперше ламається уявлення про TCP як про «канал, що доставляє повідомлення»: send на одному боці не дорівнює одному recv на іншому, а UDP, навпаки, зберігає межі, але нічого не гарантує. Модуль 3 показує, що сокет у ядрі є чергою байтів у буфері, а модуль 11 пояснює, чому TCP не має поняття повідомлення. Тут ви напишете обидва сервери й клієнт, а перевірка покаже, що станеться, коли повідомлення приходить шматками, склеєним, завеликим або від десятка клієнтів одразу.

Після цієї роботи ви зможете:

  • написати TCP- і UDP-сервер на socket, bind, listen, accept, recv, send, recvfrom, sendto;
  • читати й писати в TCP-сокет у циклі, не покладаючись на те, що один виклик обробить усі дані;
  • обслуговувати кількох клієнтів одночасно через fork або poll і пояснити, чим ці підходи відрізняються;
  • закривати з’єднання так, щоб не втратити останні дані, не накопичувати дескриптори й зомбі, а порт після перезапуску лишався доступним;
  • пояснити, чому UDP-датаграма приходить цілою або не приходить зовсім.

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

Написати на C сервер echo-server і клієнт echo-client.

Terminal window
./echo-server 7007 # слухає TCP і UDP на порту 7007
./echo-client tcp 127.0.0.1 7007 # stdin → сервер → stdout
./echo-client udp 127.0.0.1 7007 # кожен рядок stdin — одна датаграма

Сервер має вміти:

  • приймати TCP-з’єднання і UDP-датаграми на одному номері порту, який передано першим аргументом;
  • повертати TCP-клієнту рівно ті байти, які той надіслав, у тому самому порядку, незалежно від того, як їх розбило recv: довільні байти (зокрема нульові), шматки по одному байту, два «повідомлення» в одному сегменті, 2 МіБ за одне з’єднання;
  • тримати з’єднання відкритим, доки клієнт його не закриє: серія запитів і відповідей в одному з’єднанні працює;
  • обслуговувати багатьох клієнтів одночасно; мовчазний клієнт, який підключився й нічого не шле, або клієнт, який шле багато й не читає відповідь, не зупиняє інших;
  • після shutdown(SHUT_WR) від клієнта повернути все, що ще не відправлено, і закрити з’єднання зі свого боку;
  • повертати кожну UDP-датаграму як окрему датаграму відправнику;
  • не завершуватися, коли клієнт розірвав з’єднання (RST, SIGPIPE);
  • не втрачати дескриптори й не накопичувати процеси-зомбі;
  • запускатися на тому самому порту відразу після того, як попередній екземпляр було вбито з відкритим з’єднанням.

Клієнт має вміти:

  • у режимі tcp відправляти весь stdin, одночасно друкувати у stdout те, що повернулось, а на кінці stdin закрити свою половину з’єднання (shutdown(SHUT_WR)) і дочекатися, поки сервер закриє свою;
  • у режимі udp відправляти кожен рядок stdin окремою датаграмою й виводити відповідь; якщо відповіді немає 2 секунди, написати про це в stderr і завершитись із ненульовим кодом;
  • при недоступному сервері завершуватись із повідомленням у stderr і ненульовим кодом.

Чого робити не треба. IPv6, TLS, epoll, ліміти на кількість клієнтів і розбір протоколу поверх echo. Якщо цікавить epoll, його розібрано в A7 курсу «Операційні системи».

Обмеження. C і системні виклики. Розбір рядків, printf("%s") і strlen на даних з мережі заборонені: дані можуть містити нульові байти. Перевірці потрібен python3, права root не потрібні.

Готово, коли:

  • ./check.sh ./echo-server ./echo-client проходить усі перевірки;
  • ви можете пояснити, навіщо в сервері на poll потрібна черга на запис, а у fork-варіанті цикл навколо send;
  • у звіті є відповіді на запитання з етапу 3.
  • Прочитайте в модулі 11, як TCP розрізає потік на сегменти і що означає поняття «межі повідомлень». У модулі 3 подивіться, де в ядрі лежать черги сокета.
  • Розпакуйте архів курсу: перевірка лежить у labs/a1-echo/check.sh, поруч є Makefile.
  • Знадобляться gcc, python3, ss і strace. Підійде будь-який Linux, зокрема контейнер і WSL2; права root не потрібні.
  1. UDP-сервер.

    Найпростіше з усього: socket(SOCK_DGRAM), bind, цикл recvfrom → sendto на адресу відправника. Перевірте вручну:

    Terminal window
    echo hello | nc -u -w1 127.0.0.1 7007

    Надішліть датаграму більшу за буфер, який ви виділили, і подивіться, що залишиться від решти (підказка: man 2 recv, розділ про MSG_TRUNC).

  2. TCP-сервер для одного клієнта.

    socket(SOCK_STREAM), SO_REUSEADDR, bind, listen, accept, цикл recv → send до recv == 0. Перевірте nc 127.0.0.1 7007.

    Поставте SO_REUSEADDR, а потім приберіть і спробуйте перезапустити сервер, поки клієнт ще підключений: ss -tan | grep 7007 покаже TIME_WAIT, через який bind відмовляє без цього прапора.

  3. Межі повідомлень.

    Запустіть strace -e trace=recvfrom,sendto,read,write на сервері й надішліть 2 МіБ: head -c 2M /dev/urandom | nc 127.0.0.1 7007 | wc -c. Подивіться, які значення повертає recv і чи збігаються вони з send клієнта.

    Запишіть у звіт: скільки байтів читав за раз ваш сервер, чи бувало, що send записував менше, ніж просили, і за яких умов (блокуючий чи неблокуючий сокет) це стається.

  4. Багато клієнтів.

    Оберіть один підхід. fork на з’єднання: у дитині close(listen_fd), у батька close(conn_fd), а зомбі прибирає SIGCHLD із SA_NOCLDWAIT або signal(SIGCHLD, SIG_IGN). Або один процес на poll: сокети неблокуючі, а те, що не вдалося відправити одразу, лежить у черзі на запис, поки poll не скаже POLLOUT. Тоді читання з цього клієнта краще призупинити, щоб черга не росла без меж.

    Чекер приймає обидва підходи. Обидва можна реалізувати, і друга реалізація дасть вам відчуття, де межа між ними.

  5. Закриття.

    Обробіть recv == 0 (клієнт закрив свою половину), ECONNRESET і SIGPIPE. Переконайтеся через ls /proc/<pid>/fd | wc -l, що після сотні з’єднань число дескрипторів не зросло.

  6. Клієнт.

    TCP: цикл на poll за stdin і сокетом, щоб велике введення не зависло на write, поки сервер чекає, що його відповідь хтось прочитає. На кінці stdin shutdown(SHUT_WR) і читання до кінця потоку. UDP: getline, send, poll із таймаутом на 2 секунди, recv.

Terminal window
cd labs/a1-echo
make
./check.sh ./echo-server ./echo-client

Другий аргумент необов’язковий: якщо його немає, але поруч лежить ./echo-client, перевіряється він. Чекер сам вибирає вільний порт, запускає ваш сервер і перевіряє:

  • TCP як потік байтів: короткий рядок, усі 256 значень байта, 2 МіБ, шматки з паузами, тисячу однобайтових send, два повідомлення в одному сегменті, серію повідомлень в одному з’єднанні;
  • кількох клієнтів: мовчазні, тридцять одночасних, той, що не читає;
  • закриття: shutdown(SHUT_WR), RST, витік дескрипторів і зомбі;
  • UDP: датаграми від 1 до 4000 байтів, збереження меж, відповідь саме відправнику;
  • перезапуск на тому самому порту одразу після kill -9;
  • клієнт: проти власного echo-сервера чекера, з великим введенням, порожнім введенням і відсутнім сервером.

Скрипт не залежить від того, fork у вас чи poll, на яких адресах ви слухаєте і що друкуєте в stdout. Він нічого не виставляє й нікуди нічого не надсилає.

Один recv — одне повідомлення. На loopback і коротких рядках це виглядає правдою. Проте 2 МіБ, або клієнт, який пише по байту, або мережа з нормальною затримкою роздрібнять потік так, як ви не очікували. Кордони повідомлень у TCP мусить задавати ваш протокол (довжина, роздільник), а echo просто не має права їх вигадувати.

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

strlen і %s. Перший нульовий байт обрізає «рядок». Echo працює з масивом байтів і його довжиною з recv.

Порт зайнятий після перезапуску. Не виставлено SO_REUSEADDR. Подивіться ss -tan state time-wait.

Сервер вмирає з «Broken pipe». Клієнт пішов, а ви пишете в закритий сокет: прийшов SIGPIPE. Ігноруйте його або передавайте MSG_NOSIGNAL і дивіться на EPIPE.

Зомбі у fork-версії. Батько не збирає дітей. Виставте signal(SIGCHLD, SIG_IGN) або обробник із waitpid(-1, ..., WNOHANG).

Клієнт зависає на великому введенні. Він пише все, не читаючи відповідь, а сервер, що чекає читача, перестає читати. Обидва заблоковані на write. Клієнтові потрібен poll або окремий потік.

Додати в сервер epoll (A7 курсу ОС) і порівняти на 10 000 з’єднань. Поставити між клієнтом і сервером tc netem із втратами (не в кожному контейнері він працює, див. огляд) і подивитися в tcpdump, як TCP повторює сегменти, а UDP ні. Зробити UDP-клієнт, який сам повторює запит, якщо відповіді немає, і зрозуміти, чому з цього виростає A4.