This commit is contained in:
lovinervy committed 2026-10-07 13:06:47 +05:00
commit c394677d07
59 files changed
+5149

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
# Технология параллельного программирования (Python)
Курс лабораторных работ для направления «Прикладная математика и информатика».
## Цель курса
Научиться осознанно выбирать модель исполнения для конкретной задачи:
| Модель | Когда помогает | Инструменты |
|---|---|---|
| Последовательный код | базовая линия | stdlib |
| Потоки (threads) | задачи I/O-bound | `threading`, `concurrent.futures` |
| Процессы (processes) | задачи CPU-bound | `multiprocessing` |
| Асинхронность | много I/O-операций, кооперативность | `asyncio` |
| Распределённые вычисления | несколько машин / узлов | `mpi4py` (MS-MPI) |
Отдельное внимание: **GIL** — почему потоки в CPython не ускоряют чистый Python-код,
и как это обходить.
## Состав работ
| № | Папка | Тема |
|---|---|---|
| 0 | `lab00_intro/` | Окружение: Python, venv, VS Code, проверка готовности |
| 1 | `lab01_sequential/` | Последовательное вычисление, профилирование, CPU-bound vs I/O-bound |
| 2 | `lab02_threads/` | Многопоточность на I/O-bound задачах, `ThreadPoolExecutor` |
| 3 | `lab03_gil/` | GIL: почему потоки не ускоряют CPU-код; `hashlib` как исключение |
| 4 | `lab04_multiprocessing/` | Процессы, `Pool`, накладные расходы, spawn на Windows |
| 5 | `lab05_sync/` | Гонки данных, `Lock`, `Semaphore`, `Event`, producer–consumer |
| 6 | `lab06_asyncio/` | `async/await`, event loop, `gather`, семафор, сравнение с потоками |
| 7 | `lab07_comparison/` | Одна задача × 4 подхода, закон Амдала, итоговые графики |
| 8 | `lab08_mpi/` | Распределённые вычисления, MPI (по указанию преподавателя) |
## Варианты
Каждый студент выполняет **свой вариант** — см. `common/варианты.md`
(20 вариантов: CPU-задача + параметры + I/O-нагрузка). Ядра всех задач уже
реализованы в `common/tasks.py` — смысл лабораторных именно в *организации
вычислений и замерах*, а не в переписывании алгоритмов.
## Установка (Windows, 10/11)
1. Python 3.10+ с python.org (галочка **Add python.exe to PATH**).
2. Открыть папку курса в VS Code (расширение Python).
3. В терминале VS Code:
```bat
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python lab00_intro\check_env.py
```
Если вывод кириллицы «кракозябрами» — выполните `chcp 65001`.
На macOS/Linux всё то же самое, кроме `activate` (там `source .venv/bin/activate`).
## Как сдавать
- Код: `labXX_.../solution.py` (заполняются заготовки, интерфейсы менять нельзя —
по ним работают автотесты).
- Отчёт: по шаблону `common/шаблон_отчёта.md` — таблицы замеров, графики, выводы.
- Самопроверка перед сдачей: `python -m pytest tests/ -k labXX` из корня курса.
- Чек-лист: `common/чек-лист_сдачи.md`. Типичные ошибки: `common/типичные_ошибки.md`.
## Структура репозитория
```
common/ банк задач, варианты, утилиты замеров, шаблоны
lab00_intro/ ... lab08_mpi/
методичка.md теория + задание + контрольные вопросы + критерии
examples/ запускаемые демонстрации
solution.py заготовка решения (заполняет студент)
tests/ автотесты pytest (самопроверка студента, проверка преподавателя)
instructor_solutions/ эталонные решения (только для преподавателя)
```
## Запуск примеров и решений
Все команды выполняются **из корня курса**:
```bat
python lab01_sequential\examples\01_perf_counter_demo.py
python lab01_sequential\solution.py
python -m pytest tests\ -v
```
+41
View File
@@ -0,0 +1,41 @@
# Банк CPU-задач для вариантов
Все ядра уже реализованы в `common/tasks.py`. Здесь — смысловые описания:
что считается результатом, почему задача CPU-bound и чем интересна при
распараллеливании. Единый интерфейс каждой задачи:
```
split(total, parts) -> куски — разбиение работы на части
kernel(кусок) -> частичный результат — вычисление одной части
combine(части) -> итог — сборка итога
```
Этот интерфейс намеренно совпадает с типовой схемой MapReduce — он один и тот же
во всех лабораторных: сначала вы вызываете его последовательно, затем потоками,
процессами и т. д.
| № | Задача | Результат | Почему CPU-bound | Особенности при распараллеливании |
|---|--------|-----------|------------------|-----------------------------------|
| 1 | Умножение матриц (наивное, N×N) | контрольная сумма произведения | тройной цикл на чистом Python | разбиение по строкам; большие данные при пересылке |
| 2 | Число π методом Монте-Карло | приближение π | миллионы случайных бросков | идеально независимые части; суммирование счётчиков |
| 3 | Численное интегрирование (трапеции) | значение интеграла | миллионы вычислений f(x) | разбиение отрезка; сумма частичных площадей |
| 4 | Подсчёт простых чисел ≤ M (перебор делителей) | количество простых | O(N·√N) на чистом Python | разбиение диапазона; сумма счётчиков |
| 5 | Задача N ферзей (подсчёт расстановок) | число решений | комбинаторный перебор | ветка = первый ферзь в колонке; сумма решений |
| 6 | Частотный анализ текста (подсчёт слов) | словарь «слово → число» | построчная обработка миллионов слов | разбиение по строкам; слияние словарей |
| 7 | «Размытие» матрицы (box blur 3×3) | контрольная сумма нового изображения | 9 операций на пиксель на чистом Python | разбиение по строкам с перекрытием (halo) |
| 8 | Хеширование строк (SHA-256) | контрольная сумма дайджестов | сотни тысяч хеширований | независимые элементы; **hashlib освобождает GIL** |
| 9 | Сортировка слиянием больших массивов | отсортированный массив | сортировка миллионов элементов | сортировка кусков + merge; дорогостоящая пересылка данных |
| 10 | Игра «Жизнь» (клеточный автомат) | число живых клеток после T поколений | обход поля на каждое поколение | параллелятся поколения (шаги), обмен каймой строк |
## Примечания
- Задачи 1–5, 7, 10 — «честно» последовательные на чистом Python: потоки их
**не** ускоряют (GIL), процессы — ускоряют почти линейно.
- Задача 8 — исключение: `hashlib` освобождает GIL на время хеширования
**одного блока**, поэтому потоки дают реальное ускорение, если блоки
крупные (≥ 256 КБ). На мелких блоках ускорения нет — GIL возвращается
мгновенно. Используется в лабораторной 3 (сравнение 4 КБ против 1 МБ).
- Задача 9 — демонстрация **накладных расходов**: пересылка данных процессам
может съесть весь выигрыш от сортировки. Отличный материал для выводов в отчёте.
- Задача 10 — знакомство с идеей обмена «каймой» (halo exchange), как в
реальных сеточных методах и в MPI.
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""
common.benchmark — утилиты для замеров времени и построения таблиц/графиков.
Используется во всех лабораторных (1–7), чтобы отчёты были единообразными.
Пример:
from common.benchmark import time_call, make_table, save_speedup_plot
elapsed, result = time_call(lambda: task["kernel"](chunk), repeats=1)
rows = [("последовательно", T1), ("потоки, p=4", T2)]
print(make_table(rows))
"""
from __future__ import annotations
import os
import statistics
import time
from typing import Callable, Dict, List, Sequence, Tuple
def time_call(fn: Callable, args: tuple = (), repeats: int = 3) -> Tuple[float, any]:
"""Замер времени выполнения fn(*args) с повторами.
Возвращает (лучшее_время_сек, результат). Лучшее (минимальное) время —
стандартная практика: случайные помехи только удлиняют замер.
"""
best = float("inf")
result = None
for _ in range(repeats):
t0 = time.perf_counter()
result = fn(*args)
best = min(best, time.perf_counter() - t0)
return best, result
def speedup(T1: float, Tp: float) -> float:
"""Ускорение S(p) = T(1) / T(p)."""
if Tp <= 0:
return 0.0
return T1 / Tp
def efficiency(speedup_val: float, p: int) -> float:
"""Эффективность E(p) = S(p) / p (доля, а не проценты)."""
if p <= 0:
return 0.0
return speedup_val / p
def amdahl(speedup_bound: float, p: int) -> float:
"""Теоретическое ускорение по закону Амдала.
speedup_bound — доля последовательной части (0..1), т.е. доля кода,
которая не распараллеливается.
"""
s = max(1e-12, speedup_bound)
return 1.0 / (s + (1.0 - s) / p)
def make_table(rows: Sequence[Tuple[str, float]], T1: float = None,
title: str = "Замеры") -> str:
"""Текстовая таблица: подход | T, c | S(p) | E(p).
rows — пары (название, время в секундах). T1 — время последовательного
запуска (первая строка, если не задано отдельно).
"""
if T1 is None:
T1 = rows[0][1]
lines = [
f"{title}",
f"{'подход':<32} {'T, c':>10} {'S(p)':>8} {'E(p)':>8}",
"-" * 62,
]
for name, t in rows:
s = speedup(T1, t)
p = _p_from_name(name)
e = efficiency(s, p)
lines.append(f"{name:<32} {t:>10.3f} {s:>8.2f} {e:>8.2f}")
return "\n".join(lines)
def _p_from_name(name: str) -> int:
"""Пытается вытащить число воркеров из названия ('потоки, p=4' -> 4)."""
if "p=" in name:
try:
return int(name.split("p=")[1].split()[0].strip("),"))
except ValueError:
pass
return 1
def save_speedup_plot(results: Dict[str, List[Tuple[int, float]]], title: str,
out_path: str, show: bool = False):
"""График ускорения S(p) для нескольких подходов.
results = {"потоки": [(1, 1.0), (2, 1.9), (4, 3.8)],
"процессы": [(1, 1.0), (2, 1.95), (4, 3.7)]}
Для каждого подхода S(p) считается относительно его же T(1) — так
кривые корректно сравниваются между собой.
"""
import matplotlib
if not show:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 5))
for name, pts in results.items():
pts = sorted(pts)
base = next((t for p, t in pts if p == 1), None)
if base is None:
continue
xs = [p for p, _ in pts]
ys = [base / t for _, t in pts]
plt.plot(xs, ys, marker="o", label=name)
plt.plot(xs, xs, "--", color="gray", label="идеал S(p)=p")
plt.xlabel("число воркеров p")
plt.ylabel("ускорение S(p) = T(1)/T(p)")
plt.title(title)
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
plt.savefig(out_path, dpi=150)
if show:
plt.show()
plt.close()
print(f"график сохранён: {out_path}")
def save_bar_plot(results: Dict[str, float], title: str, out_path: str,
ylabel: str = "время, c", show: bool = False):
"""Столбчатая диаграмма: подход -> значение (время или ускорение)."""
import matplotlib
if not show:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
names = list(results.keys())
vals = [results[k] for k in names]
plt.figure(figsize=(9, 5))
plt.bar(names, vals, color="steelblue")
plt.ylabel(ylabel)
plt.title(title)
plt.xticks(rotation=20, ha="right")
plt.grid(True, axis="y", alpha=0.3)
plt.tight_layout()
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
plt.savefig(out_path, dpi=150)
if show:
plt.show()
plt.close()
print(f"график сохранён: {out_path}")
def median_time(fn: Callable, args: tuple = (), repeats: int = 5) -> Tuple[float, any]:
"""Аналог time_call, но по медиане — устойчив к выбросам."""
times = []
result = None
for _ in range(repeats):
t0 = time.perf_counter()
result = fn(*args)
times.append(time.perf_counter() - t0)
return statistics.median(times), result
+795
View File
@@ -0,0 +1,795 @@
# -*- coding: utf-8 -*-
"""
common.tasks — ядра CPU-задач и унифицированный доступ к вариантам.
Все ядра «честно» последовательные на чистом Python (без NumPy), чтобы
эффект GIL и накладные расходы параллелизма были видны честно.
Единая схема каждой CPU-задачи (MapReduce-подобная):
data = build(params) # подготовить данные
chunks = split(data, parts) # разбить работу на части
partial = kernel(chunk) # вычислить одну часть
raw = combine(partials) # собрать итог
value = checksum(raw) # свёртка результата для таблиц/сравнений
Важно: checksum не зависит от способа разбиения — поэтому последовательный и
любой параллельный запуск обязаны давать одинаковый checksum.
Для multiprocessing функции передавать напрямую нельзя (вложенные функции не
пиккелятся). Используйте dispatch_kernel модульного уровня:
from common.tasks import dispatch_kernel
with Pool(p) as pool:
partials = pool.map(dispatch_kernel, [(name, ch) for ch in chunks])
На верхнем уровне модуля только определения — безопасно для spawn (Windows).
"""
from __future__ import annotations
import hashlib
import random
import re
import time
from collections import Counter
from typing import Any, Callable, Dict, List, Sequence, Tuple
# ---------------------------------------------------------------------------
# Реестр задач
# ---------------------------------------------------------------------------
TASKS: Dict[str, Dict[str, Callable]] = {}
def register(name: str):
def wrap(fn):
TASKS[name] = fn()
return fn
return wrap
# ---------------------------------------------------------------------------
# Задача 1. Умножение матриц (наивное, чистый Python)
# ---------------------------------------------------------------------------
@register("matmul")
def _task_matmul():
def build(params):
n = params["n"]
rng = random.Random(42)
A = [[rng.random() for _ in range(n)] for _ in range(n)]
B = [[rng.random() for _ in range(n)] for _ in range(n)]
return (A, B)
def split(data, parts):
A, B = data
n = len(A)
bounds = [i * n // parts for i in range(parts + 1)]
return [(A, B, bounds[i], bounds[i + 1]) for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(chunk):
A, B, i0, i1 = chunk
n = len(A)
# транспонируем B для линейного доступа по памяти (эффект кэша)
Bt = [[B[j][k] for j in range(n)] for k in range(n)]
C = []
for i in range(i0, i1):
Ai = A[i]
row = []
for j in range(n):
Bj = Bt[j]
s = 0.0
for k in range(n):
s += Ai[k] * Bj[k]
row.append(s)
C.append(row)
return (i0, C)
def combine(partials):
C = []
for _, rows in sorted(partials, key=lambda r: r[0]):
C.extend(rows)
return C
def checksum(raw):
s = 0.0
for row in raw:
for x in row:
s += x
return s
def params(level):
return {"n": {"S": 140, "M": 180, "L": 220}[level]}
def describe(params):
return f"умножение матриц {params['n']}x{params['n']}"
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 2. Число π методом Монте-Карло
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Задача 2. Число π методом Монте-Карло
#
# Важный приём: total бросков делится на ФИКСИРОВАННЫЕ серии по SERIES_SIZE
# бросков, у каждой серии своё зерно по её номеру. Кусок = диапазон номеров
# серий. Поэтому checksum не зависит от того, как серии сгруппированы в
# куски — приём, реально применяемый в MC-расчётах для воспроизводимости.
# ---------------------------------------------------------------------------
@register("pi_mc")
def _task_pi_mc():
SERIES_SIZE = 10_000 # бросков в серии; серия = единица воспроизводимости
def build(params):
return params["total"]
def split(data, parts):
total = data
n_series = total // SERIES_SIZE
bounds = [i * n_series // parts for i in range(parts + 1)]
return [((i, bounds[i], bounds[i + 1]), total) for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(chunk):
(idx, s0, s1), _total = chunk
inside = 0
count = 0
for s in range(s0, s1):
rng = random.Random(s) # зерно = номер серии
for _ in range(SERIES_SIZE):
x = rng.random()
y = rng.random()
if x * x + y * y <= 1.0:
inside += 1
count += SERIES_SIZE
return (inside, count)
def combine(partials):
inside = sum(p[0] for p in partials)
total = sum(p[1] for p in partials)
return 4.0 * inside / total
def checksum(raw):
return raw
def params(level):
return {"total": {"S": 2_000_000, "M": 4_000_000, "L": 8_000_000}[level]}
def describe(params):
return f"π методом Монте-Карло, {params['total']:,} бросков".replace(",", " ")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 3. Численное интегрирование (метод средних прямоугольников)
# ---------------------------------------------------------------------------
@register("integrate")
def _task_integrate():
def f(x):
return 4.0 / (1.0 + x * x) # на [0, 1] интеграл = π (проверяемый ответ)
def build(params):
return params
def split(data, parts):
a, b, n = data["a"], data["b"], data["n"]
# кусок = диапазон ИНДЕКСОВ отрезков [i0, i1): kernel считает всегда
# одни и те же отрезки, checksum не зависит от разбиения
bounds = [i * n // parts for i in range(parts + 1)]
return [(data, bounds[i], bounds[i + 1]) for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(chunk):
data, i0, i1 = chunk
a, b, n = data["a"], data["b"], data["n"]
h = (b - a) / n
s = 0.0
for i in range(i0, i1):
s += f(a + (i + 0.5) * h)
return s * h
def combine(partials):
return sum(partials)
def checksum(raw):
return raw
def params(level):
n = {"S": 3_000_000, "M": 6_000_000, "L": 10_000_000}[level]
return {"a": 0.0, "b": 10.0, "n": n}
def describe(params):
n_str = f"{params['n']:,}".replace(",", " ")
return (f"интегрирование [{params['a']:g}, {params['b']:g}] "
f"{n_str} отрезков")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 4. Подсчёт простых чисел ≤ M (перебор делителей)
# ---------------------------------------------------------------------------
@register("primes")
def _task_primes():
def _is_prime(k):
if k < 2:
return False
if k % 2 == 0:
return k == 2
d = 3
while d * d <= k:
if k % d == 0:
return False
d += 2
return True
def build(params):
return params["M"]
def split(data, parts):
M = data
lo = 2
span = (M + 1 - lo) // parts
if span == 0:
span = 1
bounds = [lo + i * span for i in range(parts)] + [M + 1]
return [(bounds[i], min(bounds[i + 1], M + 1)) for i in range(parts)
if bounds[i] < min(bounds[i + 1], M + 1)]
def kernel(chunk):
lo, hi = chunk
c = 0
for k in range(lo, hi):
if _is_prime(k):
c += 1
return c
def combine(partials):
return sum(partials)
def checksum(raw):
return raw
def params(level):
return {"M": {"S": 1_000_000, "M": 2_000_000, "L": 4_000_000}[level]}
def describe(params):
return f"подсчёт простых чисел ≤ {params['M']:,}".replace(",", " ")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 5. Задача N ферзей (подсчёт расстановок)
# ---------------------------------------------------------------------------
@register("nqueens")
def _task_nqueens():
def build(params):
return params["N"]
def split(data, parts):
N = data
# одна ветка = первый ферзь в колонке col
return [(N, col) for col in range(N)]
def kernel(chunk):
N, col0 = chunk
count = 0
cols = [-1] * N
def place(row):
nonlocal count
if row == N:
count += 1
return
start = col0 if row == 0 else 0
for col in range(start, N):
ok = True
for r in range(row):
c = cols[r]
if c == col or abs(c - col) == row - r:
ok = False
break
if ok:
cols[row] = col
place(row + 1)
cols[row] = -1
place(0)
return count
def combine(partials):
return sum(partials)
def checksum(raw):
return raw
def params(level):
return {"N": {"S": 10, "M": 11, "L": 12}[level]}
def describe(params):
return f"задача {params['N']} ферзей, подсчёт расстановок"
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 6. Частотный анализ текста
# ---------------------------------------------------------------------------
_WORD_RE = re.compile(r"[а-яёa-z0-9]+")
_SENTENCES = [
"параллельное программирование это просто",
"потоки и процессы работают по разному",
"гил ограничивает настоящую параллельность потоков",
"асинхронный код ждёт ввод вывод эффективно",
"метод монте карло считает число пи",
"распределённые системы соединяют много машин",
"синхронизация защищает общие данные",
"очередь сообщений соединяет производителя и потребителя",
"каждый воркер обрабатывает свой кусок работы",
"ускорение зависит от доли последовательного кода",
]
@register("wordcount")
def _task_wordcount():
def build(params):
lines = params["lines"]
rng = random.Random(12345)
words_pool = [w for s in _SENTENCES for w in s.split()]
out = []
for _ in range(lines):
k = rng.randint(5, 20)
out.append(" ".join(rng.choice(words_pool) for _ in range(k)))
return out
def split(data, parts):
lines = data
n = len(lines)
bounds = [i * n // parts for i in range(parts + 1)]
return [lines[bounds[i]:bounds[i + 1]] for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(chunk):
c = Counter()
for line in chunk:
for w in _WORD_RE.findall(line):
c[w] += 1
return c
def combine(partials):
total = Counter()
for c in partials:
total.update(c)
return total
def checksum(raw):
# полная сумма вхождений — не зависит от разбиения
return sum(raw.values())
def params(level):
return {"lines": {"S": 100_000, "M": 200_000, "L": 400_000}[level]}
def describe(params):
return f"частотный анализ, {params['lines']:,} строк".replace(",", " ")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 7. Box blur (размытие матрицы, чистый Python, кайма 1 строка)
# ---------------------------------------------------------------------------
@register("blur")
def _task_blur():
def build(params):
size = params["size"]
rng = random.Random(777)
return [[rng.random() for _ in range(size)] for _ in range(size)]
def split(data, parts):
img = data
n = len(img)
bounds = [i * n // parts for i in range(parts + 1)]
chunks = []
for i in range(parts):
i0, i1 = bounds[i], bounds[i + 1]
if i0 >= i1:
continue
top = max(0, i0 - 1)
bot = min(n, i1 + 1)
sub = [row[:] for row in img[top:bot]] # копия с каймой (halo)
chunks.append((sub, i0 - top, i1 - top))
return chunks
def kernel(chunk):
sub, lo, hi = chunk # вычисляем строки [lo, hi) внутри sub
m = len(sub[0])
out = []
for r in range(lo, hi):
new_row = []
for c in range(m):
s = 0.0
cnt = 0
for dr in (-1, 0, 1):
rr = r + dr
if 0 <= rr < len(sub):
row = sub[rr]
for dc in (-1, 0, 1):
cc = c + dc
if 0 <= cc < m:
s += row[cc]
cnt += 1
new_row.append(s / cnt)
out.append(new_row)
return (lo, out)
def combine(partials):
out = []
for _, rows in sorted(partials, key=lambda r: r[0]):
out.extend(rows)
return out
def checksum(raw):
s = 0.0
for row in raw:
for x in row:
s += x
return s
def params(level):
return {"size": {"S": 600, "M": 900, "L": 1200}[level]}
def describe(params):
return f"box blur {params['size']}x{params['size']}"
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 8. Хеширование строк SHA-256
# ---------------------------------------------------------------------------
@register("hashing")
def _task_hashing():
def build(params):
lines = params["lines"]
rng = random.Random(555)
pool = [w for s in _SENTENCES for w in s.split()]
out = []
for _ in range(lines):
k = rng.randint(30, 80)
out.append(" ".join(rng.choice(pool) for _ in range(k)).encode("utf-8"))
return out
def split(data, parts):
items = data
n = len(items)
bounds = [i * n // parts for i in range(parts + 1)]
return [items[bounds[i]:bounds[i + 1]] for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(items):
# свёртка по каждому элементу отдельно => результат не зависит
# от границ кусков. hashlib отпускает GIL на время одного вызова:
# на коротких строках эффект незаметен, на блоках ≥ 256 КБ потоки
# реально параллелятся (демо в лабе 3)
acc = 0
for it in items:
d = hashlib.sha256(it).digest()
acc = (acc + int.from_bytes(d[:8], "big")) & 0xFFFFFFFFFFFFFFFF
return acc
def combine(partials):
acc = 0
for v in partials:
acc = (acc + v) & 0xFFFFFFFFFFFFFFFF
return acc
def checksum(raw):
return raw
def params(level):
return {"lines": {"S": 300_000, "M": 600_000, "L": 1_200_000}[level]}
def describe(params):
return f"хеширование SHA-256, {params['lines']:,} строк".replace(",", " ")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 9. Сортировка слиянием (куски + k-way merge)
# ---------------------------------------------------------------------------
@register("sortbig")
def _task_sortbig():
def build(params):
n = params["n"]
rng = random.Random(999)
return [rng.random() for _ in range(n)]
def split(data, parts):
arr = data
n = len(arr)
bounds = [i * n // parts for i in range(parts + 1)]
return [arr[bounds[i]:bounds[i + 1]] for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(chunk):
return sorted(chunk)
def combine(partials):
import heapq
return list(heapq.merge(*partials))
def checksum(raw):
s = 0.0
prev = -1.0
for x in raw:
if x < prev:
raise ValueError("массив не отсортирован")
s += x
prev = x
return s
def params(level):
return {"n": {"S": 1_000_000, "M": 2_000_000, "L": 4_000_000}[level]}
def describe(params):
return f"сортировка слиянием {params['n']:,} чисел".replace(",", " ")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# Задача 10. Игра «Жизнь» (параллелятся строки одного поколения)
# ---------------------------------------------------------------------------
@register("life")
def _task_life():
def build(params):
size = params["size"]
rng = random.Random(31337)
grid = [[1 if rng.random() < 0.30 else 0 for _ in range(size)]
for _ in range(size)]
return (grid, params["steps"])
def split(data, parts):
grid, _steps = data
n = len(grid)
bounds = [i * n // parts for i in range(parts + 1)]
return [(grid, bounds[i], bounds[i + 1]) for i in range(parts)
if bounds[i] < bounds[i + 1]]
def kernel(chunk):
# ОДНО поколение для строк [i0, i1). Соседние строки читаются, свои — пишутся.
grid, i0, i1 = chunk
m = len(grid[0])
band = []
for r in range(i0, i1):
new_row = [0] * m
for c in range(m):
s = 0
for dr in (-1, 0, 1):
rr = r + dr
if 0 <= rr < len(grid):
row = grid[rr]
for dc in (-1, 0, 1):
if dc == 0 and dr == 0:
continue
cc = c + dc
if 0 <= cc < m:
s += row[cc]
alive = grid[r][c] == 1
if alive and (s == 2 or s == 3):
new_row[c] = 1
elif not alive and s == 3:
new_row[c] = 1
band.append(new_row)
return (i0, band)
def combine(partials):
grid = []
for _, rows in sorted(partials, key=lambda r: r[0]):
grid.extend(rows)
return grid
def checksum(raw):
return sum(sum(row) for row in raw)
def params(level):
size = {"S": 500, "M": 600, "L": 700}[level]
steps = {"S": 20, "M": 25, "L": 30}[level]
return {"size": size, "steps": steps}
def describe(params):
return (f"игра «Жизнь» {params['size']}x{params['size']}, "
f"{params['steps']} поколений")
return dict(build=build, split=split, kernel=kernel, combine=combine,
checksum=checksum, params=params, describe=describe)
# ---------------------------------------------------------------------------
# I/O-нагрузка (общая для всех вариантов, имитация сетевых запросов)
# ---------------------------------------------------------------------------
def io_fetch(item: int, delay: float = 0.05) -> Tuple[int, int]:
"""Имитация I/O-операции: блокирующая задержка + «полезная работа».
Никаких настоящих сетевых вызовов — только time.sleep (блокирующий).
Это позволяет сравнивать threading / multiprocessing / asyncio на равных.
"""
time.sleep(delay)
return (item, item * item)
def io_items(count: int) -> List[int]:
return list(range(count))
# ---------------------------------------------------------------------------
# Вызов kernel из процессов (модульного уровня — пиккелится)
# ---------------------------------------------------------------------------
def dispatch_kernel(payload: Tuple[str, Any]):
"""payload = (имя_задачи, chunk) -> частичный результат.
Pool не умеет передавать вложенные функции, поэтому dispatch живёт
на уровне модуля и диспетчеризует по имени задачи.
"""
name, chunk = payload
return TASKS[name]["kernel"](chunk)
# ---------------------------------------------------------------------------
# Последовательный запуск (эталон для сравнения и проверки)
# ---------------------------------------------------------------------------
def run_sequential(task_name: str, params: dict, parts: int = 1):
"""Последовательное выполнение задачи. Возвращает (checksum, секунды).
parts > 1 означает: те же куски, что пошли бы воркерам, но выполняются
в главном процессе по очереди. Используется, чтобы проверить, что
разбиение не меняет результат.
"""
task = TASKS[task_name]
data = task["build"](params)
t0 = time.perf_counter()
if task_name == "life":
grid, steps = data
for _ in range(steps):
chunks = task["split"]((grid, steps), parts)
partials = [task["kernel"](ch) for ch in chunks]
grid = task["combine"](partials)
raw = grid
else:
chunks = task["split"](data, parts)
partials = [task["kernel"](ch) for ch in chunks]
raw = task["combine"](partials)
elapsed = time.perf_counter() - t0
return task["checksum"](raw), elapsed
# ---------------------------------------------------------------------------
# Известные ответы (для автотестов)
# ---------------------------------------------------------------------------
_NQUEENS_KNOWN = {4: 2, 5: 10, 6: 4, 7: 40, 8: 92, 9: 352,
10: 724, 11: 2680, 12: 14200}
def known_checksum(task_name: str, params: dict):
"""Известный правильный ответ, если он есть, иначе None.
Для pi_mc и integrate ответ известен математически (π), но с ограниченной
точностью — их проверяют автотесты отдельно.
"""
import math
if task_name == "nqueens":
return _NQUEENS_KNOWN.get(params["N"])
if task_name == "primes":
M = params["M"]
sieve = bytearray([1]) * (M + 1)
sieve[0:2] = b"\x00\x00"
for p in range(2, int(M ** 0.5) + 1):
if sieve[p]:
sieve[p * p:: p] = bytearray(len(sieve[p * p:: p]))
return int(sum(sieve))
if task_name == "integrate" and params["a"] == 0.0 and params["b"] == 1.0:
return math.pi
if task_name == "pi_mc":
return math.pi # приближённо, тест сравнит с допуском
return None
# ---------------------------------------------------------------------------
# «Дымовые» параметры (маленькие — для автотестов и самопроверки)
# ---------------------------------------------------------------------------
_SMOKE = {
"matmul": {"n": 12},
"pi_mc": {"total": 50_000}, # кратно SERIES_SIZE=10_000
"integrate": {"a": 0.0, "b": 1.0, "n": 20_000},
"primes": {"M": 2_000},
"nqueens": {"N": 6},
"wordcount": {"lines": 2_000},
"blur": {"size": 40},
"hashing": {"lines": 5_000},
"sortbig": {"n": 20_000},
"life": {"size": 20, "steps": 3},
}
def smoke_params(task_name: str) -> dict:
return dict(_SMOKE[task_name])
# ---------------------------------------------------------------------------
# Варианты
# ---------------------------------------------------------------------------
_VARIANTS: Dict[int, Tuple[str, str]] = {
1: ("matmul", "S"),
2: ("pi_mc", "M"),
3: ("integrate", "L"),
4: ("primes", "S"),
5: ("nqueens", "M"),
6: ("wordcount", "L"),
7: ("blur", "S"),
8: ("hashing", "M"),
9: ("sortbig", "L"),
10: ("life", "S"),
11: ("matmul", "M"),
12: ("pi_mc", "L"),
13: ("integrate", "S"),
14: ("primes", "M"),
15: ("nqueens", "L"),
16: ("wordcount", "S"),
17: ("blur", "M"),
18: ("hashing", "L"),
19: ("sortbig", "S"),
20: ("life", "M"),
}
_IO_BY_LEVEL = {"S": (24, 0.05), "M": (48, 0.05), "L": (96, 0.05)}
def get_variant(num: int) -> Dict[str, Any]:
"""Параметры варианта по номеру студента в журнале (1–20)."""
if not 1 <= num <= 20:
raise ValueError("номер варианта должен быть от 1 до 20")
task_name, level = _VARIANTS[num]
task = TASKS[task_name]
cpu_params = task["params"](level)
io_items_n, io_delay = _IO_BY_LEVEL[level]
return {
"variant": num,
"task_name": task_name,
"level": level,
"cpu_params": cpu_params,
"io": {"items": io_items_n, "delay": io_delay},
"p_list": [1, 2, 4] if level in ("S", "M") else [1, 2, 4, 8],
"describe": task["describe"](cpu_params),
}
+66
View File
@@ -0,0 +1,66 @@
# Варианты заданий (20 вариантов)
Вариант = номер студента в журнале (1–20). Если студентов больше 20 — вариант
`N` = `(N - 1) % 20 + 1`.
Каждый вариант состоит из:
1. **CPU-задача** (из `bank_задач.md`) с размером данных по уровню S/M/L;
2. **I/O-нагрузка** — одинаковая для всех по смыслу: `N_items` «запросов»,
каждый имитируется задержкой `delay = 0.05 c` (`common/tasks.py: io_fetch`);
3. **Список p** — количества потоков/процессов для замеров T(p).
| Вариант | Задача | Уровень | Размер данных | I/O (items × delay) | p для замеров |
|--------:|--------|:-------:|---------------|:-------------------:|:-------------:|
| 1 | 1. Умножение матриц | S | n = 140 | 24 × 0.05 c | 1, 2, 4 |
| 2 | 2. π методом Монте-Карло | M | 4 000 000 бросков | 48 × 0.05 c | 1, 2, 4 |
| 3 | 3. Интегрирование | L | 10 000 000 отрезков, [0, 10] | 96 × 0.05 c | 1, 2, 4, 8 |
| 4 | 4. Простые числа | S | M = 1 000 000 | 24 × 0.05 c | 1, 2, 4 |
| 5 | 5. N ферзей | M | N = 11 | 48 × 0.05 c | 1, 2, 4 |
| 6 | 6. Частотный анализ | L | 400 000 строк | 96 × 0.05 c | 1, 2, 4, 8 |
| 7 | 7. Размытие (box blur) | S | 600 × 600 | 24 × 0.05 c | 1, 2, 4 |
| 8 | 8. Хеширование SHA-256 | M | 600 000 строк | 48 × 0.05 c | 1, 2, 4 |
| 9 | 9. Сортировка слиянием | L | 4 000 000 чисел | 96 × 0.05 c | 1, 2, 4, 8 |
| 10 | 10. Игра «Жизнь» | S | 500 × 500, 20 поколений | 24 × 0.05 c | 1, 2, 4 |
| 11 | 1. Умножение матриц | M | n = 180 | 48 × 0.05 c | 1, 2, 4 |
| 12 | 2. π методом Монте-Карло | L | 8 000 000 бросков | 96 × 0.05 c | 1, 2, 4, 8 |
| 13 | 3. Интегрирование | S | 3 000 000 отрезков, [0, 10] | 24 × 0.05 c | 1, 2, 4 |
| 14 | 4. Простые числа | M | M = 2 000 000 | 48 × 0.05 c | 1, 2, 4 |
| 15 | 5. N ферзей | L | N = 12 | 96 × 0.05 c | 1, 2, 4, 8 |
| 16 | 6. Частотный анализ | S | 100 000 строк | 24 × 0.05 c | 1, 2, 4 |
| 17 | 7. Размытие (box blur) | M | 900 × 900 | 48 × 0.05 c | 1, 2, 4 |
| 18 | 8. Хеширование SHA-256 | L | 1 200 000 строк | 96 × 0.05 c | 1, 2, 4, 8 |
| 19 | 9. Сортировка слиянием | S | 1 000 000 чисел | 24 × 0.05 c | 1, 2, 4 |
| 20 | 10. Игра «Жизнь» | M | 700 × 700, 30 поколений | 48 × 0.05 c | 1, 2, 4 |
## Уровни размеров (справочно)
| Задача | S | M | L |
|--------|---|---|---|
| 1. Матрицы | 140 | 180 | 220 |
| 2. π Монте-Карло | 2 000 000 | 4 000 000 | 8 000 000 |
| 3. Интегрирование | 3 000 000 | 6 000 000 | 10 000 000 |
| 4. Простые числа | 1 000 000 | 2 000 000 | 4 000 000 |
| 5. N ферзей | 10 | 11 | 12 |
| 6. Частотный анализ | 100 000 | 200 000 | 400 000 |
| 7. Box blur | 600 | 900 | 1200 |
| 8. Хеширование | 300 000 | 600 000 | 1 200 000 |
| 9. Сортировка | 1 000 000 | 2 000 000 | 4 000 000 |
| 10. «Жизнь» | 500×500, 20 пок. | 700×700, 30 пок. | 900×900, 40 пок. |
## Как получить свои параметры в коде
```python
from common.tasks import get_variant
v = get_variant(13) # ваш номер варианта
print(v["task_name"]) # "integrate"
print(v["cpu_params"]) # {"a": 0.0, "b": 10.0, "n": 3_000_000}
print(v["io"]) # {"items": 24, "delay": 0.05, "concurrency": 4}
```
## Правило добросовестности
Параметры варианта менять нельзя (иначе сравнение T(p) между студентами теряет
смысл). Если ваша машина слишком медленная для уровня L — согласуйте с
преподавателем переход на уровень M и отразите это в отчёте.
+109
View File
@@ -0,0 +1,109 @@
# Типичные ошибки и как их избежать
Сборник реальных проблем, с которыми сталкиваются студенты на этом курсе.
Прочитайте перед началом работы — сэкономит часы отладки.
## 1. `multiprocessing` на Windows: `BrokenProcessPool` / бесконечный цикл
**Симптом:** при запуске кода с `Pool` или `Process` на Windows процесс
перезапускается бесконечно или падает с ошибкой пиккелинга.
**Причина:** Windows использует метод запуска `spawn` — каждый дочерний
процесс заново импортирует ваш модуль. Если создание воркеров не защищено,
оно повторяется в каждом импорте.
**Лечение:** весь код создания процессов — только под
`if __name__ == "__main__":`. Это правило №1 курса.
```python
def main():
with Pool(4) as pool:
...
if __name__ == "__main__":
main()
```
## 2. «Мой параллельный код медленнее последовательного»
**Причины по частоте:**
1. Накладные расходы (запуск процесса ~10–100 мс, передача данных через
pickle) больше, чем выгода, — работа слишком мелкая.
2. GIL: потоки на CPU-bound задаче не ускоряют, а только добавляют
переключения.
3. Слишком много частей: 10 000 задач по 1 мс — плохо; 4–8 частей по
100 мс — хорошо.
**Что делать:** это не ошибка, а результат для анализа. Опишите его в отчёте —
именно это и является целью курса.
## 3. Результат параллельного запуска не совпадает с последовательным
**Причины:**
- результат зависит от порядка сборки частичных результатов;
- несколько воркеров изменяют общую структуру без синхронизации (гонка);
- случайные данные без фиксированного seed.
**Лечение:** в этом курсе все задачи устроены так, что checksum не зависит от
разбиения. Если не совпадает — ищите гонку или неверную сборку.
## 4. Дедлок в producer–consumer
**Симптом:** программа висит и не завершается.
**Частые причины:**
- потребитель ждёт `queue.get()` в бесконечном цикле, а производитель уже
закончил — используйте `queue.put(None)` как «маркер конца» или
`queue.close()` + `join_thread()`;
- `pool.apply()` без закрытия пула;
- ожидание события, которое никто не установит.
**Отладка:** добавьте print перед/после каждого get/put — сразу видно, кто
кого не дождался.
## 5. `asyncio.run()` нельзя вложить
**Симптом:** `RuntimeError: asyncio.run() cannot be called from a running
event loop`.
**Причина:** попытка запустить event loop внутри уже работающего (например,
из Jupyter).
**Лечение:** один `asyncio.run(main())` на программу, вызывается из обычного
синхронного кода.
## 6. Забыли `await`
**Симптом:** `RuntimeWarning: coroutine ... was never awaited`, код
выполняется мгновенно и ничего не делает.
**Причина:** вызов корутины без `await` создаёт объект корутины, но не
запускает её.
**Лечение:** `await корутина()` — всегда.
## 7. Кириллица «кракозябрами» в консоли Windows
```bat
chcp 65001
```
или запуск через `python -X utf8 файл.py`. В VS Code терминал обычно уже UTF-8.
## 8. `pytest` не находит модуль студента
**Причина:** тесты запускаются не из корня курса, или solution.py лежит не там.
**Лечение:** запускать `python -m pytest tests/ -k labXX` строго из корня
курса; `solution.py` — в папке своей лабораторной.
## 9. Замеры «прыгают»
- закройте браузер/IDE-мессенджеры на время замеров;
- на ноутбуке подключите питание (троттлинг на батарее режет частоту вдвое);
- берите лучшее время из ≥3 повторов (`time_call`);
- не замеряйте в отладчике.
## 10. Списывание
Код проверяется и на совпадение между собой, и на понимание при защите.
Варианты у всех разные (см. `common/варианты.md`) — чужой вариант виден сразу.
+36
View File
@@ -0,0 +1,36 @@
# Чек-лист сдачи лабораторной
Перед сдачей пройдитесь по списку — это снимает 90% возвратов на доработку.
## Код
- [ ] `solution.py` заполнен, интерфейсы (имена функций, сигнатуры) не менялись —
по ним работают автотесты
- [ ] В файлах, где создаются процессы (`multiprocessing`), есть
`if __name__ == "__main__":` — иначе на Windows будет `BrokenProcessPool`
- [ ] Нет абсолютных путей и путей с диском (`C:\...`) — только относительные
- [ ] Код запускается из корня курса: `python labXX_.../solution.py`
- [ ] Нет `print`-мусора; вывод осмысленный (таблицы, результаты, checksum)
- [ ] Нет «магического» ускорения за счёт уменьшения объёма работы:
checksum параллельного запуска совпадает с последовательным
## Замеры
- [ ] Замерены T(p) для всех p из вашего варианта (например 1, 2, 4)
- [ ] Каждый замер — минимум 3 повтора, берётся лучшее время
- [ ] Замеры сделаны на одной машине, без фоновых программ
- [ ] Последовательный запуск тоже замерен (это T(1), база для сравнения)
## Отчёт
- [ ] Заполнен по `common/шаблон_отчёта.md`
- [ ] Таблица замеров: T(p), S(p), E(p)
- [ ] График ускорения S(p) (картинка вставлена в отчёт)
- [ ] Анализ: почему ускорение такое, где накладные расходы, что ограничивает
- [ ] Ответы на контрольные вопросы из методички
- [ ] Указаны ОС, CPU и параметры варианта
## Самопроверка
- [ ] `python -m pytest tests/ -k labXX` — все тесты зелёные
- [ ] Код отформатирован, нет закомментированных кусков и TODO-заглушек
+70
View File
@@ -0,0 +1,70 @@
# Шаблон отчёта по лабораторной работе
> Скопируйте этот файл в папку своей лабораторной (например,
> `lab02_threads/отчёт.md`), заполните и приложите графики.
- **ФИО:** ____________
- **Группа:** ____________
- **Вариант:** № ____ из `common/варианты.md`
- **Задача:** ____ (описание + параметры варианта)
- **ОС / CPU / объём RAM:** ____ (например: Windows 11, AMD Ryzen 5 5600, 16 ГБ)
## 1. Цель работы
(2–3 предложения своими словами)
## 2. Краткая теория
(5–10 предложений: какие механизмы использовались и почему именно они
подходят/не подходят для вашей задачи. Переписывать методичку не нужно —
сформулируйте понимание своими словами.)
## 3. Описание решения
- что разбивается на части и почему именно так;
- что выполняет каждый воркер;
- как собирается итог;
- какие средства синхронизации использованы и зачем (если использовались).
## 4. Результаты замеров
Таблица: время T(p) для p = 1, 2, 4 (…), ускорение S(p) = T(1)/T(p),
эффективность E(p) = S(p)/p.
| Подход | T, c | S(p) | E(p) |
|---|---|---|---|
| последовательно | | 1.00 | 1.00 |
| потоки, p=2 | | | |
| потоки, p=4 | | | |
Как замерять: `common/benchmark.py` (`time_call`, минимум 3 повтора,
берётся лучшее время). Замеры — на спокойной машине, без фоновых программ.
## 5. Графики
- график ускорения S(p) — `save_speedup_plot` из `common/benchmark.py`;
- при необходимости — столбчатая диаграмма времени.
Вставьте сюда картинки:
![ускорение](speedup.png)
## 6. Анализ
Ответьте письменно (по 2��5 предложений на пункт):
1. Получено ли ускорение? На сколько? Совпадает ли с ожиданиями?
2. Почему именно такая модель (потоки/процессы/async) подходит или не
подходит вашей задаче?
3. Наблюдались ли накладные расходы (запуск процессов, передача данных,
синхронизация)? Где они видны в замерах?
4. Что ограничивает дальнейший рост ускорения (GIL, память, число ядер,
доля последовательного кода)?
## 7. Выводы
(3–6 предложений: что изучено, какие практические выводы сделаны.)
## 8. Ответы на контрольные вопросы
(Из конца методички. Полными предложениями, своими словами.)
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""Лабораторная 0: проверка готовности окружения.
Запуск из корня курса:
python lab00_intro/check_env.py
Скрипт проверяет версию Python, библиотеки, число ядер и работоспособность
threading / multiprocessing / asyncio. Все проверки должны напечатать [OK].
"""
import os
import sys
# делаем доступным импорт common.* при запуске из корня курса
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
FAILED = []
def check(name: str, fn):
try:
detail = fn()
print(f"[OK] {name}{(': ' + str(detail)) if detail else ''}")
except Exception as e: # noqa: BLE001
FAILED.append(name)
print(f"[FAIL] {name}: {type(e).__name__}: {e}")
def check_python_version():
v = sys.version_info
assert v >= (3, 10), f"нужен Python 3.10+, у вас {v.major}.{v.minor}"
return f"Python {v.major}.{v.minor}.{v.micro}"
def check_modules():
import numpy
import matplotlib
import pytest
return (f"numpy {numpy.__version__}, matplotlib {matplotlib.__version__}, "
f"pytest {pytest.__version__}")
def check_cpu_count():
import multiprocessing
n = multiprocessing.cpu_count()
assert n >= 2, "нужно минимум 2 логических ядра"
return f"{n} логических ядер"
def check_threading():
from concurrent.futures import ThreadPoolExecutor
results = []
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda x: x * x, range(8)))
assert results == [i * i for i in range(8)]
return "ThreadPoolExecutor работает"
def _double(x):
"""Функция модульного уровня — Pool требует пиккелируемость."""
return x * 2
def check_multiprocessing():
import multiprocessing
ctx = multiprocessing.get_context() # spawn на Windows, fork на macOS/Linux
with ctx.Pool(2) as pool:
res = pool.map(_double, [1, 2, 3, 4])
assert res == [2, 4, 6, 8], res
return f"метод запуска: {ctx.get_start_method()}"
def check_asyncio():
import asyncio
async def main():
await asyncio.sleep(0.01)
return 42
assert asyncio.run(main()) == 42
return "event loop работает"
def check_common_tasks():
from common.tasks import get_variant
v = get_variant(1)
assert "task_name" in v and "io" in v
return f"вариант 1: {v['describe']}"
def main():
print("=" * 60)
print("Проверка окружения для курса «Технология параллельного программирования»")
print("=" * 60)
check("Версия Python", check_python_version)
check("Библиотеки", check_modules)
check("Число ядер CPU", check_cpu_count)
check("Потоки (threading)", check_threading)
check("Процессы (multiprocessing)", check_multiprocessing)
check("Асинхронность (asyncio)", check_asyncio)
check("Банк задач (common.tasks)", check_common_tasks)
print("-" * 60)
if FAILED:
print(f"ПРОБЛЕМЫ: {', '.join(FAILED)}")
print("Разберитесь по методичке lab00_intro/методичка.md — без этого")
print("следующие лабораторные не будут работать.")
sys.exit(1)
print("Всё готово. Запомните свой вариант из common/варианты.md.")
if os.name == "nt":
print("(Кириллица отображается кракозябрами? Выполните: chcp 65001)")
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
# Лабораторная работа 0. Подготовка окружения
**Время на работу:** 1 пара. **Оценка:** зачёт/не зачёт.
## Цель работы
Подготовить рабочее окружение и убедиться, что все механизмы параллельного
программирования доступны на вашей машине — до того, как вы начнёте основную
лабораторную.
## Теория (минимум)
### Виртуальное окружение (venv)
Виртуальное окружение — изолированная папка с собственной копией Python и
своим набором библиотек. Оно нужно, чтобы зависимости курса не конфликтовали
с другими проектами. Активированное окружение видно в терминале по префиксу
`(.venv)`.
### Почему не Google Colab / онлайн-интерпретаторы
Параллельность — это про реальные ядра CPU и реальную ОС. В облаке вы не
получите ни честного числа ядер, ни контроля над фоновыми процессами, ни
Windows-специфики `spawn`. Работаем локально.
### Модель памяти Python: всё передаётся по ссылке
В Python переменные — ссылки на объекты. Для потоков это значит: **все потоки
одного процесса видят одни и те же объекты** — отсюда гонки данных (лаба 5).
Для процессов это значит: **каждый процесс получает свою копию данных**
(через pickle) — отсюда накладные расходы (лаба 4).
## Задание
### Шаг 1. Установите Python 3.10+
Windows: python.org → Downloads → «Windows installer (64-bit)».
**Обязательно** отметьте «Add python.exe to PATH» на первом экране установщика.
Проверка (новое окно терминала):
```bat
python --version
```
### Шаг 2. VS Code
Установите VS Code и расширение **Python** (издатель Microsoft). Откройте
папку курса: File → Open Folder. Терминал: Terminal → New Terminal
(или `` Ctrl+` ``).
### Шаг 3. Виртуальное окружение и зависимости
В терминале VS Code **из корня курса**:
```bat
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
```
macOS/Linux: `source .venv/bin/activate`.
Если VS Code спросит интерпретатор — выберите `.\.venv\Scripts\python.exe`.
### Шаг 4. Git (кратко, если ещё не знакомы)
```bat
git init
git add .
git commit -m "lab 0: окружение готово"
```
Дальше фиксируйте прогресс коммитами — это история вашей работы (и аргумент
в спорах о списывании).
### Шаг 5. Проверка окружения
```bat
python lab00_intro\check_env.py
```
Скрипт проверит версию Python, наличие библиотек, число ядер CPU и работоспособность
потоков, процессов и asyncio. Все проверки должны быть `[OK]`.
### Шаг 6. Свой вариант
```python
from common.tasks import get_variant
print(get_variant(<ваш номер в журнале>))
```
Запишите параметры — они понадобятся во всех следующих лабораторных.
## Контрольные вопросы
1. Зачем нужно виртуальное окружение? Что произойдёт, если его не создавать?
2. Чем процесс отличается от потока? (Подсказка: общая память.)
3. Почему для этого курса важна работа на локальной машине, а не в облаке?
## Что сдаётся
- Скриншот вывода `check_env.py` (все проверки `[OK]`);
- короткий ответ на контрольные вопросы (можно в чате при сдаче).
## Критерии зачёта
| Пункт | Баллы |
|---|---|
| Окружение развёрнуто, check_env.py всё `[OK]` | 1 |
| Ответы на контрольные вопросы | 1 |
BIN
View File
Binary file not shown.
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Демо: perf_counter против time.time и process_time.
Запуск из корня курса:
python lab01_sequential/examples/01_perf_counter_demo.py
Показывает:
- почему time.time() для замеров плох (не монотонные часы);
- как perf_counter и process_time диагностируют I/O-bound задачу.
"""
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
def demo_time_vs_perf():
print("--- time.time() против time.perf_counter() ---")
t1 = time.time()
p1 = time.perf_counter()
time.sleep(0.05)
t2 = time.time()
p2 = time.perf_counter()
print(f"time.time(): {(t2 - t1) * 1000:8.2f} мс (точность зависит от ОС)")
print(f"time.perf_counter():{(p2 - p1) * 1000:8.2f} мс (монотонные, наносекундное разрешение)")
def demo_cpu_vs_io():
print("\n--- CPU-bound против I/O-bound: perf_counter vs process_time ---")
# CPU-bound: считаем
t0 = time.perf_counter()
c0 = time.process_time()
s = 0.0
for i in range(3_000_000):
s += i ** 0.5
cpu_wall = time.perf_counter() - t0
cpu_proc = time.process_time() - c0
# I/O-bound: ждём (имитация сетевых запросов)
t0 = time.perf_counter()
c0 = time.process_time()
for _ in range(10):
time.sleep(0.05) # «ожидание ответа сервера»
io_wall = time.perf_counter() - t0
io_proc = time.process_time() - c0
print(f"{'':14}{'настенное, c':>14}{'процессорное, c':>18}{'доля сна':>10}")
print(f"{'CPU-bound':14}{cpu_wall:>14.3f}{cpu_proc:>18.3f}"
f"{100 * (1 - cpu_proc / cpu_wall):>9.0f}%")
print(f"{'I/O-bound':14}{io_wall:>14.3f}{io_proc:>18.3f}"
f"{100 * (1 - io_proc / io_wall):>9.0f}%")
print("\nВывод: у I/O-задачи процессор почти всё время спит —")
print("именно поэтому потоки/asyncio могут её ускорить (лабы 2 и 6).")
if __name__ == "__main__":
demo_time_vs_perf()
demo_cpu_vs_io()
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""Демо: разбиение работы на куски не должно менять результат.
Запуск из корня курса:
python lab01_sequential/examples/02_split_check_demo.py
Ключевая идея курса: задача формулируется как split -> kernel -> combine ->
checksum. Разбиение — это подготовка к параллелизму: последовательный запуск
кусков обязан давать тот же checksum, что и один большой кусок.
"""
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.tasks import TASKS, smoke_params
def run(task_name: str, parts: int):
"""Последовательный запуск задачи из `parts` кусков."""
task = TASKS[task_name]
params = smoke_params(task_name)
data = task["build"](params)
chunks = task["split"](data, parts)
partials = [task["kernel"](ch) for ch in chunks]
raw = task["combine"](partials)
return task["checksum"](raw)
def main():
print(f"{'задача':<12} {'parts=1':>18} {'parts=2':>18} {'parts=4':>18} совпадает")
for name in ("pi_mc", "integrate", "nqueens", "blur", "hashing"):
v1 = run(name, 1)
v2 = run(name, 2)
v4 = run(name, 4)
# float-результаты (π, интеграл) сравниваем с допуском: при разбиении
# меняется порядок суммирования, меняются последние биты мантиссы
def same(a, b):
if isinstance(a, float):
return abs(a - b) <= 1e-6 * max(1.0, abs(a))
return a == b
same12 = same(v1, v2)
same14 = same(v1, v4)
marker = "True" if (same12 and same14) else "False <-- ОШИБКА"
print(f"{name:<12} {v1:>18.6f} {v2:>18.6f} {v4:>18.6f} {marker}")
print("\nЕсли checksum зависит от разбиения — параллельная версия будет")
print("давать «случайный» результат. Это ошибка, а не особенность.")
print("(Float-задачи сравниваются с допуском — см. код демо.)")
if __name__ == "__main__":
main()
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""Демо: cProfile — где программа проводит время.
Запуск из корня курса:
python lab01_sequential/examples/03_cprofile_demo.py
На глаз две реализации выглядят одинаково. Профилировщик показывает,
что наивная transpose-версия медленнее — из-за плохой локальности памяти.
"""
import cProfile
import os
import pstats
import sys
import io
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
N = 250
def matmul_rows(A, B):
"""Доступ к B по строкам: b[k] читается целиком (хорошо для кэша)."""
C = []
for i in range(N):
Ai, row = A[i], []
for j in range(N):
s = 0.0
for k in range(N):
s += Ai[k] * B[k][j] # B[k] — колонка, каждый раз новая строка
row.append(s)
C.append(row)
return C
def main():
rng_a = [[float(i + j) for j in range(N)] for i in range(N)]
rng_b = [[float(i - j) for j in range(N)] for i in range(N)]
profiler = cProfile.Profile()
profiler.enable()
C = matmul_rows(rng_a, rng_b)
profiler.disable()
stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream)
stats.sort_stats("cumulative").print_stats(8)
print(stream.getvalue())
print(f"checksum = {sum(sum(r) for r in C):.3f}")
print("Смотрим tototime: почти всё время — во внутреннем цикле matmul_rows.")
print("Это и есть узкое место, которое будут распараллеливать лабы 3–4.")
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 1: последовательное вычисление и профилирование.
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab01.py).
Запуск из корня курса:
python lab01_sequential/solution.py
"""
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.benchmark import make_table, time_call
from common.tasks import TASKS, get_variant, io_fetch
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
def run_cpu_sequential(task_name: str, params: dict, parts: int = 4):
"""Последовательное выполнение задачи из `parts` кусков.
Возвращает (checksum, elapsed_seconds).
Схема:
build -> split -> kernel (по очереди!) -> combine -> checksum
"""
task = TASKS[task_name]
# TODO: реализуйте по схеме выше и замерьте время perf_counter-ом
raise NotImplementedError
def run_io_sequential(items: int, delay: float = 0.05):
"""Последовательное выполнение I/O-нагрузки.
Для каждого item из range(items) вызывает io_fetch(item, delay).
Возвращает (список_результатов, elapsed_seconds).
Результаты — в порядке возрастания item.
"""
# TODO: цикл по items, сбор результатов, замер времени
raise NotImplementedError
def main():
v = get_variant(VARIANT_NUMBER)
print(f"Вариант {v['variant']}: {v['describe']}")
print(f"I/O-нагрузка: {v['io']['items']} запросов по {v['io']['delay']} c\n")
# --- CPU-часть: T(1), T(2), T(4) --------------------------------------
rows, checksums = [], []
for parts in (1, 2, 4):
t, (cs, _) = time_call(run_cpu_sequential,
(v["task_name"], v["cpu_params"], parts))
rows.append((f"CPU, {parts} кусков", t))
checksums.append(cs)
print(make_table(rows, title="CPU-часть (последовательно)"))
def same(a, b):
# float-результаты сравниваем с допуском (порядок суммирования!)
if isinstance(a, float) or isinstance(b, float):
return abs(a - b) <= 1e-6 * max(1.0, abs(a))
return a == b
ok = all(same(checksums[0], c) for c in checksums[1:])
print(f"checksum совпадает при разном разбиении: {ok}\n")
# --- I/O-часть ----------------------------------------------------------
results, t_io = run_io_sequential(v["io"]["items"], v["io"]["delay"])
t0 = time.perf_counter()
c0 = time.process_time()
run_io_sequential(v["io"]["items"], v["io"]["delay"])
wall = time.perf_counter() - t0
proc = time.process_time() - c0
print(f"I/O-часть: {len(results)} запросов за {t_io:.3f} c")
print(f" настенное время (perf_counter): {wall:.3f} c")
print(f" процессорное время (process_time): {proc:.3f} c")
print(f" доля ожидания: {100 * (1 - proc / wall):.0f}%")
print(f" потенциал ускорения потоками (лаба 2): x{t_io / v['io']['delay']:.1f}\n")
# --- Профилирование ------------------------------------------------------
import cProfile
import io as _io
import pstats
print("--- cProfile CPU-части (топ-5 по времени) ---")
profiler = cProfile.Profile()
profiler.enable()
run_cpu_sequential(v["task_name"], v["cpu_params"], parts=4)
profiler.disable()
stream = _io.StringIO()
pstats.Stats(profiler, stream=stream).sort_stats("cumulative").print_stats(5)
print(stream.getvalue())
# TODO: выпишите в отчёт, какая функция — узкое место
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
# Лабораторная работа 1. Последовательное вычисление и профилирование
**Время на работу:** 2 пары.
## Цель работы
Создать базовую линию: последовательную реализацию своей задачи и её замеры.
Без неё невозможно показать ускорение в следующих лабораторных — нечего будет
ускорять.
## Теория
### Последовательное вычисление
Программа выполняет операцию за операцией, в одном потоке управления. Это
простейшая и самая предсказуемая модель: результат не зависит от порядка
выполнения, время выполнения = сумма времён всех операций. Все дальнейшие
лабораторные сравниваются с этой базой.
### Два типа нагрузки
**CPU-bound** — программа ограничена скоростью процессора: считает, шифрует,
сортирует. Время определяется числом элементарных операций.
**I/O-bound** — программа ограничена ожиданием: сеть, диск, база данных.
Время определяется временем ожидания ответов, процессор при этом простаивает.
Отличить просто: если удалить «полезную работу», а программа всё равно
медленная — это I/O-bound; если быстрая — CPU-bound. Тип нагрузки определяет,
какая модель параллелизма поможет (потоки — для I/O, процессы — для CPU,
async — для I/O в больших количествах).
### Время: настенное и процессорное
- `time.perf_counter()` — «настенные» часы: сколько времени прошло реально
(включая сон и ожидание). Ими меряют всё в этом курсе.
- `time.process_time()` — только время, потраченное процессором.
Разница между ними для I/O-задачи огромна — это её диагноз.
Обе функции монотонные (не откатываются назад, как `time.time()` при
переводе часов), имеют наносекундное разрешение — всегда используйте их, а не
`time.time()`.
### Профилирование
Профилировщик отвечает на вопрос «где программа проводит время». `cProfile`
собирает статистику по функциям: число вызовов, суммарное время, время без
вложенных вызовов.
```python
import cProfile
cProfile.run("main()", sort="cumulative")
```
Интерпретация: колонка `cumtime` — общее время функции (с вызовами),
`totime` — собственное. Первые строки обычно сразу показывают узкое место.
## Задание
Ваша задача и параметры — по варианту (`common/варианты.md`), доступ:
```python
from common.tasks import get_variant, TASKS
v = get_variant(13) # ваш номер
task = TASKS[v["task_name"]]
data = task["build"](v["cpu_params"])
```
Заполните `solution.py` (интерфейсы менять нельзя):
1. **`run_cpu_sequential(task_name, params, parts=4)`** — выполняет задачу
последовательно: `build` → разбиение на `parts` кусков → `kernel` для
каждого куска в цикле → `combine` → вернуть `checksum`. Куски
обязательны: в следующих лабораторных те же куски уйдут воркерам.
2. **`run_io_sequential(items, delay)`** — для каждого элемента вызывает
`common.tasks.io_fetch(item, delay)` и собирает результаты в список
(в порядке возрастания item).
3. **`main()`** — запускает обе части на параметрах своего варианта, выводит
таблицу времен (`common.benchmark.make_table`) и значения checksum.
### Измерения для отчёта
- CPU-часть: T(1) при разбиении на 1, 2, 4 куска (убедитесь, что checksum
одинаков и время почти не меняется — разбиение не должно менять работу).
- I/O-часть: суммарное время для items из варианта; сравните
`perf_counter` и `process_time` — увидите, сколько времени машина спит.
- Профилирование CPU-части: `cProfile.run(...)`, топ-5 функций по времени.
Укажите в отчёте, какая функция — узкое место.
## Контрольные вопросы
1. Чем CPU-bound задача отличается от I/O-bound? Какая у вас?
2. Почему для замеров нужен `perf_counter`, а не `time.time()`?
3. Что показывает `cumtime` и `tototime` в выводе cProfile?
4. Почему checksum не должен зависеть от числа кусков? Что это проверяет?
## Что сдаётся
`solution.py` + отчёт по шаблону: таблицы T(1/2/4 кусков), время I/O-части,
сравнение perf_counter/process_time, топ-5 из cProfile, анализ.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| `run_cpu_sequential` работает, checksum совпадает при разном разбиении | 2 |
| `run_io_sequential` работает | 1 |
| Замеры и сравнение perf_counter/process_time | 2 |
| Профилирование и вывод об узком месте | 2 |
| Отчёт: таблицы, анализ, ответы на вопросы | 3 |
## Типичные ошибки
См. `common/типичные_ошибки.md` (пп. 8, 9). Здесь их всего две: замеры в
отладчике и замеры без повторов.
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Демо: потоки выполняются конкурентно — sleep не складывается.
Запуск из корня курса:
python lab02_threads/examples/01_threads_sleep_demo.py
Пять потоков спят по 0.4 c. Сумма времён = 2 c, реальное время ≈ 0.4 c:
пока один поток спит, процессор обслуживает другие.
"""
import threading
import time
N_THREADS = 5
SLEEP = 0.4
def worker(idx: int) -> None:
name = threading.current_thread().name
t0 = time.perf_counter()
time.sleep(SLEEP) # «ожидание сети/диска»
dt = time.perf_counter() - t0
print(f" {name}: {dt:.3f} c")
def main():
print(f"Запускаем {N_THREADS} потоков, каждый спит {SLEEP} c")
threads = [threading.Thread(target=worker, args=(i,), name=f"поток-{i}")
for i in range(N_THREADS)]
t0 = time.perf_counter()
for t in threads:
t.start() # ВСЕ стартуют первыми...
for t in threads:
t.join() # ...потом ВСЕ ждём (start и join не перемешивать!)
total = time.perf_counter() - t0
print(f"Суммарно: {total:.3f} c "
f"(сумма времён была бы {N_THREADS * SLEEP:.1f} c)")
print(f"Ускорение: x{N_THREADS * SLEEP / total:.1f} — так работает I/O-параллелизм")
if __name__ == "__main__":
main()
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""Демо: ThreadPoolExecutor, Future и порядок завершения.
Запуск из корня курса:
python lab02_threads/examples/02_executor_future_demo.py
Показывает:
- pool.map: результаты в порядке входа, даже если завершились в другом;
- submit + as_completed: обрабатываем по мере готовности.
"""
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.tasks import io_fetch
def main():
# «Сервер» отвечает тем медленнее, чем больше номер запроса
delays = [0.30, 0.25, 0.20, 0.15, 0.10]
print("--- pool.map: результаты в порядке входа ---")
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(io_fetch, range(5), delays))
print(f" порядок: {[r[0] for r in results]} (как на входе)")
print(f" время: {time.perf_counter() - t0:.3f} c "
f"(последовательный был бы {sum(delays):.2f} c)\n")
print("--- submit + as_completed: по мере готовности ---")
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
futures = {pool.submit(io_fetch, i, d): i for i, d in enumerate(delays)}
for fut in as_completed(futures):
item = futures[fut]
print(f" завершён запрос {item} "
f"(через {time.perf_counter() - t0:.3f} c)")
print(f" время: {time.perf_counter() - t0:.3f} c\n")
print("--- Future: .result() блокирует до готовности ---")
with ThreadPoolExecutor(max_workers=2) as pool:
fut = pool.submit(io_fetch, 42, 0.20)
print(f" done() сразу после submit: {fut.done()}")
result = fut.result() # ждём
print(f" done() после result(): {fut.done()}, результат: {result}")
if __name__ == "__main__":
main()
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""Демо: предвестник лабы 3 — потоки НЕ ускоряют счёт.
Запуск из корня курса:
python lab02_threads/examples/03_threads_cpu_demo.py
Та же схема, что с sleep, но вместо sleep — счёт. Время не уменьшается:
GIL разрешает исполнять байт-код Python только одному потоку.
Подробное исследование — лабораторная 3.
"""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
def cpu_worker(n: int) -> None:
s = 0.0
for i in range(n):
s += i ** 0.5 # чистый Python-код: держит GIL
return s
def run_threads(n_threads: int, n_per_thread: int) -> float:
threads = [threading.Thread(target=cpu_worker, args=(n_per_thread,))
for _ in range(n_threads)]
t0 = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
return time.perf_counter() - t0
def main():
TOTAL = 3_000_000
t0 = time.perf_counter()
cpu_worker(TOTAL)
t_seq = time.perf_counter() - t0
print(f"1 поток, {TOTAL:,} итераций: {t_seq:.3f} c")
t2 = run_threads(2, TOTAL // 2)
print(f"2 потока, по {TOTAL // 2:,}: {t2:.3f} c "
f"(ускорение x{t_seq / t2:.2f})")
t4 = run_threads(4, TOTAL // 4)
print(f"4 потока, по {TOTAL // 4:,}: {t4:.3f} c "
f"(ускорение x{t_seq / t4:.2f})")
print("\nУскорения нет (даже хуже!) — GIL. Но тот же код с time.sleep")
print("ускорился бы почти идеально (демо 01). Разницу изучаем в лабе 3.")
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 2: многопоточный код (I/O-bound).
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab02.py).
Запуск из корня курса:
python lab02_threads/solution.py
"""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.benchmark import make_table, save_speedup_plot, time_call
from common.tasks import get_variant, io_fetch
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
def _split_range(items: int, parts: int):
"""Разбиение range(items) на parts почти равных кусков (списки item'ов)."""
# TODO: верните список из parts списков номеров запросов
raise NotImplementedError
def run_io_threads_manual(items: int, delay: float = 0.05, p: int = 4):
"""I/O-нагрузка на p ручных потоках.
Каждый поток обрабатывает СВОЙ кусок и кладёт результаты в СВОЙ список.
Вернуть (список_результатов, elapsed_seconds), упорядочено по item.
Подсказки:
- threading.Thread(target=worker, args=(...));
- start() всех потоков ДО первого join() — иначе выйдет последовательно.
"""
raise NotImplementedError
def run_io_threads_pool(items: int, delay: float = 0.05, p: int = 4):
"""I/O-нагрузка через ThreadPoolExecutor(max_workers=p) и pool.map.
Вернуть (список_результатов, elapsed_seconds), упорядочено по item.
pool.map сохраняет порядок входа независимо от порядка завершения.
"""
raise NotImplementedError
def main():
v = get_variant(VARIANT_NUMBER)
items, delay = v["io"]["items"], v["io"]["delay"]
print(f"Вариант {v['variant']}: I/O-нагрузка {items} запросов по {delay} c\n")
rows = {"ручные потоки": [], "пул потоков": []}
for p in v["p_list"]:
t1, (res, _) = time_call(run_io_threads_manual, (items, delay, p), repeats=1)
assert [r[0] for r in res] == list(range(items)), "порядок нарушен!"
rows["ручные потоки"].append((p, t1))
t2, (res, _) = time_call(run_io_threads_pool, (items, delay, p), repeats=1)
assert [r[0] for r in res] == list(range(items)), "порядок нарушен!"
rows["пул потоков"].append((p, t2))
ideal = items * delay / p
print(f"p={p}: вручную {t1:.3f} c | пул {t2:.3f} c | идеал ≈ {ideal:.3f} c")
print()
table_rows = [(f"пул, p={p}", t) for p, t in rows["пул потоков"]]
print(make_table(table_rows, title="Пул потоков"))
save_speedup_plot(
rows,
f"Ускорение I/O-нагрузки потоками ({items} запросов)",
os.path.join(os.path.dirname(__file__), "speedup.png"))
# TODO: в отчёт — почему ускорение близко к идеальному?
# TODO (по желанию, +1 балл): submit + as_completed, порядок завершения
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
# Лабораторная работа 2. Многопоточный код (I/O-bound)
**Время на работу:** 2 пары. **Зависимости:** лаба 1.
## Цель работы
Освоить потоки на задаче, где они реально дают ускорение — I/O-bound.
К концу работы вы должны уметь объяснить, почему один и тот же механизм
бесполезен для CPU-задач (лаба 3).
## Теория
### Что такое поток
Поток (thread) — независимая последовательность инструкций **внутри одного
процесса**. Все потоки делят общую память: один и тот же список, словарь,
глобальную переменную видно из любого потока. Это делает потоки лёгкими
(создание ~десятки микросекунд, у процессов — миллисекунды) и опасными
одновременно: два потока могут менять одну переменную без ведома друг друга
(лаба 5).
### Почему потоки ускоряют I/O-bound, но не CPU-bound
Когда поток выполняет `time.sleep`, чтение сокета или диска, он **отпускает
процессор** — ОС переключает его на другой поток. Десять потоков, ждущих сеть,
«спят» одновременно, и суммарное время ≈ времени одного запроса.
Когда поток считает (`x = a * b`), он держит процессор, а в CPython — ещё и
**GIL** (Global Interpreter Lock): глобальный замок, разрешающий исполнять
байт-код Python только одному потоку процесса. Поэтому CPU-код потоками не
ускоряется в принципе (подробно — лаба 3).
Правило: **потоки — для ожидания, процессы — для вычислений.**
### threading vs concurrent.futures
`threading.Thread` — ручное управление: создать, `start()`, `join()`,
самому разложить данные и собрать результаты. Полезно понять один раз.
`concurrent.futures.ThreadPoolExecutor` — пул потоков и высокоуровневый
интерфейс:
```python
with ThreadPoolExecutor(max_workers=p) as pool:
results = list(pool.map(io_fetch, items)) # порядок входа сохранён
futures = [pool.submit(io_fetch, i) for i in items] # или Future-объекты
```
`submit` возвращает `Future` — «обещание» результата: `.result()` блокирует
до готовности, `.done()` проверяет готовность, можно навесить колбэк
`add_done_callback`. `pool.map` — как встроенный `map`, но параллельный;
результаты приходят в порядке входа.
В работе используются оба способа: `Thread` — чтобы понять механику,
`ThreadPoolExecutor` — как рабочий инструмент.
## Задание
I/O-нагрузка та же, что в лабе 1 (`io_fetch` из `common.tasks`: имитация
запроса задержкой 0.05 c). Параметры items — из вашего варианта.
Заполните `solution.py`:
1. **`run_io_threads_manual(items, delay, p)`** — разбить `range(items)` на
`p` кусков, на каждый кусок — свой `threading.Thread`. Каждый поток
кладёт результаты в **свой** список (общая структура без синхронизации —
пока избегаем). Собрать куски по порядку.
2. **`run_io_threads_pool(items, delay, p)`** — то же через
`ThreadPoolExecutor(max_workers=p)` и `pool.map`.
3. **`main()`** — замерить T(1), T(2), T(4) (и T(8), если есть в варианте)
обоими способами; построить таблицу и график ускорения
(`common.benchmark`).
### Ожидаемые результаты (проверьте себя)
- T(1) ≈ items × delay (например 24 × 0.05 = 1.2 c);
- T(4) ≈ items × delay / 4 (если items кратно 4) — почти идеальное ускорение;
- ручные потоки и пул дают примерно одинаковое время.
Если T(4) ≈ T(1) — вы где-то синхронизируете лишний раз или запускаете
потоки по очереди (проверьте: `start()` всех потоков должен быть **до**
первого `join()`).
### Дополнительно (по желанию, +1 балл)
- `ThreadPoolExecutor` + `submit`/`as_completed`: выводить номер завершившегося
запроса по мере готовности — увидите, что порядок завершения не совпадает
с порядком запуска.
## Контрольные вопросы
1. Почему `time.sleep` в 4 потоках занимает те же 0.05 c, а не 0.2 c?
2. Чем `Future` отличается от готового результата? Что делает `.result()`?
3. Почему потоки имеют общую память и чем это опасно?
4. Что произойдёт с вашей I/O-задачей, если `max_workers=100`? Почему рост
останавливается?
## Что сдаётся
`solution.py` + отчёт: таблица T(p) обоих способов, график S(p), анализ
(совпало ли с ожидаемым, где видны накладные расходы на создание потоков),
ответы на контрольные вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| Ручные потоки: результаты корректны и упорядочены | 2 |
| Пул потоков: результаты корректны | 2 |
| Замеры T(1/2/4), ускорение ≈ p на I/O-части | 2 |
| График и анализ в отчёте | 2 |
| Ответы на контрольные вопросы | 2 |
## Типичные ошибки
- `start()` и сразу `join()` в одном цикле — потоки выполнятся по очереди.
- Общий список без блокировки (`results.append(...)` из многих потоков) —
в этой лабе «повезёт» (append атомарен из-за GIL), но полагаться на это
нельзя — в лабе 5 это сломают демонстративно.
- Забыли `args=(chunk,)` в `Thread` — функция вызвалась в главном потоке.
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""Демо: GIL изнутри — интервал переключения и stat.
Запуск из корня курса:
python lab03_gil/examples/01_gil_basics_demo.py
Показывает:
- sys.getswitchinterval() — как часто потоки уступают GIL;
- sys._is_gil_enabled() (Python 3.13+) — статус GIL;
- поток-«наблюдатель» работает, пока главный поток занят счётом
(переключения есть!), но суммарная производительность не растёт.
"""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
def burn(n):
s = 0.0
for i in range(n):
s += i ** 0.5
return s
def main():
print(f"Python: {sys.version.split()[0]}")
if hasattr(sys, "_is_gil_enabled"):
print(f"GIL включён: {sys._is_gil_enabled()}")
print(f"интервал переключения GIL: {sys.getswitchinterval() * 1000:.0f} мс\n")
N = 4_000_000
# 1. Один поток
t0 = time.perf_counter()
burn(N)
t1 = time.perf_counter() - t0
print(f"1 поток: {t1:.3f} c")
# 2. Поток-наблюдатель: он живёт (переключения идут), но работе это не помогает
ticks = [0]
def observer(stop_flag):
while not stop_flag[0]:
ticks[0] += 1
time.sleep(0.005)
stop = [False]
obs = threading.Thread(target=observer, args=(stop,), daemon=True)
obs.start()
t0 = time.perf_counter()
burn(N) # главный поток считает
t2 = time.perf_counter() - t0
stop[0] = True
obs.join()
print(f"1 поток + наблюдатель: {t2:.3f} c (наблюдатель проснулся "
f"{ticks[0]} раз) — работа не замедлилась: переключения дешёвые")
# 3. Два считающих потока: время то же, работы вдвое больше — но не быстрее
t0 = time.perf_counter()
threads = [threading.Thread(target=burn, args=(N // 2,)) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
t4 = time.perf_counter() - t0
print(f"2 потока по {N // 2}: {t4:.3f} c => S={t1 / t4:.2f} (GIL!)")
print("\nВывод: переключения GIL происходят, но байт-код всё равно")
print("исполняется по очереди — суммарная скорость не растёт.")
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Демо: hashlib освобождает GIL — потоки дают настоящее ускорение.
Запуск из корня курса:
python lab03_gil/examples/02_hashlib_gil_demo.py
hashlib.sha256 отпускает GIL на время хеширования ОДНОГО блока. На коротких
блоках (4 КБ) вызовы слишком быстрые — GIL забирается обратно, ускорения нет.
На больших блоках (1 МБ) вызовы длинные — потоки работают параллельно.
"""
import hashlib
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
# Три набора данных (для hashlib — одинаковый суммарный объём, разные блоки)
PY_DATA = [os.urandom(256 * 1024) for _ in range(16)] # 4 МБ, для чистого Python
HASH_BIG = [os.urandom(1 << 20) for _ in range(48)] # 48 МБ блоками по 1 МБ
HASH_SMALL = [os.urandom(4096) for _ in range(48 * 256)] # те же 48 МБ блоками по 4 КБ
MASK = 0xFFFFFFFFFFFFFFFF
def py_checksum(chunk):
"""Чистый Python: GIL не отпускает."""
acc = 0
for b in chunk:
for x in b: # питоновский цикл по байтам
acc = (acc + x * 31) & MASK
return acc
def hash_checksum(chunk):
"""C-функция hashlib: GIL отпускается на время хеширования блока."""
acc = 0
h = hashlib.sha256
for b in chunk:
acc = (acc + int.from_bytes(h(b).digest()[:8], "big")) & MASK
return acc
def run_threads(fn, data, parts):
bounds = [i * len(data) // parts for i in range(parts + 1)]
chunks = [data[bounds[i]:bounds[i + 1]] for i in range(parts)]
with ThreadPoolExecutor(max_workers=parts) as pool:
return list(pool.map(fn, chunks))
def min_time(fn, data, parts, repeats=3):
best = float("inf")
for _ in range(repeats):
t0 = time.perf_counter()
run_threads(fn, data, parts)
best = min(best, time.perf_counter() - t0)
return best
def bench(label, fn, data):
acc1 = sum(run_threads(fn, data, 1)) & MASK
acc4 = sum(run_threads(fn, data, 4)) & MASK
assert acc1 == acc4, "результат зависит от разбиения — ошибка!"
t1 = min_time(fn, data, 1)
t4 = min_time(fn, data, 4)
print(f"{label:<26} T(1)={t1:.3f} c T(4)={t4:.3f} c S(4)={t1 / t4:.2f}")
def main():
print("hashlib: одинаковый суммарный объём 48 МБ, разные размеры блоков\n")
bench("чистый Python", py_checksum, PY_DATA)
bench("hashlib, блоки 4 КБ", hash_checksum, HASH_SMALL)
bench("hashlib, блоки 1 МБ", hash_checksum, HASH_BIG)
print("\nS(4)≈1 у чистого Python и мелких блоков против S(4)≈ядрам у")
print("больших блоков. GIL отпускается на время ОДНОГО вызова C-функции —")
print("вызовы должны быть длинными.")
if __name__ == "__main__":
main()
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Демо: sys.setswitchinterval — интервал переключения GIL.
Запуск из корня курса:
python lab03_gil/examples/03_switch_interval_demo.py
Уменьшение интервала переключения не добавляет параллельности (GIL всё равно
один), но увеличивает накладные расходы: слишком частые переключения
замедляют работу.
"""
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
def burn(n):
s = 0.0
for i in range(n):
s += i ** 0.5
return s
def bench(interval, n_threads, n_total):
sys.setswitchinterval(interval)
bounds = [i * n_total // n_threads for i in range(n_threads + 1)]
chunks = [bounds[i + 1] - bounds[i] for i in range(n_threads)]
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=n_threads) as pool:
pool.map(burn, chunks)
return time.perf_counter() - t0
def main():
N = 4_000_000
print(f"{'интервал':>10} {'T(1 поток)':>12} {'T(4 потока)':>12}")
for interval in (0.005, 0.0005, 0.00005):
t1 = bench(interval, 1, N)
t4 = bench(interval, 4, N)
print(f"{interval * 1000:>9.1f}мс {t1:>11.3f}c {t4:>11.3f}c"
f" S(4)={t1 / t4:.2f}")
sys.setswitchinterval(0.005) # вернуть значение по умолчанию
print("\nВывод: интервал переключения влияет на накладные расходы,")
print("но параллельности не добавляет — держатель GIL всё равно один.")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 3: GIL — почему потоки не ускоряют CPU-код.
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab03.py).
Запуск из корня курса:
python lab03_gil/solution.py
"""
import hashlib
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.benchmark import save_speedup_plot, time_call
from common.tasks import TASKS, get_variant
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
def run_task_on_threads(task_name: str, params: dict, p: int):
"""Выполнить CPU-задачу на p потоках (ThreadPoolExecutor).
Каждый поток получает свой кусок (task['split']) и выполняет task['kernel'].
Вернуть (checksum, elapsed_seconds). checksum обязан совпасть с
последовательным запуском — иначе разбиение/сборка неверны.
Подсказка: потоки, в отличие от процессов, не требуют пиккелируемости —
task["kernel"] можно вызывать прямо в воркере.
"""
task = TASKS[task_name]
# TODO: build -> split -> pool.map(kernel, chunks) -> combine -> checksum
raise NotImplementedError
def measure_cpu_threads(task_name: str, params: dict, p_list):
"""Замеры T(p) на потоках для чистого-Python задачи.
Вернуть dict {p: лучшее_время}. p=1 — база. Проверять совпадение
checksum при каждом p!
"""
# TODO: для каждого p -> time_call(run_task_on_threads, ..., repeats=3)
raise NotImplementedError
def measure_gil_release(p_list, block_size=1 << 20, blocks_per_part=32):
"""Замеры T(p) для hashlib (освобождает GIL на длинных вызовах).
Готовит данные заранее: W = blocks_per_part * max(p_list) блоков по
block_size байт (os.urandom). Общий объём W делится между p потоками —
как в CPU-замерах. Вернуть dict {p: лучшее_время}, checksum проверять.
ВАЖНО: блоки должны быть большими (≥ 256 КБ). На коротких вызовах GIL
возвращается мгновенно и ускорение пропадает. Сравните block_size=4096
и block_size=1<<20 в отчёте!
"""
# TODO: данные -> деление на p кусков -> ThreadPoolExecutor ->
# hashlib.sha256(блок).digest() -> checksum -> {p: время}
raise NotImplementedError
def gil_status():
"""Вернуть (версия_python, gil_enabled: bool).
Используйте sys._is_gil_enabled() (Python 3.13+); если атрибута нет,
считайте GIL включённым.
"""
raise NotImplementedError
def main():
v = get_variant(VARIANT_NUMBER)
print(f"Вариант {v['variant']}: {v['describe']}\n")
ver, gil = gil_status()
print(f"Python {ver}, GIL включён: {gil}\n")
# --- 1. Чистый Python: S(p) ≈ 1 ---------------------------------------
t_cpu = measure_cpu_threads(v["task_name"], v["cpu_params"], v["p_list"])
print("Чистый Python (GIL не отпускается):")
base = t_cpu[1]
for p, t in sorted(t_cpu.items()):
print(f" p={p}: T={t:.3f} c S={base / t:.2f}")
# --- 2. hashlib: S(p) > 1 ---------------------------------------------
t_hash = measure_gil_release(v["p_list"])
print("\nhashlib SHA-256 (GIL отпускается, блоки 1 МБ):")
base = t_hash[1]
for p, t in sorted(t_hash.items()):
print(f" p={p}: T={t:.3f} c S={base / t:.2f}")
# --- 2b. Мелкие блоки: ускорение должно исчезнуть ----------------------
t_small = measure_gil_release(v["p_list"], block_size=4096,
blocks_per_part=8000)
print("\nhashlib SHA-256 (блоки 4 КБ — GIL возвращается сразу):")
base = t_small[1]
for p, t in sorted(t_small.items()):
print(f" p={p}: T={t:.3f} c S={base / t:.2f}")
# --- 3. График ----------------------------------------------------------
save_speedup_plot(
{"чистый Python": sorted(t_cpu.items()),
"hashlib 1 МБ (released GIL)": sorted(t_hash.items()),
"hashlib 4 КБ": sorted(t_small.items())},
f"GIL: ускорение потоками (Python {ver})",
os.path.join(os.path.dirname(__file__), "speedup.png"))
# TODO: в отчёт — таблицу серий, график, объяснение разницы кривых,
# ответы на контрольные вопросы из методички.
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
# Лабораторная работа 3. GIL: почему потоки не ускоряют CPU-код
**Время на работу:** 2 пары. **Зависимости:** лабы 1–2.
## Цель работы
Экспериментально исследовать GIL (Global Interpreter Lock) и научиться
обходить его тремя способами: процессы, освобождающие GIL C-функции
(`hashlib`), мультипоточность вне GIL (превью `sys._is_gil_enabled`).
## Теория
### Что такое GIL
GIL — мьютекс в CPython, разрешающий исполнять байт-код Python только одному
потоку процесса в каждый момент. Один интерпретатор — один «держатель» GIL.
Зачем он нужен: упрощает реализацию CPython (счётчики ссылок, внутренние
структуры не защищены отдельными замками) и делает однопоточный код быстрым.
Потоки в CPython — **настоящие** потоки ОС, но байт-код Python они исполняют
по очереди.
Как поток получает процессор: держатель GIL обязан отпускать его
периодически (по умолчанию каждые ~5 мс, `sys.getswitchinterval()`), и при
блокирующих вызовах (`sleep`, чтение сети/диска).
### Следствия
| Тип задачи | Потоки | Почему |
|---|---|---|
| I/O-bound | ускоряют почти идеально | GIL отпускается на время ожидания |
| CPU-bound (чистый Python) | НЕ ускоряют | GIL: байт-код исполняется по очереди |
| CPU-bound (C-код внутри) | МОГУТ ускорять | C-функция может отпустить GIL |
Третий пункт — самый интересный: библиотеки на C (`hashlib`, `zlib`,
`numpy` во многих операциях) во время длинных вычислений **отпускают GIL**,
и потоки работают по-настоящему параллельно. Тонкость: GIL отпускается на
время **одного вызова** C-функции. Если блоки маленькие (4 КБ), вызовы
слишком короткие и GIL тут же забирается обратно — ускорения не будет.
Блоки ≥ 256 КБ (в работе — 1 МБ) дают честный параллелизм. В лабе это
демонстрирует `hashlib`.
### Свободнный threading (Python 3.13+, превью)
Начиная с CPython 3.13 существует экспериментальная сборка без GIL
(free-threaded). В обычном CPython можно проверить, включён ли GIL:
`sys._is_gil_enabled()`. В работе — короткое ознакомление, не основное.
### Проверка GIL в эксперименте
Метод: фиксированная CPU-работа `W`. Замеряем T(1), затем ту же работу
на p потоках. Ускорение S = T(1)/T(p):
- S ≈ 1 → GIL не отпускался (чистый Python);
- S ≈ p → GIL отпускался (C-код, sleep).
## Задание
CPU-задача — из вашего варианта (`common.tasks`), ядро `kernel` работает на
чистом Python. Заполните `solution.py`:
1. **`measure_cpu_threads(task_name, params, p_list)`** — для каждого p:
последовательный запуск (p=1, база) и запуск CPU-задачи на p **потоках**
(`ThreadPoolExecutor`, каждый поток — свой кусок). Вернуть
`{p: время}`. Убедиться, что checksum совпадает с последовательным.
2. **`measure_gil_release(p_list, block_size, blocks_per_part)`** — то же,
но работа — SHA-256 над готовыми блоками байтов (освобождает GIL).
Фиксированный общий объём `W = blocks_per_part * max(p_list)` блоков по
`block_size` байт (`os.urandom`), делится между p потоками. Вернуть
`{p: время}`, проверять checksum.
3. **`gil_status()`** — вернуть `(python_version, gil_enabled)` через
`sys._is_gil_enabled()` (если атрибута нет — GIL включён).
4. **`main()`** — таблицы ускорений для обоих случаев + график S(p).
### Ожидаемые результаты
- Чистый Python: S(4) ≈ 0.9–1.1 (замедление из-за переключений GIL).
- SHA-256 на блоках 1 МБ: S(4) ≈ 2–4 (реальный параллелизм).
- SHA-256 на блоках 4 КБ: S(4) ≈ 0.8 — GIL не успевает отпускаться.
**Попробуйте оба размера блоков и объясните разницу** — это ядро работы.
- Разница между «чистый Python» и «hashlib 1 МБ» — и есть GIL.
### Важно про честность замеров
- объём работы при любом p одинаковый (проверка checksum);
- `time_call(..., repeats=3)`, берём лучшее;
- следите, чтобы данные hashing-задачи были крупными блоками (в банке
задач так и сделано).
## Контрольные вопросы
1. Что такое GIL и зачем он в CPython?
2. Почему `time.sleep` в 100 потоках не «съедает» процессор, а 100
считающих потоков работают как один?
3. Почему `hashlib.sha256` ускоряется потоками, а `sum` по списку — нет?
4. Что изменится в Python 3.13+ free-threaded сборках? Найдите статус PEP 703.
5. Как проверить в вашем эксперименте, что работа распределена честно?
## Что сдаётся
`solution.py` + отчёт: таблица S(p) для чистого Python и для hashlib,
график с обеими кривыми и «идеальной» прямой, вывод: когда потоки полезны.
Ответы на контрольные вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| Корректный замер чистого Python (S ≈ 1) | 2 |
| Корректный замер hashlib (S(p) > 1.5 при p=4) | 2 |
| `gil_status` и версия Python в отчёте | 1 |
| График с тремя кривыми | 2 |
| Анализ и ответы на вопросы | 3 |
## Типичные ошибки
- Слишком мелкие блоки для hashlib (4 КБ): S ≈ 0.8, а не 3+. GIL отпускается
на время одного вызова — вызовы должны быть долгими (блоки ≥ 256 КБ).
- Разный объём работы при разных p — обязательно проверяйте checksum.
- Замер на ноутбуке без питания — турбо-частоты скачут, S(p) «плавает».
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
"""Демо: spawn — каждый процесс заново импортирует модуль.
Запуск из корня курса:
python lab04_multiprocessing/examples/01_spawn_demo.py
Показывает, что на Windows/spawn дочерний процесс заново выполняет
модуль верхнего уровня. Поэтому защита __main__ обязательна.
Здесь же видно время старта процесса.
"""
import multiprocessing
import os
import sys
import time
print(f"[импорт модуля] pid={os.getpid()}, parent={os.getppid()}, "
f"метод={multiprocessing.get_start_method()}")
def worker(x):
return x * x
def main():
print(f"\n[главный процесс] pid={os.getpid()}")
ctx = multiprocessing.get_context() # spawn на Windows и macOS
t0 = time.perf_counter()
with ctx.Pool(2) as pool:
results = pool.map(worker, range(8))
dt = time.perf_counter() - t0
print(f"pool.map -> {results}")
print(f"время (включая старт 2 процессов): {dt * 1000:.1f} мс")
print("\nОбратите внимание: строка [импорт модуля] могла напечататься")
print("дважды-трижды (для каждого воркера при spawn). На fork — один раз.")
print("Вывод: старт воркеров не бесплатный — это накладные расходы лабы 4.")
if __name__ == "__main__":
main()
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Демо: что можно, а что нельзя передать в Pool (pickle).
Запуск из корня курса:
python lab04_multiprocessing/examples/02_pickle_demo.py
Вложенные функции и лямбды не пиккелятся — PicklingError.
Модульные функции — можно. Отсюда правило: kernel для Pool живёт
на уровне модуля (как dispatch_kernel в common.tasks).
"""
import multiprocessing
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from common.tasks import dispatch_kernel, smoke_params
def module_level_double(x): # МОЖНО: функция уровня модуля
return x * 2
def make_nested():
def nested_double(x): # НЕЛЬЗЯ: вложенная функция
return x * 2
return nested_double
def main():
ctx = multiprocessing.get_context()
print("--- модульная функция: работает ---")
with ctx.Pool(2) as pool:
print(f" pool.map(module_level_double, [1,2,3]) = "
f"{pool.map(module_level_double, [1, 2, 3])}")
print("\n--- lambda: PicklingError ---")
try:
with ctx.Pool(2) as pool:
pool.map(lambda x: x * 2, [1, 2, 3])
except Exception as e:
print(f" {type(e).__name__}: {str(e)[:90]}...")
print("\n--- kernel задачи через dispatch_kernel: работает ---")
task_name = "primes"
chunks = None
task = __import__("common.tasks", fromlist=["TASKS"]).TASKS[task_name]
data = task["build"](smoke_params(task_name))
chunks = task["split"](data, 2)
payload = [(task_name, ch) for ch in chunks]
with ctx.Pool(2) as pool:
partials = pool.map(dispatch_kernel, payload)
print(f" подсчёт простых ≤ 2000: {task['combine'](partials)} "
f"(ожидалось 303)")
print("\nВывод: всё, что уходит воркерам, должно пиккелиться —")
print("поэтому в этом курсе kernel диспетчеризуется по имени задачи.")
if __name__ == "__main__":
main()
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Демо: Process + Queue — ручная организация воркеров.
Запуск из корня курса:
python lab04_multiprocessing/examples/03_process_queue_demo.py
Pool — удобный «конвейер». Process+Queue — ручная схема: воркеры сами берут
задачи из очереди. Это основа для producer-consumer в лабе 5.
"""
import multiprocessing
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
def worker(task_q, result_q):
"""Воркер: берёт задачи, пока не встретит None — «маркер конца»."""
name = multiprocessing.current_process().name
while True:
task = task_q.get()
if task is None:
result_q.put((name, "done"))
break
idx, value = task
time.sleep(0.05) # «полезная работа»
result_q.put((name, f"задача {idx}: {value}² = {value ** 2}"))
def main():
ctx = multiprocessing.get_context()
task_q = ctx.Queue()
result_q = ctx.Queue()
n_workers = 3
workers = [ctx.Process(target=worker, args=(task_q, result_q),
name=f"воркер-{i}")
for i in range(n_workers)]
for w in workers:
w.start()
# кладём задачи
for i in range(9):
task_q.put((i, i + 1))
# маркеры конца — по одному на воркер
for _ in range(n_workers):
task_q.put(None)
# собираем результаты
done = 0
t0 = time.perf_counter()
while done < n_workers:
name, msg = result_q.get()
if msg == "done":
done += 1
print(f" [{name}] завершился")
else:
print(f" [{name}] {msg}")
for w in workers:
w.join()
print(f"время: {time.perf_counter() - t0:.2f} c (9 задач по 0.05 c на 3 воркерах)")
if __name__ == "__main__":
main()
+111
View File
@@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 4: мультипроцессорный код.
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab04.py, запускаются через подпроцесс).
Запуск из корня курса:
python lab04_multiprocessing/solution.py
"""
import multiprocessing
import json
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.benchmark import make_table, save_speedup_plot, time_call
from common.tasks import TASKS, dispatch_kernel, get_variant
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
def _sum_list(data):
"""Функция уровня модуля — суммирует список (для measure_overhead)."""
return sum(data)
def run_cpu_processes(task_name: str, params: dict, p: int):
"""CPU-задача на p процессах через Pool и dispatch_kernel.
Вернуть (checksum, elapsed_seconds). Подсказки:
- payload = [(task_name, chunk) для каждого куска];
- dispatch_kernel пиккелится, лямбды и вложенные функции — НЕТ;
- ctx = multiprocessing.get_context(); with ctx.Pool(p) as pool: ...
"""
task = TASKS[task_name]
# TODO: build -> split -> pool.map(dispatch_kernel, payload) ->
# combine -> checksum (замерить время)
raise NotImplementedError
def measure_overhead(items_count_list):
"""Стоимость передачи данных процессу.
Для каждого n из items_count_list: отправить в воркер список из n чисел
(list(range(n))), воркер возвращает их сумму (_sum_list). Пул создаётся
ОДИН раз, первый вызов — прогрев. Вернуть {n: лучшее_время из 3}.
"""
# TODO: ctx.Pool(1) -> для каждого n: замер pool.map(_sum_list, [data])
raise NotImplementedError
def main():
# --- smoke-режим для автотестов: быстрый прогон и JSON-отчёт -----------
# (не удаляйте: тесты запускают solution.py подпроцессом с LAB_SMOKE=1)
if os.environ.get("LAB_SMOKE") == "1":
from common.tasks import run_sequential, smoke_params
name = "integrate" # smoke не зависит от варианта: быстрая задача
sp = smoke_params(name)
expected, _ = run_sequential(name, sp, parts=1)
got, _t = run_cpu_processes(name, sp, p=2)
ok = (got == expected) or (isinstance(got, float) and
abs(got - expected) < 1e-6 * max(1, abs(expected)))
ov = measure_overhead([1_000, 50_000])
print("LAB_SMOKE_JSON:" + json.dumps({
"ok": True, "result_ok": bool(ok), "p": 2,
"overhead": {str(k): t for k, t in ov.items()}}))
return
v = get_variant(VARIANT_NUMBER)
print(f"Вариант {v['variant']}: {v['describe']}")
print(f"метод запуска: {multiprocessing.get_start_method()}\n")
# --- T(p) на процессах ---------------------------------------------------
rows, times = [], {}
expected = None
for p in v["p_list"]:
t, (cs, _) = time_call(run_cpu_processes,
(v["task_name"], v["cpu_params"], p), repeats=2)
times[p] = t
rows.append((f"процессы, p={p}", t))
if expected is None:
expected = cs
assert cs == expected or (isinstance(cs, float) and
abs(cs - expected) < 1e-6 * max(1, abs(expected))), \
f"checksum при p={p} не совпал!"
print(make_table(rows, title="CPU-задача на процессах"))
base = times[1]
for p, t in times.items():
print(f" S(p={p}) = {base / t:.2f}")
print()
# --- Накладные расходы передачи -------------------------------------------
overhead = measure_overhead([1_000, 100_000, 1_000_000])
print("Стоимость передачи данных процессу (туда+обратно):")
for n, t in sorted(overhead.items()):
print(f" {n:>9,} чисел: {t * 1000:8.1f} мс".replace(",", " "))
save_speedup_plot({"процессы": sorted(times.items())},
f"Ускорение CPU-задачи процессами ({v['describe']})",
os.path.join(os.path.dirname(__file__), "speedup.png"))
# TODO: в отчёт — S(p) и число физических ядер вашей машины,
# сравните время kernel варианта со временем передачи из measure_overhead.
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
# Лабораторная работа 4. Мультипроцессорный код
**Время на работу:** 2 пары. **Зависимости:** лабы 1–3.
## Цель работы
Освоить настоящую параллельность на CPU-задачах: процессы обходят GIL.
Измерить накладные расходы multiprocessing и понять, когда они съедают
выигрыш.
## Теория
### Процесс против потока
Процесс — отдельный экземпляр интерпретатора со **своей** памятью. У каждого
процесса свой GIL, поэтому CPU-задачи на p процессах исполняются по-настоящему
параллельно (на p ядрах). Цена: создание процесса ~10–100 мс (против ~0.1 мс
у потока), а любые данные передаются между процессами через **pickle** —
сериализацию. Большие данные (матрицы, списки миллионов чисел) передаются
долго, и это становится главным ограничением.
### Методы запуска: fork и spawn
- **fork** (Linux/macOS): дочерний процесс — копия родителя «снимком» памяти.
Быстро, но опасно с потоками и блокировками.
- **spawn** (Windows, по умолчанию на macOS): запускается новый интерпретатор,
который заново импортирует ваш модуль. Медленнее и строже: **весь код
создания процессов должен быть под `if __name__ == "__main__":`**, иначе
бесконечное размножение процессов.
Проверить и выбрать: `multiprocessing.get_start_method()` /
`multiprocessing.get_context("spawn")`.
### Pool и передаваемые функции
`multiprocessing.Pool(p)` — пул из p воркеров-процессов:
```python
with multiprocessing.Pool(p) as pool:
results = pool.map(kernel, chunks) # как map, но по процессам
```
ВАЖНО: `kernel` и `chunks` должны пиккелиться. Вложенные функции (def внутри
def) и лямбды — **не** пиккелятся. Поэтому в `common.tasks` ядро задачи
вызывается через модульный `dispatch_kernel((name, chunk))`.
### Что именно передаётся
Схема split → kernel → combine в мире процессов означает:
- `chunks` сериализуются и уходят воркерам (расход на передачу);
- частичные результаты сериализуются и возвращаются (расход на возврат);
- если данные больше результата — передача может занять дольше, чем счёт
(см. задачу «сортировка» в банке задач).
### Разные способы организации
- `Pool.map` — распределить список по воркерам;
- `Process` + `Queue` — ручная схема: воркеры берут задачи из очереди и кладут
ответы в другую (задел для лабы 5);
- `ProcessPoolExecutor` —ThreadPoolExecutor-подобный интерфейс для процессов.
## Задание
CPU-задача — из вашего варианта. Заполните `solution.py`:
1. **`run_cpu_processes(task_name, params, p)`** — CPU-задача на `p`
процессах через `Pool` и `dispatch_kernel`. Вернуть `(checksum, сек)`.
Вся логика — под `if __name__ == "__main__"` (в `main()`).
2. **`measure_overhead(items_count)`** — измерить стоимость передачи:
отправить в воркер список из `items_count` чисел, воркер возвращает его
длину. Время / количество = цена байта. Вернуть `{items_count: сек}`.
3. **`main()`** — T(p) для p из варианта; таблица; график S(p) с «идеальной»
прямой; вывод о накладных расходах.
### Ожидаемые результаты
- CPU-задача: S(p) растёт почти линейно до числа физических ядер;
на машинах с гиперторингом S(p_max) может упираться в ~число физ. ядер.
- `measure_overhead`: передача маленьких данных ~миллисекунды, больших —
десятки-сотни мс. Сравните с временем kernel вашего варианта.
### Важно (Windows)
- Всё создание пулов — строго внутри `main()`/под `__main__`.
- Не используйте лямбды и вложенные функции как аргументы `pool.map`.
- На macOS/Linux можно сравнить `fork` и `spawn` (`get_context`) — добавьте
в отчёт, если работаете не на Windows.
## Контрольные вопросы
1. Почему процессы ускоряют CPU-код, а потоки — нет?
2. Что такое pickle и почему лямбда не может быть аргументом `pool.map`?
3. Замерили T(p=8) на 4-ядерной машине с гиперторингом — ускорение ~4, а не 8.
Почему?
4. Когда передача данных между процессами дороже самих вычислений?
5. Почему на Windows нужен `if __name__ == "__main__":`?
## Что сдаётся
`solution.py` + отчёт: таблица T(p), график S(p), замеры overhead передачи,
анализ (до какого p растёт, где упирается), ответы на вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| Корректный запуск на процессах, checksum совпадает | 2 |
| S(p) > 1.5 при p=4 на CPU-задаче | 2 |
| Замер overhead передачи данных | 2 |
| График + анализ накладных расходов | 2 |
| Ответы на контрольные вопросы | 2 |
## Типичные ошибки
- Нет `__main__` — на Windows бесконечный spawn (см. типичные_ошибки п.1).
- Лямбда/вложенная функция в `pool.map` — `PicklingError`.
- Слишком мелкие куски: создание процессов и передача дороже счёта,
S(p) < 1. Увеличьте размер задачи.
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Демо: гонка данных — потерянные обновления счётчика.
Запуск из корня курса:
python lab05_sync/examples/01_race_demo.py
counter += 1 — три операции (чтение, инкремент, запись). Два потока читают
одно значение, оба пишут — одно увеличение теряется. Lock устраняет гонку.
"""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
N_THREADS = 8
N_INCR = 100_000
def worker_unsafe(counter):
for _ in range(N_INCR):
value = counter[0] # ЧТЕНИЕ
time.sleep(0) # усиливает переключения: GIL уступается
counter[0] = value + 1 # ЗАПИСЬ (могла потеряться)
def worker_safe(counter, lock):
for _ in range(N_INCR):
with lock: # критическая секция
value = counter[0]
counter[0] = value + 1
def main():
expected = N_THREADS * N_INCR
counter = [0]
threads = [threading.Thread(target=worker_unsafe, args=(counter,))
for _ in range(N_THREADS)]
t0 = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
unsafe_time = time.perf_counter() - t0
print(f"без синхронизации: {counter[0]:>10,} из {expected:,} "
f"(потеряно {expected - counter[0]:,}) за {unsafe_time:.2f} c")
counter = [0]
lock = threading.Lock()
threads = [threading.Thread(target=worker_safe, args=(counter, lock))
for _ in range(N_THREADS)]
t0 = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
safe_time = time.perf_counter() - t0
print(f"с Lock: {counter[0]:>10,} из {expected:,} "
f"(идеально) за {safe_time:.2f} c")
print(f"\nРазница времени: {(safe_time - unsafe_time) / unsafe_time * 100:+.0f}%")
print("(Lock может даже ускорить: без него потоки чаще конфликтуют)")
print("Запустите ещё раз — потерянные обновления будут другими:")
print("гонка недетерминирована, этим она и опасна.")
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""Демо: Semaphore — лимит одновременного доступа к ресурсу.
Запуск из корня курса:
python lab05_sync/examples/02_semaphore_demo.py
Имитация: 10 «клиентов», ресурс допускает 3 одновременных подключения.
Семафор гарантирует: активных клиентов никогда не больше 3.
"""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
MAX_CONN = 3
N_CLIENTS = 10
DELAY = 0.2
active = 0
active_lock = threading.Lock()
max_seen = [0]
def client(idx, sem):
global active
with sem: # ждём свободный «слот»
with active_lock:
active += 1
max_seen[0] = max(max_seen[0], active)
print(f" клиент {idx:>2} подключился (активно: {active})")
time.sleep(DELAY) # «работа с ресурсом»
with active_lock:
active -= 1
print(f" клиент {idx:>2} отключился (активно: {active})")
def main():
sem = threading.Semaphore(MAX_CONN)
threads = [threading.Thread(target=client, args=(i, sem))
for i in range(N_CLIENTS)]
t0 = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
total = time.perf_counter() - t0
print(f"\nмаксимум одновременно: {max_seen[0]} (лимит {MAX_CONN})")
print(f"время: {total:.2f} c, идеал ≈ {N_CLIENTS / MAX_CONN * DELAY:.2f} c")
print("Если max_seen > лимита — семафор сломан (такого быть не должно).")
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""Демо: Event — сигнал между потоками.
Запуск из корня курса:
python lab05_sync/examples/03_event_demo.py
Потоки-«работники» ждут сигнала готовности (Event.set()), после чего
начинают работу одновременно. Event — как стартовый пистолет.
"""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
def worker(idx, start_event, results):
name = f"работник-{idx}"
print(f" {name}: готов, жду сигнала")
start_event.wait() # блокируется, пока set() не вызван
t0 = time.perf_counter()
time.sleep(0.1) # «работа»
results[idx] = (name, time.perf_counter() - t0)
print(f" {name}: закончил")
def main():
start = threading.Event()
results = {}
threads = [threading.Thread(target=worker, args=(i, start, results))
for i in range(4)]
for t in threads:
t.start()
time.sleep(0.5) # «подготовка данных»
print("главный: сигнал! Event.set()")
start.set() # все ждущие просыпаются РАЗОМ
for t in threads:
t.join()
print(f"все закончили: {len(results)} из {len(threads)}")
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 5: синхронизация и гонки данных.
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab05.py).
Запуск из корня курса:
python lab05_sync/solution.py
"""
import os
import queue
import sys
import threading
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.tasks import io_fetch
# TODO: впишите свой номер в журнале (1..20) — используется в main()
VARIANT_NUMBER = 0
def race_demo(n_threads: int, n_increments: int):
"""Гонка данных БЕЗ синхронизации.
n_threads потоков, каждый увеличивает общий счётчик n_increments раз.
counter += 1 не атомарен! Чтобы гонка проявилась надёжно, после чтения
счётчика добавьте time.sleep(0) — уступает GIL.
Вернуть (фактический_счётчик, ожидаемый = n_threads * n_increments).
"""
# TODO: общий счётчик (список [0] или nonlocal), потоки, start/join
raise NotImplementedError
def race_fixed(n_threads: int, n_increments: int):
"""То же, но с threading.Lock. Вернуть (фактический_счётчик, ожидаемый).
Результат обязан быть точным ПРИ ЛЮБЫХ запусках.
"""
raise NotImplementedError
def semaphore_limiter(items: int, delay: float, max_concurrent: int):
"""Выполнить items запросов io_fetch(item, delay) на потоках,
но не более max_concurrent одновременно (threading.Semaphore).
Вернуть (список_результатов, elapsed_seconds), упорядочен по item.
Подсказка: results[idx] — каждый воркер пишет в СВОЮ ячейку списка.
"""
raise NotImplementedError
def producer_consumer(n_producers: int, n_consumers: int, n_items: int):
"""Схема producer-consumer на queue.Queue.
Производители суммарно кладут задачи со значениями 0..n_items-1
(распределите между производителями как удобно, но все значения ровно
по одному разу). Потребители забирают и «обрабатывают» (суммируют).
Программа обязана завершиться сама: маркеры конца (None) — по одному
на потребителя. Вернуть (обработано_задач, сумма_значений).
"""
# TODO: queue.Queue() для задач и результатов; producers кладут;
# consumers берут до None; join всех; собрать результаты
raise NotImplementedError
def main():
print("=== 1. Гонка данных ===")
for attempt in range(3):
got, expected = race_demo(8, 100_000)
print(f"попытка {attempt + 1}: {got:,} из {expected:,} "
f"(потеряно {expected - got:,})")
got, expected = race_fixed(8, 100_000)
print(f"с Lock: {got:,} из {expected:,} (всегда точно)")
print("\n=== 2. Семафор: лимит соединений ===")
v_items, v_delay = 24, 0.05
for mc in (2, 4, 8):
res, t = semaphore_limiter(v_items, v_delay, mc)
ideal = v_items / mc * v_delay
print(f"max_concurrent={mc}: {len(res)} запросов за {t:.2f} c "
f"(идеал ≈ {ideal:.2f} c)")
print("\n=== 3. Producer–consumer ===")
t0 = time.perf_counter()
processed, total_sum = producer_consumer(2, 3, 30)
print(f"производителей=2, потребителей=3, задач=30: "
f"обработано {processed}, сумма {total_sum}, "
f"время {time.perf_counter() - t0:.2f} c")
print("Если программа зависла — дедлок: проверьте маркеры конца!")
# TODO: в отчёт — несколько запусков race_demo (недетерминизм!),
# цена Lock, таблицу semaphore_limiter, схему producer-consumer.
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
# Лабораторная работа 5. Синхронизация и гонки данных
**Время на работу:** 2 пары. **Зависимости:** лабы 2, 4.
## Цель работы
Увидеть гонку данных своими глазами (без синхронизации результат «плавает»),
научиться защищать общие ресурсы (`Lock`, `Semaphore`, `Event`) и собрать
классическую схему producer–consumer без дедлоков.
## Теория
### Гонка данных
Гонка — ситуация, когда результат зависит от порядка доступа потоков к общим
данным. Классика: `counter += 1` — это **три** операции (прочитать,
увеличить, записать). Два потока могут прочитать одно значение, оба
увеличить, оба записать — одно из двух увеличений потеряется.
Важно: `x += 1` в Python **не атомарен**. Атомарны отдельные операции вроде
`list.append` (спорно, но под GIL обычно надёжно) — полагаться на это нельзя.
### Инструменты синхронизации
| Инструмент | Что делает | Когда нужен |
|---|---|---|
| `Lock` | взаимное исключение: только один в критической секции | защита счётчиков, списков |
| `RLock` | Lock, который можно взять повторно тем же потоком | рекурсивные функции |
| `Semaphore(n)` | разрешает не более n одновременных входов | лимит на соединения/ресурс |
| `Event` | флаг «событие произошло»: `set()` / `wait()` | сигнал между потоками |
| `Condition` | ожидание условия (`wait`/`notify`) | сложные очереди |
| `Barrier(n)` | все ждут, пока соберутся n | синхронный старт |
В multiprocessing те же примитивы есть в `multiprocessing` (они работают
между процессами через разделяемую память ОС).
### Producer–consumer
Классическая схема: производители кладут задачи в очередь, потребители
забирают. `queue.Queue` — потокобезопасна сама по себе (внутри Lock +
Condition). Главные грабли:
- **Дедлок**: потребитель ждёт `get()`, а производитель уже закончил.
Решение: «маркер конца» (`put(None)`) — по одному на каждого потребителя,
или `get(timeout=...)`/`get_nowait()`.
- **Забытый join** очереди или потоков — программа завершается раньше времени.
### Как искать гонки
- Замеры не помогут — гонка «мимикрирует». Помогает:
1. увеличение числа потоков и повторов;
2. `time.sleep(0)` внутри критической секции — усиливает переключения;
3. сравнение результата с эталоном (checksum).
## Задание
Заполните `solution.py`:
1. **`race_demo(n_threads, n_increments)`** — n потоков увеличивают общий
счётчик `n_increments` раз каждый БЕЗ синхронизации. Вернуть
`(фактический_счётчик, ожидаемый)`. Фактический должен быть МЕНЬШЕ
ожидаемого хотя бы иногда — если всегда ровно, увеличьте параметры
и повторите (см. «Как искать гонки»).
2. **`race_fixed(n_threads, n_increments)`** — то же с `threading.Lock`.
Результат обязан быть точным всегда. Замерьте, во что обходится Lock.
3. **`semaphore_limiter(items, delay, max_concurrent)`** — выполнить
`items` «запросов» `io_fetch` на потоках, но не более
`max_concurrent` одновременно (`Semaphore` + ThreadPoolExecutor).
Вернуть `(результаты, сек)`.
4. **`producer_consumer(n_producers, n_consumers, n_items)`** — производители
кладут `n_items` задач суммарно, потребители обрабатывают, программа
завершается без дедлока. Вернуть количество обработанных задач.
5. **`main()`** — демонстрация всех пункто�� с замерами.
### Ожидаемые результаты
- `race_demo`: счётчик < ожидаемого (часто сильно); при больших
`n_increments` потери растут.
- `race_fixed`: всегда ровно; время чуть больше (цена Lock).
- `semaphore_limiter`: время ≈ `items / max_concurrent × delay` — лимит
работает.
- `producer_consumer`: обработано ровно `n_items`, завершается сам.
## Контрольные вопросы
1. Почему `counter += 1` не атомарен? Какие операции он включает?
2. Чем `Semaphore(3)` отличается от трёх `Lock`? Где это полезно?
3. Что такое дедлок и как «маркер конца» его предотвращает?
4. Почему `queue.Queue` не требует внешнего Lock?
5. Приведите пример из вашей CPU-задачи, где нужна синхронизация при
многопоточности (например, сборка результатов).
## Что сдаётся
`solution.py` + отчёт: вывод race_demo (несколько запусков!), сравнение
race_fixed, замеры semaphore_limiter, описание схемы producer–consumer,
ответы на вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| race_demo демонстрирует потери (или обоснование, почему не демонстрирует) | 2 |
| race_fixed даёт точный результат всегда | 2 |
| semaphore_limiter: лимит соблюдается, замеры есть | 2 |
| producer–consumer завершается сам, ничего не теряется | 2 |
| Отчёт и ответы на вопросы | 2 |
## Типичные ошибки
- race_demo всегда даёт точный результат → увеличьте n_increments (10⁵+),
число потоков, добавьте `time.sleep(0)` после чтения счётчика.
- producer–consumer висит → забыли маркеры конца или не join() потоки.
- `Lock` взят внутри `with` другого `Lock` в другом порядке → дедлок
(порядок взятия замков должен быть единым).
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""Демо: asyncio — конкурентность на одном потоке.
Запуск из корня курса:
python lab06_asyncio/examples/01_async_basics_demo.py
Три корутины «ждут сеть» одновременно: 0.6 c ожидания занимают 0.3 c.
Всё на одном потоке — переключения только на await.
"""
import asyncio
import time
async def fetch(name: str, delay: float) -> str:
print(f" {name}: старт")
await asyncio.sleep(delay) # «ожидание сети» — уступает циклу
print(f" {name}: готов")
return name
async def main():
print("--- gather: три корутины конкурентно ---")
t0 = time.perf_counter()
results = await asyncio.gather(
fetch("A", 0.3),
fetch("B", 0.2),
fetch("C", 0.1),
)
print(f"результаты: {results}")
print(f"время: {time.perf_counter() - t0:.2f} c "
f"(сумма ожиданий 0.6 c)\n")
print("--- create_task: запустить сейчас, ждать позже ---")
t0 = time.perf_counter()
task = asyncio.create_task(fetch("фоновая", 0.3))
await asyncio.sleep(0.1) # пока она ждёт — занимаемся своим
print(" главное: что-то делаю, пока фоновая ждёт")
await task
print(f"время: {time.perf_counter() - t0:.2f} c (задачи шли вместе)")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
"""Демо: блокирующий сон убивает event loop.
Запуск из корня курса:
python lab06_asyncio/examples/02_blocking_mistake_demo.py
time.sleep (в отличие от asyncio.sleep) НЕ уступает управление:
все корутины встают в очередь. Классическая ошибка в async-коде.
"""
import asyncio
import time
async def worker_async(name: str, delay: float) -> str:
await asyncio.sleep(delay) # уступает: другие работают
return name
async def worker_blocking(name: str, delay: float) -> str:
time.sleep(delay) # НЕ уступает: парализует loop
return name
async def run_all(worker, delay: float) -> float:
t0 = time.perf_counter()
await asyncio.gather(*(worker(f"w{i}", delay) for i in range(5)))
return time.perf_counter() - t0
async def main():
t = await run_all(worker_async, 0.2)
print(f"asyncio.sleep: 5 задач за {t:.2f} c (идеал ≈ 0.2 c)")
t = await run_all(worker_blocking, 0.2)
print(f"time.sleep: 5 задач за {t:.2f} c (последовательные 1.0 c!)")
print("\nВывод: в корутине можно вызывать только неблокирующие версии")
print("операций. time.sleep -> asyncio.sleep, requests -> aiohttp и т.д.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""Демо: asyncio.Semaphore — ограничение конкурентности.
Запуск из корня курса:
python lab06_asyncio/examples/03_semaphore_demo.py
50 «запросов», сервер выдерживает 5 одновременных. Семафор не даёт
превысить лимит: время ≈ 50/5 × delay.
"""
import asyncio
import time
async def request(i: int, delay: float) -> int:
await asyncio.sleep(delay)
return i
async def bounded(i: int, delay: float, sem: asyncio.Semaphore) -> int:
async with sem: # только 5 корутин внутри одновременно
return await request(i, delay)
async def main():
N, DELAY, LIMIT = 50, 0.05, 5
sem = asyncio.Semaphore(LIMIT)
t0 = time.perf_counter()
results = await asyncio.gather(*(bounded(i, DELAY, sem)
for i in range(N)))
dt = time.perf_counter() - t0
print(f"{N} запросов, лимит {LIMIT}: {dt:.2f} c")
print(f"идеал ≈ {N / LIMIT * DELAY:.2f} c")
print(f"результаты упорядочены: {results == list(range(N))}")
print("\nБез семафора было бы ≈ 0.05 c (все сразу) — но так сервер ляжет.")
if __name__ == "__main__":
asyncio.run(main())
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 6: асинхронный код (asyncio).
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab06.py).
Запуск из корня курса:
python lab06_asyncio/solution.py
"""
import asyncio
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.benchmark import make_table
from common.tasks import get_variant
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
async def fetch_async(item: int, delay: float):
"""Корутина: имитация I/O-запроса.
await asyncio.sleep(delay) — НЕ блокирует цикл событий.
Вернуть (item, item * item).
"""
# TODO: asyncio.sleep + вернуть результат
raise NotImplementedError
async def fetch_all_async(items: int, delay: float):
"""Все items запросов конкурентно через asyncio.gather.
Вернуть (список результатов, elapsed_seconds), упорядочен по item.
"""
# TODO: gather по всем item -> сортировка не нужна, gather сохраняет порядок
raise NotImplementedError
async def fetch_all_limited(items: int, delay: float, max_concurrent: int):
"""То же, но не более max_concurrent одновременно (asyncio.Semaphore).
Вернуть (список результатов, elapsed_seconds), упорядочен по item.
"""
# TODO: sem = asyncio.Semaphore(...); async with sem: await fetch_async(...)
raise NotImplementedError
async def _fetch_blocking(item: int, delay: float):
"""«Ошибка новичка»: блокирующий time.sleep внутри корутины.
time.sleep НЕ уступает управление — парализует весь event loop.
"""
time.sleep(delay)
return (item, item * item)
async def _blocking_mistake(items: int, delay: float):
"""Все запросы через gather, но с блокирующим сном. Вернуть секунды."""
# TODO: gather по _fetch_blocking, вернуть время
raise NotImplementedError
def measure_blocking_mistake(items: int, delay: float) -> float:
"""Обёртка для тестов: asyncio.run + return секунд."""
# TODO: return asyncio.run(_blocking_mistake(items, delay))
raise NotImplementedError
def main():
v = get_variant(VARIANT_NUMBER)
items, delay = v["io"]["items"], v["io"]["delay"]
print(f"Вариант {v['variant']}: I/O-нагрузка {items} запросов по {delay} c\n")
# --- 1. Async: все запросы сразу ---------------------------------------
t0 = time.perf_counter()
results, t_async = asyncio.run(fetch_all_async(items, delay))
print(f"async, без лимита: {t_async:.3f} c "
f"(упорядочено: {[r[0] for r in results] == list(range(items))})")
# --- 2. Async с лимитом --------------------------------------------------
for mc in (4, 8):
res, t_lim = asyncio.run(fetch_all_limited(items, delay, mc))
print(f"async, лимит {mc}: {t_lim:.3f} c "
f"(идеал ≈ {items / mc * delay:.2f} c)")
# --- 3. Ошибка: time.sleep в корутине -----------------------------------
t_block = measure_blocking_mistake(items, delay)
print(f"async + time.sleep (ошибка!): {t_block:.3f} c "
f"≈ последовательные {items * delay:.2f} c\n")
# --- 4. Сравнение с потоками (лаба 2) ------------------------------------
from concurrent.futures import ThreadPoolExecutor
from common.tasks import io_fetch
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(io_fetch, range(items), [delay] * items))
t_threads = time.perf_counter() - t0
rows = [("async без лимита", t_async),
("async, лимит 8", t_lim),
("потоки, p=8", t_threads),
("последовательно (ошибка time.sleep)", t_block)]
print(make_table(rows, title="Сравнение подходов к I/O"))
# TODO: в отчёт — таблицу, объяснение разницы потоков и asyncio,
# ответ на вопрос «почему time.sleep убил конкурентность».
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
# Лабораторная работа 6. Асинхронный код (asyncio)
**Время на работу:** 2 пары. **Зависимости:** лабы 1–2.
## Цель работы
Освоить кооперативную многозадачность: `async/await`, event loop, `gather`,
ограничение конкурентности семафором. Понять, чем asyncio отличается от
потоков и когда выбирать её.
## Теория
### Модель: один поток, но тысячи задач
Asyncio — **однопоточная** кооперативная многозадачность. Есть один цикл
событий (event loop) и множество корутин. Корутина выполняется, пока сама
не уступит управление через `await` — это ключевое отличие от потоков, где
ОС переключает задачи принудительно в любой момент (вытесняющая
многозадачность).
Следствия:
- нет гонок на `counter += 1` между await-ами: код между await-ами
исполняется атомарно (переключение — только на await);
- зато «жадная» корутина без await блокирует всё;
- одна корутина может ожидать тысячи сокетов — накладные расходы на задачу
в разы меньше, чем на поток.
### Ключевые слова и функции
```python
async def fetch(i): # корутина — «ленивая» функция
await asyncio.sleep(0.05) # уступает управление на время ожидания
return i
async def main():
results = await asyncio.gather( # запустить все и ждать все
fetch(1), fetch(2), fetch(3))
task = asyncio.create_task(fetch(4)) # задача: стартовала сейчас
r = await task # ...а результат ждём тут
```
`asyncio.run(main())` — вход в async-мир из обычного кода (один раз на
программу). `await` можно ставить только внутри `async def`.
### gather и семафор
`gather` запускает все корутины конкурентно. Если задач тысячи, а «сервер»
выдерживает 100 соединений — используем `asyncio.Semaphore`:
```python
sem = asyncio.Semaphore(100)
async def limited(i):
async with sem:
return await fetch(i)
```
### Потоки или asyncio?
| | Потоки | asyncio |
|---|---|---|
| Переключение | вытесняющее (ОС) | кооперативное (await) |
| Масштаб задач | десятки–сотни | тысячи и больше |
| Гонки данных | да | только между await-ами |
| CPU-код | бесполезен (GIL) | блокирует всё |
| Блокирующие библиотеки | подходят | блокируют цикл (!) |
Критерий: много I/O + нужно много одновременных задач → asyncio.
CPU-работа → процессы (лаба 4). Смешанная → комбинировать.
### Чем опасна блокирующая функция в async-коде
`time.sleep(0.05)` (не `asyncio.sleep`) внутри корутины останавливает ВЕСЬ
цикл событий: ни одна другая корутина не выполняется. В работе это
демонстрируется явно.
## Задание
Заполните `solution.py` (все функции `async`, кроме `main`):
1. **`fetch_async(item, delay)`** — корутина: `await asyncio.sleep(delay)`,
вернуть `(item, item * item)`.
2. **`fetch_all_async(items, delay)`** — конкурентно все items запросов
через `asyncio.gather`. Вернуть `(результаты, сек)`, упорядочены по item.
3. **`fetch_all_limited(items, delay, max_concurrent)`** — то же, но
не более max_concurrent одновременно (`asyncio.Semaphore`).
4. **`measure_blocking_mistake(items, delay)`** — «ошибка новичка»:
корутина использует `time.sleep(delay)` вместо `asyncio.sleep`.
Вернуть время выполнения: оно окажется ≈ items × delay — блокирующий
сон парализует цикл событий.
5. **`main()`** — все пункты + сравнение с потоками (лаба 2).
### Ожидаемые результаты
- `fetch_all_async(96, 0.05)` ≈ 0.05–0.1 c — все запросы «одновременно».
- `fetch_all_limited(96, 0.05, 8)` ≈ 0.6 c.
- `measure_blocking_mistake(96, 0.05)` ≈ 4.8 c — блокирующий sleep убил
конкурентность.
## Контрольные вопросы
1. Чем кооперативная многозадачность отличается от вытесняющей?
2. Почему между двумя `await` корутина «владеет» данными без Lock?
3. Что случится, если в корутине вызвать `time.sleep(10)`? Проверьте.
4. Когда выбрали бы потоки, а когда asyncio? Приведите по примеру.
5. Что делает `asyncio.gather` и чем отличается от последовательных `await`?
## Что сдаётся
`solution.py` + отчёт: таблица сравнения (последовательно/потоки/async/
async+лимит/ошибка с time.sleep), анализ, ответы на вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| fetch_all_async: время ≈ delay, результаты упорядочены | 2 |
| fetch_all_limited: лимит соблюдается | 2 |
| measure_blocking_mistake: продемонстрирована деградация | 2 |
| Сравнительная таблица в отчёте | 2 |
| Ответы на контрольные вопросы | 2 |
## Типичные ошибки
- `await` вне `async def` — SyntaxError.
- Забыт `await` перед корутиной: `RuntimeWarning: never awaited`, ничего
не выполняется.
- `asyncio.run()` внутри работающего цикла (Jupyter) — RuntimeError.
- `time.sleep` в корутине — «секретный» последовательный режим (это п.4
работы, тут это фича).
+163
View File
@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 7 (итоговая): одна задача — четыре подхода.
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab07.py, запускаются через подпроцесс).
Запуск из корня курса:
python lab07_comparison/solution.py
"""
import asyncio
import json
import multiprocessing
import os
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.benchmark import amdahl, make_table, save_speedup_plot, time_call
from common.tasks import TASKS, dispatch_kernel, get_variant, io_fetch
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
# ========================= CPU-сценарий ====================================
def cpu_sequential(task_name: str, params: dict):
"""Последовательно (лаба 1). Вернуть (checksum, сек)."""
task = TASKS[task_name]
# TODO: build -> замер времени -> split(data, 1) -> kernel -> combine -> checksum
raise NotImplementedError
def cpu_threads(task_name: str, params: dict, p: int):
"""На p потоках (лаба 3): ожидание S ≈ 1 из-за GIL. Вернуть (checksum, сек)."""
# TODO: как в лабе 3 — ThreadPoolExecutor, kernel напрямую в воркере
raise NotImplementedError
def cpu_processes(task_name: str, params: dict, p: int):
"""На p процессах (лаба 4): ожидание S ≈ ядра. Вернуть (checksum, сек)."""
# TODO: как в лабе 4 — ctx.Pool + dispatch_kernel
raise NotImplementedError
# ========================= I/O-сценарий ====================================
def io_sequential(items: int, delay: float):
"""Последовательно (лаба 1). Вернуть (список, сек)."""
# TODO: [io_fetch(i, delay) for i in range(items)] с замером
raise NotImplementedError
def io_threads(items: int, delay: float, p: int):
"""На p потоках (лаба 2). Вернуть (список, сек)."""
# TODO: ThreadPoolExecutor + pool.map(io_fetch, ..., [delay]*items)
raise NotImplementedError
async def _io_async_coro(items: int, delay: float):
"""Корутина (лаба 6): gather + asyncio.sleep. Вернуть (список, сек)."""
raise NotImplementedError
def io_async(items: int, delay: float):
"""Обёртка asyncio.run. Вернуть (список, сек)."""
raise NotImplementedError
def io_async_limited(items: int, delay: float, max_concurrent: int = 8):
"""Async с семафором (лаба 6). Вернуть (список, сек)."""
raise NotImplementedError
# ========================= main ============================================
def _smoke():
"""Smoke-режим автотестов: быстрый прогон всех подходов, JSON-отчёт.
(не удаляйте: тесты запускают solution.py подпроцессом с LAB_SMOKE=1)"""
from common.tasks import smoke_params, run_sequential
name, sp = "integrate", smoke_params("integrate")
exp, _ = run_sequential(name, sp, parts=1)
cs1, _ = cpu_threads(name, sp, 2)
cs2, _ = cpu_processes(name, sp, 2)
ok_cpu = (cs1 == exp or abs(cs1 - exp) < 1e-6) and \
(cs2 == exp or abs(cs2 - exp) < 1e-6)
res, t_seq = io_sequential(8, 0.0)
res2, t_thr = io_threads(8, 0.02, 4)
res3, t_asy = io_async(8, 0.02)
ok_io = len(res) == 8 and len(res2) == 8 and len(res3) == 8
print("LAB_SMOKE_JSON:" + json.dumps({
"ok": True, "cpu_ok": bool(ok_cpu), "io_ok": bool(ok_io),
"p": 2, "io_times": {"seq": t_seq, "threads": t_thr, "async": t_asy}}))
return
def main():
if os.environ.get("LAB_SMOKE") == "1":
_smoke()
return
v = get_variant(VARIANT_NUMBER)
items, delay = v["io"]["items"], v["io"]["delay"]
print(f"Вариант {v['variant']}: {v['describe']}")
print(f"I/O: {items} запросов по {delay} c\n")
# --- CPU-сценарий -------------------------------------------------------
print("=== CPU-сценарий ===")
cs0, t_seq = cpu_sequential(v["task_name"], v["cpu_params"])
print(f"последовательно: {t_seq:.3f} c")
cpu_results = {"потоки": [], "процессы": []}
for p in v["p_list"]:
_, (c1, t_thr) = time_call(cpu_threads,
(v["task_name"], v["cpu_params"], p), repeats=2)
_, (c2, t_proc) = time_call(cpu_processes,
(v["task_name"], v["cpu_params"], p), repeats=2)
assert (c1 == cs0 or abs(c1 - cs0) < 1e-6) and \
(c2 == cs0 or abs(c2 - cs0) < 1e-6), f"checksum не совпал при p={p}"
cpu_results["потоки"].append((p, t_thr))
cpu_results["процессы"].append((p, t_proc))
print(f"p={p}: потоки {t_thr:.3f} c (S={t_seq/t_thr:.2f}) | "
f"процессы {t_proc:.3f} c (S={t_seq/t_proc:.2f})")
# --- I/O-сценарий ---------------------------------------------------------
print("\n=== I/O-сценарий ===")
_, t_ioseq = io_sequential(items, delay)
_, t_iothr = io_threads(items, delay, 8)
_, t_ioasy = io_async(items, delay)
_, t_ioasy_lim = io_async_limited(items, delay, 8)
io_rows = [("последовательно", t_ioseq),
("потоки, p=8", t_iothr),
("async", t_ioasy),
("async, лимит 8", t_ioasy_lim)]
print(make_table(io_rows, title="I/O-сценарий"))
# --- График CPU ------------------------------------------------------------
save_speedup_plot(cpu_results,
f"CPU: ускорение ({v['describe']})",
os.path.join(os.path.dirname(__file__), "speedup_cpu.png"))
# --- Оценка s по Амдалу -----------------------------------------------------
p_max = max(v["p_list"])
s_meas = t_seq / cpu_results["процессы"][-1][1]
best_s, best_diff = None, float("inf")
for i in range(1, 200): # перебираем s = 0.005 .. 0.995
s = i / 200
diff = abs(amdahl(s, p_max) - s_meas)
if diff < best_diff:
best_diff, best_s = diff, s
print(f"\nИзмеренное S({p_max}) = {s_meas:.2f}; "
f"по закону Амдала это соответствует s ≈ {best_s:.3f} "
f"(доля последовательного кода с накладными расходами)")
# TODO: в отчёт — обе таблицы, график, объяснение s, развёрнутые выводы
# по всему курсу (это итоговая работа).
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
# Лабораторная работа 7. Сравнительный анализ: одна задача — четыре подхода
**Время на работу:** 2 пары. **Зависимости:** лабы 1–6. Итоговая работа.
## Цель работы
Свести весь курс в одну таблицу: ваша CPU-задача и ваша I/O-нагрузка,
выполненные последовательно, потоками, процессами и асинхронно. Объяснить
каждую цифру. Это работа для защиты: по ней проверяется понимание всего
курса.
## Теория
### Ускорение и эффективность
- Ускорение: **S(p) = T(1) / T(p)** — во сколько раз быстрее на p воркерах.
- Эффективность: **E(p) = S(p) / p** — насколько эффективно используется
каждый воркер. E = 1 — идеал, E = 0.5 — половина мощности простаивает.
### Закон Амдала
Если доля последовательного кода задачи равна s (0 ≤ s ≤ 1), то ускорение
ограничено:
```
S(p) ≤ 1 / (s + (1 − s) / p)
```
При s = 0.1 и p → ∞: S ≤ 10. Даже бесконечное число воркеров не даст
больше 10×. Отсюда главный вывод курса: **сначала сокращайте
последовательную часть и накладные расходы, потом наращивайте p.**
В `common.benchmark` есть `amdahl(s, p)` — сравните свои S(p) с теорией:
зная измеренное S(p), можно оценить эффективную долю последовательной части
вашего эксперимента (создание пулов, pickle, GIL-переключения — всё это
«последовательная» часть).
### Матрица выбора модели
| Задача | Потоки | Процессы | asyncio |
|---|---|---|---|
| CPU-bound (чистый Python) | ✗ (GIL) | ✓ | ✗ (блокирует loop) |
| CPU-bound в C-коде (hashlib, numpy) | ✓ | ✓ | ✗ |
| I/O-bound, десятки задач | ✓ | возможно (дорого) | ✓ |
| I/O-bound, тысячи задач | дорого | ✗ | ✓✓ |
## Задание
Заполните `solution.py` — оба сценария из ваших лабораторных:
**CPU-сценарий** (задача из варианта):
1. `cpu_sequential(task_name, params)` — как в лабе 1;
2. `cpu_threads(task_name, params, p)` — как в лабе 3 (S ≈ 1!);
3. `cpu_processes(task_name, params, p)` — как в лабе 4 (S ≈ ядра).
**I/O-сценарий** (items × delay из варианта):
4. `io_sequential(items, delay)` — как в лабе 1;
5. `io_threads(items, delay, p)` — как в лабе 2;
6. `io_async(items, delay)` — как в лабе 6;
7. `io_async_limited(items, delay, max_concurrent)` — с семафором 8.
**`main()`** — таблицы для обоих сценариев с T(p), S(p), E(p); два графика
ускорения; оценка доли s по закону Амдала для процессов.
### Ожидаемые результаты (сверьтесь)
CPU: потоки S ≈ 1; процессы S(p) ≈ до числа физических ядер.
I/O: последовательный — база; потоки и async — S ≈ p; async без лимита —
минимальное время (нет расходов на потоки); processes для I/O — работает,
но с заметными накладными расходами.
### Что дополнительно оценить
- Разница T(1) у процессов против последовательного — цена старта процессов
и pickle.
- По S(p_max) процессов найдите s через `amdahl` (подберите s так, чтобы
теория совпала с практикой) — укажите его в отчёте.
## Контрольные вопросы
1. Почему потоки не ускорили CPU-задачу, но ускорили I/O?
2. Почему asyncio на I/O обгоняет потоки? Откуда выигрыш?
3. Какая доля последовательного кода получилась у процессов по Амдалу?
Что в неё входит?
4. Какую модель вы выбрали бы для веб-краулера на 10 000 страниц?
Для рендера видео? Обоснуйте.
5. Что в ваших замерах не совпало с теорией и почему?
## Что сдаётся
`solution.py` + отчёт: две таблицы (CPU и I/O) с T/S/E, два графика,
оценка s по Амдалу, развёрнутые выводы (это итоговая работа — выводы
должны покрывать весь курс), ответы на вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| Все 7 функций работают, checksum корректен | 3 |
| Таблицы и графики для обоих сценариев | 3 |
| Оценка s по закону Амдала с объяснением | 2 |
| Выводы и ответы на вопросы (защита) | 4 |
## Типичные ошибки
- Сравнение T(p) разных сценариев при разном объёме работы — объём
фиксирован внутри сценария.
- Забыт smoke-режим `LAB_SMOKE` (автотесты запускают solution.py
подпроцессом — не удаляйте блок в main()).
- Замеры в один запуск без повторов: `time_call(..., repeats=3)`.
+38
View File
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""Демо: привет, MPI — ранги, размер мира, точка-точка.
Запуск из корня курса:
mpiexec -n 4 python lab08_mpi/examples/01_hello_mpi.py
Каждый ранг — отдельный процесс с копией программы. Ранги обмениваются
сообщениями: ранг 0 рассылает своё имя всем, каждый отвечает.
Без mpiexec (обычный python) программа работает как единственный ранг.
"""
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
print(f"[до обмена] ранг {rank} из {size}, узел {MPI.Get_processor_name()}")
if rank == 0:
# ранг 0 рассылает задание всем остальным (bcast: всем одно и то же)
greeting = f"привет от координатора (ранг 0), всего рангов {size}"
else:
greeting = None
greeting = comm.bcast(greeting, root=0) # коллективная: вызывают ВСЕ
print(f"ранг {rank}: получил «{greeting}»")
# точка-точка: каждый ранг (кроме 0) отправляет 0 свой номер
if rank != 0:
comm.send(rank, dest=0, tag=11)
print(f"ранг {rank}: отправил свой номер координатору")
else:
received = [comm.recv(source=r, tag=11) for r in range(1, size)]
print(f"ранг 0: получил номера от рангов {received}")
if size == 1:
print("\nВы запустили без mpiexec — работает один ранг.")
print("Попробуйте: mpiexec -n 4 python lab08_mpi/examples/01_hello_mpi.py")
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Демо: ping-pong и замер стоимости обмена между рангами.
Запуск из корня курса:
mpiexec -n 2 python lab08_mpi/examples/02_pingpong_mpi.py
Ранги перекидывают список чисел туда-обратно. Так измеряют латентность и
пропускную способность канала между узлами.
"""
import time
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
if size < 2:
print("Нужны минимум 2 ранга: mpiexec -n 2 python lab08_mpi/examples/02_pingpong_mpi.py")
raise SystemExit(0)
ROUNDS = 20
for n_bytes in (1_000, 100_000, 1_000_000):
data = list(range(n_bytes // 8)) # ~n_bytes байт целых чисел
comm.barrier() # синхронный старт замера
t0 = time.perf_counter()
if rank == 0:
for _ in range(ROUNDS):
comm.send(data, dest=1, tag=0)
comm.recv(source=1, tag=0)
elif rank == 1:
for _ in range(ROUNDS):
comm.recv(source=0, tag=0)
comm.send(data, dest=0, tag=0)
dt = time.perf_counter() - t0
if rank == 0:
per_round = dt / ROUNDS
print(f"{n_bytes:>9,} байт: {ROUNDS} раундов за {dt:.3f} c "
f"=> {per_round * 1000:.2f} мс/раунд (туда-обратно)".replace(",", " "))
if rank == 0:
print("\nНа одной машине это IPC (быстро), между узлами — сеть.")
print("Эта цена обмена и есть накладные расходы распределённых вычислений.")
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Демо: коллективные операции scatter / gather / reduce / bcast.
Запуск из корня курса:
mpiexec -n 4 python lab08_mpi/examples/03_collectives_mpi.py
Показывает все четыре коллективные операции на маленьком примере.
"""
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
# --- bcast: один -> всем ------------------------------------------------
config = {"task": "summa_kvadratov", "n": 100} if rank == 0 else None
config = comm.bcast(config, root=0)
print(f"ранг {rank}: config = {config}")
# --- scatter: СПИСОК КУСКОВ (ровно size штук), ранг i получает i-й --------
if rank == 0:
# каждый кусок — свой список; scatter ждёт список из size кусков!
numbers = [[i * 10 + j for j in range(10)] for i in range(size)]
else:
numbers = None
my_chunk = comm.scatter(numbers, root=0) # ранг i получает свой кусок
print(f"ранг {rank}: мой кусок {my_chunk[0]}..{my_chunk[-1]} ({len(my_chunk)} шт.)")
# --- каждый ранг считает СВОЮ часть --------------------------------------
my_sum = sum(x * x for x in my_chunk)
print(f"ранг {rank}: сумма квадратов моего куска = {my_sum}")
# --- gather: все части -> список на ранге 0 ------------------------------
partial_sums = comm.gather(my_sum, root=0)
if rank == 0:
print(f"ранг 0: частичные суммы = {partial_sums}")
print(f"ранг 0: итого через sum(gather) = {sum(partial_sums)}")
# --- reduce: то же самое, но сразу со свёрткой (проще и быстрее) ----------
total = comm.reduce(my_sum, op=MPI.SUM, root=0)
if rank == 0:
print(f"ранг 0: итого через reduce(MPI.SUM) = {total}")
print("\ngather+sum и reduce(SUM) дали одно и то же — reduce короче.")
print("В задании mpi_pi вы используете reduce для суммы попаданий.")
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Демо: π методом Монте-Карло в MPI (reduce).
Запуск из корня курса:
mpiexec -n 4 python lab08_mpi/examples/04_pi_reduce_mpi.py
Каждый ранг бросает свою порцию «точек» со СВОИМ зерном (rank-зависимым),
reduce(SUM) сворачивает попадания. Число рангов не меняет формулу —
меняется только распределение бросков.
"""
import random
import time
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
N_PER_RANK = 500_000
def count_inside(n: int, seed: int) -> int:
rng = random.Random(seed) # зерно зависит от ранга
inside = 0
for _ in range(n):
x = rng.random()
y = rng.random()
if x * x + y * y <= 1.0:
inside += 1
return inside
t0 = time.perf_counter()
my_inside = count_inside(N_PER_RANK, seed=1000 + rank)
total_inside = comm.reduce(my_inside, op=MPI.SUM, root=0)
dt = time.perf_counter() - t0
if rank == 0:
total = N_PER_RANK * size
pi = 4.0 * total_inside / total
print(f"рангов: {size}, бросков на ранг: {N_PER_RANK:,} (всего {total:,})")
print(f"π ≈ {pi:.6f} (погрешность {abs(pi - 3.141592653589793):.6f})")
print(f"время: {dt:.2f} c")
if size == 1:
print("\nПопробуйте: mpiexec -n 4 python lab08_mpi/examples/04_pi_reduce_mpi.py")
print("Каждый ранг добавляет 500k бросков: точность растёт, время — нет.")
+124
View File
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
"""Лабораторная работа 8: распределённые вычисления (mpi4py).
Заполните функции ниже. ИНТЕРФЕЙСЫ МЕНЯТЬ НЕЛЬЗЯ — по ним работают
автотесты (tests/test_lab08.py; если mpi4py не установлен — тесты
пропускаются, но работа сдаётся с запуском через mpiexec).
Запуск из корня курса:
python lab08_mpi/solution.py (один ранг — отладка)
mpiexec -n 4 python lab08_mpi/solution.py (4 ранга — замеры)
"""
import os
import random
import sys
import time
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from common.tasks import TASKS, get_variant
try:
from mpi4py import MPI
HAVE_MPI = True
except ImportError:
HAVE_MPI = False
# TODO: впишите свой номер в журнале (1..20)
VARIANT_NUMBER = 0
def _group_for_ranks(chunks, size):
"""Распределить куски по рангам round-robin: список из size групп.
scatter ожидает список ровно из size элементов, а естественных кусков
может быть другое число (например, nqueens всегда даёт N кусков) —
поэтому группируем.
"""
# TODO: groups = [[] for _ in range(size)]; groups[i % size].append(ch)
raise NotImplementedError
def mpi_run(task_name: str, params: dict):
"""CPU-задача на всех рангах: scatter групп -> kernel -> gather -> combine.
Схема:
ранг 0: build -> split(data, size) -> _group_for_ranks
scatter раздаёт ГРУППЫ кусков (по одной на ранг)
каждый ранг: my_partials = [task["kernel"](ch) for ch in my_group]
gather собирает списки частичных результатов
ранг 0: развернуть в плоский список -> combine ОДИН раз -> checksum
Функции пиккелить не нужно (в отличие от multiprocessing) — только данные.
Для задачи 'life': данные — (grid, steps), параллелятся ПОКОЛЕНИЯ.
Ранг 0 держит текущее поле; каждый шаг: scatter полос -> kernel ->
gather -> combine -> новое поле. Число шагов раздаётся через bcast.
"""
if not HAVE_MPI:
raise RuntimeError("mpi4py не установлен (см. методичку, раздел Установка)")
task = TASKS[task_name]
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
if task_name == "life":
# TODO: цикл по поколениям (см. схему выше); в конце bcast поля
# с ранга 0 всем, чтобы checksum был у всех рангов
raise NotImplementedError
# TODO: общий случай по схеме выше; вернуть checksum на ранге 0 (None на других)
raise NotImplementedError
def mpi_pi(N: int) -> float:
"""π методом Монте-Карло на всех рангах (reduce).
Каждый ранг бросает N//size точек с зерном random.Random(100 + rank).
reduce(MPI.SUM) сворачивает попадания на ранге 0.
Вернуть π (на ранге 0 — вычисленное значение, на остальных 0.0).
"""
# TODO: my_n = N // size; цикл бросков; comm.reduce(inside, op=MPI.SUM, root=0)
raise NotImplementedError
def main():
if not HAVE_MPI:
print("mpi4py не установлен. Инструкция — в методичке, раздел «Установка».")
print("Кратко (Windows): MS-MPI runtime + pip install mpi4py")
sys.exit(1)
v = get_variant(VARIANT_NUMBER)
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()
if rank == 0:
print(f"Вариант {v['variant']}: {v['describe']}")
print(f"Запущено рангов: {size}\n")
# --- pi через reduce ------------------------------------------------------
pi = mpi_pi(1_000_000)
if rank == 0:
print(f"π ≈ {pi:.6f} (Монте-Карло, 1 000 000 бросков суммарно)")
# --- основная задача --------------------------------------------------------
comm.barrier()
t0 = time.perf_counter()
checksum = mpi_run(v["task_name"], v["cpu_params"])
dt = time.perf_counter() - t0
if rank == 0:
from common.tasks import run_sequential
ref, _ = run_sequential(v["task_name"], v["cpu_params"], parts=1)
ok = checksum == ref or (isinstance(checksum, float) and
abs(checksum - ref) < 1e-6 * max(1, abs(ref)))
print(f"задача: {dt:.3f} c, checksum совпадает с последовательным: {ok}")
print("\nЗапустите при разном числе рангов для таблицы T(p):")
print(" mpiexec -n 1 python lab08_mpi/solution.py")
print(" mpiexec -n 2 python lab08_mpi/solution.py")
print(" mpiexec -n 4 python lab08_mpi/solution.py")
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
# Лабораторная работа 8. Распределённые вычисления (MPI)
**Время на работу:** 2–3 пары. **Зависимости:** лабы 1, 4.
## Цель работы
Освоить модель передачи сообщений (MPI) — стандарт распределённых вычислений,
на котором работают суперкомпьютеры. Понять отличие «общей памяти»
(потоки/процессы Python) от «обмена сообщениями» между независимыми узлами.
## Теория
### Модель MPI
MPI (Message Passing Interface) — набор программ на нескольких **узлах**
(машинах или процессах), обменивающихся сообщениями. Нет общей памяти: любые
данные передаются явно, функциями отправки/приёма. Программа запускается
одновременно на N «рангах» (процессах):
```bat
mpiexec -n 4 python script.py # 4 копии script.py
```
Каждая копия узнаёт, кто она:
```python
from mpi4py import MPI
comm = MPI.COMM_WORLD # «мир» — все ранги
rank = comm.Get_rank() # мой номер: 0..size-1
size = comm.Get_size() # всего рангов
```
Ранг 0 обычно координатор: он делит работу и собирает результат.
### Операции: «точка-точка» и коллективные
**Точка-точка** (ранг ↔ ранг):
```python
comm.send(data, dest=1, tag=0) # блокирующая отправка
data = comm.recv(source=0, tag=0) # блокирующий приём
```
**Коллективные** (все ранги сразу — проще и обычно быстрее):
| Операция | Что делает |
|---|---|
| `bcast` | один раздает всем (broadcast) |
| `scatter` | список режется на части: ранг i получает i-ю часть |
| `gather` | части собираются в список на одном ранге |
| `reduce` | части сворачиваются операцией (sum/max/…) на одном ранге |
| `barrier` | все ждут, пока все дойдут до этой точки |
### Схема параллельной задачи в MPI
```
ранг 0: build -> split -> scatter ─┐
ранги 1..N: получают кусок │-> kernel -> gather -> combine -> checksum
ранг 0: собирает ┘
```
Это ровно ваша схема split→kernel→combine из лаб 1–4, только обмен данными —
через сеть/IPC, а не общую память.
### Установка (Windows)
1. MS-MPI: скачать и установить **msmpisetup.exe** и **msmpires.msi**
(https://www.microsoft.com/en-us/download/details.aspx?id=105289).
2. `pip install mpi4py`
3. Проверка: `mpiexec -n 2 python -c "from mpi4py import MPI; print(MPI.COMM_WORLD.Get_rank())"`
На macOS/Linux: `brew install open-mpi` / `apt install libopenmpi-dev`,
затем `pip install mpi4py`, запуск `mpiexec` (или `mpirun`).
Важно: запускать `mpiexec -n 4` — тогда `solution.py` выполнится 4 раза
параллельно. Запуск без mpiexec допустим: программа работает как один ранг
(полезно для отладки и автотестов).
## Задание
Заполните `solution.py`:
1. **`mpi_run(task_name, params)`** — параллельное выполнение вашей CPU-задачи:
ранг 0 строит данные и делит на `size` кусков, `scatter` раздаёт,
каждый ранг считает `kernel` (используя `dispatch_kernel` не нужно —
в MPI функции не пиккелятся, только данные), `gather` собирает,
ранг 0 возвращает checksum. Для задачи «Жизнь» — цикл по поколениям
(см. подсказку в заготовке).
2. **`mpi_pi(N)`** — учебный пример: π методом Монте-Карло на всех рангах,
сборка через `reduce` (сумма попаданий). Каждый ранг использует
`random.Random(100 + rank)` — свои броски.
3. **`main()`** — checksum и время `mpi_run` на текущем числе рангов
(`size`), печать с ранга 0. Запустите при `-n 1, 2, 4` — получите T(p).
### Ожидаемые результаты
- checksum совпадает с последовательным запуском (лаба 1).
- T(p) на одном компьютере ≈ результатам multiprocessing из лабы 4
(плюс небольшие расходы на обмен).
- На настоящих нескольких машинах код не меняется — меняется только
запуск (это главный смысл MPI).
### Измерения для отчёта
```bat
mpiexec -n 1 python lab08_mpi\solution.py
mpiexec -n 2 python lab08_mpi\solution.py
mpiexec -n 4 python lab08_mpi\solution.py
```
## Контрольные вопросы
1. Чем модель передачи сообщений отличается от модели общей памяти?
2. Что делает `scatter` и чем она отличается от `bcast`?
3. Почему в MPI функции не нужно пиккелить, а в multiprocessing — нужно?
4. Что такое ранг? Как ранг 0 узнаёт, сколько всего рангов?
5. Когда MPI выгоднее multiprocessing, даже на одном компьютере?
## Что сдаётся
`solution.py` + отчёт: вывод при `-n 1/2/4`, таблица T(p), сравнение с
лабой 4, ответы на вопросы.
## Критерии оценки
| Пункт | Баллы |
|---|---|
| mpi_run: checksum корректен при -n 1 и -n 4 | 3 |
| mpi_pi через reduce | 2 |
| Замеры при разном числе рангов | 2 |
| Отчёт и ответы на вопросы | 3 |
## Типичные ошибки
- Код с веткой «если rank == 0» выполняется ВСЕМИ рангами — не заворачивайте
всю программу в `if rank == 0`.
- Разные random на разных рангах без фиксации зерна — невоспроизводимый
результат.
- Заблокированный `recv` без парной `send` — программа висит. Начинайте с
коллективных операций, они безопаснее.
+9
View File
@@ -0,0 +1,9 @@
# Обязательные зависимости курса
numpy>=1.24
matplotlib>=3.7
pytest>=7.0
# Опционально (только для лабораторной работы 8, MPI):
# Windows: установить MS-MPI (https://www.microsoft.com/en-us/download/details.aspx?id=105289),
# затем: pip install mpi4py
# mpi4py>=3.1
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Общие фикстуры и помощники для автотестов курса.
Запуск из корня курса: python -m pytest tests/ -k lab01 -v
"""
import importlib.util
import math
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
def load_solution(lab_dir: str):
"""Импортирует solution.py из папки лабораторной как отдельный модуль."""
path = ROOT / lab_dir / "solution.py"
if not path.exists():
raise FileNotFoundError(f"не найден {path}")
spec = importlib.util.spec_from_file_location(f"solution_{lab_dir}", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def close(a, b, rel=1e-6, abs_tol=1e-6):
"""Сравнение с допуском: для float-результатов (π, интеграл, checksum).
Принимает как значения, так и кортежи (value, seconds), которые
возвращают функции-заготовки студентов.
"""
if isinstance(a, tuple):
a = a[0]
if isinstance(b, tuple):
b = b[0]
if isinstance(a, int) and isinstance(b, int):
return a == b
return math.isclose(float(a), float(b), rel_tol=rel, abs_tol=abs_tol)
def source_of(lab_dir: str) -> str:
"""Исходный код solution.py — для проверки использованных механизмов."""
return (ROOT / lab_dir / "solution.py").read_text(encoding="utf-8")
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 1 (последовательное вычисление)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from conftest import close, load_solution # noqa: E402
from common.tasks import TASKS, run_sequential, smoke_params # noqa: E402
def test_variant_number_set():
mod = load_solution("lab01_sequential")
assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, (
"Укажите свой VARIANT_NUMBER (1..20) в solution.py")
def test_cpu_sequential_correct():
mod = load_solution("lab01_sequential")
# имя задачи берём из варианта студента
from common.tasks import get_variant
name = get_variant(mod.VARIANT_NUMBER)["task_name"]
sp = smoke_params(name)
# эталон: последовательный запуск из common
expected, _ = run_sequential(name, sp, parts=1)
# последовательный запуск из 3 кусков тоже обязан совпасть
expected3, _ = run_sequential(name, sp, parts=3)
got1 = mod.run_cpu_sequential(name, sp, parts=1)
got3 = mod.run_cpu_sequential(name, sp, parts=3)
assert close(got1, expected), f"checksum(parts=1)={got1}, ожидалось {expected}"
assert close(got3, expected3), (
f"checksum зависит от разбиения: parts=3 -> {got3}, parts=1 -> {expected}")
def test_cpu_sequential_returns_time():
mod = load_solution("lab01_sequential")
from common.tasks import get_variant
name = get_variant(mod.VARIANT_NUMBER)["task_name"]
res = mod.run_cpu_sequential(name, smoke_params(name), parts=2)
assert isinstance(res, tuple) and len(res) == 2, (
"run_cpu_sequential должна возвращать (checksum, секунды)")
checksum, seconds = res
assert isinstance(seconds, (int, float)) and seconds >= 0
def test_io_sequential_correct():
mod = load_solution("lab01_sequential")
results, seconds = mod.run_io_sequential(6, 0.0)
assert results == [(i, i * i) for i in range(6)]
assert seconds >= 0
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 2 (многопоточный код)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from conftest import load_solution, source_of # noqa: E402
def test_variant_number_set():
mod = load_solution("lab02_threads")
assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, (
"Укажите свой VARIANT_NUMBER (1..20) в solution.py")
def test_manual_threads_correct():
mod = load_solution("lab02_threads")
results, seconds = mod.run_io_threads_manual(8, 0.0, p=3)
assert [r[0] for r in results] == list(range(8)), (
"результаты должны быть упорядочены по item")
assert seconds >= 0
def test_manual_threads_parallel():
"""8 запросов по 0.1 c на 4 потоках должны занять ~0.2 c, не ~0.8 c."""
mod = load_solution("lab02_threads")
_, seconds = mod.run_io_threads_manual(8, 0.1, p=4)
assert seconds < 0.6, (
f"выполнено за {seconds:.2f} c — похоже, потоки работают по очереди "
"(start() до join()!)")
def test_pool_threads_correct():
mod = load_solution("lab02_threads")
results, seconds = mod.run_io_threads_pool(8, 0.0, p=3)
assert [r[0] for r in results] == list(range(8))
assert seconds >= 0
def test_pool_threads_parallel():
mod = load_solution("lab02_threads")
_, seconds = mod.run_io_threads_pool(8, 0.1, p=4)
assert seconds < 0.6, (
f"выполнено за {seconds:.2f} c — пул не даёт параллельности")
def test_uses_threading():
src = source_of("lab02_threads")
assert "threading" in src or "ThreadPoolExecutor" in src, (
"в решении должны использоваться потоки")
def test_manual_uses_thread_class():
src = source_of("lab02_threads")
assert "threading.Thread" in src, (
"run_io_threads_manual должна использовать threading.Thread")
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 3 (GIL)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from conftest import close, load_solution # noqa: E402
from common.tasks import run_sequential, smoke_params # noqa: E402
def test_variant_number_set():
mod = load_solution("lab03_gil")
assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, (
"Укажите свой VARIANT_NUMBER (1..20) в solution.py")
def test_run_task_on_threads_correct():
mod = load_solution("lab03_gil")
from common.tasks import get_variant
name = get_variant(mod.VARIANT_NUMBER)["task_name"]
sp = smoke_params(name)
expected, _ = run_sequential(name, sp, parts=1)
got, seconds = mod.run_task_on_threads(name, sp, p=4)
assert close(got, expected), f"checksum {got} != {expected}"
assert seconds >= 0
def test_run_task_on_threads_uses_pool():
src = (Path(__file__).resolve().parents[1] / "lab03_gil" / "solution.py"
).read_text(encoding="utf-8")
assert "ThreadPoolExecutor" in src or "threading.Thread" in src, (
"нужны именно потоки (ThreadPoolExecutor или Thread)")
def test_measure_cpu_threads_keys():
mod = load_solution("lab03_gil")
from common.tasks import get_variant
name = get_variant(mod.VARIANT_NUMBER)["task_name"]
sp = smoke_params(name)
res = mod.measure_cpu_threads(name, sp, [1, 2, 4])
assert sorted(res.keys()) == [1, 2, 4], f"ключи {sorted(res.keys())}"
assert all(t > 0 for t in res.values())
def test_measure_gil_release_parallel():
"""hashlib на блоках 1 МБ обязан показать S(4) > 1.4."""
mod = load_solution("lab03_gil")
res = mod.measure_gil_release([1, 4], block_size=1 << 20, blocks_per_part=8)
s4 = res[1] / res[4]
assert s4 > 1.4, (
f"S(4)={s4:.2f} — hashlib не ускорился потоками. Проверьте размер "
"блоков (нужны ≥ 256 КБ) и что общий объём делится между потоками")
def test_gil_status():
mod = load_solution("lab03_gil")
ver, gil = mod.gil_status()
assert isinstance(ver, str) and ver
assert isinstance(gil, bool)
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 4 (multiprocessing).
Важная особенность: тесты запускают solution.py как ПОДПРОЦЕСС с переменной
окружения LAB_SMOKE=1 — это одновременно проверяет и корректность, и
Windows-совместимость (наличие if __name__ == "__main__").
"""
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
LAB_DIR = "lab04_multiprocessing"
def run_smoke():
"""Запускает solution.py в подпроцессе в smoke-режиме, возвращает JSON."""
env = dict(os.environ)
env["LAB_SMOKE"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, str(ROOT / LAB_DIR / "solution.py")],
capture_output=True, text=True, env=env, timeout=120,
cwd=str(ROOT))
if proc.returncode != 0:
raise AssertionError(
f"solution.py упал:\n--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}")
# smoke-режим печатает одну строку JSON с префиксом LAB_SMOKE_JSON:
for line in proc.stdout.splitlines():
if line.startswith("LAB_SMOKE_JSON:"):
return json.loads(line[len("LAB_SMOKE_JSON:"):])
raise AssertionError(
f"в smoke-режиме не найден LAB_SMOKE_JSON. stdout:\n{proc.stdout}")
def test_smoke_processes_work():
"""Запуск на процессах из подпроцесса: checksum корректен, p>1 отработал."""
out = run_smoke()
assert out.get("ok") is True, f"smoke-отчёт: {out}"
assert out.get("result_ok") is True, (
"checksum параллельного запуска не совпал с последовательным")
assert out.get("p") and out["p"] >= 2, "smoke должен запускаться при p>=2"
def test_smoke_overhead_measured():
out = run_smoke()
ov = out.get("overhead", {})
assert len(ov) >= 2, "measure_overhead должна вернуть замеры минимум для 2 размеров"
assert all(t > 0 for t in ov.values())
def test_uses_dispatch_kernel():
src = (ROOT / LAB_DIR / "solution.py").read_text(encoding="utf-8")
assert "dispatch_kernel" in src, (
"для Pool нужен пиккелируемый dispatch_kernel из common.tasks")
assert 'if __name__ == "__main__"' in src, (
"обязательна защита if __name__ == '__main__' (Windows/spawn)")
+65
View File
@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 5 (синхронизация)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from conftest import load_solution # noqa: E402
def test_variant_number_set():
mod = load_solution("lab05_sync")
assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, (
"Укажите свой VARIANT_NUMBER (1..20) в solution.py")
def test_race_fixed_exact():
"""С Lock результат точен при любых запусках."""
mod = load_solution("lab05_sync")
for _ in range(3):
got, expected = mod.race_fixed(6, 10_000)
assert got == expected, (
f"race_fixed потерял обновления: {got} из {expected} — "
"Lock не защищает счётчик")
def test_race_demo_type():
mod = load_solution("lab05_sync")
got, expected = mod.race_demo(4, 5_000)
assert isinstance(got, int) and isinstance(expected, int)
assert expected == 20_000
assert 0 < got <= expected
def test_semaphore_limits():
"""max_concurrent=2 на 10 запросах по 0.1 c должно занять ~0.5 c (не ~0.1)."""
mod = load_solution("lab05_sync")
res, t = mod.semaphore_limiter(10, 0.1, 2)
assert len(res) == 10
assert t >= 0.45, (
f"выполнено за {t:.2f} c — лимит {2} одновременных не соблюдается")
# и параллельность работает: на limiter=10 было бы ~0.1 c
res2, t2 = mod.semaphore_limiter(10, 0.1, 10)
assert t2 < t * 0.6, "при большем лимите время должно заметно упасть"
def test_producer_consumer_no_deadlock():
mod = load_solution("lab05_sync")
import signal
def handler(signum, frame):
raise TimeoutError("producer_consumer не завершился — дедлок!")
try:
signal.signal(signal.SIGALRM, handler)
signal.alarm(30)
processed, total_sum = mod.producer_consumer(2, 3, 30)
signal.alarm(0)
except (AttributeError, ValueError):
# Windows: SIGALRM нет — просто вызываем
processed, total_sum = mod.producer_consumer(2, 3, 30)
assert processed == 30, f"обработано {processed} из 30 — задачи потерялись"
assert total_sum == sum(range(30)), (
f"сумма {total_sum} неверна — данные искажены")
+54
View File
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 6 (asyncio)."""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from conftest import load_solution, source_of # noqa: E402
LAB = "lab06_asyncio"
def test_variant_number_set():
mod = load_solution(LAB)
assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, (
"Укажите свой VARIANT_NUMBER (1..20) в solution.py")
def test_fetch_all_async_correct_and_concurrent():
mod = load_solution(LAB)
results, seconds = asyncio.run(mod.fetch_all_async(8, 0.1))
assert results == [(i, i * i) for i in range(8)], (
"результаты должны быть (item, item*item) и упорядочены по item")
assert seconds < 0.5, (
f"8 запросов по 0.1 c заняли {seconds:.2f} c — конкурентности нет; "
"проверьте, что используется asyncio.sleep и await")
def test_fetch_all_limited_respects_limit():
mod = load_solution(LAB)
results, seconds = asyncio.run(mod.fetch_all_limited(10, 0.1, 2))
assert results == [(i, i * i) for i in range(10)]
assert seconds >= 0.45, (
f"10 запросов по 0.1 c при лимите 2 заняли {seconds:.2f} c "
"— лимит не соблюдается (должно быть ~0.5 c)")
# и без лимита было бы ~0.1 c
_, t_all = asyncio.run(mod.fetch_all_limited(10, 0.1, 10))
assert t_all < seconds * 0.5, "при большем лимите время должно заметно упасть"
def test_blocking_mistake_is_sequential():
"""time.sleep в корутине должен дать время ≈ items*delay."""
mod = load_solution(LAB)
seconds = mod.measure_blocking_mistake(5, 0.1)
assert seconds >= 0.45, (
f"заняло {seconds:.2f} c вместо ~0.5 c — time.sleep внезапно "
"конкурентен? Проверьте, что в _fetch_blocking именно time.sleep")
def test_uses_async_syntax():
src = source_of(LAB)
assert "async def" in src, "нужны корутины (async def)"
assert "asyncio.gather" in src, "нужен asyncio.gather"
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 7 (сводная).
solution.py запускается подпроцессом с LAB_SMOKE=1 (как в лабе 4),
т.к. в решении используется multiprocessing.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
LAB_DIR = "lab07_comparison"
def run_smoke():
env = dict(os.environ)
env["LAB_SMOKE"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, str(ROOT / LAB_DIR / "solution.py")],
capture_output=True, text=True, env=env, timeout=120,
cwd=str(ROOT))
if proc.returncode != 0:
raise AssertionError(
f"solution.py упал:\n--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}")
for line in proc.stdout.splitlines():
if line.startswith("LAB_SMOKE_JSON:"):
return json.loads(line[len("LAB_SMOKE_JSON:"):])
raise AssertionError(f"нет LAB_SMOKE_JSON. stdout:\n{proc.stdout}")
def test_smoke_all_approaches_work():
out = run_smoke()
assert out.get("ok") is True
assert out.get("cpu_ok") is True, (
"CPU-подходы дают неверный checksum (потоки и/или процессы)")
assert out.get("io_ok") is True, "I/O-подходы вернули неверный объём результатов"
def test_smoke_io_times_present():
out = run_smoke()
times = out.get("io_times", {})
assert all(k in times for k in ("seq", "threads", "async")), times
assert all(t >= 0 for t in times.values())
def test_uses_all_four_models():
src = (ROOT / LAB_DIR / "solution.py").read_text(encoding="utf-8")
assert "ThreadPoolExecutor" in src, "нужны потоки"
assert "Pool" in src or "ProcessPoolExecutor" in src, "нужны процессы"
assert "asyncio" in src and "gather" in src, "нужен asyncio"
assert 'if __name__ == "__main__"' in src, "нужен __main__-guard"
+51
View File
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""Автотесты лабораторной 8 (MPI).
Если mpi4py не установлен — тесты пропускаются (SKIPPED), но сама работа
сдаётся с запуском через mpiexec -n 2/4 (проверяет преподаватель).
Тесты выполняются в режиме ОДНОГО ранга (без mpiexec): в этом режиме
scatter/gather работают тривиально, но корректность схемы и checksum
проверяются. Многоранговую корректность гарантирует эталон и проверка
преподавателем через mpiexec.
"""
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
mpi4py = pytest.importorskip("mpi4py", reason="mpi4py не установлен")
from conftest import close, load_solution # noqa: E402
from common.tasks import run_sequential, smoke_params # noqa: E402
LAB = "lab08_mpi"
def test_variant_number_set():
mod = load_solution(LAB)
assert 1 <= getattr(mod, "VARIANT_NUMBER", 0) <= 20, (
"Укажите свой VARIANT_NUMBER (1..20) в solution.py")
def test_mpi_run_single_rank():
"""Один ранг: checksum должен совпадать с последовательным запуском."""
mod = load_solution(LAB)
from common.tasks import get_variant
name = get_variant(mod.VARIANT_NUMBER)["task_name"]
sp = smoke_params(name)
expected, _ = run_sequential(name, sp, parts=1)
got = mod.mpi_run(name, sp)
assert got is not None, "mpi_run должна вернуть checksum (на любом ранге)"
assert close(got, expected), f"checksum {got} != {expected}"
def test_mpi_pi():
mod = load_solution(LAB)
pi = mod.mpi_pi(100_000)
assert 3.0 < pi < 3.3, f"pi = {pi} — вне допустимого диапазона для 100k бросков"