В книге «Python. К вершинам мастерства» Лучано Рамальо описывает одну историю. В 2000 году Лучано проходил курсы, и однажды в аудиторию заглянул Гвидо ван Россум. Раз подвернулся такой случай, все стали задавать ему вопросы. На вопрос о том, какие функции Python заимствовал из других языков, Гвидо ответил: «Все, что есть хорошего в Python, украдено из других языков».

Это действительно так. Python давно живет в контексте других языков программирования и впитывает концепции из окружения: asyncio позаимствован, благодаря Lisp появились лямбда-выражения, а Tornado скопировали с libevent. Но если у кого и стоит заимствовать идеи, так это у Erlang. Он создан 30 лет назад, и все концепции в Python, которые сейчас реализуются или только намечаются, в Erlang давно работают: многоядерность, сообщения как основа коммуникации, вызовы методов и интроспекция внутри живой системы на продакшн. Эти идеи в том или в ином виде находят своё проявление в системах вроде Seastar.io.


Если не брать во внимание Data Science, в котором Python сейчас вне конкуренции, то все остальное уже реализовано в Erlang: работа с сетью, обработка HTTP и веб-сокетов, работа с базами данных. Поэтому Python-разработчикам важно понимать, куда будет двигаться язык: по дороге, которую уже прошли 30 лет назад.

Чтобы разобраться в истории развития других языков и понять, куда двигается прогресс, мы пригласили на Moscow Python Conf++ Максима Лапшина (erlyvideo) — автора проекта Erlyvideo.ru.

Под катом текстовая версия этого доклада, а именно: в каком направлении вынуждена развиваться система, которая продолжает мигрировать от простого линейного кода к libevent и дальше, что общего и в чем отличия между Elixir и Python. Отдельное внимание уделим тому, как на разных языках программирования и платформах управлять сокетами, потоками исполнения и данными.


У Erlyvideo.ru есть система видеонаблюдения, в которой управление доступом к камерам написано на Python. Это классическая задача для этого языка. Есть пользователи и камеры, видео с которых они могут смотреть: кто-то видит одни камеры, кто-то другие — обычный сайт.

Python был выбран, потому что на нём удобно писать такой сервис: есть фреймворки, ORM, программисты, в конце концов. Разрабатываемый софт пакуется и продается пользователям. Erlyvideo.ru та компания, которая продает софт, а не только дает сервис.

Какие проблемы с Python хочется решить.

Почему такие проблемы с многоядерностью? Мы запускали Flussonic на стоядерных компьютерах еще до того, как это делал Intel. Но у Python с этим сложности: почему он до сих пор не использует все 80 ядер наших серверов для работы?

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

Есть ли решение у забытых глобальных переменных? Утечка глобальных переменных — это ад для любого языка со сборкой мусора, как то Java или C#.

Как использовать железо, не сжирая впустую ресурсы? Как обойтись без запуска 40 джанговских воркеров и 64 Гбайт RAM, если мы хотим использовать серверы эффективно, а не выбрасывать сотни тысяч долларов в месяц на ненужное железо?

Зачем нужна многоядерность


Чтобы все ядра использовались полностью, требуется гораздо больше воркеров, чем ядер. Например, на 40 ядер процессора нужно от 100 воркеров: один воркер пошел к базе данных, другой занят чем-то еще.

Один воркер может потреблять 300-400 Мбайт. Это мы еще пишем на Python, а не на Ruby on Rails, который может потреблять в несколько раз больше и 40 Гбайт RAM легко и непринужденно вылетят впустую. Это не сильно дорого, но зачем покупать память там, где можно не покупать.

Многоядерность помогает шарить общие данные и снижать расход памяти, удобно и безопасно запускать много независимых друг от друга процессов. Это гораздо проще программировать, но дороже по памяти.

Управление сокетами


По веб-сокету опрашиваем runtime-данные видеокамер с бэкенда. Софт на Python подсоединяется к Flussonic и опрашивает данные состояния видеокамер: работают или нет, есть ли новые события.

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

Но, например, произошла какая-то проблема: база данных не ответила на запрос, весь код упал, осталось два открытых сокета. Запустили reload, что-то сделали, опять эта проблема — остались два сокета. Неправильно обработали ошибку БД и повисло два открытых соединения. Через какое-то время это приводит к утечкам сокетов.

Забытые глобальные переменные


Сделали глобальный dict для списка подключенных по веб-сокету браузеров. Человек логинится на сайт, мы открываем для него веб-сокет. Потом веб-сокет с его идентификатором помещаем в какой-то глобальный dict, и, так уж получается, что возникает какая-то ошибка.

