Как выбрать между multithreading, multiprocessing и asyncio для скачивания большого количества файлов в Python
Что проверяют
Понимаете ли вы, где в задаче узкое место: скачивание упирается в сеть и ожидание ответа, а не в процессор. Следом обычно спрашивают, как ограничить число одновременных запросов и что делать, если после скачивания файлы нужно ещё парсить.
Как отвечать
Начните с классификации: загрузка файлов это I/O-bound задача. GIL в CPython отпускается на время сетевого ввода-вывода, поэтому потоки здесь действительно работают параллельно.
- asyncio: лучший выбор для тысяч файлов. Один поток, корутины на
aiohttpилиhttpx.AsyncClient, лимит конкурентности черезasyncio.Semaphoreили пул соединений клиента. Задачи удобно собирать вasyncio.TaskGroup(Python 3.11+). Пишите на диск кусками черезiter_chunked, не держите файл целиком в памяти; - потоки:
concurrent.futures.ThreadPoolExecutorс обычнымrequests. Проще, если код уже синхронный или библиотека не умеет async. Десятки потоков нормально, тысячи уже дороги по памяти и переключениям; - процессы:
ProcessPoolExecutorдля скачивания избыточен. Каждый процесс стоит памяти и времени на старт, а ждёте вы всё равно сеть. Он оправдан для CPU-части: распаковка, парсинг, ресайз картинок, подсчёт признаков.
Хороший ответ для data-ml звучит так: качаем через asyncio с семафором примерно на 50 соединений, ретраи с экспоненциальной задержкой, а тяжёлую обработку отдаём в пул процессов через loop.run_in_executor. Упомяните, что реальный предел часто задаёт не Python, а пропускная способность канала, rate limit сервера и скорость диска.
Где спотыкаются
Выбирают multiprocessing «потому что GIL». Сетевому ожиданию GIL не мешает, и вы просто платите памятью за лишние процессы.
Запускают asyncio.gather на сто тысяч задач без ограничения. Сервер отвечает 429 или рвёт соединения, а у клиента заканчиваются файловые дескрипторы.
Вызывают внутри корутины синхронный requests.get или пишут большой файл обычным open. Цикл событий блокируется, и асинхронный код выполняется последовательно. Такие вызовы уводят в asyncio.to_thread.