Компьютерные сети: от IPC к C/Winsock
Один сервер — много клиентов: от цикла accept до select
Строим лестницу concurrency по боли: один клиент, цикл accept, thread-per-connection, молчащий чат, non-blocking socket и select/WSAPoll. Каждая ступень решает ровно одну проблему предыдущей.
Один сервер — много клиентов: от цикла accept до select
Задача: сервер обязан обслуживать не одного клиента
До этой главы наш учебный server жил недолго: accept(), обменялся двенадцатью
байтами Привет, закрыл connection и завершился. Для первого lifecycle это
правильная упрощённая модель. Но у настоящего сервиса клиентов много, они
подключаются в непредсказуемые моменты и ведут себя по-разному:
- один шлёт запрос и сразу отключается;
- другой держит connection и отвечает медленно;
- третий подключился и молчит час — как пользователь чата, который не печатает.
К главному вопросу курса добавляется второй:
Где сейчас наши 12 байтов, кто ими владеет — и кого заставляет ждать каждый отдельный client?
В этой главе мы пройдём пять ступеней. Каждая ступень существует не потому, что «так принято», а потому что у предыдущей есть одна конкретная боль. Держите эту лестницу перед глазами:
один client -> цикл accept -> thread-per-connection
-> молчащий чат ломает потоки
-> non-blocking socket -> select/WSAPoll
Ступень 1. Один клиент и завершение
Baseline из первых лабораторных выглядит так:
listener = socket(); bind(); listen();
peer = accept(listener); /* один клиент за весь запуск */
recv(peer, ...); send(peer, ...);
closesocket(peer);
closesocket(listener);
Модель честная и полезная: весь socket lifecycle виден целиком, от socket()
до closesocket(). Но контракт жёсткий — один клиент за один запуск
программы. Второй клиент в лучшем случае подождёт restart сервера.
Ступень 2. Цикл accept: много клиентов, но строго по очереди
Первое очевидное улучшение — обернуть обслуживание в бесконечный цикл:
for (;;) {
SOCKET peer = accept(listener, NULL, NULL);
serve(peer); /* recv/send до конца обмена */
closesocket(peer); /* только потом accept следующего */
}
Теперь сервер принимает сколько угодно клиентов за запуск. Но — по одному за раз. Здесь нужен эксперимент с предсказанием.
Предсказание. Client A подключился и молчит. Client B подключился следом.
Что покажет connect() у B? Ответит ли ему сервер?
Ответ разделяет два события, которые легко склеить. TCP handshake для B
выполнит ядро: connect() у B завершится успешно, потому что listener
ещё существует и ОС держит очередь входящих connections (backlog). Но ответа на
данные B не получит: процесс сервера стоит в recv() на socket клиента A и не
дойдёт до accept() для B, пока A не закончит обмен или не отключится.
client B: connect() == успех <- handshake сделало ядро
client B: send() == 12 <- байты принял kernel receive buffer
client B: recv() ... ждёт <- процесс сервера занят клиентом A
Успешный connect() поэтому доказывает только достижимость listener, а не то,
что приложение когда-либо обслужит connection. Для коротких request-response с
редкими клиентами цикл accept — рабочая модель. Для всего остального нужна
следующая ступень.
Ступень 3. Thread-per-connection: каждому клиенту свой поток
На курсе операционных систем мы уже запускали потоки внутри одного процесса.
Теперь они впервые решают сетевую задачу: accept() выделяет клиенту отдельный
поток, и тот крутится в обычном blocking recv()/send():
static DWORD WINAPI serve_client(void *arg) {
SOCKET peer = (SOCKET)arg;
/* обычный blocking recv/send loop этого клиента */
closesocket(peer);
return 0;
}
for (;;) {
SOCKET peer = accept(listener, NULL, NULL);
if (peer == INVALID_SOCKET) continue;
HANDLE h = (HANDLE)_beginthreadex(NULL, 0, serve_client, (void *)peer, 0, NULL);
if (h != NULL) CloseHandle(h); /* поток добежит сам */
}
Теперь client B не ждёт клиента A: обслуживание действительно параллельно. Главное достоинство модели — простота рассуждения: код одного клиента остаётся линейным, без машины состояний. Правила гигиены при этом не отменяются:
- каждый поток закрывает ровно свой socket и ровно один раз;
- общих буферов между потоками без синхронизации нет;
_beginthreadex()вместо сырогоCreateThread(), чтобы C runtime корректно завершался в потоке; handle потока закрывается, сам поток работает дальше.
Где слабость? Поток — это ресурс ОС: стек, запись в планировщике, переключения контекста. Пока клиентов немного и все они активны, цена разумная. Проблема появляется, когда меняется сама задача.
Ступень 4. Проблема чата: recv держит поток без работы
В задаче request-response клиент почти всегда что-то хочет: прислал запрос, получил ответ, отключился. Поток занят делом.
В задаче а-ля чат контракт другой: пользователь вызвал connect(), но не
обязан ничего слать. Он может молчать минуты и часы, ожидая сообщений от
других участников. Что делает его поток на сервере? Стоит в blocking recv()
и ждёт. Полезной работы ноль, а ресурс занят:
1000 подключённых молчащих клиентов = 1000 потоков, стоящих в recv()
Каждый такой поток держит стек и место в планировщике, но не продвигает ни один байт. Сервер растёт по памяти и потокам без всякой нагрузки по данным. Это не «потоки плохие»: при ограниченном числе клиентов модель остаётся честной — и в capstone курса thread-per-connection с лимитом разрешён как минимальный трек. Но произвольное число idle clients требует другого механизма.
Ступень 5. Non-blocking socket: спросить и не ждать
Вторая половина решения начинается с режима сокета. Переведём socket в non-blocking mode:
u_long nonblocking = 1;
if (ioctlsocket(peer, FIONBIO, &nonblocking) == SOCKET_ERROR) {
fail_wsa("ioctlsocket(FIONBIO)", WSAGetLastError());
}
Теперь recv() никогда не ждёт: либо забирает доступные bytes, либо завершается
с WSAEWOULDBLOCK — «сейчас не готово». Это не ошибка передачи и не разрыв, а
сигнал попробовать позже. Один поток может опрашивать много сокетов по кругу:
for (;;) {
для каждого connection: recv() -> данные / WSAEWOULDBLOCK / EOF
выполнить полезную фоновую работу
}
Молчащий чат больше не держит поток: его socket просто отвечает «не готово»,
и цикл идёт дальше. Но мы получили новую боль: когда вообще ничего не
происходит, цикл всё равно крутится и жжёт CPU. Вставка Sleep() — это обмен
задержки ответа на процент процессора. Мы переложили ожидание из ядра в свой
цикл и теперь вынуждены угадывать, как часто опрашивать.
Хочется вернуть ожидание туда, где оно дешёвое, — в ОС, но без потери всех остальных клиентов.
Ступень 6. select/WSAPoll: ОС будит нас, когда кто-то готов
Модель readiness переворачивает опрос: мы отдаём ядру весь набор sockets и один раз засыпаем до первого интересного события. ОС будит поток и сообщает, какие именно sockets готовы:
Пройдите цикл по шагам — на каждом видно, что в этот момент происходит в ядре:
Три правила, без которых эта схема ломается:
- Наборы пересобираются каждый цикл. После возврата
select()вfd_setостаются только готовые sockets, поэтому перед следующим ожиданием набор строят заново из текущего состояния. - Readable — не обязательно данные.
recv() == 0— это orderly close от peer, аSOCKET_ERRORсWSAECONNRESET— обрыв. Оба случая требуют закрыть slot ровно один раз. - На writable подписываются только с pending output. Пока отвечать нечего, socket почти всегда writable, и подписка превратит ожидание в тот самый busy poll, от которого мы ушли. Это и есть application backpressure из главы про TCP stream: пока pending output клиента не ушёл, новых запросов от него не читаем.
У Winsock есть два readiness API. select() — простой, но стандартный
FD_SETSIZE равен 64: listener и все клиенты должны в него помещаться, поэтому
MAX_CLIENTS выбирают меньше. WSAPoll() работает массивом WSAPOLLFD без
этого лимита, но требует проверять revents на POLLERR, POLLHUP и
POLLNVAL.
Главная связка с прошлыми главами: incremental parser из главы «TCP-поток»
теперь живёт между событиями готовности — в структуре состояния каждого
connection, а не в цикле blocking recv_exact(). Один client, приславший
половину header, не мешает другому, приславшему целый frame.
Углубление. За пределами обязательной программы: в Linux роль readiness
API играет epoll, в macOS — kqueue. В Windows для тяжёлой нагрузки
используют IOCP — модель completion: ОС сама выполняет операцию и будит нас
уже с результатом, а не с фактом готовности. IOCP сильнее масштабируется, но и
сложнее; в этом курсе достаточно select()/WSAPoll().
Лимиты и backpressure при любой модели
Независимо от ступени сервер обязан иметь конечные лимиты, иначе один клиент может остановить всех или раздуть память:
MAX_CLIENTS— конечное число одновременных connections; лишнийaccept()закрывается с журналомCAPACITY_REJECT, обслуживаемые клиенты не страдают;- bounded input/output на connection — никаких неограниченных очередей ответов;
- deadline незавершённого frame и idle timeout молчащего connection;
- в thread-per-connection лимит — это размер пула потоков: idle клиент занимает один из ограниченного числа потоков, а не бесконечного.
Slow reader проверяет именно эти лимиты: сервер может по documented policy приостановить чтение от медленного peer или закрыть его, но не расти по памяти и не останавливать normal clients.
Какую ступень выбрать
| Модель | Когда достаточна | Цена | Место в курсе |
|---|---|---|---|
| Один client | первый lifecycle | один клиент за запуск | Lab 00 |
Цикл accept() | редкие короткие request-response | первый молчащий client ставит очередь | baseline Lab 03 |
| Thread-per-connection + лимит | десятки клиентов, линейный код | поток и стек на клиента, нужен bound | минимальный трек ByteBox |
Non-blocking + select()/WSAPoll() | сотни клиентов в одном потоке | ручная машина состояний | Lab 06, сильный трек |
| IOCP | высокая нагрузка в production Windows | completion-модель сложнее | углубление |
Неправильного выбора «на всю жизнь» здесь нет: контракты wire protocol, validation order и evidence одинаковы на всех ступенях. Меняется только механизм ожидания.
Предсказание → эксперимент → доказательство
Один сценарий прогоняется на трёх моделях: client A подключается и молчит, client B подключается следом и шлёт полный запрос.
Предсказание. До запуска заполните таблицу:
| Модель | connect() у B | Ответ B | Потоки на сервере |
|---|---|---|---|
| Цикл accept | ? | ? | ? |
| Thread-per-connection | ? | ? | ? |
| select event loop | ? | ? | ? |
Эксперимент. Lab 06 повторяет сценарий на blocking baseline из Lab 03/04, затем на выбранной concurrency-модели. Параллельно наблюдайте:
Get-NetTCPConnection -RemotePort 27015 |
Format-Table LocalAddress, RemoteAddress, State, OwningProcess
и число потоков процесса сервера в Task Manager или Process Explorer.
Доказательство. Сильный отчёт содержит: timestamps логов обоих клиентов;
строку ESTABLISHED для B до его обслуживания (доказательство backlog, а не
магии connect()); счётчик потоков на каждой модели; p95 времени ответа normal
clients из harness. Чего это не доказывает: loopback скрывает сетевые задержки,
а 16 клиентов не равны production-нагрузке — вывод ограничен моделью ожидания.
Частые заблуждения
«Thread-per-connection — всегда плохо». Нет. С лимитом пула это простая и честная модель; она проигрывает только при неограниченном числе idle clients.
«Non-blocking значит, что ОС сама читает данные в фоне». Нет. Вызов просто не ждёт: данные забирает тот же поток, но только когда они готовы.
«select() читает данные». Нет. Он сообщает готовность; читает по-прежнему
recv() приложения.
«WSAEWOULDBLOCK — соединение сломалось». Нет. Это штатное «сейчас не
готово»; закрывать по нему client нельзя.
«Есть одна правильная модель concurrency». Нет. Есть модель, чьи лимиты и цена подходят текущей задаче, и evidence, что normal clients живы при slow peer.
Проверьте модель
Короткий итог
- Каждая ступень отвечает на одну боль предыдущей: один клиент → очередь → потоки → idle-блокировка потоков → busy poll → readiness от ОС.
- Успешный
connect()клиента не доказывает, что приложение его обслужит: handshake делает ядро, обслуживание делает процесс послеaccept(). - Thread-per-connection с лимитом — легальная минимальная модель курса;
select()/WSAPoll()event loop — сильная модель с ручной машиной состояний. WSAEWOULDBLOCK— «не готово», а не ошибка; readable сrecv() == 0— orderly close; writable-подписка — только при pending output.MAX_CLIENTS, bounded buffers, frame deadline и idle timeout обязательны при любой модели.
Мост к следующей главе
Сервер теперь умеет держать много клиентов одновременно. Вернёмся на сторону
клиента, к самому началу пути: до сих пор процесс A знал numeric IP процесса B.
Человек так не работает — он пишет имя. В следующей главе DNS превратит имя в
список адресов, а getaddrinfo заставит нас перебирать candidates, не смешивая
ошибку resolver с ошибкой connect().