Deep Engineering

MEASUREMENT

bench/parallel/cost.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/python/threads-vs-processes
How to run it
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

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

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

Скрипт Что делает
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 мс).

Script

93 lines
"""Потоки против процессов по времени: вычисления и ожидание.

ЗАЧЕМ. «Для вычислений — процессы, для ввода-вывода — потоки» — правило,
которое на собеседовании ждут услышать. Здесь оно видно по часам: четыре
одинаковые задачи последовательно, в пуле из четырёх потоков и в пуле из
четырёх процессов.

ЧТО МЕРЯЕТСЯ (секунды на все четыре задачи, медианы):

  1. вычисления — четыре задачи «сумма квадратов» на чистом Python;
  2. ожидание — четыре задачи time.sleep(0.2): модель ожидания (sleep
     отпускает GIL, как ввод-вывод), а не замер настоящей сети или базы;
  3. старт пула — создать пул и выполнить одну пустую задачу: цена того,
     что процессы надо запустить, а потоки — нет.

Сборка CPython — обычная, с GIL; os.cpu_count() на машине записи — 2
(basics.py, раздел 3).

Запускать: python3.13 / 3.14. Числа — этой машины (linux/x86_64, 2 ядра) и
этого прогона: на 3.14.7 отношения уже другие. Механизм — GIL держит поток,
выполняющий Python-код, и отпускает при ожидании — объясняет, почему потоки не
ускорили вычисления; сколько дадут процессы на другой машине, надо мерить.
"""
import statistics
import sys
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor

N = 2_000_000
TASKS = 4
ROUNDS = 5


def cpu_task(n):
    total = 0
    for i in range(n):
        total += i * i
    return total


def io_task(seconds):
    time.sleep(seconds)
    return seconds


def noop(x):
    return x


def sequential(fn, args):
    return [fn(a) for a in args]


def in_pool(pool_cls, fn, args):
    with pool_cls(TASKS) as pool:
        return list(pool.map(fn, args))


def timed(fn):
    t0 = time.perf_counter()
    fn()
    return time.perf_counter() - t0


def main():
    print("PY", sys.version.split()[0])
    print()
    cases = [
        ("1) вычисления: 4 задачи «сумма квадратов»", cpu_task, [N] * TASKS),
        ("2) ожидание: 4 задачи time.sleep(0.2)", io_task, [0.2] * TASKS),
    ]
    for title, fn, args in cases:
        samples = {"последовательно": [], "4 потока": [], "4 процесса": []}
        for _ in range(ROUNDS):
            samples["последовательно"].append(timed(lambda: sequential(fn, args)))
            samples["4 потока"].append(timed(lambda: in_pool(ThreadPoolExecutor, fn, args)))
            samples["4 процесса"].append(timed(lambda: in_pool(ProcessPoolExecutor, fn, args)))
        print(title + ", секунды, медианы", ROUNDS, "кругов")
        base = statistics.median(samples["последовательно"])
        for name, xs in samples.items():
            med = statistics.median(xs)
            print(f"   {name:<17}{med:>8.3f}   ускорение {base / med:>4.1f}")
        print()

    print("3) старт пула и одна пустая задача, мс, медианы", ROUNDS, "кругов")
    for name, cls in [("пул потоков", ThreadPoolExecutor), ("пул процессов", ProcessPoolExecutor)]:
        xs = [timed(lambda cls=cls: in_pool(cls, noop, [0])) * 1000 for _ in range(ROUNDS)]
        print(f"   {name:<15}{statistics.median(xs):>8.2f}")


if __name__ == "__main__":
    main()