Например, записали в dict ссылку на подключение, чтобы рассылать данные. Сработало исключение, забыли удалить ссылку и данные повисли. Так через какое-то время начинает не хватать уже и 64 Гбайт, и хочется удвоить память на сервере. Это не решение, потому что все равно данные будут утекать.
Мы всегда совершаем ошибки — мы люди и не можем за всем уследить.
Вопрос в том, что какие-то ошибки происходят, даже те, которые мы не ожидали увидеть.

Исторический экскурс


Чтобы подойти к основной теме, углубимся в историю. Все, о чем мы сейчас говорим о Python, Go и Erlang, — весь этот путь другие люди прошли лет 30 назад. Мы в Python проходим путь и набиваем шишки, которые уже пройдены десятилетия назад. Путь повторяется просто удивительным образом.

DOS


Сначала обратимся к DOS, он ближе всего. До него были совершенно другие вещи и не все живы, кто помнит компьютеры до DOS.

Программа на DOS занимала компьютер (почти) монопольно. Пока запущена, например, игра, ничего другое не выполняется. В интернет не пойдешь — его еще нет, и даже никуда не дозвонишься. Это было грустно, но воспоминания об этом теплые, потому что связаны с молодостью.

Кооперативная многозадачность


Поскольку с DOS было совсем больно, появлялись новые вызовы, компьютеры становились мощнее. Десятилетия назад разработали концепцию кооперативной многозадачности, еще до Windows 3.11.

Данные разделяются по процессам, и каждый процесс выполняется отдельно: они друг от друга как-то защищены. Плохой код в одном процессе не сможет испортить код в браузере (тогда уже появлялись первые браузеры).

Дальше вопрос: как между разными процессами будет распределяться вычислительное время? Тогда не то, что не было больше одного ядра, двухпроцессорная система была редкостью. Схема была такая: пока один процесс пошел, например, на диск за данными, второй процесс получает управление от ОС. Первый сможет получить управление, когда второй сам добровольно отдаст. Я сильно упрощаю ситуацию, но процесс как-то мог добровольно разрешать снимать его с процессора.

Вытесняющая многозадачность


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

В ответ на эту проблему придумали вытесняющую многозадачность. ОС теперь сама жестко рулит: снимает процессы с выполнения, полностью разделяет их данные, защищает память процессов друг от друга и дает каждому какое-то количество вычислительного времени. ОС выделяет одинаковые интервалы времени каждому процессу.

Вопрос шедулинга времени все еще не закрыт. Сегодня разработчики ОС все еще придумывают, как правильно, в каком порядке, кому и сколько давать времени на управление. Мы сегодня видим развитие этих идей.

Потоки


Но и этого оказалось недостаточно. Процессам нужно обмениваться данными: через сеть дорого, как-то еще сложно. Поэтому была придумана концепция потоков.
Потоки — это легковесные процессы, которые объединены общей памятью.
Потоки были созданы с надеждой, что все будет легко, просто и весело. Сейчас мультипотоковое программирование считается антипаттерном. Если бизнес-логика написана на потоках — этот код, скорее всего, надо выбросить, потому что в нем наверняка есть ошибки. Если вам кажется, что ошибок нет, значит вы просто их еще не нашли.

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

Тем временем появились многоядерные компьютеры. Они принесли с собой ужасные вещи. Потребовался совершенно другой подход к данным, возникли вопросы с локальностью данных, теперь нужно понимать, с какого ядра к каким данным идешь.

Одному ядру надо данные положить сюда, другому туда, и ни в коем случае не путать эти вещи, потому что внутри компьютера возникли фактически кластеры. Внутри современного компьютера бывает кластер, когда часть памяти припаяна к одному ядру, а другая к другому. Время прохода между этими данными может отличаться на порядки.

Примеры на Python


Рассмотрим простой пример «Сервис в помощь покупателю». Он подбирает лучшую цену товара на нескольких площадках: вбиваем название товара и ищем торговые площадки с минимальной ценой.

Это код на старом Django, Python 2. Он сегодня не очень популярен, мало кто на нем начинает проекты.

@api_view(['GET'])
def best_price(request):  
    name = request.GET['name']  
    price1 = http_fetch_price('market.yandex.ru', name)  
    price2 = http_fetch_price('ebay.com', name)  
    price3 = http_fetch_price('taobao.com', name)  
    return Response(min([price1,price2,price3]))

