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

+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) «плавает».