ЗАМЕР
bench/nameres/connect_timeout.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/sre/name-resolution
- Как запустить
sudo python3 bench/nameres/resolve.py > bench/nameres/runs/resolve.txt sudo python3 bench/nameres/practice.py > bench/nameres/runs/practice.txt sudo python3 bench/nameres/connect_timeout.py > bench/nameres/runs/connect_timeout.txt
Запись прогона
Замеры для урока «Разрешение имени»
| Файл | Что делает |
|---|---|
resolve.py |
пять наблюдений: откуда приходит ответ (файл или сеть); есть ли кеш в процессе; цена имени, которое не разрешается; сколько запросов уходит при AF_UNSPEC и при AF_INET; что делают поисковые области и точка на конце имени |
connect_timeout.py |
кто покрывает резолв: таймаут сокета у socket.create_connection в CPython его не ограничивает (3,00 с при бюджете 0,3 с), дедлайн вокруг всего вызова (asyncio.wait_for) возвращает вызывающего за 0,30 с, а net.DialTimeout в Go (dialtimeout.go, резолверы go и cgo) включает резолв в свои 300 мс |
practice.py |
ответы к задачам урока: сколько имён спрошено с поисковой областью и без неё, сколько запросов уходит на неуказанное семейство и во что обходится ненайденное имя при настройках по умолчанию |
Запуск из корня репозитория:
sudo python3 bench/nameres/resolve.py > bench/nameres/runs/resolve.txt
sudo python3 bench/nameres/practice.py > bench/nameres/runs/practice.txt
sudo python3 bench/nameres/connect_timeout.py > bench/nameres/runs/connect_timeout.txt
connect_timeout.py (02.11.2026) снят в отдельном пространстве имён
монтирования — unshare -m с mount --bind копии на /etc/resolv.conf, —
чтобы подмена файла не задела соседние процессы; молчащий сервер слушает
127.0.0.77:53. Интерпретатор — CPython 3.11.17 (3.11.15 из шапок остальных
записей в среде уже нет), Go — go1.24.7 linux/amd64.
Как устроен замер
Настоящий сервер имён для блоков 3–5 не нужен и вреден: его времена — это
чужая сеть, а не поведение библиотеки. Вместо него скрипт поднимает свой
сервер на 127.0.0.1:53, который принимает запросы и не отвечает ни на один,
и на время замера подменяет /etc/resolv.conf. Всё, что при этом видно, —
расписание повторов самой libc.
Исходный /etc/resolv.conf копируется в /tmp до первой подмены и
возвращается в finally. Если процесс всё же убить между блоками, файл
останется подменённым — копия лежит в /tmp/resolv.conf.bench-backup.
Что воспроизводимо
Цена ненайденного имени: timeout × attempts × число серверов, секунда в
секунду — для блокирующего getaddrinfo glibc и молчащего сервера.
Что из этого следует для таймаутов клиента, зависит от клиента, а не от DNS.
Замер resolve.py и practice.py — голый socket.getaddrinfo из CPython;
клиента с таймаутом 300 мс в них нет, и «клиент ждал 12 секунд» — вывод для
клиента, у которого таймаут стоит на сокете после резолва. connect_timeout.py
проверяет это прямо: у socket.create_connection(timeout=0.3) резолв занял
3,00 с мимо бюджета, а net.DialTimeout в Go вернулся за 0,30 с, потому что
его таймаут включает разрешение имени. Число запросов: два при AF_UNSPEC, один при AF_INET; два имени
при поисковой области и одно при точке на конце. Порядок источников — из
nsswitch.conf.
Не воспроизводимы миллисекунды настоящих запросов: в этой среде они гуляют от
2,2 до 29,3 мс, поэтому ни одно утверждение урока на них не опирается. Не
воспроизводится и то, какой именно адрес вернётся для example.com, — важно
лишь, что за шесть вызовов подряд он менялся.
Требования к среде
Блокам 3–5 нужны права root: bind на порт 53 и запись в
/etc/resolv.conf. Без них скрипт печатает not permitted и выполняет
первые два блока. Нужен также рабочий выход в сеть для блоков 1 и 2 — без
него example.com не разрешится и строки покажут gaierror.
Числа сняты на CPython 3.11.15, Linux 6.18.44, glibc с hosts: files dns.
Скрипт
201 строк"""Таймаут соединения и дедлайн всего вызова: кто из них покрывает резолв.
ЗАЧЕМ ЭТОТ ФАЙЛ (аудит 01.11.2026, Q-04). Урок говорил безусловно: шаг
разрешения имени «не ограничен таймаутом запроса: таймаут ставится на
соединение, а соединять ещё не с чем». Для одного класса клиентов это верно,
для другого — нет:
- у `socket.create_connection(addr, timeout=...)` в CPython таймаут ставится
на сокет ПОСЛЕ `getaddrinfo` (Lib/socket.py: сначала цикл по
getaddrinfo, внутри него settimeout), и блокирующий вызов резолвера им не
ограничен;
- у `net.DialTimeout` в Go документация прямо говорит «The timeout includes
name resolution, if required», а Dialer.DialContext (net/dial.go,
go1.24.7) превращает Timeout в дедлайн контекста ДО resolveAddrList.
Урок до этого мерил только первый класс — голый `socket.getaddrinfo` из
CPython — и выдавал его за общее правило. Здесь оба класса стоят рядом на
одном и том же молчащем сервере имён.
КАК ИЗМЕРЯЕТСЯ. Как в resolve.py: свой сервер имён, который принимает запросы
и не отвечает ни на один, и подменённый /etc/resolv.conf. Отличие одно: сервер
слушает 127.0.0.77:53, а не 127.0.0.1:53, чтобы не столкнуться с resolve.py,
если тот запущен рядом. Исходный resolv.conf копируется и возвращается в
finally.
ПОЧЕМУ ПОДПИСИ ПО-АНГЛИЙСКИ. Как у соседей: урок цитирует запись прогона
дословно обеими версиями.
ЗАПУСК: sudo python3 bench/nameres/connect_timeout.py
Вывод: runs/connect_timeout.txt
Запись прогона снята в отдельном пространстве имён монтирования, чтобы
подмена resolv.conf не задела соседние процессы машины:
cp /etc/resolv.conf /tmp/rc && unshare -m sh -c \\
'mount --bind /tmp/rc /etc/resolv.conf && python3.11 bench/nameres/connect_timeout.py'
"""
import asyncio
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
RESOLV = "/etc/resolv.conf"
BACKUP = "/tmp/resolv.conf.connect-timeout-backup"
SERVER = "127.0.0.77"
PROBE = "zz-probe-9931"
TIMEOUT_S = 0.3
HERE = os.path.dirname(os.path.abspath(__file__))
def show(title: str) -> None:
print()
print(title)
print("-" * len(title))
def row(label: str, value: object) -> None:
print(f" {label:<46} {value}")
class SilentNameServer:
"""Принимает запросы на SERVER:53 и не отвечает ни на один."""
def __init__(self) -> None:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind((SERVER, 53))
self.queries = 0
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self) -> None:
while True:
try:
self.sock.recvfrom(512)
except OSError:
return
self.queries += 1
def close(self) -> None:
self.sock.close()
def timed(fn) -> tuple[float, str]:
started = time.perf_counter()
try:
fn()
outcome = "connected"
except OSError as exc:
outcome = f"{type(exc).__name__}: {exc}"
return time.perf_counter() - started, outcome
# ------------------------------------------------------------------ 1
def block1() -> None:
show("1. A SOCKET TIMEOUT DOES NOT BOUND THE LOOKUP BEFORE IT")
row("client", f"CPython {sys.version.split()[0]}, socket module")
row("resolv.conf", "timeout:1 attempts:3, one silent server")
s, outcome = timed(lambda: socket.getaddrinfo(PROBE, 80, socket.AF_INET, socket.SOCK_STREAM))
row("getaddrinfo alone, s", f"{s:.2f} -> {outcome}")
s, outcome = timed(
lambda: socket.create_connection((PROBE, 80), timeout=TIMEOUT_S).close()
)
row(f"create_connection(timeout={TIMEOUT_S}), s", f"{s:.2f} -> {outcome}")
print()
print(" create_connection resolves first and sets the timeout on each")
print(" socket afterwards, so the 0.3 s budget never applies to the lookup.")
# ------------------------------------------------------------------ 2
def block2() -> None:
show("2. A DEADLINE AROUND THE WHOLE CALL RETURNS ON TIME")
row("client", f"CPython {sys.version.split()[0]}, asyncio.wait_for")
async def run() -> tuple[float, str, float]:
loop = asyncio.get_running_loop()
lookup_done = loop.create_future()
started = time.perf_counter()
def blocking() -> None:
try:
socket.getaddrinfo(PROBE, 80, socket.AF_INET, socket.SOCK_STREAM)
except OSError:
pass
loop.call_soon_threadsafe(lookup_done.set_result, time.perf_counter() - started)
task = loop.run_in_executor(None, blocking)
try:
await asyncio.wait_for(asyncio.shield(task), TIMEOUT_S)
outcome = "resolved"
except TimeoutError:
outcome = "TimeoutError"
caller = time.perf_counter() - started
background = await lookup_done
return caller, outcome, background
caller, outcome, background = asyncio.run(run())
row(f"caller waited (deadline {TIMEOUT_S} s), s", f"{caller:.2f} -> {outcome}")
row("the lookup thread itself finished after, s", f"{background:.2f}")
print()
print(" The deadline frees the caller, not the resolver: the blocking")
print(" getaddrinfo keeps its thread busy for the full retry schedule.")
# ------------------------------------------------------------------ 3
def block3() -> None:
show("3. GO: DIALTIMEOUT INCLUDES NAME RESOLUTION")
go = shutil.which("go") or "/usr/local/go/bin/go"
version = subprocess.run([go, "version"], capture_output=True, text=True).stdout.strip()
row("client", version)
row("resolv.conf", "timeout:1 attempts:3, one silent server")
for resolver in ("go", "cgo"):
env = dict(os.environ, GODEBUG=f"netdns={resolver}")
out = subprocess.run(
[go, "run", os.path.join(HERE, "dialtimeout.go"), PROBE],
capture_output=True,
text=True,
env=env,
).stdout.strip()
seconds, _, err = out.partition("\t")
row(f"net.DialTimeout 300ms, netdns={resolver}, s", f"{seconds} -> {err}")
print()
print(" Dialer turns Timeout into a context deadline before it resolves")
print(" the name, so the same 0.3 s covers the lookup and the connect.")
def main() -> None:
print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
print(f"a silent name server on {SERVER}:53, an unresolvable name {PROBE!r}")
try:
shutil.copyfile(RESOLV, BACKUP)
server = SilentNameServer()
except OSError as exc:
show("1-3. NOT PERMITTED")
row("cannot bind :53 or write resolv.conf", type(exc).__name__)
return
try:
with open(RESOLV, "w") as handle:
handle.write(f"nameserver {SERVER}\noptions timeout:1 attempts:3\n")
block1()
block2()
block3()
row("queries the silent server received", server.queries)
finally:
shutil.copyfile(BACKUP, RESOLV)
server.close()
print()
print(" resolv.conf restored from the backup.")
if __name__ == "__main__":
main()