Приходит запрос, мы идем к одному бэкенду, потом к другому. В местах, где вызывается http_fetch_price, потоки блокируются. В этот момент весь воркер встает на поход к Яндекс.Маркету, потом к eBay, потом до таймаута на Taobao, а в конце выдает ответ. Все это время весь воркер стоит.

Очень сложно одновременно опрашивать несколько бэкендов. Это плохая ситуация: потребляется память, требуется запуск большого количества воркеров и мониторинг всего сервиса. Надо смотреть насколько часты такие запросы, не нужно ли еще воркеров запускать или опять есть лишние. Это как раз те самые проблемы, о которых я говорил. Опрашивать несколько бэкендов надо по очереди.

Что мы видим на Python? Один процесс на задачу, в Python до сих пор нет мультикора. Ситуация понятна: в языках такого класса сложно сделать безопасный простой мультикор, потому что он убьет производительность.

Если пойти к dict с разных потоков, то доступ к данным можно написать так: склеить в памяти два экземпляра Python, чтобы они пошарили данные — они их просто сломают. Например, чтобы пойти к dict и ничего не сломать, надо ставить перед ним мьютексы. Если перед каждым dict будет мьютекс, тогда система замедлится примерно в 1000 раз — будет просто неудобно. Это сложно протаскивать в мультикор.

У нас есть только один поток исполнения и масштабироваться возможно только процессами. Фактически, мы переизобрели DOS внутри процесса — скриптовый язык образца 2010 года. Внутри процесса есть штука, которая напоминает DOS: пока мы что-то делаем, все другие процессы не работают. Огромный перерасход ресурсов и медленный ответ никому не нравился.

Какое-то время назад в Python появился реактор сокетов, хотя сама концепция родилась давно. Появилась возможность ожидать готовности сразу нескольких сокетов.

Сначала реактор стал востребован на серверах типа nginx. В том числе благодаря правильному использованию этой технологии, он и стал популярен. Потом концепция переползла и в скриптовые языки вроде Python и Ruby.
Идея реактора в том, что мы перешли к событийно-ориентированному программированию.

Событийно-ориентированное программирование


Один контекст выполнения производит запрос. Пока ждем ответ, выполняется другой контекст. Примечательно, что мы практически прошли тот же этап эволюции, как переход от DOS к Windows 3.11. Только люди это сделали на 20 лет раньше, а в Python и в Ruby это появилось лет 10 назад.

Twisted


Это событийно-ориентированный сетевой фреймворк. Он появился в 2002 году и написан на Python. Я взял пример выше и переписал его на Twisted.

def render_GET(self, request):  
    price1 = deferred_fetch_price('market.yandex.ru', name)  
    price2 = deferred_fetch_price('ebay.com', name)  
    price3 = deferred_fetch_price('taobao.com', name)  
    dl = defer.DeferredList([price1,price2,price3])    

    def reply(prices):    
        request.write('%d'.format(min(prices)))    
        request.finish()  
    dl.addCallback(reply)  
    return server.NOT_DONE_YET

Здесь могут быть ошибки, неточности, не хватает пресловутой обработки ошибок. Но примерная схема такая: мы не делаем запрос, а просим сходить за этим запросом когда-нибудь потом, когда будет время. В строке с defer.DeferredList мы хотим собрать вместе ответы от нескольких запросов.

Фактически, код состоит из двух частей. В первой части то, что было до запроса, а во второй то, что после.
Вся история событийно-ориентированного программирования пропитана болью от разрыва линейного кода на «до запроса» и «после запроса».
Это больно, потому что куски кода смешиваются: последние строчки еще выполняются в оригинальном запросе, а функция reply вызовется уже после.

Это непросто удержать в голове именно потому, что мы разорвали линейный код, но это нужно было сделать. Если не вдаваться в детали, то код, который переписан с Django на Twisted, выдаст совершенно неимоверное псевдоускорение.

Идея Twisted

Объект может быть активирован при готовности сокета.
Мы берем объекты, в которые собираем необходимые данные от контекста и привязываем их активацию к сокету. Теперь готовность сокетов — один из самых важных элементов управления всей системой. Объекты будут нашими контекстами.

Но при этом язык все еще отделяет само понятие контекста исполнения, в котором живут исключения. Контекст исполнения живет отдельно от объектов и слабо связан с ними. Здесь возникает проблема с тем, что мы стараемся собирать данные внутри объектов: без них никак, а язык это не поддерживает.

Все это приводит к классическому callback hell. За что, например, «любят» Node.js — до недавнего времени не было вообще никаких других способов, а в Python уже все-таки появилось. Беда в том, что есть разрывы кода в точках внешнего IO, которые приводят к callback.

