Deep Engineering

ЗАМЕР

bench/parallel/basics.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/threads-vs-processes
Как запустить
python3.11 bench/parallel/basics.py
python3.12 bench/parallel/basics.py
python3.13 bench/parallel/basics.py
python3.14 bench/parallel/basics.py

python3.13 bench/parallel/cost.py
python3.14 bench/parallel/cost.py

python3.11 bench/parallel/practice.py
python3.12 bench/parallel/practice.py
python3.13 bench/parallel/practice.py
python3.14 bench/parallel/practice.py

Запись прогона

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

Скрипт Что делает
basics.py наблюдения без времени: общая память у потока и своя у процесса, что можно передать в пул процессов (pickle), среда — ядра, GIL, -X gil=0, способ запуска процессов, — гонка за общий счётчик без блокировки и с Lock, полный файл squares.py с пулом процессов (способ запуска spawn) и есть ли InterpreterPoolExecutor
cost.py время четырёх одинаковых задач последовательно, в пуле из четырёх потоков и из четырёх процессов — для вычислений и для ожидания; цена запуска пула
practice.py источник ответов двух практических задач урока
python3.11 bench/parallel/basics.py
python3.12 bench/parallel/basics.py
python3.13 bench/parallel/basics.py
python3.14 bench/parallel/basics.py

python3.13 bench/parallel/cost.py
python3.14 bench/parallel/cost.py

python3.11 bench/parallel/practice.py
python3.12 bench/parallel/practice.py
python3.13 bench/parallel/practice.py
python3.14 bench/parallel/practice.py

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7). Сборки обычные, с GIL; os.cpu_count() на машине записи — 2, linux/x86_64. Числа cost.py — этой машины и этого прогона, а не правило: на 3.14.7 потоки в вычислениях дали 0,8, процессы — 1,4. Механизм (GIL) объясняет, почему потоки не ускорили вычисления на Python; сколько дадут процессы на другой машине, надо мерить. time.sleep в cost.py — модель ожидания, а не замер сети.

Что получилось

Память: поток дописал 9 в общий список, процесс — 16 в свою копию; в родителе [9].

pickle: лямбда в пуле потоков работает, в пуле процессов — ошибка: 3.11 и 3.12 — AttributeError: Can't pickle local object, 3.13 — AttributeError: Can't get local object, 3.14 — PicklingError: Can't pickle local object.

Среда: sys._is_gil_enabled() есть с 3.13 и возвращает True; -X gil=0 на 3.13 и 3.14 — Disabling the GIL is not supported by this build, на 3.11 и 3.12 опция не проверяется; способ запуска по умолчанию — fork на 3.11–3.13 и forkserver на 3.14.

Гонка: когда поток уступает ход между чтением и записью (time.sleep(0)), без блокировки итог не 4000, с Lock — 4000; обычный += 1 без уступки в записанных прогонах потерь не дал — это наблюдение, а не гарантия.

