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()