Вопросов много. Можно ли «склеить» края разрыва в коде? Можно ли вернуться обратно к нормальному человеческому коду? Что делать, если логический объект работает с двумя сокетами и один из них закрывается? Как не забыть закрыть второй? Можно ли как-то использовать все ядра?

Async IO


Хороший ответ на эти вопросы — Async IO. Это крутой шаг вперед, хотя и непростой. Async IO сложная штука, под капотом которой много болезненных нюансов.

async def best_price(request):  
    name = request.GET['name']  
    price1 = async_http_fetch_price('market.yandex.ru', name)  
    price2 = async_http_fetch_price('ebay.com', name)  
    price3 = async_http_fetch_price('taobao.com', name)

    prices = await asyncio.wait([price1,price2,price3])  
    return min(prices)

Разрыв кода скрыт под синтаксическим сахаром async/await. Мы взяли, все что было раньше, но не пошли к сети в этом коде. Мы убрали Callback(reply), который был в предыдущем примере и скрыли его за await — местом, где код будет разрезан ножницами. Он будет разделен на две части: вызывающую и callback-часть, которая обрабатывает результаты.

Это прекрасный синтаксический сахар. Есть методы для склейки нескольких ожиданий в одно. Это классно, но есть нюанс: все можно сломать «классическим» сокетом. В Python до сих пор огромное количество библиотек, которые пойдут к сокету синхронно, сделают timer libraryи все вам испортят. Как это отладить, я не знаю.

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

У нас остались все проблемы, о которых мы говорили в начале:

  • легко утекать сокетами;
  • легко оставлять ссылки в глобальных переменных;
  • очень кропотливая обработка ошибок;
  • всё так же сложно сделать многоядерность.

Что делать


Будет ли это все развиваться, я не знаю, но покажу реализацию в других языках и платформах.

Изолированные контексты выполнения. В контекстах исполнения накапливаются результаты, держатся сокеты: логические объекты, в которых мы обычно сохраняем все данные про callback’и и сокеты. Одна из концепций: взять контексты исполнения, склеить их с потоками исполнения и полностью изолировать их друг от друга.

Смена парадигмы объектов. Давайте соединим контекст с потоком выполнения. Существуют аналоги, это не что-то свежее. Если кто-то пытался править исходники Apache и писать к ним модули, то знает, что там есть Apache pool. Между Apache pool’s запрещены какие-либо ссылки. Данные от одного Apache pool — пула, связанного с запросами, находятся внутри него, и нельзя оттуда ничего выносить.

Теоретически можно, но если так делать, то либо кто-то наругает, либо патч не примут, либо ждет долгая и мучительная отладка на продакшн. После этого никто не будет так поступать и разрешать делать такие вещи другим. На данные между контекстами ссылаться уже просто так нельзя, нужна полная изоляция.

Как обмениваться активностью? Необходимы не маленькие монады, которые внутри себя закрыты и никак друг с другом не общаются. Нам надо, чтобы они общались. Один из подходов — это обмен сообщениями. Это примерно тот путь, по которому пошли в Windows, обмениваясь сообщениями между процессами. В обычной ОС нельзя дать ссылку на память другого процесса, но можно сигнализировать через сеть, как в UNIX, или через сообщения, как в Windows.

Все ресурсы внутри процесса и контекст становятся потоком исполнения. Мы склеили вместе:

  • runtime-данные в виртуальной машине, в которых возникают исключения;
  • поток исполнения, как то, что исполняется на процессоре;
  • объект, в котором логически собираются все данные.

Поздравляю — мы изобрели UNIX внутри языка программирования! Эту идею придумали примерно в 1969 году. Пока что в Python его еще нет, но Python, скорее всего, к этому придет. А, возможно, и не придет — не знаю.

Что это дает


Прежде всего, автоматический контроль за ресурсами. На Moscow Python Conf++ 2019 рассказывали, что можно на Go написать программу и обработать все ошибки. Программа будет стоять как влитая и работать месяцами. Это действительно так, но мы не обрабатываем все ошибки.

Мы — живые люди, у нас всегда есть сроки, желание сделать что-то полезное, а не обрабатывать 535-ю ошибку за сегодня. Код, который обсыпан обработкой ошибок, ни у кого никогда не вызывает теплых чувств.

Поэтому мы все пишем «happy path», а дальше на продакшн разберемся. Будем честны: только когда нужно что-то обрабатывать, тогда и начинаем обрабатывать. Defensive programming — это чуть-чуть другое, и это не коммерческая разработка.

