Русский
Русский
English
Статистика
Реклама

Многопоточность

Как заблокировать приложение с помощью runBlocking

10.02.2021 14:12:50 | Автор: admin

Когда мы начинаем изучать корутины, то идём и пробуем что-то простое с билдером runBlocking, поэтому многим он хорошо знаком. runBlocking запускает новую корутину, блокирует текущий поток и ждёт пока выполнится блок кода. Кажется, всё просто и понятно. Но что, если я скажу, что в runBlocking есть одна любопытная вещь, которая может заблокировать не только текущий поток, а вообще всё ваше приложение навсегда?

Напишите где-нибудь в UI потоке (например в методе onStart) такой код:

//где-то в UI потокеrunBlocking(Dispatchers.Main) {  println(Hello, World!)}

Вы получите дедлок приложение зависнет. Это не ошибка, а на 100% ожидаемое поведение. Тезис может показаться неочевидным и неявным, поэтому давайте погрузимся поглубже и я расскажу, что здесь происходит.


Сравним код выше с более низкоуровневым подходом с потоками. Вы можете написать в главном потоке вот так:

//где-то в UI потокеHandler().post {println("Hello, World!") // отработает в UI потоке}

Или даже так:

//где-то в UI потокеrunOnUiThread {  println("Hello, World!") // и это тоже отработает в UI потоке}

Вроде конструкция очень похожа на наш проблемный код, но здесь обе части кода работают (по-разному под капотом, но работают). Чем они отличаются от кода с runBlocking?

Как работает runBlocking

Для начала небольшой дисклеймер. runBlocking редко используется в продакшн коде Android-приложения. Обычно он предназначен для использования в синхронном коде, вроде функций main или unit-тестах.

Несмотря на это, мы всё-таки рассмотрим этот билдер при вызове в главном потоке Android-приложения потому, что:

  • Это наглядно. Ниже мы придем к тому, что это актуально и не только для UI-потока Android-приложения. Но для наглядности лучше всего подходит пример на UI-потоке.

  • Интересно разобраться, почему всё именно так работает.

  • Всё-таки иногда мы можем использовать runBlocking, пусть даже в тестовых приложениях.

Билдер runBlocking работает почти так же, как и launch: создает корутину и вызывает в ней блок кода. Но чтобы сделать вызов блокирующим runBlocking создает особую корутину под названием BlockingCoroutine, у которой есть дополнительная функция joinBlocking(). runBlocking вызывает joinBlocking() сразу же после запуска корутины.

Фрагмент из runBlocking():

// runBlocking() function// val coroutine = BlockingCoroutine<T>(newContext, )coroutine.start(CoroutineStart.DEFAULT, coroutine, block)return coroutine.joinBlocking()

Функция joinBlocking() использует механизм блокировки Java LockSupport для блокировки текущего потока с помощью функции park(). LockSupport это низкоуровневый и высокопроизводительный инструмент, обычно используется для написания собственных блокировок.

Кроме того, BlockingCoroutine переопределяет функцию afterCompletion(), которая вызывается после завершения работы корутины.

