Deep Engineering

MEASUREMENT

bench/rlimits/descriptors.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/sre/file-descriptors
How to run it
python3 bench/rlimits/descriptors.py > bench/rlimits/runs/descriptors.txt
python3 bench/rlimits/practice.py    > bench/rlimits/runs/practice.txt

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Лимит дескрипторов»

Файл Что делает
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). Версия интерпретатора роли не играет.

Script

207 lines
"""Лимит дескрипторов: где он живёт, как выглядит его достижение и кто его меняет.

ЗАЧЕМ ЭТОТ ФАЙЛ. «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()