Поэтому, когда у нас есть автоконтроль за ошибками — это прекрасно. Но операционные системы его придумали 50 лет назад: если какой-то процесс умирает, то все, что он открыл, закроется автоматически. Никому сегодня не надо писать код, который будет подчищать файлы за убитым процессом. Этого нет уже 50 лет ни в одной ОС, а в Python все еще надо за этим всем внимательно и аккуратно следить руками. Это странно.

Можно вынести тяжелые вычисления в другой контекст, а он уже может уйти на другое ядро. Мы разделили данные, нам больше не нужны мьютексы. Можно отправить данные в другой контекст, сказать: «Ты где-нибудь там выполнись, а потом сообщи мне, что ты закончил и что-то сделал».

Реализация asyncio без слов «async/await». Дальше небольшая помощь от виртуальной машины, от runtime. Это то, о чем мы говорили с async/await: можно переделать также на сообщения, убрать async/await и получить это на уровне виртуальной машины.

Процессы Erlang


Erlang придумали 30 лет назад. Бородатые ребята, которые тогда были не очень бородатые, посмотрели на UNIX и перенесли все концепции в язык программирования. Они решили, что у них теперь будет своя штука, чтобы спать по ночам и спокойно ездить на рыбалку без компьютера. Тогда еще не было ноутбуков, но бородатые ребята уже догадывались, что об этом нужно думать заранее.

Мы получили Erlang (Elixir) — активные контексты, которые выполняются сами. Дальше мой пример на Erlang. На Elixir он выглядит примерно так же, с некоторыми вариациями.

best_price(Name) ->  
    Price1 = spawn_price_fetcher('market.yandex.ru', Name),  
    Price2 = spawn_price_fetcher('ebay.com', Name),  
    Price3 = spawn_price_fetcher('taobao.com', Name),  
    lists:min(wait4([Price1,Price2,Price3])).

Запускаем несколько fetcher'ов — это несколько отдельных новых контекстов, которые мы ждем. Дождались, собрали данные и результат вернули как минимальную цену. Все это похоже на async/await, только без слов «async/await».

Особенности Elixir


Elixir находится в базе у Erlang, и все концепции языка спокойно переносятся на Elixir. Какие у него особенности?

Запрет на кросс-процессорные ссылки. Под словом процесс я подразумеваю уже легковесный процесс внутри виртуальной машины — контекст. Упрощенно, если перенести на Python, в Erlang запрещены ссылки на данные внутри другого объекта. Можно иметь ссылку на весь объект целиком, как на закрытую коробочку, но на данные внутри него ссылаться нельзя. Нельзя даже синтаксически получить указатель на данные, которые находятся внутри другого объекта. Можно только знать о самом объекте.

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

Процессы могут перемещаться по ядрам, это безопасно. Нам больше не нужно обходить, как в Java, кучу других pointer и переписывать их при перемещении данных из одного места в другое: у нас нет общих данных и внутренних ссылок. Например, откуда возникает проблема разреженности хипа? Из-за того, что на эти данные кто-то ссылается.

Если мы переносим данные внутри кучи в другое место для уплотнения, нам нужно пройтись по всей системе. Она может занимать десятки гигабайт и обновить все указатели — это безумие.

Полная потокобезопасность, за счет того, что вся коммуникация идет через сообщения. На сдачу от всего этого мы получили вытесняющий шедулинг процессов. Он достался легко и дешево.

Сообщения как основа коммуникации. Внутри объектов обычные вызовы функций, а между объектами сообщения. Приход данных из сети это сообщение, ответ другого объекта — сообщение, что-то ещё снаружи тоже сообщение в одной входящей очереди. Такого нет в UNIX, потому что не прижилось.

Вызовы методов. У нас есть объекты, которые мы называем процессы. Через сообщения вызываются методы на процессах.

Вызов методов — это тоже посылка сообщения. Здорово, что теперь его можно сделать с таймаутом. Если что-то нам отвечает медленно, вызываем метод на другом объекте. Но при этом говорим, что готовы ждать не больше 60 с, потому что у меня клиент с таймаутом в 70 с. Мне нужно будет пойти и сказать ему «503» — приходи завтра, сейчас тебя не ждут.

Больше того, ответ на вызов можно отложить. Внутри объекта можно принять запрос на вызов метода, и сказать: «Да-да, я тебя сейчас положу, приходи через полчаса, я тебе отвечу». Можно и не говорить, а молча отложить в сторонку. Мы этим иногда пользуемся.

Как работать с сетью?


Можно писать линейный код, callback’ами или в стиле asyncio.gather. Пример, как это будет выглядеть.

