Pull to refresh

Comments 26

Не понимаю за что плюсы. Автор излагает свои поверхностные знания об асинхронном программировании, делает неверные выводы, а ведь кто-то эту статью будет использовать как учебное пособие. 

Что там с threading?

Тут особо смысла нет - практический эксперимент показал, что threading показывает такие же результаты (плюс - минус), как асихнронный код из пункта 3.

multiprocessing

Не буду врать, просто посмотрел как нечто похожее делал какой-то индус с Ютуба. Результаты сильно хуже, чем у предыдущих способов. Да и писать такой код - это насилие над своей психикой. А я свою психику берегу.

Языковые конструкции async await — это просто синтаксический сахар. Значение имеет только конкретная реализация и конфигурация под капотом этих конструкций.  

Автор использовал дефолтную реализацию в Python — на ивент лупе. Любые вычисления на CPU будут блокировать весь поток и съедать весь выигрыш на асинхронных операциях чтения из неблокируемых сокетов. Внезапно, к этим вычислениям относится даже json десериализация.

Поэтому если говорить о реальных RPS, которые измеряются тысячами, а не десятками, без мультипоточности или мультипроцессорности не обойтись. Ниакой asyncio.gather тут не поможет.

У меня искренний вопрос, а как мультипоточность может дать выигрыш над асинхронностью? Треды также будут ограничены ресурсом CPU же? Или я что-то не так понимаю?

Асинхронный цикл работает строго в одном потоке, и обслуживает одну корутину за раз. Ожидать выполнения операции ввода-вывода может несколько, но CPU потребляет только одна корутина.
В случае многопоточности гипотетически можно добиться выигрыша на многоядерных машинах (с поправкой на проблему GIL — вроде есть реализации Питона, которые ей не страдают).
В случае многопроцессности GIL уже тоже не проблема.
Т.е. если у нас почти чистый ввод-вывод — то выигрыш многопоточности будет минимален. Если есть солидная доля CPU-активности — разница будет.

Ну а совсем до кучи питон из коробки позволяет «спрятать» процесс/поток в асинхронную задачу, что здорово размывает границы между подходами.

Давайте разберёмся.  

Асинхронность — свойство операции, которое означает что время выполнения этой операции принципиально не совпадает с ходом времени вашей программы, можно сказать нелинейно. Как правило — это длительные и не дискретные операции: пользовательский ввод, календарное планирование, долгий запрос в базу данных или внешний сервис.

Многопоточность — свойство системы, которое позволяет выполнять несколько операции параллельно, то есть одновременно. В современных компьютерах это физически параллельные вычисления на разных ядрах CPU (отсюда такая гонка за количеством ядер и феноменальная производительность GPU на тензорных операциях).  

Напрямую противопоставлять эти свойства некорректно. 

Да, дефолтная реализация в Python или JavaScript использует ивент луп - то есть цикл с диспетчеризацией неблокируемых и последовательных операций. Ключевой момент здесь — последовательных. Заметное увеличение производительности происходит только за счёт того, что приложение продолжает выполнение других задач пока ожидает ответ от подсистемы IO (например поток байт из сокета). Но параллельных вычислений при этом не происходит, а значит компьютер не использует все свои вычислительные мощности.

Но, асинхронное программирование можно реализовать, как угодно. Например, в C# дефолтная реализация асинхронщины использует разогретый пул потоков, читай многопоточность. В Python тоже можно запускать async/await на потоках, через какой-нибудь ThreadPoolExecutor. 

Таким образом максимальную производительность вы можете получить, комбинируя неблокируемые операции и параллельные вычисления. Конфигураций можно придумать миллион. В одном потоке принимать в расшаренную память данные из сокетов, а в другом их обрабатывать в векторе с кэшем процессора. Ну или запускать несколько ивент лупов, потому что на больших количествах RPS начинают играть даже накладные расходы на диспетчерезацию внутри цикла (так, все асинхронные Python фреймворки в продакшене работают через ранеры которые, сюрприз-сюрприз, запускают несколько потоков или даже процессов для выполнения вашего асинхронного кода).

Использовать при этом сахарный async/await или долбить всё на колбеках — это уже вам решать. Главное, что от физики не уйти, нужно понимать, как работает железо под копотом, о том и речь. 

