Когда создаешь новое приложение, особенно если оно должно быстро обрабатывать данные, использование библиотеки asyncio — это хороший выбор. Она позволяет работать с неблокирующими библиотеками, asyncpg и aiohttp. Однако чаще всего программисты работают с уже существующим кодом, который использует блокирующие библиотеки. Поэтому большую часть времени может занять адаптация и модернизация старого кода, так как асинхронный код не дружит с синхронным (им мешает GIL). GIL (Global Interpreter Lock) — это механизм, который предотвращает одновременное выполнение нескольких потоков в Python. Это означает, что даже если у вас есть многопоточное приложение, только один поток может выполнять Python-код в любой момент времени. Поэтому можно запускать дополнительный поток для выполнения операции ввода-вывода.
Погружение в суть
В Python для работы с потоками (параллельным выполнением задач) используется модуль threading
. Он позволяет запускать несколько задач одновременно, что может быть полезно, когда нужно выполнять операции ввода-вывода, такие, как чтение или запись файлов. Python работает с одним потоком в каждый момент времени из-за глобальной блокировки (GIL). Это значит, что только один участок кода может выполняться одновременно. Однако во время операций ввода-вывода GIL может быть освобождён, и это позволяет использовать многопоточность более эффективно. Давайте рассмотрим простой пример, где мы создадим несколько потоков и посмотрим, как работает Thread.
from threading import Thread
import time
def synchronous_code():
time.sleep(2) # Имитация задержки синхронного кода
def main():
start = time.time()
# Создание потоков
threads = [Thread(target=synchronous_code) for _ in range(10)]
# Запуск потоков
for thread in threads:
thread.start()
# Ожидание завершения всех потоков
for thread in threads:
thread.join()
end = time.time()
print(f'Выполнение запросов завершено за {end - start:.4f} с')
if __name__ == "__main__":
main()
Код завершается за 2 секунды, так как мы запускаем два параллельных потока. Конечно, не очень удобно использовать for для создания, запуска и завершения потоков, поэтому можно использовать пул потоков.
from concurrent.futures import ThreadPoolExecutor, wait
import time
def synchronous_code():
time.sleep(2) # Имитация задержки в 2 секунды, например, для выполнения какого-то запроса
def main():
start = time.time()
with ThreadPoolExecutor() as executor: # Создаем пул потоков
# Отправляем 10 задач на выполнение в пул потоков, каждая задача вызывает функцию
synchronous_code
futures = [executor.submit(synchronous_code) for _ in range(10)]
# Ожидаем завершения всех задач
wait(futures)
end = time.time()
print(f'Выполнение запросов завершено за {end - start:.4f} с')
if __name__ == "__main__":
main()
Этот код тоже завершился за 2 секунды, но если мы создадим не 10 задач, а 17 задач, то код будет выполнен за 4 секунды, но если выполнить 16 задач, то код по-прежнему будет выполнен за 2 секунды. А почему так? В python 3.8 значение max_workers
по умолчанию равно min (32, os.cpu_count() + 4). Это значение сохраняет не менее 5 рабочих потоков для задач, связанных с вводом/выводом. Оно использует не более 32 ядер процессора для задач, связанных с процессором, которые освобождают GIL. Это позволяет избежать неявного использования очень больших ресурсов на многоядерных машинах. Так, например, у меня 12 логических ядер + 4 по формуле. В итоге в пуле 16 потоков.
А что будет, если…?
Что будет, если потоки будут ломиться к глобальной переменной? А ничего хорошего не будет. Мы погрузимся в состояние гонки. Это ситуация, когда два или более потоков (или процессов) одновременно пытаются получить доступ к общему ресурсу, и результат зависит от того, в каком порядке они выполняются. Это может привести к непредсказуемым результатам и ошибкам.
from concurrent.futures import ThreadPoolExecutor, wait
import time
help_me = 0
def synchronous_code():
global help_me
for _ in range(100):
current_value = help_me
time.sleep(0.0001)
help_me = current_value + 1
def main():
global help_me
with ThreadPoolExecutor() as executor:
futures = [executor.submit(synchronous_code) for _ in range(10)]
wait(futures)
print(f"Фактическое значение: {help_me}")
if __name__ == "__main__":
main()
Результат всегда будет не предсказуемый. В этом коде он будет +- 100. Небольшая задержка в коде (time.sleep(0.0001))
добавлена с целью увеличить вероятность гонок данных между потоками. Без задержки потоки могут выполняться слишком быстро, и результат может быть более предсказуемым (например, все потоки могут завершиться до того, как другие потоки успеют начать работу). Задержка позволяет потокам "пересекаться" и взаимодействовать друг с другом, что создает условия для гонок данных. Состояние гонки можно избежать блокировкой.
Избегаем гонки
Многопоточный код подвержен состоянию гонки при использовании разделяемых данных, потому что управлять порядком выполнения потоков мы не можем. Всякий раз, когда два потока или процесса потенциально могут изменить разделяемый элемент данных, следует использовать блокировку для синхронизации доступа (да, да, такой парадокс, чтобы синхронный код интегрировать в асинхронный, нужно синхронизировать многопоточный код).
Для этого нам понадобится реализация класса Lock
в модуле threading
. Нужно лишь импортировать Lock
из threading
и окружить критические секции вызовами with lock
.
from concurrent.futures import ThreadPoolExecutor, wait
import time
import threading
help_me = 0
lock = threading.Lock() # Создаем блокировку
def synchronous_code():
global help_me
for _ in range(100):
with lock: # Захватываем блокировку перед изменением переменной
current_value = help_me
time.sleep(0.0001)
help_me = current_value + 1
def main():
global help_me
with ThreadPoolExecutor() as executor:
futures = [executor.submit(synchronous_code) for _ in range(10)]
wait(futures)
print(f"Фактическое значение: {help_me}")
if __name__ == "__main__": # Исправлено на правильное имя
main()
Создаем неблокирующий REST API
Парадигма REST широко используется в современной разработке веб-приложений. REST API пригоден для взаимодействия с любыми клиентами: от мобильного телефона до браузера, нужно лишь изменить представление данных на стороне клиента. При проектировании REST API мы будем возвращать данные в формате JSON, поскольку так чаще всего и делают, но в принципе можно выбрать любой формат, отвечающий конкретным потребностям. Напишем на Aiohttp свой простой api:
from aiohttp import web
from aiohttp.web_response import Response
from aiohttp.web_request import Request
import random
routes = web.RouteTableDef()
@routes.get('/random')
async def handle(request: Response) -> Request:
result = random.randint(1, 1000)
return web.json_response(data={'random_values': result})
if __name__ == '__main__':
app = web.Application()
app.add_routes(routes)
web.run_app(app, host='127.0.0.1', port=8000)
Тут более-менее все очевидно, создаём роутер, выдаём случайное число и отправляем json
формат. Допустим, у нас есть синхронная функция, которая блокирует наш поток.(Возможно это будет взаимодействие с БД).
def blocking_task():
time.sleep(1)
value = random.randint(1, 1000)
return value
Ситуация не из приятных. Функция выполняется 1 секунду. Это очень долго и если 100 пользователей решат одновременно достучаться до нашего API, то самый последний запрос будет выполняться долго. Тут нам помогает ThreadPoolExecutor
. Это удобный инструмент из модуля concurrent.futures
, который позволяет легко управлять пулом потоков для выполнения задач параллельно. Он особенно полезен, когда необходимо выполнять множество операций ввода-вывода или сетевых запросов, которые могут блокировать выполнение программы.
def blocking_task():
# Эта функция выполняет блокирующую задачу, которая занимает 1 секунду
time.sleep(1)
value = random.randint(1, 1000)
return value
async def run_in_executor(executor):
# Получаем текущий цикл событий
loop = asyncio.get_event_loop()
# Запускаем блокирующую задачу в пуле потоков и ждем ее завершения
result = await loop.run_in_executor(executor, blocking_task)
return result
@routes.get('/random')
async def handle(request: Response) -> Request:
# Запускаем блокирующую задачу и ждем результат
result = await run_in_executor(executor)
return web.json_response(data={'random_values': result})
В нашу программу мы добавили одну функцию run_in_executor
. Что под капотом этой функции?
loop = asyncio.get_event_loop()
Здесь мы получаем текущий цикл событий. Цикл событий управляет выполнением асинхронных задач и обработкой событий.
result = await loop.run_in_executor(executor, blocking_task)
loop.run_in_executor(executor, blocking_task)
принимает два аргумента:
- executor
— это объект ThreadPoolExecutor, который управляет пулом потоков.
- blocking_task
— это функция, которую мы хотим выполнить в отдельном потоке.
Функция run_in_executor
запускает blocking_task
в одном из потоков пула и возвращает результат выполнения этой функции.
Давайте посмотрим еще один пример, чтобы продемонстрировать, как можно обрабатывать несколько параллельных запросов, не блокируя основной поток. В этом примере мы создадим API, который будет выполнять несколько блокирующих задач одновременно, используя asyncio.gather()
. Функция asyncio.gather
позволяет группировать объекты, допускающие ожидание (то есть async функции). Эти объекты после группировки можно запустить в конкурентном режиме.
Предположим, у нам нужно вызвать синхронную функцию несколько раз и мы хотим вернуть результаты всех этих операций в одном ответе.
@routes.get('/random')
async def handle_batch(request: Request) -> Response:
with ThreadPoolExecutor() as executor:
# Создаем список задач
tasks = [run_in_executor(executor) for _ in range(3)]
# Запускаем все задачи параллельно и ждем их завершения
results = await asyncio.gather(*tasks)
return web.json_response(data={'random_values_1': results[0],
'random_values_2': results[1],
'random_values_3': results[2],
})
Также можно вызывать несколько разных синхронных функций.
def blocking_task():
time.sleep(.5)
value = random.randint(1, 1000)
return value
def blocking_task_1():
time.sleep(1)
value = random.randint(1000, 2000)
return value
async def run_in_executor_1(executor):
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, blocking_task)
return result
async def run_in_executor_2(executor):
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(executor, blocking_task_1)
return result
async def run_in_executor_main(executor):
values = await asyncio.gather(
run_in_executor_1(executor),
run_in_executor_2(executor)
)
return {values[0] : values[1]}
@routes.get('/random')
async def handle_batch(request: Request) -> Response:
with ThreadPoolExecutor() as executor:
tasks = [run_in_executor_main(executor) for _ in range(3)]
results = await asyncio.gather(*tasks)
return web.json_response(data={'random_values_1': results[0],
'random_values_2': results[1],
'random_values_3': results[2],
})
Наш код выглядит менее привлекательным, но он результативнее! Используя asyncio.gather()
, мы можем эффективно обрабатывать несколько параллельных запросов к нашему API без блокировки основного потока. Это позволяет значительно повысить производительность приложения, особенно при наличии множества блокирующих операций. Теперь наш REST API способен обрабатывать множество запросов одновременно, что делает его более отзывчивым и эффективным.
Итоги
Использование ThreadPoolExecutor
для запуска синхронных функций в асинхронном коде открывает новые горизонты для разработки высокопроизводительных приложений. Мы рассмотрели ключевые аспекты, начиная с основ работы ThreadPoolExecutor
. Это позволяет эффективно управлять потоками и ресурсами. Также мы обсудили, как можно предотвратить состояния гонки, что критически важно для обеспечения целостности данных и стабильности приложения. Создание неблокирующего REST API демонстрирует, как можно интегрировать синхронные операции в асинхронную архитектуру, обеспечивая при этом отзывчивость и масштабируемость. Применяя эти принципы на практике, вы сможете улучшить производительность ваших приложений и создать более надежные решения для пользователей.
Автор статьи: Алексей Любимов
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
Комментарии (24)
sergey_prokofiev
08.10.2024 10:10+1Как перед написанием статьи разобраться с тем, что такое асинхронность и многопоточность.
TIEugene
08.10.2024 10:10+1Статью копипастил маркетолог. Не царское это дело - разбираться в предмете статьи.
sergey_prokofiev
08.10.2024 10:10шах и мат, сдаюсь :)
TIEugene
08.10.2024 10:10Шутки шутками, но это реально проблема.
Недавно на очередном собесе вопрос: "Чем отличаются multiprocess, multithreading и async?"
А я как собака - знать знаю, а сказать не могу.
Рожал пару часов определения (на суд зрителей):
- multiprocess (много...что?) - вытесняющая многозадачность в пределах ОС. Одна задача - один процесс. Применительно к питону - один процесс питона с кодом.
- multithreading (многопоточность) - многозадачность в пределах одного процесса. Применительно к питону - один процесс питона и много потоков по одному потоку на задачу. С GIL - кооперативная многозадачность, без GIL - вытесняющая.
- async - кооперативная многозадачность в пределах одного процесса и одного потока. Последнее - но это не точно.
PS. кооперативная == конкурентнаяosmanpasha
08.10.2024 10:10GIL - кооперативная многозадачность, без GIL - вытесняющая.
Ну я бы не сказал, что с GIL многозадачность кооперативная. В обоих случаях переключение потоков выполняется планировщиком ОС, а он вытесняющий. Просто все потоки используют один блокируемый разделяемый ресурс, то бишь весь интерпретатор. Функции типа time.sleep перед началом отпускают GIL, поэтому отлично выполняются параллельно даже с GIL. Правильно написанные нативные библиотеки для CPU-bound задач (numpy) тоже отпускают GIL, так что они тоже работают параллельно даже с GIL.
TIEugene
08.10.2024 10:10Ну, я писал слишком фкрации, упуская ряд моментов:
1. да, вызов glibc отпускает GIL. НО - это не код именно питона.
2. А касабельно именно байт-кода питона, то сколько бы threads не было, в один момент времени обрабатывается одна команда именно питона (с включенным GIL). Вне зависимости от кол-ва ядер. Поэтому для именно питона multithread таки кооперативная многозадачность (параллельного выполнения не будет).
Пруфов не будет, это выжимка из того, что начитался и экспериментировал.
Поправьте, если ошибаюсь.osmanpasha
08.10.2024 10:10Ну по существу я согласен, что параллельного выполнения не будет, но по формулировке и терминологии - нет. Кооперативность/вытесняемость - это про то, как переключаются задачи, а не про возможность/невозможность параллельного исполнения. Блокирумые объекты можно захватывать при любом способе переключения задач, и исполнение не становится внезапно кооперативным. Более того, с точки зрения питон-программиста threading в любом случае вытесняющий - из питона нельзя захватить GIL так, что другие потоки не смогут исполняться, переключение в любом случае будет (раньше переключение происходило по счетчику опкодов, начиная с 3.2 - через заданные промежутки времени).
да, вызов glibc отпускает GIL. НО - это не код именно питона.
Не понял, причем тут glibc, но если вы имеете в виду код на питоне, то да, понятно, что изнутри интепретатора питона нельзя отпустить блокировку интерпретатора, можно только из C-кода, используя питоновое C API (
Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS
). Если вы вызываете glibc через ctypes, то ctypes добавляет такую обертку.TIEugene
08.10.2024 10:10Ok, давайте так.
Надо различать multiprocess/multithread/async с кочки зрения ОС и с кочки зрения питон-машины.
Ну и зачем нужно - разнести питон-задачи по ядрам.
Давайте с кочки зрения питон-машины.
- multiprocess - здесь питон-машина вообще ни при чем. Это на уровне ОС.
- mutithread - здесь интересное. По существу это multiprocess в рамках питон-машины. Обращение к системным i/o помогает. А вот собственно байт-код питона во всех потоках - в одну очередь (ибо GIL). То есть тут и вытесняющая и кооперативная многозадачность в одном флаконе.
С выходом 3.13 появились варианты, здесь надо тыкать палочкой.
- async - теоретически... я вот фиг его знает. Такое впечатление, что multithread наоборот. Ну или костыль.osmanpasha
08.10.2024 10:10То есть тут и вытесняющая и кооперативная многозадачность в одном флаконе
Может, у нас разные определния кооперативности? Потому что я все еще не вижу ее тут.
Вот в async-подходе кооперативность есть - пока интерпретатор не дойдет до
await
, переключения на другую корутину не произойдет, т.е. корутина управляет тем, когда с нее можно переключиться. При многопотоковости интерпретатор будет переключаться между потоками, даже если программист запустит в 10 потоках бесконечные циклы (даже если в них не будет блокирующих функций типа time.sleep).async - теоретически... я вот фиг его знает. Такое впечатление, что multithread наоборот. Ну или костыль.
Напишу свое видение. Процесс - один, поток ОС - один. Внутри потока крутится написанный на питоне планировщик задач, он же eventloop, который переключается между задачами-корутинами. Поскольку целью было уменьшить накладные расходы на переключение и хранение состояния, корутины получились кооперативные - переключаться можно только когда корутина разрешит, и в этот момент записывается состояние, к которому можно потом вернуться.
Andrey_Solomatin
08.10.2024 10:10Я бы сказал, что надо по слоям разложить.
Если вы в Питоне используете async-await это кооперативная многозадачнасть, реальзованная в коде.
Этот код выполняется в виртульной машине Питона, в которой есть свой планировщик задач. Там вытесняющая многозадачность.
Виртульная машина выполняется в операционной системе, там тоже свой планироващик и он тоже вытесняющий.
Всё это работает одновременно.
osmanpasha
08.10.2024 10:10Этот код выполняется в виртульной машине Питона, в которой есть свой планировщик задач. Там вытесняющая многозадачность.
А это что за слой такой? Есть планировщик задач ОС (переключает процессы ОС и потоки ОС), есть async/await eventloop, например от asyncio (переключает корутины, выполняется в одном потоке ОС). Между ними нет никакого другого планировщика.
Наличие какого-то еще одного планировщика означало бы, что есть какие-то еще сущности, которые надо переключать, особые питоновские потоки, которые не являются потоками ОС. Но таких сущностей нет, потоки питона - это потоки ОС.
Andrey_Solomatin
08.10.2024 10:10Наличие какого-то еще одного планировщика означало бы, что есть какие-то еще сущности, которые надо переключать, особые питоновские потоки, которые не являются потоками ОС. Но таких сущностей нет, потоки питона - это потоки ОС.
Потоки системные, а планировщик в виртуальной машине есть свой. Его даже можно настраивать.
https://docs.python.org/3/library/sys.html#sys.setswitchinterval
sergey_prokofiev
08.10.2024 10:10мне, как старперу было проще и я застал ассемблер с концепцией прерываний. Как сейчас помню 21 прерывание с разными флагами :)
Тоесть в архитектуре харда было заложено некое прерывания чего то зачем то. И поняв, что же и зачем прерывалось, очень просто дойти до концепции асинхронности - это тоже самое, но на другом уровне.
И тут же рядом возникает вопрос мультипоточности: если что то зачем то прервалось( то есть ждет), то может это самое можно загрузить? А если можно загрузить, то как шарить данные между этими самыми загрузками того что ждет ответа от непонятно чего. Привет виндовз 3.11, там концепцию очень хорошо понимали, но реализовали как получилось. Сори за сарказм, но стартовая статья просто обязывает к этому.
TIEugene
08.10.2024 10:10Как старпер старперу:
- таки да, прерывания DOS это была многозадачность. Для некоторых прерываний (аппаратных) - вытесняющая. Да-да, DOS, вытесняющая многозадачность. О параллельном выполнении на многих ядрах тогда речь не шла.
- я больше скажу - оформить вытесняющую многозадачность можно было даже на i8080. Таймер вешается на NMI - и вуаля.
- но питон с этим своим GIL - это отдельная тема. Глобальный мютекс.sergey_prokofiev
08.10.2024 10:10О параллельном выполнении на многих ядрах тогда речь не шла.
Сложно говорить о выполнении задач на многих ядрах, когда ядро в принципе одно и второе не предвидится в ближайшие годы.
прерывания DOS это была многозадачность.
И да и нет. Я таки повторю вопрос: что и зачем прерывалось. Ответив на этот нехитрый вопрос, все остальное станет очевидно.
- но питон с этим своим GIL - это отдельная тема. Глобальный мютекс.
В питоне придумали как вызвать обьект ядра ОС мутекс, который есть во всех POSIX-совместимых системах? Неужели? И главное - раньше то никто не знал про мутексы и семафоры!
TIEugene
08.10.2024 10:10В питоне придумали как вызвать обьект ядра ОС мутекс, который есть во всех POSIX-совместимых системах? Неужели? И главное - раньше то никто не знал про мутексы и семафоры!
Не. Python - это компьютер в компьютере. Как и эти ваши JavaScript, Java/Kotlin (JVM), PHP, C# и всё такое.
И GIL питона - это НЕ мютекс ядра ОС.sergey_prokofiev
08.10.2024 10:10Тоесть сделали свою какую то "уникальную" прокладку - и ок.
TIEugene
08.10.2024 10:10"Прокладка" в смысле интерпретатора?
Ну не сильно и уникальная.
Любой интертрепатор - это компьютер в компьютере.
TIEugene
08.10.2024 10:10Нет, ну опосредованно код питона превращается в машинный.
Или постепенно (классика, интертрепатор) или оптом (JIT, только-что завезли).
Но это уже тюнинг. Расчитывать надо на "питон-машину".sergey_prokofiev
08.10.2024 10:10JIT, только-что завезли
и эти люди дотнет критиковали что там JIT лет 20 работает не так :)
Ну да ок, напишу развернуто про асинхронность-многопоточностсть-многопроцессность.
Для понимая мне думается имеет смысл откатиться лет на 50-70 назад и потом осмысливать концепции с эволюцией харда и софта.
----
В 70е годы большинство пользовательских компов имели одноядерный проц. Он, умел(и до сих пор многоядерные процы умеют)очень быстро изменять данные в ОЗУ/регистрах процессора - большинство команд ассеблера как раз про то чтобы взять какие то данные из ОЗУ(возможно положить в реестры) и изменить их, шина данных между процом и ОЗУ всегда была и есть самой быстродейственной среди всех остальных шин данных. Но неожиданно оказалось, что необходимо не только изменить данные в ОЗУ, но и еще принимать/передавать данные в/из так называемые устройства ввода-вывода(IO ports), которые включают в себя монитор, винт, клавиатуру, мышу, сетевую карту и так далее. И тут внезапно, вся эта обвеска работает на порядки медленей чем проц/память - как было в 70е так и есть до сих пор. И наш одноядерный проц выдает команду на это самое устройство ввода/вывода и потом.... ничего не делает, то есть ждет ответа от медленного устройства. Вот команда "скоммуницируй с медленным устройстов ввода/вывода" и назысается прерыванием, потому что она буквально прерывает работу процессора по непрерывному изменению данных в ОЗУ и ждет пока внешнеее устройство отдуплит. Это неэффективно, время ожидания можно занять чем-то другим, тоесть выполнением процессором других потоков команд, а когда внешнее устройство отдуплится то и можно продолжать выполнять предыдущий.----
Тут же возникает концепция "пула процессов", которые становятся в очередь ожидания IO операций и попутно оказалось что можно пулом процессов решать не только проблему асинхронности, но об этом ниже.
Тоесть синтаксический сахар в виде async/await предназачен для эффективного использования CPU "пока внешние устройства дуплят" и точка.
Почти.
скобках отметим что тут нет никакой синхронизации данных потому что... нечего синхронизировать. Каждый процесс работает со своими данными.
----
Затем прошло еще много лет и процессоры стали многоядерными - тоесть они могут выполнять несколько потоков команд одновременно. Каждый поток команд работает со своими данными и называтся процессом. А если в рамках одго процесса есть несколько потоков команд, которые шарят общие данные - то их так и назвали "потоки".
Максимально эффективная утилизация CPU - это "процесс на ядро", но процессы опять таки взаимодействуют с IO устройствами и некоторые ядра процессора могут простаивать в ожидании медленных внешних устройств. Это нехорошо, но пул процессов придумали раньше и почему бы не расширить эту концепцию для многоядерного железа. Расширили. И да никаких блокировок тут не нужно, потому что у каждого процесса свои данные.
----
Гдето в 50е года прошлого века появились алгоритмы, которые обращаются к одним и тем же данным параллельно в рамках одого процесса, тоесть несколькими потоками. Но пока процессоры были одноядерные, то эти алгоритмы были не очень актуальны - один проц, один процесс в одну единицу времени, ок с оптимизацией IO операций. Но с появлением многоядерных процов, проблема доступа к конкуррентым данным стала ппц насколько актуальной. И появились так называемые "примитивы синхронизации", тоесть некоторые механизмы, которые преднназначены для упорядоченного чтения/записи данных в общую область памяти. Но появилась проблемка: никто не парится тем чтобы создавать количество потоков, адекватное количество ядер компа. Если задачу проще решить созданием тысячи потоков... дык почему бы не создать тысячу. Или 10 тыщ. И тут оказалось, что уже есть пул потоков для IO операций. Почему бы его не зареюзать для могопоточности? И да, зареюзали. И да, отсюда вот такие статьи как выше. Несмотря на своершенно разные концепции - просто удобный инструмент дял решения совершенно разных проблем. И усе. Спасибо что прочитали.
----
Если камент наберет 10 лайков, сделаю из него статью, таки лонг райт получился.
Andrey_Solomatin
08.10.2024 10:10Недавно на очередном собесе вопрос: "Чем отличаются multiprocess, multithreading и async?"А я как собака - знать знаю, а сказать не могу.
multiprocess vs multithreading это про память. Треды сидят в одном пространстве, а процессы в своих изолированных.
Треды и процессы могут как IO-bound так и CPU-bound задачи, хотя есть нюансы с GIL. Асинхронность она только для IO-bound.
Накладные расходы больше всего у процессов, меньше у тредов и совсем немного для асинхронных.
Ваш бы ответ не принял, особенно за то, что асинхронность одно-поточная. Но я такой вопрос задавать на собеседовании стал бы только если у человека есть опыт в этом направлении. Тема не самая простая и без желания в ней глубоко разобраться или реальной практики, вряд ли услышите что-то разумное.
Andrey_Solomatin
Нет. Использование CPU bound задач через асинхронные функции на пуле потоков, будет равноценно использованию этих же функций синхронно на пуле потоков. Асинхронность не принесёт никаких плюсов, только сложность.
Стратегия использовать несколько пулов потоков для разных задач нормальная. Так например сделанны десктопные приложения: один поток рисует, другие обрабатывают кнопочки.
Как только количество запросов дорастёт до executor.max_workers всё встанет.
А возможно еще раньше, так как вы используете количество тредов по умолчанию и можете получить больше тредов чем ядер на машине `min(32, os.cpu_count() + 4)`. Тогда GIL начнет влиять и на главный поток.
wesp1nz
По-моему, сервер может спокойно обрабатывать запросов больше, чем min(32, os.cpu_count() + 4)
Andrey_Solomatin
Да, если задачи не используют процессов, а больше времени порводят ождании, то тогда норм. Значение по умолчанию примерно на такое и расчитаны.
Проблемы будут только если это задачи на процессор. И с GIL и без него (PEP 703).