wait4([ ]) ->  
    [ ]; 
wait4(List) ->  
    receive   
        {reply, Pid, Price} -> [Price] ++ wait4(List -- [Pid])  
    after    
        60000 ->     
            []  
    end.

В функцииwait4 из предыдущего примера мы перебираем список тех, от кого еще ждем ответы. Если с помощью метода receive получаем сообщение от того процесса — записываем в список. Если список закончился, мы возвращаем все, что было и накапливаем список. Мы попросили одновременно три объекта пригнать нам данные. Если они не справились все вместе за 60 с, и хотя бы один из них не ответил ОК, у нас будет пустой список. Но важно то, что мы сделали общий таймаут на запрос сразу к целой пачке объектов.

Кто-то может сказать: «Подумаешь, в libcurlесть все то же самое». Но здесь важно то, что с той стороны может быть не только поход по HTTP, но и поход к БД, а еще какие-то вычисления, например, подсчет какой-то оптимальной циферки для клиента.

Обработка ошибок


Ошибки перешли из потока в объект, которые теперь одно и то же. Теперь сама ошибка становится привязана не к потоку, а к объекту, где это выполнялось.

Это гораздо логичнее. Обычно, когда мы рисуем на доске всякие квадратики и кружочки в надежде, что они оживут и начнут приносить нам результат и деньги, мы рисуем, как правило, объекты, а не потоки, в которых эти объекты будут выполняться. Например, на сдачу мы можем получить автоматическое сообщение о смерти другого объекта.

Интроспекция или отладка в продакшн


Что может быть приятнее, чем пойти на прод и дебажить, особенно, если ошибка возникает только под нагрузкой в часы пик. В час пик мы говорим:

— Давайте, я сейчас рестартну!
— Иди за дверь и там рестартни у кого-нибудь другого!

Здесь мы можем зайти внутрь живой системы, которая запущена прямо сейчас и специально к этому не подготовлена. Для этого не требуется перезапускать её с профилировщиком, с отладчиком, пересобирать.

Без какой-либо потери производительности в живой продакшн-системе мы можем посмотреть список процессов: что у них внутри, как это все это работает, потрейсить их, проверить, что у них происходит. Все это бесплатно из коробки.

Бонусы


Код сверхнадежен. Например, у Python есть хрупкость с old vs async, и она еще сохранится лет пять, не меньше. Учитывая, с какой скоростью внедрялся Python 3, не стоит надеяться, что это будет быстро.

Читать и трейсить сообщения проще, чем отлаживать callback’и. Это важно. Казалось бы, если у нас все равно есть callback’и для обработки сообщений, которые мы можем увидеть, то чем это лучше? Тем, что сообщения — это кусочек данных в памяти. Его можно посмотреть глазками и понять, что сюда пришло. Его можно добавить в трейсер, получить в текстовом файле список сообщений. Это удобнее, чем callback’и.

Шикарная многоядерность, управление памятью и интроспекция внутри живой системы на продакшн.

Проблемы


Естественно, проблемы у Erlang тоже есть.

Потеря максимальной производительности из-за того, что больше не можем ссылаться на данные в другом процессе или объекте. Приходится их перемещать, а это не бесплатно.

Накладные расходы на копирование данных между процессами. Мы можем написать программу на C, которая будет запускаться на всех 80 ядрах и обрабатывать один массив данных, и будем считать, что она это делает правильно и корректно. В Erlang так нельзя: надо аккуратно распилить данные, распределить по пачке процессов, уследить за всем. Это коммуникация стоит ресурсов — тактов процессора.

Насколько это быстро или медленно? Мы пишем код на Erlang уже 10 лет. Единственный конкурент, который выжил за эти 10 лет, написан на Java. С ним у нас практически полный паритет по производительности: кто-то говорит, что мы хуже, кто-то, что они. Но у них Java со всеми ее заморочками, начиная с JIT.

Мы пишем программу, которая обслуживает одновременно десятки тысяч сокетов и прокачивает через себя десятки Гб данных. Внезапно выясняется, что в этом случае правильность алгоритмов и умение все это отлаживать в продакшн оказывается важнее, чем потенциальные плюшки от Java. В нее вложили миллиарды долларов, но это не дает Java JIT каких-то магических преимуществ.

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

Накладные расходы на аллокацию сообщений. Иногда это больно. Например, у нас в коде есть некоторые кусочки на C, и в этих местах совсем не получалось с Erlang. Но таких мест очень мало, мы почти все выпилили из того, что оказалось лишним.

