Когда threading всё-таки полезен (I/O-bound)

GIL — не приговор: для задач, которые много ждут, потоки в Python очень даже полезны.

I/O-bound задача — та, что большую часть времени ждёт внешнего ресурса (сеть, диск, ответ сервера), а не считает на процессоре.

Главная хорошая новость: при операциях ввода-вывода CPython отпускает GIL. Пока поток ждёт ответа от сервера, GIL свободен, и другой поток успевает что-то сделать. Поэтому для I/O потоки реально перекрываются и дают ускорение.

Где это работает

Скачать 100 страниц по сети, прочитать множество файлов, опросить несколько баз данных — всюду время уходит на ожидание. Запустив задачи в потоках, вы ждёте все ответы «внахлёст», а не по очереди.

import threading, urllib.request

urls = ["https://a", "https://b", "https://c"]

def fetch(url):
    # пока ждём ответа, GIL отпущен -> другой поток работает
    data = urllib.request.urlopen(url).read()
    print(url, len(data))

threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()

Почему время складывается выгодно

Сравним последовательное и параллельное ожидание трёх запросов по 1 секунде каждый:

requests = [1.0, 1.0, 1.0]  # время ожидания каждого, сек

sequential = sum(requests)            # друг за другом
concurrent = max(requests)            # внахлёст, ограничено самым долгим

print("последовательно:", sequential, "сек")
print("конкурентно:", concurrent, "сек")

Вывод:

последовательно: 3.0 сек
конкурентно: 1.0 сек

Таймлайн I/O-потоков

Поток 1: запрос--[ждёт ответ]--готово
Поток 2:   запрос--[ждёт ответ]--готово
Поток 3:     запрос--[ждёт ответ]--готово
         ожидания перекрываются -> общее время ~= одно ожидание

Как работает под капотом

Когда поток вызывает блокирующую I/O-функцию, интерпретатор отпускает GIL и просит ОС разбудить поток, когда данные придут. В это окно GIL достаётся другому потоку. Получается, что «узкое место» — не процессор, а ожидание, и потоки прекрасно его параллелят. По той же причине C-расширения, которые отпускают GIL на время тяжёлой работы (например, numpy), тоже выигрывают от потоков.

Частые ошибки

  • Путать тип нагрузки. Для CPU-bench потоки бесполезны, для I/O — отличны. Сначала определите, чего именно ждёт код.
  • Создавать поток на каждую микрозадачу. Лучше пул потоков (разберём дальше), чтобы не плодить тысячи потоков.
  • Забывать про потокобезопасность общих данных. GIL чередует потоки, и гонки на общих переменных никуда не делись.

Итог

  • На I/O CPython отпускает GIL, и потоки реально перекрываются.
  • Для I/O-bound задач threading даёт ощутимое ускорение.
  • Общее время стремится к длительности самого долгого ожидания, а не к их сумме.
  • Тип нагрузки (CPU vs I/O) определяет, поможет ли threading.
Проверьте себя
1. Почему threading полезен для I/O-bound задач в Python?
AGIL ускоряет сеть
BВо время ожидания I/O GIL отпускается, и потоки реально перекрываются
CI/O-задачи не используют GIL никогда полностью
DПотоки превращаются в процессы
2. К чему стремится общее время трёх I/O-запросов по 1 сек, выполненных в потоках?
AК сумме — 3 секундам
BК длительности самого долгого ожидания — около 1 секунды
CК нулю
DК 9 секундам