У меня такое представление:
Треды/Потоки тоже не исполняются одновременно, разница лишь в том, что потоки - "рандом", и на уровне ОС решается, какой поток должен исполняться в конкретный промежуток времени(кроме Greenlet), а асинхронность - да, цикл и прочее, но тоже одна задача в промежуток времени исполняется, просто всё решается на уровне приложения.

Я не слышал о таком, чтобы потоки были действительно параллельны, как процессы. Именно параллельны, то есть одновременно исполяющиеся, а не последовательны. Может есть какие-то бонусы от памяти/контекста исполнения, но используя 100% CPU что асинк, что многопоточность будут примерно одинаковы, ибо на самом деле делают считай одни и те же вещи - "прыгают" от ожидающей задачи к готовой к исполнению. Просто у асинка все делает eventloop и реализация приложения/языка, а у тредов - ОС(кроме зеленых тредов).

И собственно говоря мой вопрос отсюда и возник - где треды берут "лишнюю" работу ЦПУ? От физики не уйти, как вы сказали. И все будет ограничено железом ну и оверхедом от реализаций/вызовов и тп.

П.С про процессы все понятно, к ним вопросов нет. Но даже они обычно - воркеры, считай копии приложения, а не подресурс в одном контексте исполнения(зависит от приложения, конечно).

Ваше представление сложилось так, потому что "поток" — тоже определенная абстракция, за которой стоят детали реализации. Стоит разобраться, о чем именно мы говорим. 
 
"Системные потоки" — то есть потоки управляемые ОС, могут выполняться буквально и физически параллельно на разных ядрах процессора или разных процессорах. В рамках одного системного процесса или нет. Для этого и придумывают многоядерные процессоры, многопроцессорные суперкомпьютеры, тензорные ядра и сопроцессоры.  
 
"Виртуальные потоки" — разного рода легкие|тонкие|зелёные потоки, тоже могут выполняться параллельно. Представьте что ваша виртуальная машина умеет компоновать несколько виртуальных операций в одну физическую и исполнять за один такт физического процессора, тогда потоки выполнятся параллельно даже на одном ядре. Для этого придумывают всякие Erlang'и. 
 
Проблема планирования времени выполнения этих потоков возникает тогда, когда физический ресурс меньше, чем логический. Если на вашем компе 128 ядер, а системных потоков 512, тогда да, не все из них будут физически одновременно работать, будет оверхед на переключение и так далее.  
 
Об этом и речь. Возвращаясь к вашему изначальному вопросу. Суть не в том, что потоки берут откуда-то дополнительную работу ЦПУ. Суть в том, что дефолтное асинхронное приложение на ивентлупе с одним потоком не утилизирует многоядерный ЦПУ на 100%

В своём опыте не встречал, к сожалению, кросс-процессорных/ядровых/процессовых потоков, все всегда было в рамках одного процесса, выше - только уже воркеры и тп, максимально за абстракциями. Что-то уровня 1 ядро = 1 процесс и около того.

На уровне одного процесса у асинка меньше оверхед, и он даст результат лучше в плане RPS и прочего.

UPD. в случае многоядерного ЦПУ - просто много асинхронных воркеров под количество ядер, вот и утилизация на 100%. Думаю даже оверхед выиграет.

Продвинутые(в моём понимании) виды параллельности, что вы описали, как компановка операций в один тик, межпроцессовые треды в рамках одной задачи и тп думаю в самом деле перебьют асинк, ибо "пул" больше, но в стандартном вебе такого не встречал.

Спасибо за разъяснения, узнал новое для себя.

Треды это больше про отзывчивость (десктоп, GUI, игры там разные), нежели масштабирование.

