что такое ctl потоки данных
Что такое ctl потоки данных
CTL (от англ. cross-the-line) — совокупность рекламно-маркетинговых инструментов и технологий. В основе CTL лежит сбалансированный комплекс следующих факторов: цели и задачи кампании, предпочтения целевой аудитории по форме и механике, конкретные УТП и/или особенности продукта и, как следствие, выбор оптимальной коммуникации.
В отличие от BTL, где целевая аудитория зачастую «размыта» и для ее достижения используется стандартные механики, CTL подразумевает четкую сегментацию целевой аудитории, определение параметров кампании и использует наиболее эффективную коммуникацию в зависимости от особенностей продукта и предпочтений конкретного сегмента ЦА.
• Если УТП продукта не очевидно для ЦА либо незначительно для принятия решения о покупке.
• Когда стандартные механики, традиционно применимые для продвижения продукта, не приводят к желаемому результату.
• Если целевая аудитория «размыта» либо нечетко сегментирована.
• В случае существующих законодательных либо иных ограничений.
Смотреть что такое «CTL» в других словарях:
CTL — can refer to: *Computation tree logic *Cut to length logging *Complex Text Layout languages *cytotoxic T cell (cytotoxic T lymphocyte) *Commission de transport de la Ville de Laval *Coal to liquids, the chain of chemical processes to transform… … Wikipedia
CTL — CTL, sigle composé des trois lettres C, T et L, peut faire référence à : Charleville dans le Queensland, en Australie, selon la liste des codes AITA des aéroports, CTL de l anglais Coal to liquid ou en français charbon liquide. Désigne en… … Wikipédia en Français
ctl — ctl; CTL; … English syllables
CTL — UK US noun [U] ► INSURANCE ABBREVIATION for CONSTRUCTIVE TOTAL LOSS(Cf. ↑constructive total loss) … Financial and business terms
CTL — (cytotoxic T lymphocyte) killer T cells, immune system cell that kills cancerous or infected cells, primary type of cell involved in cell mediated immunity (Immunology) … English contemporary dictionary
CTL* — Die Computation Tree Logic (kurz CTL) ist eine Temporale Logik, die speziell zur Spezifikation und Verifikation von Computersystemen dient. Meist wird sie auch mit CTL* bezeichnet. CTL bezeichnet dann eine spezielle Teilmenge der CTL* Formeln.… … Deutsch Wikipedia
CTL — Die Abkürzung CTL steht für Cobalt Titan Lithium, Spezial Knopfzelle, aufladbar Complex Text Layout, Schriftsysteme, die nicht von links nach rechts ausgerichtet sind. Computation Tree Logic Control Track Longitudinal, ein Bildzählimpuls auf der… … Deutsch Wikipedia
CTL — See cytotoxic T cells … Dictionary of molecular biology
PipelineDB: работа с потоками данных
Ниже мы сравним PipelineDB с существующими решениями аналогичного плана, приведём краткую инструкцию по установке и первичной настройке, а также разберём практический пример.
Обработка данных в реальном времени: экскурс в историю
Принцип работы PipelineDB можно сформулировать так: «постоянные запросы, кратковременные данные». В реляционных СУБД всё обстоит ровно наоборот: «кратковременные запросы, постоянные данные. В PipelineDB данные не хранятся, а поступают в потоке; их обработка происходит «на лету», в движении.
Преимущества PipelineDB перед упомянутыми выше инструментами очевидны:
Рассмотрим, как в PipelineDB строится работа с потоками данных. Начнём с анализа двух важнейших понятий: «непрерывное представление» и «поток».
Потоки и непрерывные представления
«Поток» и «непрерывное представление» — главные абстракции PipelineDB.
Поток — это последовательность событий. Запись событий в поток осуществляется точно так же, как запись в таблицы в реляционных ДБ (подробнее об этом см. здесь ). Когда событие поступает в поток, к нему добавляется временная метка (timestamp).
Потоки в PipelineDB выполняют вспомогательную функцию, которая заключается в поставке данных для непрерывных представлений. В отличии от таблиц, для потоков не нужно создавать схемы. В поток можно записывать данные, пока он взаимодейстсвует хотя бы с одним непрерывным представлением.
Непрерывное представление (англ. continuous view) — это выборка из потоков и таблиц, обновляемая по мере поступления новых данных. В непрерывные представления попадают события, отбираемые по определённым параметрам.
Чтобы лучше понять, как работает PipelineDB, приведём несколько примеров непрерывных представлений.
Вот так, например, можно создать непрерывное представление для ежедневного подсчёта числа уникальных посетителей, приходящих на сайт по внешним ссылкам:
Ещё один пример — подсчёт числа показов рекламы на сайте за последние 5 минут:
Как видим, непрерывные представления имеют следующую форму:
При создании непрерывного представления по отношению к потокам выполняется операция SELECT, с помощью которой отбираются данные, соответствующие требуемым параметрам.
Основные теоретические сведения, необходимые для понимания принципов работы PipelineDB, мы изложили. Переходим к практической части. Сначала мы опишем процедуру установки и первичной настройки PipelineDB, а затем перейдём к практическим примерам.
Установка и первичная настройка
Чтобы установить PipelineDB, достаточно выполнить две команды:
После этого инициализируем сервер PipelineDB:
Основные настройки PipelineDB хранятся в файле pipelinedb.conf. Они почти не отличаются от соответствующих настроек PostgreSQL.
По умолчанию PipelineDB не может принимать соединения с удалённых хостов. Чтобы изменить эту настройку, откроем файл pipelinedb.conf, найдём в нём раздел Connections and Authentication, расскомментируем первую строку и отредактируем её следующим образом:
После этого пропишем конкретные хосты в файле pg_hba.conf:
Если нам нужно принимать соединения со всех возможных хостов, эта строка должна выглядеть так:
Вот и всё. PipelineDB готова к работе.
Чтобы запустить её в фоновом режиме, выполним следующую команду:
Практический пример: анализируем статистику Википедии
Мы разобрали необходимую теорию, а также описали процедуры установки и первичной настройки PipelineDB. Переходим к особенностям использования PipelineDB на практике.
Нас будут интересовать максимальное, минимальное и среднее количество обращений к странице в течение часа, а также 99-й перцентиль обращений.
Активируем выполнение непрерывных запросов:
После этого создадим непрерывное представление:
В приведённой команде указано, что мы будем получать данные для непрерывного представления из потока wiki_stream. Чтобы создать такой поток, нам потребуется загрузить с сайта данные, разархивировать, записать в стандартный вывод, а после этого передать PipelineDB с помощью команды COPY:
Отметим, что объём данных очень велик (они хранятся в виде архивов по 80-90 MБ каждый), и их загрузка может занять продолжительное время. Загрузку можно остановить в любой момент нажатием стандартной комбинации клавиш Ctrl+C.
По завершении загрузки выполним команду:
Результат будет представлен в виде таблицы (приводим лишь небольшой фрагмент):
Заключение
PipelineDB — интересный и перспективный продукт. Надеемся, что он будет успешно развиваться и в дальнейшем.
Если у вас есть опыт использования PipelineDB на практике — будем рады, если вы поделитесь опытом в комментариях.
Для желающих узнать больше приводим несколько полезных ссылок:
Принципы построения систем потоковой аналитики
Проектирование систем потоковой аналитики и потоковой обработки данных имеет свои нюансы, свои проблемы и свой технологический стек. Об этом мы поговорили в очередном открытом уроке, прошедшим накануне запуска курса «Data Engineer».
На вебинаре обсудили:
Когда нужна потоковая обработка? Stream vs Batch
Прежде всего, следует разобраться, когда нам нужна потоковая, а когда пакетная обработка. Давайте поясним сильные и слабые стороны этих подходов.
Итак, минусы пакетной обработки (batch):
Пример 2. Аналитика для веб-портала:
Таким образом, во втором примере лучше использовать стримы.
Элементы СПОД
Инженеры обработки данных захватывают, перемещают, доставляют, преобразовывают и хранят эти самые данные (да-да, хранить данные — это тоже активный процесс!).
Следовательно, чтобы построить систему потоковой обработки данных (СПОД), нам будут нужны следующие элементы:
Инструменты для обработки потоков данных
На роль загрузчика данных у нас есть несколько «кандидатов»:
Apache Flume
Первый, о ком поговорим — это Apache Flume — инструмент для транспортировки данных между различными источниками и хранилищами.
Выше мы создаём один простейший канал, который “сидит” на порту, берёт оттуда данные и просто их логирует. В принципе, для описания одного процесса это ещё нормально, но когда у вас таких процессов десятки, конфигурационный файл превращается в ад. Кто-то добавляет некие визуальные конфигураторы, но зачем мучиться, если есть инструменты, которые делают это из коробки? Например, те же NiFi и StreamSets.
Apache NiFi
По сути, выполняет ту же роль, что и Flume, но уже с визуальным интерфейсом, что большой плюс, особенно когда процессов много.
Пару фактов о NiFi
У нас есть поле для творчества и этапы обработки данных, которые мы туда накидываем. Есть много коннекторов на все возможные системы и т. д.
StreamSets
Это тоже система управления потоком данных с визуальным интерфейсом. Она разработана выходцами из Cloudera, легко устанавливается в виде Parcel на CDH, имеет особую версию SDC Edge для сбора данных с устройств.
Состоит из двух компонент:
Неприятный момент — у StreamSets есть как бесплатная, так и платная части.
Шина обмена данными
Теперь давайте разберёмся, куда мы эти данные будем заливать. Претенденты:
Во всех остальных случаях, Kafka — отличный выбор. По сути, это брокер сообщений с горизонтальным масштабированием и огромной пропускной способностью. Он отлично интегрирован во всю экосистему инструментов для работы с данными и выдерживает большие нагрузки. Обладает универсальнейшим интерфейсом и является кровеносной системой нашей обработки данных.
Внутри Kafka делится на Topic — некий отдельный поток данных из сообщений с одинаковой схемой или, хотя бы, с одинаковым назначением.
Чтобы обсудить следующий нюанс, нужно вспомнить, что источники данных могут немного различаться. Очень важен формат данных:
Отдельного упоминания заслуживает формат сериализации данных Apache Avro. Система использует JSON для определения структуры данных (схемы), которые сериализуются в компактный бинарный формат. Следовательно, мы экономим огромное количество данных, а сериализация/десериализация происходит дешевле.
Вроде бы всё неплохо, но наличие отдельных файлов со схемами порождает проблему, так как нам нужно между разными системами обмениваться файлами. Казалось бы, это просто, но когда вы работаете в разных отделах, ребята на другом конце могут что-нибудь поменять и успокоиться, а у вас всё поломается.
Чтобы не передавать все эти файлы на флешках, дискетах и наскальных рисунках, существует специальный сервис — Schema registry. Это сервис для синхронизации avro-схем между сервисами, которые пишут и читают из Kafka.
В терминах Kafka продюсер — это тот, кто пишет, консьюмер — тот, кто потребляет (читает) данные.
Хранилище данных
Претенденты (на самом деле вариантов много больше, но возьмем лишь несколько):
Как этого добиться в стриминговых системах:
Что касается ClickHouse, то это аналитическая база данных от Yandex. Её главное назначение — аналитика на таблице, наполняемой большим потоком сырых данных. Из плюсов — есть движок ReplacingMergeTree для дедупликации по ключу (дедупликация предназначена для экономии места и может оставлять дубликаты в некоторых случаях, нужно учитывать нюансы).
Остаётся добавить несколько слов про Divolte. Если помните, мы говорили о том, что некоторые данные нужно захватывать. Если вам нужно быстро и на коленке организовать аналитику для какого-нибудь портала, то Divolte — отличный сервис для захвата пользовательских событий на веб-странице через JavaScript.
Практический пример
Что попытаемся сделать? Попробуем построить пайплайн, чтобы в реальном времени собирать Clickstream-данные. Clickstream — виртуальный след, который пользователь оставляет во время нахождения на вашем сайте. Будем захватывать данные с помощью Divolte, а писать их в Kafka.
Для работы нужен Docker, плюс потребуется клонировать следующий репозиторий. Всё происходящее будет запущено в контейнерах. Чтобы согласованно запустить сразу несколько контейнеров будет использоваться docker-compose.yml. Кроме того, есть Dockerfile, собирающий наш StreamSets с определёнными зависимостями.
Также есть три папки:
Для запуска вводим следующую команду:
И наслаждаемся тем, как медленно, но верно запускаются контейнеры. После запуска мы можем перейти по адресу http://localhost:18630/ и сразу же потрогать Divolte:
Итак, у нас есть Divolte, который уже получил какие-то события и записал их в Kafka. Попробуем их высчитать с помощью StreamSets: http://localhost:18630/ (пароль/логин — admin/admin).
Чтобы не мучиться, лучше импортировать Pipeline, назвав его, к примеру, clickstream_pipeline. А из папки examples импортируем clickstream.json. Если всё ок, увидим следующую картину:
Итак, мы создали connection к Кафке, прописали, какая Кафка нам нужна, прописали, какой топик нас интересует, потом выбрали те поля, которые нас интересуют, потом поставили слив в Кафку, прописав, в какую Кафку и какой топик. Отличия в том, что в одном случае, Data format — это Avro, а во втором — просто JSON.
Идём дальше. Мы можем, например, сделать превью, которое захватит в реальном времени из Кафки определённые записи. Далее всё записываем.
Выполнив запуск, увидим, что у нас в Кафку летит поток событий, причём это происходит в реал-тайме:
Теперь можно сделать для этих данных хранилище в ClickHouse. Чтобы работать с ClickHouse, можно использовать простой нативный клиент, выполнив следующую команду:
Обратите внимание — в этой строке указана сеть, к которой нужно подключаться. И в зависимости от того, как у вас называется папка с репозиторием, название сети у вас может отличаться. В общем случае команда будет следующей:
Список сетей можно посмотреть командой:
Что же, осталось всего ничего:
1. Сначала «подпишем» наш ClickHouse на Кафку, «объяснив ему», какого формата данные там нам нужны:
2. Теперь создадим реальную таблицу, куда будем класть итоговые данные:
3. А потом обеспечим связь между этими двумя таблицами:
4. А теперь выберем необходимые поля:
В итоге выбор из целевой таблицы даст нужный нам результат.
Вот и всё, это был простейший Clickstream, который можно построить. Если хотите выполнить вышеописанные шаги самостоятельно, смотрите видео целиком.
Асинхронность в программировании
В области разработки высоконагруженных многопоточных или распределенных приложений часто возникают дискуссии об асинхронном программировании. Сегодня мы подробно погрузимся в асинхронность и изучим, что это такое, когда она возникает, как влияет на код и язык программирования, которым мы пользуемся. Разберемся, зачем нужны Futures и Promises и затронем корутины и операционные системы. Это сделает компромиссы, возникающие во время разработки ПО, более явными.
В основе материала — расшифровка доклада Ивана Пузыревского, преподавателя школы анализа данных Яндекса.
Видеозапись
1. Содержание
2. Введение
Всем привет, меня зовут Иван Пузыревский, я работаю в компании Яндекс. Последние лет шесть я занимался инфраструктурой хранения и обработки данных, сейчас перешел в продукт — в поиск путешествий, отелей и билетов. Так как я работал долгое время в инфраструктуре, то у меня накопилось довольно много опыта, как писать разные нагруженные приложения. Наша инфраструктура работает 24*7*365 каждый день нон-стоп, непрерывно на тысячах машин. Естественно, нужно писать код так, чтобы он работал надежно и производительно и решал задачи, которые перед нами ставит компания.
Сегодня мы с вами поговорим про асинхронность. Что такое асинхронность? Это несовпадение чего-либо с чем-либо во времени. Из этого описания вообще не понятно, про что я сегодня буду говорить. Чтобы как-то пояснить вопрос, мне нужен пример а-ля «Hello, world!». Асинхронность обычно возникает в контексте написания сетевых приложений, поэтому у меня будет сетевой аналог «Hello, world!». Это приложение ping-pong. Код выглядит таким образом:
Я создаю сокет, читаю оттуда строку, и проверяю — если это ping, то пишу в ответ pong. Очень просто и понятно. Что происходит, когда вы видите такой код на экране своего компьютера? Мы думаем об этом коде как о последовательности вот таких шагов:
С точки зрения реального физического времени все немного смещено.
Те, кто реально такой код писал и запускал, знают, что после шага read и после шага
write идет довольно заметный интервал времени, когда наша программа вроде бы ничего не делает с точки зрения нашего кода, но под капотом работает машинерия, которую мы называем «ввод-вывод».
Во время ввода/вывода происходит обмен пакетами по сети и вся сопутствующая тяжелая низкоуровневая работа. Проведем мысленный эксперимент: возьмем такую одну программу, запустим на одном физическом процессоре и сделаем вид, что у нас нет никакой операционной системы, что получится? Процессор не может остановиться, он продолжает делать такты, не исполняя никаких инструкций, просто зря потребляя энергию.
Возникает вопрос, можем ли мы в этот период времени сделать что-нибудь полезное. Очень естественный вопрос, ответ на который позволил бы нам сэкономить процессорные мощности и использовать их для чего-то полезного, пока наше приложение вроде как ничего не делает.
3. Основные понятия
3.1. Поток выполнения
Как мы можем подступиться к этой задаче? Давайте согласуем понятия. Я буду говорить «поток выполнения», имея в виду некоторую осмысленную последовательность элементарных операций или шагов. Осмысленность будет определяться контекстом, в котором я говорю о потоке выполнения. То есть если мы говорим про однопоточный алгоритм (Ахо-Корасик, поиск по графу), то сам этот алгоритм — уже есть поток выполнения. Он делает какие-то шаги для решения задачи.
Если я говорю о базе данных, то одним потоком выполнения может быть часть действий, совершаемых базой данных для обслуживания одного пришедшего запроса. То же и для веб-серверов. Если я пишу какое-то мобильное или веб-приложение, то для обслуживания одной операции пользователя, например, клика на кнопку, происходят сетевые взаимодействия, взаимодействие с локальным хранилищем и так далее. Последовательность этих действий с точки зрения моего мобильного приложения будет также отдельным осмысленным потоком выполнения. С точки зрения операционной системы, процесс или нить процесса также являются осмысленным потоком выполнения.
3.2. Многозадачность и параллелизм
Краеугольный камень производительности — это умение сделать такой трюк: когда у меня есть один поток выполнения, который содержит в своей физической временной развертке пустоты, тогда заполнить эти пустоты чем-нибудь полезным — исполнить шаги других потоков выполнения.
Базы данных обычно обслуживают много клиентов одновременно. Если мы можем совместить работу над несколькими потоками выполнения в рамках одного потока выполнения более высокого уровня, то это называется многозадачность. То есть многозадачность — это когда я в рамках одного более крупного потока выполнения совершаю действия, которые подчинены решению более мелких задач.
При этом важно не путать понятие многозадачности с параллелизмом. Параллелизм —
это свойства среды исполнения, которое дает возможность за один такт времени, за один шаг, совершить прогресс в разных потоках выполнения. Если у меня есть два физических процессора, то за один такт времени они могут исполнить две инструкции. Если программа запущена на одном процессоре, то ей потребуется два такта времени, чтобы исполнить эти же две инструкции.
Важно не путать эти понятия, так как они относятся к разным категориям. Многозадачность — это свойство вашей программы, что она внутри структурирована как переменная работа над разными задачами. Параллелизм — это свойство среды исполнения, которое дает вам возможность за один такт времени работать над несколькими задачами.
Во многом асинхронный код и написание асинхронного кода — это написание многозадачного кода. Основная сложность связана с тем, как мне кодировать задачи и как ими управлять. Поэтому сегодня мы будем говорить именно об этом — о написании многозадачного кода.
4. Блокирование и ожидание
Начнем с какого-нибудь простого примера. Вернемся к ping-pong:
Как мы уже обсудили, после строчек read и white поток выполнения засыпает, блокируется. Обычно мы так и говорим, «поток заблокирован».
Это значит, что поток выполнения дошел до такой точки, когда для дальнейшего его продолжения необходимо наступление какого-либо события. В частности, в случае нашего сетевого приложения нужно, чтобы поступили данные по сети или, наоборот, у нас освободился буфер для записи данных в сеть. События могут быть разные. Если мы говорим про временные аспекты, то можем ждать срабатывания таймера или завершения другого процесса. События здесь — некая абстрактная вещь, про них важно понимать, что их можно ожидать.
Когда мы пишем простой код, то неявно отдаем управление ожиданием событий уровню выше. В нашем случае — операционной системе. Она, как сущность более высокого уровня, отвечает за выбор, какая задача будет исполняться далее, и она же отвечает за отслеживание наступления событий.
Наш код, который мы пишем как разработчики, структурирован в это же время относительно работы над одной задачей. Фрагмент кода из примера занимается обработкой одного соединения: он из одного соединения читает ping и в одно соединение пишет pong.
Код понятный. Его можно прочитать и понять, что он делает, как он работает, какую задачу решает, какие инварианты в нем есть и так далее. При этом мы очень слабо управляем планированием задач в такой модели. Вообще, в операционных системах есть понятия приоритетов, но если вы писали системы мягкого реального времени, то знаете, что инструментов, доступных в Linux, не хватает для создания достаточно вменяемых систем реального времени.
Далее, операционная система — штука сложная, и переключение контекста из нашего приложения в ядро стоит единицы микросекунд, что при некотором несложном подсчете дает нам оценку на порядка 20-100 тысяч переключений контекста в секунду. Это значит, что если мы пишем веб-сервер, то за одну секунду можем обработать порядка 20 тысяч запросов, предполагая, что обработка запросов стоит в десять раз дороже, чем работа системы.
4.1. Неблокирующее ожидание
Если вы приходите к ситуации, что вам нужно работает с сетью более эффективно, то вы начинаете искать помощь в интернете и приходите к использованию select/epoll. В интернете написано, что если хочется обслуживать тысячи соединений одновременно, нужен epoll, потому что это хороший механизм и так далее. Вы открываете документацию и видите что-то типа такого:
Функции, в которых в интерфейсе фигурирует либо множество дескрипторов, с которыми вы работаете (в случае select), либо множество событий, которые проходят
через границы вашего приложения ядра операционной системы, которые вам нужно обрабатывать (в случае epoll).
Также стоит добавить, что можно прийти не к select/epoll, а к библиотеке типа libuv, у которой в API не будет никаких событий, но будет множество коллбэков. Интерфейс библиотеки будет говорить: «Дорогой друг, для чтения сокета предоставь коллбэк, который я позову, когда появятся данные».
Что поменялось по сравнению с нашим синхронным кодом в предыдущей главе? Код стал асинхронным. Это значит, что мы в приложение забрали логику по определению момента времени, когда отслеживается наступление событий. Явные вызовы select/epoll — это точки, где мы запрашиваем у операционной системы информацию о наступивших событиях. Также мы забрали в код своего приложения выбор, над какой задачей работать дальше.
Из примеров интерфейсов можно заметить, что механизмов привнесения многозадачности принципиально есть два. Один вида «тяни», когда мы
вытягиваем множество наступивших событий, которые мы ждем, и дальше на них как-то реагируем. В таком подходе легко амортизировать накладные расходы на одно
событие и поэтому достигать высокой пропускной способности по коммуникации о множестве наступивших событий. Обычно все сетевые элементы вроде взаимодействия ядра с сетевой карточкой или взаимодействия вас и операционной системы построены на poll-механизмах.
Второй способ — это механизм вида «толкай», когда некая внешняя сущность явно приходит, прерывает поток выполнения и говорит: «Теперь, пожалуйста, обработай событие, которое сейчас наступило». Это подход с коллбэками, с uniх-сигналами, с прерываниями на уровне процессора, когда внешняя сущность явно вторгается в ваш поток выполнения и говорит: «Сейчас, пожалуйста, работаем вот над этим событием». Такой подход появился для того, чтобы уменьшить задержку между наступлением события и реакцией на него.
Зачем мы, разработчики на C++, которые пишут и решают конкретные прикладные задачи, можем захотеть притащить в свой код событийную модель? Если мы перетаскиваем в свой код работу над многими задачами и управление ими, то из-за отсутствия перехода в ядро и обратно, мы можем чуть быстрее работать и за единицу времени совершать больше полезных действий.
К чему это приводит с точки зрения кода, который мы пишем? Возьмем, к примеру, nginx — высокопроизводительный HTTP-сервер, очень распространенный. Если почитать его код, он построен по асинхронной модели. Код читать довольно сложно. Когда ты задаешься вопросом, что же конкретно происходит при обработке одного HTTP-запроса, то оказывается, что в коде есть очень много фрагментов, разнесенных по разным файлам, по разным углам кодовой базы. Каждый фрагмент совершает маленький объем работы в рамках обслуживания всего HTTP-запроса. К примеру:
Есть структура request, которая пробрасывается в обработчик наступивших событий, когда сокет сигнализирует о доступности на чтение или на запись. Дальше этот обработчик постоянно по ходу работы программы переключается в зависимости от того, в каком состоянии находится обработка запроса. Либо мы читаем заголовки, либо читаем тело запроса, либо спрашиваем у upstream данные — в общем, много разных состояний.
Такой код сложновато читать, потому что он, в своей сути, описан в терминах реакции на события. Мы находимся в таком-то состоянии и реагируем определенным образом на наступившие события. Не хватает целостной картины обо всем процессе обработки HTTP-запроса.
Другой вариант – который обычно часто используется в JavaScript — это построение кода на основе коллбеков, когда мы пробрасываем в интерфейсный вызов свой коллбэк, в котором обычно есть еще какой-нибудь вложенный коллбэк на наступление события и так далее.
Код опять сильно фрагментирован, нет понимания текущего состояния, как мы работаем над запросом. Через замыкания передается много информации, и нужно прикладывать умственные усилия, чтобы реконструировать логику обработки одного запроса.
Таким образом, привнеся в свой код многозадачность (логику выбора рабочих задач и их мультиплексирования), мы получаем эффективный код и контроль над приоритезацией задач, но очень сильно теряем в читаемости. Этот код сложно читать и сложно поддерживать.
Почему? Представим, у меня есть простой кейс, например, я читаю файл и передаю его по сети. В неблокирущем варианте этому кейсу будет соответствовать такой линейный конечный автомат:
Теперь, допустим, я хочу к этому файлу добавить информацию из базы данных. Простой вариант:
Вроде как линейный код, но количество состояний увеличилось.
Дальше вы начинаете думать, что было бы неплохо распараллелить два шага — чтение из файла и из базы данных. Начинаются чудеса комбинаторики: вы находитесь в начальном состоянии, запрашиваете чтение файла и данные из базы данных. Дальше вы можете прийти либо в состояние, где есть данные из базы данных, но нет файла, либо наоборот — есть данные из файла, но нет из базы данных. Далее нужно перейти в состояние, когда у вас есть одно из двух. Опять же это два состояния. Потом нужно перейти в состояние, когда у вас есть оба ингредиента. Потом писать их в сокет и так далее.
Чем сложнее приложение, тем больше состояний, тем больше фрагментов кода, которые нужно комбинировать в своей голове. Неудобно. Либо вы пишете лапшу из коллбэков, которую читать неудобно. Если пишется развесистая система, то однажды наступает момент, когда терпеть это больше нельзя.
5. Futures/Promises
Чтобы решить проблему, нужно посмотреть на ситуацию проще.
Есть программа, в ней есть черные и красные кружочки. Наш поток выполнения – это черные кружочки; иногда они перемежаются красными, когда поток не может продолжать свою работу. Проблема в том, что для нашего черного потока выполнения нужно попасть в следующий черный кружочек, который будет неизвестно когда.
Проблема в том, что когда мы пишем код на языке программирования, мы объясняем компьютеру, что делать прямо сейчас. Компьютер — условно простая штука, которая ожидает инструкции, которые мы пишем на языке программирование. Она ожидает инструкции про следующий кружочек, и в нашем языке программирования не хватает средств, чтобы сказать: «В будущем, пожалуйста, когда наступит некоторое событие, сделай что-нибудь».
В языке программирования мы оперируем понятными сиюминутными действиями: вызов функции, арифметические операции и тд. Они описывают конкретный ближайший следующий шаг. При этом для обработки логики приложения нужно описывать не следующий физический шаг, а следующий логический шаг: что нам делать, когда появятся данные из базы данных, например.
Поэтому нужен некий механизм, как комбинировать эти фрагменты. В случае, когда мы писали синхронный код, мы скрыли вопрос полностью под капот и сказали, что этим будет заниматься операционная система, разрешили ей прерывать и перепланировать наш потоки выполнения.
В уровне 1 мы открыли этот ящик Пандоры, и он привнес в код много switch, case, условий, ветвей, состояний. Хочется какого-то компромисса, чтобы код был относительно читаемый, но сохранял все преимущества уровня 1.
К счастью для нас, в 1988 году люди, занимающиеся распределенными системами, Барбара Лисков и Люба Ширира, осознали проблему, и пришли к необходимости лингвистических изменений. В язык программирования нужно добавить конструкции, позволяющие выражать темпоральные связи между событиями — в текущем моменте времени и в неопределенном моменте в будущем.
Это назвали Promises. Концепция классная, но она двадцать лет пылилась на полке. В последнее время она набирает интерес — к примеру, товарищи в Twitter, когда рефакторили свой код с Ruby on Rails на Scala, прониклись этой концепцией достаточно глубоко, и решили, что все сервисы будут суть функция, которая берет запрос и возвращает future на ответ. Вы можете прочитать статью Your Server as a Function. Очень стройная концепция, которая позволила им очень оперативно переструктурировать весь код.
Но это Scala, а что делать нам, С++ разработчикам?
Нам нужна некая абстракция, назовем её Future. Это контейнер для значения типа T cо следующей семантикой: прямо сейчас значение в контейнере может отсутствовать, но когда-то в будущем оно появится.
С помощью этого контейнера мы будем связывать те значения, что появятся в будущем, с теми, что есть в настоящем моменте. То есть мы, находясь в «сейчас», будем говорить, что нужно будет сделать в будущем. В дальнейшем в рассказе Future будем называть интерфейсом для «чтения», а Promise — для «записи». В других языках программирования именования могут быть другими; к примеру, в JavaScript, Promise — интерфейс для чтения и записи одновременно, а в Java – есть только Future.
Чтобы проиллюстрировать идею, я буду использовать модельную реализацию. Если вам нужен реальный код, который можно использовать в своей кодовой базе, то стоит посмотреть на boost::future (не std::future) — в нем есть большая часть того, о чем мы будем говорить.
5.1. Интерфейс Future & Promise
Это контейнер, значит, есть какое-то значение, о котором мы хотим контейнер спросить. В частности, узнать, есть ли в нем значение сейчас, достать его оттуда. И, раз значения могут появиться в будущем, было бы неплохо иметь возможность подписаться — предоставить некоторую функцию, которая будет вызвана, когда появится значения. Для выразительности добавим ещё две функции Then, о которых я буду говорить позже.
Интерфейс для записи. Через него также можно опросить контейнер, есть ли в нем значение или нет. Можем сказать контейнеру «запиши, пожалуйста, значение, которое у меня есть в руках».
5.2. Композиция вычислений
В чем крутость конструкции? Крутость начинается, когда вы пытаетесь написать комбинаторы для связывания ваших вычислений. Функция Then — это то, что позволяет делать комбинации такого рода.
Я могу скомбинировать мое обещание в будущем получить значение t и желание применить трансформацию f. Тогда я получу обещание в настоящем моменте времени, о том, что в будущем появится значение r.
Появится каким способом: когда будет значение t, то я применю к нему функцию, получу значение r и положу в заранее подготовленный контейнер. С точки зрения кода это выглядит так:
На диаграмме мы находимся в левом нижнем углу, в будущем когда-то появится значение типа t. Мы над ним в будущем позовем функцию f, и ещё дальше в будущем появится значение r, которое положим в контейнер. Верхняя дуга связывает обещание, которое даем в настоящем и то значение, которое мы получим.
С точки зрения кода, в момент вызова Then случаются три шага:
Функция работает быстро и моментально связывает, что произойдет в настоящем и в будущем. Такой подход удобен тем, что позволяет прямо сейчас сконструировать конвейер обработки, не дожидаясь никаких событий, и выносит за скобки логику ожидания событий и вызова реакции на них.
Если обратить внимание на слайды, то можно заметить, что в предыдущих примерах я только подписываюсь на появление значения, но нигде не вызываю функции-обработчики. Чтобы конструкция заработала, нужны интерфейсы, чтобы вызывать функции-обработчики, переданные в Subscribe. Нужен поток или пул потоков в фоне, в которые вы сможете, при наступлении событий, запланировать функции-обработчики на исполнение. Они будут исполнены, и вся конструкция заведется.
5.3. Примеры
Мы это можем выразить таким сниппетом. Он утрированный, но что здесь важно: желание посчитать (2v+1) 2 с точки зрения исходного кода очень локально. Мы уже в этом сниппете кода написали, что хотим сделать во все последующие моменты времени и взглядом охватить происходящее.
С точки зрения времени исполнения, картинка не будет соответствовать написанному, но сейчас нас это беспокоить не должно. Мы хотим исправить проблему с понятностью кода.
Второй пример. Имеется несколько функций: первая считает ключ, по которому в БД хранятся секретные послания; вторая читает данные из БД по ключу; третья отправляет данные роверу на другой планете.
Можно скомбинировать все три шага в новую функцию — ExploreOuterSpace. С точки зрения кода она состоит просто из цепочки вызовов Then; логика работы функции — последовательная композиция действий — помещается на один экран, её просто понять и осознать. При этом все шаги исполнения будут во времени (скорее всего) разнесены. Темпоральный характер связывания вынесен за скобки.
5.4. Any-комбинатор
Приятный бонус: если применять конструкцию с Future в среде с параллелизмом, то можно выстроить ещё более интересные комбинаторы, которых нет в однопоточном последовательном коде. Например, можем сказать, что мы бы хотели дождаться появления любого из двух значений:
Мы создаем обещание, что в будущем посчитаем это Any-значение, и в подписке на два Future устраиваем гонку: кто первый успел, тот и молодец. То есть если в контейнере пусто, то кладем туда значение, которое появилось.
Это может понадобиться, к примеру, когда у нас есть две базы данных, и мы читаем из обеих, и смотрим, откуда быстрее пришел ответ. То есть пишем код вида «Отправить запрос в DB1, отправить запрос в DB2, и как только получили любой из ответов — делаем что-то ещё».
5.5. All-комбинатор
Симметрично можем устраивать барьеры. Если из двух баз данных мы читаем две существенно разные записи и хотим комбинировать их у себя локально, то можно один раз написать барьерную логику, создать контейнер, и заполнять его частями (пары T1 и T2), прописаться в обработчике T1 и T2 на появление значений, положить их соответствующие компоненты контейнера и завести счетчик, сколько шагов осталось.
Вспомним пример с nginx. Там отслеживание того, какие части обработки запросы уже закончены, был явный и привязанный к конкретному домену приложения. То есть в случае nginx были выделены стадии «разбираю заголовки», «читаю тело запроса», «пишу заголовки и ответа ответа» и так далее. В All-комбинаторе логика по отслеживанию завершенных шагов абстрагирована до подсчета того, сколько фрагментов уже закончило работу. Это позволяет схлопнуть сложность нашего приложения.
5.6. Адаптация обратного вызова
Третий плюс Future и Promises — они просто интегрируются с legacy-кодом, построенным на коллбеках. Можно элегантно подцепить существующие callback-ориентированные библиотеки в наш код, обернув их в несложную обертку, устроенную простым образом: мы создаем Future, который сразу же и возвращает, а в callback-функции заполняем Future.
Итого: мы получили простой и читаемый код, инструменты для композиции нашего кода и сохранили возможность реализовать исполнение фрагментов кода поверх Future неблокирующим образом.
6. Как сделать код читаемее с помощью корутин
Иногда возникают ситуации, когда у нас возникает понятный, но нелинейный по данным код. Рассмотрим сниппет.
У меня есть некая обработка запроса. Я получаю структуру Request, хожу за какими-то данным в бэкэнд. Они закодированы, поэтому их нужно отдельно декодировать. А потом, чтобы прислать ответ, нужно на руках одновременно иметь и декодированные данные, и оригинальную структуру запроса. К примеру, чтобы пробросить какой-то заголовок из моего запроса в ответ.
Вроде бы, связка читаема, но выглядит не совсем аккуратно. Что делать? Типичный подход — всё рефакторить, не пробрасывать отдельно request и payload, а завести абстрактный контекст — один аргумент, который всюду будем протаскивать сквозным образом.
Например, так в Java устроена библиотека Netty. Удобно, но тогда контекст становится общей тарелкой, куда разные фрагменты кода пишут или читают значения. Сложно понять, что в этом контексте реально заполнено и когда, и что там происходит.
Если бы мы писали синхронный код, то позвали бы GetRequest, QueryBackend, HandlePayload и потом Reply от двух аргументов, если бы не было Future.
Чтобы воплотить фантазии в реальность, нужен некий метод, который принимает Future и возвращает T — назовём его WaitFor.
Тогда мы реформируем код таким образом:
Почему стало проще: убрали обертку из Future, которую только что добавили. Код читаемый и удобный. Также теперь в нем явным образом размечены четыре точки, где мы ждем данных. Это точки разрыва нашего потока исполнения.
У нас появляется две опции. Мы в этих точках на уровне нашего приложения можем выбрать как именно мы будем связывать эти два разорванных фрагмента кода. Либо мы можем связывать а-ля уровнь 0, синхронно заблокировать наш тред через, к примеру, mutex+cvar внутри future. Либо можем поставить себе задачу сделать неблокирующее ожидание. Мы освободим квант времени в нашем приложении под другие задачи, а текущий поток исполнения заморозим.
6.1. Корутины
Это функции, в которых есть множество точек входа и выхода. Если вспомнить картинку с черными и красными кружочками, то там, где черный меняется на красный, нам надо выйти, потому что есть какие-то события, которых нужно подождать. Но если мы прерываем корутину, нам нужно выйти куда-то.
В нашем случае каждая точка ожидания — «выход» в некую сущность более высокого уровня, которая решит, какой задачей заняться далее. На уровне операционной системы это планировщик задач. У нас будет свой планировщик. Модельная имплементация: boost::asio и boost::fiber.
Чтобы переходить от текущего логического потока исполнения в планировщик, нам понадобится возможность менять контекст исполнения физического процессора и прыгать из одной точки программы в другую. Как это сделать?
6.2. Черновик реализации WaitFor
Есть разные инструменты, например, boost::context, который в сущности сводится к интерфейсу такого вида: есть структура, отвечающая за контекст; есть функция, которая принимает один контекст для сохранения текущего контекста и один контекст для загрузки следующего. В ассемблере для x86/64 типичная реализация выписывает все регистры на стек, сохраняет в структуре текущий указатель на стек, далее заменяет указатель на стек новым и восстанавливает регистры.
Пока можно думать об этой функции, как об управляемом в процессе исполнения goto: когда в коде говорите, что прыгните в другую точку, но она задается не статически в момент компиляции, а подбирается динамически.
Чтобы доработать конструкцию до рабочего состояния, нужно как-то оперировать запланированными задачами. Обычно планируемую задачу называют Fiber — логический поток выполнения. Мы её будем представлять как пару контекст+Future. Для того, чтобы привязывать все конструкции с ожиданием, будем хранить в каждом логическом потоке выполнения ту Future, которую он сейчас ждет.
Здесь Future будет либо пустой, если мы ничего не ждем, либо некий заполненный объект, если мы ожидаем этот объект. Планировщик устроен таким образом: основная функция Loop, которая содержит цикл основной работы программы, очередь логических потоков выполнения, которые нужно запланировать, указатель на текущий поток выполнения и контекст планировщика, в который будем переключаться.
Как будет выглядеть функция WaitFor?
Здесь важное наблюдение: для того, чтобы дождаться какого-то значения в будущем, нам не очень важен его тип, поэтому мы хотим работать с Future поверх void, и конкретный тип значения нас интересовать не будет. И нам придется только один раз реализовать логику по темпоральной привязке.
Конструкция Future игнорирует тип контейнера, использует его как абстрактный контейнер, который будет заполнен когда-то в будущем.
Тогда функция WaitFor будет выглядеть следующим образом: я говорю планировщику: «Я бы хотел в текущем Fiber дождаться появления Future», и в следующей инструкции моей текущей сопрограммы (потока выполнения) сразу из этого контейнера хочу извлечь значение.
Как планировщик будет обслуживать моё желание? Он запомнит, что текущий поток выполнения ожидает некоторый Future, и перейдет обратно в цикл планирования.
6.3. Как устроен цикл планирования
Раз мы что-то уже запустили, наверное, у нас была очередь, из которой мы извлекли какой-то готовый к планированию логический поток, и перепрыгнули в него. Когда мы сделали SwitchContext из нашего потока, возвращаемся в точку 2 — из логического потока выполнения возвращаемся в планировщик.
Что происходит дальше? Нам нужно понять, если поток выполнения, из которого мы вернулись, попросил нас дождаться появления Future, то тогда подпишемся на этот ожидаемый Future, чтобы, при появлении значения в будущем, мы могли обратно поставить наш логический поток выполнения в цикл планирования.
Чтобы не запутаться, можно посмотреть на два этих слайда. Ход исполнения программы будет устроен таким образом:
Когда я зову функцию WaitFor — перехожу в планировщик.
Планировщик с помощью Switch-контекста прыгает в основной цикл.
Дальше я подписываюсь на Future и в будущем (обозначено синей стрелкой), когда значение появится, я положу в очередь исполнения обратно мой логический поток. Тогда в какой-то следующей итерации я этот логический поток вытащу из очереди и перепрыгну обратно в мой Fiber.
Вернувшись в WaitFor я смогу извлечь из Future значение, и там уже точно что-то будет, Future будет заполнен. Мы получим такую классную конструкцию:
Мы сможем написать код, который выглядит как синхронный, в нем явно размечены точки ожидания, они хорошо читаются. При этом сохранены возможности приложения влиять на планирование задач, а переключать контекст дешевле, чем это делает ядро.
6.4. Coroutine TS
Можно ли сделать лучше? В будущем — да. Когда будет Coroutine TS, можно будет убрать скобочки в коде и сказать, что WaitFor и CoroutineWait, который получается из CoroutineTS — это более-менее одинаковые сущности. Это явно размеченные точки ожидания, где нам нужно прерывать текущий поток выполнения и чего-то дождаться. Соответственно, можно будет реализовать Waiter и Co, которые будут делать всё то, что на предыдущих слайдах.
7. Что получили?
Давайте подведем итог. С помощью корутин мы получили механизм для написания читаемого внятного кода, похожего на синхронный, сохранили возможность улучшать производительность, реализуя всю сложную машинерию. Но заплатили за это тем, что реализовали свой планировщик из операционной системы, и нам нужно его поддерживать, отлаживать и так далее.
Нужно это или нет — хороший вопрос. Нужно посмотреть на цифры, когда вообще этот механизм стоит использовать и какие у него есть плюсы и минусы по сравнению с другими. Переключение контекста процессора стоит порядка сотни наносекунд. Это на два порядка быстрее, чем переключить контекст нити исполнения. Соответственно, если в вашем коде фрагменты полезной логики очень маленькие, и время их выполнения сопоставимо с переключением контекста, тогда вы сможете оптимизировать свое приложение за счет того, что вы будете явно управлять многозадачностью.
Именно поэтому все сетевые вещи типа веб-серверов строятся как асинхронный код, потому что там действий по существу очень мало, там нужно перекладывать байты из одной корзины в другую корзину и ничего больше не делать. Там нет никаких симуляций, сложных решений. Процессор там по большому счету простаивает, и поэтому мы можем срезать накладные расходы на взаимодействие с ядром и больше делать в приложении, меньше платя за накладные расходы по переключению процессов или нитей.
Как понять из всех этих четырех вариантов написания кода, что делать дальше? Для этого нужно подумать, чего мы хотим как разработчики.
Давайте кратко резюмируем. На нулевом уровне мы писали понятный код, не писали кода по планированию, по ожиданию событий, по выбору, что делать дальше и так далее. Этими задачами за нас занималась операционная система. Если у вас не очень нагруженное приложение, и вам достаточно этого уровня, то дальше идти не нужно, вы можете здесь остаться, и все будет хорошо.
Если вы автор nginx, у вас, к сожалению, тернистый путь, вам нужно явно работать с низкими уровнями, вписать этот сложный код. И если вы хотите максимально уменьшать накладные расходы, то вы срезаете все абстракции, в частности, не используете никаких future и promises.
Если вы оказываетесь в ситуации, когда вы пишете большую и сложную систему, которая должна быть производительна, но не точна до копеек, и вам больше важна скорость вашей разработки, тогда вы готовы слегка пожертвовать производительностью, получив выгоду во времени вашей разработки, чтобы могли быстрее писать новый код.
Тогда вам будут удобны абстракции типа futures, promises и actors. Это может сэкономить время. При этом эти абстракции можно более глубоко интегрировать в язык за счет сопрограмм, как я постарался проиллюстрировать.
Важно: если вы работаете с асинхронным кодом, поймите, что вы умеете, и какую задачу вы решаете. Сейчас кажется очень легко найти в интернете материалы по асинхронном программированию на все случаи жизни, кучу библиотек с акторами, файберами, корутинами и вашими любимыми новыми технологиями, которые появятся завтра. Но нужно ли их использовать? Подумайте, это важный вопрос.
Минутка рекламы. 19-20 апреля в Москве пройдёт конференция C++ Russia 2019. На ней будут доклады похожей тематики, например, Grimm Rainer приедет с докладом «Concurrency and parallelism in C++17 and C++20/23», а Павел Новиков расскажет об асинхронной разработке на C++. Подробнее программу можно посмотреть на официальном сайте, там же можно приобрести билет. Обратите внимание, что если вы не сможете приехать на конференцию вживую, есть билеты на онлайн-трансляцию.