ЗАМЕР
bench/rlimits/descriptors.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/sre/file-descriptors
- Как запустить
python3 bench/rlimits/descriptors.py > bench/rlimits/runs/descriptors.txt python3 bench/rlimits/practice.py > bench/rlimits/runs/practice.txt
Запись прогона
Замеры для урока «Лимит дескрипторов»
| Файл | Что делает |
|---|---|
descriptors.py |
четыре наблюдения: мягкий и жёсткий лимит и что процесс может с ними сделать; исчерпание лимита (сколько открылось, имя и текст ошибки); что тратит дескриптор — файл, сокет, канал, epoll; наследование лимита потомком |
practice.py |
ответы к задачам урока: имя ошибки, её текст, унаследованный лимит и число открытых файлов |
Запуск из корня репозитория:
python3 bench/rlimits/descriptors.py > bench/rlimits/runs/descriptors.txt
python3 bench/rlimits/practice.py > bench/rlimits/runs/practice.txt
Что в этих числах воспроизводимо, а что нет
Воспроизводимо на любой машине с Linux: имя ошибки EMFILE и её текст;
арифметика «лимит минус уже занятые дескрипторы» — при лимите 64 открывается
61; канал берёт по два номера; мягкий лимит поднимается до жёсткого без
перезапуска; жёсткий выше себя не поднимается; потомок стартует с лимитом
родителя.
Не воспроизводимо: значения 20000 — это лимит конкретно этой машины. Число дескрипторов, уже занятых процессом на старте, тоже может отличаться, если интерпретатор запущен иначе; поэтому проба вынесена в отдельный процесс, где их ровно три.
Требования к среде
Никаких особых прав: скрипт только опускает и поднимает собственный мягкий
лимит. Единственное, что может не сработать на чужой машине, — последняя
строка первого блока: если процесс запущен с правом менять жёсткий лимит, она
напечатает allowed here (privileged) вместо отказа.
Числа сняты на CPython 3.11.15, Linux 6.18.44 (x86-64). Версия интерпретатора роли не играет.
Скрипт
207 строк"""Лимит дескрипторов: где он живёт, как выглядит его достижение и кто его меняет.
ЗАЧЕМ ЭТОТ ФАЙЛ. «Too many open files» — ошибка, которую чинят перезапуском и
увеличением лимита в конфиге, редко разбираясь, чей это лимит и почему он
кончился. Здесь показано всё, из чего состоит механизм: мягкий и жёсткий
лимит, число, до которого удаётся дойти, вид ошибки, что считается
дескриптором и что процесс может сделать со своим лимитом сам.
ПОЧЕМУ ПОДПИСИ ПО-АНГЛИЙСКИ. Урок существует в двух языках и цитирует запись
прогона дословно обеими версиями.
ЧТО ЗДЕСЬ ИЗМЕРЯЕТСЯ. Ни одного замера времени: только числа дескрипторов,
имена ошибок и значения лимитов. Всё это — наблюдения, воспроизводимые на
любой машине с Linux.
ЗАПУСК: python3 bench/rlimits/descriptors.py
Вывод: runs/descriptors.txt
"""
import errno
import os
import resource
import socket
import subprocess
import sys
SOFT_FOR_PROBE = 64
def show(title: str) -> None:
print()
print(title)
print("-" * len(title))
def row(label: str, value: object) -> None:
print(f" {label:<44} {value}")
def open_until_emfile(kind: str) -> tuple[int, str]:
"""Открывает объекты одного вида, пока ядро не откажет. Возвращает счёт и ошибку."""
keep: list[object] = []
while True:
try:
if kind == "file":
keep.append(open("/dev/null", "rb"))
elif kind == "socket":
keep.append(socket.socket(socket.AF_INET, socket.SOCK_STREAM))
elif kind == "pipe":
r, w = os.pipe()
keep.append(r)
keep.append(w)
elif kind == "epoll":
keep.append(select_epoll())
except OSError as exc:
name = errno.errorcode.get(exc.errno, str(exc.errno))
count = len(keep)
for item in keep:
close_any(item)
return count, f"{name}: {exc.strerror}"
def select_epoll():
import select
return select.epoll()
def close_any(item: object) -> None:
if isinstance(item, int):
try:
os.close(item)
except OSError:
pass
else:
try:
item.close() # type: ignore[attr-defined]
except Exception:
pass
# Дочерняя программа: ставит себе мягкий лимит и считает, сколько успела
# открыть. Отдельный процесс нужен потому, что интерпретатор родителя уже
# держит какие-то дескрипторы, и число получилось бы «про этот прогон», а не
# про лимит.
CHILD = """
import errno, os, resource, sys
soft = int(sys.argv[1])
kind = sys.argv[2]
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, resource.getrlimit(resource.RLIMIT_NOFILE)[1]))
# listdir сам открывает каталог, поэтому один дескриптор здесь лишний.
open_at_start = len(os.listdir("/proc/self/fd")) - 1
keep = []
try:
while True:
if kind == "file":
keep.append(open("/dev/null", "rb"))
else:
import socket
keep.append(socket.socket(socket.AF_INET, socket.SOCK_STREAM))
except OSError as exc:
print(errno.errorcode.get(exc.errno, exc.errno))
print(exc.strerror)
print(len(keep))
print(open_at_start)
"""
def child_probe(soft: int, kind: str) -> list[str]:
done = subprocess.run(
[sys.executable, "-c", CHILD, str(soft), kind], capture_output=True, text=True
)
return done.stdout.strip().splitlines()
# ------------------------------------------------------------------ 1
def block1() -> None:
show("1. TWO LIMITS, AND ONLY ONE OF THEM IS ENFORCED")
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
row("soft limit (the one the kernel enforces)", soft)
row("hard limit (the ceiling for the soft one)", hard)
row("descriptors this process holds right now", len(os.listdir("/proc/self/fd")) - 1)
# Мягкий лимит процесс меняет себе сам — в пределах жёсткого.
resource.setrlimit(resource.RLIMIT_NOFILE, (256, hard))
row("after lowering the soft limit to 256", resource.getrlimit(resource.RLIMIT_NOFILE)[0])
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
row("raised back to the hard limit, no restart", resource.getrlimit(resource.RLIMIT_NOFILE)[0])
try:
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard + 1))
row("raising the hard limit", "allowed here (privileged)")
except (ValueError, OSError) as exc:
row("raising the hard limit", f"refused: {type(exc).__name__}")
# ------------------------------------------------------------------ 2
def block2() -> None:
show("2. WHAT HITTING THE LIMIT LOOKS LIKE")
lines = child_probe(SOFT_FOR_PROBE, "file")
if len(lines) != 4:
row("child probe", "unexpected output")
return
code, message, opened, at_start = lines
row("soft limit set by the child", SOFT_FOR_PROBE)
row("descriptors the child already had", at_start)
row("files it managed to open", opened)
row("error name", code)
row("error message", message)
print()
print(" The count plus the descriptors already held equals the limit:")
print(" the limit is a number of descriptors, not a number of files.")
# ------------------------------------------------------------------ 3
def block3() -> None:
show("3. IT IS NOT ONLY FILES THAT COUNT")
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (SOFT_FOR_PROBE, hard))
for kind in ("file", "socket", "pipe", "epoll"):
count, message = open_until_emfile(kind)
row(f"{kind}: opened before the refusal", f"{count} — {message}")
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
print()
print(" A socket, a pipe end, an epoll instance: each is a descriptor.")
print(" So is every connection a client library keeps open.")
# ------------------------------------------------------------------ 4
def block4() -> None:
show("4. THE LIMIT IS PER PROCESS, AND IT IS INHERITED")
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (128, hard))
done = subprocess.run(
[
sys.executable,
"-c",
"import resource; print(resource.getrlimit(resource.RLIMIT_NOFILE)[0])",
],
capture_output=True,
text=True,
)
row("soft limit set in the parent", 128)
row("soft limit the child starts with", done.stdout.strip())
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
row("soft limit restored in the parent", resource.getrlimit(resource.RLIMIT_NOFILE)[0])
print()
print(" Nothing global changed: another process on this machine keeps")
print(" its own limit. That is why raising a limit in one shell fixes")
print(" nothing for a service started from somewhere else.")
def main() -> None:
print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
block1()
block2()
block3()
block4()
if __name__ == "__main__":
main()