С одной стороны у нас находится огромный сегмент задач, которые прекрасно выполняются в один поток (включая кооперативную многозадачность a'la asyncio). С другой: хайлоад, высокая доступность, числодробилки - где про масштабирование уже думают хостами, availability zone и дата центрами. И где-то между ними находится сегмент задач, которые укладываются в одну машинку, но могут получить преимущества от небольшого кратного распараллеливания (сколько там у вас ядер?).

Одна из распространенных проблем, которую разработчики на python пытаются решать с помощью тредов, это блокирующий ввод-вывод. Но по сути это костыли, ввиду отсутствия удобных интерфейсов. Хотя есть же twisted или rxpy, которые скрывают от приложения всю такую ерунду.

С generic точки зрения-то все так, но конкретно в питоне существование GIL (в CPython, который стандарт де-факто, как ни крути) нельзя игнорировать -- в python web фреймворках раннеры на тредах делают только в том случае, если код хендлеров написан не в async манере / если используются бинарные расширения (например, numpy, который вообще держит свой пулл openmp потоков под капотом), отпускающие GIL. То же самое -- использование TheadPoolExecutor не даст прироста, если не отпускать GIL в бинарных расширениях. Вообще с раннерами же правило большого пальца -- делать все на процессных воркерах -- и жизненный цикл воркеров с мастером развязан (можно киллять без зазрения совести), и утилизация cpu из коробки нормальная. Единственная проблема -- ботлнек в общении с мастер-процессом, но это мизер и там где это надо учитывать в принципе не надо писать на питоне.

Со всем остальным согласен :+1:

А можно было использовать Scrapy, там параллельные запросы и всякие лимиты из коробки.

Освоил документацию к асинхронщине и уже бандит, что еще впереди...

Вариант с async/await у вас работает в цикле, в программе где одна функция, поэтому предсказуемо работает так же быстро как и синхронный код. gather/wait/asyncio.run хорошо помогают в этом случае, как и показал ваш пример ниже.

Глаз зацепился, даже аккаунт полез восстанавливать. Все же, стоило нормально разобраться, что там с выигрышем по времени и отчего он происходит, а отчего нет. В итоге статья получилась не только простенькая, но еще и неправильная. А при нормальной проработке вопросов получилась бы конфета.

Не дожидаясь ответа, управление потоком отдается снова event loop'у, который создает следующую по "for циклу" корутину, которая тоже отправляет запрос.

В вашем первом примере с async-await-oм ничего такого не происходит. Следующая итерация цикла не начнется, до тех пор, пока мы не вывалимся (последовательно) из обоих await-ов.

  • await coroutine -- синтаксический сахар над await from iterable. В этот момент передачи управления event-loop-у не происходит -- мы сразу проваливаемся в корутину

  • корутина session.get() через цепочку await-ов внутри себя в какой-то момент блокируется (например, на ожидание сокета). Все это происходит через asyncio методы, поэтому он про все знает -- и помечает всю текущую task как ожидающую сокет (там все немного интереснее, но верхнеуровнево -- так), все это дело засыпает и контроль передается event-loop-у

  • В event-loop-е в этот момент нет никаких готовых к запуску здесь и сейчас task (есть всего одна и та в режиме ожидания)

  • Когда сокет готов, asyncio передает управление таске -- мы вылетаем из await-а

  • так, последовательно мы доходим до конца итерации цикла и начинаем следующую -- только тогда и начнем второе хождение по http

Доп. моменты, которые стоило бы обсудить:

  • Все, таки, что с тредингом и  multiprocesing -- упомянуто три четыре способа, один из которых неправильный, а два -- "ну это пропустим". Multiprocessing вполне себе валидный инструмент в определенных контекстах.

  • rate limit -- подумали бы, как сделать красивое ограничение на частоту запросов

  • какие минусы вашего решения, если нужно обкачать много-много запросов, но с низким рейт лимитом (подсказка -- в event-loop будут создаваться ненужные task-и)

    • как поступать в таком случае (подсказка -- asyncio очередь с воркерами)

Спасибо за развернутый комментарий. Покурю материал на эту тему и попробую дополнить/переделать статью.

С документацией одна беда -- она не user-friendly и не для начинающего входить в эту тему, для меня ее полезность стала актуальной только когда я полез в исходники asyncio. На своем курсе по Python я активно рекламирую цикл уроков от bbc https://bbc.github.io/cloudfit-public-docs/ и вишенкой на торте -- шикарнейшие видеокасты от David Beazley, который пишет свой микро asyncio -- после этого наступает просветление и кристалльное понимание, что никакой магии там не происходит. После -- разбор концепций структурного асинхронного программирования (trio / anyio), потом депрессия от понимания, что асинхронность в Python безвозвратно продолбана и наконец -- посыл Python к чертовой матери и переход на Go =D

Расскажите о "своем курсе по Python". Можно в личку. Спасибо.

курс в (к сожалению, закрытом недавно) ОзонМастерс

асинхронность в Python безвозвратно продолбана

В питоне давно так: активные контрибьюторы создают хайп вокруг своей идеи и пока ни кто не успел опомниться проталкивают сырой код в стандартную библиотеку. А потом уже ни кто не может это исправить. Интерфейс Event Loop , содержащий 100500 публичных методов, очередной яркий тому пример.

Trio концептуально интересный проект, осмелившихся бросить вызов этому огромному монстру. Но ему не хватает строгой теоретической базы под капотом. Автор тоже скорее думает метафорами из реального мира, поэтому отдельные противоречия вылезают в самых неудобных местах (например как реализована обработка ошибок).

Я плюсовать не могу:) Это точно, что-то когда-то пошло не так. Меня тут напрягают несколько моментов

  • дихотомия sync vs async миров -- из-за разных путей вызовов магических методов в кишках очень сложно писать код, который работал бы и там, и там без копипасты. Мне кажется, если бы одни и те же dunder методы можно было вызывать и в sync режим, и в async -- питон был бы сильно проще. И мне кажется, что корень зла -- генераторное прошлое корутин. Т.е. их сначала сделали, извернувшись, через генераторы, но потом закопали эти генераторы глубоко в имплементацию, добавив async/await сахар поверх, а мб стоило выкинуть генераторы и сделать async/await "на уровне интерпретатора". Но я глубоко про это не думал, это, очевидно, сложная тема + задним числом все умные, как всегда

    • особняком стоит гениальный и настолько же взрывоопасный gevent, который позволяет не разделять миры, но делает это настолько инвазивно, что сидишь все время гадаешь, бомбанет или нет и главное -- в каком месте

    • есть изыски типа https://sobolevn.me/2020/06/how-async-should-have-been , идеи интересные, но я считаю, что такое в прод тащить нельзя =D

  • зоопарк несовместимых друг с другом ивентлупов и завязанность третьесторонних библиотек на конкретные имплементации

  • жару добавляет какая-нибудь реализация gRPC на питоне -- python thread-based сервер поверх cython-а, который поверх сишной либы, у которой свой тред пул.

    • и ты такой пилишь проект на джанге с gevent-ом и сидишь трясешься, не понимая, будет ли gRPC работать на greenlet-ах и можно ли и как это все поженить с какими-нибудь async-либами (у gevent и asyncio ивентлупы не 1-в-1 совместимы), а потом еще думаешь, а классно сделать в trio стиле и мозг в итоге взрывается. Слишком много слоев, которые все надо держать в голове