Под капотом в Erlang нет даже синтаксиса для изменения переменных, есть только данные, которые передаются в саморекурсивную функцию. Это функция, которая вращается по кругу, делает методы receive и send receive. Это и есть процесс — эмуляция состояния объекта, которая инспектируется снаружи. Там даже нет объектов, это просто функция, которая работает с данными.

Зачем это всё программисту на Python


Важно понимать траекторию развития. Я не просто так начал с исторического контекста. Хотел показать стадии разработки у системных программистов, и что мы с вами и Python находимся где-то в середине того пути развития.

Возможно, это позволит понять дальнейшее развитие. Вдруг кто-то из вас решит менять Python, чтобы он наконец приобрел фичи современнее, которым хотя бы 20 лет, а не 40.

Естественно, кругозор и знание альтернатив тоже полезны. Какие-то вещи, возможно, вы решите переписать на Elixir, посмотрев на примеры, но это в качестве дополнительного бонуса.

Сейчас мы работаем над программой следующей Moscow Python Conf++. Здесь можете посмотреть, что у программного комитета в работе и какие 6 тем приняты в программу за 4 месяца до конференции. Если знаете, что нужно добавить, то а) напишите в комментариях или б) подайте заявку на доклад. Call for Papers открыт до 13 января, а сама конференция состоится 27 марта.

