51. (Л) Побудова сокетного подієвого циклу на selectors¶
Зміст лекції¶
- Від наївного
selectors-сервера до повноцінного event loop - Три проблеми, що ламають сервер із лекції 49
- Per-connection state: де живуть дані з'єднання
- Read-буфер: збирання повідомлень із потоку байтів
- Write-буфер: партіальна відправка через
send - Динамічна маска інтересу:
sel.modify(...) - Тайм-аути через
select(timeout=...) - Reactor — клас, що ховає
selectors - Як це все стає
asyncio
Від наївного selectors-сервера до повноцінного event loop¶
У лекції 49 ми вже зібрали робочий неблокуючий ехо-сервер: selectors.DefaultSelector, register / select / unregister, маски EVENT_READ / EVENT_WRITE. Для іграшкового ехо-сервера, де одна порція байтів = одне повідомлення, а send ніколи «не дає здачі», цього вистачає.
Ця лекція — про те, як той самий код перетворити на event loop, придатний для реального протоколу. Жодних нових інструментів — лише дисципліна довкола вже знайомих selectors.
graph LR
A["Лекція 49:<br/>один recv → один send,<br/>state = нуль"]
B["Лекція 51:<br/>буфери + динамічна маска +<br/>per-connection state"]
A -. «реальне життя» .-> B
style A fill:#ffd43b,stroke:#333,color:#000
style B fill:#82c91e,stroke:#333,color:#000
Три проблеми, що ламають сервер із лекції 49¶
Згадаймо ядро сервера 49:
def serve(conn, sel, addr):
chunk = conn.recv(4096)
if not chunk:
sel.unregister(conn); conn.close(); return
conn.send(chunk)
Він коректний для одного діалекту: «надсилай ціле повідомлення цілим recv, і не давай більше, ніж 4 КБ за раз». Будь-який реальний протокол одразу ламає три припущення:
- Повідомлення довше за один
recv. Клієнт надіславHello, world!\n, але через MTU чи timing ядро віддало вам спершуHello,— а решту через 30 мс. Перша половина «зависла в повітрі»: куди її покласти? sendповертає менше байтів, ніж дали. Буфер відправки в ядрі обмежений. Якщо клієнт читає повільно —send(b"...4 KB...")поверне 1024 і скаже «решту відправ потім». У нашому коді ці байти просто губляться.- Постійна підписка на
WRITE. Якщо «про всяк випадок» зареєструватиREAD | WRITE,selectпробуджуватиметься щомиті: неблокуючий сокет майже завжди готовий до запису. CPU летить у 100%.
Кожна з трьох проблем має одне розв'язання — і всі вони складаються в одну й ту саму структуру, яку прийнято називати event loop.
Per-connection state¶
Перший крок — дати кожному з'єднанню «домівку». У сервері 49 ми клали в data= словник з 1 поля (адресу). Реальний обробник хоче тримати більше:
addr— для логів;inbuf— байти, які прочитали, але ще не сформували в повідомлення;outbuf— байти, які треба відправити, але ще не влізли в буфер ядра;n— лічильник повідомлень для цього клієнта;last_seen— для idle-timeout.
Це природний dataclass:
import socket
import time
from dataclasses import dataclass, field
@dataclass
class Connection:
sock: socket.socket
addr: tuple[str, int]
inbuf: bytearray = field(default_factory=bytearray)
outbuf: bytearray = field(default_factory=bytearray)
n: int = 0
last_seen: float = field(default_factory=time.monotonic)
При реєстрації кладемо цей об'єкт у data:
conn = Connection(sock=client_sock, addr=client_addr)
sel.register(client_sock, selectors.EVENT_READ, data=conn)
Тепер у обробнику ми одним рухом дістаємо все:
def on_event(key, mask):
conn: Connection = key.data
# доступні conn.sock, conn.addr, conn.inbuf, conn.outbuf, conn.n...
Чому не глобальний dict[fd, state]
Технічно можна тримати окремий словник «fd → стан». Але selectors уже дає key.data — це і є офіційний канал. Дві копії стану — двоїте джерело істини й ризикуєте розузгодженням при unregister/close.
Read-буфер: збирання повідомлень із потоку байтів¶
TCP — це потік байтів, не повідомлень. recv(4096) повертає «скільки є», а не «один логічний пакет». Якщо ваш протокол ділить повідомлення, наприклад, символом \n, рамки складаєте ви, а не ядро.
Алгоритм: накопичуємо в inbuf усе, що прийшло, і потім «нарізаємо» по роздільнику:
def on_readable(conn: Connection) -> list[bytes]:
try:
chunk = conn.sock.recv(4096)
except BlockingIOError:
return []
if not chunk:
return [b""] # peer закрив
conn.inbuf.extend(chunk)
# витягуємо ЦІЛІ рядки, що закінчуються на \n
messages: list[bytes] = []
while True:
i = conn.inbuf.find(b"\n")
if i == -1:
break # повного рядка ще нема — чекаємо
line = bytes(conn.inbuf[:i])
del conn.inbuf[: i + 1] # викидаємо рядок і сам \n
messages.append(line)
return messages
Ключове: inbuf накопичує між викликами. Подія READ спрацьовує — дочитуємо й намагаємось «нарізати», скільки виходить. Те, що не нарізалось — лежить до наступного пробудження.
graph LR
R1["recv → 'Hel'"] --> B1["inbuf: 'Hel'"]
B1 --> N1{"\n всередині?"}
N1 -- ні --> WAIT1["чекаємо event"]
WAIT1 --> R2["recv → 'lo\\nwor'"]
R2 --> B2["inbuf: 'Hello\\nwor'"]
B2 --> N2{"\n всередині?"}
N2 -- так --> CUT["msg='Hello'<br/>inbuf: 'wor'"]
CUT --> WAIT2["чекаємо event"]
style CUT fill:#82c91e,stroke:#333,color:#000
Один recv ≠ одне \n-обмежене повідомлення
У nc так часто здається, бо stdin лінійно-буферизований. Припущення «recv поверне рівно один рядок» — це класичний баг: він стрілятиме рівно тоді, коли клієнт почне посилати багато повідомлень підряд або переходити по сегментах TCP.
Write-буфер: партіальна відправка¶
send на неблокуючому сокеті повертає число фактично записаних байтів — і це число може бути менше за довжину того, що ви дали. Решта не відправилась. Якщо її загубити — клієнт отримає урізане повідомлення.
Шаблон роботи з частковим записом:
- Усе, що треба відправити, спершу йде в
conn.outbuf. - На події
EVENT_WRITEберемо стільки зoutbuf, скількиsendпогодиться прийняти, і викидаємо саме цю кількість з початку буфера. - Якщо
outbufспорожнів — знімаємо інтересEVENT_WRITE(інакшеselectбуде нескінченно прокидати нас, бо сокет «майже завжди готовий до запису»).
import selectors
def queue_send(conn: Connection, sel: selectors.BaseSelector, data: bytes) -> None:
was_empty = not conn.outbuf
conn.outbuf.extend(data)
if was_empty:
# тепер є що писати — додаємо інтерес WRITE
sel.modify(conn.sock, selectors.EVENT_READ | selectors.EVENT_WRITE, data=conn)
def on_writable(conn: Connection, sel: selectors.BaseSelector) -> None:
if not conn.outbuf:
return
try:
sent = conn.sock.send(conn.outbuf)
except BlockingIOError:
return
del conn.outbuf[:sent] # викидаємо те, що пішло
if not conn.outbuf:
# черга порожня — більше не цікавимось WRITE
sel.modify(conn.sock, selectors.EVENT_READ, data=conn)
Ця пара функцій — мінімально достатня для коректного запису на повільний канал. Без неї будь-який сервер, що пише більше, ніж кілька кілобайт, рано чи пізно загубить дані.
sequenceDiagram
participant APP as Програма
participant SEL as Selector
participant K as Ядро
APP->>APP: queue_send(b"4 KB")
APP->>SEL: modify(READ|WRITE)
SEL-->>APP: WRITE ready
APP->>K: send(outbuf) → 1024
APP->>APP: outbuf: 3 KB лишилось
SEL-->>APP: WRITE ready
APP->>K: send(outbuf) → 3072
APP->>APP: outbuf порожній
APP->>SEL: modify(READ only)
Note over APP,SEL: WRITE-події припиняються —<br/>CPU не горить даремно
Динамічна маска інтересу¶
sel.modify(fd, events, data=...) — серце event loop'а. Це єдиний правильний спосіб сказати «тепер мене цікавлять інші події на цьому сокеті».
Чотири канонічних патерни:
| Стан з'єднання | Маска | Чому |
|---|---|---|
| Чекаємо запиту | READ |
Нема що відправляти — нема сенсу слухати WRITE |
Є дані в outbuf |
READ \| WRITE |
Хочемо і нових запитів, і вікна відправки |
| Відповідь повністю відправлена | READ |
Назад у режим очікування |
| Клієнт закрив свою половину, але ми ще не дописали | тільки WRITE |
Дочитувати нема куди, лише дописати |
Постійно тримати READ | WRITE — антипатерн
Неблокуючий сокет майже завжди готовий до запису. Якщо маска WRITE увімкнена, а outbuf порожній — select повертатиметься щомиті, і CPU зупиниться на 100%. «Можна писати» не означає «треба писати».
Тайм-аути через select(timeout=...)¶
select приймає необов'язковий timeout у секундах:
None— чекати безкінечно (як у 49);0— не чекати взагалі, опитати поточний стан і повернутись;- число
>0— чекати, доки спрацює подія або мине стільки секунд.
Це дає місце для періодичних задач: idle-timeouts, статистика, очищення кешів, перевірка прапорця running.
IDLE_DEADLINE = 30.0 # сек
def idle_pass(connections, sel, now):
for conn in list(connections):
if now - conn.last_seen > IDLE_DEADLINE:
sel.unregister(conn.sock)
conn.sock.close()
connections.remove(conn)
def loop(sel, connections):
while True:
events = sel.select(timeout=1.0) # просинаємось хоча б раз на секунду
for key, mask in events:
... # обробка події
idle_pass(connections, sel, time.monotonic())
Точність — через купу таймерів
Замість фіксованого timeout=1.0 зрілий event loop тримає впорядковану купу найближчих дедлайнів і передає в select різницю до найближчого з них. Саме так зроблено в asyncio.base_events.
Reactor — клас, що ховає selectors¶
Складемо все докупи у мінімальний клас разом із прикладним підкласом. Це той самий скелет, що ви впізнаєте в asyncio чи Twisted. Файл повністю самодостатній — копіюйте в reactor_echo.py, запускайте python3 reactor_echo.py, підключайтесь через nc 127.0.0.1 9100.
import selectors
import socket
import time
from dataclasses import dataclass, field
@dataclass
class Connection:
sock: socket.socket
addr: tuple[str, int]
inbuf: bytearray = field(default_factory=bytearray)
outbuf: bytearray = field(default_factory=bytearray)
last_seen: float = field(default_factory=time.monotonic)
class Reactor:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self.sel = selectors.DefaultSelector()
self.connections: dict[int, Connection] = {} # fd -> Connection
self.running = False
# --- API для обробників ---
def queue_send(self, conn: Connection, data: bytes) -> None:
was_empty = not conn.outbuf
conn.outbuf.extend(data)
if was_empty:
self.sel.modify(
conn.sock,
selectors.EVENT_READ | selectors.EVENT_WRITE,
data=conn,
)
def close(self, conn: Connection) -> None:
self.sel.unregister(conn.sock)
conn.sock.close()
self.connections.pop(conn.sock.fileno(), None)
# --- внутрішнє ---
def _accept(self, server: socket.socket) -> None:
try:
client, addr = server.accept()
except BlockingIOError:
return
client.setblocking(False)
conn = Connection(sock=client, addr=addr)
self.connections[client.fileno()] = conn
self.sel.register(client, selectors.EVENT_READ, data=conn)
self.on_connect(conn)
def _read(self, conn: Connection) -> None:
try:
chunk = conn.sock.recv(4096)
except BlockingIOError:
return
if not chunk:
self.on_disconnect(conn)
self.close(conn)
return
conn.last_seen = time.monotonic()
conn.inbuf.extend(chunk)
self.on_data(conn)
def _write(self, conn: Connection) -> None:
if not conn.outbuf:
return
try:
sent = conn.sock.send(conn.outbuf)
except BlockingIOError:
return
del conn.outbuf[:sent]
if not conn.outbuf:
self.sel.modify(conn.sock, selectors.EVENT_READ, data=conn)
# --- події, які перевизначає підклас ---
def on_connect(self, conn: Connection) -> None: ...
def on_data(self, conn: Connection) -> None: ...
def on_disconnect(self, conn: Connection) -> None: ...
# --- головний цикл ---
def run(self) -> None:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((self.host, self.port))
server.listen(128)
server.setblocking(False)
self.sel.register(server, selectors.EVENT_READ, data=None)
self.running = True
print(f"reactor listening on {self.host}:{self.port}")
try:
while self.running:
for key, mask in self.sel.select(timeout=1.0):
if key.data is None:
self._accept(key.fileobj)
else:
if mask & selectors.EVENT_READ:
self._read(key.data)
if mask & selectors.EVENT_WRITE:
self._write(key.data)
except KeyboardInterrupt:
print("reactor stopped")
finally:
self.sel.close()
server.close()
# --- прикладний код: підклас, що знає лише про повідомлення ---
class LineEchoServer(Reactor):
def on_connect(self, conn: Connection) -> None:
print(f"connected: {conn.addr}")
self.queue_send(conn, b"Hello!\n")
def on_data(self, conn: Connection) -> None:
while True:
i = conn.inbuf.find(b"\n")
if i == -1:
return # повного рядка ще нема
line = bytes(conn.inbuf[:i])
del conn.inbuf[: i + 1]
self.queue_send(conn, b"[echo] " + line + b"\n")
def on_disconnect(self, conn: Connection) -> None:
print(f"disconnected: {conn.addr}")
if __name__ == "__main__":
LineEchoServer("127.0.0.1", 9100).run()
Зверніть увагу: прикладний код (LineEchoServer) навіть не згадує selectors. Він описує лише три події — підключився, прийшли дані, відключився — а вся механіка READ/WRITE-масок, буферів і modify ховається у Reactor.
flowchart TD
A([sel.select])
A --> B{key.data is None?}
B -- так --> C[_accept → register conn → on_connect]
B -- ні --> D{READ?}
D -- так --> E[recv → inbuf → on_data]
D --> F{WRITE?}
F -- так --> G[send outbuf<br/>→ pop sent bytes]
G --> H{outbuf empty?}
H -- так --> I[modify: READ only]
H -- ні --> A
E --> F
C --> A
I --> A
style C fill:#82c91e,stroke:#333,color:#000
style G fill:#4dabf7,stroke:#333,color:#000
Як це все стає asyncio¶
Те, що ми щойно зібрали, — це і є asyncio в мініатюрі. Дослівне відображення:
| Наш Reactor | asyncio |
|---|---|
Connection (inbuf/outbuf) |
StreamReader + StreamWriter |
on_data (хук на READ) |
await reader.readline() |
queue_send |
writer.write(...) |
Знаття інтересу WRITE при порожньому outbuf |
await writer.drain() |
Цикл while self.running |
asyncio.run(main()) |
select(timeout=1.0) |
loop._run_once() |
asyncio дозволяє писати «лінійний» код із async/await, який під капотом перетворюється на ту саму машину станів, що ми писали руками. Жодної магії не з'являється — лише зручніший синтаксис для того самого механізму.
Чому варто було пройти руками
Коли await writer.drain() блокується довше за очікуване, або сервер їсть CPU при простої, або asyncio.gather дає неочікуваний порядок — без розуміння нижнього рівня причину знайти важко. Тепер ви знаєте, де шукати.
Підсумок¶
| Концепція | Опис |
|---|---|
| Per-connection state | Один об'єкт на з'єднання, кладеться в key.data |
inbuf (read buffer) |
Збирає байти між подіями, поки не сформується повне повідомлення |
outbuf (write buffer) |
Тримає те, що send не зміг вмістити в буфер ядра |
sel.modify(...) |
Динамічно змінює маску READ/WRITE залежно від стану |
Маска WRITE лише за наявності даних |
Інакше CPU 100% |
select(timeout=N) |
Дає вікно для періодичних задач (idle-timeouts тощо) |
| Reactor | Клас, що ховає selectors і дає on_connect / on_data / on_disconnect |
Ключові ідеї:
- Event loop — це дисципліна, а не бібліотека.
selectorsдає примітиви, але без буферів і динамічних масок далеко не зайдеш. - Партіальний
send— головна пастка реальних серверів. Завжди майтеoutbufі обробникEVENT_WRITE. asyncio— це той самий Reactor під капотом. Усе, що ви тут побудували, — його прозоре пояснення.
Корисні посилання¶
- Python docs — selectors
- Python docs — Streams (asyncio)
- CPython — asyncio/base_events.py — як цей самий цикл написано в стандартній бібліотеці
- Reactor pattern — Wikipedia
- Doug Lea — Scalable IO in Java — класична стаття про Reactor (Java, але концепції універсальні)
Знайшли помилку чи бажаєте додати інформацію, щоб покращити курс? Створіть issue на GitHub