Полный файл squares.py (раздел 5; функция на уровне модуля, пул в main(), вызов под if __name__ == "__main__", mp_context=get_context("spawn")): файлом отработал на 3.11–3.14; import squares не создал ни одного процесса; тот же код через python -c — AttributeError в дочернем процессе (Can't get attribute 'compute' на 3.11–3.13, module '__main__' has no attribute 'compute' на 3.14); вариант с пулом на уровне модуля со spawn — RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase. на всех четырёх.

InterpreterPoolExecutor (раздел 6): есть на 3.14.7 и выполнил функцию модуля; на 3.11–3.13 его нет.

Без if __name__ == "__main__": на 3.11–3.13 (fork) скрипт отработал, на 3.14 (forkserver) — RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase.

fork при живом потоке: с 3.12 — DeprecationWarning: This process (pid=…) is multi-threaded, use of fork() may lead to deadlocks in the child.; на 3.11 предупреждения нет.

Время (медианы, 3.13.7, секунды на четыре задачи): вычисления — последовательно 0,502, потоки 0,478, процессы 0,262; ожидание — 0,801, 0,201, 0,212. Старт пула: потоки 0,15 мс, процессы 9,09 мс (на 3.14.7 — 18,86 мс).

Скрипт

272 строк
"""Потоки и процессы: что общее, что нет и что из этого следует.

Времени здесь нет — только наблюдения. Цена — в cost.py.

ШЕСТЬ СЮЖЕТОВ:

1. ПАМЯТЬ. Поток меняет тот же список, что и основная программа; процесс —
   свою копию, и родитель изменений не видит.

2. ЧТО МОЖНО ОТДАТЬ ПРОЦЕССУ. Задачу и аргументы процессу передают через
   pickle, поэтому лямбда или локальная функция в ProcessPoolExecutor не
   годится; потоку — годится что угодно.

3. СРЕДА. Число ядер, включён ли GIL (sys._is_gil_enabled есть с 3.13),
   можно ли его выключить в этой сборке (-X gil=0) и каким способом
   multiprocessing запускает процессы по умолчанию.

4. ГОНКА. Чтение, изменение и запись общего счётчика из нескольких потоков,
   когда поток уступает ход между чтением и записью (time.sleep(0)), теряют
   обновления; с Lock — нет. Для сравнения — обычный `+= 1` без уступки: в
   записанных прогонах потерь не видно, но это не гарантия.

5. ПОЛНЫЙ ФАЙЛ С ПУЛОМ ПРОЦЕССОВ. Шаблон из урока (строка POOL_EXAMPLE):
   функция на уровне модуля, пул внутри main(), вызов под
   `if __name__ == "__main__"`, способ запуска явно spawn. Его запускают
   файлом; импортируют, чтобы убедиться, что import пул не создаёт; тот же
   код отдают через python -c — как в интерактивном интерпретаторе, где у
   главного модуля нет файла; и для сравнения — вариант без проверки
   __name__, с пулом на уровне модуля (в нём один рабочий процесс, чтобы
   тексты ошибок дочерних процессов не перемешивались).

6. INTERPRETERPOOLEXECUTOR. Есть ли в concurrent.futures пул отдельных
   интерпретаторов в одном процессе (появился в 3.14) и выполняет ли он
   функцию модуля.

Весь код — под `if __name__ == "__main__"`: при запуске процессов способом
spawn или forkserver дочерний процесс заново импортирует этот файл.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import multiprocessing
import os
import re
import subprocess
import sys
import threading
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

shared = []


def append_square(n):
    shared.append(n * n)


def square(n):
    return n * n


NO_GUARD = """
import multiprocessing

def f():
    pass

p = multiprocessing.Process(target=f)
p.start()
p.join()
print("exitcode", p.exitcode)
"""

FORK_WITH_THREAD = """
import multiprocessing, threading, time, warnings
warnings.simplefilter("always")

def f():
    pass

if __name__ == "__main__":
    threading.Thread(target=time.sleep, args=(1,), daemon=True).start()
    with warnings.catch_warnings(record=True) as caught:
        p = multiprocessing.get_context("fork").Process(target=f)
        p.start()
        p.join()
    print(caught[0].category.__name__ + ": " + str(caught[0].message) if caught else "предупреждений нет")
"""

# Полный файл из урока: его пишут во временную папку как squares.py.
POOL_EXAMPLE = """\
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import get_context


def compute(n):
    return sum(i * i for i in range(n))


def main():
    chunks = [10, 100, 1000, 10000]
    with ProcessPoolExecutor(4, mp_context=get_context("spawn")) as pool:
        print(list(pool.map(compute, chunks)))


if __name__ == "__main__":
    main()
"""

POOL_NO_GUARD = """\
from concurrent.futures import ProcessPoolExecutor
from multiprocessing import get_context


def compute(n):
    return sum(i * i for i in range(n))


# Один рабочий процесс: иначе тексты ошибок нескольких дочерних процессов
# перемешиваются в stderr построчно.
with ProcessPoolExecutor(1, mp_context=get_context("spawn")) as pool:
    print(list(pool.map(compute, [10, 100, 1000, 10000])))
"""


def first_line(text, prefix):
    for line in text.splitlines():
        if line.startswith(prefix):
            return line
    return None


def pool_example_cases():
    """Раздел 5: файл squares.py запускают, импортируют и отдают через -c."""
    import tempfile
    with tempfile.TemporaryDirectory() as tmp:
        path = os.path.join(tmp, "squares.py")
        with open(path, "w", encoding="utf-8") as f:
            f.write(POOL_EXAMPLE)
        run = lambda *args: subprocess.run([sys.executable, *args], capture_output=True, text=True,
                                           timeout=120, cwd=tmp)
        r = run("squares.py")
        print("   python squares.py               ->", r.stdout.strip() or r.stderr.strip().splitlines()[-1])
        r = run("-c", "import multiprocessing, squares; print(len(multiprocessing.active_children()))")
        print("   import squares: дочерних процессов ->", r.stdout.strip(), "| stderr пуст:", not r.stderr.strip())
        r = run("-c", POOL_EXAMPLE)
        out = r.stdout + r.stderr
        print("   тот же код через python -c      ->", first_line(out, "AttributeError:") or out.strip().splitlines()[-1])
        print("   без if __name__, пул на уровне модуля ->", run_snippet(POOL_NO_GUARD))


def run_snippet(code):
    """Запустить код отдельным файлом и вернуть итог: последнюю строку вывода
    или первую строку RuntimeError, если она есть."""
    import tempfile
    with tempfile.TemporaryDirectory() as tmp:
        path = os.path.join(tmp, "snippet.py")
        with open(path, "w", encoding="utf-8") as f:
            f.write(code)
        r = subprocess.run([sys.executable, path], capture_output=True, text=True, timeout=120)
    lines = (r.stdout + r.stderr).splitlines()
    for i, line in enumerate(lines):
        if line.startswith("RuntimeError:"):
            # Текст ошибки идёт следующими строками до пустой — собираем первое предложение.
            words = [line.strip()]
            for nxt in lines[i + 1:]:
                if not nxt.strip():
                    break
                words.append(nxt.strip())
            text = " ".join(words).replace("RuntimeError: ", "RuntimeError: ", 1)
            return text.split(".")[0] + "."
    last = lines[-1] if lines else ""
    return re.sub(r"pid=\d+", "pid=…", last)


def main():
    print("PY", sys.version.split()[0])
    print()

    # ── 1 ─────────────────────────────────────────────────────────────────
    print("1) память: поток против процесса")
    t = threading.Thread(target=append_square, args=(3,))
    t.start()
    t.join()
    print("   после потока  shared =", shared)
    p = multiprocessing.Process(target=append_square, args=(4,))
    p.start()
    p.join()
    print("   после процесса shared =", shared, "| exitcode процесса:", p.exitcode)
    print()

    # ── 2 ─────────────────────────────────────────────────────────────────
    print("2) что можно отдать в пул")
    with ThreadPoolExecutor(2) as pool:
        print("   потоки,   функция модуля ->", pool.submit(square, 5).result())
        print("   потоки,   lambda         ->", pool.submit(lambda n: n * n, 5).result())
    with ProcessPoolExecutor(2) as pool:
        print("   процессы, функция модуля ->", pool.submit(square, 5).result())
        try:
            pool.submit(lambda n: n * n, 5).result()
            print("   процессы, lambda         -> сработало")
        except Exception as e:  # noqa: BLE001 — нам нужен текст
            # Адрес объекта в тексте ошибки меняется от запуска к запуску.
            text = re.sub(r"0x[0-9a-f]+", "0x…", f"{type(e).__name__}: {e}")
            print("   процессы, lambda         ->", text)
    print()

    # ── 3 ─────────────────────────────────────────────────────────────────
    print("3) среда")
    print("   sys.platform                    ->", sys.platform)
    print("   os.cpu_count()                  ->", os.cpu_count())
    gil = getattr(sys, "_is_gil_enabled", None)
    print("   sys._is_gil_enabled()           ->", gil() if gil else "нет такой функции")
    print("   multiprocessing.get_start_method() ->", multiprocessing.get_start_method())
    r = subprocess.run([sys.executable, "-X", "gil=0", "-c", "pass"], capture_output=True, text=True)
    answer = r.stderr.strip().splitlines()[0] if r.returncode else "запустился, опция не проверяется"
    print("   python -X gil=0                 ->", answer)
    print("   запуск процесса без if __name__ == '__main__' ->", run_snippet(NO_GUARD))
    print("   fork при живом потоке           ->", run_snippet(FORK_WITH_THREAD))
    print()

    # ── 4 ─────────────────────────────────────────────────────────────────
    print("4) гонка: 4 потока по 1000 увеличений общего счётчика")
    state = {"n": 0}
    lock = threading.Lock()

    def unsafe():
        for _ in range(1000):
            value = state["n"]
            time.sleep(0)  # уступить: другой поток успеет прочитать то же значение
            state["n"] = value + 1

    def safe():
        for _ in range(1000):
            with lock:
                value = state["n"]
                time.sleep(0)
                state["n"] = value + 1

    def plain():
        for _ in range(1000):
            state["n"] += 1  # без уступки между чтением и записью

    for name, fn in [("без блокировки", unsafe), ("с Lock", safe), ("просто += 1", plain)]:
        state["n"] = 0
        threads = [threading.Thread(target=fn) for _ in range(4)]
        for th in threads:
            th.start()
        for th in threads:
            th.join()
        print(f"   {name:<15} итог == 4000: {state['n'] == 4000}")
    print()

    # ── 5 ─────────────────────────────────────────────────────────────────
    print("5) полный файл squares.py: пул процессов, способ запуска spawn")
    pool_example_cases()
    print()

    # ── 6 ─────────────────────────────────────────────────────────────────
    print("6) пул интерпретаторов в одном процессе")
    import concurrent.futures as cf
    ipe = getattr(cf, "InterpreterPoolExecutor", None)
    if ipe is None:
        print("   concurrent.futures.InterpreterPoolExecutor -> нет")
    else:
        with ipe(2) as pool:
            print("   concurrent.futures.InterpreterPoolExecutor -> есть, функция модуля ->",
                  pool.submit(square, 5).result())


if __name__ == "__main__":
    main()