Когда 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.