Я плюсовать не могу:)

Напишите уже статью. Вы тут в комментах уже накидали на целый учебник. :)

В python async/await это такая профанация, что в любой момент с помощью нехитрого декоратора все можно вернуть как было https://github.com/alex-sherman/unsync .

Буквально недавно был конкурс от Россельхоз банка на tdconf? Ddos me. Ребята попросили сделать к ним много get запросов. Ну в общем я сделал попрядка 1.3 миллиона реквестов в секунду с одного сервера особо не напрягаясь, и всё упёрлось в их инфраструктуру. Да это слегка хулиганство, но чёрт возьми, на порядок быстрее чем люди у которых стояли многоядерные сервера в 100% загрузке cpu. На nodejs. Код тут https://github.com/Busyrev/gatling/blob/master/src/index.ts учтите, код писался в 2 часа начи между конфой на коленке, это не эталон хорошего кода. Надо бы причесать да написать что-нибудь на эту тему, может быть когда нибудь. Единицы rps это невероятно медленно, это ужасно. Нормальный веб фреймвёрк должен давать ну хотябы десятки тысяч rps вообще без сложностей.

Это я всё зачем написал, чтобы люди понимали что нормальной сетевой производительностью являются миллионы запросов в секунду. На фреймвёрках такое как правило не достижимо, но тем не менее, десятки и сотни тысяч rps это нормальный цифры, ели они они меньше то надо искать проблемы, либо на вызывающей, либо на принимающей стороне. И ну не очень верю я что провайдер будет блочить за какие-то штучные rps, посмотрите сколько запросов уходит когды вы открываете что-то типа facebook. Вот пример где человек осознал что его цифры ооочень странные и вроде как даже нашёл проблему https://habr.com/ru/post/580066/

Дааа-с, завернуть в gather() отправку запросов на сервер вы догадались, а сделать то же самое с получением тела ответа от сервера - нет. В результате ответы на свои запросы вы по прежнему вычитываете последовательно, один за другим.

Sign up to leave a comment.

Articles