121 lines
7.2 KiB
Markdown
121 lines
7.2 KiB
Markdown
# Лабораторная работа 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` — функция вызвалась в главном потоке.
|