Комментарии (19)


  1. masai
    11.12.2019 17:27

    У меня есть пара глупых вопросов.


    Что автор понимает под многоядерностью в Python? Разве процессы не распределяются планировщиком на различные ядра?


    Что мы видим на Python? Один процесс на задачу, в Python до сих пор нет мультикора.

    Какой код должны выполнять несколько процессов, если задача одна?


    всё так же сложно сделать многоядерность.

    Так всё же сложно сделать или она вообще отсутствует?


    1. gecube
      11.12.2019 21:39

      Задача никогда не бывает одна. Точнее не так.
      Например, обработка запросов от 1000 клиентов. Это 1 задача? Или 1000 задач?
      А представьте себе, что поток выполнения один, а обработка клиента не очень быстрая (скажем, cpu bound).


      Так всё же сложно сделать или она вообще отсутствует?

      asyncio — это не про настоящую многопоточность. И Вы сами это знаете. Поэтому если нужно наращивать производительность — надо комбинировать и многопоток, и асинхронщину. А в python — первое — это боль. И асинхронщина никак эту боль не снимает. А какие варианты? Либо ждать у моря погоды, либо комбинировать подходы, либо искать более лучшие языки (golang? Erlang?)


      1. masai
        12.12.2019 15:38

        asyncio — это не про настоящую многопоточность. И Вы сами это знаете.

        Да, знаю. Но мой вопрос не про многопоточность или асинхронность.


        Мне непонятно, что именно автор понимает под многоядерностью в Python. В Python можно создавать процессы? Можно. Процессы работают на разных ядрах? Работают. Почему утверждается, что в Python нет многоядерности. И как вообще язык должен поддерживать многоядерность кроме как через примитивы операционной системы?


        Ещё раз повторю, я сейчас об определении самого понятия «многоядерность в Python», которое автор использует, а не о том, насколько эффективно можно на чистом Python нагружать ядра.


        1. eyeofhell Автор
          12.12.2019 16:06

          erlyvideo, ворвешься в комменты?)


          1. erlyvideo
            12.12.2019 16:10
            +1

            коменты, на хабре? Ну уж нафиг.


            1. gecube
              12.12.2019 18:02

              Правильно! Работать надо, а не комментить! Ведь всегда в интернете найдется тот, кто будет неправ!


        1. gecube
          12.12.2019 18:06

          Потому что интерпретатор работает в однопоточном режиме (и опять же — Вы сами это знаете). GIL и это вот все. И другого способа, кроме как инстанцировать НОВУЮ копию интерпретатора и общаться с ней, как Вы правильно заметили, через существующие примитивы ОС — нет. А это малоэффективно. Внезапно, но многопоточность даже в Си/C++ будет эффективнее работать, как минимум, потому что нет интерпретатора с необходимостью поддерживать консистентность глобального стейта. А идеальная ситуация — когда как в голанге — язык уже изначально форсит работу в легких потоках и обмен сообщениями между ними. На уровне ядра языковых конструкций. Что и позволяет этому всему превращаться в эффективный и максимально безопасный (насколько это возможно) код.


          1. masai
            15.12.2019 11:23

            Спасибо за подробные ответы, но я лишь хотел узнать, что автор понимает под отсутствием многоядерности в Python. Я не рассчитывал на продолжительную дискуссию. Мне достаточно было бы ответа вроде «это работа интерпретатора в однопоточном режиме». Потому что «многоядерность в Python» можно понимать по-разному.


            1. erlyvideo
              15.12.2019 13:23
              +2

              Программа на питоне не может использовать ядра процессора, не может обрабатывать одни данные одновременно на нескольких ядрах.


          1. bvm84
            15.12.2019 13:46
            +1

            Не Cypthon-ном единым жив Python. Если GIL есть проблема — существуют реализации спеки Python без него. Функциональный подход хорош, но Python это зачастую обертка с приятным синтаксисом вокруг чего-то более специфичного. Тот же openblas написан на C и крошит матрицы во все ядра и потоки, а numpy/scipy оборачивает его в доступный api. Опять же если в голанге работа с потоками уже сделано хорошо, проще сделать обертку вокруг хорошо работающей вещи, чем пилить ведосипед. На мой взгляд Python стремится идти таким путём, и для его круга задач это правильно. Кому сильно нужны фичи Elixira возьмет и выучит его.


            1. gecube
              15.12.2019 13:59

              Это очень интересный вопрос. И если Вам есть что рассказать — с радостью послушаю.
              Единственное, что могу добавить по поводу numpy/openblas — это то, что это не очень хорошо прыгать из одной экосистемы в другую, т.к. фактически мы очень ограниченно исправляем недостатки одной, а при этом добавляем фрагментарности. Не удивительно, что иногда всё-таки появляются нативные python реализации каких-либо библиотек (напр., hdfs3 для Питона), потому что стыковать и доставлять программные модули из разных миров… достаточно сложная и хрупкая работа. Тем более — на разных архитектурах.


              . На мой взгляд Python стремится идти таким путём, и для его круга задач это правильно.

              Допускаю. Иначе он не стал бы таким популярным инструментом.


  1. deseven
    11.12.2019 17:57

    Потоки были созданы с надеждой, что все будет легко, просто и весело. Сейчас мультипотоковое программирование считается антипаттерном. Если бизнес-логика написана на потоках — этот код, скорее всего, надо выбросить, потому что в нем наверняка есть ошибки. Если вам кажется, что ошибок нет, значит вы просто их еще не нашли.

    Вот это, блин, новость. Ну правильно, если нет у тебя вменяемой поддержки тредов, то давайте объявим треды злом. JS уже бежит следом, радостно поддакивая.


    1. gecube
      11.12.2019 21:34

      Что такое вменяемая поддержка тредов? Я почти наверняка уверен, что даже с настоящими тредовыми языками — все равно будут проблемы с синхронизацией потоков, общим стейтом, дедлоками и гоночками. Разве не так ?


      1. deseven
        11.12.2019 22:55

        Вменяемая поддержка тредов это точно не как GIL в пайтоне.

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


    1. gorodnev
      12.12.2019 21:01

      Имхо, у автора спутано теплое с мягким — антипаттерном я бы называл ручное управление потоками (непосредественное использование низкоуровнего API операционных систем). А сами потоки вполне хорошо работают в реализациях высокоуровневых конструкций и их использование вполне себе best practices.


  1. akardapolov
    11.12.2019 19:48

    Elixir и Phoenix оставили приятные впечатления. Быстрый старт, только первое время тяжеловато воспринимать конвееры, неизменяемое состояние. Небольшой проект, сделал за 10 дней с нуля (fast and dirty) здесь.


  1. tumikosha
    12.12.2019 12:32
    +1

    "все остальное уже реализовано в Erlang: работа с сетью, обработка HTTP и веб-сокетов, работа с базами данных"
    Спасибо, посмеялся. Там уже завезли поддержку unicode strings?
    В Erlange даже http-реквесты нормально сделать проблема :) Дрова к базам сплошь кривые да устаревшие. Слишком маленькое коммюнити, некому и не для кого писать.
    Желающему что-то реализовать на ерланге придется непрерывно заниматься велосиподостроением. Но статья хорошая! Всегда полезно сравнивать.


    1. Virviil
      12.12.2019 14:06

      Скорее всего вы отстали лет на пять-семь.


      Загляните в Hex с его десятком тысяч пакетов и попробуйте прикинуть на вашем текущем проекте к примеру — какой процент недостающих пакетов был бы, если бы вы писали его на Эликсире?


      (Если вы действительно озаботитесь этим, скиньте пожалуйста название этих пакетов — у меня есть карманная армия велосипедостроителей, которая уже завтра их напишет)


  1. kraglik
    13.12.2019 08:12

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

    Вдруг кому интересно.