override fun afterCompletion(state: Any?) {//wake up blocked threadif (Thread.currentThread ()! = blockedThread)LockSupport.unpark (blockedThread)}

Эта функция просто разблокирует поток, если она была заблокирована до этого с помощью park().

Как это всё работает примерно показано на схеме работы runBlocking.

Что здесь делает Dispatchers

Хорошо, мы поняли, что делает билдер runBlocking. Но почему в одном случае он блокирует UI-поток, а в другом нет? Почему Dispatchers.Main приводит к дедлоку...

// Этот код создает дедлокrunBlocking(Dispatchers.Main) {  println(Hello, World!)}

...,а Dispatchers.Default нет?

// А этот код создает дедлокrunBlocking(Dispatchers.Default) {  println(Hello, World!)}

Для этого вспомним, что такое диспатчер и зачем он нужен.

Диспатчер определяет, какой поток или потоки использует корутина для своего выполнения. Это некий высокоуровневый аналог Java Executor. Мы даже можем создать диспатчер из Executorа с помощью удобного экстеншна:

public fun Executor.asCoroutineDispatcher(): CoroutineDispatcher

Dispatchers.Default реализует класс DefaultScheduler и делегирует обработку исполняемого блока кода объекту coroutineScheduler. Его функция dispatch() выглядит так:

override fun dispatch (context: CoroutineContext, block: Runnable) =  try {    coroutineScheduler.dispatch (block)  } catch (e: RejectedExecutionException) {    //    DefaultExecutor.dispatch(context, block)  }

Класс CoroutineScheduler отвечает за наиболее эффективное распределение обработанных корутин по потокам. Он реализует интерфейс Executor.

override fun execute(command: Runnable) = dispatch(command)

А что же делает функция CoroutineScheduler.dispatch()?

  • Добавляет исполняемый блок в очередь задач. При этом существует две очереди: локальная и глобальная. Это часть механизма приоритезации внешних задач.

  • Создает воркеры. Воркер это класс, унаследованный от обычного Java Thread (в данном случае daemon thread). Здесь создаются рабочие потоки. У воркера также есть локальная и глобальная очереди, из которых он выбирает задачи и выполняет их.

  • Запускает воркеры.

Теперь соединим всё, что разобрали выше про Dispatchers.Default, и напишем, что происходит в целом.

  • runBlocking запускает корутину, которая вызывает CoroutineScheduler.dispatch().

  • dispatch() запускает воркеры (под капотом Java потоки).

  • BlockingCoroutine блокирует текущий поток с помощью функции LockSupport.park().

  • Исполняемый блок кода выполняется.

  • Вызывается функция afterCompletion(), которая разблокирует текущий поток с помощью LockSupport.unpark().

Эта последовательность действий выглядит примерно так.

Перейдём к Dispatchers.Main

Это диспатчер, который создан специально для Android. Например, при использовании Dispatchers.Main фреймворк бросит исключение, если вы не добавляете зависимость:

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:..*'

Перед началом разбора Dispatchers.Main стоит поговорить о HandlerContext. Это специальный класс, который добавлен в пакет coroutines для Android. Это диспатчер, который выполняет задачи с помощью Android Handler всё просто.

Dispatchers.Main создаёт HandlerContext с помощью AndroidDispatcherFactory через функцию createDispatcher().

override fun createDispatcher() =  HandlerContext(Looper.getMainLooper().asHandler(async = true))

И что мы тут видим? Looper.getMainLooper().asHandler() означает, что он принимает Handler главного потока Android. Получается, что Dispatchers.Main это просто HandlerContext с Handlerом главного потока Android.

Теперь посмотрим на функцию dispatch() у HandlerContext:

override fun dispatch(context: CoroutineContext, block: Runnable) {  handler.post(block)}

Он просто постит исполняемый код через Handler. В нашем случае Handler главного потока.

Итого, что же происходит?

  • runBlocking запускает корутину, которая вызывает CoroutineScheduler.dispatch().

  • dispatch() отправляет исполняемый блок кода через Handler главного потока.

  • BlockingCoroutine блокирует текущий поток с помощью функции LockSupport.park().

  • Main Looper никогда не получает сообщение с исполняемым блоком кода, потому что главный поток заблокирован.

  • Из-за этого afterCompletion() никогда не вызывается.

  • И из-за этого текущий поток не будет разблокирован (через unparked) в функции afterCompletion().

Эта последовательность действий выглядит примерно так.

Вот почему runBlocking с Dispatchers.Main блокирует UI-поток навсегда.

Главный потокблокируется и ждёт завершения исполняемого кода. Но он никогда не завершается, потому что Main Looper не может получить сообщение на запуск исполняемого кода. Дедлок.

Совсем простое объяснение

Помните пример с Handler().post в самом начале статьи? Там код работает и ничего не блокируется. Однако мы можем легко изменить его, чтобы он был в значительной степени похож на наш код с Dispatcher.Main, и стал ещё нагляднее. Для этого можем добавить операции parking и unparking к текущему потоку, иммитируя работу функций afterCompletion() и joinBlocking(). Код начинает работать почти так же, как с билдером runBlocking.

//где-то в UI потокеval thread = Thread.currentThread()Handler().post {  println("Hello, World!") // это никогда не будет вызвано  // имитируем afterCompletion()  LockSupport.unpark(thread)}// имитируем joinBlocking()LockSupport.park()

Но этот трюк не будет работать с функцией runOnUiThread.

//где-то в UI потокеval thread = Thread.currentThread()runOnUiThread {  println("Hello, World!") // этот код вызовется  LockSupport.unpark(thread)}LockSupport.park()

Это происходит потому, что runOnUiThread использует оптимизацию, проверяя текущий поток. Если текущий поток главный, то он сразу же выполнит блок кода. В противном случае сделает post в Handler главного потока.

Если всё же очень хочется использовать runBlocking в UI-потоке, то у Dispatchers.Main есть оптимизация Dispatchers.Main.immediate. Там аналогичная логика как у runOnUiThread. Поэтому этот блок кода будет работать и в UI-потоке:

//где-то в UI потокеrunBlocking(Dispatchers.Main.immediate) {   println(Hello, World!)}

Выводы

В статье я описал как безобидный билдер runBlocking может заморозить ваше приложение на Android. Это произойдет, если вызвать runBlocking в UI-потоке с диспатчером Dispatchers.Main. Приложение заблокируется по следующему алгоритму:

  • runBlocking создаёт блокирующую корутину BlockingCoroutine.

  • Dispatchers.Main отправляет на запуск исполняемый блок кода через Handler.post.

  • Но BlockingCoroutine тут же заблокирует UI поток.

  • Поэтому Main Looper никогда не получит сообщение с исполняемым блоком кода.

  • А UI не разблокируется, потому что корутина ждёт завершения исполняемого кода.

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

Но заблокировать исполнение можно не только в UI-потоке, но и с помощью других диспатчеров, если поток вызова и корутины окажется одним и тем же. В такую ситуацию можно попасть, если мы будем пытаться вызвать билдер runBlocking на том же самом потоке, что и корутина внутри него. Например, мы можем использовать newSingleThreadContext для создания нового диспатчера и результат будет тот же. Здесь UI не будет заморожен, но выполнение будет заблокировано.

val singleThreadDispatcher = newSingleThreadContext("Single Thread")GlobalScope.launch (singleThreadDispatcher) {  runBlocking (singleThreadDispatcher) {    println("Hello, World!") // этот кусок кода опять не выполнится  }}

Если очень надо написать runBlocking в главном потоке Android-приложения, то не используйте Dispatchers.Main. Используйте Dispatchers.Default или Dispatchers.Main.immediate в крайнем случае.


Также будет интересно почитать:

Оригинал статьи на английском How runBlocking May Surprise You.
Как страдали iOS-ники когда выпиливали Realm.
О том, над чем в целом мы тут работаем: монолит, монолит, опять монолит.
Кратко об истории Open Source просто развлечься (да и статья хорошая).

Подписывайтесь начат Dodo Engineering, если хотите обсудить эту и другие наши статьи и подходы, а также на канал Dodo Engineering, где мы постим всё, что с нами интересного происходит.

Подробнее..

Долой циклы, или Неленивая композиция алгоритмов в C

15.06.2020 16:10:07 | Автор: admin
"Кто ни разу не ошибался в индексировании цикла, пусть первый бросит в деструкторе исключение."

Древняя мудрость

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


В конце концов, это просто некрасиво.


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


Данная работа ставит своей целью пролить свет на отнюдь не новую, но пока что не слишком распространённую идею, которая вполне способна произвести очередной прорыв в области написания программ на языке C++.


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



Содержание


  1. Существующие модели
  2. Базовые понятия
    1. Определение 1: свёртка
    2. Определение 2: ядро свёртки
  3. Идеология
    1. Факт 1: каждый цикл можно представить в виде свёртки
    2. Факт 2: большинство циклов расладываются на простые составляющие
    3. Факт 3: каждую свёртку можно представить в виде автомата
    4. Факт 4: автоматы комбинируются
    5. Снова к свёртке
  4. Я птичка, мне такое сложно, можно я сразу код посмотрю?
    1. Простой пример
    2. constexpr
  5. Многопоточность
  6. Сравнительная таблица
  7. Ссылки


Существующие модели


Основные на текущий момент способы избавления от циклов это алгоритмы из стандартной библиотеки и ленивые итераторы и диапазоны из библиотек Boost.Iterator, Boost.Range и range-v3.


range-v3 частично попали в стандартную библиотеку C++20, но, во-первых, попали они туда в достаточно усечённом виде, а во-вторых, соответствующих реализаций на текущий момент пока нет.

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


Именно из-за этого появились ленивые итераторы и диапазоны в сторонних библиотеках, а в C++17 появились гибриды семейства std::transform_reduce.


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


Возникает вопрос: можно ли объединить преимущества обоих подходов одновременно? Оказывается, можно. Об этом и пойдёт речь далее.



Базовые понятия


Для того, чтобы двинуться далее, необходимо разобраться с тем, что такое свёртка.



Определение 1: свёртка


Свёртка это вычислительный процесс, производимый над некоторой последовательностью значений по правилу, задаваемому ядром свёртки.


Результат свёртки значение, полученное последовательным применением ядра свёртки к текущему значению и очередному элементу последовательности.



Определение 2: ядро свёртки


Ядро свёртки это действие, производимое на каждом шаге свёртки. Применяется к текущему значению свёртки и очередному элементу последовательности.


Свёртка


На этом рисунке изображена свёртка последовательности $\{x_0, x_1, x_2\}$ с ядром $f$ и начальным значением $v_0$. $v_3$ результат свёртки.


В стандартной библиотеке свёртка представлена алгоритмами std::accumulate и std::reduce.



Идеология


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



Факт 1: каждый цикл можно представить в виде свёртки


И действительно:


  1. Контекст программы перед началом цикла начальное значение;
  2. Набор индексов, контейнер, диапазон и т.п. последовательность элементов;
  3. Итерация цикла применение двуместной операции (ядра свёртки) к текущему значению и очередному элементу последовательности, в результате чего текущее значение изменяется.

auto v = 0;                   // Начальное значение: v_0for (auto i = 0; i < 10; ++i) // Последовательность: [x_0, x_1, ...]{    v = f(v, i);              // Двуместная операция, изменяющая                              // значение: v_{i + 1} = f(v_i, x_i)}

Иначе говоря, для того, чтобы выразить любой цикл, достаточно базиса из одной единственной операции свёртки. А все остальные операции например, стандартные алгоритмы, можно выразить через неё.


Пример 1: отображение через свёртку


template <ForwardIterator I, OutputIterator J, UnaryFunction F>J transform (I begin, I end, J result, F f){    // Начальное значение  это выходной итератор.    auto initial_value = result;    // Ядро свёртки.    auto binary_op =        [] (auto iterator, auto next_element)        {            // Записываем в текущий итератор результат отображения...            *iterator = f(next_element);            // ... и возвращаем продвинутый итератор.            return ++iterator;        };    // Свёртка.    return accumulate(begin, end, initial_value, binary_op);}

Пример 2: фильтрация через свёртку


template <ForwardIterator I, OutputIterator J, UnaryPredicate P>J copy_if (I begin, I end, J result, P p){    // Начальное значение.    auto initial_value = result;    // Ядро свёртки.    auto binary_op =        [p] (auto iterator, auto next_element)        {            if (p(next_element))            {                *iterator = next_element;                ++iterator;            }            return iterator;        };    // Свёртка.    return accumulate(begin, end, initial_value, binary_op);}

Аналогичным образом выражаются и все остальные последовательные алгоритмы. Любознательный читатель может проделать это в качестве упражнения.



Факт 2: большинство циклов расладываются на простые составляющие


Если присмотреться, то станет понятно, что большинство циклов типовые. Они раскладываются на простые составляющие:


  • Преобразование;
  • Фильтрация;
  • Группировка;
  • Подсчёт;
  • Суммирование;
  • Запись в массив;
  • ...
  • и т.д.

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



Факт 3: каждую свёртку можно представить в виде автомата


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


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


Важно:

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

Кроме того, наш автомат может обладать памятью.

Автомат


Пример 1: автомат для отображения


Например, так будет выглядеть автомат для отображения (transform, или map в функциональном программировании).


Автомат для отображения


Здесь $a$ входной символ, $f$ функция преобразования.


Данный автомат имеет одно состояние и один переход. Каждый входной символ $a$ он преобразует с помощью функции $f$, и результат этого преобразования подаёт на выход. После этого возвращается в исходное состояние.


Пример 2: автомат для фильтрации


Автомат для фильтрации


Здесь $a$ входной символ, $p$ предикат, $\epsilon$ обозначение пустого символа.


Данный автомат имеет одно состояние и два перехода. Один переход реализуется тогда, когда входной символ $a$ удовлетворяет предикату $p$. В этом случае на выход подаётся сам символ $a$. В случае, если символ $a$ не удовлетворяет предикату, на выход подаётся пустой символ $\epsilon$ (то есть ничего не подаётся). В обоих случаях автомат возвращается в исходное состояние.



Факт 4: автоматы комбинируются


Если у автомата есть выход, то, очевидно, этот выход можно подать на вход другому автомату.


Композиция автоматов


Таким образом, имея набор из нескольких автоматов, каждый из которых задаёт одну операцию преобразования, можно составлять достаточно сложные преобразования.



Снова к свёртке


Чтобы получить нужную нам свёртку, в конец цепочки мы поставим автомат, который представляет собой ядро свёртки.


Цепочка с ядром в конце


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


Схлопнули


Итак, мы разложили цикл на простые составляющие и представили с помощью свёртки. В теории всё прекрасно, но как же это будет выглядеть в коде?



Код


На основе изложенных выше идей разработана библиотека Проксима.



Простой пример


#include <proxima/compose.hpp>#include <proxima/kernel/sum.hpp>#include <proxima/reduce.hpp>#include <proxima/transducer/stride.hpp>#include <proxima/transducer/take_while.hpp>#include <proxima/transducer/transform.hpp>#include <cassert>int main (){    const int items[] = {1, 2, 3, 4, 5};    const auto kernel =        proxima::compose        (            proxima::transform([] (auto x) {return x * x;}),   // 1. Каждый элемент возведён в квадрат;            proxima::stride(2),                                // 2. Берутся только элементы с номерами,                                                               //    кратными двойке (нумерация с нуля);            proxima::take_while([] (auto x) {return x < 10;}), // 3. Элементы берутся до тех пор, пока                                                               //    они меньше десяти;            proxima::sum                                       // 4. Результат суммируется.        );    const auto x = proxima::reduce(items, kernel);    assert(x == 10); // 1 * 1 + 3 * 3}


constexpr


Можно отметить, что код из примера может быть выполнен на этапе компиляции:


#include <proxima/compose.hpp>#include <proxima/kernel/sum.hpp>#include <proxima/reduce.hpp>#include <proxima/transducer/stride.hpp>#include <proxima/transducer/take_while.hpp>#include <proxima/transducer/transform.hpp>int main (){    constexpr int items[] = {1, 2, 3, 4, 5};    constexpr auto kernel =        proxima::compose        (            proxima::transform([] (auto x) {return x * x;}),   // 1. Каждый элемент возведён в квадрат;            proxima::stride(2),                                // 2. Берутся только элементы с номерами,                                                               //    кратными двойке (нумерация с нуля);            proxima::take_while([] (auto x) {return x < 10;}), // 3. Элементы берутся до тех пор, пока                                                               //    они меньше десяти;            proxima::sum                                       // 4. Результат суммируется.        );    constexpr auto x = proxima::reduce(items, kernel);    static_assert(x == 10); // 1 * 1 + 3 * 3}

Большая часть Проксимы может быть выполнена на этапе компиляции.



Многопоточность


Одна из ключевых особенностей описываемой модели состоит в том, что она легко поддаётся параллелизации.


В Проксиме существует механизм, с помощью которого очень легко распараллеливать вычисления. Это делается с помощью фиктивного преобразователя pipe, который выполняет роль "разделителя потоков":


proxima::reduce(values,    proxima::compose    (        proxima::for_each(hard_work), // | Поток 1                                      // ----------        proxima::pipe,                //            Разделитель потоков                                      // ----------        proxima::for_each(hard_work), // | Поток 2                                      // ----------        proxima::pipe,                //            Разделитель потоков                                      // ----------        proxima::for_each(hard_work), // | Поток 3        proxima::sum                  // | Поток 3    ));

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


Чтобы показать эффективность такого разбиения, рассмотрим пример (полный код лежит на Гитлабе).


В нём будем замерять разницу между распараллеленной в три потока свёрткой, обычной свёрткой и простым циклом. Для имитации "тяжёлых" вычислений сделаем функцию, которая просто засыпает на несколько микросекунд. И сгенерируем набор случайных чисел, которые и будут определять время засыпания.


auto hard_work (std::int32_t time_to_sleep){    std::this_thread::sleep_for(std::chrono::microseconds(time_to_sleep));}const auto proxima_crunch_parallel =    [] (auto b, auto e)    {        return            proxima::reduce(b, e,                proxima::compose                (                    proxima::for_each(hard_work),                    proxima::pipe,                    proxima::for_each(hard_work),                    proxima::pipe,                    proxima::for_each(hard_work),                    proxima::sum                ));    };const auto proxima_crunch =    [] (auto b, auto e)    {        return            proxima::reduce(b, e,                proxima::compose                (                    proxima::for_each(hard_work),                    proxima::for_each(hard_work),                    proxima::for_each(hard_work),                    proxima::sum                ));    };const auto loop_crunch =    [] (auto b, auto e)    {        auto sum = typename decltype(b)::value_type{0};        while (b != e)        {            hard_work(*b);            hard_work(*b);            hard_work(*b);            sum += *b;            ++b;        }        return sum;    };

Если сгенерировать 1000 случайных засыпаний в диапазоне от 10 до 20 микросекунд, то получим следующую картину (показано время работы соответствующего обработчика чем меньше, тем лучше):


proxima_crunch_parallel | 0.0403945proxima_crunch          | 0.100419loop_crunch             | 0.103092

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


proxima_crunch_parallel | 0.213352proxima_crunch          | 0.624727loop_crunch             | 0.625393

То есть почти в три раза быстрее, как было бы при идеальном разложении на три потока.



Сравнительная таблица


Библиотека STL (алгоритмы) Boost range-v3 Проксима
Компонуемость Нет Да Да Да
Вывод типов Плохо Средне Средне Хорошо
Параллелизация Почти* Нет Нет Да
Совместимость Boost STL STL Всё
Расширяемость Сложно Нормально Сложно Легко
Самостоятельность Да Да Да Не совсем
constexpr Частично Нет Частично** Да***
Модель Монолитная Ленивая Ленивая Неленивая

*) Параллелизация в STL ещё не везде реализована.

**) constexpr диапазонов, видимо, будет лучше, когда они попадут в STL.

***) constexpr Проксимы зависит от STL. Всё, что своё уже constexpr. Всё, что зависит от STL, будет constexpr как только в STL оно будет таковым.


Ссылки


  1. Проксима
  2. Алгоритмы STL
  3. atria::xform
  4. Boost.Iterator
  5. Boost.Range
  6. range-v3
  7. Абстрактный автомат
Подробнее..

Stdatomic. Модель памяти C в примерах

07.09.2020 08:13:19 | Автор: admin

Для написания эффективных и корректных многопоточных приложений очень важно знать какие существуют механизмы синхронизации памяти между потоками исполнения, какие гарантии предоставляют элементы многопоточного программирования, такие как мьютекс, join потока и другие. Особенно это касается модели памяти C++, которая была создана сложной таковой, чтобы обеспечивать оптимальный многопоточный код под множество архитектур процессоров. Кстати, язык программирования Rust, будучи построенным на LLVM, использует модель памяти такую же, как в C++. Поэтому материал в этой статье будет полезен программистам на обоих языках. Но все примеры будут на языке C++. Я буду рассказывать про std::atomic, std::memory_order и на каких трех слонах стоят атомики.


В стандарте C++11 появилась возможность писать многопоточные программы на C++, используя только стандартные средства языка. В то время многоядерные процессоры уже завоевали рынок. Особенность выполнения программы на многоядерном процессоре в том, что инструкции программы из разных потоков физически могут исполняться одновременно. Ранее многопоточность на одном ядре эмулировалась частым переключением контекста исполнения с одного потока на последующие. Для оптимизации работы с памятью у каждого ядра имеется его личный кэш памяти, над ним стоит общий кэш памяти процессора, далее оперативная память. Задача синхронизации памяти между ядрами - поддержка консистентного представления данных на каждом ядре (читай в каждом потоке). Очевидно, что если применить строгую упорядоченность изменений памяти, то операции на разных ядрах уже не будут выполнятся параллельно: остальные ядра будут ожидать, когда одно ядро выполнит инструкции изменения данных. Поэтому процессоры поддерживают работу с памятью с менее строгими гарантиями консистентности памяти. Более того, разработчику программы предоставляется выбор, какие гарантии по доступу к памяти из разных потоков требуются для достижения максимальной корректности и производительности многопоточной программы. Задача предоставить разные гарантии по памяти решалась по-разному для разных архитектур процессоров. Наиболее популярные архитектуры x86-64 и ARM имеют разные представления о том, как синхронизировать память.

Язык C++ компилируется под множество архитектур, поэтому в вопросе синхронизации данных между потоками в С++11 была добавлена модель памяти, которая обобщает механизмы синхронизации различных архитектур, позволяя генерировать для каждого процессора оптимальных код с необходимой степенью синхронизации.

Отсюда следует несколько важных выводов: модель синхронизации памяти C++ это "искусственные" правила, которые учитывают особенности различных архитектур процессоров. В модели C++ некоторые конструкции, описанные стандартом как undefined behavior (UB), могут корректно работать на одной архитектуре, но приводить к ошибкам работы с памятью на других архитектурах.

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

Код каждого потока компилируется и выполняется так, как будто он один в программе. Вся синхронизация данных между потоками возложена на плечи атомиков (std::atomic), т.к. именно они предоставляют возможность форсировать "передачу" изменений данных в другой поток. Далее я покажу, что мьютексы (std::mutex) и другие многопоточные примитивы либо реализованы на атомиках, либо предоставляют гарантии, семантически похожие на атомарные операции. Поэтому ключом к написанию корректных многопоточных программ является понимание того, как конкретно работают атомики.

Три слона

На мой взгляд, основная проблема с атомиками в C++ состоит в том, что они несут сразу три функции. Так на каких же трех слонах держатся атомики?

  1. Атомики позволяют реализовать атомарные операции.

  2. Атомики накладывают ограничения на порядок выполнения операций с памятью в одном потоке.

  3. Синхронизируют память в двух и более потоках выполнения.

Атомарная операция это операция, которую невозможно наблюдать в промежуточном состоянии, она либо выполнена либо нет. Атомарные операции могут состоять из нескольких операций. Если говорить про тип std::atomic, то он предоставляет ряд примитивных операций: load, store, fetch_add, compare_exchange_* и другие. Последние две операции это read-modify-write операции, атомарность которых обеспечивается специальными инструкциями процессора.

Рассмотрим простой пример read-modify-write операции, а именно прибавление к числу единицы. Пример 0, link:

static int v1 = 0;static std::atomic<int> v2{ 0 };void add_v1() {v1++;   /* Generated asm for x86-64:  mov eax, DWORD PTR v1[rip]  add eax, 1  mov DWORD PTR v1[rip], eax  */} void add_v2() {v2.fetch_add(1);   /* Generated asm for x86-64 (simplified):  mov edx, OFFSET FLAT:_ZL2v2  lock xadd DWORD PTR [rdx], 1  */}

В случае с обычной переменной v1 типа int имеем три отдельных операций: read-modify-write. Нет гарантий, что другое ядро процессора не выполняет другой операции над v1. Операция над v2 в машинных кодах представлена как одна операция с lock сигналом на уровне процессора, гарантирующим, что к кэш линии, в которой лежит v2, эксклюзивно имеет доступ только ядро, выполняющее эту инструкцию.

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

Про синхронизацию данных между потоками. Если мы хотим изменить данные в одном потоке и сделать так, чтобы эти изменения были видны в другом потоке, то нам необходимы примитивы многопоточного программирования. Фундаментальным таким примитивом являются атомики, остальные, например мьютексы, либо реализованы на основе атомиков, либо повторяют семантику атомиков. Все остальные попытки записывать и читать одни и те же данные из разных потоков могут приводить к UB.

Случаи, когда синхронизация памяти не требуется:

  1. Если все потоки, работающие с одним участком памяти, используют ее только на чтение

  2. Если разные потоки используют эксклюзивно разные участки памяти

Далее будет рассмотрены более сложные случаи, когда требуется чтение и запись одного участка памяти из разных потоков. Язык C++ предоставляет три способа синхронизации памяти. По мере возрастания строгости: relaxed, release/acquire и sequential consistency. Рассмотрим их.

Неделимый, но расслабленный

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

  • модификация переменной "появится" в другом потоке не сразу

  • поток thread2 "увидит" значения одной и той же переменной в том же порядке, в котором происходили её модификации в потоке thread1

  • порядок модификаций разных переменных в потоке thread1 не сохранится в потоке thread2

Можно использовать relaxed модификатор в качестве счетчика. Пример 1, link:

std::atomic<size_t> counter{ 0 }; // process can be called from different threadsvoid process(Request req) {counter.fetch_add(1, std::memory_order_relaxed);// ...}void print_metrics() {std::cout << "Number of requests = " << counter.load() << "\n";// ...}

Использование в качестве флага остановки. Пример 2, link:

std::atomic<bool> stopped{ false }; void thread1() {while (!stopped.load(std::memory_order_relaxed)) {// ...}} void stop_thread1() {stopped.store(true, std::memory_order_relaxed);}

В данном примере не важен порядок в котором thread1 увидит изменения из потока, вызывающего stop_thread1. Также не важно то, чтобы thread1 мгновенно (синхронно) увидел выставление флага stopped в true.

Пример неверного использования relaxed в качестве флага готовности данных. Пример 3, link:

std::string data;std::atomic<bool> ready{ false }; void thread1() {data = "very important bytes";ready.store(true, std::memory_order_relaxed);} void thread2() {while (!ready.load(std::memory_order_relaxed));std::cout << "data is ready: " << data << "\n"; // potentially memory corruption is here}

Тут нет гарантий, что поток thread2 увидит изменения data ранее, чем изменение флага ready, т.к. синхронизацию памяти флаг relaxed не обеспечивает.

Полный порядок

Флаг синхронизации памяти "единая последовательность" (sequential consistency, seq_cst) самый строгий и понятный. Его свойства:

  • порядок модификаций разных атомарных переменных в потоке thread1 сохранится в потоке thread2

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

  • все модификации памяти (не только модификации над атомиками) в потоке thread1, выполняющей store на атомарной переменной, будут видны после выполнения load этой же переменной в потоке thread2

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

Этот флаг синхронизации памяти в C++ используется по-умолчанию, т.к. с ним меньше всего проблем с точки зрения корректности выполнения программы. Но seq_cst является дорогой операцией для процессоров, в которых вычислительные ядра слабо связаны между собой в плане механизмов обеспечения консистентности памяти. Например, для x86-64 seq_cst дешевле, чем для ARM архитектур.

Продемонстрируем второе свойство. Пример 4, из книги [1], link:

std::atomic<bool> x, y;std::atomic<int> z; void thread_write_x() {x.store(true, std::memory_order_seq_cst);} void thread_write_y() {y.store(true, std::memory_order_seq_cst);} void thread_read_x_then_y() {while (!x.load(std::memory_order_seq_cst));if (y.load(std::memory_order_seq_cst)) {++z;}}  void thread_read_y_then_x() {while (!y.load(std::memory_order_seq_cst));if (x.load(std::memory_order_seq_cst)) {++z;}}

После того, как все четыре потока отработают, значение переменной z будет равно 1 или 2, потому что потоки thread_read_x_then_y и thread_read_y_then_x "увидят" изменения x и y в одном и том же порядке. От запуска к запуску это могут быть: сначала x = true, потом y = true, или сначала y = true, потом x = true.

Модификатор seq_cst всегда может быть использован вместо relaxed и acquire/release, еще и поэтому он является модификатором по-умолчанию. Удобно использовать seq_cst для отладки проблем, связанных с гонкой данных в многопоточной программе: добиваемся корректной работы программы и далее заменяем seq_cst на менее строгие флаги синхронизации памяти. Примеры 1 и 2 также будут корректно работать, если заменить relaxed на seq_cst, а пример 3 начнет работать корректно после такой замены.

Синхронизация пары. Acquire/Release

Флаг синхронизации памяти acquire/release является более тонким способом синхронизировать данные между парой потоков. Два ключевых слова: memory_order_acquire и memory_order_release работают только в паре над одним атомарным объектом. Рассмотрим их свойства:

  • модификация атомарной переменной с release будет мгновенно видна в другом потоке, выполняющим чтение этой же атомарной переменной с acquire

  • все модификации памяти в потоке thread1, выполняющей запись атомарной переменной с release, будут видны после выполнения чтения той же переменной с acquire в потоке thread2

  • процессор и компилятор не могут перенести операции записи в память ниже release операции в потоке thread1, и нельзя перемещать выше операции чтения из памяти выше acquire операции в потоке thread2

Важно понимать, что нет полного порядка между операциями над разными атомиками, происходящих в разных потоках. Например, в примере 4 если все операции store заменить на memory_order_release, а операции load заменить на memory_order_acquire, то значение z после выполнения программы может быть равно 0, 1 или 2. Это связано с тем, что, независимо от того в каком порядке по времени выполнения выполнены store для x и y, потоки thread_read_x_then_y и thread_read_y_then_x могут увидеть эти изменения в разных порядках. Кстати, такими же изменениями для load и store можно исправить пример 3. Такое изменение будет корректным и производительными, т.к. тут нам не требуется единый порядок изменений между всеми потоками (как в случае с seq_cst ), а требуется синхронизировать память между двумя потоками.

Используя release, мы даем инструкцию, что данные в этом потоке готовы для чтения из другого потока. Используя acquire, мы даем инструкцию "подгрузить" все данные, которые подготовил для нас первый поток. Но если мы делаем release и acquire на разных атомарных переменных, то получим UB вместо синхронизации памяти.

Рассмотрим реализацию простейшего мьютекса, который ожидает в цикле сброса флага, для того, чтобы получить lock. Такой мьютекс называют spinlock. Это не самый эффективный способ реализации мьютекса, но он обладает всеми нужными свойствами, на которые я хочу обратить внимание. Пример 5, link:

class mutex {public:void lock() {bool expected = false;while(!_locked.compare_exchange_weak(expected, true, std::memory_order_acquire)) {expected = false;}} void unlock() {_locked.store(false, std::memory_order_release);} private:std::atomic<bool> _locked;};

Функция lock() непрерывно пробует сменить значение с false на true с модификатором синхронизации памяти acquire. Разница между compare_exchage_weak и strong незначительна, про нее можно почитать на cppreference. Функция unlock() выставляет значение в false с синхронизацией release. Обратите внимание, что мьютекс не только обеспечивает эксклюзивным доступ к блоку кода, который он защищает. Он так же делает доступным те изменения памяти, которые были сделаны до вызова unlock() в коде, который будет работать после вызова lock(). Это важное свойство. Иногда может сложиться ошибочное мнение, что мьютекс в конкретном месте не нужен.

Рассмотрим такой пример, называемый Double Checked Locking Anti-Pattern из [2]. Пример 6, link:

struct Singleton {// ...}; static Singleton* singleton = nullptr;static std::mutex mtx;static bool initialized = false; void lazy_init() {if (initialized) // early return to avoid touching mutex every callreturn; std::unique_lock l(mtx); // `mutex` locks here (acquire memory)if (!initialized) {singleton = new Singleton();initialized = true;}// `mutex` unlocks here (release memory)}

Идея проста: хотим единожды в рантайме инициализировать объект Singleton. Это нужно сделать потокобезопасно, поэтому имеем мьютекс и флаг инициализации. Т.к. создается объект единожды, а используется singleton указатель в read-only режиме всю оставшуюся жизнь программы, то кажется разумным добавить предварительную проверку if (initialized) return. Данный код будет корректно работать на архитектурах процессора с более строгими гарантиями консистентности памяти, например в x86-64. Но данный код неверный с точки зрения стандарта C++. Давайте рассмотрим такой сценарий использования:

void thread1() {lazy_init();singleton->do_job();} void thread2() {lazy_init();singleton->do_job();}

Рассмотрим следующую последовательность действий во времени:

1. сначала отрабатывает thread1 -> выполняет инициализацию под мьютексом:

  • lock мьютекса (acquire)

  • singleton = ..

  • initialized = true

  • unlock мьютекса (release)

2. далее в игру вступает thread2:

  • if(initalized) возвращает true (память, где содержится initialized могла быть неявно синхронизирована между ядрами процессора)

  • singleton->do_job() приводит к segmentation fault (указатель singleton не обязан был быть синхронизирован с потоком thread1)

Этот случай интересен тем, что наглядно показывает роль мьютекса не только как примитива синхронизации потока выполнения, но и синхронизации памяти.

Семантика acquire/release классов стандартной библиотеки

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

std::thread::(constructor) vs функция потока

Вызов конструктора объекта std::thread (release) синхронизирован со стартом работы функции нового потока (acquire). Таким образом функция потока может видеть все изменения памяти, которые произошли до вызова конструктора в исходном потоке.

std::thread::join vs владеющий поток

После успешного вызова join поток, в котором был вызван join, "увидит" все изменения памяти, которые были выполнены завершившимся потоком.

std::mutex::lock vs std::mutex::unlock

успешный lock синхронизирует память, которая была изменена до вызова предыдущего unlock.

std::promise::set_value vs std::future::wait

set_value синхронизирует память с успешным wait.

И так далее. Полный список можно найти в книге [1].

Что это все значит? Повторю эту важную мысль еще раз: это значит, на примере std::promise::set_value и std::future::wait, что тут мы не только получили данные, которые содержатся в примитиве синхронизации, но и нам доступны все изменения памяти, которые были в потоке до того, как он выполнил set_value. Это маленькое чудо нам кажется само собой разумеющееся с нашим бытовым, последовательным причинно-следственным, взглядом на мир. Но в мире многоядерного процессора, законы которого больше похожи на квантовую физику, которую никто до конца не понимает, нет единого последовательно порядка изменения памяти в разных ядрах процессора, если это не затребовано разработчиком явно, или неявно через многопоточные примитивы.

Заключение

Сложно представить современную C++ программу, которая была бы однопоточной. Опасно писать многопоточные программы, не имея представления о правилах синхронизации памяти. Я считаю, что нужно знать как работают атомики в C++. Чтобы не совершать ошибок типа volatile bool, чтобы понимать какие изменения в каких потоках будут видны после использования того или иного многопоточного примитива, чтобы использовать read-modify-write атомарные операции вместо мьютекса, там где это возможно. Данная статья помогла мне систематизировать материал, который я находил в разных источниках и освежить знания в памяти. Надеюсь, она поможет и вам!

Источники

[1] Anthony Williams. C++ Concurrency in Action. https://www.amazon.com/C-Concurrency-Action-Practical-Multithreading/dp/1933988770

[2] Tony van Eerd. C++ Memory Model & Lock-Free Programming. https://www.youtube.com/watch?v=14ntPfyNaKE

Подробнее..

ALog плюс один логгер для С приложений

12.12.2020 18:17:24 | Автор: admin

Система логирования ALog первоначально разрабатывалась для использования в серверных приложениях. Первая реализация ALog была выполнена в 2013 году, на тот момент я и подумать не мог, что спустя семь лет буду писать про нее статью на Хабр. Но, видимо, на все воля случая Сейчас уже и не вспомню, что именно искал на просторах интернета, когда мне на глаза попалась статья Сравнение библиотек логирования. Я решил бегло просмотреть её в ознакомительных целях. По мере знакомства с материалом в голове возникла мысль: "А где же в этом 'табеле о рангах' находится мой логгер?". Чтобы это выяснить был создан небольшой проект LoggerTest для тестирования систем логирования.

Асинхронный логгер

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

  1. У логгера нет ограничения по памяти (обычно серверы не испытывают недостатка в ОЗУ) 2

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

[1] ALog не является самостоятельной библиотекой, это всего лишь несколько модулей в составе библиотеки общего назначения.

[2] Использование ALog в ситуациях отличных от тестовых не приводит к существенному потреблению оперативной памяти, что позволяет использовать логгер на ARM-системах с небольшим объемом ОЗУ.

Участники тестирования

Первоначально для сравнения была выбран только Spdlog. Это было сделано по нескольким причинам:

  1. В исходной статье Spdlog показал неплохие результаты - фактически второе место;

  2. Логгер асинхронный (синхронные логгеры меня не интересовали в принципе, по причине их низкой производительности в многопоточных приложениях);

  3. Под впечатлением от статьи, P7 казался просто недосягаемым, поэтому вначале я его даже не рассматривал в качестве участника тестирования.

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

Для тестов было отобрано три логгера::

  1. G3log (версия 1.3.3, gitrev: f1eff42b)

  2. P7 (версия 5.5)

  3. Spdlog (версия 2.x, gitrev: f803e1c7)

G3log был добавлен просто для статистики. У этого логгера не очень высокая скорость сохранения данных на диск, что и было подтверждено тестами. Последним в сравнение был включен P7. Хочу сказать спасибо разработчикам P7, за помощь в написании тестового примера.

Формат тестовых сообщений

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

У Spdlog и G3log формат префикса можно менять, что позволяет сделать лог-сообщения похожими на сообщения ALog, и таким образом обеспечить примерно одинаковый объем записываемой информации.

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

Внешний вид префиксов для лог-сообщений:

  • ALog ----------------------------------------------------- 15.10.2020 19:39:23.981457 DEBUG2 LWP18876 [alog_test.cpp:35 LoggerTest]

  • Spdlog ---------------------------------------------------[2020-10-15 20:22:55.165] [trace] LWP19519 [spdlog_test.cpp:76 LoggerTest]

  • G3log -----------------------------------------------------2020/10/15 20:24:48 836329 DEBUG [g3log_test.cpp->thread_func:36]

Тестовый стенд

  • OS: Ubuntu 20.04

  • Compiler: GCC 8.4.0 C++14

  • CPU: Intel Core i7 2700K, 4 ядра, 8 потоков (4.5GHz, разгон)

  • RAM: 32Gb (DDR3-1600, XMP 8-8-8-24-2N)

  • SSD: Samsung 860 Pro 512Gb3

  • Количество итераций в тесте: 5

  • Количество записываемых строк: 5 000 000

[3] Любопытный момент: скорость сохранения логов на HDD диск (TOSHIBA HDWD120) оказалась выше чем на SSD.

Тестирование

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

Прямые характеристики:

  • Logging time - усредненное время, за которое все тестовые сообщения будут добавлены в систему логирования;

  • Flush time - усредненное время, за которое все тестовые сообщения будут сохранены на диск (это время отсчитывается от начала теста, поэтому включает в себя значение Logging time).

Косвенные характеристики:

  • Memory usage (max/average) - пиковое и среднее потребление памяти логгером в тесте (берутся худшие показатели из выполненных итераций);

  • CPU usage (max/average) - пиковое и среднее потребление ресурсов процессора. За 100% принимается полная загрузка одного ядра процессора (берутся худшие показатели из выполненных итераций).

Дополнительные условия:

  1. В P7 размер пула был установлен в 1 Гб (/P7.Pool=1048576). Для P7 это абсолютно сверх меры, но все участники на старте должны иметь более-менее одинаковые условия;

  2. Для Spdlog размер очереди установлен в 3 млн. сообщений. Уменьшение её размера будет сказываться на показателе Logging time. Логгер работает в режиме async_overflow_policy::block, что запрещает ему отбрасывать "старые" сообщения если очередь переполнена.

Тест 4 потока (режим сборки: release, ключ компилятора -O2)

ALog

G3log

P7

Spdlog

Logging time (sec)

1.325060

2.91048

4.27096

2.489934

Flush time (sec)

3.051857

23.1829

4.66385

2.489951

Logging per/sec

3788071

1720496

1170852

2008105

Flush per/sec

1638855

215697

1072226

2008092

Memory usage (max, MB)

1468

2343

86

1170

Memory usage (avg, MB)

1302

2310

85

1095

CPU usage (max, %)

106

87

57

100

CPU usage (avg, %)

39

18

37

67

По параметру Flush time ALog так и не смог опередить Spdlog. Поэтому можно сказать, что по Flush time Spdlog - лидер. Правда тут есть одна оговорка: это преимущество сохраняется до тех пор, пока размер очереди больше либо равен 3 * 10^6. "Ложка дегтя" заключается в том, что память под очередь сообщений выделяется в момент создания Spdlog, и остается занятой на всем протяжении работы логгера. В данном тесте Spdlog использовал 1170Мб. Остальные участники тестирования выделяют память по мере повышения нагрузки, и освобождают по мере понижения.

Тест 4 потока (режим сборки: debug, ключ компилятора -O0)

ALog

G3log

P7

Spdlog

Logging time (sec)

3.080949

5.59882

4.69356

7.591786

Flush time (sec)

4.717017

38.5406

5.05907

7.591814

Logging per/sec

1625193

893396

1065342

658611

Flush per/sec

1060223

129736

988428

658609

Memory usage (max, MB)

1241

1840

57

1170

Memory usage (avg, MB)

1071

1811

56

1130

CPU usage (max, %)

106

100

58

118

CPU usage (avg, %)

44

21

36

73

Тест в debug-режиме интересен с точки зрения падения производительности. Очевидно, что проседание есть, но оно не такое катастрофическое (десятки раз), как об этом говорится в Сравнение библиотек логирования. Возможно, причина в том, что тесты проводились на Linux.

Тест 1 поток (режим сборки: release, ключ компилятора -O2)

ALog

G3log

P7

Spdlog

Logging time (sec)

3.936475

8.43987

1.93741

3.090048

Flush time (sec)

4.029064

22.5557

2.32743

3.090063

Logging per/sec

1270377

596768

2580784

1618186

Flush per/sec

1241177

221687

2148340

1618178

Memory usage (max, MB)

84

1353

53

392

Memory usage (avg, MB)

55

1350

52

383

CPU usage (max, %)

50

64

21

64

CPU usage (avg, %)

25

21

11

44

В этом тесте P7 восстановил свой статус-кво. Запись в систему логирования 5 млн. сообщений в один поток за ~1.9 секунды выглядит просто недосягаемо! Скорость сохранения данных на диск тоже весьма впечатляет.

Здесь ALog уступает Spdlog в Logging time. Valgrind по этому поводу "говорит", что одна из причин низкой производительности кроется в использовании функции std::vsnprintf(). При сборке тестов под С++17 появляется возможность минимизировать использование std::vsnprintf(). При этом Spdlog все еще впереди, но отставание уже минимально (~0.15 сек). В то же время ALog улучшает другие показатели: максимальный объем занимаемой памяти уменьшился до 44 Мб, а пиковое потребление ресурсов процессора сократилось до 33%.

Для Spdlog размер очереди сообщений уменьшен до 1 млн. записей, что позволило сократить объем занимаемой памяти с 1170 Мб до 392 Мб. Дальнейшее уменьшение размера очереди ведет к ухудшению Logging time. Еще один штрих: сборка Spdlog под С++17 даёт незначительное (примерно на 0.1 секунды) увеличение Logging time, не затрагивая остальные результаты.

Промежуточный итог

Что ж, тщеславие удовлетворено. Пусть ALog не первый в этом тестировании, но тем не менее выглядит вполне достойно, а по параметру Logging time в многопоточном режиме работы - безусловный лидер.

Но что же дальше?! Эффективность работы системы логирования безусловно важна, но есть и другие критерии, которые целесообразно принимать во внимание. Например: возможность конфигурирования, фильтрация, удобство записи лог-сообщений в рабочем коде. Уже в процессе подготовки тестов пришла мысль, что наверное не следует ограничиваться только сравнением логгеров, и было бы неплохо рассказать об особенностях использования ALog. Это я и собираюсь сделать в следующих разделах.

Немного плагиата

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

Документация и зависимости

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

  • Demo 01: Вывод в лог "Hello world";

  • Demo 02: Вывод сообщений в файл;

  • Demo 03: Вывод сообщений из разных потоков;

  • Demo 04: Фильтрация лог-сообщений по имени модуля, с последующим сохранением в разные лог-файлы;

  • Demo 05: Конфигурирование лог-системы при помощи конфиг-файлов4;

  • Demo 06: Форматированный вывод через log_format (тестовое использование)

Для сборки потребуется QtCreator 4.12 или выше. Сборочная система QBS.

Примечание: сборка под ОС Windows не выполнялась, но проект тривиален, поэтому проблем возникнуть не должно.

Зависимости: ALog - не самостоятельное решение, он является составной частью библиотеки общего назначения SharedTools. Для такого подхода есть пара причин:

  • ALog достаточно маленький - всего три программных модуля5:

    1. logger - непосредственно сам логгер;

    2. config - модуль конфигурирования;

    3. format - модуль для форматированного вывода сообщений.

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

[4] Для сборки примера Demo 5 требуется Qt-framework.

[5] Под программным модулем понимается пара cpp/h файлов, или один h-файл.

Тип логгера, контроль потребления памяти и потокобезопасность

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

  • Контроль потребления памяти отсутствует.

Обработка сбоев процесса

Автоматическая обработка сбоев процесса не предусмотрена. Считаю, что программист должен самостоятельно выполнять такого вида обработки, так как сложно предвидеть все инварианты, которые могут потребоваться разработчику. Тем не менее, существует механизм для немедленного "сброса" всех сообщений из буфера логгера в систему вывода. Функция "сброса" сообщений может выглядеть так:

void prog_abort(){    log_error << "Program aborted";    alog::logger().flush();    alog::logger().waitingFlush();    alog::logger().stop();    abort();}

Стиль логирования и вывод (sink)

Вывод (sink): В ALog механизм вывода называется Saver (дословно: хранитель, в вольной трактовке: средство/механизм сохранения). В дальнейшем будет использоваться именно этот термин.

По умолчанию поддерживаются вывод в файл, и в stdout. В качестве демо-примера, сделан вывод в syslog. Правда, на момент его написания, syslog не мог похвастаться высокой скоростью работы, конечно с той поры может что и поменялось.

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

Споры о том, какой способ вывода лучше не закончатся наверное никогда. На мой взгляд, у потокового оператора есть одно важное преимущество: его можно переопределять, что позволяет системе логирования работать с пользовательскими типами данных. А возможность выводить лог-сообщения сразу через несколько saver-ов добавляет системе логирования ещё больше гибкости.

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

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

struct TaskLog{    int taskId;    int userId;    string status;};

На следующем шаге реализуем потоковый оператор 6.

namespace alog {Line& operator<< (Line& line, const TaskLog& tl){    if (line.toLogger())        line << "TaskId: "   << tl.taskId             << "; UserId: " << tl.userId             << "; Status: " << tl.success;    return line;}} // namespace alog

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

TaskLog tl {10, 20, "success"};log_info << "Task complete. " << tl;

В лог-файл будет добавлена строка: 15.10.2020 19:39:23 INFO LWP18876 [example.cpp:35] Task complete. TaskId: 10; UserId: 20; Status: success И одновременно с этим система логирования вставит в таблицу базы данных запись из трех полей: TASK_ID, USER_ID, STATUS. Конечно, реализация потокового оператора и saver-a для вывода информации в базу данных потребует определенных усилий, но это может быть оправдано удобством последующего использования.

Вот еще одна ситуация, которую хотелось бы рассмотреть: иногда лог-сообщение должно формироваться в зависимости от условия. "Классический" способ решения этой задачи - конкатенация строк с последующим выводом результата. В ALog конкатенация может быть заменена на последовательное формирование лог-сообщения при помощи явно объявленной переменной с типом alog::Line:

{ //Block for alog::Line    alog::Line logLine = log_verbose << "Threshold ";    if (threshold > 0.5)        logLine << "exceeded";    else        logLine << "is normal";    logLine << " (current value: " << threshold << ")";}

Форматированный вывод реализован как надстройка над потоковыми операторами. Таким образом форматированный вывод будет работать со всеми типами данных для которых реализован оператор <<. Пример с выводом TaskLog будет выглядеть так:

TaskLog tl {10, 20, "success"};log_info << log_format("Task complete. %?", tl);

Возможно комбинирование потоковых операторов и форматированного вывода:

log_info << "Task complete." << log_format(" %?", tl);

[6] В примере приведена базовая (упрощенная) реализация потокового оператора.

Инициализация логгера

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

Для stdout программная инициализация состоит из одной строки:

alog::logger().addSaverStdOut(alog::Level::Info);

Инициализация для вывода в файл будет чуть сложнее:

const char* saverName = "default";const char* filePath = "/tmp/logger-demo.log";alog::Level logLevel = alog::Level::Debug;bool logContinue = true;{ //Block for SaverPtr    SaverPtr saver {new SaverFile(saverName, filePath, logLevel, logContinue)};    logger().addSaver(saver);}

Для небольших приложений программная инициализация достаточно эффективна. Но по мере роста приложения, по мере усложнения конфигурации логирования, такая инициализация становится громоздкой, и в конечном счете неудобной для использования. Поэтому для больших приложений предпочтительнее будет инициализация с использованием конфигурационных файлов. Работа с файлами конфигурации требует наличия yaml-парсера. ALog для этой цели использует yaml-cpp.

Пример секции инициализации логгера:

### YAML syntax #### Конфигурирование системы логированияlogger:    # Уровень логирования. Допускаются следующие значения: error, warning, info,    # verbose, debug, debug2. По умолчанию используется info    level: verbose        # Определяет будет ли пересоздаваться log-файл при перезапуске программы.    # (флаг: true/false). Если параметр равен 'false', то log-файл будет    # пересоздаваться при каждом перезапуске программы, в противном случае    # логирование будет выполняться в существующий файл    continue: true        # Наименование файла логирования    file: /var/opt/application/log/application.log        # Наименование файла логирования в Windows    file_win: ProgramData/application/log/application.log        # Определяет файл конфигурирования сейверов и фильтров для системы логирования    conf: /etc/application/application.logger.conf        # Определяет файл конфигурирования сейверов и фильтров для системы логирования в Windows    conf_win: ProgramData/application/config/application.logger.conf        filters:        # Наименование фильтра      - name: default        type: module_name        mode: exclude            modules: [            VideoCap,            VideoJitter,        ]

В этой конфигурации будет создан saver по умолчанию7, с выводом в файл /var/opt/application/log/application.log. Параметры logger.conf и logger.filters отвечают за механизмы расширенной фильтрации, их назначение будет рассмотрено в разделе Настройка фильтрации.

В примере Demo 05 показано как создается saver по умолчанию с использованием файла конфигурации. А пример ниже демонстрирует, как можно переконфигурировать систему логирования в процессе работы программы:

// Время последней модификации конфиг-файла приложенияstd::time_t configBaseModify = 0;// Время последней модификации конфиг-файла для логераstd::time_t configLoggerModify = 0;void init(){    // Время модификации файлов на момент старта программы    configBaseModify = config::baseModifyTime();    configLoggerModify = config::loggerModifyTime();}// Таймер-функцияvoid configModifyTimer(){    bool modify = false;    std::time_t configModify = config::baseModifyTime();    if (configBaseModify != configModify)    {        modify = true;        configBaseModify = configModify;        config::base().rereadFile();        log_verbose << "Config file was reread: " << config::base().filePath();        alog::configDefaultSaver();    }    configModify = config::loggerModifyTime();    if (configLoggerModify != configModify)    {        modify = true;        configLoggerModify = configModify;        alog::configExtensionSavers();    }    if (modify)        alog::printSaversInfo();}

[7] Наименование saver-a по умолчанию всегда 'default'

Точность времени

Все сообщения в ALog фиксируются с точностью до 1 микросекунды, что покрывает потребности большинства существующих приложений. Но выводится это время только при уровне логирования Debug2 (режим трассировки). Такая точность временных меток необходима для режима подробной диагностики, для менее интенсивных режимов логирования вполне достаточно точности в 1 секунду. Данный подход несколько сокращает объем лог-файла. При разработке собственного saver-a разработчик может выводить микросекунды на любом уровне логирования.

Доступ к логгеру

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

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

Настройка фильтрации

ALog, как и большинство современных логгеров, поддерживает фильтрацию по уровням логирования. Имеются следующие варианты:

enum Level {None = 0, Error = 1, Warning = 2, Info = 3, Verbose = 4, Debug = 5, Debug2 = 6};

Debug2 - соответствует режиму трассировки, название имеет исторические причины.

На этом, сходство с другими логгерами заканчиваются. Первое существенное отличие состоит в том, что для ALog невозможно задать уровень логирования напрямую, так как уровень логирования - не характеристика логгера, а характеристика его saver-ов. Уровень логирования самого логгера всегда будет равен максимальному уровню логирования saver-ов, использующихся на данный момент. Однако, для логгера эта характеристика тоже достаточно важна, так как она позволяет еще на первичном этапе обработки сообщений отсекать те из них, которые гарантированно не будут выводиться в лог.

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

  1. При использовании глобального логгера все программные модули будут выдавать сообщения с заданным уровнем логирования, в результате чего получается много лишней, избыточной информации;

  2. Использование нескольких экземпляров логгера в попытке уйти от проблемы из п.1 выглядит не очень удачной идеей. На мой взгляд, система логирования должна быть простой и единообразной в использовании из любой части программы. Программист не должен задумываться над тем, каким экземпляром логгера ему пользоваться;

  3. Объем файлов логирования может быть очень большим (см. п.1). Иногда объемы лог-файлов исчисляются десятками гигабайт, то есть существует риск отказа операционной системы из-за отсутствия свободного места на диске;

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

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

Тогда возникает вторая мысль: "Нужно сделать так, чтобы разные модули программы могли выдавать лог-сообщения с разным уровнем логирования". При диагностике программы, как правило, уже имеются предположения о том, какие её узлы могут приводить к сбою. Соответственно, только эти узлы должны выдавать максимально подробные логи, а остальные части программы могут работать со штатным уровнем логирования. Как выводить такую гетерогенную лог-информацию? Возможны два подхода:

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

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

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

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

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

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

Тип фильтрации

Описание

module_name

По именам логических модулей

log_level

По уровню логирования (это вариант одного файла с разными уровнями логирования)

func_name

По именам функций (механизм реализован, но фактически не востребован)

file_name:line

По именам файлов (читай: по именам программных модулей) и номерам строк

thread_id

По идентификаторам потоков

content

По контенту сообщения

Более подробно типы фильтрации описаны тут. Файл demo05.logger.conf содержит пример конфигурации логгера для Demo 05

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

alog::logger().debug(alog_line_location, "Module1") << "Message";

Как часто бывает в таких ситуациях - на помощь приходят макросы.

// Определяем макросы в начале модуля#define log_error_m   alog::logger().error   (alog_line_location, "Module1")#define log_warn_m    alog::logger().warn    (alog_line_location, "Module1")#define log_info_m    alog::logger().info    (alog_line_location, "Module1")#define log_verbose_m alog::logger().verbose (alog_line_location, "Module1")#define log_debug_m   alog::logger().debug   (alog_line_location, "Module1")#define log_debug2_m  alog::logger().debug2  (alog_line_location, "Module1")...void moduleFunc1(){    for (int i = 0; i < 10; ++i)    {        log_debug_m << "Func1. Message " << i;        usleep(10);    }}...// Удаляем макросы в конце модуля#undef log_error_m#undef log_warn_m#undef log_info_m#undef log_verbose_m#undef log_debug_m#undef log_debug2_m

В cpp-файлах #undef можно опустить, но не стоит так делать в заголовочных файлах. В этом случае результат работы лог-фильтров может оказаться неожиданным.

Поддержка юникода

Фактически, широкой поддержки юникода нет. В Linux в подавляющем большинстве ситуаций используется UTF-8. Для QString есть перегруженный потоковый оператор, в нем осуществляется преобразование из UTF-16 в UTF-8. На этом все.

Ротация файлов

ALog не поддерживает какие либо механизмы по ротации лог-файлов, так как с этой задачей прекрасно справляется logrotate.

Некоторые приемы использования

Здесь описаны некоторые приемы использования ALog, которые не попали в другие разделы. Делаю это на тот случай, если вдруг кому-то в голову придет мысль о применении ALog в своей программе.

Сложные трассировочные сообщения лучше использовать внутри условия:

if (alog::logger().level() == alog::Level::Debug2){    log_debug2_m << "Message was sent to socket"                 << ". Id: " << message->id()                 << ". Command: " << CommandNameLog(message->command())                 << ". Type: " << message->type()                 << ". ExecStatus: " << message->execStatus();}

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

log_debug2_m << "Message was sent to socket";

Наименование программного модуля получено из другой программы

std::string s = ".../python/example1.py";const char* file = alog::__file__cache(s);alog::logger().info(file, 0, 10, "Python") << "Message from python-script";

Не так давно в ALog была добавлена оптимизация позволяющая извлекать наименование программного модуля из макроса __FILE__ в процессе компиляции, и это реально ускорило работу логгера. Но у такой оптимизации есть обратная сторона: адреса строк содержащих наименования модулей не должны меняться на протяжении жизни программы. В принципе, с макросом __FILE__ все так и есть. Но иногда возникает желание (или необходимость) сделать сквозное логирование для внутренних и внешних (работающих в другом процессе) компонентов приложения. В данном случае наименования модулей получаемые из внешней программы имеют динамические адреса строк, а следовательно их нельзя передавать в ALog. Для решения этой проблему была создана функция alog::__file__cache(), она преобразует динамические строки в псевдостатические, что вполне устраивает логгер.

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

Пример подмены функции логирования logmvnc() для драйвера Intel Movidius Neural Compute Stick:

void logmvnc(enum mvLog_t level, const char* file, int line, const char* func,             const char* format, ...) asm ("logmvnc");void logmvnc(enum mvLog_t level, const char* file, int line, const char* func,             const char* format, ...){    va_list args;    va_start(args, format);    int len;    char buff[1024] = {0};    auto removeLastN = [&len, &buff]()    {        if ((len < int(sizeof(buff))) && (buff[len - 1] == '\n'))            buff[len - 1] = '\0';    };    switch (level)    {        case MVLOG_DEBUG:            if (alog::logger().level() == alog::Level::Debug2)            {                len = vsnprintf(buff, sizeof(buff) - 1, format, args);                removeLastN();                alog::logger().debug2(file, func, line, "Movidius") << buff;            }            break;        case MVLOG_WARN:            {                len = vsnprintf(buff, sizeof(buff) - 1, format, args);                removeLastN();                alog::logger().warn(file, func, line, "Movidius") << buff;            }            break;        case MVLOG_ERROR:        case MVLOG_FATAL:        case MVLOG_LAST:            {                len = vsnprintf(buff, sizeof(buff) - 1, format, args);                removeLastN();                alog::logger().error(file, func, line, "Movidius") << buff;            }            break;        default:            {                // LEVEL_INFO                len = vsnprintf(buff, sizeof(buff) - 1, format, args);                removeLastN();                alog::logger().info(file, func, line, "Movidius") << buff;            }    }    va_end(args);}

[8] Это возможно не для всех библиотек.

Вместо заключения

Когда я только начинал работу над этим материалом, то полагал, что ALog уже достаточно устоявшееся, отработанное решение. Но мои представления были неверными. Оказалось, что есть много аспектов, требующих переоценки или доработки. Какие-то правки вносились в процессе тестирования, а какие-то уже в процессе написания этой статьи. Благодаря всей этой затее были созданы два новых проекта: LoggerTest и LoggerDemo. Оба проекта открыты для развития, поэтому если у кого-то возникнут вопросы по работе логгера - постараюсь, по мере возможности, на них ответить. Может быть ответы воплотятся в новых примерах. Касательно LoggerTest - думаю, я далеко не единственный, кто пишет свой логгер, так что если есть желание помериться цифирьками - приходите.

Подробнее..

Перевод Совместная игра в Factorio лучшее техническое собеседование, что мы проводили

09.04.2021 14:13:02 | Автор: admin
В последнее время много копий сломано вокруг технических собеседований. Очевидно, что инвертирование двоичного дерева на доске практически никак не связано с практическими навыками реального программиста. Примитивный Fizzbuzz по-прежнему остаётся самым эффективным тестом. Как следствие, выросло внимание к опенсорсным проектам, но оказалось, что это тоже не очень хороший показатель, потому что у большинства профессионалов нет на них времени.

У нас в компании самое эффективное собеседование по программированию на сегодняшний день это обычно какое-то домашнее задание на несколько дней, в котором кандидата просят исправить баг или реализовать небольшую функцию. Это не очень хорошо, потому что занимает много времени, и человек может получить внешнюю помощь (или погуглить, если функция достаточно распространённая). С другой стороны, некоторые крупные компании вместо этого удвоили количество собеседований с доской (и алгоритмами), подвергая будущих инженеров многочасовым сессиям онлайн-программирования с различным уровнем инвазивного наблюдения.

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

Factorio?


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

Начинаете с нуля. Добываете вручную железную руду и камень, строите плавильную печь, чтобы плавить руду в железные пластины, из которых можно скрафтить автоматический твёрдотопливный бур. Можно самостоятельно забирать железную руду из бура и закладывать её в плавильню, но более эффективно использовать автоматический конвейер. Потом можно использовать полученное железо, чтобы сделать другой бур, который автоматизирует добычу угля. Затем строится конвейер для забора угля и конвейер для его передачи в бур. Эта крошечная фабрика производит железные пластины, из которых можно сделать третий бур и начать добычу медной руды, которая позволяет крафтить медные плиты, а из них можно сделать медный провод, необходимый для работы погружного насоса. В сочетании с паровым котлом и паровым двигателем это даёт нам первую электроэнергию. Её можно использовать для исследовательского центра и изобретения новых технологий, таких как сборочный автомат. Как только разблокируете сборочные автоматы, можете использовать сделанный вручную провод для создания сборочной машины, которая будет автоматически изготавливать эти провода.

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

Выбор направления


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

Конкретные ожидания можно сформулировать так:

  • Стажёр, как правило, должен быть в состоянии разместить чертёж и соединить его с чем-то ещё, например, с месторождением руды.
  • От джуниоров ожидают самостоятельного строительства производственной линии, пусть и не оптимальной. Им может понадобиться помощь старшего разработчика в том, как правильно направить конвейеры ко всем промежуточным сборочным автоматам.
  • Как только получено задание, миддл должен спроектировать почти оптимальную производственную линию (без маяков) с минимальным контролем.
  • Ведущий программист не нуждается в руководстве, способен сам определить цели и разработать план действий, а затем делегировать эти задачи другим программистам.

Командная работа


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

Если игрок уйдёт в себя, начнёт делать всё сам или молча исправлять проблемы, это быстро навлечёт на него гнев команды по тем же самым причинам, по которым коллеги злятся на программистов-ковбоев. К счастью, в Factorio есть встроенный эквивалент git blame: он показывает последнего игрока, который изменил любую сущность. Таким образом, если кто-то поставил костыль и не сообщил команде о проблеме, то когда этот костыль наконец сломается все узнают, кто виноват. Если хотите выиграть, придётся плотно сотрудничать с товарищами по команде.

Отладка


Одним из главных навыков для программиста является отладка. Это, пожалуй, самая очевидная параллель между Factorio и реальной разработкой ПО. Что-то может сломаться очень далеко от фактического источника проблемы. Способность быстро выяснить реальную проблему это критический навык, и процесс мышления почти идентичен отслеживанию причины сбоя в реальной программе. Если перестал работать сборочный автомат, сначала нужно проверить выходящие потоки. Потом проверить, какого ингредиента не хватает на входе. Затем проследить ингредиент по фабрике, чтобы узнать, где он производится. И повторять процесс снова и снова, до тошноты.

Отладка в Factorio быстро усложняется. Как только вы построите нефтеперерабатывающий завод, то займётесь крекингом, где на выходе три разных трубы (мазут, дизельное топливо и попутный нефтяной газ), и если какая-то из них по какой-то причине стопорится, то весь завод прекращает работу.

Бывали случаи, когда весь завод останавливался, потому что вы начали исследовать что-то, не требующее жёлтой науки. В результате вы перестали использовать каркасы дронов, которым перестали поставляться электрические двигатели, где использовалась смазочная жидкость, для производства которой забирался мазут. В итоге стопорилась выходящая труба на нефтеперерабатывающем заводе, из-за чего у вас заканчивался попутный нефтяной газ (petrolium), что останавливало производство пластмассы. Как результат, прекращался выпуск сигнального красного провода и вся фабрика выходила из строя. Опытные игроки предвидят подобные сценарии и внедряют самобалансирующийся крекинг нефти, чтобы всегда гарантировать баланс системы. Такой завод остановится только при закупорке выходящей трубы с попутным газом. Если хорошему программисту дадут сломавшийся нефтеперерабатывающий завод, он обычно сможет проследить проблему до источника, понять, что произошло, и быстро попытаться найти решение. С другой стороны, если человек просто плюхает на землю пару новых резервуаров без веской причины (он абсолютно уверен, что смазочная жидкость будет нужна всегда), то это большой красный флаг на методы решения проблем в его программах.

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

Код-ревью


Часто первоначальный дизайн нужно исправить для повышения производительности или пропускной способности. Хорошие программисты не только примут критику своих чертежей, но и учтут её в будущей работе. Если они не согласны с изменением, то предоставят конкретное объяснение, чтобы команда могла более точно обдумать плюсы и минусы предлагаемого изменения.

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

Стиль написания кода и фреймворки


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

Конструкция магистрального ленточного конвейера включает 4-8 конвейеров, разделённых на две секции (для подземных конвейеров). Он помещается в центре фабрики, и всё производство происходит перпендикулярно ленте. Эта конструкция опирается на несколько правил, нарушение которых может привести к полному хаосу. Во-первых, всегда следует использовать разделитель на выходе с конвейера. Никогда нельзя перенаправлять всю ленту: пустое пространство для другой ленты означает, что вы теряете целый конвейер ресурсов, даже после апгрейда. Во-вторых, все заводы должны масштабироваться перпендикулярно основному конвейеру. Невозможность быстрого масштабирования приведёт либо к огромной потере пространства, либо к невозможности масштабирования производственной линии, потому что она окружена другими производственными линиями.


Логистическая сеть

Есть разные способы построения логистических сетей. Самый простой с сундуками пассивного снабжения. Но есть другой метод сундуки хранения с фильтром, который решает проблему с мусором. Оба метода требуют правильной установки ограничителей в нужных местах. Сундуки пассивного снабжения обычно ограничены пространством сундука. К сундукам хранения нужно поставить манипулятор для подключения сундука к логистической сети. И обеспечить не менее N предметов перед установкой манипулятора. Если забыть про эти шаги, то будут впустую потрачены огромные ресурсы. Если программист постоянно забывает про ограничители на выходах это красный флаг, что человек небрежно относится к производительности в реальных приложениях.

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


Неоптимальный дизайн завода на дронах, источник

Многопоточность


Поезда в Factorio прямой аналог многопоточности: один поезд это один поток выполнения, а каждое пересечение поездов или остановка место в памяти, куда два потока потенциально могут писать одновременно. Светофоры это блокировки (или мьютексы). Все баги в железнодорожной сети проявляются точно так же, как состояние гонки в ПО, потому что они буквально являются физическим состоянием гонки. Все компромиссы применимы и здесь слишком длительная блокировка уменьшает пропускную способность. Неправильное проектирование светофоров обычно вызывает взаимоблокировку, точно так же, как в программном обеспечении, потому что в итоге возникает циклическая зависимость блокировок. Самая распространённая взаимоблокировка это когда поезд слишком длинный и неожиданно блокирует второй перекрёсток, ожидая входа в первый. Затем этот второй перекрёсток препятствует отправлению другого поезда, предотвращая разблокировку первого перекрёстка.

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

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

Масштабирование


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

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

В конце игре для управления поездами требует перехода с push-архитектуры на pull-архитектуру, поскольку push-архитектура не справляется с высокой пропускной способностью. Это неизбежно приводит к использованию функции Train Limit и изучению, как использовать логические сети для кодирования базовой логики, чтобы станция запрашивала поезд только тогда, когда действительно готова полностью заполнить его ресурсами, вместо обычной игровой тактики в начале игры, когда куче поездов просто даётся команда ехать за железом. Новая схема сводит к минимуму количество поездов и при этом гарантирует, что в сети обслуживаются все станции.

Часто бывает, что ограничения входных линий в сборочный автомат и лимиты скорости манипулятора требуют перепроектирования заводов вокруг, точно так же, как высокоскоростные вычисления требуют знания узких мест в работе CPU. Эти узкие места почти никогда не являются проблемой, пока вы не достигнете определённого масштаба, но после этого начинают ограничивать производительность.

Микросервисы и модули


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

Мегабазу на основе поездов иногда называют дизайном городских кварталов (city-block), где поезда вокруг кварталов завода контролируют все входы и выходы. Таким образом, каждый отдельный квартал изолирован от всех остальных, поскольку все входные данные чисты в том смысле, что они поступают из железнодорожной сети. Это почти идентично архитектуре микросервисов (по HTTP) или межпроцессному взаимодействию (IPC), с аналогичными потенциальными проблемами из-за задержек I/O, поскольку результаты не могут поступать постоянно, они должны передаваться в пакетах или поездах по железнодорожной сети.

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

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

Распределённые системы


Space Exploration полностью переделанная версия Factorio для колонизации космоса. Здесь планеты становятся ограниченными ресурсами, требуя от игроков колонизировать другие миры и использовать ракеты для передачи ресурсов между планетами. Из-за огромной задержки с доставкой материалов между планетами, координация этих баз приводит к возникновению проблем, сходных с глобально распределённой системой баз данных. Даже в логической сети приходится бороться с задержкой, потому что автоматическая система теряет из виду элементы, которые запущены, но ещё не достигли целевой планеты. Если это не учитывать, возникают дубликаты запросов для всех нужных элементов. С точно такой же проблемой сталкиваются распределённые системы при попытке обеспечить согласованность между узлами.

Вывод


В целом софтверная индустрия не имеет ни малейшего представления, как находить и нанимать лучших разработчиков. Наверное, совместная игра в Factorio стала лучшим техническим собеседованием, которые мы когда-либо проводили. И это нас сильно смущает. Такое собеседование дико непрактично, занимает более 20 часов в мультиплеере с первого раза или 8 часов для команды опытных игроков. Что из этого можно извлечь? Не знаю. Мы, конечно, не можем перейти на Factorio в качестве метода собеседований с таким же успехом можно просто дать кандидату домашнее задание.

Но это уже лучше, чем собеседование на доске.
Подробнее..

Как синхронизировать сценарий без транзакций? Штатными средствами Java

15.06.2021 02:14:19 | Автор: admin

Давайте представим, что вы параноик, и параноик вдвойне, когда дело касается многопоточности. Предположим, что вы делаете backend некого функционала приложения, а приложение переодически дергает на вашем серверы какие-то методы. Все вроде хорошо, но есть одно но. Что если ваш функционал напрямую зависит от каких-либо других данных, того же банального профиля например? Встает вопрос, как гарантировать то, что сценарий отработает именно так, как вы планировали и не будет каких-либо сюрпризов? Транзакции? Да это можно использовать, но что если Вы фантастический параноик и уже представляете как к вам на сервер летит 10 запросов к одному методу от разных клиентов и все строго в одно время. А в этот момент бизнес-логика данного метода завязана на 100500 разных данных. Как всем этим управлять? Можно просто синхронизировать метод и все. Но что если летят еще и те запросы, держать которые нет смысла? Тут уже начинаются костыли. Я пару раз уже задавался подобным вопросом, и были интересно, ведь задача до абсурда простая и повседневная (если вы заботитесь о том, чтобы не было логических багов конечно же :). Сегодня решил подумать, как это можно очень просто и без костылей реализовать. И решение вышло буквально на 100 строк кода.

Немного наглядного примера

Давайте предположим, что есть водитель и есть пассажир. Водитель не может менять машину до тех пор, пока клиент, например подтверждает поездку. Это что получается, клиент соглашался на поездку с одними характеристиками машины, а по факту у водителя другая машина? Не дела! Можно организовать что-то подобное:

String result = l.lock(new ArrayList<Locker.Item>() {{    add(new Locker.Item(SimpleType.TRIP, 1));    add(new Locker.Item(SimpleType.USER, 2));}}, () -> {    // Тут выполняем отмену поездки и держим водителя на привязи    // Кстати если кто-то где-то вызовет USER=2 (водитель), то он также будет ждать    // ну или кто-то обратится к поездке TRIP=1    // А если обратится к USER=3, то уже все будет нормально :)    // так как никто не блокировал третьего пользователя :)    return "Тут любой результат :)";    });

Элегантно и просто! :)

Исходники тут - https://github.com/GRIDMI/GRIDMI.Sync

Камнями не бросаться! :)

Подробнее..

Многопоточный HTTP-сервер с ThreadPoolом и конечным автоматом

23.05.2021 12:20:57 | Автор: admin

Сегодня я расскажу вам про довольно простую, но интересную реализацию многопоточности в HTTP-сервере без создания потока для каждого клиента. На мое удивление информацию про такую реализацию я нашёл с трудом, поэтому решил поделиться с вами. Начнем с описание проблемы.

Проблемы решения "один поток = один клиент"

Проблемы, которые описаны ниже, справедливы как для потоков, так и для процессов, поэтому "один поток = один клиент" также можно расценивать как и "один процесс - один клиент" в данном контексте.

Первая проблема - количество потоков, которые могут быть созданы в программе, ограничено. Следствием этого ограничено и количество пользователей, подключённых к нашему серверу. Такая проблема есть, например, у Apache.

Проблема вторая - один поток занят только одним клиентом. В связи с этим мы получаем неэффективное использование ресурсов. (поток может простаивать, пока ждёт события от клиента)

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

Решение, которое я приведу ниже, закрывает эти проблемы.

Решение есть

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

Вторая проблема закрывается тем, что потоки не прикреплены к клиентам. Фактически, обслуживание клиента разбивается на задачи, которые решают потоки. Таким образом, мы избавляемся от простоев потоков, если работа для них есть.

Конечный автомат

Первое, что нам понадобится, ввести состояние клиента. Таким образом поток (далее, воркер) будет знать, какой хэндлер нужно вызвать для текущего состояния. Хэндлером может выступать метод, который выполняет характерные для состояния действия. После обработки очередного состояния мы, в зависимости от условий, меняем его.

На каждое состояние у нас есть свой хэндлер. Рассмотрим пример. У клиента четыре состояния: readRequest, generateResponse, sendResponse и closeConnection (чтение запроса, создание ответа, отправка ответа и закрытие соединения, соответственно). На каждое состояние мы имеем хэндлер. readRequest читает и парсит запрос и, в зависимости от успеха чтения и парсинга (например, в зависимости от того, что вернула функция чтения запроса), переключает состояние либо на generateResponse, либо на closeConnection. generateResponse отвечает за генерацию ответа и переключает состояние клиента на sendResponse. sendResponse отправляет ответ клиенту и либо возвращет клиента на состояние readRequest, либо переключает на closeConnection. closeConnection, в свою очередь, просто отключает клиента и удаляет его.

Этот примитивный пример показывает суть принципа. Мы можем добавлять новые состояния клиентов (причем в коде делается это довольно просто: мы просто реализуем новый метод) и переключать их как угодно в зависимости от условий. Вы можете с легкостью разбивать состояние на два отдельных, если чувствуете в этом необходимость. В нашем примере парсинг запроса включен в состояние readRequest и его можно вынести в отдельное состояние - parsingRequest, например.

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

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

ThreadPool (или пул потоков)

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

Механика проста: при создании клиента главный процесс добавляет его в пул. Клиентов воркеры рассматривают как некоторую задачу, которую им нужно взять из пула, решить и вернуть обратно. Воркеры находятся в ожидании (активном или нет - решать вам) появления задач в пуле, и как только она появляется там, по принципу кто успел, тот и съел, один из них получает ее (гонку за получение клиента мы конечно же обрамляем мутексами, семафорами и чем угодно ещё). Воркер, в зависимости от состояния клиента, вызывает необходимый хэндлер, переключает состояние клиента и кладёт его обратно в пул. Дальнейшая судьба клиента воркеру неизвестна. Задача воркера - обработать текущее состояние клиента.

Если наш клиент отправил нам только часть запроса, поток в сервере формата "один клиент=один поток" будет ожидать оставшуюся часть запроса. (то есть простаивать) В нашем же случае поток обработает часть запроса и пойдет обрабатывать следующих клиентов, если такие есть (простаивания потока не происходит).

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

Заключение

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

В этот раз я в общих чертах рассказал только про суть подхода. Если вам интересно увидеть продолжение статьи уже с практической частью (подходы к реализации и их подводные камни) - дайте знать об этом:)

На этом все. Делитесь своими вариантами, предложениями, дополнениями и критикой в комментариях! Благодарю за прочтение:)

Несколько полезных ссылок:

http://personeltest.ru/aways/habr.com/ru/post/260065/

http://personeltest.ru/aways/habr.com/ru/company/latera/blog/273283/

http://www.aosabook.org/en/nginx.html

Подробнее..

Kotlin Multiplatform. Работаем с многопоточностью на практике. Ч.2

19.12.2020 22:21:31 | Автор: admin
Доброго всем времени суток! С вами я, Анна Жаркова, ведущий мобильный разработчик компании Usetech.
В предыдущей статье я рассказывала про один из способов реализации многопоточности в приложении Kotlin Multiplatform. Сегодня мы рассмотрим альтернативную ситуацию, когда мы реализуем приложение с максимально расшариваемым общим кодом, перенося всю работу с потоками в общую логику.
В прошлом примере нам помогла библиотека Ktor, которая взяла на себя всю основную работу по обеспечению асинхронности в сетевом клиенте. Это избавило нас от необходимости использовать DispatchQueue на iOS в том конкретном случае, но в других нам бы пришлось использовать задание очереди исполнения для вызова бизнес-логики и обработки ответа. На стороне Android мы использовали MainScope для вызова suspended функции.

Итак, если мы хотим реализовать единообразную работу с многопоточностью в общем проекте, то нам потребуется корректно настроить scope и контекст корутины, в котором она будет выполняться.
Начнем с простого. Создадим нашего архитектурного посредника, который будет вызывать методы сервиса в своем scope, получаемом из контекста корутины:
class PresenterCoroutineScope(context: CoroutineContext) : CoroutineScope {    private var onViewDetachJob = Job()    override val coroutineContext: CoroutineContext = context + onViewDetachJob    fun viewDetached() {        onViewDetachJob.cancel()    }}//Базовый класс для посредникаabstract class BasePresenter(private val coroutineContext: CoroutineContext) {    protected var view: T? = null    protected lateinit var scope: PresenterCoroutineScope    fun attachView(view: T) {        scope = PresenterCoroutineScope(coroutineContext)        this.view = view        onViewAttached(view)    }}

Вызываем сервис в методе посредника и передаем нашему UI:
class MoviesPresenter:BasePresenter(defaultDispatcher){    var view: IMoviesListView? = null    fun loadData() {        //запускаем в скоупе        scope.launch {            service.getMoviesList{                val result = it                if (result.errorResponse == null) {                    data = arrayListOf()                    data.addAll(result.content?.articles ?: arrayListOf())                    withContext(uiDispatcher){                    view?.setupItems(data)                   }                }            }        }//IMoviesListView - интерфейс/протокол, который будут реализовывать UIViewController и Activity. interface IMoviesListView  {  fun setupItems(items: List<MovieItem>)}class MoviesVC: UIViewController, IMoviesListView {private lazy var presenter: IMoviesPresenter? = {       let presenter = MoviesPresenter()        presenter.attachView(view: self)        return presenter    }()    override func viewWillAppear(_ animated: Bool) {        super.viewWillAppear(animated)        presenter?.attachView(view: self)        self.loadMovies()    }    func loadMovies() {        self.presenter?.loadMovies()    }   func setupItems(items: List<MovieItem>){}//....class MainActivity : AppCompatActivity(), IMoviesListView {    val presenter: IMoviesPresenter = MoviesPresenter()    override fun onResume() {        super.onResume()        presenter.attachView(this)        presenter.loadMovies()    }   fun  setupItems(items: List<MovieItem>){}//...


Чтобы корректно создавать scope из контекста корутины, нам потребуется задать диспетчер корутины.
Это платформозависимая логика, поэтому используем кастомизацию с помощью expect/actual.
expect val defaultDispatcher: CoroutineContextexpect val uiDispatcher: CoroutineContext

uiDispatcher будет отвечать за работу в потоке UI. defaultDispatcher будем использовать для работы вне UI потока.
Проще всего создать в androidMain, т.к в Kotlin JVM есть готовые реализации для диспетчеров корутин. Для доступа к соответствующим потокам используем CoroutineDispatchers Main (UI поток) и Default (стандартный для Coroutine):
actual val uiDispatcher: CoroutineContext    get() = Dispatchers.Mainactual val defaultDispatcher: CoroutineContext    get() = Dispatchers.Default


Диспетчер MainDispatcher выбирается для платформы под капотом CoroutineDispatcher с помощью фабрики диспетчеров MainDispatcherLoader:

internal object MainDispatcherLoader {    private val FAST_SERVICE_LOADER_ENABLED = systemProp(FAST_SERVICE_LOADER_PROPERTY_NAME, true)    @JvmField    val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()    private fun loadMainDispatcher(): MainCoroutineDispatcher {        return try {            val factories = if (FAST_SERVICE_LOADER_ENABLED) {                FastServiceLoader.loadMainDispatcherFactory()            } else {                // We are explicitly using the                // `ServiceLoader.load(MyClass::class.java, MyClass::class.java.classLoader).iterator()`                // form of the ServiceLoader call to enable R8 optimization when compiled on Android.                ServiceLoader.load(                        MainDispatcherFactory::class.java,                        MainDispatcherFactory::class.java.classLoader                ).iterator().asSequence().toList()            }            @Suppress("ConstantConditionIf")            factories.maxBy { it.loadPriority }?.tryCreateDispatcher(factories)                ?: createMissingDispatcher()        } catch (e: Throwable) {            // Service loader can throw an exception as well            createMissingDispatcher(e)        }    }}


Так же и с Default:
internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {    val IO: CoroutineDispatcher = LimitingDispatcher(        this,        systemProp(IO_PARALLELISM_PROPERTY_NAME, 64.coerceAtLeast(AVAILABLE_PROCESSORS)),        "Dispatchers.IO",        TASK_PROBABLY_BLOCKING    )    override fun close() {        throw UnsupportedOperationException("$DEFAULT_DISPATCHER_NAME cannot be closed")    }    override fun toString(): String = DEFAULT_DISPATCHER_NAME    @InternalCoroutinesApi    @Suppress("UNUSED")    public fun toDebugString(): String = super.toString()}


Однако, не для всех платформ есть реализации диспетчеров корутин. Например, для iOS, который работает с Kotlin/Native, а не с Kotlin/JVM.
Если мы попробуем использовать код, как в Android, то получим ошибку:


Давайте разберем, в чем же у нас дело.

Issue 470 c GitHub Kotlin Coroutines содержит информацию, что специальные диспетчеры еще не реализованы для iOS:


Issue 462, от которой зависит 470, то же еще в статусе Open:


Рекомендуемым решением является создание собственных диспетчеров для iOS:
actual val defaultDispatcher: CoroutineContextget() = IODispatcheractual val uiDispatcher: CoroutineContextget() = MainDispatcherprivate object MainDispatcher: CoroutineDispatcher(){    override fun dispatch(context: CoroutineContext, block: Runnable) {        dispatch_async(dispatch_get_main_queue()) {            try {                block.run()            }catch (err: Throwable) {                throw err            }        }    }}private object IODispatcher: CoroutineDispatcher(){    override fun dispatch(context: CoroutineContext, block: Runnable) {        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(),0.toULong())) {            try {                block.run()            }catch (err: Throwable) {                throw err            }        }    }


При запуске мы получим ту же самую ошибку.
Во-первых, мы не можем использовать dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(),0.toULong())), потому что он не привязан ни к одному потоку в Kotlin/Native:

Во-вторых, Kotlin/Native в отличие от Kotlin/JVM не может шарить корутины между потоками. А также любые изменяемые объекты.
Поэтому мы используем MainDispatcher в обоих случаях:
actual val ioDispatcher: CoroutineContextget() = MainDispatcheractual val uiDispatcher: CoroutineContextget() = MainDispatcher@ThreadLocalprivate object MainDispatcher: CoroutineDispatcher(){    override fun dispatch(context: CoroutineContext, block: Runnable) {        dispatch_async(dispatch_get_main_queue()) {            try {                block.run().freeze()            }catch (err: Throwable) {                throw err            }        }    }


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

Однако, если мы попытаемся заморозить уже замороженный объект, например, синглтоны, которые считаются замороженными по умолчанию, то получим FreezingException.
Чтобы этого не произошло, помечаем синглтоны аннотацией @ThreadLocal, а глобальные переменные @SharedImmutable:
/** * Marks a top level property with a backing field or an object as thread local. * The object remains mutable and it is possible to change its state, * but every thread will have a distinct copy of this object, * so changes in one thread are not reflected in another. * * The annotation has effect only in Kotlin/Native platform. * * PLEASE NOTE THAT THIS ANNOTATION MAY GO AWAY IN UPCOMING RELEASES. */@Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS)@Retention(AnnotationRetention.BINARY)public actual annotation class ThreadLocal/** * Marks a top level property with a backing field as immutable. * It is possible to share the value of such property between multiple threads, but it becomes deeply frozen, * so no changes can be made to its state or the state of objects it refers to. * * The annotation has effect only in Kotlin/Native platform. * * PLEASE NOTE THAT THIS ANNOTATION MAY GO AWAY IN UPCOMING RELEASES. */@Target(AnnotationTarget.PROPERTY)@Retention(AnnotationRetention.BINARY)public actual annotation class SharedImmutable


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


Исходники примера github.com/anioutkazharkova/movies_kmp
tproger.ru/articles/creating-an-app-for-kotlin-multiplatform
github.com/JetBrains/kotlin-native
github.com/JetBrains/kotlin-native/blob/master/IMMUTABILITY.md
github.com/Kotlin/kotlinx.coroutines/issues/462
helw.net/2020/04/16/multithreading-in-kotlin-multiplatform-apps

Подробнее..

Kotlin Multiplatform. Работаем с многопоточностью на практике. Ч.1

19.12.2020 22:21:31 | Автор: admin
Доброго всем времени суток! С вами я, Анна Жаркова, ведущий мобильный разработчик компании Usetech
Я давно занимаюсь не только нативной разработкой (как iOS, так и Android), но и кросс-платформенной. В свое время я очень плотно писала на Xamarin (iOS, Android, так и Forms). Так как я интересуюсь различными технологиями шаринга кода, то не прошла и мимо Kotlin Multiplatform (KMM). И сегодня мы с вами поговорим об этом SDK, и как с ним работать на практике.
В сети хватает базовых примеров приложений на KMM, поэтому мы рассмотрим что-то, более приближенное к нашим ежедневным разработческим задачам, а именно, как реализовать многопоточное приложение на Kotlin Multiplatform.



Для начала немного вводной информации. Если вы уже знакомы с Kotlin Multiplatform, то листайте ниже до примера.

Основная идея KMM, как и других кросс-платформенных технологий оптимизация разработки путем написания кода один раз и последующего его использования на разных платформах.

Согласно концепции JetBrains, Kotlin Multiplatform не является фреймворком. Это именно SDK, который позволяет создавать модули с общим кодом, подключаемые к нативным приложениям.


Для взаимодействия с платформами используются специфические для платформы версии Kotlin: Kotlin/JVM, Kotlin/JS, Kotlin/Native. Данные версии включают расширения языка Kotlin, а также специфичные для конкретной платформы библиотеки и инструменты. Написанный на Kotlin модуль компилируется в JVM байткод для Android и LLVM байткод для iOS.


Модуль (Shared, Common) содержит переиспользуемую бизнес-логику. Платформенные модули iOS/Android, к которым подключен Shared/Common, либо используют написанную логику напрямую, либо имплементируют свою реализацию в зависимости от особенностей платформы.

Общая бизнес-логика может включать в себя:
  • сервисы для работы с сетью;
  • сервисы для работы с БД;
  • модели данных.


Также в нее могут входить архитектурные компоненты приложения, напрямую не включающие UI, но с ним взаимодействующие:
  • ViewModel;
  • Presenter;
  • Интеракторы и т.п.

Концепцию Kotlin Multiplatform можно сравнить с реализацией Xamarin Native. Однако, здесь нет модулей или функционала, реализующих UI. Эта логическая нагрузка ложится на подключенные нативные проекты.

Теперь рассмотрим подход на практике.
Если вы еще не работали с KMM, то потребуется установить и настроить инструменты. Раньше это было довольно хлопотно, но сейчас достаточно установить Android Studio (версии от 4.1) и плагин Kotlin Multiplatform Mobile . Выбираем шаблон KMM Application при создании проекта, и все отработает автоматически.


Мультиплатформенные проекты Kotlin обычно делятся на несколько модулей:
  • модуль переиспользуемой бизнес-логики (Shared, commonMain и т.п);
  • модуль для IOS приложения (iOSMain, iOSTest);
  • модуль для Android приложения (androidMain, androidTest).

В них располагается наша бизнес-логика. Всю используемую в проекте бизнес-логику можно разделить на:
  • переиспользуемую (общую);
  • платформенную реализацию.

Переиспользуемая логика располагается в проекте commonMain в каталоге kotlin и разделяется на package. Декларации функций, классов и объектов, обязательных к переопределению, помечаются модификатором expect:

Реализации должны иметь модификатор actual.
В качестве примера работы с многопоточностью рассмотрим небольшое приложение, обращающееся к стороннему API по сети:

Я выбрала www.themoviedb.org. Полный код примера будет по ссылке внизу статьи.

В общей Common части расположим общую бизнес-логику:

А именно наш сетевой сервис. Это логично.
В модулях iOS/Android приложений оставим только UI компоненты для отображения списка и адаптеры. iOS часть будет написана на Swift, Android на Kotlin.

Начнем с бизнес-логики. Т.к весь функционал будет в модуле common, то мы будем использовать в качестве библиотек решения для Kotlin Multiplatform:

Ktor библиотека для работы с сетью и сериализации.

В build.gradle (:app) пропишем следующие зависимости:
val ktorVersion = "1.4.0"val serializationVersion = "1.0.0-RC" sourceSets {        val commonMain by getting {            dependencies {                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")                implementation("io.ktor:ktor-client-core:$ktorVersion")                implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion")                implementation("io.ktor:ktor-client-serialization:$ktorVersion")            }        }        val androidMain by getting {            dependencies {                //...                implementation("io.ktor:ktor-client-android:$ktorVersion")            }        }        val iosMain by getting {            dependencies {                implementation("io.ktor:ktor-client-ios:$ktorVersion")            }        }        ...


Также добавим поддержку сериализации:
plugins {    //...    kotlin("plugin.serialization") version "1.4.10"}


Далее нам надо определить, что делать с многопоточностью, ведь она реализуется по-разному на каждой платформе. На стороне iOS мы используем GCD (Grand Central Dispatch), а на стороне Android JVM Threads и Coroutines. Однако, в Kotlin Multiplatform мы можем сделать общей и работу с многопоточностью.
Для этого мы будет использовать Kotlin Coroutines:
val coroutinesVersion = "1.3.9-native-mt" sourceSets {        val commonMain by getting {            dependencies {                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")               //...            }        }        val androidMain by getting {            dependencies {                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")               //...            }        }        val iosMain by getting {            dependencies {                //...            }        }        ...

Тут стоит сделать пояснение, как с этим работать, потому что далеко не все iOS разработчики знают, что такое Coroutines. Если вкратце, то это блок кода, который можно приостановить, не блокируя поток. У корутины может быть контекст выполнения (CoroutineContext), цикл жизни корутины управляется Job. У корутины есть область действия (CoroutineScope), а поток, в котором она исполняется, задается с помощью CoroutineDispatcher.
Если проводить аналогию с iOS, то это похоже на выполнение блока кода в DispatchQueue, имеющей определенный QoS и привязку к определенному потоку NSThread, либо Operation в OperationQueue, где GlobalScope аналогичен DispatchQueue.global(), а MainScope DispatchQueue.main:
//Androidfun loadMovies() {  GlobalScope.async {    service.makeRequest()   withContext(uiDispatcher) {//...}  }}

//iOSfunc loadMovies() {  DispatchQueue.global().async {    service.makeRequest()  DispatchQueue.main.async{//...}}}

Еще одной ключевой особенностью корутин является использование слова suspend. Данный модификатор не превращает метод в асинхронный сам по себе, это зависит от других деталей реализации, но маркирует, что его можно приостановить без блокировки потока. Также такой метод можно вызывать только в контексте корутины.
Ktor использует механизм корутины для реализации асинхронной работы, поэтому вызов HttpClient делаем в suspended функции:
//Network serviceclass NetworkService {     val httpClient = HttpClient {        install(JsonFeature) {            val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }            serializer = KotlinxSerializer(json)        }    }    suspend inline fun <reified T> loadData(url: String): T? {       return httpClient.get(url)    }}//Movies service suspend fun loadMovies():MoviesList? {        val url = MY_URL        return networkService.loadData<MoviesList>(url)    }

При подключении Kotlin Coroutines мы не указали никакую особую версию для iOS. Это не ошибка. Дело в том, что начиная с версии Kotlin 1.4 Suspended функция Kotlin легко трансформируется в функцию Swift c completion handler блоком:
func getMovies() {    self.networkService?.loadMovies {(movies, error) in       //...    }}

Т.к Ktor уже обеспечивает асинхронность, то в данном случае потребности в использовании DispatchQueue на стороне iOS нет.

На стороне Android используем механизм корутинов, и вызов будет иметь вид:
fun getMovies() {    mainScope.launch {    val movies = this.networkService?.loadMovies()//...    }}}


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

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

Посмотрим это в следующей части

Исходники примера github.com/anioutkazharkova/movies_kmp
Подробнее о работе корутин вы можете узнать тут

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru