Files
2026-10-07 13:06:47 +05:00

7.2 KiB
Raw Permalink Blame History

Лабораторная работа 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 — пул потоков и высокоуровневый интерфейс:

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 — функция вызвалась в главном потоке.