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

Lua

Web Security by Bugbounty

06.11.2020 14:12:14 | Автор: admin

Александр Колесников (вирусный аналитик в международной компании) приглашает на мастер-класс Основы технологии, необходимые для понимания уязвимостеи. Классификация OWASP TOP 10, который пройдёт в рамках профессионального курса . А также Александр поделился статьёй для начинающих bug hunter-ов, где рассматривает TOP 10 Уязвимостей 2020 года, которые были найдены платформой HackerOne.

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

TOP 10 уязвимостей by HackerOne

Информацию по топу уязвимостей можно найти здесь. Приведу ниже статистику по выплатам для каждой из уязвимостей:

Напрашивается сравнение с очень популярным списком OWASP TOP 10. Но надо учитывать, что последний раз обновление в OWASP TOP 10 вносились в 2017 году. На данный момент ведется сбор информации для обновления списка. Итак, проведем сравнительный анализ данных рейтингов:

В списке OWASP представлены множества уязвимостей, тогда как в списке HackerOne содержатся конкретные уязвимости. Объединим все уязвимости из списка HackerOne в типы:

  • Injection

  • Broken Authentication

  • Sensitive Data Exposure

  • Security Misconfiguration

Получается, что для создания подходящей лаборатории для исследований достаточно найти 3 уязвимых приложения.

Injection

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

В качестве тестового рассмотрим приложение, которое было использовано на соревновании Real World CTF. Приложение предоставляет пользователю информацию о книгах.

Попробуем выяснить, какая структура у данного приложения. Отправим любой HTTP запрос с помощью BurpSuite Community, получим следующее:

Форма логина и страница admin представляет собой одну и ту же страницу Login.

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

Нас интересуют только файлы, которые содержат код:

После анализа исходного кода становится ясно, что приложение использует фреймворк для создания веб-приложений Flask. Известно, что этот фреймворк может запускать приложение в 2х режимах - debug и release. В debug версии стандартный порт - 5000. Проверим, используется ли этот порт на сервере:

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

Файл, который представляет наибольший интерес:

.\views\user.py

Уязвимость находится в отрезке кода, который представлен выше на скриншоте. Проблема данного кода заключается в том, что декоратор @login_required указан в неверной последовательности, из-за чего его использование бессмысленно. Любой пользователь приложения может использовать код, который вызывается через обращение к /admin/system/change_name/. Так же есть кусок кода, который так же может быть интересен:

В исходнике используется часть Lua кода для работы с Redis. Данные инициализируются прямо из токена, который отправляет на сервер пользователь. Атака на приложение может быть проведена через Redis. Данные из токена в дальнейшем будут отправлены на обработку модулю python pickle. Это можно использовать для инжекта кода.

Security Misconfiguration

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

Снова будем использовать задание из соревнования Real World CTF. Приложение представляет собой простой загрузчик файлов и может быть использовано для загрузки картинок из любого источника:

Проверим интерфейс на возможность чтения локальных файлов, к примеру, file:///etc/passwd. Этот прием нам поможет выяснить есть ли уязвимость из заголовка в этом задании. Результат можно увидеть ниже:

Задание подразумевает возможность заставить сервер выполнять команды, которые мы ему сообщаем, благодаря добытой с помощью эксплуатации уязвимости информации. Поэтому следующим шагом нам будет полезно узнать, как именно приложение запущено на сервере. С помощью команды file:///proc/self/cmdline можно получить строку запуска приложения:

Приложение работает с использованием uwsgi-сервера и может использовать соответствующий протокол для трансляции данных от сервера к приложению и наоборот. Сервер работает через сокет 8000. Рабочая директория приложения - /usr/src/rwctf. Для завершения атаки достаточно научиться создавать команды для uwsgi, с помощью которых можно будет управлять сервером.

Broken Authentication, Sensitive Data Exposure

Broken Authentication - уязвимость, которая может быть использована для доступа к ресурсам приложения в обход системы разграничения доступа. Sensitive Data Exposure - уязвимость, которая может быть использована для доступа к чувствительным данным приложения. Это могут быть конфигурации приложения, логины и пароли от сторонних сервисов и т.д.

Следующее приложение, которое будет изучено, собрало максимальное количество уязвимостей из искомого списка. Данное Приложение было использовано на соревновании 35с3 CTF. Оно предоставляет минимальный интерфейс для регистрации пользователей и получения доступа к закрытой части. Это выглядит следующим образом:

Поля ввода не принимают ничего, кроме корректной последовательности данных. Попробуем проверить, насколько безошибочно настроен сервер для обработки данных. Запустим инструмент dirbuster, чтобы найти список директорий, которые относятся к приложению. В итоге была найдена директория uploads, которая возвращает HTTP код 403. Модифицируем путь c помощью специальных символов файловой системы. Для этого просто добавим символ перехода в директорию на уровень выше: /uploads../:

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

Вместо вывода

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

Disclamer: Все использованные приложения принадлежат их авторам.


Информацию по топу уязвимостей можно найти здесь. Интересно развиваться в данном направлении? Узнайте больше о профессиональной программе Безопасность веб-приложений, приходите на День Открытых Дверей и запишитесь на бесплатный демо-урок Основы технологии, необходимые для понимания уязвимостеи. Классификация OWASP TOP 10.

Подробнее..

Новогодние бенчмарки компьютеров Эльбрус

29.12.2020 02:22:15 | Автор: admin

Новогодние бенчмарки компьютеров Эльбрус


LuaBenchmarks.png


Продолжение статьи Большое тестирование процессоров различных архитектур. В этот раз я решил измерить производительность конкретных сред/языков программирования (C#, Java, JavaScript, Python, Lua) на компьютерах с процессорами Эльбрус и сравнить их с компьютерами (даже телефонами) на процессорах архитектурой ARM и X86-64.


Языки программирования:


  • C#
  • PHP
  • JavaScript (Browser, не NodeJS)
  • Java
  • Python
  • Lua

Список тестов



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


Тестовые стенды и их процессоры


А пока можете опробовать JavaScript версию бенчмарка: http://laseroid.azurewebsites.net/js-bench/


Стенды на процессорах x86 (i386) х86-64 (amd64):



Компилируемые бенчмарки на C/C++


Таблица с результатами с прошлой статьи


Результаты нативных бенчмарков

Тут выходит так: компьютеры на Эльбрусах имеют сопоставимую производительность с Intel Core i7 2600, если бы он работал на частоте 1200 1300 МГц (Для Эльбрус-8С), кроме теста MP MFLOPS, который хорошо распараллеливается компилятором LCC и для Эльбрус 8СВ выдаёт 325 ГФлопс, где Core i7 2600 выдавал
85 ГФлопс (Это с SSE, без AVX).


Задержки кеша. Тест TLB от Линуса Торвальдса


Cpu Elbrus 2C+ Elbrus 4C Elbrus 8C Elbrus 8CB Elbrus R1000 Allwinner A64 Amd A6 3650
Freq (MHz) 500 750 1300 1550 1000 1152 2600
Cores 2 4 8 8 4 4 4
4k 14.02ns 4.04ns 2.51ns 1.94ns 45.01ns 3.48ns 1.16ns
4k cycles 7.0 3.0 3.0 3.0 5.0 4.0 3.0
8k 14.02ns 4.04ns 2.51ns 1.94ns 5.01ns 3.48ns 1.16ns
8k cycles 7.0 3.0 3.0 3.0 5.0 4.0 3.0
16k 14.02ns 4.04ns 2.51ns 1.94ns 5.01ns 3.48ns 1.16ns
16k cycles 7.0 3.0 3.0 3.0 5.0 4.0 3.0
32k 14.03ns 4.04ns 2.51ns 1.94ns 5.16ns 3.58ns 1.16ns
32k cycles 7.0 3.0 3.0 3.0 5.2 4.1 3.0
64k 14.04ns 4.06ns 2.51ns 1.94ns 23.45ns 6.83ns 1.16ns
64k cycles 7.0 3.0 3.0 3.0 23.4 7.9 3.0
128k 18.04ns 14.83ns 9.19ns 7.10ns 23.75ns 7.28ns 4.00ns
128k cycles 9.0 11.1 11.0 11.0 23.7 8.4 10.4
256k 24.97ns 14.82ns 9.19ns 7.10ns 23.98ns 7.69ns 4.00ns
256k cycles 12.5 11.1 11.0 11.0 24.0 48.9 10.4
512k 22.26ns 14.82ns 9.28ns 7.13ns 24.12ns 8.04ns 4.00ns
512k cycles 11.1 11.1 11.1 11.1 24.1 9.3 10.4
1M 67.80ns 14.83ns 27.57ns 21.31ns 24.07ns 34.36n 4.03ns
1M cycles 33.9 11.1 33.1 33.0 24.1 39.6 10.5
2M 106.21ns 22.49ns 27.56ns 21.31ns 46.62ns 37.05n 12.14ns
2M cycles 53.1 16.9 33.1 33.0 46.6 42.7 31.6
4M 107.51ns 120.65ns 27.56ns 21.31ns 119.53ns 37.36n 12.06ns
4M cycles 53.8 90.5 33.1 33.0 119.5 43.0 31.3
8M 107.92ns 121.18ns 27.57ns 21.31ns 141.08ns 37.37n 12.21ns
8M cycles 54.0 90.9 33.1 33.0 141.1 43.0 31.7
16M 107.86ns 121.27ns 47.72ns 31.54ns 143.90ns 37.57n 12.01ns
16M cycles 53.9 90.9 57.3 48.9 143.9 43.3 31.2
32M 107.91ns 119.22ns 111.71ns 117.28ns 144.34ns 37.09n 12.02ns
32M cycles 54.0 89.4 134.1 181.8 144.3 42.7 31.3
64M 107.91ns 119.48ns 149.90ns 117.39ns 144.36ns 37.07n 11.98ns
64M cycles 54.0 89.6 179.9 182.0 144.4 42.7 31.2
128M 107.91ns 121.75ns 169.79ns 117.51ns 144.42ns 37.57n 12.02ns
128M cycles 54.0 91.3 203.7 182.1 144.4 43.3 31.3
256M 107.97ns 122.11ns 174.90ns 117.58ns 144.34ns 37.77n 12.21ns
256M cycles 54.0 91.6 209.9 182.3 144.3 43.5 31.7

Задержки кеша на Эльбруса 8СВ таковы:


  • L1: 3 такта с 1,94 нс
  • L2: 11 тактов с 7,1 нс
  • L3: 3 такта с 21,31 нс
  • ОЗУ: 90-180 тактов с 117 нс

Характеристики кеша для Эльбрус 8С можно посмотреть здесь: Архитектура Эльбрус 8С


Исходный код: Test TLB


Тесты памяти STREAM


Array size = 10000000 (elements), Offset = 0 (elements)

Memory per array = 76.3 MiB (= 0.1 GiB).

Total memory required = 228.9 MiB (= 0.2 GiB).

CPU Frequency Threads Memory Type Copy (MB/s) Scale (MB/s) Add (MB/s) Triad (MB/s)
Elbrus 4C 750 4 DDR3-1600 9 436.30 9 559.70 10 368.50 10 464.80
Elbrus 8C 1300 8 DDR3-1600 11 406.70 11 351.70 12 207.50 12 355.10
Elbrus 8CB 1550 8 DDR4-2400 23 181.80 22 965.20 25 423.90 25 710.20
Allwinner A64 1152 4 LPDDR3-800 2 419.90 2 421.30 2 112.70 2 110.10
AMD A6-3650 2600 4 DDR3-1333 6 563.60 6 587.90 7 202.80 7 088.00

Исходный код: STREAM


Geekbench 4/5 (В режиме RTC: x86 -> e2k трансляция)


Geekbench 5


CPU Frequency Threads Single Thread Multi Thread
Эльбрус 8С 1300 8 142 941
Эльбрус 8СВ 1550 8 159 1100
Intel Core i7 2600 3440 8 720 2845
Amd A6 3650 2600 4 345 1200

Geekbench 4


CPU Frequency Threads Single Thread Multi Thread
Эльбрус 8С 1300 8 873 3398
Эльбрус 8СВ 1550 8 983 4042
Intel Pentium 4 2800 1 795 766
Intel Core i7 2600 3440 8 3702 12063
Qualcomm 625 2000 8 852 2829

Crystal Mark 2004 (В режиме RTC: x86 -> e2k трансляция)


CPU Threads Frequency ALU FPU MEM R (Mb/s) MEM W (Mb/s) Anounced
486 DX4 1 75 119 77 9 11 1993
P1 (P54C) 1 200 484 420 80 65 1994
P1 MMX (P55C) 1 233 675 686 112 75 1997
P2 1 400 1219 1260 222 150 1998
Transmeta Crusoe TM5800 1 1000 2347 1689 405 223 2000
P3 (Coopermine) 1 1000 3440 3730 355 170 2000
P4 (Willamete) 1 1600 3496 4110 1385 662 2001
Celeron (Willamete) 1 1800 3934 4594 1457 657 2001
Athlon XP (Palomino) 1 1400 4450 6220 430 520 2001
P4 (Northwood) 1 2400 5661 6747 1765 754 2002
P4 (Prescott) 1 2800 5908 6929 3744 851 2004
Athlon 64 (Venice) 1 1800 6699 7446 1778 906 2005
Celeron 530 (Conroe-L) 1 1733 7806 9117 3075 1226 2006
P4 (Prescott) 2 3000 9719 10233 3373 1578 2004
Atom D525 4 1800 10505 7605 3407 1300 2010
Athlon 64 X2 (Brisbane) 2 2300 16713 19066 3973 2728 2007
Core i3-6100 2 3700 17232 10484 5553 9594 2015
Pentium T3200 (Merom) 2 2000 20702 18063 4150 1598 2008
Atom x5-Z8350 4 1440 21894 18018 4799 2048 2016
Core i3-M330 4 2133 25595 26627 6807 4257 2010
Core 2 Duo 2 3160 28105 18196 6850 2845 2008
Atom Z3795 4 1600 40231 34963 12060 5797 2016
AMD A6-3650 4 2600 46978 35315 9711 3870 2011
Core 2 Quad 4 2833 47974 31391 9710 5493 2008
Core i3-4130 4 3400 54296 39163 19450 9269 2013
AMD Phenom II X4 965 (Agena) 4 3400 59098 56272 11162 5973 2009
Core i7-2600 8 3400 95369 71648 19547 9600 2011
Core i7-9900K 16 3600 270445 238256 44618 17900 2018
Elbrus-8C RTC-x86 8 1300 65817 29977 49800 7945 2016
Elbrus-8CB RTC-x86 8 1500 77481 37972 62100 13940 2018
Elbrus-1C+ RTC-x86 1 1000 6862 2735 6230 1800 2015

Процессор Эльбрус-8С 1.3 ГГц на уровне AMD Phenom II X4 965 3.4 ГГц 4 ядра. 8СВ на 20% быстрее.


Бенчмарки сред/языков программирования


А теперь переходим к бенчмаркам языков программирования (C#, Java, JavaScript, Python, Lua).


Исходный код здесь: https://github.com/EntityFX/EntityFX-Bench
Исходный код для прощлых бенчмарков можете найти тут: https://github.com/EntityFX/anybench


Микро бенчмарки


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


Arithmetics


Замеряет скорость арифметики: в цикле выполняет различные математические операции с замером времени выполнения.


Пример кода на Python:


    @staticmethod    def _doArithmetics(i : int) -> float:        return math.floor(i / 10) * math.floor(i / 100) * math.floor(i / 100) * math.floor(i / 100) * 1.11) + math.floor(i / 100) * math.floor(i / 1000) * math.floor(i / 1000) * 2.22 - i * math.floor(i / 10000) * 3.33 + i * 5.33

Math


Замеряет скорость математических функций (Cos, Sin, Tan, Log, Power, Sqrt):


    @staticmethod    def _doMath(i : int, li : float) -> float:        rev = 1.0 / (i + 1.0)        return (math.fabs(i) * math.acos(rev) * math.asin(rev) * math.atan(rev) + math.floor(li) + math.exp(rev) * math.cos(i) * math.sin(i) * math.pi) + math.sqrt(i)

Loops


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


Conditions


Замеряет скорость работы условий.


        d = 0        i = 0; c = -1        while i < self._iterrations:             c = ((-1 if c == (-4) else c))            if (i == (-1)):                 d = 3            elif (i == (-2)):                 d = 2            elif (i == (-3)):                 d = 1            d = (d + 1)            i += 1; c -= 1        return d

Array speed (Memory, Random Memory)


Замеряет скорость чтения из массива в переменную (последовательно или со случайными индексами)


Большой кусок кода на Python
    def _measureArrayRead(self, size) :        block_size = 16        i = [0] * block_size        array0_ = list(map(lambda x: random.randint(-2147483647, 2147483647), range(0, size)))        end = len(array0_) - 1        k0 = math.floor(size / 1024)        k1 = 1 if k0 == 0 else k0        iter_internal = math.floor(self._iterrations / k1)        iter_internal = 1 if iter_internal == 0 else iter_internal        idx = 0        while idx < end:             i[0] = (array0_[idx])            i[1] = (array0_[idx + 1])            i[2] = (array0_[idx + 2])            i[3] = (array0_[idx + 3])            i[4] = (array0_[idx + 4])            i[5] = (array0_[idx + 5])            i[6] = (array0_[idx + 6])            i[7] = (array0_[idx + 7])            i[8] = (array0_[idx + 8])            i[9] = (array0_[idx + 9])            i[0xA] = (array0_[idx + 0xA])            i[0xB] = (array0_[idx + 0xB])            i[0xC] = (array0_[idx + 0xC])            i[0xD] = (array0_[idx + 0xD])            i[0xE] = (array0_[idx + 0xE])            i[0xF] = (array0_[idx + 0xF])            idx += block_size        start = time.time()        it = 0        while it < iter_internal:             idx = 0            while idx < end:                 i[0] = (array0_[idx])                i[1] = (array0_[idx + 1])                i[2] = (array0_[idx + 2])                i[3] = (array0_[idx + 3])                i[4] = (array0_[idx + 4])                i[5] = (array0_[idx + 5])                i[6] = (array0_[idx + 6])                i[7] = (array0_[idx + 7])                i[8] = (array0_[idx + 8])                i[9] = (array0_[idx + 9])                i[0xA] = (array0_[idx + 0xA])                i[0xB] = (array0_[idx + 0xB])                i[0xC] = (array0_[idx + 0xC])                i[0xD] = (array0_[idx + 0xD])                i[0xE] = (array0_[idx + 0xE])                i[0xF] = (array0_[idx + 0xF])                idx += block_size            it += 1        elapsed = time.time() - start        return (iter_internal * len(array0_) * 4 / elapsed / 1024 / 1024, i)

String manipulation


Скорость работы со строковыми функциями (replace, upper, lower)


    @staticmethod    def _doStringManipilation(str0_ : str) -> str:        return ("/".join(str0_.split(' ')).replace("/", "_").upper() + "AAA").lower().replace("aaa", ".")

Hash algorithms


Алгоритмы SHA1 и SHA256 над байтами строк.


    @staticmethod    def _doHash(i : int, prepared_bytes):        hashlib.sha1()        sha1_hash = hashlib.sha1(prepared_bytes[i % 3]).digest()        sha256_hash = hashlib.sha256(prepared_bytes[(i + 1) % 3]).digest()        return sha1_hash + sha256_hash

Комплексные бенчмарки


Выполнил реализацию популярных бенчмарков Dhrystone, Whetstone, LINPACK, Scimark 2 на всех 5 языках программирования (конечно же использовал существующие исходники, но адаптировал под мои тесты).


Dhrystone


Dhrystone синтетический тест, который был написан Reinhold P. Weicker в 1984 году.
Данный тест не использует операции с плавающей запятой, а версия 2.1 написана так, чтобы исключить возможность сильных оптимизаций при компиляции.
Бенчмарк выдаёт результаты в VAX Dhrystones в секунду, где 1 VAX DMIPS = Dhrystones в секунду делить на 1757.

Whetstone


Whetstone синтетический тест, который был написан Harold Curnow в 1972 году на языке Fortran.
Позже был переписан на языке C Roy Longbottom. Данный тест выдаёт результаты в MWIPS,
также промежуточные результаты в MOPS (Миллионов операций в секунду) и MFLOPS (Миллионы вещественных операций с плавающей запятой в секунду).
Данный тест производит различные подсчёты: производительность целочисленных и операций с плавающей запятой,
производительность операций с массивами, с условным оператором, производительность тригонометрических функций и функций возведения в степень, логарифмов и извлечения корня.

LINPACK


LINPACK тест, который был написан Jack Dongarra на языке Fortran в 70х годах, позже переписан на язык C.
Тест считает системы линейных уравнений, делает различные операции над двумерными (матрицами) и одномерными (векторами).
Используется реализация Linpack 2000x2000.

Scimark 2


SciMark 2 набор тестов на языке C измеряющий производительность кода встречающегося в научных и профессиональных приложениях. Содержит в себе 5 вычислительных тестов: FFT (быстрое преобразование Фурье), Gauss-Seidel relaxation (Метод Гаусса Зейделя для решения СЛАУ), Sparse matrix-multiply (Умножение разреженных матриц), Monte Carlo integration (Интегрирование методом Монте-Карло), и LU factorization (LU-разложение).

Переходим к результатам.


Результаты


Бенчмарки Java


Результаты нативных бенчмарков

Результаты Java на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков 3.4 ГГц:


  • Эльбрус 1С+ в 11 раз медленнее на 1 поток
  • Эльбрус 4С в 10 раз медленнее на 1 поток
  • Эльбрус 8С в 5,5 раз медленнее на 1 поток
  • Эльбрус 8СВ в 4,5 раз медленнее на 1 поток
  • Эльбрус 1С+ в 18 раз медленнее на всех потоках
  • Эльбрус 4С в 12,5 раз медленнее на всех потоках
  • Эльбрус 8С в 3 раз медленнее на всех потоках
  • Эльбрус 8СВ в 2,5 раз медленнее на всех потоках

Результаты Java на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков, но на одинаковых частотах:


  • Эльбрус 1С+ в 3,5 раз медленнее на 1 поток Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 2,5 раз медленнее на 1 поток Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 2 раз медленнее на 1 поток Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 2 раз медленнее на 1 поток Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус 1С+ в 5 раз медленнее на всех потоках Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 2,75 раз медленнее на всех потоках Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 1,15 раз медленнее на всех потоках Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,15 раз медленнее на всех потоках Core i7 2600 на частоте 1,55 ГГц

Для Java делали очень серьёзные оптимизации, поэтому отставание в 2 раза на одинаковых частотах не является плохим результатом.


Про то, как оптимизировали Java можно почитать тут: Java на Эльбрусе


Посмотреть здесь:



Бенчмарки C# (.Net Framework, .Net Core, Mono)


Результаты бенчмарков на C#

DotnetBenchmarks.png


Так как сред исполнения несколько (.Net Framework, .Net Core, Mono), то я старался сравнивать одинаковые среды исполнения, т. е. Mono на e2k c Mono на x86.


Результаты C# (Mono) на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков 3.4 ГГц:


  • Эльбрус 1С+ в 15,5 раз медленнее на 1 поток
  • Эльбрус 4С в 19 раз медленнее на 1 поток
  • Эльбрус 8С в 10,5 раз медленнее на 1 поток
  • Эльбрус 8СВ в 8 раз медленнее на 1 поток
  • Эльбрус 1С+ в 24 раз медленнее на всех потоках
  • Эльбрус 4С в 12,5 раз медленнее на всех потоках
  • Эльбрус 8С в 4,5 раз медленнее на всех потоках
  • Эльбрус 8СВ в 4 раз медленнее на всех потоках

Результаты C# (Mono) на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков, но на одинаковых частотах:


  • Эльбрус 1С+ в 4,5 раз медленнее на 1 поток Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 4,2 раз медленнее на 1 поток Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 3 раз медленнее на 1 поток Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 3 раза медленнее на 1 поток Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус 1С+ в 7 раз медленнее на всех потоках Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 3 раза медленнее на всех потоках Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 1,5 раз медленнее на всех потоках Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,2 раз медленнее на всех потоках Core i7 2600 на частоте 1,55 ГГц

Результаты C# (NetCore) в режиме RTC на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков 3.4 ГГц:


  • Эльбрус 8С в 3,5 раз медленнее на 1 поток Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 3 раз медленнее на 1 поток Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус 8С в 2 раз медленнее на всех потоках Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,5 раз медленнее на всех потоках Core i7 2600 на частоте 1,55 ГГц

Результаты C# (NetCore) в режиме RTC на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков, но на одинаковых частотах:


  • Эльбрус 8С в 1,3 раз медленнее на 1 поток Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,2 раз медленнее на 1 поток Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус 8С в 1,25 раза быстрее на всех потоках Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,25 раза быстрее на всех потоках Core i7 2600 на частоте 1,55 ГГц

Mono сам по себе является достаточно медленной средой выполнения по сравнению с Net Fremework, а особенно с NetCore (до 3х раз). Что достаточно допустимо. Здесь я не знаю, делали ли оптимизации как это было сделано с Java.


Выходит NetCore в режиме RTC на Эльбрусах работает до 4х раз быстрее чем Mono. Будем ждать нативного NetCore для e2k.


Бенчмарки JavaScript (Браузерные)


JavaScript версия бенчмарка: http://laseroid.azurewebsites.net/js-bench/


Результаты бенчмарков на JavaScript

Результаты JavaScript на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков 3.4 ГГц:


  • Эльбрус 1С+ в 16 раз медленнее
  • Эльбрус 4С в 12,5 раз медленнее
  • Эльбрус 8С в 6,5 раз медленнее
  • Эльбрус 8СВ в 5 раз медленнее

Результаты JavaScript на Эльбрусах в сравнении с Core i7 2600 4 ядра, 8 потоков, но на одинаковых частотах:


  • Эльбрус 1С+ в 5 раз медленнее Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 2,75 раза медленнее Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 2,5 раза медленнее Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 2,25 раз медленнее Core i7 2600 на частоте 1,55 ГГц

Другие популярные JavaScript бенчмарки


Octane


Firefox (версии разные)


Cpu Result (баллы)
Intel Core i7 2600 23321
AMD A6-3650 11741
Intel Pentium 4 2800 3387
Elbrus 8C (rtc x86 32bit) 2815
Elbrus 8C 2102
Elbrus 1C+ 739

Kraken Benchmark


Firefox (версии разные)


Cpu Result (ms)
Elbrus 8C 10493.4
Elbrus 8CB RTX x86 9567.5
Elbrus 8CB 8714.2
Intel Pentium 4 2800 9486.6
AMD A6-3650 3052.5
Intel Core i7 2600 (3.4 GHz) 1456.8

Sunspider


Firefox (версии разные)


Cpu Result (ms)
Elbrus 8C 3059.8
Elbrus 8CB 2394.6
Intel Pentium 4 2800 1295.5
AMD A6-3650 485.6
Intel Core i7 2600 (3.4 GHz) 242.9

Результаты слабоваты. Причина: низкие тактовые частоты и недостаточная оптимизация. Но это гораздо лучше, чем было раньше. Также браузер FX52 уже старый, а будет новая, надеюсь, там уже допилили JavaScript.


Бенчмарки PHP


Результаты бенчмарков на PHP

Результаты PHP на Эльбрусах в сравнении с Core i7 2600 3.4 ГГц:


  • Эльбрус 2С+ в 15,5 раз медленнее
  • Эльбрус 1С+ в 8 раз медленнее
  • Эльбрус 4С в 4,5 раза медленнее
  • Эльбрус 8С в 3 раза медленнее
  • Эльбрус 8СВ в 2,5 раза медленнее
  • Эльбрус R1000 в 12,5 раз медленнее

Результаты PHP на Эльбрусах в сравнении с Core i7 2600, но на одинаковых частотах:


  • Эльбрус 2С+ в 2,3 раз медленнее Core i7 2600 на частоте 0,5 ГГц
  • Эльбрус 1С+ в 2,3 раз медленнее Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С = Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 1,1 раза медленнее Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,1 раза медленнее Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус R1000 в 3,75 раз медленнее Core i7 2600 на частоте 1 ГГц

PHP показывает почти равную скорость на одинаковых частотах с Intel процессорами. Причина проста: здесь МЦСТ делали оптимизацию. Очень удивительно для интерпретируемого языка. Кстати, хочу обратить внимание на то что PHP 7.4 стал быстрее версии PHP 5.6 в 1,5 раза, поэтому я запускал бенчмарки на 2х версиях на Core i7 2600.


Другие популярные PHP бенчмарки


PHP Simple Benchmark


Test Elbrus 8C Elbrus 8CB Pentium 4 2800 AMD A6-3650 Core i7-2600 Allwinner A64
Frequency 1300 1550 2800 2600 3400 1152
CPU Threads 8 8 1 4 8 (4) 4
Version 7.0.33 7.0.33 7.2.24 7.4.3 7.0.33 5.6.20 7.0.33
01_math (kOp/s) 58.15 69.72 104.19 295.97 308.94 131.73 44.33
02_string_concat (MOp/s) 3.56 3.92 4.00 13.15 5.52 0.56 3.07
03_1_string_number_concat (kOp/s) 418.29 472.77 631.10 1510.00 1680.00 1600.00 332.99
03_2_string_number_format (kOp/s) 506.39 573.89 724.44 1690.00 1810.00 1620.00 432.88
04_string_simple_functions (kOp/s) 77.06 91.50 198.03 332.67 39.12 57.60 59.48
05_string_multibyte (kOp/s) 2.48 2.90 -.-- 57.53 11.01 12.77 2.50
06_string_manipulation (kOp/s) 22.10 26.91 78.96 127.08 14.11 23.96 35.73
07_regex (kOp/s) 48.24 54.60 128.41 233.76 334.99 62.43 47.64
08_1_hashing (kOp/s) 113.58 132.62 180.46 306.24 345.52 270.31 71.44
08_2_crypt (Op/s) 361.21 403.62 571.99 813.60 460.00 454.15 238.00
09_json_encode (kOp/s) -.-- -.-- 88.33 233.62 313.52 191.66 48.67
10_json_decode (kOp/s) -.-- -.-- 68.02 143.01 211.62 94.15 33.57
11_serialize (kOp/s) 73.67 81.57 130.16 307.52 435.66 263.06 62.20
12_unserialize (kOp/s) 63.89 69.02 79.33 301.98 348.62 258.75 46.21
13_array_fill (MOp/s) 2.08 2.50 5.30 9.69 14.07 5.35 1.97
14_array_range (kOp/s) 50.36 57.54 31.68 61.01 1140.00 30.35 25.25
14_array_unset (MOp/s) 2.08 2.48 7.17 14.05 14.45 7.32 2.16
15_loops (MOp/s) 13.57 16.21 38.75 150.46 78.92 42.54 12.64
16_loop_ifelse (MOps/s) 4.74 5.64 13.41 28.34 19.04 18.72 4.48
17_loop_ternary (MOp/s) 3.18 3.79 7.29 12.10 11.40 11.85 2.90
18_1_loop_defined_access (MOp/s) 3.28 3.90 9.03 18.90 18.29 15.35 3.18
18_2_loop_undefined_access (MOp/s) 0.60 0.66 1.13 2.60 2.40 2.10 0.49
19_type_functions (MOp/s) 250.57 293.21 806.37 1560.00 1180.00 971.77 193.89
20_type_conversion (MOp/s) 382.32 458.44 812.72 1570.00 1530.00 1510.00 298.61
21_0_loop_exception_none (MOp/s) 7.45 8.91 19.67 56.57 26.35 15.67 6.97
21_1_loop_exception_try (MOp/s) 6.48 7.74 19.11 52.18 23.61 18.99 6.39
21_2_loop_exception_catch (kOp/s) 184.22 216.00 573.09 1380.00 1240.00 498.60 147.28
22_loop_null_op (MOp/s) 3.25 3.74 8.39 16.03 17.62 -.-- 3.08
23_loop_spaceship_op (MOp/s) 4.30 5.12 8.50 17.98 20.39 -.-- 3.96
24_xmlrpc_encode (Op/) -.-- -.-- -.-- -.-- 17.6 -.-- -.--
25_xmlrpc_decode (Op/) -.-- -.-- -.-- -.-- 9.16 -.-- -.--
26_1_class_public_properties (MOp/s) 3.32 4.08 10.51 26.70 19.57 9.42 3.22
26_2_class_getter_setter (MOp/s) 1.31 1.51 4.66 9.41 5.52 4.13 0.97
26_3_class_magic_methods (MOp/s) 0.52 0.59 1.35 3.77 3.21 1.89 0.41
Total (MOp/s) 1.23 1.43 2.60 5.33 2.48 2.02 0.98
Time (sec) 488.324 419.895 231.485 113.087 252.376 261.652 609.787

Бенчмарки Python


Результаты бенчмарков на Python

Так как под Windows не удалось запустить многопоточный Python (я не Питонист, да и времени сделать универсальную многопоточность на всех ОС не было, принимаю патчи), приведу относительно Amd A6 3650, который на 40% медленнее Core i7.


Результаты Python на Эльбрусах в сравнении с Core i7 2600 3.4 ГГц:


  • Эльбрус 2С+ в 30 раз медленнее на 1 поток
  • Эльбрус 1С+ в 12,5 раз медленнее на 1 поток
  • Эльбрус 4С в 15,5 раз медленнее на 1 поток
  • Эльбрус 8С в 9 раз медленнее на 1 поток
  • Эльбрус 8СВ в 7,8 раз медленнее на 1 поток
  • Эльбрус 2С+ в 58 раз медленнее на всех потоках
  • Эльбрус 1С+ в 25 раз медленнее на всех потоках
  • Эльбрус 4С в 13,5 раз медленнее на всех потоках
  • Эльбрус 8С в 4,2 раза медленнее на всех потоках
  • Эльбрус 8СВ в 3,8 раза медленнее на всех потоках

Результаты Python на Эльбрусах в сравнении с Core i7 2600, но на одинаковых частотах:


  • Эльбрус 2С+ в 4,5 раз медленнее на 1 поток Core i7 2600 на частоте 0,5 ГГц
  • Эльбрус 1С+ в 3,6 раз медленнее на 1 поток Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 3,5 раз медленнее на 1 поток Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 3,5 раз медленнее на 1 поток Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 3,5 раз медленнее на 1 поток Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус 2С+ в 8,5 раз медленнее на всех потоках Core i7 2600 на частоте 0,5 ГГц
  • Эльбрус 1С+ в 7,4 раз медленнее на всех потоках Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 3 раз медленнее на всех потоках Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 1,5 раза медленнее на всех потоках Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 1,35 раза медленнее на всех потоках Core i7 2600 на частоте 1,55 ГГц

Во первых, для Python пока не делалось оптимизаций, которые повысили бы производительность. Во вторых, интерпретируемые языки плохо подходят к VLIW-архитектуре Эльбруса. Но МЦСТ советуют использовать CPython для производительности (Python -> C, а затем компилируем с помощью LCC). Про PyPy c Jit пока ничего не известно, но если потребуется, то такая среда выполнения будет реализована, а это сильно повысит производительность.


Также было замечено, что в некоторых случаях Python в режиме RTC (двоичная трансляция x86-64 в e2k) показывает большую производительность: в арифметике и математике. Значит есть ещё потенциал для оптимизации Python, возможно даже в 2 раза.


Бенчмарки Lua


Результаты бенчмарков на Lua

Результаты Lua на Эльбрусах в сравнении с Core i7 2600 3.4 ГГц:


  • Эльбрус 2С+ в 16 раз медленнее
  • Эльбрус 1С+ в 6 раз медленнее
  • Эльбрус 4С в 10 раз медленнее
  • Эльбрус 8С в 6 раз медленнее
  • Эльбрус 8СВ в 5 раз медленнее
  • Эльбрус R1000 в 9 раз медленнее

Результаты Lua на Эльбрусах в сравнении с Core i7 2600, но на одинаковых частотах:


  • Эльбрус 2С+ в 2,4 раза медленнее Core i7 2600 на частоте 0,5 ГГц
  • Эльбрус 1С+ в 1,75 раза медленнее Core i7 2600 на частоте 1 ГГц
  • Эльбрус 4С в 2 раза медленнее Core i7 2600 на частоте 0,8 ГГц
  • Эльбрус 8С в 2,3 раза медленнее Core i7 2600 на частоте 1,3 ГГц
  • Эльбрус 8СВ в 2,2 раза медленнее Core i7 2600 на частоте 1,55 ГГц
  • Эльбрус R1000 в 2,6 раз медленнее Core i7 2600 на частоте 1 ГГц

У Lua отставание всего в 2 раза на равных частотах, это достаточно тепримо для VLIW-архитектуры.


Выводы


Во сколько раз Core i7 2600 быстрее Эльбрусов:


Сводная таблица: во сколько раз Core i7 2600 быстрее Эльбрусов


Во сколько раз Core i7 2600 быстрее Эльбрусов, если бы он работал на частоте Эльбрусов:


Сводная таблица: во сколько раз Core i7 2600 быстрее Эльбрусов на одинаковой частоте


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


Следует:


  • Компилируемые программы на C/C++ (возможно, другие) будут иметь хорошую производительность. Это достигается патчами участков кода, где нужно оптимизировать производительность и умным компилятором LCC (eLbrus C Compiler).
  • Языки с JIT-трансляцией (Java, JavaScript, C# Mono) будут иметь среднюю производительность. Здесь оптимизируют саму среду исполнения. Возможно, также потребуется оптимизировать сами программы.
  • Интерпретируемые языки (PHP, Python, Lua) будут иметь низкую производительность. Но оптимизация среды выполнения позволит поднять до среднего уровня.

Другие способы:


  • Доработка компилятора LCC.
  • Архитектурно-специфические доработки в самой ОС.
  • Улучшать архитектуру Эльбрус:
    • Поднимать частоту
    • Добавить предсказатель и т.д.

Какие языки ещё хотелсоь бы потестировать:


  • Golang (Ждём выпуска)
  • Ruby
  • Perl

P.S. Поздравляем команду МЦСТ с Новым Годом. Желаем удачи в разработке следующих поколений процессоров. Ждём массового появления устройств на процессорах с архитектурой E2K!


Другая интересная информация

"Что такое Эльбрус?


Эльбрус это полувековая история развития отечественной вычислительной технологии.
Это уникальная российская архитектура микропроцессора.
Это группа компаний объединенных одной идеей.
И наконец, это просто современный микропроцессор."
elbrus.ru


Web:


Официальный сайт: http://elbrus.ru


Официальная вики: http://wiki.community.elbrus.ru


Официальный форум: http://forum.community.elbrus.ru


Персональный сайт Максима Горшенина: http://imaxai.ru


Отличная вики на сайте ALT Linux Team: https://www.altlinux.org/Эльбрус


Telegram:


https://t.me/imaxairu основной канал с новостями из мира микропроцессоров Эльбрус из первых рук


https://t.me/e2k_chat на текущий момент основной чат по микропроцессорам Эльбрус, в котором можно пообщаться с разработчиками (организован сотрудниками компании Промобит, хорошо известной по продуктам BITBLAZE)


https://t.me/joinchat/FUktBxkwG8FKlhdzQ7DegQ чат для поболтать на разные темы любителями (и не очень) микропроцессоров Эльбрус с целью незасорения основного чата ненужной информацией, одним словом флудильня фан-клуба :)


Instagram:


Максим Горшенин и Эльбрусы: https://instagram.com/imaxai


Youtube:


Официальный канал группы компаний Elbrus: https://www.youtube.com/c/ElbrusTV


Частный канал Максима Горшенина: https://www.youtube.com/c/MaximGorshenin


Частный канал Михаила Захарова с эмз "Звезда": https://www.youtube.com/channel/UC3mtwuC2ugAngyO9tY2mqRw


Канал фанатов Е2К Elbrus PC Test (https://www.youtube.com/channel/UC4zlCBy0eFLkE-BxgqQK8FA):



Серия видеороликов по Эльбрусам от Дмитрия Бачило:



Серия видеороликов по играм на Эльбрусах от Дмитрия Пугачева:



Старенький, но не устаревающий ликбез по Эльбрусам Константина Трушкина на HighLoad++ 2014: https://youtu.be/ZTxOSGBCRec


Актуальными моделями микропроцессоров Эльбрус являются:



О существующих и будующих моделях микропроцессоров можно прочитать в вики Модели процессоров Эльбрус


Альта и оф. вики Характеристики процессоров Эльбрус


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


Попробовать ПК с процессором Эльбрус вживую можно в Яндекс.Музее в Москве.


Полезно прочитать:


Руководство по эффективному программированию на платформе Эльбрус http://mcst.ru/elbrus_prog Нейман-заде М. И., Королёв С. Д. 2020 [ PDF ] [ HTML ]


Микропроцессоры и вычислительные комплексы семейства Эльбрус (http://mcst.ru/files/511cea/886487/1a8f40/000000/book_elbrus.pdf) Ким А. К., Перекатов В. И., Ермаков С. Г. СПб.: Питер, 2013 272 с. ( PDF )


Операционные системы для архитектуры E2K:


АЛЬТ 8 СП (http://altsp.su/produkty/o-produktakh/) | Рабочая станция (https://www.basealt.ru/products/alt-workstation/) | Сервер (https://www.basealt.ru/products/alt-server/) | Образование (https://www.basealt.ru/products/alt-education/)


Astra Linux SE "Ленинград" (http://astralinux.ru/)


Эльбрус Линукс (http://mcst.ru/programmnoe-obespechenie-elbrus)


Попробовать ОС Эльбрус Линукс для x86_64:
https://yadi.sk/d/spqkqOAncT-aKQ
Документация:
https://yadi.sk/d/2shOcqIrmZQLLA


Интересное:


Вопрос:
Зачем VLIW, давай другую?


Ответ:


  1. http://www.mcst.ru/e2k_arch.shtml
  2. https://t.me/e2k_chat/89326

Лекция Бориса Бабаяна "История развития архитектуры вычислительных машин":
Часть 1 https://youtu.be/Swk27K9m_SA
Часть 2 https://youtu.be/QFD0NboTwTU

Подробнее..

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

28.12.2020 12:07:24 | Автор: admin
КДПВ


<irony>
Не прошло и полугода Но зато конструкция прошла проверку временем!
</irony>

В продолжение первой части о проектировании максимально универсального семисегментного дисплея сделаем на получившихся модулях первое, что приходит в голову конечно же часы! Так что это очередная статья про очередные часы. Без кнопок, на ESP8266, на NodeMCU и Lua. Кому до сих пор интересно прошу под кат.

Кусочек hardware


Для создания часов требуется четырехразрядный индикатор (или шести, если отображать еще и секунды). Так как часы планируются полностью автономными настенными я решил делать их из двух модулей по два трехдюймовых индикатора. В наличии такие были красные с общим анодом, так что устанавливаем элементы master-платы согласно первой части статьи, для slave-платы устанавливает только боковой разъём и индикаторы. Соединяем вместе и вперед программировать!



Стартуем с NodeMCU


Писать на arduino-вых скетчах мне не позволяет религия, извините, а bare-metal прошивка под ESP8266 для данной задачи это явно перебор. Так что выбор вполне логично пал на NodeMCU и скриптовый язык lua. Вкратце, что такое NodeMCU это открытый бесплатный проект на основе lua, имеющий отличную гибкость и достаточную мощность, что позволяет быстро и эффективно создавать разнообразные проекты. NodeMCU модульная прошивка, а это значит, что можно собрать вариант конкретно под свой проект без лишних модулей. Благодаря обширной комьюнити NodeMCU уже умеет работать с разными протоколами обмена данных поверх WiFi (HTTP, MQTT, JSON, CoAP), периферией, с несколькими десятками популярных датчиков, с дисплеями, и даже умеет в файловую систему FatFS.

Для того, чтобы собрать прошивку под свой проект переходим на сайт www.nodemcu-build.com, вводим свою электронную почту, отмечаем галочками нужные модули и жмем Start your build.

Shit happens
Мне несказанно повезло и все мои модули ESP-07 оказались с флешем 512кБ на борту. Хотя по документации, описанию на сайте продавца и фото в интернете должно быть 1Мб. В связи с чем я целый вечер искал причину, почему модуль или не шьется вовсе или шлёт мусор в СОМ-порт при включении неистово мигая синим светодиодом. Оказалось master branch NodeMCU требует от 1 Мб флеша. Для таких же счастливчиков, как я нужно поставить галочку на сайте рядом с branch-ем версии 1.5.4.1 это финальная версия, которая работает с 512кБ.

Для часов нам потребуется минималистичный набор модулей:
wifi окно во внешний мир
enduser_setup удобный интерфейс для подключения к сети WiFi
file проект будет состоять из разных файлов, нужно уметь с ними работать
gpio дергать ножками
net модуль сетевого клиента
rtctime часы реального времени
sntp синхронизация часов по сети, кнопок то нет
spi интерфейс для MAX7219
tmr таймеры

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

Для заливки образа, как и для сохранения lua-скриптов используется UART. Для подключения внешнего адаптера USB-to-UART (3.3V!) используется разъём J3 UART. Как упоминалось в первой части, на плате присутствует посадочное место под преобразователь CH340. В случае его использования все общение с контроллером (и питание платы) будет производится через порт USB на плате. Удобно если проект требует частых изменений или длительного процесса разработки программы. Для переключения в режим записи во флеш нужно предварительно установить на плате перемычку J4. Скорость UART 115200 бод, номер правильного СОМ порта оставляю на вас.

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

Конкретнее
В некоторых непонятных ситуациях при смене обычной прошивки на NodeMCU модули на ESP8266 перестают правильно инициализироваться. Это лечится или предварительной зашивкой файла esp_init_data_default.bin по адресу 0x7C000 или установкой галочки Erase Chip в NodeMCU-PyFlasher.

Процесс успешной заливки образа должен выглядеть следующим образом:


Теперь перемычку J4 можно снять, перезапустить плату и начать писать скрипты в программе ESPlorer. Я не преследую цели написать курс по программированию на lua, эта тема хорошо освещена на многих ресурсах. Лично от себя могу дать рекомендацию на блог avislab там понятным языком написана целая серия статей, в которых освещаются вопросы от азов до общения с облачными хранилищами.
Ниже приведу минимальный набор скриптов для реализации вполне себе функциональных (показывающих время!) часов, требующих только стартовой настройки подключению к сети WiFi. Часики прошли уже проверку временем, все работает отлично, не сбоит, за более чем полугода работы зависли один раз, как я понял, через проблемы с интернетом, полечились простым перезапуском.

Библиотека по работе с MAX7219 - max7219.lua

local spi_index = 1;local cs_pin = 3;-- MAX7219 SPI Master Initializationfunction max7219_spi_init()    print('SPI init');        spi.setup(spi_index, spi.MASTER, spi.CPOL_LOW, spi.CPHA_LOW, 16, 80, spi.HALFDUPLEX);    gpio.mode(cs_pin, gpio.OUTPUT, gpio.PULLUP);    gpio.write(cs_pin, gpio.HIGH);end-- MAX7219 Outputfunction max7219_output(digit, value)    local data = digit*256 + value;    gpio.write(cs_pin, gpio.LOW);    spi.send(spi_index, data);    gpio.write(cs_pin, gpio.HIGH);end-- MAX7219 set intensityfunction max7219_intensity(value)    local data = 0x0A00 + value;    gpio.write(cs_pin, gpio.LOW);    spi.send(spi_index, data);    gpio.write(cs_pin, gpio.HIGH);end-- MAX7219 Initializationfunction max7219_init(digits, intensity)    print(string.format("MAX7219 init for %d digits", digits));        gpio.write(cs_pin, gpio.LOW);    -- Display test mode off    spi.send(spi_index, 0x0F00);    gpio.write(cs_pin, gpio.HIGH);        gpio.write(cs_pin, gpio.LOW);    -- Normal Operation mode    spi.send(spi_index, 0x0C01);    gpio.write(cs_pin, gpio.HIGH);        gpio.write(cs_pin, gpio.LOW);    -- Intensity duty cycle     -- [min 0x0A00 .. 0x0A0F max]    spi.send(spi_index, 0x0A00 + intensity);    gpio.write(cs_pin, gpio.HIGH);        gpio.write(cs_pin, gpio.LOW);    -- Decode-Mode     -- [0 - no decode, 1 - B-Code mode]    spi.send(spi_index, 0x09FF);    gpio.write(cs_pin, gpio.HIGH);        gpio.write(cs_pin, gpio.LOW);    -- Scan-Limit Register Format    spi.send(spi_index, 0x0B04);    gpio.write(cs_pin, gpio.HIGH);    -- Set blank as default    for d=0, digits do         max7219_output(d, 0x0F);    endendcollectgarbage();


main cкрипт - init.lua
local point = 0;local time_zone = 3;local sntp_cnt = 1;local cur_intensity = 0x0F;function timer_do()    tm = rtctime.epoch2cal(rtctime.get());    if point == 0 then point = 1; else point = 0; end;    max7219_intensity(cur_intensity);    max7219_output(5, tm["min"]%10);    max7219_output(4, tm["min"]/10);    max7219_output(2, tm["hour"]%10 + (128*point));    max7219_output(1, tm["hour"]/10);    if tm["hour"] <= 7 then        -- from 0 to 8        cur_intensity = 0x01;    else         if tm["hour"] <= 18 then            -- from 8 to 19            cur_intensity = 0x0F;        else            if tm["hour"] <= 22 then                -- from 19 to 22                cur_intensity = 0x05;            else                -- from 23 to 24                cur_intensity = 0x01;              end        end    endendfunction sntp_sync()    print ("SNTP sync");    sntp.sync("194.54.161.214",        function(sec, usec, server, info)            rtctime.set(sec + 3600*time_zone)            tm = rtctime.epoch2cal(rtctime.get());            print(string.format("%04d/%02d/%02d %02d:%02d:%02d", tm["year"], tm["mon"], tm["day"], tm["hour"], tm["min"], tm["sec"]));            sntp_cnt = 4320;        end,        function(err, str)            print("Nope...")        end    )endfunction timer_sntp()    if sntp_cnt > 0 then        sntp_cnt = sntp_cnt - 1;    else        if wifi.sta.status() == wifi.STA_GOTIP then             print("Connected to WiFi as:" .. wifi.sta.getip());            sntp_cnt = 6;            sntp_sync();        else             print("No WiFi");         end;    endendrequire("max7219");max7219_spi_init();max7219_init(5, cur_intensity);rtctime.set(1577872800 + 3600*time_zone);tm = rtctime.epoch2cal(rtctime.get());print(string.format("%02d:%02d:%02d", tm["hour"], tm["min"], tm["sec"]));enduser_setup.start(  function()    print("Connected to WiFi as:" .. wifi.sta.getip())    sntp_sync();  end,  function(err, str)    print("enduser_setup: Err #" .. err .. ": " .. str)  end);local mytimer = tmr.create();mytimer:register(500, tmr.ALARM_AUTO, timer_do);mytimer:start()local sntp_timer = tmr.create();sntp_timer:register(10000, tmr.ALARM_AUTO, timer_sntp);sntp_timer:start()collectgarbage();


Файл для шаринга параметров enduser_setup - enduser_setup.lua
local p = {}p.wifi_ssid="ssid"p.wifi_password="password"-- your own parameters:p.utc_zone="xxx"return p


Во флеш контроллера также нужно залить страницу enduser_setup.html с интерфейсом подключения к сети WiFi.
Несмотря на такой компактный скрипт часы действительно получаются функционально законченными. Реализован следующий сценарий: при включении, на основе enduser_setup модуля создаётся открытая WiFi-точка с названием SetupGaget_xxx.


При подключении к которой и попытке перейти по какому-либо адресу (или просто по 192.168.4.1) открывается интерфейс подключения к доступным сетям.


Такая себе landing-page, куда нужно ввести название сети и пароль. Можно ввести вручную или выбрать из списка доступных. При нажатии на кнопку контроллер пытается подключится к выбранной сети и в случае успеха выводит радостное сообщение и отключает WiFi-точку. Дополнительно я добавил на страницу настройку часового пояса.


После подключения к Интернету часы синхронизируются с сервером точного времени по протоколу SNTP и начинают тихо выполнять свою основную функцию отображать время на дисплее, помигивая точкой второго разряда.
Буквально в несколько строчек можно добавить периодическую синхронизацию времени и изменение яркости в зависимости от времени суток. Если вы счастливый обладатель модулей с 512кБ памяти придется писать проверками, как в коде выше, если же есть возможность использовать master branch версию рекомендую использовать модуль простого планировщика событий cron. Аналогично и с функцией изменения яркости дисплея, которая выше также реализована на банальных проверках.

cron
cron.schedule("0 */12 * * *", function(e)  print("Every 12 hours");  sntp_sync();end)



Сразу прошу прощения за фото, съемка ярких светодиодных индикаторов оказалась той еще задачей, даже при хорошем фронтальном освещении картинка выглядит не очень. В жизни часы выглядят яркими, равномерными и вокруг солнечный день :)

Что еще?..


Теперь пара слов о других идеях. С помощью универсального семисегментного дисплея и простого lua-скрипта под NodeMCU можно буквально за час сделать настольные/настенные счетчики событий (клиенты, коммиты, факапы) или отсчитыватели времени до чего-то, будь до дедлайн или отпуск. Или считать дни без падений сервера.

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

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

Как простенький пример, и как раз по случаю грядущего праздника, я запилил счетчик дней до Нового Года.





NY ждун
local time_zone = 3;local sntp_cnt = 1;local cur_intensity = 0x0F;local days = 189;function print_days()    max7219_intensity(cur_intensity);    max7219_output(3, days%10);    max7219_output(2, (days%100)/10);    max7219_output(1, days/100);    if tm["hour"] <= 7 then        -- from 0 to 8        cur_intensity = 0x01;    else         if tm["hour"] <= 18 then            -- from 8 to 19            cur_intensity = 0x0F;        else            if tm["hour"] <= 22 then                -- from 19 to 22                cur_intensity = 0x05;            else                -- from 23 to 24                cur_intensity = 0x01;              end        end    endendlocal dpm = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};function days_till_ny()    tm = rtctime.epoch2cal(rtctime.get());    days = dpm[tm["mon"]-1] - tm["day"];    if (tm["year"]%4) and (tm["mon"]<=2) then days = days - 1; end;    local month = tm["mon"];    while(month < 12)    do        days = days + dpm[month];        month = month + 1;    end;    print_days();endfunction sntp_sync()    if wifi.sta.status() == wifi.STA_GOTIP then         print("Connected to WiFi as:" .. wifi.sta.getip());        print ("SNTP sync");        sntp.sync("194.54.161.214",            function(sec, usec, server, info)                rtctime.set(sec + 3600*time_zone)                tm = rtctime.epoch2cal(rtctime.get());                print(string.format("%04d/%02d/%02d %02d:%02d:%02d", tm["year"], tm["mon"], tm["day"], tm["hour"], tm["min"], tm["sec"]));                days_till_ny();            end,            function(err, str)                print("Nope...")            end    );    else         print("No WiFi");     end;    end;require("max7219");max7219_spi_init();max7219_init(3, cur_intensity);rtctime.set(1577872800 + 3600*time_zone);tm = rtctime.epoch2cal(rtctime.get());print(string.format("%02d:%02d:%02d", tm["hour"], tm["min"], tm["sec"]));enduser_setup.start(    function()        sntp_sync()    end,    function(err, str)        print("enduser_setup: Err #" .. err .. ": " .. str)    end)cron.schedule("0 */12 * * *", function(e)  print("Every 12 hours");  sntp_sync();end)collectgarbage();


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

Буду рад почитать конструктивную критику или интересные предложения.
Всем спасибо за внимание!
И всех с наступающими праздниками!
Подробнее..

Как образовательный коптер помогает научиться программировать на Python, и что не так с Lua

17.02.2021 18:10:52 | Автор: admin

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

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

В 2020 году линейка Пионеров дополнилась новыми моделями - появились младший и старший братья Мини и Макс. И если по размеру и массе братья стоят по ранжиру - Мини самый маленький и легкий, а Макс самый большой и тяжелый, то по функционалу младший уже готов дать фору своему предшественнику (назовём его Классическим Пионером).

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

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

Какая связь? Сейчас поясню.

Пионер Мини рассчитан на полёт в помещении, и применение на нём Wi-fi в качестве канала связи было не только оправдано, но и открыло новые возможности по сравнению с Классическим Пионером, где обеспечивалась только узкополосная, но дальнобойная связь в канале 868МГц. Простота подключения (без использования доп. модулей), высокая скорость передачи данных и поддержка протокола MAVLink в совокупности позволяют осуществлять программирование квадрокоптера удаленно, используя, к примеру, ноутбук, на котором запущена программа. В данном случае коптер как бы визуализирует код, написанный пользователем на компьютере. При этом все, что происходит с коптером можно отслеживать на экране ноутбука в реальном времени, в том числе по изображению с видеокамеры.

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

Почему Lua не лучший вариант для обучения программированию:

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

Постараюсь объяснить это на примере.

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

Контроллер автопилота решает только задачи управления и связи, при этом пользовательские скрипты на Lua запускаются внутри интерпретатора, который сам является частью прошивки. Поэтому вычислительные мощности автопилота как и набор доступных интерфейсов оказывается ограниченным и логичным решение является использование внешнего вычислителя. Например, мы можем подключить к автопилоту модуль с камерой OpenMV H7, которая имеет достаточно мощный контроллер для обработки изображений и способна выдавать результаты обработки в виде команд в декартовых координатах. Дальше нас ждут сюрпризы. Среда программирования Pioneer Station, поддерживает только работу с автопилотом, позволяя написать код на Lua и загружать его в коптер. Для работы с камерой нужно отдельно установить среду OpenMV IDE, и оказывается, что камера программируется уже на MicroPython К слову, IDE для камеры довольно хорошая и поддерживает отладку, правда отследить работу программы можно только по светодиодам - отладчик для работыLua скриптов внутри автопилота не предусмотрен. Камера с автопилотом может быть соединена по интерфейсу UART, а для её подключения к автопилоту, для крепления на раме коптера используется плата адаптер.

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

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

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

Lua является очень быстрым и легким скриптовым языком во многом потому, что из коробки в нем практически ничего нет. И тут Python с пакетным менеджером просто не оставляет ему шансов. Вернусь к вышеописанной задаче по обработке изображения. Вполне логичной кажется ситуация, когда скрипт должен работать в асинхронном режиме. Я имею ввиду, что обработка изображения не должна вешать часть кода, связанную с отправкой команд управления дрону. На Python уже из коробки стоят пакеты threading и multiprocessing, к которым в придачу идет отличная документация и примеры, когда как на Lua скорее всего я найду чей-нибудь проект на github-е, и если в нем окажется хороший readme, это уже будет огромной удачей. Также важным фактором является и то что, Python используется как нативный язык для ROS, что позволяет сильно облегчить процесс понимания разработки своих роботов.

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

Так, библиотека numpy может стать полноценной альтернативой вычислениям в более мощных пакетах, таких как Matlab, а полученные результаты можно очень легко встроить в программы полета. Опять же, говоря о техническом зрении, многие процессы получения геометрических характеристик сводятся к последовательным переходам от одной системы координат к другой, и тут возможности матричных вычислений numpy очень сильно помогают. Библиотека matplotlib со своей стороны может отлично помочь в визуализации данных, получаемых с дрона в реальном времени. Но в ситуации, когда для реализации Lua скриптов их заливают в микроконтроллер, возможности подключить к нему пользовательскую библиотеку нет вообще.

Чем сейчас удобна работа с библиотекой:

Библиотека для программирования Пионер Мини на Python выложена как open-source проект на github (https://github.com/geoscan/pioneer_sdk/tree/master), а так же может быть установлена используя pip с хранилища PyPi (https://pypi.org/project/pioneer-sdk/). Это, по сравнению с применением Lua скриптов, позволило реализовать полноценную версионность и дало нам уверенность в том, что пользователь сам может узнать об актуальной версии библиотеки.

Реализация библиотеки представляет собой класс квадрокоптера, в котором присутствуют методы по получению изображения с камеры квадрокоптера, полета, управлению светодиодами и получению сырых данных с датчиков. Освоив работу с ними с помощью преподавателя, ничего не мешает ученику внедрить свои идеи в скрипт квадрокоптера: будь то распознавание объектов или ARUCO маркеров, использование нейросетей, построение различных регуляторов и т.д. В дополнение к этому был реализован метод запуска предварительно залитого на коптер Lua скрипта по воздуху (в дальнейшем хочется также реализовать загрузку скрипта, чтобы уже написанные образовательные методики могли использоваться удобнее).

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

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

Как самый свежий пример расскажу вкратце об опыте работы с Python на Пионере Мини в ФМЛ 239 г. Санкт-Петербурга. Школьники Центра робототехники в январе этого года работали с установкой всего необходимого ПО (PyCharm Community и Pioneer Station 1.11.0.), перепрошивали ESP-32 до версии 0.2.7., учились подключать компьютер к дрону. В итоге за одно занятие они смогли разобраться и запустить скрипт калибровки камеры на Python.

Сейчас у них есть возможность опробовать другие примеры скриптов и создать свои уникальные кейсы, например, реализовывать полёт Пионер Мини по линии (с помощью библиотек OpenCV и pioneer_sdk).

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

Подробнее..

Перевод Три года я работал в VSC и переключился на Lite

22.02.2021 12:20:43 | Автор: admin
Lite на моём компьютере с Linux, снято автором. Фотография ноутбука с Linux и редактора Lite, который выполняется на ноутбуке

Вероятно, Lite подойдёт программистам, которым не нужно слишком много функций, чтобы писать код. Редактор создал впечатление Notepad++, урезанного в сторону Блокнота. В нём есть очарование минимализма и любопытный исходный код, но хотя бы небольшого обзора на Хабре не было до сих пор. Я исправляю ситуацию. Не лишним будет сказать, что автор оригинала разработчик веб-фреймворка Neutralinojs, публикации о котором есть на Хабре, а также член комитета управления проектами в Apache Software Foundation.

Мы пользуемся IDE, когда нам нужно поработать с определённым фреймворком или платформой. Например, Android Studio помогает написать приложения для Android. С другой стороны, редакторы кода помогают программистам работать с разными проектами. У них есть полезные функции, такие как подсветка синтаксиса, линтинг и автозавершение кода.

Как и большинство крепких орешков, около 13 лет назад я писал код в стандартном Блокноте Windows. Затем я нашёл Notepad++ и переключился на него, после установил Dreamweaver. Работая над многими проектами веб-разработки, я пытался оставаться на Dreamweaver. Старые версии Dreamweaver имели довольно хорошую производительность на моём компьютере Pentium-IV, но версии позднее замедляли работу моего компьютера.

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

В 2017 году, как и любой другой современный программист, я установил Visual Studio Code. В то время он выглядел великолепно, и у меня не было никаких проблем с производительностью. К сожалению, пришлось переключиться на недорогой ноутбук во время ситуации с COVID-19. Мой нынешний личный ноутбук имеет 4 гигабайта физической памяти. С другой стороны, VSCode требует не менее 8 гигабайт физической памяти, когда мы работаем в нём и одновременно с веб-браузером. Я обнаружил, что VSCode часто тормозит, а иногда мой компьютер зависает совсем.

Почему VSCode такой медленный?


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

Lite


Скриншот Lite на Линукс
Lite на Linux. Скриншот автора

Lite лёгкий современный редактор кода на Lua. Приложение не гибридное. Lite использует C и графическую библиотеку SDL, чтобы визуализировать элементы графического интерфейса. Таким образом, в Lite нет громоздкого кода JavaScript и HTML, написанного, чтобы отрисовывать псевдо-нативный GUI внутри экземпляра веб-браузера. Всё отображается удивительно быстро, не запаздывая на миллисекунды.

Архитектура проекта и система плагинов выразительны и минималистичны. Lite сам по себе это просто текстовое поле, всё остальное поставляется в виде плагинов на Lua. Lite следует хорошему принципу проектирования языка программирования Go: команда Go не расширяет синтаксис языка, как другие популярные языки программирования вместо этого команда Go расширяет язык пакетами. Точно так же редактор Lite не имеет всех функций в базовом виде. Плагины Lite расширяют редактор, предоставляя то, что нужно программисту. Проект часто меняется, поэтому я собрал его из исходников: это не сложнее загрузки из релизов. Посмотрим, как установить Lite на Linux.

[Примечание: я чуть изменил текст оригинала, чтобы код можно было быстрее скопировать и выполнить]

Клонируйте репозиторий. Перед сборкой исходного кода поставьте пакет SDL2. После соберите проект, чтобы сгенерировать бинарники. Вот код:

git clone https://github.com/rxi/lite.gitsudo apt-get install libsdl2-devbash build_release.sh

Как только будет создан архив lite.zip, извлеките файлы в каталог по желанию. Наконец, выполните ./lite, чтобы запустить редактор. А чтобы активировать ваши любимые функции, вы можете скопировать файлы плагинов в data/plugins.

Lite и VSCode


VSCode зрелый проект с историей в 5 лет. С другой стороны, Lite выпустили год назад, поэтому мы не можем сравнивать функциональность редакторов. Но мы можем выбрать лучший, зная о целях редакторов. Продукты Microsoft часто становятся неоправданно раздутыми. Мы все пережили путь от Windows XP до Windows 10. Мой пост по ссылке поясняет сказанное:

Я был поклонником Windows 98, 2000, XP, 7 и 10. Но, в конце концов, перешёл на Ubuntu

Цель VSCode добавлять функциональность, чтобы разработчики обленились и навсегда застряли в единственном редакторе. Более того, VSCode вообще не заботиться об экономии ресурсов. Однажды, чтобы запустить VSCode, вам может понадобится 16 ГБ памяти. С другой стороны, цель Lite оставаться лёгким и минималистичным и предоставлять современные функции, в которых нуждаются разработчики. Через плагины в Lite доступны такие функции:

  • Подсветка синтаксиса и автозавершение кода.
  • Линтинг.
  • Темы.
  • Навеянные минимализмом современные функции.

Выполняя одну и ту же работу, Lite занимает всего 20 мегабайт памяти, а VSCode 1,2 гигабайта.

Кроме того, Lite занимает менее 1 мегабайта места на вашем диске. Между тем, VSCode обычно занимает более 200 мегабайт.

Заключение


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

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

Мониторинг Tarantool логи, метрики и их обработка

28.12.2020 18:17:25 | Автор: admin

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


Мониторинг Tarantool


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


Настройка логов в Tarantool


Базовое конфигурирование и использование логов


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


Каждое сообщение лога имеет свой уровень детализации. Уровень логирования Tarantool характеризуется значением параметра log_level (целое число от 1 до 7):


  1. SYSERROR.
  2. ERROR сообщения log.error(...).
  3. CRITICAL.
  4. WARNING сообщения log.warn(...).
  5. INFO сообщения log.info(...).
  6. VERBOSE сообщения log.verbose(...).
  7. DEBUG сообщения log.debug(...).

Значение параметра log_level N соответствует логу, в который попадают сообщения уровня детализации N и всех предыдущих уровней детализации < N. По умолчанию log_level имеет значение 5 (INFO). Чтобы настроить этот параметр при использовании Cartridge, можно воспользоваться cartridge.cfg:


cartridge.cfg( { ... }, { log_level = 6, ... } )

Для отдельных процессов настройка производится при помощи вызова box.cfg:


box.cfg{ log_level = 6 }

Менять значение параметра можно непосредственно во время работы программы.


Стандартная стратегия логирования: писать об ошибках в log.error() или log.warn() в зависимости от их критичности, отмечать в log.info() основные этапы работы приложения, а в log.verbose() писать более подробные сообщения о предпринимаемых действиях для отладки. Не стоит использовать log.debug() для отладки приложения, этот уровень диагностики в первую очередь предназначен для отладки самого Tarantool. Не рекомендуется также использовать уровень детализации ниже 5 (INFO), поскольку в случае возникновения ошибок отсутствие информационных сообщений затруднит диагностику. Таким образом, в режиме отладки приложения рекомендуется работать при log_level 6 (VERBOSE), в режиме штатной работы при log_level 5 (INFO).


local log = require('log')log.info('Hello world')log.verbose('Hello from app %s ver %d', app_name, app_ver) -- https://www.lua.org/pil/20.htmllog.verbose(app_metainfo) -- type(app_metainfo) == 'table'

В качестве аргументов функции отправки сообщения в лог (log.error/log.warn/log.info/log.verbose/log.debug) можно передать обычную строку, строку с плейсхолдерами и аргументы для их заполнения (аналогично string.format()) или таблицу (она будет неявно преобразована в строку методом json.encode()). Функции лога также работают с нестроковыми данными (например числами), приводя их к строке c помощью tostring().


Tarantool поддерживает два формата логов: plain и json:


2020-12-15 11:56:14.923 [11479] main/101/interactive C> Tarantool 1.10.8-0-g2f18757b72020-12-15 11:56:14.923 [11479] main/101/interactive C> log level 52020-12-15 11:56:14.924 [11479] main/101/interactive I> mapping 268435456 bytes for memtx tuple arena...

{"time": "2020-12-15T11:56:14.923+0300", "level": "CRIT", "message": "Tarantool 1.10.8-0-g2f18757b7", "pid": 5675 , "cord_name": "main", "fiber_id": 101, "fiber_name": "interactive", "file": "\/tarantool\/src\/main.cc", "line": 514}{"time": "2020-12-15T11:56:14.923+0300", "level": "CRIT", "message": "log level 5", "pid": 5675 , "cord_name": "main", "fiber_id": 101, "fiber_name": "interactive", "file": "\/tarantool\/src\/main.cc", "line": 515}{"time": "2020-12-15T11:56:14.924+0300", "level": "INFO", "message": "mapping 268435456 bytes for memtx tuple arena...", "pid": 5675 , "cord_name": "main", "fiber_id": 101, "fiber_name": "interactive", "file": "\/tarantool\/src\/box\/tuple.c", "line": 261}

Настройка формата происходит через параметр log_format так же, как для параметра log_level. Подробнее о форматах можно прочитать в соответствующем разделе документации.


Tarantool позволяет выводить логи в поток stderr, в файл, в конвейер или в системный журнал syslog. Настройка производится с помощью параметра log. О том, как конфигурировать вывод, можно прочитать в документации.


Обёртка логов


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


local log = require('log')local context = require('app.context')local function init()    if rawget(_G, "_log_is_patched") then        return    end    rawset(_G, "_log_is_patched", true)    local wrapper = function(level)        local old_func = log[level]        return function(fmt, ...)            local req_id = context.id_from_context()            if select('#', ...) ~= 0 then                local stat                stat, fmt = pcall(string.format, fmt, ...)                if not stat then                    error(fmt, 3)                end            end            local wrapped_message            if type(fmt) == 'string' then                wrapped_message = {                    message = fmt,                    request_id = req_id                }            elseif type(fmt) == 'table' then                wrapped_message = table.copy(fmt)                wrapped_message.request_id = req_id            else                wrapped_message = {                    message = tostring(fmt),                    request_id = req_id                }            end            return old_func(wrapped_message)        end    end    package.loaded['log'].error = wrapper('error')    package.loaded['log'].warn = wrapper('warn')    package.loaded['log'].info = wrapper('info')    package.loaded['log'].verbose = wrapper('verbose')    package.loaded['log'].debug = wrapper('debug')    return trueend

Данный код обогащает информацию, переданную в лог в любом поддерживаемом формате, идентификатором запроса request_id.


Настройка метрик в Tarantool


Подключение метрик


Для работы с метриками в приложениях Tarantool существует пакет metrics. Это модуль для создания коллекторов метрик и взаимодействия с ними в разнообразных сценариях, включая экспорт метрик в различные базы данных (InfluxDB, Prometheus, Graphite). Материал основан на функционале версии 0.6.0.


Чтобы установить metrics в текущую директорию, воспользуйтесь стандартной командой:


tarantoolctl rocks install metrics 0.6.0

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


dependencies = {    ...,    'metrics == 0.6.0-1',}

Для приложений, использующих фреймворк Cartridge, пакет metrics предоставляет специальную роль cartridge.roles.metrics. Включение этой роли на всех процессах кластера упрощает работу с метриками и позволяет использовать конфигурацию Cartridge для настройки пакета.


Встроенные метрики


Сбор встроенных метрик уже включён в состав роли cartridge.roles.metrics.


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


local metrics = require('metrics')metrics.enable_default_metrics()

Достаточно выполнить её единожды на старте приложения, например поместив в файл init.lua.


В список метрик по умолчанию входят:


  • информация о потребляемой Lua-кодом RAM;
  • информация о текущем состоянии файберов;
  • информация о количестве сетевых подключений и объёме сетевого трафика, принятого и отправленного процессом;
  • информация об использовании RAM на хранение данных и индексов (в том числе метрики slab-аллокатора);
  • информация об объёме операций на спейсах;
  • характеристики репликации спейсов Tarantool;
  • информация о текущем времени работы процесса и другие метрики.

Подробнее узнать о метриках и их значении можно в соответствующем разделе документации.


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


Плагины для экспорта метрик


Пакет metrics поддерживает три формата экспорта метрик: prometheus, graphite и json. Последний можно использовать, например, в связке Telegraf + InfluxDB.


Чтобы настроить экспорт метрик в формате json или prometheus для процессов с ролью cartridge.roles.metrics, добавьте соответствующую секцию в конфигурацию кластера:


metrics:  export:    - path: '/metrics/json'      format: json    - path: '/metrics/prometheus'      format: prometheus

Экспорт метрик в формате json или prometheus без использования кластерной конфигурации настраивается средствами модуля http так же, как любой другой маршрут.


local json_metrics = require('metrics.plugins.json')local prometheus = require('metrics.plugins.prometheus')local httpd = require('http.server').new(...)httpd:route(    { path = '/metrics/json' },    function(req)        return req:render({            text = json_metrics.export()        })    end)httpd:route( { path = '/metrics/prometheus' }, prometheus.collect_http)

Для настройки graphite необходимо добавить в код приложения следующую секцию:


local graphite = require('metrics.plugins.graphite')graphite.init{    host = '127.0.0.1',    port = 2003,    send_interval = 60,}

Параметры host и port соответствуют конфигурации вашего сервера Graphite, send_interval периодичность отправки данных в секундах.


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


Добавление пользовательских метрик


Ядро пакета metrics составляют коллекторы метрик, созданные на основе примитивов Prometheus:


  • counter предназначен для хранения одного неубывающего значения;
  • gauge предназначен для хранения одного произвольного численного значения;
  • summary хранит сумму значений нескольких наблюдений и их количество, а также позволяет вычислять перцентили по последним наблюдениям;
  • histogram агрегирует несколько наблюдений в гистограмму.

Cоздать экземпляр коллектора можно следующей командой:


local gauge = metrics.gauge('balloons')

В дальнейшем получить доступ к объекту в любой части кода можно этой же командой.


Каждый объект коллектора способен хранить сразу несколько значений метрики с разными лейблами. Например, в результате кода


local gauge = metrics.gauge('balloons')gauge:set(1, { color = 'blue' })gauge:set(2, { color = 'red' })

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


gauge:inc(11, { color = 'blue' }) -- increase 1 by 11

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


В программе есть модуль server, который принимает запросы и способен сам их отправлять. Вместо того, чтобы использовать две различных метрики server_requests_sent и server_requests_received для хранения данных о количестве отправленных и полученных запросов, следует использовать общую метрику server_requests с лейблом type, который может принимать значения sent и received.


Подробнее о коллекторах и их методах можно прочитать в документации пакета.


Заполнение значений пользовательских метрик


Пакет metrics содержит полезный инструмент для заполнения коллекторов метрик коллбэки. Рассмотрим принцип его работы на простом примере.


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


local metrics = require('metrics')local buffer = require('app.buffer')metrics.register_callback(function()    local gauge = metrics.gauge('buffer_count')    gauge.set(buffer.count())end)

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


Мониторинг HTTP-трафика


Пакет metrics содержит набор инструментов для подсчёта количества входящих HTTP-запросов и измерения времени их обработки. Они предназначены для работы со встроенным пакетом http, и подход будет отличаться в зависимости от того, какую версию вы используете.


Чтобы добавить HTTP-метрики для конкретного маршрута при использовании пакета http 1.x.x, вам необходимо обернуть функцию-обработчик запроса в функцию http_middleware.v1:


local metrics = require('metrics')local http_middleware = metrics.http_middlewarehttp_middleware.build_default_collector('summary', 'http_latency')local route = { path = '/path', method = 'POST' }local handler = function() ... endhttpd:route(route, http_middleware.v1(handler))

Для хранения метрик можно использовать коллекторы histogram и summary.


Чтобы добавить HTTP-метрики для маршрутов роутера при использовании пакета http 2.x.x, необходимо воспользоваться следующим подходом:


local metrics = require('metrics')local http_middleware = metrics.http_middlewarehttp_middleware.build_default_collector('histogram', 'http_latency')router:use(http_middleware.v2(), { name = 'latency_instrumentation' })

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


Глобальные лейблы


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


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


local metrics = require('metrics')local global_labels = {}-- постоянное значениеglobal_labels.app = 'MyTarantoolApp'-- переменные конфигурации кластера (http://personeltest.ru/aways/www.tarantool.io/ru/doc/latest/book/cartridge/cartridge_api/modules/cartridge.argparse/)local argparse = require('cartridge.argparse')local params, err = argparse.parse()assert(params, err)global_labels.alias = params.alias-- переменные окружения процессаlocal host = os.getenv('HOST')assert(host)global_labels.host = hostmetrics.set_global_labels(global_labels)

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


Роль cartridge.roles.metrics по умолчанию выставляет alias процесса Tarantool в качестве глобального лейбла.


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


Мониторинг внешних параметров


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


С помощью psutils можно настроить сбор метрик об использовании CPU процессами Tarantool. Его информация основывается на данных /proc/stat и /proc/self/task. Подключить сбор метрик можно с помощью следующего кода:


local metrics = require('metrics')metrics.register_callback(function()    local cpu_metrics = require('metrics.psutils.cpu')    cpu_metrics.update()end)

Возможность писать код на Lua делает Tarantool гибким инструментом, позволяющим обходить различные препятствия. Например, psutils возник из необходимости следить за использованием CPU вопреки отказу администраторов со стороны заказчика "подружить" в правах файлы /proc/* процессов Tarantool и плагин inputs.procstat Telegraf, который использовался на местных машинах в качестве основного агента.


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


Визуализация метрик


Пример из tarantool/grafana-dashboard


Хранение метрик в Prometheus


Настройка пути для экспорта метрик Tarantool в формате Prometheus описана в пункте "Плагины для экспорта метрик". Ответ запроса по такому маршруту выглядит следующим образом:


...# HELP tnt_stats_op_total Total amount of operations# TYPE tnt_stats_op_total gaugetnt_stats_op_total{alias="tnt_router",operation="replace"} 1tnt_stats_op_total{alias="tnt_router",operation="select"} 57tnt_stats_op_total{alias="tnt_router",operation="update"} 43tnt_stats_op_total{alias="tnt_router",operation="insert"} 40tnt_stats_op_total{alias="tnt_router",operation="call"} 4...

Чтобы настроить сбор метрик в Prometheus, необходимо добавить элемент в массив scrape_configs. Этот элемент должен содержать поле static_configs с перечисленными в targets URI всех интересующих процессов Tarantool и поле metrics_path, в котором указан путь для экспорта метрик Tarantool в формате Prometheus.


scrape_configs:  - job_name: "tarantool_app"    static_configs:      - targets:         - "tarantool_app:8081"        - "tarantool_app:8082"        - "tarantool_app:8083"        - "tarantool_app:8084"        - "tarantool_app:8085"    metrics_path: "/metrics/prometheus"

В дальнейшем найти метрики в Grafana вы сможете, указав в качестве job соответствующий job_name из конфигурации.


Пример готового docker-кластера Tarantool App + Prometheus + Grafana можно найти в репозитории tarantool/grafana-dashboard.


Хранение метрик в InfluxDB


Чтобы организовать хранение метрик Tarantool в InfluxDB, необходимо воспользоваться стеком Telegraf + InfluxDB и настроить на процессах Tarantool экспорт метрик в формате json (см. пункт "Плагины для экспорта метрик"). Ответ формируется следующим образом:


{    ...    {        "label_pairs": {            "operation": "select",            "alias": "tnt_router"        },        "timestamp": 1606202705935266,        "metric_name": "tnt_stats_op_total",        "value": 57    },    {        "label_pairs": {            "operation": "update",            "alias": "tnt_router"        },        "timestamp": 1606202705935266,        "metric_name": "tnt_stats_op_total",        "value": 43    },    ...}

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


[[inputs.http]]    urls = [        "http://tarantool_app:8081/metrics/json",        "http://tarantool_app:8082/metrics/json",        "http://tarantool_app:8083/metrics/json",        "http://tarantool_app:8084/metrics/json",        "http://tarantool_app:8085/metrics/json"    ]    timeout = "30s"    tag_keys = [        "metric_name",        "label_pairs_alias",        "label_pairs_quantile",        "label_pairs_path",        "label_pairs_method",        "label_pairs_status",        "label_pairs_operation"    ]    insecure_skip_verify = true    interval = "10s"    data_format = "json"    name_prefix = "tarantool_app_"    fieldpass = ["value"]

Список urls должен содержать URL всех интересующих процессов Tarantool, настроенные для экспорта метрик в формате json. Обратите внимание, что лейблы метрик попадают в Telegraf и, соответственно, InfluxDB как теги, название которых состоит из префикса label_pairs_ и названия лейбла. Таким образом, если ваша метрика имеет лейбл с ключом mylbl, то для работы с ним в Telegraf и InfluxDB необходимо указать в пункте tag_keys соответствующего раздела [[inputs.http]] конфигурации Telegraf значение ключа label_pairs_mylbl, и при запросах в InfluxDB ставить условия на значения лейбла, обращаясь к тегу с ключом label_pairs_mylbl.


В дальнейшем найти метрики в Grafana вы сможете, указав measurement в формате <name_prefix>http (например, для указанной выше конфигурации значение measurement tarantool_app_http).


Пример готового docker-кластера Tarantool App + Telegraf + InfluxDB + Grafana можно найти в репозитории tarantool/grafana-dashboard.


Стандартный дашборд Grafana


Для визуализации метрик Tarantool с помощью Grafana на Official & community built dashboards опубликованы стандартные дашборды. Шаблон состоит из панелей для мониторинга HTTP, памяти для хранения данных вместе с индексами и операций над спейсами Tarantool. Версию для использования с Prometheus можно найти здесь, а для InfluxDB здесь. Версия для Prometheus также содержит набор панелей для мониторинга состояния кластера, агрегированной нагрузки и потребления памяти.



Чтобы импортировать шаблон дашборды, достаточно вставить необходимый id или ссылку в меню Import на сервере Grafana. Для завершения процесса импорта необходимо задать переменные, определяющие место хранения метрик Tarantool в соответствующей базе данных.


Генерация дашбордов Grafana с grafonnet


Стандартные дашборды Grafana были созданы с помощью инструмента под названием grafonnet. Что это за заморский зверь и как мы к нему пришли?


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


Одним из первых способов решить большинство возникающих проблем было использование механизма динамических переменных (Variables) в Grafana. Например, он позволяет объединить дашборды с метриками из разных зон в одну с удобным переключателем. К сожалению, мы слишком быстро столкнулись с проблемой: использование механизма оповещений (Alert) не совместимо с запросами, использующими динамические переменные.


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


В открытом доступе можно найти несколько проектов, посвящённых данной теме. К счастью или несчастью, проблема выбора решилась быстро: на контуре заказчика для хранения метрик мы безальтернативно пользовались InfluxDB, а поддержка запросов к InfluxDB хоть в какой-то форме присутствовала только в grafonnet.


grafonnet opensource-проект под эгидой Grafana, предназначенный для программной генерации дашбордов. Он основан на языке программирования jsonnet языке для генерации json. Сам grafonnet представляет собой набор шаблонов для примитивов Grafana (панели и запросы разных типов, различные переменные) с методами для объединения их в цельный дашборд.


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


Начав с форка проекта и сборки нашей дашборды на основе этого форка, впоследствии мы оформили несколько Pull Request-ов в grafonnet на основе наших изменений. Например, один из них добавил поддержку запросов в InfluxDB на основе визуального редактора.


Визуальный редактор запросов InfluxDB


Код наших стандартных дашбордов расположен в репозитории tarantool/grafana-dashboard. Здесь же находится готовый docker-кластер, состоящий из стеков Tarantool App + Telegraf + InfluxDB + Grafana, Tarantool App + Prometheus + Grafana. Его можно использовать для локальной отладки сбора и обработки метрик в вашем собственном приложении.


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


На что смотреть?


В первую очередь, стоит следить за состоянием самих процессов Tarantool. Для этого подойдёт, например, стандартный up Prometheus. Можно соорудить простейший healthcheck самостоятельно:


httpd:route(    { path = '/health' },    function(req)        local body = { app = app, alias = alias, status = 'OK' }        local resp = req:render({ json = body })        resp.status = 200        return resp    end)

Рекомендации по мониторингу внешних параметров ничем принципиально не отличаются от ситуации любого другого приложения. Необходимо следить за потреблением памяти на хранение логов и служебных файлов на диске. Заметьте, что файлы с данными .snap и .xlog возникают даже при использовании движка memtx (в зависимости от настроек). При нормальной работе нагрузка на CPU не должна быть чересчур большой. Исключение составляет момент восстановления данных после рестарта процесса: построение индексов может загрузить все доступные потоки процессора на 100 % на несколько минут.


Потребление RAM удобно разделить на два пункта: Lua-память и память, потребляемая на хранение данных и индексов. Память, доступная для выполнения кода на Lua, имеет ограничение в 2 Gb на уровне Luajit. Обычно приближение метрики к этой границе сигнализирует о наличии какого-то серьёзного изъяна в коде. Более того, зачастую такие изъяны приводят к нелинейному росту используемой памяти, поэтому начинать волноваться стоит уже при переходе границы в 512 Mb на процесс. Например, при высокой нагрузке в наших приложениях показатели редко выходили за предел 200-300 Mb Lua-памяти.


При использовании движка memtx потреблением памяти в рамках заданного лимита memtx_memory (он же метрика quota_size) заведует slab-аллокатор. Процесс происходит двухуровнево: аллокатор выделяет в памяти ячейки, которые после занимают сами данные или индексы спейсов. Зарезервированная под занятые или ещё не занятые ячейки память отображена в quota_used, занятая на хранение данных и индексов arena_used (только данных items_used). Приближение к порогу arena_used_ratio или items_used_ratio свидетельствует об окончании свободных зарезервированных ячеек slab, приближение к порогу quota_used_ratio об окончании доступного места для резервирования ячеек. Таким образом, об окончании свободного места для хранения данных свидетельствует приближение к порогу одновременно метрик quota_used_ratio и arena_used_ratio. В качестве порога обычно используют значение 90 %. В редких случаях в логах могут появляться сообщения о невозможности выделить память под ячейки или данные даже тогда, когда quota_used_ratio, arena_used_ratio или items_used_ratio далеки от порогового значения. Это может сигнализировать о дефрагментации данных в RAM, неудачном выборе схем спейсов или неудачной конфигурации slab-аллокатора. В такой ситуации необходима консультация специалиста.


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


Заключение


Как этот материал, так и пакет metrics назвать "всеохватными" и "универсальными" на данный момент нельзя. Открытыми или находящимися на данный момент в разработке являются вопросы метрик репликации, мониторинга движка vinyl, метрики event loop и полная документация по уже существующим методам metrics.


Не стоит забывать о том, что metrics и grafana-dashboard являются opensource-разработками. Если при работе над своим проектом вы наткнулись на ситуацию, которая не покрывается текущими возможностями пакетов, не стесняйтесь внести предложение в Issues или поделиться вашим решением в Pull Requests.


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


Полезные ссылки:


Подробнее..

Свой ремейк ZX игры Reskue в Steam

12.06.2021 16:15:59 | Автор: admin

Да, понимаю, что это игра про учёных, но это не игра про немого учёного с монтировкой с цифрой 3, которую все ждут. Что вышло за 4 года разработки.

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

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

Заправьте свой корабль радиоактивным топливом и спасите ученых. Берегите себя, науку и удачи!

Это ретро экшен, требующий достаточной быстроты, либо ловкости, либо хитрости от игрока. Есть несколько тактик прохождения игры. Кроме арсенала в 10 видов оружия, каждое из которых накладывает особый эффект на врага и имеет несколько режимов стрельбы, есть ещё и хитрость в виде ловушек или предметов сильно отвлекающих противника. некоторых настолько что игрок будет почти невидим для врагов. Даже на нормальной сложности игра не будет лёгкой прогулкой по которой игрока будут водить за ручку и обьяснять Press F to Win. Однако если вам игра покажется слишком лёгкой уровень сложности можно повысить.

В самой игре заложена и пара "пасхалок" напоминающих о спектруме. Я не скажу где они однако одна из них содержит пару обьектов из Boulder dash, а другая ещё что-то.

Ключевые обновления:

  • Добавлен режим хотсита - кооп (. затем kp9) и версус (. затем kp7). (Игра на 2 клавиатурах и/или джойстиках). Cетевая игра увы возможна только через Steam remote play.

  • Все почти враги теперь анимированы (новое видео пока не готово)

  • Внешний интерфейс (USER GUI) доработан. (Инвентарь, выбранное оружие, датчик жизни, кулдаун, селектор оружия.)

  • Встроенная игровая справка по F2 с описанием тактики поведения и арсенала.

Ключевые особенности

  • Редактор карт поддерживает импорт карт M2K (mission 2000), Rescue+ с реального ZX-Spectrum. Карты будут работать если их копировать программой HOBETA.

  • Мультиязычность и кроссплатформенность.

  • Высокая скорость работы даже на устаревших компьютерах.

Подсказка: Максимально просто игру можно пройти вдвоем - Один из игроков должен выбрать ученого при начале совместной игры.

Игра нетребовательная идёт почти на всём. Требования: 64бит и OpenGL 3.3 и хотя бы 30 мб места.

Steam Ранний доступ.

Все списки обновлений переводятся также на 3 языка как и сама игра. Прочесть можно на страничке игры

Я ранее писал статью на DTF и если кто хочет просто попробовать игру Reskue или M2K или Colony (очень ранняя версия) может скачать их ниже.

Видео новее пока нет. если только про кооператив и версус ссылка под видео. С тех пор игра была сильно переработана но я пока не осилил трейлер.

Хотсит на русском.

Новый трейлер на польском.

Я на вопросы иногда делаю выпуски видеоответов. Реже чем надо было бы, но делаю. https://www.youtube.com/watch?v=JJAe2b-kTgs - Руководство о портировании готовой игры на Love в Android APK (с подписью) для Linux. В блоге разработчика (devblog) бывают и арты и рисунки и другая всячина.

Игра написана с нуля и сохранившая в основном идею оригинала (Rescue od Mastertronic) и схожесть с Spectrum играми (тайлы, цветовая гамма, некоторая хардкорность геймплея и некоторые команды в Lua движке.). От оригинальной игры не используется ничего, перерисовано все что возможно. Изначально я делал ремейк собственной игры M2K (mission2000), затем понял что на основе движка могу сделать новую игру что и начал в 2019, вдохновили меня в коллективе confa-gd на конкурсе Джем победы на это. Канал по игре.

C чего всё начиналось:

Это оказался прекрасный фундамент для применения множества идей как Sci-fi так и просто современных удобств геймплея каждую из которых пришлось делать самому, т.к. движок мой авторский на Love framework. Я постарался бережно развить идею так как ранее с ограничениями платформы ее развить было бы значительно сложнее. Я с трудом нашел нескольких художников и аниматоров и за ещё 2 года и 16К получилась эта игра. Отдельная история и целая лекция получилась по работе со Steamworks чтобы игра попала в магазин, это заняло целых полгода в основном доработки тексту. Я даже не думаю что она вообще когда то отобьется и будет кормить меня и принесет мне хоть что то. мне просто хотелось возродить частичку классики так как я её вижу и сделать ее доступной всем.

Также я веду небольшой ютуб и телеграм каналы без мемасиков и приколов посвященный Linux для домашнего использования. Сборка в основном для тестирования Windows игр предназначена. Сам я выбрал Mint c Mate DE немного новостей немного взаимопомощи.

N.B. Людям, которые ждут, что один человек почти в одно лицо накодит им Ведьмака 3 в 3D за денек, прошу выйти из чата. Этим методом получатся только игры для Gry z kosza (польская передача об очень плохих играх).

Мне просто хочется, чтобы в эту игру можно было сыграть и через много лет спустя, я думаю Steam с нами всеми надолго и игра там точно проживёт много лет. Да название я написал в духе названия mortal kombat. А почему нет?

Также я время от времени обновляю код движка на github. У меня там несколько проектов.

Подробнее..

Расчет перцентилей для мониторинга высоконагруженных систем

24.11.2020 16:19:42 | Автор: admin


Привет, меня зовут Игорь, и я разработчик решений на Tarantool в Mail.ru Group. Я работаю над витринами маркетинга в реальном времени для Мегафона. При мониторинге часто требуется использовать перцентили. Они позволяют понять, как система работает бльшую часть времени, в отличие от усреднения значений, которое сильно подвержено влиянию выбросов. Если 9 из 10 запросов выполняются за 1 секунду, а один за 10 секунд, то среднее будет 1,9 секунды, а 50-перцентиль 1 секунда. Это лишь один пример того, что среднее значение не подходит для мониторинга. Возникает необходимость считать перцентили, для этого мы добавили в tarantool/metrics Summary-коллектор.


Функциональность Summary-коллектора расчет квантилей для наблюдаемых данных. Расскажу об алгоритме, который мы использовали для квантилей, и о том, как мы его реализовывали для tarantool/metrics.


Summary-коллектор


Алгоритм


$\phi$-квантиль это значение, которое случайная величина не превышает с вероятностью $\phi$. Пример: 0,5-квантиль (она же 50-перцентиль), равная 1 секунде, для мониторига HTTP-запросов означает, что 50% запросов были обработаны меньше, чем за секунду. Чтобы посчитать квантиль $\phi$ для отсортированного массива размером $n$, необходимо взять элемент с индексом $\phi \times n$. При таком подходе необходимо хранить все данные, а в метриках их может быть очень много. Если был 1 млрд запросов, то будет 1 млрд элементов массива порядка 1 Гб данных.


Для решения этой проблемы существует несколько алгоритмов расчета приближенных значений квантилей на потоках данных. Мы взяли алгоритм, который использует Prometheus. Он сжимает исходные данные в отрезки из трех чисел: расстояние от начала предыдущего отрезка до начала текущего $w$, длина текущего отрезка $\Delta$ и приближенное значение квантили на этом отрезке $v$.



Элементы исходного массива изображены зеленым, элементы сжатого массива красным. Чтобы найти квантиль на сжатых данных, нужно пройтись по всем отрезкам, складывая расстояния, и найти тот, в который попадает значение $\phi \times n$. Тогда на рисунке 0,5-квантиль будет располагаться посередине зеленого массива, а приближенное значение будет принадлежать соответствующему красному отрезку.


Процесс компрессии подробно описан в исходной статье.


Реализация


Мы ориентировались на реализацию алгоритма на Go.


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


typedef struct {int Delta, Width; double Value; } sample;

Алгоритм работает только с отсортированными значениями. Ограничим размер буфера 500 значениями, а размер массива наблюдений определим как $2 \times 500 + 2$ операция сжатия сокращает размер массива примерно вполовину, так что в среднем нам потребуется: 500 элементов несжатого массива с предыдущего шага + 500 элементов, которые вливаются в массив на текущем шаге + 2 элемента $+\infty$ и $-\infty$ для упрощения поиска в массиве.


Ход разработки


Разрабатывали итеративно: делаем версию, проверяем производительность c помощью профилировщика и сравниваем с версией на Go; думаем, как улучшить. Сравнивать будем с простым бенчмарком: делаем вставку 108 образцов, для гошной версии это занимает порядка 8 с. Теперь подробнее о каждом шаге:


1) pure-Lua версия очень плохо, вставка занимает в среднем около 100 с.


В профилировщике видим следующее:



Код проседает на вставке наблюдений в массив (вызов table.insert) и сортировке буфера (table.sort). На помощь приходит ffi, или foreign function interface. Ffi позволяет обращаться к функциям из стандартной библиотеки C, а потом работать с ними в Lua, как с обычными Lua-объектами (ну, почти; например, индексация таблиц в Lua начинается с 1, а у массивов, созданных из С, всё еще с 0).


2) Lua + ffi заменим создание буфера на создание массива double:


local ffi = require('ffi')array = ffi.new('double[?]', max_samples)for i = 0, max_samples - 1 do    array[i] = math.hugeend

Сортировать такой массив будем средствами стандартной библиотеки С:


ffi.cdef[[

    void qsort(void *base, size_t nitems, size_t size, int (*compar)(const void *, const void*));    int cmpfunc (const void * a, const void * b);

]]

Функцию-компаратор для double нужно написать на С и подключить как динамическую библиотеку. Пишем компаратор:


int cmpfunc (const void * a, const void * b) {    if (*(double*)a > *(double*)b)        return 1;    else if (*(double*)a < *(double*)b)        return -1;    else        return 0;}

Собираем его:


gcc -c -o metrics/quantile.o metrics/quantile.cgcc -shared -o metrics/libquantile.so metrics/quantile.o

Подключаем библиотеку в Lua-коде:


local dlib_path = package.search('libquantile', package.cpath)local dlib = ffi.load(dlib_path)

Теперь можно заполнить массив double и вызвать сортировку:


local DOUBLE_SIZE = ffi.sizeof('double')ffi.C.qsort(array, len, DOUBLE_SIZE, dlib.cmpfunc)

Тестируем производительность и получаем прирост в 3 раза, в среднем до 30 с. Проседание происходило из-за того, что размер таблиц в Lua не фиксированный, тип элементов тоже никак не указывается заранее. Это позволяет гибче работать с таблицами, но снижает производительность (подробнее о Lua-таблицах можно почитать здесь). Ffi позволяет перейти от Lua-таблиц к С-массивам с фиксированным размером, поэтому вставка и вычисление размера массива теперь обходятся в $O(1)$ вместо $O(\log n)$. Сортировка тоже происходит гораздо быстрее благодаря зафиксированным типам и, соответственно, фиксированным размерам элементов. Но при таком решении появилась зависимость от gcc, что усложняет поставку приложений. Поэтому пришлось избавиться от C-кода.


3) Lua + ffi + самописная сортировка время работы простейшего варианта быстрой сортировки на Lua получилось всего лишь на пару секунд хуже, чем вариант с сишной библиотекой. Это значение вместе с отсутствием gcc нас удовлетворило, и мы решили остановиться на нем.


Расход памяти


metrics.quantile использует два массива:


  • Буфер размером max_samples * sizeof(double) = $500 \times 8$ байт.
  • Массив наблюдений размером (2 * max_samples + 2) * sizeof(struct sample) = $1002 \times 16$ байт. Размер массива наблюдений может увеличиваться при изменении наблюдаемых значений на несколько порядков.

Влияние на производительность


Провели нагрузочное тестирование Яндекс.Танком (подробнее здесь). Приложение с выключенными метриками:



При использовании Summary-коллектора:



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


Использование


tarantoolctl rocks install metrics 0.5.0

local metrics = require('metrics') -- подключаем метрики-- Создаем summary коллекторlocal http_requests_latency = metrics.summary(    'http_requests_latency', 'HTTP requests latency',    {[0.5]=0.01, [0.9]=0.01, [0.99]=0.01})-- наблюдаем значение:local latency = math.random(1, 10)http_requests_latency:observe(latency)

Поддерживается экспорт в JSON, Prometheus и Graphite. Вот так могут выглядеть собранные результаты в Grafana:



Итоги


Мы написали Summary-коллектор для tarantool/metrics. При разработке столкнулись с проблемой производительности, которую решили с помощью ffi. Новый коллектор можно использовать для мониторинга величин, которые выставляются по квантилям, например задержки HTTP-запросов. Summary можно использовать во всех продуктах на Tarantool, где важно время отклика сервиса, например в высоконагруженных приложениях, где HTTP-запросы обращаются к большим объемам данных. Наблюдение за этой метрикой позволит понять, какие запросы нагружают систему.


Ссылки


Подробнее..

Есть ли параллелизм в произвольном алгоритме и как его использовать лучшим образом

26.11.2020 16:14:32 | Автор: admin

Параллелизации обработки данных в настоящее время применяется в основном для сокращения времени вычислений путем одновременной обработки данных по частям на множестве различных вычислительных устройств с последующим объединением полученных результатов. Параллельное выполнение позволяет обойти сформулированный лордом Рэлеем в 1871 г. фундаментальный закон, согласно которому (в применимости к тепловыделению процессоров) мощность их тепловыделения пропорциональна четвертой степени тактовой частоты процессора (увеличение частоты вдвое повышает тепловыделение в 16 раз) и фактически заменить его линейным от числа параллельных вычислителей при сохранении тактовой частоты). Ничто не дается даром задача выявления (обычно скрытого для непосвящённого наблюдателя, [1]) потенциала параллелизма в алгоритмах не является лежащей на поверхности, а уж эффективность его (параллелизма) использования тем более.

Ниже приведена иллюстрация процесса выявления параллелизма для простейшего случая вычисления выражения axb+a/c (a, b, c входные данные).

а) облако операторов (последовательность выполнения не определена), б) полностью последовательное выполнение, не определена), б) полностью последовательное выполнение, в) параллельное исполнение

Даже в таком примитивном алгоритме параллельное выполнение ускорило вычисления в полтора раза, но потребовало двух вычислителей вместо одного. Обычно с увеличением числа операторов (усложнением алгоритма) ускорение вычислений возрастает (хотя имеются и полностью нераспараллеливаемые на уровне арифметических действий алгоритмы напр., вычисление чисел Фибоначчи). Для приведенного на рис.1 алгоритма все выкладки легко выполняются в уме, но для реальных (много более сложных алгоритмов) требуется машинная обработка.

Параллельная вычислительная система включает несколько вычислителей (арифметико-логических устройств), объединённых общей или локальной оперативной памятями и кэшами. Современные параллельные системы часто имеют не только гомогенное, но и гетерогенное вычислительное поле. Задача распределения вычислений между отдельными вычислителями приводит к разработке расписания (плана) вычислений. Проблемой является многозначность расписаний параллельного выполнения алгоритма в общем случае это NP-полная задача [2], точное решение (при заданных оптимизационных требованиях) которой можно получить только методом полного перебора (что нереально при числе операторов уже более сотен-тысяч). Выходом является использование эвристических методов, исходя из сложности данную область знания можно обоснованно отнести к наиболее сложным случаям Науки о данных (Data Science).

Параметрам выполнения распространенных алгоритмов в параллельном варианте посвящен известный ресурс AlgoWiki [3].

Особенно интересен, с точки зрения автора, вариант параллелизации для языков программирования высокого уровня без явного указания распараллеливания и в системах c концепцией ILP (Instruction-Level Parallelism, параллелизм на уровне команд, с реализацией посредством вычислительной архитектуры EPIC (Explicitly Parallel Instruction Computing, явный параллелизм выполнения команд). При этом аппаратное обеспечение вычислительных систем сильно упрощается и все проблемы выявления параллелизма и построение собственно расписания выполнения программы для заданной конфигурации параллельной вычислительной системы ложатся на компилятор, что ведет к его усложнению и снижению скорости компиляции.

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

Одной из важных процедур выявления параллелизма по заданному ИГА является получения его исходной (обладающей свойством каноничности) Ярусно-Параллельной Формы (ЯПФ), [4]. При этом условием расположением операторов на едином ярусе является независимость их друг от друга по информационным связям (необходимое условие их параллельного выполнения).

Получение такой (без условия ограниченности количества операторов на ярусах) ЯПФ требует O(N2) действий, где N число операторов (вершин графа), ее высота (общее число ярусов) равна увеличенной на единицу длине критического пути в ИГА. Напрямую использовать такую ЯПФ в качестве основы для построения расписания выполнения параллельной программы обычно затруднительно (ширина некоторых ярусов часто сильно превышает число имеющихся параллельных вычислителей). Но т.к. вычисление такой ЯПФ вычислительно малозатратно, во многих случаях целесообразно начинать анализ именно с ее получения и в дальнейшем проводить модификацию этой ЯПФ с учетом конкретных задач. При этом при каждой модификации ЯПФ получаем новую форму, полностью соответствующую исходному ИГА.

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

На рис. ниже приведен несложный случай алгоритма вычисления вещественных корней полного квадратного уравнения ax2+bx+c=0.

На рисунке -ярусно-параллельная форма алгоритма решения полного квадратного уравнения в вещественных числах в канонической форме (номера ярусов ЯПФ расположены справа)На рисунке -ярусно-параллельная форма алгоритма решения полного квадратного уравнения в вещественных числах в канонической форме (номера ярусов ЯПФ расположены справа)

Показанная ЯПФ уже является расписание выполнения параллельной программы (выполнение начинается сверху вниз, требует 6 относительных единиц времени и 4-х параллельных вычислителей). При этом число задействованных вычислителей по ярусам крайне неравномерно (важно при однозадачном режиме работы вычислительной системы) на 1-м ярусе задействованы все 4, на 2,3,4 - только один и по два на 5- и 6 ярусах. Однако легко видеть, что простейшее преобразование (показанные красным пунктиром допустимые перестановки операторов с яруса на ярус) позволяют выполнить тот же алгоритм за то же (минимальное из возможных) время всего на двух вычислителях! Не для любого алгоритма получается столь идеально часто для снижения требуемого числа параллельных вычислителей единственным путем является увеличение времени выполнения алгоритма (возрастание числа ярусов ЯПФ).

Для решения задач определения рационального (на основе заданных критериев) расписания выполнения произвольных алгоритмов создан инструментальный программный комплекс, включающая два модуля - D-F и SPF@home. Свободная выгрузка инсталляционных файлов доступна с ресурсов http://vbakanov.ru/dataflow/content/installdf.exe и http://vbakanov.ru/spf@home/content/installspf.exe соответственно (дополнительная информация по теме - http://vbakanov.ru/dataflow/dataflow.htm и http://vbakanov.ru/spf@home/spf@home.htm).

 На рисунке - схема инструментального комплекса (*.set и *.gv программный файл и файл информационного графа анализируемой программы соответственно, *.mvr, *.med файлы метрик вершин и дуг графа алгоритма соответственно, *.cls, *.ops файлы параметров вычислителей и операторов программы соответственно, *.lua текстовый файл на языке Lua, содержащий методы реорганизации На рисунке - схема инструментального комплекса (*.set и *.gv программный файл и файл информационного графа анализируемой программы соответственно, *.mvr, *.med файлы метрик вершин и дуг графа алгоритма соответственно, *.cls, *.ops файлы параметров вычислителей и операторов программы соответственно, *.lua текстовый файл на языке Lua, содержащий методы реорганизации

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

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

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

Родительское приложение создано с использованием языка программирования С++, является GUI Win32-программой и доступно для свободной выгрузки и использования (как и исходные тексты) через GIT-репозиторий. Вывод данных осуществляется на экран в текстовом и графическом форматах и в файлы (также в структурированной текстовой форме).

Сочетание компилируемой родительской программы и встроенного интерпретатора скриптового языка позволило обеспечить высокую производительность и гибкость (Lua-вызовы фактически являются обертками для API-функций модуля SPF@home).

Копии экранов обсуждаемого комплекса приведены на изображениях ниже (программные модули D-F и SPF@home соответственно).

Модуль D-F (Data-Flow) является фактически универсальным вычислителем, выполняющим программу на ассемблероподобном языке на заданном числе параллельных вычислителей. При их числе большем 1 вычисления ведутся по принципу Data-Flow (реализуется статическая потоковая архитектура), операторы выполняются по условию готовности их к выполнению (ГКВ), что является следствием присвоенности значений все операндам данного оператора; при единичном вычислителе реализуется обычное последовательное выполнение. В случае превышения числа ГКВ-операторов числа свободных вычислители используется задаваемая система приоритетов их выполнения, условное выполнение реализуется предикатным выполнением, для реализации циклов используется система макросов, разворачивающая циклические структуры. Модуль D-F имеет встроенную систему проверку корректности ИГА, для контроля выполнения используется динамическая цветовая индикация выполненности операторов.

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

Такая программа может рассматриваться как результат компиляции с языков программирования высокого уровня, не содержащих явных указаний на параллелизацию вычислений. В модуле D-F программа отлаживается, результат выдается в файл ИГА-формата для обработки в модуле SPF@home. В модуле SPF@home для получения ЯПФ из gv-файла (стандартный формат текстовых файлов описаний графов), запоминания его во внутреннем представлении системы, создание ЯПФ и запоминание его в Lua-массиве для дальнейшей обработки может быть выполнен следующий код (наклоном выделены API-вызовы системы, двойной дефис означает начало комментария до конца строки):

CreateTiersByEdges("EdgesData.gv")  -- создать ЯПФ по файлу EdgesData.gv -- с подтянутостью операторов вверх-- CreateTiersByEdges_Bottom("EdgesData.gv")  -- создать ЯПФ по файлу EdgesData.gv -- с подтянутостью операторов вниз--OpsOnTiers={} -- создаём пустой 1D-массив OpsOnTiers for iTier=1,GetCountTiers() do -- по ярусам ЯПФ   OpsOnTiers[iTier]={} -- создаём iTier-тую строку 2D-массива OpsOnTiers   for nOp=1,GetCountOpsOnTier(iTier) do -- по порядковым номерам операторов на ярусе iTier        OpsOnTiers[iTier][nOp]=GetOpByNumbOnTier(nOp,iTier) -- взять номер оператора nOpend end -- конец циклов for по iTier и for по nOp

Для удобства данные метрик операторов и дуг графа выведены из gv-файлов и расположены в текстовых mvr и med-файлах, для моделирования выполнения программ на гетерогенном поле параллельных вычислителей служат cls и ops-файлы сопоставления возможностей выполнения определенных операторов на конкретных вычислителях. Преимуществом такого подхода является возможность задания нужных параметров целой группе объектов (списком типа от-до, причем список может быть и вырожденным) одной строкой файла. Задавать параметры можно по практически неограниченному количеству тегов, определяемых разработчиком.

Модуль SPF@home также позволяет определять время жизни данных между ярусами ЯПФ, что необходимо для определения/оптимизации параметров устройств временного хранения данных (обычно регистров процессора). Собственно размер данных при этом берется из med-файлов.

Система нацелена в основном на анализ программ, созданных с использованием языков программирования высокого уровня без явного указания распараллеливания и в системах c концепцией ILP (Instruction-LevelParallelism, параллелизм на уровне команд), хотя возможности модуля SPF@home позволяют использовать в качестве неделимых блоков последовательности команд любого размера.

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

I. Балансировка числа операторов по всем ярусам заданной ЯПФ без увеличения ее высоты (минимизируется требуемое для решения задачи число параллельных вычислителей).

II. Получение расписания выполнения программы на заданном числе параллельных вычислителей с возможным увеличением высоты ЯПФ (фактически временем выполнения программы).

III. Получение расписания выполнения программы на гетерогенном поле параллельных вычислителей.

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

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

Собственно последовательность получения рационального расписания выполнения параллельной задачи будут следующей:

1) Получение исходной (не имеющей ограничений по ширине ярусов) ЯПФ.

2) Модификация этой ЯПФ в нужном направлении путем целенаправленной перестановки операторов с яруса на ярус при сохранении исходных связей в информационном графе алгоритма.

Описанная процедура итеративна и является естественно-последовательной для решения поставленной задачи. Второй параграф последовательности выполнятся в зависимости от выбранных исследователя критериев оптимизации метода разработки расписания (получение ЯПФ минимальной ширины, заданное ограничение ширины ЯПФ, минимизация числа регистров временного хранения данных, комбинированная функция цели). При этом сначала выполняются информационные API-вызовы и на основе полученных данных реализуются акционные вызовы (дающие информацию о состоянии ЯПФ и осуществляющие реальную перестановку операторов соответственно, причем перед выполнением вторых всегда проверяется условие неизменности исходных информационных связей в графе после совершения действия).

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

Методы достижения цели могут быть собственно разработанными эристиками (обычно ограниченной сложности для возможности использования в режиме реального времени) или стандартными - метод ветвей и границ, генетические, муравьиные и др. (чаще используются при исследовательской работе). Возможности системы включают использование оберток многофункциональных системных Windows-вызовов WinExec, ShellExecute и CreateProcess, так что исследователь имеет возможность вызова и управления любыми внешними программами (например, использовать тот же METIS в качестве процесса-потомка), файловое обслуживание при этом осуществляется средствами Lua.

В качестве примера на рис.6 приведены (в форме линейчатых диаграмм распределения ширин ярусов ЯПФ) результаты вычислительных экспериментов с общим символическим названием Bulldozer, возникшим по аналогии с отвалом бульдозера, перемещающим грунт с возвышенностей во впадины.

На рис. показаны линейчатые диаграммы ширин ярусов реальных ЯПФ из копий экранов при работе системы SPF@home (средне-арифметическое значение ширин показано пунктиром, a) и символическая схема действия метода Bulldozer - б)На рис. показаны линейчатые диаграммы ширин ярусов реальных ЯПФ из копий экранов при работе системы SPF@home (средне-арифметическое значение ширин показано пунктиром, a) и символическая схема действия метода Bulldozer - б)

Многочисленные вычислительные эксперименты показывают, что во многих случаях удается значительно (до 1,5-2 раз) снизить ширину ЯПФ без увеличения высоты, но почти никогда до минимальной величины (средне-арифметическое значение ширин ярусов).

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

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

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

Список литературы

1. Воеводин В.В., Воеводин Вл.В. Параллельные вычисления. СПб.: БХВ-етербург, 2002. 608 c.

2. Гери М., Джонсон Д. Вычислительные машины и труднорешаемые задачи. : Мир, Книга по Требованию, 2012. 420 c.

3. AlgoWiki. Открытая энциклопедия свойств алгоритмов. URL: http://algowiki-project.org (дата обращения 31.07.2020).

4. ФедотовИ.Е.Параллельное программирование. Модели и приёмы. М.: СОЛОН-Пресс, 2018. 390 с.

5. Roberto Ierusalimschy. Programming in Lua. Third Edition. PUC-Rio, Brasil, Rio de Janeiro, 2013. 348 p.

Подробнее..

Такие важные короткоживущие данные

24.12.2020 22:23:19 | Автор: admin

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

В публикации на Habrе Есть ли параллелизм в произвольном алгоритме и как его использовать лучшим образом от 26.11.2020 г. (http://personeltest.ru/aways/habr.com/ru/post/530078/) описан исследовательский программный комплекс для анализа произвольного алгоритма на наличие скрытого потенциала параллелизма и инструментарий для построения рационального каркаса расписания параллельной программы.

Комплекс включает модуль программного симулятора универсального вычислителя, реализующий Data-Flow (далее D-F) подход к управлению последовательностью вычислений [1] с использованием архитектуры SMP (Symmetric MultiProcessing, симметричная мультипроцессорность), позволяющий выполнять произвольную программу, разработанную на языке программирования уровня Ассемблера [2], причем порядок операндов соответствует стилю AT&T. Программная модель использует трёхадресные команды, условность выполнения осуществляется предикатным методом, [3]). D-F симулятор позволяет гибко управлять параметрами потокового выполнения программ в соответствии с концепцией ILP (Instruction-LevelParallelism, параллелизм уровня машинных команд) и автоматически (в физической реализации на аппаратном уровне) генерировать план выполнения параллельной программы [4].

В вычислителях потоковой архитектуры процесс выбора операторов для исполнения удобно представить результатом взаимодействия некоторых сущностей, асинхронно выполняющих определенные действия актёров [5], при этом естественным образом моделируются связанные с характеристиками времени параметры обработки операторов.

Второй модуль комплекса (SPF@home, далее SPF) служит для выявления в Информационном Графе Алгоритма (далее ИГА) скрытого параллелизма методом построения Ярусно-параллельной Формы (далее ЯПФ) алгоритма [5] c последующей реорганизацией ЯПФ путём последовательных целенаправленных изменений без нарушения информационных связей [6] для составления рационального каркаса расписания выполнения параллельной программы. Одной из важных целей реорганизации ЯПФ является повышение плотности кода (формально определяемой в данном случае равномерностью заполнения ярусов ЯПФ операторами). Т.к. задачи составления расписаний относят к классу NP-полных [7], в данной работе использован метод разработки, основанный на создании и итерационном улучшении основанных на эвристическом подходе сценариев реорганизации ЯПФ [8] (собственно сценарии разрабатываются с использованием скриптового языка Lua [9]).

Исходными данными для модуля SPF служат ИГА-файлы стандартного формата, которые могут быть получены путём импорта из системы D-F или любым иным (в общем случае уровень операторов может быть любым необязательно соответствующим концепции ILP).

Исходя из сложности алгоритмов обработки данную область исследований можно обоснованно отнести к наиболее сложным областям Науки о данных (Data Science).

Все вышеописанные программные модули полностью Open Source, свободная выгрузка инсталляционных файлов доступна с ресурсов http://vbakanov.ru/dataflow/content/install_df.exe и http://vbakanov.ru/spf@home/content/install_spf.exe соответственно (дополнительная информация - http://vbakanov.ru/dataflow/dataflow.htm и http://vbakanov.ru/spf@home/spf@home.htm).

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

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

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

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

Набор API-вызовов системы SPF позволяет получить информацию о требуемых при выполнении заданного алгоритма параметрах (времени жизни данных, далее ВЖД). Эти данные появляются следствием выполнения отдельных операторов и, в свою очередь, служат входными операндами для иных операторов (метафорически можно сказать, это такие ВЖД живут между ярусами ЯПФ).

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

На рис.1 и 2 показаны расписания выполнения в форме ЯПФ (причём, как и было принято ранее, выполнение осуществляется в направлении увеличения номеров ярусов) алгоритма решения полного квадратного уравнения в вещественных числах (аналогично рис.2 предыдущей статьи для исходной и модифицированной ЯПФ соответственно; при этом номера операторов в овалах (111-116) соответствуют операциям присвоения (или передачи из внешних модулей) начальных значений для расчёта. Диаграммы ВЖД приведены в форме отдельных блоков, включающих циклы генерация использование уничтожение данных, поэтому некоторые номера операторов дублируются.

Рисунок1Исходная ЯПФ алгоритма (слева) и соответствующая диаграмма ВЖД (справа, формула одновременно существующих данных 6-5-4-3-3-3-2)

Рисунок2Модифицированная (один из вариантов) ЯПФ рис.1 и соответствующая ВЖД данных (формула 6-7-5-3-3-3-2)

Из сравнения рис.1 и 2 видим, что полезная модификация ЯПФ (позволяющая выполнить программу на двух параллельных вычислителях вместо 4-х и, соответственно, существенно повысить плотность кода) требует большей нагрузки на разделяемую память (формула показывает список количества обобщённых данных, существующих между ярусами последовательно вдоль ЯПФ).

Анализ ВЖД в случае полностью последовательного характера выполнения показанного на рис.3 алгоритма (линейчатые графики распределения количества данных отрисовываются непосредственно системой SPF@home). Собственно ЯПФ здесь вырождена, имеет ширину 1 и содержит 11 исполняемых операторов (плюс уровни исходных и выходных данных).

Рисунок3Количество одновременно существующих данных для трёх (слева направо) вариантов последовательного выполнения алгоритма рис. 1 (слева от диаграммы промежуток ярусов с указанием номеров операторов в формате предыдущий / последующий; in и out входные и выходные данные соответственно, в прямоугольниках диаграммы количество данных)

Первыми (не единственными!) претендентами на линейность исполнения являются находящиеся на ярусах 1,5,6 (рис. 1) операторы, причем вследствие ортогональности по данным они могут быть выполнены в любой последовательности. При этом достигаются разные качества распределения ВЖЛД (на рис. 3 варианта, локальным оптимумом из которых является последний, характеризующийся максимумом числа данных 6 единиц). В данном случае вычислительная сложность процедуры реорганизации ЯПФ явилась незначительной - оказалось достаточным всего двух перестановок операторов (изменения в положении коснулись операторов 101,102,103. Даже в таком простом примере результат оптимизации стоит свеч, для более сложных случаев качество оптимизации будет более значительным.

Следующим этапом определения параметров устройства временного хранения данных является определение необходимых объемов (данные поочередно используют одни и те же области памяти) этого устройства (например, числа регистров общего назначения); для этого применяются известные приемы (напр., метод раскраски графов, [10]). Такие возможности не поддерживаются API модуля SPF@home и могут быть реализованы средствами встроенного языка Lua или внешними модулями.

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

Список литературы

1. J.B.Dennis, D.P.Misunas. A Preliminary Architecture for a Basic Data-Flow Processor. In Proc. Second Annual Symp. Computer Architecture, pр. 126-132, Houston, Texas, January, 1975.

2. БакановВ.М.Параллелизация обработки данных на вычислителях потоковой (Data-Flow) архитектуры. //Журнал Суперкомпьютеры, M.: 2011, 5, с.54-58.

3. Э.Таненбаум, Т.Остин. Архитектура компьютера. СПб.: Изд. Питер, 2019. 816 c.

4. БакановВ.М.Управление динамикой вычислений в процессорах потоковой архитектуры для различных типов алгоритмов. //Журнал Программная инженерия", М.: 2015, 9, c. 20-24.

5. ФедотовИ.Е.Параллельное программирование. Модели и приёмы. М.: СОЛОН-Пресс, 2018. 390 с.

6. БакановВ.М. Программный комплекс для разработки методов построения оптимального каркаса параллельных программ. //Журнал Программная инженерия", М.: 2017, том 8,2, c. 58-65.

7. M.R.Gary, D.S.Johnson. Computers and Intractability: A Guide to the Theory of NP-Completeness. W.H.Freeman and Company, San Francisco, 1979. 338 pp.

8. V.M.Bakanov. Software complex for modeling and optimization of program implementation on parallel calculation systems. Open Computer Science, Volume 8, Issue 1, Pages 228234, ISSN (Online) 2299-1093, DOI: https://doi.org/10.1515/comp-2018-0019.

9. Roberto Ierusalimschy. Programming in Lua. Third Edition. PUC-Rio, Brasil, Rio de Janeiro, 2013. 348 p.

10. Карпов В.Э. Теория компиляторов. Часть 2. Двоичная трансляция. URL: http://www.rema44.ru/resurs/study/compiler2/Compiler2.pdf (дата обращения 15.12.2020).

Подробнее..

Это непростое условное выполнение

02.01.2021 18:15:24 | Автор: admin
Некоторое время назад я рассказывал о программном комплексе для выявления скрытого параллелизма в произвольном алгоритме и технологиях его, параллелизма, рационального использовании (http://personeltest.ru/aways/habr.com/ru/post/530078/). Одним из компонентов этого комплекса является т.н. универсальный вычислитель, выполненный в соответствии с архитектурой Data-Flow (далее DF, потоковый вычислитель, описание здесь habr.com/ru/post/534722).
Подробнее..

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

05.03.2021 14:13:31 | Автор: admin

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

Естественным перед началом анализа будет указание ограничений на ширину и глубину исследований. Принимаем, что многозадачность в рассматриваемых параллельных системах осуществляется простейшим путём - перегрузкой всего блока (связки) выполняющихся операторов (одновременное выполнение операторов разных программ не предполагается) или же система работает в однозадачном режиме; в противном случае высказанное в предыдущей фразе утверждение может быть неверным. Минимизация объёма устройств временного хранения данных (описано здесь http://personeltest.ru/aways/habr.com/ru/post/534722/) проводиться не будет. На этом этапе исследований также не учитываются задержки времени на обработку операторов и пересылку данных между ними (для системы SPF@home формально эти параметры могут быть заданы в файлах с расширениями med и mvr).

В предыдущей публикации http://personeltest.ru/aways/habr.com/ru/post/540122/ была описана технология получения ПВПП на основе модели потокового (Data-Flow) вычислителя. Обычно считают, что правила выбора операторов для выполнения в такой машине подчиняются логике действия некоторых сущностей, совместно выполняющих определённые действия актёров (actors); при этом естественным образом моделируются связанные с характеристиками времени параметры обработки операторов. В общем случае при этом отдельные операторы выполняются асинхронно. В публикации показано, что описанный принцип получения ПВПП приемлем (при выполнении несложных условий) и для машин архитектуры VLIW (Very Long Instruction Word, сверхдлинное машинное слово), отличающихся требованием одновременности начала выполнения всех операторов в связке. В расчётах использовали модель ILP (Instruction-LevelParallelism, параллелизм уровня машинных команд).

В рассматриваемый программный комплекс http://personeltest.ru/aways/habr.com/ru/post/530078/ включен модуль SPF@home, позволяющей работать с гранулами параллелизма любого размера (оператор любой сложности). Основным инструментом этого модуля является метод получения ярусно-параллельной формы (ЯПФ) графа алгоритма (здесь используется информационный граф, в котором вершинами являются узлы преобразования информации, а дугами её передачи).

Реформирование ЯПФ может дать результат, идентичный полученному моделированием выполнения программы на Data-Flow -машине, но в некоторых случаях результаты расходятся. В самом деле, это во многом различные подходы. Не столь сложно представить себе рой самостоятельных, взаимодействующих программ-актёров, выполняющих действия по поиску готовых к выполнению операторов, свободных в данный момент отдельных вычислителей и назначающих обработку выбранных операторов конкретному вычислителю etc etc, но действия эти логично производятся в RunTime и именно на границе (линии фронта) между уже выполненными и ещё не выполненными операторами (метафора поиска в ширину, а не в глубину в теории графов). При этом естественным образом создаётся очень подробный план выполнения параллельной программы с точными временными метками начала и конца выполнения операторов с привязкой их к конкретным вычислителям. Такой план хорош (с точностью до математической модели, которая в конечном счёте всегда в чём-то ограничена), однако автор сомневается в степени достаточной безумности практического использования полученного т.о. ПВПП

Метод обработки ЯПФ в целом более теоретичен и от многих особенностей абстрагирован, однако он может ещё до выполнения программы (DesignTime) показать ПВПП сверху (учитывая не только параллельный фронт выполнения операторов, но и всю карту боевых действий). Следствием большего абстрагирования ЯПФ-метода является субъективность интерпретации связи моментов обработки операторов с собственно ярусом ЯПФ. В самом деле, можно рассмотреть несколько укрупнённых подходов к таким интерпретациям; см. рис. ниже слева варианты a) и б) соответственно. На этом рисунке операторы показаны красными прямоугольниками, а яруса (в целях большей выразительности сказанного) - горизонтальными линиями:

  • выполнение всех принадлежащих конкретному ярусу операторов начинается одновременно (тогда это фактически VLIW-идеологема и ПВПП изначально обладает ограниченностью, связанной с потерями времени на ожидание выполнения наиболее длительного оператора на ярусе; точное решение получается лишь при одинаковости времён выполнения всех операторов на ярусе);

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

При использовании ЯПФ в своих изысканиях Исследователь должен сам выбрать определённую модель и далее ей следовать. В рамках системы SPF@home, например, имеется возможность целевой реорганизации ЯПФ с конечной целью собрать на ярусах операторы с наиболее близкими длительностями обработки. Именно использование ЯПФ как нельзя лучше отвечает идеологеме EPIC (Explicitly Parallel Instruction Computing, явный параллелизм выполнения команд), позволяющей параллельной вычислительной системе выполнять инструкции согласно плану, заранее сформированному компилятором. Не следует игнорировать и субъективный фактор - бесспорным преимуществом ЯПФ является возможность простой и недвусмысленной визуализации собственно ПВПП.

Исходными данными для модуля SPF@home служат описания информационных графов алгоритма (программы) в стандартной DOT-форме (расширения файлов gv, могущие быть полученными импортом из модуля Data-Flow или иными путями). Допустимые (не нарушающие информационные связи в алгоритме) преобразования ЯПФ управляются программой на языке Lua, реализующей разработанные методы реструктуризации ЯПФ (дополнительная информация приведена в публикации http://personeltest.ru/aways/habr.com/ru/post/530078/). Эти методы неизбежно будут являться эвристическими вследствие невозможности прямого решения поставленных (относящихся к классу NP-полных) задач.

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

В действительности эвристический подход предполагает метод итераций по постепенному приближению к наилучшему решению сформулированной задачи (ибо точное оной решение априори неизвестно). Поэтому тут же встаёт вопрос определения качества работы этих эвристик и их вычислительной сложности с многокритериальной оптимизацией по этим (определённым количественно, конечно же!) параметрам. Учитывая сложность обсуждаемых эвристических алгоритмов данную область знания можно обоснованно отнести к наиболее сложным случаям Науки о данных (Data Science).

В качестве пациентов использовались имеющиеся в наличии информационные графы алгоритмов, в основном класса линейной алгебры (как одни из наиболее часто встречающиеся в современных задачах обработки данных). По понятным причинам исследования проводились на данных небольшого объёма в предположении сохранения корректности полученных результатов при обработке данных большего размера. Описанные в данной публикации исследования имеют цель продемонстрировать возможности имеющегося инструментария при решении поставленных задач. При желании возможно исследовать произвольный алгоритм, описав и отладив его в модуле Data-Flow (http://personeltest.ru/aways/habr.com/ru/post/535926/) с последующим импортом в форме информационного графа в модуль SPF@home.

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

Ниже приведены результаты моделирования трёх наиболее часто встречающихся типов задач (в реальном случае обычно требуется выполнение нескольких из них одновременно):

1.Расписание выполнения программ на минимальном числе параллельных вычислителей при сохранении высоты ЯПФ

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

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

Эмпирический метод 1-01_bulldozer имеет целью получение наиболее равномерного распределения операторов по ярусам ЯПФ без возрастания её высоты (сохранение времени выполнения программы); операторы переносятся только вниз (первоначально ЯПФ строится в верхнем варианте). Для этого метод старается перенести операторы с ярусов шириной выше среднего на яруса с наименьшей шириной. На каждом ярусе операторы перебираются в порядке очередности слева направо.

Метод 1-02_bulldozer является модификацией предыдущего с адаптацией. Для оператора с максимумом вариативности (назовём так диапазон возможного размещения операторов по ярусам ЯПФ без изменения информационных связей в графе алгоритма) в пределах яруса вычисляются верхний и нижний пределы его возможного расположения по ярусам.

В результирующей табл.1 рассматриваются: mnk_N программа аппроксимации методом наименьших квадратов N точек прямой, mnk_2_N то же, но квадратичной функцией, korr_N вычисление коэффициента парной корреляции по N точкам, slau_N решение системы линейных алгебраических уравнений порядка N прямым (не итерационным) методом Гаусса, m_matr_N - программа умножения квадратных матриц порядка N традиционным способом, m_matr_vec_N умножение квадратной матрицы на вектор, squa_equ_2 решение полного квадратного уравнения в вещественных числах, squa_equ_2.pred то же, но с возможностью получения вещественных и мнимых корней при использовании метода предикатов для реализации условного выполнения операторов, e17_o11_t6, e313_o206_t32, e2367_o1397_t137, e451_o271_t30, e916_o624_t89, e17039_o9853_t199 сгенерированные специальной программой по заданным параметрам информационного графа. Вычислительную трудность преобразования будем характеризовать числом перемещений операторов с яруса на ярус. Неравномерность распределения числа операторов по ярусам ЯПФ характеризуется коэффициентом неравномерности (отношение числа операторов на наиболее и наименее нагруженных ярусов соответственно).

Для удобства анализа в таблице цветом выделены ячейки, соответствующие случаю достижению снижения ширины без возрастания высоты ЯПФ, а жирным начертанием цифр величины для сравнения.

Как видно из табл.1, во многих случаях удается значительно (до 1,5-2 раз) снизить ширину ЯПФ, но почти никогда до минимальной величины (средне-арифметическое значение ширин ярусов). В целом эвристика 1-02_bulldozer несколько более эффективна, но проигрывает по вычислительной сложности. В большинстве случаев увеличение размера обрабатываемых данных повышает эффективность балансировки (очевидно, это связано с повышением числа степеней свободы ЯПФ).

Интересно, что для некоторых алгоритмов (напр., slau_N) ни один из предложенных методов не дал результата. Сложность балансировки ЯПФ связана с естественным стремлением разработчиков алгоритмов создавать максимально плотные записи последовательности действий.

2.Расписания выполнения программ на фиксированном числе параллельных вычислителей при возможности увеличения высоты ЯПФ

Практический интерес представляют методы с увеличением высоты ЯПФ (см. табл.2, в которой дано сравнение двух методов с метафорическими названиями Dichotomy и WidthByWidth); при этом приходится смириться с увеличение времени выполнения программы. В ходе вычислительных экспериментов задавалась конечная ширина преобразованной ЯПФ (отдельные столбцы в правой части табл.2). Количественные параметры преобразований выдавались в форме частного, где числитель и знаменатель показывают число перемещений (первая строка), высоту ЯПФ (вторая) и коэффициент ковариации CV (третья строка) для каждого исследованного графа. Характеризующий неравномерность распределения ширин ярусов коэффициент ковариации рассчитывался как CV=/W, где - среднеквадратичное отклонение числа операторов по всем ярусам ЯПФ, W - среднеарифметическое числа операторов по ярусам.

Эвристика Dichotomy предполагает для разгрузки излишне широких ярусов перенос половины операторов на вновь созданный ярус под текущим, эвристика WidthByWidth реализует постепенный перенос операторов на вновь создаваемые яруса ЯПФ. Из табл.2 видно, что метод WidthByWidth в большинстве случаев приводит к лучшим результатам, нежели Dichotomy (например, высота преобразованной ЯПФ существенно меньше что соответствует снижению времени выполнения параллельной программы притом за меньшее число перемещений операторов).

3.Расписание выполнения программ на фиксированном числе гетерогенных параллельных вычислителей

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

Модуль SPF@home поддерживает эту возможность путём сопоставления информации из двух файлов для операторов и вычислителей (*.ops и *.cls соответственно). Имеется возможность задавать совпадение по множеству свободно назначаемых признаков для любого диапазона операторов/вычислителей. Условием выполнимости данного оператора на заданном вычислителе является minVal_1Val_1maxVal_1 для одинакового параметра (Val_1, minVal_1, maxVal_1 числовые значения данного параметра для оператора и вычислителя соответственно).

Разработка расписания для выполнения программы на гетерогенном поле параллельных вычислителей является более сложной процедурой относительно вышеописанных и здесь упор делается на программирование на Lua (API-функции системы SPF@home обеспечивают минимально необходимую поддержку). Т.к. на одном ярусе ЯПФ могут находиться операторы, требующие для выполнения различных вычислителей, полезным может служить концепция расцепления ярусов ЯПФ на семейства подъярусов, каждое из которых соответствует блоку вычислителей c определёнными возможностями (т.к. все данного операторы яруса обладают ГКВ-свойством, последовательность выполнения их в пределах яруса/подъяруса в первом приближении произвольна). На схеме ниже слева показано расщепление операторов на одном из ярусов ЯПФ в случае наличия 6 параллельных вычислителей 3-х типов.

Пример плана выполнения программы на поле из 3-х типов параллельно работающих вычислителей (c количествоv 5,3,4 штук соответственно и номерами 1-5, 6-8, 9-12 по типам, всего 12 штук) приведён в табл.3. При расчёте в качестве исходной использовался конкретный алгоритм, характеризующийся ЯПФ с числом операторов 206 и дуг 323, ярусов 32 (после расчета подъярусов получилось 48). Первый столбец таблицы показывает (разделитель символ прямого слеша) номер яруса/подъяруса; в ячейках таблицы приведены номера операторов, сумма их числа по подъярусам равно числу операторов на соответствующем ярусе ЯПФ.

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

В самом деле, в рассматриваемом случае общее время T решения задачи определяется суммой по всем ярусам максимальных значений времён выполнения операторов на подъярусах данного яруса, т.к. группы операторов на подъярусах выполняются последовательно (первая сумма берётся по j, вторая по i, максимум по kj):

T=(maxtik),

где j - число ярусов ЯПФ, i - число подъярусов на данном ярусе, kj - типы вычислителей на j-том ярусе, tik - время выполнения оператора типа i на вычислителе типа k. Если ставится задача достижения максимальной производительности, вполне возможно определить число вычислителей конкретного типа, минимизирующее T (напр., для показанного табл.3 случая количество вычислителей типа II полезно увеличить в пику вычислителяv типа I).

Задача минимизации общего времени решения T усложняется в случае возможности выполнения каждого оператора на нескольких вычислителях вследствие неоднозначности tik в вышеприведённом выражении; здесь необходима дополнительная балансировка по подъярусам.

Описание параметров операторов располагается в файлах с расширением ops, параметров вычислителей cls; соответствующая API-функция (обёрнутая Lua-вызовом) возвращает значение, разрешающее или запрещающее выполнение данного оператора на заданном вычислителе. Описанные файлы являются текстовыми (формат данных определён в документации), что даёт возможность разработки внешних программ для генерации требуемого плана эксперимента с использованием модуля SPF@home в режиме командной строки.


В порядке обсуждения небезынтересно будет рассмотреть вариант ЯПФ в нижней форме (при этом все операторы перемещены максимально в сторону окончания выполнения программы). Такая ЯПФ может быть получена из верхней перемещениями операторов по ярусам как можно ниже или проще построением ЯПФ в направлении от конца программы к её началу. Ниже проиллюстрировано сравнение распределения ширин ЯПФ в верхней и нижней формах (изображения в строке слева и справа соответственно в 4-х рядах) для алгоритма умножения матриц традиционным способом при порядках матриц N=3,5,7,10. Здесь H, W и W высота, ширина и среднеарифметическая ширина ЯПФ (последняя показана на рисунках пунктиром; символ прямого слеша разделяет параметры для верхней и нижней ЯПФ (фиолетовый цвет ярус максимальной ширины, красный минимальной).

Соответствующие иллюстрации для процедуры решения систем линейных алгебраических уравнения порядков N=3,5,7,10 безытерационным методом Гаусса представлены ниже.

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

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


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

Выше использованные количественные характеристики неравномерности не дают информации о форме кривой, обладающей этой неравномерностью. В качестве дополнительной оценки неравномерности распределения операторов по ярусам ЯПФ может быть признан известный графо-аналитического метод определения дифференциации доходов населения, заключающегося в расчёте численных параметров расслоения (кривая Лоренца и коэффициент Джини), несмотря на зеркально-противоположную форму анализируемых кривых. Удобство сравнения ЯПФ различных алгоритмов достигается нормированием обоих осей графиков на общую величину (единицу или 100%).

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

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

Итак, программная система SPF@home выполняет практическую задачу по составлению расписаний выполнения заданных алгоритмов (программ) на заданном поле параллельных вычислителей и дает возможность проводить различного типа исследования свойств алгоритмов (в данном случае оценивать вычислительную сложность методов составления расписаний). Система нацелена в основном на анализ программ, созданных с использованием языков программирования высокого уровня без явного указания распараллеливания и в системах c концепцией ILP (Instruction-LevelParallelism, параллелизм на уровне команд), хотя возможности модуля SPF@home позволяют использовать в качестве неделимых блоков последовательности команд любого размера.

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

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


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

Подробнее..

Сколько стоит расписание

10.04.2021 22:12:11 | Автор: admin

Основные данные вычислительных экспериментов по реорганизации ярусно-параллельной формы (ЯПФ) информационных графов алгоритмов (ТГА) приведены в предыдущей публикации (http://personeltest.ru/aways/habr.com/ru/post/545498/). Цель текущей публикации показать окончательные результаты исследований разработки расписаний выполнения параллельных программ в показателях вычислительной трудоёмкости собственно преобразования и качества полученных расписаний. Данная работа является итогом вполне определённого цикла исследований в рассматриваемой области.

Как и было сказано ранее, вычислительную трудоёмкости (ВТ) в данном случае будем вычислять в единицах перемещения операторов с яруса на ярус в процессе реорганизации ЯПФ. Этот подход близок классической методике определения ВТ операций упорядочивания (сортировки) числовых массивов, недостатком является неучёт трудоёмкости процедур определения элементов для перестановки.

Т.к. в принятой модели ЯПФ фактически определяет порядок выполнения операторов параллельной программы (операторы выполняются группами по ярусам поочерёдно), в целях сокращения будем иногда использовать саму аббревиатуру ЯПФ в качестве синонима понятия плана (расписания) выполнения параллельной программы. По понятным причинам исследования проводились на данных относительно небольшого объёма в предположении сохранения корректности полученных результатов при обработке данных большего размера. Описанные в данной публикации исследования имеют цель продемонстрировать возможности имеющегося инструментария при решении поставленных задач. При желании возможно исследовать произвольный алгоритм, описав и отладив его в модуле Data-Flow (http://personeltest.ru/aways/habr.com/ru/post/535926/) с последующим импортом в формате информационного графа в модуль SPF@home для дальнейшей обработки.

Основной целью преобразований ЯПФ продолжаем считать получение максимальной плотности кода (фактически максимальная загрузка имеющихся в наличии отдельных вычислителей параллельной вычислительной системы). Кстати, именно с этими понятиями связано известное зло-ироничное высказывание об излишнем количестве NOP-инструкций в связках сверхдлинного машинного слова в вычислителях VLIW-архитектуры (даже при наличии участков полностью последовательного кода лакуны в сверхдлинном слове формально должны быть заполнены некоей операцией-пустышкой)

Как и ранее, будем ссылаться на разработанные эвристические методы (иногда сокращённо именуемые просто эвристиками), реализованные на языке Lua и управляющие преобразованиями ЯПФ. Также не учитываем возможности одновременного выполнения команд нескольких процессов на параллельном поле вычислителей (хотя теоретически это может привести к повышению плотности кода).

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

Полученные результаты предназначаются для улучшения качества разработки расписаний выполнения параллельных программ в распараллеливающих компиляторах будущих поколений. При этом внутренняя реализация данных конечно, совсем не обязана предусматривать явного построения ЯПФ в виде двумерного массива, как для большей выпуклости показано на рис.2 в публикации http://personeltest.ru/aways/habr.com/ru/post/530078/ и выдаётся программным модулем SPF@home (http://vbakanov.ru/spf@home/content/install_spf.exe). Она может быть любой удобной для компьютерной реализации например, в наивном случае устанавливающей однозначное соответствие между формой ИГА в виде множества направленных дуг {k,l} (матрица смежности) и двоек номеров вершин ik,jk и il,jl, где i,j номера строк и столбцов в ЯПФ (процедуру преобразования ИГА в начальную ЯПФ провести всё равно придётся, ибо в данном случае именно она выявляет параллелизм в заданном ИГА алгоритме; только после этого можно начинать любые преобразования ЯПФ).

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

Для каждой из группы рассматриваемых задач (преобразования с сохранением высоты исходной ЯПФ или при возможности увеличения высоты оной) рассмотрим по две методики (эвристики, ибо так согласились именовать разработки) для перового случая это 1-01_bulldozer vs 1-02_bulldozer, для второго - WidthByWidtn vs Dichotomy. Мне стыдно повторять это, но высота ЯПФ определяет время выполнения программы

1. Получение расписания параллельного выполнения программ при сохранении высоты ЯПФ

Сохранение высоты исходной ЯПФ равносильно выполнению алгоритма (программы) за минимально возможное время. Наиболее равномерная нагрузка на все вычислители параллельной системы будет при одинаковой ширине всех ярусов ЯПФ (среднеарифметическое значение). В ЯПФ реальных алгоритмов и размерности обрабатываемых данных среднеарифметическое значение является довольно большим (практически всегда много большим числа отдельных вычислителей в параллельной системе). Т.о. рассматриваемый случай не часто встречается в практике, но всё же должен быть разобран.

Для сравнения выберем часто анализируемые ранее алгоритмы и два эвристических метода целенаправленного преобразования их ЯПФ эвристики 1-01_bulldozer и 1-02_bulldozer.

Результаты применения этих эвристик приведены на рис. 1-3; обозначения на этих рисунках (по осям абсцисс отложены показатели размерности обрабатываемых данных):

  • графики a), b) и с) ширина ЯПФ, коэффициент вариации (CV ширин ярусов ЯПФ), число перемещений (характеристика вычислительной трудоёмкости) операторов соответственно;

  • сплошные (красная), пунктирные (синяя) и штрих-пунктирные (зелёная) линии исходные данные, результат применения эвристик 1-01_bulldozer и 1-02_bulldozer cответственно.

Рисунок 1. Параметры плана параллельного выполнения при сохранении высоты ЯПФ для алгоритма умножения квадратных матриц 2,3,5,7,10-го порядков (соответствует нумерации по осям абсцисс) классическим методомРисунок 1. Параметры плана параллельного выполнения при сохранении высоты ЯПФ для алгоритма умножения квадратных матриц 2,3,5,7,10-го порядков (соответствует нумерации по осям абсцисс) классическим методомРисунок 2. Параметры плана параллельного выполнения при сохранении высоты ЯПФ для алгоритма вычисления коэффициента парной корреляции по 5,10,15,20-ти точкам (соответствует нумерации по осям абсцисс)Рисунок 2. Параметры плана параллельного выполнения при сохранении высоты ЯПФ для алгоритма вычисления коэффициента парной корреляции по 5,10,15,20-ти точкам (соответствует нумерации по осям абсцисс)Рисунок 3. Параметры плана параллельного выполнения при сохранении высоты ЯПФ для алгоритма решения системы линейных алгебраических уравнений (СЛАУ) для 2,3,4,5,7,10-того порядка (соответствует нумерации по осям абсцисс) прямым (неитерационным) методом ГауссаРисунок 3. Параметры плана параллельного выполнения при сохранении высоты ЯПФ для алгоритма решения системы линейных алгебраических уравнений (СЛАУ) для 2,3,4,5,7,10-того порядка (соответствует нумерации по осям абсцисс) прямым (неитерационным) методом Гаусса

Данные рис. 1-3 показывают, что во многих случаях удаётся приблизиться к указанной цели. Напр., рис. 1a) иллюстрирует снижение ширины ЯПФ до 1,7 раз (метод 1-01_bulldozer) и до 3 раз (метод 1-02_bulldozer) при умножении матриц 10-го порядка.

Коэффициент вариации ширин ярусов ЯПФ (рис. 1b) приближается к 0,3 (граница однородности набора данных) при использовании эмпирики 1-02_bulldozer и, что немаловажно, достаточно стабилен на всём диапазоне размерности данных.

Трудоёмкость достижения результата (рис. 1c) при использовании метода 1-02_bulldozer значительно ниже (до 3,7 раз при порядке матриц 10) метода 1-01_bulldozer.

Важно, что эффективность метода возрастает с ростом размерности обрабатываемых данных.

Не менее эффективным показал себя метод 1-02_bulldozer на алгоритме вычисления коэффициента парной корреляции (рис. 2).

Попытка реорганизации ЯПФ алгоритма решения системы линейных алгебраических уравнений (СЛАУ) порядка до 10 обоими методами (рис. 3) оказалась малополезной. Ширину ЯПФ снизить не удалось вообще (рис. 3a), снижение CV очень мало (рис. 3b), однако метод 1-02_bulldozer немного выигрывает в трудоёмкости (рис. 3c).

Для большей полноты выводов объём исследований должен быть расширен, однако уже сейчас ясно, далеко не каждый метод реорганизации ЯПФ одинаково эффективен для преобразования (в смысле построения рационального расписания параллельного выполнения) различных алгоритмов. Т.к. принятый в данной работе общий подход предполагает итеративное приближение к наилучшему решению поставленной задачи, надлежит продолжить разработку новых эвристик (с учётом достигнутого).

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

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

Ниже рассматривается распространенный случай выполнения программы на заданном гомогенном поле из W параллельных вычислителей (от W=W0 до W=1, где W0 ширина ЯПФ, а нижняя граница соответствует полностью последовательному выполнению). Сравниваем два метода реорганизации ЯПФ Dichotomy и WidthByWidtn:

  • Dichotomy. Цель получить вариант ЯПФ с c шириной не более заданного W c увеличением высоты методом перенесения операторов с яруса на вновь создаваемый ярус ниже данного. Если ширина яруса выше W, ровно половина операторов с него переносится на вновь создаваемый снизу ярус и так далее, пока ширина станет не выше заданной W. Метод работает очень быстро, но грубо (высота ЯПФ получается явно излишней и неравномерность ширин ярусов высока).

  • WidthByWidtn. Подлежат переносу только операторы яруса с числом операторов выше заданного N>W путём создания под таким ярусом число ярусов М, равное:

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

На рис. 4,5 показаны результаты выполнения указанных эвристик в применении к двум распространенным алгоритмам линейной алгебры - умножение квадратных матриц классическим методом и решение системы линейных алгебраических уравнений прямым (неитерационным) методом Гаусса; красные и синие линии на этих и последующих рисунках соответствуют эвристикам WidthByWidtn и Dichotomy соответственно. Не забываем, что ширина реформированной ЯПФ здесь соответствует числу команд в связке сверхдлинного машинного слова.

Рисунок 4. Возрастание высоты (ординаты) при ограничении ширины ЯПФ (абсциссы), разы; алгоритм умножения квадратных матриц классическим методом 5 и 10-го порядков рис. a) и b) соответственноРисунок 4. Возрастание высоты (ординаты) при ограничении ширины ЯПФ (абсциссы), разы; алгоритм умножения квадратных матриц классическим методом 5 и 10-го порядков рис. a) и b) соответственноРисунок 5. Возрастание высоты (ординаты) при ограничении ширины ЯПФ (абсциссы), разы; алгоритм решения системы линейных алгебраических уравнений прямым (неитерационным) методом Гаусса 5 и 10-го порядков рис. a) и b) соответственноРисунок 5. Возрастание высоты (ординаты) при ограничении ширины ЯПФ (абсциссы), разы; алгоритм решения системы линейных алгебраических уравнений прямым (неитерационным) методом Гаусса 5 и 10-го порядков рис. a) и b) соответственно

Как видно из рис. 4 и 5, оба метода на указанных алгоритмах приводят к близким результатам (из соображений представления ЯПФ плоской таблицей и инвариантности общего числа операторов в алгоритме это, конечно, гипербола!). При большей высоте ЯПФ увеличивается время жизни данных, но само их количество в каждый момент времени снижается.

Однако при всех равно-входящих соответствующие методу WidthByWidtn кривые расположены ниже, нежели по методу Dichotomy; это соответствует несколько большему быстродействию. Полученные методом WidthByWidtn результаты практически совпадают с идеалом высоты ЯПФ, равным Nсумм./Wсредн. , где Nсумм. общее число операторов, Wсредн. среднеарифметическое числа операторов по ярусам ЯПФ при заданной ширине ея.

Рисунок 6. Число перемещений операторов между ярусами - a) и коэффициент вариации CV - b) при снижении ширины ЯПФ для алгоритма умножения квадратных матриц 10-го порядка классическим методом (ось абсцисс ширина ЯПФ после реформирования)Рисунок 6. Число перемещений операторов между ярусами - a) и коэффициент вариации CV - b) при снижении ширины ЯПФ для алгоритма умножения квадратных матриц 10-го порядка классическим методом (ось абсцисс ширина ЯПФ после реформирования)Рисунок 7. Число перемещений операторов между ярусами - a) и коэффициент вариации CV - b) при снижении ширины ЯПФ для алгоритма решения системы линейных алгебраических уравнений 10-го порядка прямым (неитерационным) методом Гаусса (ось абсцисс ширина ЯПФ после реформирования)Рисунок 7. Число перемещений операторов между ярусами - a) и коэффициент вариации CV - b) при снижении ширины ЯПФ для алгоритма решения системы линейных алгебраических уравнений 10-го порядка прямым (неитерационным) методом Гаусса (ось абсцисс ширина ЯПФ после реформирования)

Анализ результатов, приведённый на рис. 6 и 7, более интересен (хотя бы потому, что имеет чисто практический интерес вычислительную трудоёмкость преобразования ЯПФ). Как видно из рис. 6 и 7, для рассмотренных случаев метод WidthByWidtn имеет меньшую (приблизительно в 3-4 раза) вычислительную трудоёмкость (в единицах числа перестановок операторов с яруса на ярус) относительно метода Dichotomy (хотя на первый взгляд ожидается обратное). Правда, при этом метод (эвристика) WidthByWidtn обладает более сложной внутренней логикой по сравнению с Dichotomy (в последнем случае она примитивна).

Т.о. проведено сравнение методов реорганизации (преобразования) ЯПФ конкретных алгоритмов с целью их параллельного выполнения на заданном числе вычислителей. Сравнение проведено по критериям вычислительной трудоёмкости преобразований и неравномерности загрузки параллельной вычислительной системы.

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

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


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

Подробнее..

Tarantool и кодогенерация на Lua

21.05.2021 14:10:59 | Автор: admin

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

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

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

Немного про Tarantool и LuaJIT

Tarantool это платформа для in-memory вычислений флакон, объединяющий сервер приложений и базу данных. Сам Tarantool написан на языке С, но пользователь может работать с ним с помощью языка Lua. А если совсем точно, то одной из его реализаций LuaJIT не с просто интерпретатором, а ещё и с поддержкой и JIT-компиляции. И часто при работе возникают задачи по трансформации сущностей при записи в базу или после извлечения из неё, а также их валидации на соответствие схеме, заданной пользователем. Типичный подход для решения этой и схожих задач написание функций для преобразования данных. Эти функции не привязаны к конкретной схеме и зачастую представляют из себя набор замыканий. Однако не стоит забывать, что мы работаем с LuaJIT языком, который способен компилировать и достаточно быстро выполнять "горячие" участки кода.

Но, к сожалению, не всё подряд может быть скомпилировано, у платформы есть ряд ограничений это так называемые NYI (Not yet implemented) функции. Кроме того, работа с данными активно использует дополнительные структуры массивы и хэш-мапы. В Lua они представлены общим типом данных "table" (таблица). Перед нами две основные проблемы использование части функций серьезно влияет на производительность, а избыточное использование вспомогательных структур приводит к излишней нагрузке на GC, с которым у и Lua 5.1, и у LuaJIT проблемы. Поэтому задача написание кода, который сможет быть скомпилирован LuaJIT, и будет приводить к минимально возможному количеству аллокаций.

К реальным задачам

Данный подход мы будем разбирать на реальном примере, на примере модуля CRUD. Задача данного модуля это упрощение работы с шардированными данными. То есть данные распределены между несколькими стораджами (инстансами Tarantool, хранящими данные), и мы, обращаясь к ним через роутер (по сути, клиент), не хотим задумываться, на каком именно из стораджей лежат интересующие нас данные, а просто указываем условие поиска, и модуль возвращает нам уже готовые данные. Немного про хранение. Tarantool хранит данные в спейсах (spaces) аналог таблиц в реляционных БД. Единица хранения кортеж (tuple) массив заданных нами значений. При этом нам привычно работать именно с Lua-таблицами обращаться к полю по названию, а не по номеру в кортеже. В качестве аналогии можно привести формат JSON. Обычно именно в таком формате поступают данные из внешних систем которые затем парсятся в Lua-таблицы, "сплющиваются" и сохраняются в базу. Соответственно типичными для тарантула операциями являются так называемый "флаттенинг" (flatten) и "анфлаттенинг" (unflatten) получение из луа-таблицы плоского тапла и наоборот. И в частном случае пользователь может написать руками все эти операции.

-- Создаем space - аналог таблицы в реляционных БДbox.schema.space.create('data')-- Создаем первичный ключbox.space.data:create_index('primary_key')-- Попробуем вставить в наш space следующий объектobject = { id = 1, key = "key", value = "value" }-- Выполняем "сплющивание" объекта - flattentuple = {object["id"], object["key"], object["value"]}-- Единицей хранение в Tarantool является tuple - кортеж из значенийbox.space.data:insert(tuple)-- После сохранения мы можем достать наш объект по первичному ключуtuple = box.space.data:get({1})-- Преобразуем объект в исходное состояние - unflattenobject = {   id = tuple[1],   key = tuple[2],   value = tuple[3],}

Здесь мы явно захардкодили порядок полей в спейсе. Однако в общем случае схема задается извне некоторым форматом, и мы пишем простенькие функции, которые занимаются трансформацией объекта в соответствии с этим форматом. Модуль CRUD, как и сам Tarantool, имеет функцию replace она точно также вставляет кортеж в базу. Для упрощения жизни пользователям была также добавлена функция replace_object которая принимает объект, преобразует в плоский вид в соответствии с форматом спейса, а затем уже сохраняет.

Ближе к коду и измерению производительности

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

-- test_data.lua-- Формат - 8 строковых полей + bucket_id-- (специальное поле, необходимое при шардировании данных).local format = {    {name = 'field1', type = 'string', is_nullable = false},    {name = 'field2', type = 'string', is_nullable = false},    {name = 'field3', type = 'string', is_nullable = false},    {name = 'field4', type = 'string', is_nullable = false},    {name = 'field5', type = 'string', is_nullable = false},    {name = 'field6', type = 'string', is_nullable = false},    {name = 'field7', type = 'string', is_nullable = false},    {name = 'field8', type = 'string', is_nullable = false},    {name = 'bucket_id', type = 'unsigned', is_nullable = false},}-- Объект необходимого форматаlocal data = {    field1 = 'string1',    field2 = 'string2',    field3 = 'string3',    field4 = 'string4',    field5 = 'string5',    field6 = 'string6',    field7 = 'string7',    field8 = 'string8',    bucket_id = nil,}return {    format = format,    data = data,}

Функция, замеряющая время выполнения нашего кода.

-- bench.lua-- Замеряем, сколько времени займет 1 миллион итерацийlocal clock = require('clock')local count = 1e6local function run(f, ...)    local start = clock.time()    for _ = 1, count do        f(...)    end    return clock.time() - startendreturn {    run = run,}

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

#!/usr/bin/env tarantool-- init.lualocal bench = require('bench')local test_data = require('test_data')-- Это наш первый тестlocal naive = require('naive')local res = bench.run(naive.flatten, test_data.data, test_data.format, 1)print(string.format('Naive result: %0.3f s', res))-- После добавления нужного модуля, мы раскомментируем каждый фрагмент.-- local code_gen_v1 = require('code_gen_v1')-- local res = bench.run(code_gen_v1.flatten, test_data.data, test_data.format, 1)-- print(string.format('code_gen_v1 result: %0.3f s', res))-- local code_gen_v2 = require('code_gen_v2')-- local res = bench.run(code_gen_v2.flatten, test_data.data, test_data.format, 1)-- print(string.format('code_gen_v2 result: %0.3f s', res))

Давайте рассмотрим, как раньше выполнялось данное преобразование самый наивный подход:

-- naive.lualocal system_fields = { bucket_id = true }local function flatten(object, space_format, bucket_id)    if object == nil then return nil end    local tuple = {}    local fieldnames = {}    for fieldno, field_format in ipairs(space_format) do        local fieldname = field_format.name        local value = object[fieldname]        if not system_fields[fieldname] then            if not field_format.is_nullable and value == nil then                return nil, string.format("Field %q isn't nullable", fieldname)            end        end        if bucket_id ~= nil and fieldname == 'bucket_id' then            value = bucket_id        end        tuple[fieldno] = value        fieldnames[fieldname] = true    end    for fieldname in pairs(object) do        if not fieldnames[fieldname] then            return nil, string.format("Unknown field %q is specified", fieldname)        end    end    return tupleendreturn {    flatten = flatten,}

Пример слегка упрощен. Но стоит заметить несколько вещей:

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

  • Обходим весь объект в соответствии с форматом. При этом формат нам известен и меняется достаточно редко. Это одна из предпосылок для использования кодогенерации.

Запускаем:

  tarantool init.lua Naive result: 1.109 s

На моём ноутбуке этот тест выполнился за 1 секунду.

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

-- code_gen_v1.lua-- Небольшой хелпер для работы со строкамиlocal function append(lines, s, ...)    table.insert(lines, string.format(s, ...))end-- Кэш, где ключ - таблица с "форматом", а значение - функция флаттенинга.-- Для простоты считаем, что формат не меняется, не занимаемся инвалидацией кэша.local cache = {}local function flatten(object, space_format, bucket_id)    -- В случае если функция уже сгенерирована,    -- берем её из кэша. Иначе приступаем к кодогенерации.    local fun = cache[space_format]    if fun ~= nil then        return fun(object, bucket_id)    end    -- Будем "готовить" наш код построчно и сохранять в массив lines.    local lines = {}    append(lines, 'local object, bucket_id = ...')    append(lines, 'local result = {}')    for i, field in ipairs(space_format) do        if field.name ~= 'bucket_id' then            append(lines, 'result[%d] = object[%q]', i, field.name)        else            append(lines, 'result[%d] = bucket_id', i)        end    end    append(lines, 'return result')    -- Конкатенируем элементы массива, чтобы получить полный текст функции.    local code = table.concat(lines, '\n')        -- Раскомментриуйте, чтобы увидеть результат    -- print(code)        -- С помощью функции "load" преобразуем текст функции в саму функцию    fun = assert(load(code))    cache[space_format] = fun    return fun(object, bucket_id)endreturn {    flatten = flatten,}

В результате выполнения получим следующий код:

local object, bucket_id = ...local result = {}result[1] = object["field1"]result[2] = object["field2"]result[3] = object["field3"]result[4] = object["field4"]result[5] = object["field5"]result[6] = object["field6"]result[7] = object["field7"]result[8] = object["field8"]result[9] = bucket_idreturn result

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

Что еще мы можем улучшить? В самом начале сгенерированного кода мы создаем таблицу result и постепенно её заполняем. Такой подход приводит к неоднократным реаллокациям, что плохо и довольно бессмысленно ведь размер таблицы известен заранее. Давайте учтём это и поменяем строку append(lines, 'local result = {}') на append(lines, 'local result = {%s}', string.rep('box.NULL,', #space_format)). Так мы сразу создадим массив нужного нам размера local result = {box.NULL, ..., box.NULL}. Запуск бенчмарка выдает 0.2 секунды.

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

-- code_gen_v2.lualocal function append(lines, s, ...)    table.insert(lines, string.format(s, ...))endlocal cache = setmetatable({}, {__mode = 'k'})local function flatten(object, space_format, bucket_id)    local fun = cache[space_format]    if fun ~= nil then        return fun(object, bucket_id)    end    local lines = {}    append(lines, 'local object, bucket_id = ...')    append(lines, 'for k in pairs(object) do')    append(lines, '    if fieldmap[k] == nil then')    append(lines, '        return nil, format(\'Unknown field %%q is specified\', k)')    append(lines, '    end')    append(lines, 'end')    local len = #space_format    append(lines, 'local result = {%s}', string.rep('NULL,', len))    local fieldmap = {}    for i, field in ipairs(space_format) do        fieldmap[field.name] = true        if field.name ~= 'bucket_id' then            if field.is_nullable ~= true then                append(lines, 'if object[%q] == nil then', field.name)                append(lines, '    return nil, \'Field %q isn\\\'t nullable\'', field.name)                append(lines, 'end')            end            append(lines, 'result[%d] = object[%q]', i, field.name)        else            append(lines, 'if bucket_id ~= nil then')            append(lines, '    result[%d] = bucket_id', i, field.name)            append(lines, 'else')            append(lines, '    result[%d] = object[%q]', i, field.name)            append(lines, 'end')        end    end    append(lines, 'return result')    local code = table.concat(lines, '\n')    local env = {        pairs = pairs,        format = string.format,        fieldmap = fieldmap,        NULL = box.NULL,    }    fun = assert(load(code, '@flatten', 't', env))    cache[space_format] = fun    return fun(object, bucket_id)endreturn {    flatten = flatten,}

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

Бенчмарк показал 0.3 секунды.

  tarantool init.lua Naive result: 1.109 scode_gen_v1 result: 0.210 scode_gen_v2 result: 0.299 s

Также стоит отметить, что у функции load появились дополнительные аргументы, а именно chunkname название нашей функции (может быть полезным при отладке), mode t мы создаем функцию на основе обычного текста, а не байткода и env окружение, доступное внутри нашей функции. На последний аргумент стоит обратить особое внимание. Кроме возможности создавать удобные песочницы для выполнения пользовательского кода (обычно не давать доступа к "опасным" функциям), данная опция позволяет передавать в глобальное окружение нужные нам функции и аргументы. В нашем случае это pairs, format, fieldmap и NULL. Отдельно стоит отметить, что load это функция из Lua 5.2 расширение LuaJIT. Тот, кто работает с чистым Lua 5.1, может использовать функции loadstring для создания функции и setfenv для установки окружения у этой функции.

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

Небольшой пример:

local function is_string(value)    return type(value) == 'string'end-- Функции is_string нет в языке Lua,-- но с помощью окружения мы можем добавить в нужные нам функции-- и убрать лишние.local code = [[local value = ...local result = {NULL}if not is_string(value) then    error("value is not a string")endresult[1] = valuereturn result]]local fun = load(code, '@test', 't', {    error = error,    -- Функция is_string будет доступна внутри    -- загружаемого нами кода    is_string = is_string,    NULL = box.NULL,})

Как в будущем всё не сломать

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

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

Более качественная оценка результатов

В статье я привел не так много результатов тестирования просто сравнил несколько цифр: время выполнения программы до и после. При этом я говорил, что мы уменьшили количество аллокаций и написали код, пригодный для компиляции LuaJIT'ом. Но как это можно проверить, как в этом можно убедиться?

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

Во-первых, это memory profiler, который появился в версии 2.7.1 инструмент, который покажет в каких именно местах и в каких количествах выделяется/реаллоцируется память. Как по мне, вывод довольно удобен а в будущем станет ещё удобнее. Воспользовавшись этим инструментом, можно показать количественную разницу между кодом до и кодом после. В нашем случае мы получили бы вывод в формате @<filename>:<function_line>, line <line where event was detected>: <number of events> <allocated> <freed>. Для наглядности напротив некоторых строк я помещу фрагменты кода, которые находятся на этих строках:

Для кода "до" (naive.lua):

ALLOCATIONSINTERNAL: 39999533600003800@../naive.lua:4, line 26: 10000383840039360           // fieldnames[fieldname] = true@../naive.lua:4, line 7: 1000000640000000           // local tuple = {}@../naive.lua:4, line 9: 1000000640000000           // local fieldnames = {}@../naive.lua:4, line 25: 163840@../naive.lua:4, line 0: 46720REALLOCATIONSINTERNAL: 199998211200056064000288Overrides:@../naive.lua:4, line 0@../naive.lua:4, line 25INTERNAL@../naive.lua:4, line 25: 100002213600123272000704Overrides:@../naive.lua:4, line 25INTERNALDEALLOCATIONSINTERNAL: 59535720784628243Overrides:@../naive.lua:4, line 0@../naive.lua:4, line 25@../naive.lua:4, line 26@../naive.lua:4, line 7@../naive.lua:4, line 9INTERNAL@../naive.lua:4, line 26: 10000220192001584Overrides:@../naive.lua:4, line 26INTERNAL

Для кода "после" (code_gen_v2.lua):

ALLOCATIONS@flatten:0, line 7: 10000001440000000 // local result = {NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,}@../code_gen_v3.lua:7, line 55: 1480REALLOCATIONSINTERNAL: 519843968Overrides:INTERNALDEALLOCATIONSINTERNAL: 9742980140298062Overrides:@flatten:0, line 7

Во-вторых, сам LuaJIT поставляется с профилировщиком require('jit.p')

Для кода "до":

52%  ../naive.lua:11  // for fieldno, field_format in ipairs(space_format) do30%  ../naive.lua:26  // fieldnames[fieldname] = true12%  ../naive.lua:9   // local fieldnames = {}

Для кода "после":

36%  flatten:3  // if fieldmap[k] == nil then36%  flatten:7  // local result = {NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,}11%  ../code_gen_v3.lua:9 // выбираем значение из кэша 4%  flatten:39 4%  flatten:2 4%  ../code_gen_v3.lua:8 4%  flatten:8 4%  ../code_gen_v3.lua:10

А также для тех, кто хочет копнуть совсем глубоко, есть возможность дампа байткода, который LuaJIT генерирует и выполняет require('jit.dump')

Заключение

Мы рассмотрели применение кодогенерации при разработке на Tarantool. Это позволило достаточно просто ускорить в 3 раза один из участков кода в реальном проекте патч был принят. При разработке не стоит забывать о специфике платформы. По возможности стоит генерировать код, который будет приводить к выделению минимально возможного количества памяти, а также не использовать медленные функции в нашем случае те, которые не компилируются LuaJIT. Также советую обратить внимание на то, что в проекте CRUD и до этого использовалась кодогенерация. C её помощью создаются быстрые функции для проверки соответствия тапла пользовательским условиям.

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

Подробнее..

Тестируем играючи мастер-мастер репликация в Tarantool

21.10.2020 16:06:58 | Автор: admin


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


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


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


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


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




Геймплей


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




Как запустить


  1. Установить Tarantool 2-ой версии по инструкции https://bit.ly/2IL3JSc


  2. Взять исходники игры:



$ git clone https://github.com/filonenko-mikhail/mmgame.git$ cd mmgame

  1. Запустить координатора геймплея:
    • В аргументе адрес, на котором запустится координатор.
      $ reset && clear$ tarantool ./foodmaker.lua 127.0.0.1:3301
      
  2. Запустить первого игрока в отдельном терминале:
    • Первый аргумент адрес координатора;
    • Второй адрес, на котором запустить игрока;
    • Третий рабочая директория.
      $ reset && clear$ tarantool ./player.lua 127.0.0.1:3301 127.0.0.1:3302 ./player1data
      
  3. Стрелками можно управлять черной буквой на сером фоне и собирать символы на синем фоне. Первая строку вверху экрана отображает:
    • Анимацию, что репликация с координатором работает;
    • Персонажа игрока и его жизни.
  4. Игрок 2 в другом отдельном терминале:

$ reset$ tarantool ./player.lua 127.0.0.1:3301 127.0.0.1:3303 ./player2data

  1. Пробелом устанавливаем бомбы красный символ на черном фоне.

Troubleshooting


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


  • Консоль сломалась так, что ничего не видно
    • reset <Enter> не глядя
  • ER_REPLICASET_UUID_MISMATCH: Replica set UUID mismatch: expected 4f8d5028-3f4e-4f8f-a237-bb3db620813f, got 03982784-c023-4661-afe1-96752d90df86
    • Кластер создавался непоследовательно, и в итоге скорее всего кластеров получилось несколько, и реплика не может найти себе место
    • Удалить снапы и логи и перезапустить
  • ER_UNKNOWN_REPLICA: Replica 904c70b2-be5a-4e5f-afd0-daa0be66f729 is not registered with replica set d4f37bc6-3a71-43a2-8ca5-65e2bcc0bfda
    • Кластер пересоздавался, а реплика пытается присоединиться со старыми настройками
    • Удалить снапы и логи и перезапустить

Если у вас ошибка не такая как из списка, или вы делаете что-то ещё и возникают вопросы, то у нас есть русскоязычный чат в телеграме https://bit.ly/37l0awn




Как это выглядит





Игровой спейс


Все объекты игры будут содержаться в одном спейсе (таблице), который будет реплицироваться между узлами.


Вот как она будет выглядеть:


ID Icon X Y Type Health
uuid symbol int int string int

Первичным ключом будет является поле ID. Для каждого объекта в том числе персонажей это поле будет уникальным.


Icon содержит текстовый спрайт объекта.


X, Y содержит текущие координаты объекта.


Type тип объекта:


  • игрок;
  • фрукты;
  • бомба;
  • огонь после бомбы;
  • поезд;
  • бесконечный прогрессбар.

Health жизни объекта:


  • для игрока это жизни;
  • для других объектов это энергетическая ценность.

Все действия над объектами будут производится с помощью обновления соответствующих полей.


Индексация


Индексов на спейсе будет несколько:


  • первичный ключ, конечно же: {ID};
  • позиция объекта для вычисления столкновений: {x, y, type};
  • тип объекта для быстрого подсчета и итерации по разным объектам: {type};
  • жизни для наблюдения статистики: {health}.



Бесконфликтность транзакций и консистентность данных


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


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


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


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


Например:


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

Или, другими словами, операция присваивания значения неаддитивна, а операции сложения и вычитания аддитивны.




Топология


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


Репликасет в Tarantool это группа серверов, которые реплицируют данные между собой. У каждого узла есть свой уникальный идентификатор instance uuid. И одновременно с этим у узлов репликасета есть одинаковое для всех поле replicaset uuid.


Чтобы все эти идентификаторы узлов правильно сошлись, создавать репликасет лучше последовательно.


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


Вот как это будет выглядеть:


  • Запускается foodmaker (координатор) и создает cluster uuid.


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


  • После успешного подключения игрок настраивает репликацию в обратную сторону.


  • После подключения нескольких игроков получится топология звезда.


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



Программирование на Tarantool


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


Для создания приложений в Tarantool используется язык Lua с JIT-компиляцией.


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


Таким образом вы можете управлять базой данных изнутри с помощью Lua-скриптов. И если вам понравилась такая идея, то в реальных проектах я рекомендую пользоваться готовым решением для оркестрации кластера Tarantool Cartridge.



Конфигурирование координатора


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


Для настройки репликации используются параметры:


  • replication
  • replication_connect_quorum
  • replication_connect_timeout

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


box.cfg{    listen=server,    replication_connect_quorum=0,    replication_connect_timeout=0.1,    work_dir=wrkdir,    log="file:foodmaker.log",}



Конфигурирование игрока


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


box.cfg{    listen=localserver,    replication={ remoteserver },    replication_connect_timeout=60,    replication_connect_quorum=1,    work_dir=wrkdir,    log="file:player.log"}

Подключение репликации от координатора к игроку


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


Я решил это следующим образом:


  • Координатор создает функцию add_player.


  • Игрок удаленно вызывает эту функцию на координаторе со своим адресом.


  • В случае перезагрузки координатора игрок перенастраивает репликацию, когда тот вернется.


  • Функция на координаторе выглядит так:


    function add_player(server)if box.session.peer() == nil then    return falseendlocal server = uri.parse(server)local replica = uri.parse(box.session.peer())replica.service = server.servicereplica.login = conf.userreplica.password = conf.passwordreplica = uri.format(replica, {include_password=true})local replication = box.cfg.replication or {}local found = falsefor _, it in ipairs(replication) do    if it == replica then        found = true        break    endendif not found then    table.insert(replication, replica)    box.cfg({replication={}})    box.cfg({replication=replication})endreturn trueend
    

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


    _G.conn = netbox.connect(remoteserver,{wait_connected=false, reconnect_after=2})conn:on_connect(function(client)fiber.new(function ()    local rc, res = pcall(client.call, client, 'add_player', {localserver})    if not rc then        log.info(res)    endend)end)
    




Схема данных


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


В процессе разработки я постоянно перезапускал и дорабатывал детали на уже инициализированной базе. Чтобы повторное применение схемы данных не вызывало ошибки, я пользовался флагом if_not_exists=true. Он позволяет игнорировать DDL-команды, когда спейсы, индексы и другие объекты уже существуют.


Data Definition Language


Краткий обзор DDL-операций, которые я использую:


  • Создание спейса.
    box.schema.space.create(<name>, options)
    
  • Формирование структуры спейса.
    box.space.<name>:format({{name=<field_name>, type=<field_type>}, ...,})
    
  • Создание индекса.
    box.space.<name>:create_index(<index_name>,{    parts={{field=<field_name> type=<field_type>},        ...,    },    unique=false|true,})
    
  • Создания пользователя.
    box.schema.user.create(<name>, {password=<pass>})
    
  • Предоставление прав.
    box.schema.user.grant(<name>, ....)
    
  • Создание функции для удаленного вызова.
    box.schema.func.create(<name>)
    



Ожидание схемы данных


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


while true do    if type(box.cfg) ~= 'function'    and box.space[conf.space_name] ~= nil     and not box.info.ro then        break    end    fiber.sleep(0.1)end



Триггеры в Tarantool


Триггеры в Tarantool являются частью сервера приложений и не сохраняются в базе данных.


Чтобы создать триггер, я:


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

Инициализация базы данных процесс из нескольких стадий, поэтому установка триггера, на первый взгляд, может показаться сложной. На второй взгляд скорее всего, тоже :)


Итак, чтобы установить триггер в спейс, предлагается такая схема:


  • Установить триггер в инициализацию системной схемы базы данных.
    box.ctl.on_schema_init(<CALLBACK>)
    
  • В триггере инициализации схемы установить триггер на реестр спейсов.
    box.ctl_on_schema_init(function()box.space._space:on_replace(<CALLBACK 2>)end)
    
  • В триггере реестра спейсов обнаружить искомый пользовательский спейс и создать триггер завершения транзакции.
    box.ctl.on_schema_init(function()box.space._space:on_replace(function(old, space)    if not old and sp and sp.name == <USER SPACE NAME> then        box.on_commit(<CALLBACK 3>)    endend)end)
    
  • И вот, наконец, у меня в руках игла Кощея ^W^W то место, где я устанавливаю пользовательский триггер в пользовательский спейс.
    box.ctl.on_schema_init(function()box.space._space:on_replace(function(old, sp)    if not old and sp and sp.name == <USER SPACE NAME> then        box.on_commit(function()            box.space[sp.name]:on_replace(<USER TRIGGER>)        end)    endend)end)
    



Логика


Часть логики запускается на координаторе, часть на узлах игроков.


Запуск логики происходит либо в отдельном файбере, либо из триггера.


Файберы легковесные потоки исполнения (сопрограмма, зеленый тред, корутина, горутина). Они используют кооперативную многозадачность. То есть, в один момент времени запущен только один файбер. Когда он выполнил свою логику, то должен явно отдать управление через fiber.sleep(N) или fiber.yield(), либо вызвав некоторую io-операцию.


Вся логика выполняется с помощью изменения данных в спейсе.


Data Modification Language


Вставка данных


-- вставкаbox.space.Name.insert({id, sprite, x, y, type, health})-- вставка или полная перезаписьbox.space.Name.put({id, sprite, x, y, type, health})

Обновление данных


box.space.Name.update({primary key}, {{operation, field, value}})

Удаление


box.space.Name.delete({primary key})



Игрок


Узел игрока при первом запуске создаёт своего персонажа. ID персонажа применяется из значения instance uuid.


Далее узел игрока слушает события клавиатуры и меняет позицию персонажа.


Чтобы события от клавиатуры приходили как есть, я использую функции tcgetattr и tcsetattr через LuaJIT FFI.


Для создания транзакции с несколькими действиями я пользуюсь паттерном.


box.begin()local rc, res, err = pcall(function()    ...     box.space[conf.space_name]:put(bomb)    box.space[conf.space_name]:update(player['id'],         {{'-', conf.health_field, conf.bomb_energy}})end)if not rc then    log.info(res)    box.rollback()else    box.commit()end

Для обработки событий клавиатуры запущен отдельный файбер.




Рендерер


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




Генератор продуктов


Генератор продуктов запускается на координаторе и раз в N секунд создает объект.


Генератор продуктов работает в отдельном файбере.




Анимация поезда


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




Обработка столкновений


Обработка столкновений состоит из двух частей: детектора и обработчика.


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


Детектор через межфайберный канал отправляет обработчику информацию о столкнувшихся объектах.


Обработчик запускается на координаторе в отдельном файбере, в цикле читает сообщения из канала, и, в зависмости от столкновений объектов, меняет значения в полях с жизнями.




Жизненный цикл бомб


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


  • Отнимает от существования единицу.
  • При достижении 0 удаляет бомбу и создаёт ударную волну.

Этот файбер таким же образом следит за ударной волной.




Ветер


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




Таблица игроков


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


Для подключения анонимной реплики предназначен параметр replication_anon.


box.cfg{listen=localserver,        replication={ remoteserver },        replication_connect_timeout=60,        replication_connect_quorum=1,        read_only=true,        replication_anon=true,        work_dir=wrkdir}



В заключение


Вот так с помощью нехитрых приспособлений буханку хлеба можно превратить в троллейбус


Таким приложением я хочу:


  • Подчеркнуть, как просто создать топологию мастер-мастер в Tarantool.
  • Визуализировать процесс репликации.
  • Показать, что происходит в моменты перезапуска реплик.
  • Напомнить, что терминал содержит в себе много интересного.

Если вам хотелось бы рассмотреть более практичное приложение на Tarantool, то есть отличная статья от codesign про создание очереди.


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


  • Tarantool Presale
  • Tarantool Solutions
  • Облако mail.ru
Подробнее..

Синхронная репликация в Tarantool

02.02.2021 20:19:55 | Автор: admin


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

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

Задача реализации синхронной репликации стояла перед командой разработчиков Tarantool долгие годы, к ней было совершено несколько подходов. И вот теперь в релизе 2.6 Tarantool обзавёлся синхронной репликацией и выборами лидера на базе алгоритма Raft.

В статье описан долгий путь к схеме алгоритма и его реализации. Статья довольно длинная, но все её части важны и складываются в единую историю. Однако если нет времени на 60 000 знаков, то вот краткое содержание разделов. Можно пропустить те, которые уже точно знакомы.

  1. Репликация. Введение в тему, закрепление всех важных моментов.
  2. История разработки синхронной репликации в Tarantool. Прежде чем переходить к технической части, я расскажу о том, как до этой технической части дошли. Путь был длиной в 5 лет, много ошибок и уроков.
  3. Raft: репликация и выборы лидера. Понять синхронную репликацию в Tarantool без знания этого протокола нельзя. Акцент сделан на репликации, выборы описаны кратко.
  4. Асинхронная репликация. В Tarantool до недавнего времени была реализована только асинхронная репликация. Синхронная основана на ней, так что для полного погружения надо сначала разобраться с асинхронной.
  5. Синхронная репликация. В этом разделе алгоритм и его реализация описаны применительно к жизненному циклу транзакции. Раскрываются отличия от алгоритма Raft. Демонстрируется интерфейс для работы с синхронной репликацией в Tarantool.

1. Репликация


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


Репликация призвана решить сразу несколько задач. Одна из наиболее частых и очевидных наличие резервной копии данных. Она должна быть готова принимать клиентские запросы, если мастер откажет. Ещё одно из популярных применений распределение нагрузки. При наличии нескольких инстансов БД клиентские запросы между ними можно балансировать. Это нужно, если для одного узла нагрузка слишком велика, или на мастере хочется иметь наименьшую задержку на обновление/вставку/удаление записей (латенси, latency), а чтения не страшно распределить по репликам.

Типично выделяется два типа репликации асинхронная и синхронная.

Асинхронная репликация


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

Цикл жизни асинхронной транзакции сводится к следующим шагам:

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

Параллельно с этим после записи в журнал транзакция поедет на реплики и там проживёт тот же цикл.


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

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

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

Решать эту проблему можно по-разному. Есть способы остаться на асинхронной репликации и действовать в зависимости от ситуации. В случае Tarantool можно написать логику своего приложения таким образом, чтобы после успешного коммита не торопиться отвечать клиенту, а подождать явно, пока реплики транзакцию подхватят. API Tarantool такое делать позволяет после определённых приседаний. Но подходит такое решение не всегда. Дело в том, что даже если запрос-автор транзакции будет ждать её репликации, остальные запросы к БД уже будут видеть изменения этой транзакции, и исходная проблема может повториться. Это называется грязные чтения (dirty reads).

            Client 1           |           Client 2-------------------------------+--------------------------------box.space.money:update(        v    {uid}, {{'+', 'sum', 50}}  |)                              v-------------------------------+--------------------------------                               v   -- Видит незакоммиченные                               |   -- данные!!!                               v   box.space.money:get({uid})-------------------------------+--------------------------------wait_replication(timeout)      |                               v

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

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

Синхронная репликация


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


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

Количество реплик, нужное для коммита транзакции, называется кворум. Обычно это 50 % размера репликасета + 1. То есть в случае двух узлов синхронная транзакция должна попасть на два узла, в случае трёх тоже на два, в случае четырёх на 3 узла, 5 на 3, и так далее.

50 % + 1 берётся для того, чтобы кластер мог пережить потерю половины узлов и всё равно не потерять данные. Это просто хороший уровень надёжности. Ещё одна причина: обычно в алгоритмах синхронной репликации предусмотрены выборы лидера, в которых для успешных выборов за нового лидера должно проголосовать более половины узлов. Любой кворум из половины или меньше узлов мог бы привести к выборам более чем одного лидера. Отсюда и выходит 50 % + 1 как минимум. Один кворум на любые решения коммиты транзакций и выборы.

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

  1. Расплата будет в скорости. Синхронная репликация медленнее, так как существенно возрастает задержка от начала коммита до его конца. Это происходит из-за участия сети и журналов других узлов: транзакцию надо на них послать, там записать и ответ получить. Сам факт присутствия сети увеличивает задержку потенциально до порядка миллисекунд.
  2. В синхронной репликации сложнее поддерживать доступность репликасета на запись. Ведь при асинхронной репликации правило простое: если мастер есть, то данные можно менять. Неважно, есть ли живые реплики и сколько их. При синхронной, даже если мастер доступен, он может быть не способен применять новые транзакции, если подключенных реплик слишком мало какие-то могли отказать. Тогда он не может собирать кворум на новые транзакции.
  3. Синхронную репликацию сложнее конфигурировать и программировать. Нужно аккуратно подбирать значение кворума (если каноническое 50 % + 1 недостаточно), таймаут на коммит транзакции, готовить мониторинг. В коде приложения придётся быть готовым к различным ошибкам, связанным с сетью.
  4. Синхронная репликация не предусматривает мастер-мастер репликацию. Это ограничение алгоритма, который используется в Tarantool в данный момент.

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

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

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

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

2. История разработки синхронной репликации в Tarantool


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

Задача разработки синхронной репликации существует в Tarantool с 2015 года, сколько я помню себя работающим здесь. Синхронная репликация изначально не рассматривалась как что-то срочно необходимое. Всегда находились более важные задачи, или просто не хватало ресурсов. Хотя неизбежность её реализации была ясна.

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

Сначала на задачу был поставлен один человек, сделать её сразу и с наскоку. Но сложность оказалась слишком высока, а разработчик в итоге покинул команду. Было решено разбить синхронную репликацию на несколько задач поменьше, которые можно делать параллельно:

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

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

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

Ручные выборы лидера box.ctl.promote(). Это должна была быть такая функция, которую можно вызвать на инстансе и сделать его лидером, а остальных репликами. Предполагалось, что в выборах лидера самое сложное начать их, и что начать можно с того, чтобы запускать выборы вручную.

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

Всё это должно было закончиться вводом автоматических выборов лидера, логически завершая Raft.

Список задач был сформирован в 2015, и с тех пор на несколько лет был отложен в долгий ящик. Команда сильно отвлеклась на более приоритетные задачи, такие как дисковый движок vinyl, SQL, шардинг.

Ручные выборы


В 2018 году появились ресурсы, и синхронная репликация снова стала актуальна. В первую очередь попытались реализовать box.ctl.promote().

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

Получались выборы практически как в Raft, но автоматически голосование никто не начинает, даже если текущий лидер недоступен. В результате стало очевидно, что смысла делать box.ctl.promote() в его изначальной задумке нет. Это получалась чуть-чуть урезанная версия целого Raft.

Прокси


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

Модуль был спроектирован и реализован. Но было много вопросов к тому, как правильно делать некоторые технически непростые детали без поломок обратной совместимости, без переусложнения кода и протокола, без необходимости доделывать что-либо в многочисленных коннекторах к Tarantool; а также как сделать интерфейс максимально удобным.

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

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

SWIM


Следующая попытка продолжить синхронную репликацию произошла в конце 2018 начале 2019. Тогда за год был реализован протокол SWIM. Реализация была выполнена в виде встроенного модуля, доступного для использования даже без репликации, для чего угодно, прямо из Lua. На одном инстансе можно было создавать много SWIM-узлов. Планировалось, что у Tarantool будет свой внутренний SWIM-узел специально для Raft-сообщений, обнаружений отказов и автопостроения кластера.

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

Оптимизации репликации


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

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

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

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

Прогресс пошёл значительно быстрее. В 2020-м, менее чем за год была реализована синхронная репликация. За основу снова взяли протокол Raft. В качестве минимальной рабочей версии оказалось нужно сделать всего две вещи: синхронный журнал и выборы лидера. Вот так сразу, без годов подготовки, без бесчисленных подзадач и переработок существующих систем Tarantool. По крайней мере, для первой версии.

3. Raft: репликация и выборы лидера


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

Оригинальная статья с полным описанием Raft называется In Search of an Understandable Consensus Algorithm. Алгоритм делится на две независимые части: репликация и выборы лидера.

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

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

Вторая часть Raft занимается обнаружением отказа лидера и выборами нового.

В классическом Raft все узлы репликасета имеют роль: лидер (leader), реплика (follower) или кандидат (candidate):

  • Лидер это узел, который принимает все запросы на чтение и запись.
  • Реплики получают транзакции от лидера и подтверждают их. Все запросы от клиентов реплики перенаправляют на лидера (даже чтения).
  • Кандидатом становится реплика, когда видит, что мастер пропал и надо начать новые выборы.

При нормальной работе кластера (то есть почти всегда) в репликасете ровно один лидер, а все остальные реплики.

Выборы лидера


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

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


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

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

Синхронная репликация


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

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


На реплики, не попавшие в кворум сразу, транзакция и факт её коммита доставляются асинхронно.

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


Журнал в Raft устроен как последовательность записей вида key = value. Каждая запись содержит саму модификацию данных и метаданные индекс в журнале и терм, когда запись была создана на лидере.

В журнале лидера поддерживается два курсора: конец журнала и последняя закоммиченная транзакция. Журналы реплик же являются префиксами журнала лидера. Лидер по мере сборки подтверждений от реплик пишет коммиты в журнал и продвигает индекс последней завершённой транзакции.

В процессе работы Raft поддерживает два свойства:

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

Первое следует из того, что в каждом терме новые изменения генерируются на единственном лидере. Они содержат одинаковые команды и термы, распространяемые на все реплики. Ещё индекс всегда возрастает, и записи в журнале никогда не переупорядочиваются.

Второе следует из проверки, встроенной в AppendEntries. Когда лидер этот запрос рассылает, он включает туда не только новые изменения, но и терм + индекс последней записи журнала лидера. Реплика, получив AppendEntries, проверяет, что если терм и индекс последней записи лидера такие же, как в её локальном журнале, то можно применять новые изменения. Они точно следуют друг за другом. Иначе реплика не синхронизирована не хватает куска журнала с лидера, и даже могут быть транзакции не с лидера! Не синхронизированные реплики, согласно Raft, должны отрезать у себя голову журнала такой длины, чтоб остаток журнала стал префиксом журнала лидера, и скачать с лидера правильную голову журнала.

Здесь стоит сделать лирическое отступление и отметить, что на практике отрезание головы журнала не всегда возможно. Ведь данные хранятся не только в журнале! Например, это может быть B-дерево в SQLite в отдельном файле, или LSM-дерево, как в Tarantool в движке vinyl. То есть только отрезание головы журнала не удалит данные, ждущие коммита от лидера, если они попадают в хранилище сразу. Для такого журнал, как минимум, должен быть undo. То есть из каждой записи журнала можно вычислить, как сделать обратную запись, откатив изменения. Undo-журнал может занимать много места. В Tarantool же используется redo-журнал, то есть можно его проигрывать с начала, но откатывать с конца нельзя.

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


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


На реплике журнал может быть длиннее и даже иметь термы новее, чем в журнале лидера. Хотя текущий терм лидера всё равно будет больше, даже если он ещё ничего не записал (иначе бы он не избрался). Такое может произойти, если реплика была лидером в терме 3 и успела записать две записи, но кворум на них не собрала. Потом был выбран новый лидер в терме 4, и он успел записать две другие записи. Но на них тоже кворум не собрал, а только реплицировал на лидера терма 3. А потом выбрался текущий лидер в терме 5.

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

Это краткое изложение сути Raft с упором на синхронную репликацию. Алгоритм достаточно несложный по сравнению с аналогами вроде Paxos. Для понимания данной статьи изложения выше хватит.

4. Асинхронная репликация


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

В Tarantool есть три основных потока выполнения и по одному потоку на каждую подключенную реплику:

  • транзакционный поток (TX);
  • сетевой поток (IProto);
  • журнальный поток (WAL);
  • репликационный поток (Relay).

Транзакционный поток TX


Это главный поток Tarantool. TX transaction. В нём выполняются все пользовательские запросы. Поэтому Tarantool часто называют однопоточным.

Поток работает в режиме кооперативной многозадачности при помощи легковесных потоков корутин (coroutine), написанных на С и ассемблере. В Tarantool они называются файберами (fiber).

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

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

Сетевой поток IProto


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

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

Журнальный поток WAL


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

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

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

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

При этом пока WAL-поток пишет, TX-поток уже принимает новые транзакции и готовит следующую пачку транзакций. Так Tarantool экономит на системных вызовах. Пользователю этот поток недоступен никак. В схеме асинхронной репликации запись в журнал это единственное и достаточное условие коммита транзакции.

Репликационный поток Relay


Помимо трёх главных потоков Tarantool создает потоки репликации. Они есть только при наличии репликации и называются relay-потоками.

По одному relay-потоку создаётся на каждую подключённую реплику. Relay-поток получает от реплики запрос на получение всех транзакций, начиная с определённого момента. Он исполняет этот запрос в течение жизни репликации, постоянно отслеживая новые транзакции, попавшие в журнал, и посылая их на реплику. Это репликация на другой узел.

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

Например, есть узел 1 с конфигурацией box.cfg{listen = 3313, replication = {localhost:3314}}, и узел 2 с конфигурацией box.cfg{listen = 3314}. Тогда на обоих узлах будут TX-, WAL-, IProto-потоки. На узле 1 в TX-потоке будет жить applier-файбер, который скачивает транзакции с узла 2. На узле 2 будет relay-поток, который отправляет транзакции в applier узла 1.


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

Идентификация транзакций


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

Идентификация записей в журнале происходит по двум числам: replica ID и LSN. Первое это уникальный ID узла, который создал транзакцию. Второе число LSN, Log Sequence Number, идентификатор записи. Это число постоянно возрастает внутри одного replica ID, и не имеет смысла при сравнении с LSN под другими replica ID.

Такая парная идентификация служит для поддержки мастер-мастер репликации, когда много инстансов могут генерировать транзакции. Для их различия они идентифицируются по ID узла-автора, а для упорядочивания по LSN. Разделение по replica ID позволяет не заботиться о генерировании уникальных и упорядоченных LSN на весь репликасет.

Всего реплик может быть 31, и ID нумеруются от 1 до 31. То есть журнал в Tarantool в общем случае это сериализованная версия 31-го журнала. Если собрать все транзакции со всеми replica ID на узле, то получается массив из максимум 31-го числа, где индекс это ID узла, а значение последний примененный LSN от этого узла. Такой массив называется vclock vector clock, векторные часы. Vclock это точный снимок состояния всего кластера. Обмениваясь vclock, инстансы сообщают друг другу, кто на сколько отстаёт, кому какие изменения надо дослать, и фильтруют дубликаты.

Есть ещё 32-я часть vclock под номером 0, которая отвечает за локальные транзакции и не связана с репликацией.

Реплицированные транзакции на репликах применяются ровно так же, как и на узле-авторе. С тем же replica ID и LSN. А потому продвигают ту же часть vclock реплики, что и на узле-авторе. Так автор транзакций может понять, надо ли посылать их ещё раз, если реплика переподключается, и сообщает свой полный vclock.

Далее следует пример обновления и обмена vclock на трёх узлах. Допустим, узлы имеют replica ID 1, 2 и 3 соответственно. Их LSN изначально равны 0.

Узел 1: [0, 0, 0]Узел 2: [0, 0, 0]Узел 3: [0, 0, 0]

Пусть узел 1 выполнил 5 транзакций и продвинул свой LSN на 5.

Узел 1: [5, 0, 0]Узел 2: [0, 0, 0]Узел 3: [0, 0, 0]

Теперь происходит репликация этих транзакций на узлы 2 и 3. Узел 1 будет посылать их через два relay-потока. Транзакции содержат в себе {replica ID = 1}, и потому будут применены к первой части vclock на других узлах.

Узел 1: [5, 0, 0]Узел 2: [5, 0, 0]Узел 3: [5, 0, 0]

Пусть теперь узел 2 сделал 6 транзакций, а узел 3 сделал 9 транзакций. Тогда до репликации vclock будут выглядеть так:

Узел 1: [5, 0, 0]Узел 2: [5, 6, 0]Узел 3: [5, 0, 9]

А после так:

Узел 1: [5, 6, 9]Узел 2: [5, 6, 9]Узел 3: [5, 6, 9]

Общая схема


Схема асинхронной репликации в такой архитектуре:

  1. Транзакция создаётся в TX-потоке, пользователь начинает её коммит и его файбер засыпает.
  2. Транзакция отправляется в WAL-поток для записи в журнал, записывается, в TX-поток уходит положительный ответ.
  3. TX-поток будит файбер пользователя, пользователь видит успешный коммит.
  4. Просыпается relay-поток, читает эту транзакцию из журнала и посылает её в сеть на реплику.
  5. На реплике её принимает applier-файбер, коммитит её.
  6. Транзакция отправляется в WAL-поток реплики, записывается в её журнал, в TX-поток уходит положительный ответ.
  7. Applier-файбер посылает ответ со своим новым vclock, что всё нормально применилось.

Пользователь к последнему шагу уже давно ушёл. Если выключить Tarantool из розетки до того, как транзакция будет выслана на реплики (после конца шага 3, до конца шага 4) и больше не включать, то эта транзакция уже никуда не доедет и будет потеряна. Конечно, если узел включится снова, то он будет продолжать пытаться отправить транзакцию на реплики, но на сервере мог сгореть диск, и тогда уже ничего не поделать.

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

5. Синхронная репликация


Прежде чем приступать к реализации Raft, было решено зафиксировать несколько правил, которых было обязательно придерживаться в процессе разработки:

  • Если синхронная репликация не используется, то протокол репликации и формат журнала не должны быть изменены никак, полная обратная совместимость. Это позволит обновить существующие кластеры на новый Tarantool и уже потом включить синхронность, по необходимости.
  • Если синхронность используется, нельзя менять формат существующих записей журнала, снова из целей обратной совместимости. Можно только добавлять новые типы записей. Или добавлять новые опциональные поля в существующие типы записей. То же самое про сообщения в репликационных каналах.
  • Нельзя существенно изменить архитектуру Tarantool. Иначе это приведет к изначальной проблеме, когда задача был раздута и растянута на годы. То есть надо оставить основные потоки Tarantool делать то, что они делали, и сохранить их связность в текущем виде. TX-поток должен управлять транзакциями, WAL должен остаться тривиальной системой записи на диск, IProto остается простейшим интерфейсом к клиентам из сети, и relay-потоки должны только читать журнал и посылать транзакции на реплики. Любые последующие оптимизации и перераспределения обязанностей систем должны быть выполнены отдельно, не являться блокировщиками.

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

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

  • создание;
  • начало коммита;
  • ожидание подтверждений;
  • сборка кворума;
  • коммит или отмена транзакции.

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

Создание транзакции


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

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

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

Как это выглядит в коде:

Включить синхронность на существующем спейсе:

box.space[name]:alter({is_sync = true})

Включить синхронность на новом спейсе:

box.schema.create_space(name, {is_sync = true})

Синхронная транзакция на одном спейсе:

sync = box.schema.create_space(   stest, {is_sync = true}):create_index(pk)-- Транзакция из одного выражения,-- синхронная.sync:replace{1}-- Транзакция из двух выражений, тоже-- синхронная.box.begin()sync:replace{2}sync:replace{3}box.commit()

Синхронная транзакция на двух спейсах, один из которых не синхронный:

async = box.schema.create_space(    atest, {is_sync = false}):create_index(pk)-- Транзакция над двумя спейсами, один-- из них  синхронный, а значит вся-- транзакция  синхронная.box.begin()sync:replace{5}async:replace{6}box.commit()

С момента создания и до начала коммита транзакция ведёт себя неотличимо от асинхронной.

Начало коммита транзакции


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

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

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

На самом деле Raft это и подразумевает. Просто он не декларирует, как именно это сохранять в журнал, в каком формате. Столкновение с этими деталями происходит уже в процессе проектирования применительно к конкретной БД.

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

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

Ожидание подтверждений от реплик


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

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

Лимб находится в TX-потоке, куда также стягиваются все подтверждения от реплик из relay-потоков. Такая организация позволяет практически никак не менять существующие подсистемы Tarantool. Всё ядро синхронной репликации, все её действия происходят в новой подсистеме лимб, который взаимодействует с другими подсистемами через их интерфейсы.


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

Сбор кворума


Пока транзакция находится в лимбе, relay-потоки читают её из журнала и высылают на реплики. Реплика получает транзакцию и делает всё тоже самое: пишет её в свой журнал и кладет в свой собственный лимб. Синхронная транзакция или нет, реплика понимает так же, как лидер смотря на синхронность измененных спейсов.

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

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

Подтверждение суть vclock реплики. Он меняется при каждой записи в журнал. Получив это сообщение с vclock реплики, лидер может посмотреть, какой его LSN реплика уже записала в журнал. Например, лидер посылает 3 транзакции на реплику, одной пачкой, {LSN = 1}, {LSN = 2}, {LSN = 3}. Реплика отвечает {LSN = 3} это значит, что все транзакции с LSN <= 3 попали в её журнал. То есть они подтверждены.

На лидере подтверждения от реплик читаются в relay-потоке, оттуда попадают в TX-поток и становятся видны в box.info.replication. Лимб эти уведомления отлавливает и следит, не собрался ли кворум для старейшей ждущей транзакции.

Для отслеживания кворума по мере прогрессирования репликации лимб на лидере строит картину того, какая реплика как далеко зашла. Он поддерживает у себя векторные часы, в которых записаны пары {replica ID, LSN}. Только это не совсем обычный vclock. Первое число идентификатор реплики, а второе последний LSN от лидера, применённый на этой реплике.

Получается, что лимб хранит множество версий LSN лидера, как он хранится на репликах. Обычный vclock хранит LSN разных инстансов, а тут разные LSN одного инстанса лидера.

Для любой синхронной транзакции по её LSN лимб может точно сказать, сколько реплик её применило, просто посчитав, сколько частей этих специальных векторных часов >= этого LSN. Это немного отличается от того, какой vclock пользователи могут видеть в box.info. Но суть очень похожа. В итоге, каждое подтверждение от реплики немного продвигает одну часть этих часов.

Далее следует пример, как обновляется vclock лимба на лидере в кластере из трех узлов. Узел 1 лидер.

Узел 1: [0, 0, 0], лимб: [0, 0, 0]Узел 2: [0, 0, 0]Узел 3: [0, 0, 0]

Пусть лидер начал коммитить 5 синхронных транзакций. Они попали в его журнал, но ещё не были отправлены на реплики. Тогда vclock будут выглядеть так:

Узел 1: [5, 0, 0], лимб: [5, 0, 0]Узел 2: [0, 0, 0]Узел 3: [0, 0, 0]

В vclock лимба продвинулась первая компонента, так как эти 5 транзакций были применены на узле с replica ID = 1, и совершенно не важно, лидер это или нет. Лидер тоже участник кворума.

Теперь предположим, что первые 3 транзакции были реплицированы на узел 2, а первые 4 на узел 3. То есть репликация ещё не завершена. Тогда vclock будут выглядеть следующим образом:

Узел 1: [5, 0, 0], лимб: [5, 3, 4]Узел 2: [3, 0, 0]Узел 3: [4, 0, 0]

Стоит обратить внимание, как обновился vclock лимба. Он фактически является столбиком в матрице vclock-ов. Так как узел 2 подтвердил LSN 3, в лимбе это отражено как LSN 3 во второй компоненте. Так как узел 3 подтвердил LSN 4, в лимбе это LSN 4 в третьей компоненте. Так, глядя на этот vclock, можно сказать, на какой LSN есть кворум.

Например, здесь на LSN 4 есть кворум два узла: 1 и 3, так как они этот LSN подтвердили. А на LSN 5 кворума ещё нет этот LSN есть только на узле 1. Под кворумом подразумевается 50 % + 1, то есть два узла.

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

Коммит транзакции


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

Транзакции упорядочены по LSN, поэтому в какой-то момент встретится транзакция, у которой кворума ещё нет, и 100 % у следующих транзакций его тоже нет. Либо лимб окажется пуст. Для последней собранной транзакции с максимальным LSN лимб пишет в журнал запись COMMIT. Это также автоматически подтвердит все предыдущие транзакции, поскольку репликация строго последовательна. То есть если транзакция с LSN L собрала кворум, то все транзакции с LSN < L тоже его собрали. Это экономит количество операций записи в журнал и место в нём.

После записи COMMIT все завершённые транзакции отвечаются пользователям как успешные и удаляются из памяти.

Рассмотрим пример, как лимб сворачивается. Пусть в кластере 5 узлов. Лидер третий. В лимбе накопились транзакции в ожидании кворума.


Коммитить пока ничего нельзя: самая старая транзакция имеет LSN 1, который подтверждён только лидером. Пусть часть реплик подтвердила несколько LSN-ов.


Теперь LSN 1 подтвержден узлами 1, 3, 4, 5 то есть это больше половины и кворум собран, можно коммитить. Следующий LSN 2, на него только два подтверждения, от узлов 3 и 4. Его коммитить пока нельзя, как и все последующие. Значит в журнал надо записать COMMIT LSN 1.


Спустя ещё время получены новые подтверждения от реплик.


Теперь кворум есть на LSN 5 его подтвердили все. Так как везде LSN >= 5. На LSN 6 кворума нет, он есть только на двух узлах (3-й и 5-й), а это меньше половины. Значит коммитить можно все LSN <= 5.


Стоит обратить внимание, как одна запись COMMIT завершает сразу 4 транзакции.

Так как COMMIT попадает в журнал, а журнал реплицируется, то эта запись автоматически уедет на реплики и отпустит завершённые транзакции в их лимбах. Ровно таким же образом, но только реплики не будут писать ещё один COMMIT. Они только запишут тот, что пришёл от лидера.

Отмена транзакции


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

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

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

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

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

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

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

К сожалению, в распределённых системах нет никаких 100 % гарантий ни на что. Можно лишь увеличивать шансы на успех, наращивать надёжность, но идеального решения физически невозможно создать.

Смена лидера


Бывает, что лидер становится недоступен по какой-либо причине. Тогда надо выбрать нового лидера. Делать это можно разными способами, включая вторую часть Raft, которая тоже реализована в Tarantool и делает смену автоматически. Можно каким-то другим способом.

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

При выборах надо выбирать новым лидером узел с максимальным LSN относительно старого лидера. Такой узел точно будет содержать последние закоммиченные транзакции старого лидера. То есть он участвовал в последнем кворуме.

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

Рассмотрим пример. Есть 5 узлов. Один из них лидер, и он выполнил транзакцию по обновлению ключа A в значение 20 вместо старого 10. На эту транзакцию он собрал кворум из трёх узлов, закоммитил её, ответил клиенту.


Теперь лидер был уничтожен до того, как успел послать эту транзакцию на другие два узла.


Новым лидером может стать только один из узлов под синим контуром. Если лидером сделать один из узлов под красным контуром, то он форсирует на остальных состояние {a = 10}, что приведёт к потере транзакции. Несмотря на то, что на неё был собран кворум, произошел коммит и более половины кластера всё ещё цело.

Выбрав лидера, надо завершить транзакции, которые находятся в его лимбе после старого лидера, если такие есть. Делается это при помощи функции box.ctl.clear_synchro_queue(). Она будет ждать, пока на незавершённые транзакции соберётся кворум, запишет COMMIT от имени старого лидера, и лимбы в кластере опустеют, когда этот COMMIT будет доставлен на остальные узлы через репликацию. Новый лидер присваивает пустой лимб себе и становится готов к работе.

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

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

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

Интерфейс


Функции для работы с синхронной репликацией в Tarantool делятся на две группы: для управления синхронностью и для выборов лидера. Для включения синхронности на спейсе нужно указать опцию is_sync со значением true при его создании или изменении.

Создание:

box.schema.create_space('test', {is_sync = true})

Изменение:

box.space[test]:alter({is_sync = true})

Теперь любая транзакция, меняющая синхронный спейс, становится синхронной. Для настройки параметров синхрона есть глобальные опции:

box.cfg{    replication_synchro_quorum = <count or expression>,    replication_synchro_timeout = <seconds>,    memtx_use_mvcc_engine = <boolean>}

Replication_synchro_quorum это количество узлов, которые должны подтвердить транзакцию для её коммита на лидере. Можно задать его как число, а можно как выражение над размером кластера. К примеру, каноническая форма box.cfg{replication_synchro_quorum = N/2 + 1}, которая означает кворум 50 % + 1. Tarantool вместо N подставляет количество узлов, известных лидеру. Кворум можно выбрать и больше канонического, если нужны более сильные гарантии. Но выбирать половину или меньше уже небезопасно.

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

Memtx_use_mvcc_engine позволяет избавиться от грязных чтений. Дело в том, что в Tarantool грязные чтения существовали всегда, так как изменения транзакций (асинхронных) становились видны ещё до записи в журнал. Это стало серьёзной проблемой с появлением синхронной репликации, так как вступить в них стало сильно проще. Но просто взять и выключить грязные чтения по умолчанию нельзя, это может сломать совместимость с существующими приложениями. Кроме того, для их выключения требуется выполнение большого количества дополнительной работы в процессе выполнения транзакций, что может повлиять на производительность. Поэтому выключение грязных чтений опционально и контролируется этой опцией. По умолчанию грязные чтения включены!

Для выборов лидера можно пользоваться автоматикой, освещённой в отдельной статье. А можно выбирать вручную или своей собственной автоматикой через API Tarantool.

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

После нахождения кандидата следует позвать на нем box.ctl.clear_synchro_queue(). Пока эта функция не вернёт успех, лидер не может начать делать новые транзакции.

Отличия от Raft


Идентификация транзакций


Главное отличие от Raft идентификация транзакций. Происходит отличие из формата журнала. Дело в том, что в Raft журнал един. В нём нет векторности. Записи журнала Raft имеют формат вида {{key = value}, log_index, term}. В терминологии Tarantool это изменения транзакции и её LSN. Tarantool не хранит термы в каждой записи, и в нём нет единой последовательности log_index нужно хранить replica ID. В Tarantool расчёт LSN идёт индивидуально на каждом узле для транзакций его авторства.

Блокирующими проблемами это, на самом деле, не является. Потому как, во-первых, транзакции генерирует только один узел, а значит из всех компонент vclock меняется только один с ID = replica ID лидера. То есть журнал на самом деле линеен, пока лидер известен и работает. Во-вторых, хранить терм в каждой записи не нужно, и вообще может быть дорого. Достаточно фиксировать в журнале, когда терм был изменён, и в памяти держать текущее значение терма. Это делается модулем выборов лидера отдельно от синхронной репликации.

Сложность возникает, когда лидер меняется. Тогда нужно во всём кластере перевести отсчёт LSN на другую часть vclock, с отличным replica ID. Для этого новый лидер завершает все транзакции старого лидера, захватывает лимб транзакций и начинает генерировать свои собственные транзакции. На репликах произойдёт то же самое: они получат от нового лидера COMMIT и ROLLBACK на транзакции старого лидера, и потом новые транзакции с другим replica ID. Лимбы всего кластера переключаются автоматически, когда их опустошили, и начали давать новые транзакции с другим replica ID.

Это выглядит почти как если бы в кластере был 31 протокол Raft, работающий поочередно.

Нет отката журнала


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

В Tarantool отката журнала нет, так как он redo, а не undo. Кроме того, архитектурой не предусмотрен откат LSN. Если в кластере появляются такие реплики, то нет выбора кроме как их удалить и подключать как новые, скачать все данные с лидера заново. Это называется rejoin.

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

Заключение


Синхронная репликация в Tarantool доступна с версии 2.5, а автоматические выборы с версии 2.6. На данный момент эта функциональность находится в бета-версии, то есть ещё не обкатана в реальных системах, а интерфейсы и их поведение ещё могут измениться. И пока существующая реализация полируется, есть планы по её оптимизации и расширению. Оптимизации главным образом технические.

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

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

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

Транзакции в бинарном протоколе
С момента создания Tarantool в нём не было возможно делать долгие транзакции из более чем одного выражения прямо по сети, используя только удалённый коннектор к Tarantool. Для любой операции сложнее, чем один replace/delete/insert/update, требовалось написать код на Lua, который бы делал нужные операции в одной транзакции, и вызывать этот код как функцию.

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

c = netbox.connect(host)c:begin()c.space.test1:replace{100}c.space.test2:delete({5})c:commit()

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

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

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

box.begin()box.space.test1:replace{100}box.commit({is_lazy = true})box.begin()box.space.test2:replace{200}box.space.test3:replace{300}box.commit({is_lazy = true})

Оба box.commit() вернут управление сразу, а транзакция попадёт в журнал и будет закоммичена в конце итерации цикла событий Tarantool (event loop). Такой подход не только может уменьшить задержку на ответ клиенту, но и лучше использовать ресурсы WAL-потока, так как больше транзакций сможет попасть в одну пачку записи на диск к концу итерации цикла событий.

Кроме того, касательно синхронных транзакций иногда может быть удобно сделать синхронным не целый спейс, а только определённые транзакции, даже над обычными спейсами. Для такого запланировано добавлении ещё одной опции в box.commit() is_sync. Выглядеть будет так: box.commit({is_sync = true}).

Мониторинг
В данный момент нет способа узнать, сколько синхронных транзакций ожидают коммита (находятся в лимбе). Ещё нет способа узнать, каково значение кворума, если пользователь использовал выражение в replication_synchro_quorum. Например, если было задано N/2 + 1, то в коде узнать фактическое значение кворума нельзя никаким вменяемым способом (но способ есть).

Для устранения этих неизвестностей будет выведена отдельная функция мониторинга box.info.synchro.
Подробнее..

Есть ли корпоративная жизнь на удаленке и как ее обеспечить интеграция внутренней системы аутентификации

30.04.2021 12:16:20 | Автор: admin

В digital-агентстве Convergent, где я работаю, в потоке множество проектов, и у каждого из них может быть собственная админка. Есть несколько окружений (дев, стейдж, лайв). А ещё есть разные внутрикорпоративные сервисы (как собственной разработки, так и сторонние вроде Redmine или Mattermost), которыми ежедневно пользуются сотрудники.

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

В данной статье я хочу поделиться опытом создания собственной внутренней системы аутентификации на основе OpenResty, а также спецификации OAuth2. В качестве основного языка программирования мы используем PHP, а фреймворк Yii 2.

Суммирую необходимый функционал:

  • Единое место управления всеми доступами. Здесь происходит выдача и отзыв доступов в административные панели сайтов и списки доменов;

  • По умолчанию весь доступ запрещён, если не указано обратное (доступ к любым доменам контролируется с помощью OpenResty);

  • Аутентификация для сотрудников;

  • Аутентификация для клиентов.

Закрытый доступ к сайтам и инфраструктуре

Начну со схемы, как мы организовали инфраструктуру доступов.

Упрощенная схема взаимодействия между пользователями и серверамиУпрощенная схема взаимодействия между пользователями и серверами

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

Фронт-контроллером в данном случае выступает OpenResty это модифицированная версия nginx, которая в т. ч. поддерживает из коробки язык Lua. На нём я написал прослойку, через которую проходят все HTTP(s)-запросы.

Вот так может выглядеть код скрипта аунтентификации (в упрощенном варианте):

auth.lua
function authenticationPrompt()    ngx.header.www_authenticate = 'Basic realm="Restricted by OpenResty"'    ngx.exit(401)endfunction hasAccessByIp()    local ip = ngx.var.remote_addr    local domain = ngx.var.host    local port = ngx.var.server_port    local res, err = httpc:request_uri(os.getenv("AUTH_API_URL") .. "/ip.php", {        method = "GET",        query = "ip=" .. ip .. '&domain=' .. domain .. '&port=' .. port,        headers = {          ["Content-Type"] = "application/x-www-form-urlencoded",        },        keepalive_timeout = 60000,        keepalive_pool = 10,        ssl_verify = false    })    if res ~= nil then        if (res.status == 200) then            session.data.domains[domain] = true            session:save()            return true        elseif (res.status == 403) then            return false        else            session:close()            ngx.say("Server error: " .. res.body)            ngx.exit(500)        end    else        session:close()        ngx.say("Server error: " .. err)        ngx.exit(500)    endendfunction hasAccessByLogin()    local header = ngx.var.http_authorization    local domain = ngx.var.host    local port = ngx.var.server_port    if (header ~= nil) then        header = ngx.decode_base64(header:sub(header:find(' ') + 1))        login, password = header:match("([^,]+):([^,]+)")        if login == nil then            login = ""        end        if password == nil then            password = ""        end        local res, err = httpc:request_uri(os.getenv("AUTH_API_URL") .. '/login.php', {            method = "POST",            body = "username=" .. login .. '&password=' .. password .. '&domain=' .. domain .. '&port=' .. port,            headers = {              ["Content-Type"] = "application/x-www-form-urlencoded",            },            keepalive_timeout = 60000,            keepalive_pool = 10,            ssl_verify = false        })        if res ~= nil then            if (res.status == 200) then                session.data.domains[domain] = true                session:save()                return true            elseif (res.status == 403) then                return false            else                session:close()                ngx.say("Server error: " .. res.body)                ngx.exit(500)            end        else            session:close()            ngx.say("Server error: " .. err)            ngx.exit(500)        end    else        return false    endendos = require("os")http = require "resty.http"httpc = http.new()session = require "resty.session".new()session:start()if (session.data.domains == nil) then    session.data.domains = {}endlocal domain = ngx.var.hostif session.data.domains[domain] == nil then    if (not hasAccessByIp() and not hasAccessByLogin()) then        session:close()        authenticationPrompt()    else        session:close()    endelse    session:close()end

Алгоритм работы скрипта довольно простой:

  • Поступает HTTP-запрос от пользователя;

  • OpenResty запускает скрипт auth.lua;

  • Скрипт определяет запрашиваемый домен и отправляет два запроса на внешний бэкенд;

  • Первый на проверку IP-адреса пользователя в базу;

  • Если IP отсутствует, выводит браузерное окно для ввода логина и пароля, отправляет второй запрос на проверку доступа;

  • В любой другой ситуации выводит окно Вход.

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

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

Отмечу, что IP-адреса попадают в базу при аутентификации пользователя. Это сделано специально для сотрудников. Такие адреса помечают как временные и доступные ограниченное время, после чего они удаляются.

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

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

Управление доступами по паролю и по IP-адресуУправление доступами по паролю и по IP-адресу

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

Форма аутентификации для сотрудниковФорма аутентификации для сотрудников

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

Двухфакторная аутентификация

Для обеспечения двухфакторной аутентификации для сотрудников решено было добавить Google Authenticator. Такой механизм защиты позволяет больше обезопасить себя от утечки доступов. Для PHP есть готовая библиотека sonata-project/GoogleAuthenticator. Пример интеграции можно посмотреть здесь.

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

OAuth и OpenID

Третье, и не менее важное для нас, создание OAuth-сервера. За основу мы взяли модуль thephpleague/oauth2-server. Для Yii 2 готового решения не было, поэтому написали собственную имплементацию сервера. OAuth2 достаточно обширная тема, расписывать её работу в данной статье не буду. Библиотека имеет хорошую документацию. Также она поддерживает различные фреймворки, включая Laravel и Symfony.

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

Также для всех наших проектов на Yii был разработан модуль для подключения ID. Это позволило вынести всё управление доступами в админпанели для сотрудников в централизованное место. Кстати, если интересно, я писал статью о модульном подходе, который мы применили в нашем digital-агентстве.

Заключение

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

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

Ссылки по теме

Подробнее..

Haproxy программирование и конфигурирование средствами Lua

05.12.2020 22:10:10 | Автор: admin
Сервер Haproxy имеет встроенные средства для выполнения скриптов Lua.

Язык программирования Lua для расширения возможностей различных серверов используется очень широко. Например, на Lua можно программировать для серверов Redis, Nginx (nginx-extras, openresty), Envoy. Это вполне закономерно, так как язык программирования Lua как раз и был разработан для удобства встраивания в приложения в качестве скриптового языка.

В этом сообщении я рассмотрю варианты использования Lua для расширения возможностей Haproxy.

Согласно документации, скрипты Lua на сервере Haproxy могут выполняться в шести контекстах:

body context (контекст времени загрузки конфигурации сервера Haproxy, когда выполняются скрипты, заданные директивой lua-load);
init context (контекст функций, которые вызываются сразу после загрузки конфигурации, и зарегистрированы системной функции core.register_init(function);
task context (контекст функций, выполняемых по расписанию и зарегистрированных системной функцией core.register_task(function));
action context (контекст функций, зарегистрированных системной функцией сore.register_action(function));
sample-fetch context (контекст функций, зарегистрированных системной функцией сore.register_fetches(function));
converter context (контекст функций, зарегистрированных системной функцией сore.register_converters(function)).

Фактически есть еще один контекст выполнения, который не указан в документации:
service context (контекст функций, зарегистрированных системной функцией сore.register_service(function));

Начнем с самой простой конфигурации сервера Haproxy. Конфигурация состоит из двух секций frontend то есть то, к чему обращается клиент с запросом, и backend то, куда проксируется запрос клиента через сервер Haproxy:

frontend jwt        mode http        bind *:80        use_backend backend_appbackend backend_app        mode http        server app1 app:3000


Теперь все запросы, приходящие на порт 80 Haproxy будут перенаправлены на порт 3000 сервера app.

Services



Services это функции, определенные в скриптах Lua, которые формируют ответ без обращения к бэкенду. Эти функции регистрируются вызовом системной функции сore.register_service(function)).

Определим простейший Service в файле guarde.lua:

function _M.hello_world(applet)  applet:set_status(200)  local response = string.format([[<html><body>Hello World!</body></html>]], message);  applet:add_header("content-type", "text/html");  applet:add_header("content-length", string.len(response))  applet:start_response()  applet:send(response)end


И зарегистрируем ее как Service в файле register.lua:

package.path = package.path  .. "./?.lua;/usr/local/etc/haproxy/?.lua"local guard = require("guard")core.register_service("hello-world", "http", guard.hello_world);


Параметр http является триггером, который допускает использование Service только в контексте http запроса (mode http).

Дополним конфигурацию сервера Haproxy:

global        lua-load /usr/local/etc/haproxy/register.luafrontend jwt        mode http        bind *:80        use_backend backend_app        http-request use-service lua.hello-world   if { path /hello_world }backend backend_app        mode http        server app1 app:3000


Теперь, обратившись к серверу Haproxy с запросом /hello_world, клиент получит не ответ с проксируемого сервера, а ответ сервиса lua.hello-world.

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

Actions



Actions действия, выполняемые после получения запроса от клиента или после получения ответа от проксируемого сервера. Actions могут выполнять асинхронные операции (например запросы к базе данных) и не имеют возвращаемого значения. С сервером Actions общаются путем установки переменных контекста запроса. Контекст запроса предается в качестве параметра при вызове Action. Традиционно имя этого параметра txn. Создадим Action, который будет проверять наличие авторизации Bearer в запросе:

function _M.validate_token_action(txn)  local auth_header = core.tokenize(txn.sf:hdr("Authorization"), " ")  if auth_header[1] ~= "Bearer" or not auth_header[2] then    return txn:set_var("txn.not_authorized", true);  end  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});  if not claim then    return txn:set_var("txn.not_authorized", true);  end  if claim.exp < os.time() then    return txn:set_var("txn.authentication_timeout", true);  end  txn:set_var("txn.jwt_authorized", true);end


Зарегистрируем этот Action:

core.register_action("validate-token", { "http-req" }, guard.validate_token_action);


Параметр { http-req } является триггером, который позволяет использовать этот Action только в контексте http и только на этапе запроса клиента (и запрещает использовать на этапе ответа проксируемого сервера).

В конфигурации Haproxy, Action регистрируется в секции http-request:

frontend jwt        mode http        bind *:80        http-request use-service lua.hello-world   if { path /hello_world }        http-request lua.validate-token                 if { path -m beg /api/ }


На основании значения переменных, установленных в Action, формируются ACL (Access Control Lists) ключевой элемент в конфигурациях Haproxy:

        acl jwt_authorized  var(txn.jwt_authorized) -m bool        use_backend app if jwt_authorized { path -m beg /api/ }


Полный листинг конфигурации сервера Haproxy для Action validate-token:

global        lua-load /usr/local/etc/haproxy/register.luafrontend jwt        mode http        bind *:80        http-request use-service lua.hello-world   if { path /hello_world }        http-request lua.validate-token            if { path -m beg /api }        acl bad_request            var(txn.bad_request)               -m bool        acl not_authorized         var(txn.not_authorized)            -m bool        acl authentication_timeout var(txn.authentication_timeout)    -m bool        acl too_many_request       var(txn.too_many_request)          -m bool        acl jwt_authorized         var(txn.jwt_authorized)            -m bool        http-request deny deny_status 400 if bad_request { path -m beg /api/ }        http-request deny deny_status 401 if !jwt_authorized { path -m beg /api/ } || not_authorized { path -m beg /api/ }        http-request return status 419 content-type text/html string "Authentication Timeout" if authentication_timeout { path -m beg /api/ }        http-request deny deny_status 429 if too_many_request { path -m beg /api/  }        http-request deny deny_status 429 if too_many_request { path -m beg /auth/  }        use_backend app if { path /hello }        use_backend app if { path /auth/login }        use_backend app if jwt_authorized { path -m beg /api/ }backend app        mode http        server app1 app:3000


Fetches



Fetches это значения которые вычисляются в процессе запроса. Они могут быть только синхронными, и принимают параметры, заданные в конфигурации Haproxy. Например, та же самая проверка авторизации может быть выполнена как Fetch:

function _M.validate_token_fetch(txn)  local auth_header = core.tokenize(txn.sf:hdr("Authorization"), " ")  if auth_header[1] ~= "Bearer" or not auth_header[2] then    return "not_authorized";  end  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});  if not claim then    return "not_authorized";  end  if claim.exp < os.time() then    return "authentication_timeout";  end  return "jwt_authorized:" .. claim.jti;endcore.register_fetches("validate-token", _M.validate_token_fetch);


Установка ACL по значениям из Fetches задается так:

       http-request set-var(txn.validate_token) lua.validate-token()        acl bad_request var(txn.validate_token) == "bad_request" -m bool        acl not_authorized var(txn.validate_token) == "not_authorized" -m bool        acl authentication_timeout var(txn.validate_token) == "authentication_timeout" -m bool        acl too_many_request var(txn.validate_token) == "too_many_request" -m bool        acl jwt_authorized var(txn.validate_token) -m beg "jwt_authorized"


Converters



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

Соаздадим Converter, который будет заголовку Authorization преобразовывать в строку:

function _M.validate_token_converter(auth_header_string)  local auth_header = core.tokenize(auth_header_string, " ")  if auth_header[1] ~= "Bearer" or not auth_header[2] then    return "not_authorized";  end  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});  if not claim then    return "not_authorized";  end  if claim.exp < os.time() then    return "authentication_timeout";  end  return "jwt_authorized";endcore.register_converters("validate-token-converter",  _M.validate_token_converter);


В файле конфигурации использование конвертера задается следующим образом:

        http-request set-var(txn.validate_token) hdr(authorization),lua.validate-token-converter


К значениею заголовка Authorization, который извлекается системным Fetch hdr() применяется Converter lua.validate-token-converter.

Stick Table



Stick Table это хранилище пар ключ-значение, которые оптимизировано для учета количества запросов в единицу времени, и служат, прежде всего, для защиты серверов от атак DDoS или брутфорса (напрмер перебора паролей или выкачки запросами REST больших объемов данных). В паре с такими средствами как Fetches и Converters, эти таблицы могут подсчитывать количество запросов, например, с определенным сессионным cookie или jti, не давая тем самым использовать одну авторизацию для организации распределенной атаки с сотен тысяч устройств. К положительным сторонам Stick Table относится скорость работы и простота конфигурирования. К отрицательным ограниченное количество регистров для учета значений (всего восемь регистров), потребление памяти, потеря данных после перегрузки сервера Haproxy. Рассмотрим как задаются правила в Stick Table:

        stick-table  type string  size 100k  expire 30s store http_req_rate(10s)        http-request track-sc1 lua.validate-token()        http-request deny deny_status 429 if { sc_http_req_rate(1) gt 3 }


Строка 1. Создается таблица. В качестве ключа используется значение типа строка. Максимальный размер таблицы 100k. Срок хранения ключа 30 секунд. В качестве значения будут накапливаться количество запросов за последние 10 секунд с одинаковым значением ключа типа строка.
Строка 2. Задается значение ключа, полученного из Fetch lua.validate-token() и регистр 1, в котором будут накапливаться значения (track-sc1)
Строка 3. Если количество запросов с ключом, заданными в строке 2, накопленных в регистре с номером 1 (sc_http_req_rate(1)) превышает 3 сервер отдает ответ со статусом 429.

Код использованный в данном сообщении доступен в репозитарии. В частности, там есть файл docker-compose.yml, который поможет поднять необходимую для работы среду.

apapacy@gmail.com
5 декабря 2020 года.
Подробнее..

Домашний DPI, или как бороться с провайдером его же методами

21.03.2021 10:06:16 | Автор: admin

Зачем всё это нужно?

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

Какие вообще уже есть методы решения?

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

  1. Заворачивание всего трафика в VPN-туннель либо какой-то прокси - как вариант, TOR. В случае с TOR-ом о скорости можно забыть, в остальных случаях скорость и время отклика также страдают, поскольку необходимо проксировать через удалённый сервер. Поскольку РосКомНадзор у нас действует на всей территории России, получается что весь трафик придётся проксировать через зарубежный сервер, а значит время отклика (или "пинг") будет сильно страдать. Сразу отрезается весь пласт, например, игровых приложений.

  2. "Гибридный" вариант с использованием списков. Основной трафик идёт напрямую, но часть IP-адресов перенаправляются через VPN/прокси/TOR. Списки можно забирать, например, отсюда. Минусы - приходится периодически обновлять эти списки, и какими бы они не были актуальными есть вероятность всё же наткнуться на заблокированный сайт. На самом деле, один из лучших способов для комфортного пользования интернетом без ограничений. Но можно лучше!

  3. Использование "чёрной магии", связанной с особенностями применяемого провайдерами DPI-софта. Я, в общем и целом, про "дыры" в обработке трафика (например, блокирование только на уровне DNS, или пропуск необычно сформированных пакетов), и, в частности, про GoodbyeDPI уважаемого @ValdikSS. Самый большой минус - работает далеко не везде. И чем дальше, тем хуже работает. Рост вычислительных мощностей скорее упрощает жизнь провайдерам, чем нам в этом вопросе...

  4. ...и наконец, использование DPI для обхода DPI! На самом деле этот способ является подвариантом "гибридного", но безо всяких списков. Мы анализируем пришедшие пакеты от провайдера, делаем вывод, заблокировал ли он нам что-то, и на этом основании либо пускаем дальше трафик к запросившему, либо перенаправляем трафик через VPN/прокси/TOR. Всё ещё требует конфигурации VPN/прокси/TOR, но уже не требует никаких списков, а также позволяет принимать решения на основании теоретически сколь угодно сложной логики!

Про последний способ дальше и пойдёт речь. И поможет нам в этом NGINX.

Во многом идея была вдохновлена Squid-овским механизмом HTTPS Peek and Splice, но его возможностей к сожалению не хватило.

Так ведь NGINX - это веб-сервер, а не инструмент для DPI?

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

И самым лучшим расширением NGINX является проект OpenResty - который добавляет практически ко всем аспектам NGINX-а поддержку Lua.

Мне могут сейчас возразить, что современный NGINX поддерживает "изкаробки" возможность скриптинга на JavaScript (njs), и будет прав, но, во-первых, OpenResty гораздо более развитый проект и его API имеет гораздо больше возможностей, а во-вторых, OpenResty использует LuaJit с поддержкой FFI, что позволяет вызывать C-методы напрямую из Lua-мира - и это создаёт такую возможность для расширения, которая njs даже и не снилась. Во всяком случае пока что...

При этом, NGINX имеет возможность проксировать и "сырой" TCP-трафик (теоретически и UDP тоже, но я реализовал "DPI" только TCP).

О деталях реализации

Конкретно у меня по причине отсутствия кабельного интернета дома сейчас используется 4G-интернет от Мегафона, поэтому описание будет происходить с точки зрения именно Мегафона.

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

Прежде всего, мы поднимаем NGINX в режиме stream на каком-то порту... Пусть будет 40443. Но сам по себе nginx не знает что делать с трафиком, что туда приходит. Именно это мы и будем разруливать с помощью Lua.

Прежде всего, мы перенаправляем весь трафик с 80 и 443 порта на этот самый 40443 порт при помощи iptables и его команды REDIRECT. Эта команда интересна тем, что прописывает в свойства сокета опцию SO_ORIGINAL_DST, в которой сохраняет оригинальный IP и порт, куда пакет изначально направлялся, до того как iptables над ним зверски поиздевался, переписав destination... Кхм, я отвлёкся. Эту информацию можно извлечь при помощи getsockopt... Правда из коробки обёртки над ним не было, так что пришлось написать простенький C-модуль для nginx.

Теоретически можно было бы использовать TPROXY, и пропатчить NGINX для поддержки SO_TRANSPARENT сокетов, но хотелось не прибегать к прямому патчу исходников NGINX-а и обойтись модулями, поэтому REDIRECT.

Итак, мы запрашиваем заблокированный сайт... Пусть будет, например, rutracker.org.

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

HTTP - тут всё просто

Итак, мы запрашиваем http://rutracker.org. И видим, что нас перенаправило на http://m.megafonpro.ru/rkn, где Мегафон услужливо сообщает, что, мол, так и так, сайт заблокирован, просим извинить, а пока посмотрите на нашу рекламу.

Да, Мегафон просто напросто отправил 307 Temporary Redirect с Location на свой собственный сайт для отображения этого сообщения. А значит мы вполне можем отследить ровно это - 307 редирект с Location в котором http://m.megafonpro.ru/rkn.

Для этого мы вычитываем первые данные, пришедшие от клиента (вплоть до 16 кбайт, но по факту первые пакеты весьма маленькие), перенаправляем их серверу и читаем ответ от него. Если находим в нём этот редирект - это означает сразу две вещи:

  1. Сайт блокируется, значит этот коннект надо редиректить.

  2. Запрос скорее всего не дошёл до сервера, а значит переотправять его повторно - безопасно. Это необязательно верно, но верно наверное в 99% случаев. Правда, если это неверно, и вы отправляете запросы, что-то меняющие на удалённом сервере, то тут беда... Прилетит по итогу два запроса - один - тот на который заблокирован ответ, и второй - спроксированный. И узнать мы это никак не сможем. Хорошо, что HTTP без SSL становится всё меньше, правда? =)

Если редиректа нету, значит мы просто отправляем этот запрос дальше клиенту и дальше просто проксируем трафик, не вмешиваясь в него.

HTTPS вплоть до TLSv1.2 - посложнее

С SSL/TLS всё гораздо сложнее... Но есть и хорошие новости. Перед любым HTTP-запросом мы сначала должны выполнить Handshake, а значит первый пакет точно не вызовет выполнение команды на сервере в случае, если нас заблокировали, но исходный пакет таки ушёл на сервер.

Мы запрашиваем https://rutracker.org и получаем в браузере... Ошибку. Сертификат недействительный, потому что выпущен даже не для этого домена, беда-беда...

Анализируем сам сертификат... И что же мы видим? CN=megafon.ru. Получается, что для того, чтобы понять, что сайт блокируется, достаточно вычитать полученный от сервера сертификат, и если мы запрашиваем что угодно кроме megafon.ru, а получили сертификат с CN=megafon.ru - нас блокируют, и надо проксировать.

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

И здесь нам поможет SNI - дело в том, что (современный, мы не говорим про эпоху IE6, она ушла и слава богу) клиент отправляет домен, к которому обращается, в составе незашифрованных данных ClientHello. Самое интересное, что эти данные умеет вычитывать даже сам NGINX из коробки - модуль ssl_preread поставляется вместе с ним. Ну а Lua-биндинги позволяют получить эту информацию и для наших целей...

Итак, что мы делаем? Процедура во многом аналогична HTTP - мы отправляем первый пакет от клиента серверу (который как раз содержит ClientHello) и ждём от сервера ответа с сертификатом. После чего убеждаемся что SNI не megafon.ru, парсим сертификат (спасибо человеку, написавшему биндинги к OpenSSL для Lua), и принимаем решение - проксировать или нет.

И всё было бы хорошо, если бы не TLSv1.3, который всю эту историю сильно обламывает...

TLSv1.3 наносит ответный удар

Во-первых, в TLSv1.3 SNI может быть зашифрованным. Хорошая новость в том, что зашифрованный SNI не расшифрует и сам провайдер, а значит он будет блокировать любые TLS-запросы к IP точно также. Вторая особенность в том, что сертификат сервер клиенту теперь тоже отправляет в зашифрованном виде...

Проблема усугубляется ещё и тем, что Мегафон отправляет свой сертификат как раз таки по TLSv1.3, то есть зашифрованным, в случае если клиент поддерживает TLSv1.3. А все основные браузеры сейчас его поддерживают. Проблема...

На этом этапе я уже даже думал о том, чтобы патчить ClientHello, убирая из него поддержку TLSv1.3, осуществляя по сути атаку на downgrade до TLSv1.2, но вовремя почитал описание TLSv1.3. В нём реализовано аж ДВА механизма по предотвращению подобных даунгрейдов, поэтому вариант плохой.

...И здесь приходится прибегать к экстренным мерам. На самом деле этот метод я реализовал даже первым, и он по сути и является сутью метода peek and splice у Squid-а.

Мы не можем просто взять и вычитать сертификат. Поэтому мы просто открываем свой собственный коннект к серверу и пытаемся сами совершить tls-handshake. Получаем из него сертификат с CN=megafon.ru? Значит нас блокируют. Нет? Значит всё в порядке. И нам не сильно важно какой другой - да пусть даже мы дисконнект получим. Главное, что мы не получили сертификат, который является флагом блокировки.

Единственным минусом такого подхода является то, что в случае, если Мегафон начнёт поддерживать eSNI, то коннекты к его сайту будут проксироваться, но пока что SNI у него вполне незашифрованный. Да и, на самом деле, сертификат там самоподписанный отдаётся, так что можно и углубить проверку.

А что дальше с заблокированным трафиком-то делать?

Итак, мы понимаем, что сайт блокируется. Что делать? Лучшее и наиболее универсальное что я придумал - это SOCKS5 прокси. Протокол проще некуда, к нему есть удобная клиентская реализация, которую чуть доработать - и можно пользоваться. Вдобавок, SOCKS5 реализован в Tor и SSH. Поднять SOCKS5-сервер - дело пяти минут.

Особенности некоторых сайтов и приятный бонус

Во время тестов я натолкнулся на весьма странное поведение linkedin.com. Дело в том, что при его запросе почему-то я вообще не получал никакого ответа, коннект просто уходил в таймаут. Я решил, что если коннект таймаутится, есть смысл попробовать его тоже перенаправить через VPN - хуже точно не будет.

Каково же было моё удивление, когда точно такое же поведение было и через VPN. Причём, с подключением через VPN IPv4 не соединялся, а по IPv6 вполне всё работало.

Тут я вспомнил, что у SOCKS5 есть два режима подключения к удалённому хосту - по IP и по хосту. Поэтому я реализовал следующую обработку соединения (Hostname мы получаем из SNI):

direct IP => direct Hostname => socks5 IP => socks5 Hostname

С таймаутом на соединение в 2 секунды. В чём смысл? Если вдруг оказывается, что наша машина, на котором развёрнут NGINX оказывается IPv6-capable, а изначальный клиент нет, то мы сможем спроксировать трафик через IPv6, при том, что клиент будет думать что соединяется по IPv4. Аналогично и с прокси-сервером.

А что насчёт производительности?

Конкретно в моём случае, основные затраты на производительность - при первичном подключении к серверу. Но, честно говоря, даже они минимальны. После соединения потребление что CPU, что RAM остаётся почти незаметным. Конечно, Netgear R7000 достаточно мощная машинка - двухядерник с 1 GHz ядрами и 256 МБ оперативки - но он даже не нагружается на 10% во время обычного использования (активного сёрфинга, просмотра видео на YouTube). При прогоне спидтеста потребление вообще остаётся на уровне 5% CPU. Самую большую нагрузку составил как ни странно сайт по проверке замедления t.co (https://speed.gulag.link/) - вот там ядра напрягаются до 80% на одном ядре (и около 25% на другом), но при этом так и не достигают 100%.

Итак, переходим к практике - что нам для этого потребуется?

В качестве железа подойдёт почти что всё что угодно, на чем можно запустить Linux. В моём случае, я запускаю это всё на Netgear R7000 с ARM-процессором внутри, и кастомной прошивкой с версией ядра всего лишь 2.6.36. В общем и целом теоретически запустится на практически любом Linux-е с версией ядра хотя бы 2.6.32.

Поскольку решение не совсем стандартное, придётся пересобирать NGINX/OpenResty из исходников. Я успешно собирал прямо на самом роутере - занимает некоторое время, но не так чтобы бесконечное.

Конкретно нужно:

  1. OpenResty - это NGINX с LuaJit и основными Lua модулями. Я скачивал релизный тарболл из их раздела загрузок.

  2. lua-resty-openssl и его C-модуль к NGINX lua-resty-openssl-aux-module. Нужен для получения и разбора сертификатов SSL-сессий.

  3. Мой самописный C-модуль к NGINX и Lua-биндинг lua-resty-getorigdest-module для получения информации об IP и порте того, куда изначально обращался клиент.

  4. lua-struct для парсинга бинарных пакетов (в частности, поиска сертификата после ServerHello).

  5. Мой форк SOCKS5 Lua-клиента lua-resty-socks5 уважаемого @starius, которому была добавлена возможность соединяться через SOCKS5 не только по хостнейму, но и по IP-адресу.

  6. SOCKS5 прокси-сервер для проксирования заблокированного трафика - например, socks-прокси TOR-а, либо обыкновенный ssh -D. Для VPN-сервера - надо установить его на самом VPN-сервере и проксировать через него. Настройка socks-прокси выходит за рамки этой статьи.

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

Подготовка и сборка

Предполагаю, что сборку осуществляем в /home/username/build. Устанавливать будем в /opt/nginxdpi

  1. Разархивируем тарболл openresty, делаем git clone всем указанным выше дополнениям. Для удобства переименовываем папку openresty-X.Y.Z.V в openresty.

  2. Переходим в /home/username/build/openresty, выполняем:

    ./configure --prefix=/opt/nginxdpi --with-cc=gcc \
    --add-module=/home/username/build/lua-resty-openssl-aux-module \
    --add-module=/home/username/build/lua-resty-openssl-aux-module/stream \
    --add-module=/home/username/build/lua-resty-getorigdest-module/src

  3. Выполняем make -j4 && make install, ждём пока всё соберётся...

  4. После сборки копируем:

cp -r /home/username/build/lua-resty-getorigdest-module/lualib/* /opt/nginxdpi/lualib/ cp -r /home/username/build/lua-resty-openssl/lib/resty/* /opt/nginxdpi/lualib/resty/cp -r /home/username/build/lua-resty-openssl-aux-module/lualib/* /opt/nginxdpi/lualib/cp /home/username/build/lua-resty-socks5/socks5.lua /opt/nginxdpi/lualib/resty/cp /home/username/build/lua-struct/src/struct.lua /opt/nginxdpi/lualib/

Готово! Можно приступать к конфигурированию.

Конфигурация

Вся логика содержится в следующем конфигурационном файле:

nginx.conf
user root;worker_processes auto;events {    worker_connections 1024;}stream {    preread_buffer_size 16k;    server {        listen 30443 so_keepalive=on;        tcp_nodelay on;        #error_log /opt/nginxdpi/cfg/nginx/error.log info;        error_log off;        lua_socket_connect_timeout 2s;        ssl_preread on;        content_by_lua_block {            local prefer_hosts = false;            local prefer_socks_hosts = true;            local host = nil;                                local socket = ngx.req.socket(true);            socket.socktype = "CLIENT";            local god = require("resty.getorigdest");            local dest = god.getorigdest(socket);            local sni_name = ngx.var.ssl_preread_server_name;            ngx.log(ngx.DEBUG, dest);            ngx.log(ngx.DEBUG, sni_name);            local openssl = require("resty.openssl");            openssl.load_modules();            local ngx_re = require("ngx.re");            local cjson = require("cjson");            local socks5 = require("resty.socks5");            local struct = require("struct");            local dests = ngx_re.split(dest, ":");            local dest_addr = dests[1];            local dest_port = tonumber(dests[2]);            local connect_type_last = nil;            local socket_create_with_type = function(typename)                local target = ngx.socket.tcp();                target.socktype = typename;                return target;            end            local socket_connect_dest = function(target)                local ok = nil;                local err = nil;                if (prefer_hosts == true and host ~= nil) then                    ok, err = target:connect(host, dest_port);                    connect_type_last = "host";                    if (err ~= nil) then                        local socktype = target.socktype;                        target = socket_create_with_type(socktype);                        ok, err = target:connect(dest_addr, dest_port);                        connect_type_last = "ip";                    end                else                    ok, err = target:connect(dest_addr, dest_port);                    connect_type_last = "ip";                    if (err ~= nil and host ~= nil) then                        local socktype = target.socktype;                        target = socket_create_with_type(socktype);                        ok, err = target:connect(host, dest_port);                        connect_type_last = "host";                    end                end                if (ok == nil and err == nil) then                    err = "failure";                end                return target, err;            end            local intercept = false;            local connected = false;            local upstream = socket_create_with_type("UPSTREAM");            local bufsize = 1024*16;            local peek, err, partpeek = socket:receiveany(bufsize);            if (peek == nil and partpeek ~= nil) then                peek = partpeek;            elseif (err ~= nil) then                ngx.log(ngx.WARN, err);            end            if (dest_port == 80 or ngx.re.match(peek, "(^GET \\/)|(HTTP\\/1\\.0[\\r\\n]{1,2})|(HTTP\\/1\\.1[\\r\\n]{1,2})") ~= nil) then                local http_host_find, err = ngx.re.match(peek, "[\\r\\n]{1,2}([hH][oO][sS][tT]:[ ]?){1}(?<host>[0-9A-Za-z\\-\\.]+)[\\r\\n]{1,2}");                local http_host = nil;                if (http_host_find ~= nil and http_host_find["host"] ~= false) then                    http_host = http_host_find["host"];                end                if (http_host ~= nil and host == nil) then                    host = http_host;                end                upstream = socket_connect_dest(upstream);                local ok, err = upstream:send(peek);                if (err ~= nil) then                    ngx.log(ngx.WARN, err);                end                local data, err, partdata = upstream:receiveany(bufsize);                if (data == nil and partdata ~= nil) then                    data = partdata;                elseif (err ~= nil) then                    ngx.log(ngx.WARN, err);                end                if (data ~= nil) then                    local match = "HTTP/1.1 307 Temporary Redirect\r\nLocation: http://m.megafonpro.ru/rkn";                    local match_len = string.len(match);                    local extract = data:sub(1, match_len);                    if (match == extract) then                        upstream:close();                        upstream = socket_create_with_type("UPSTREAM");                        intercept = true;                    else                        connected = true;                        local ok, err = socket:send(data);                        if (err ~= nil) then                            ngx.log(ngx.WARN, err);                        end                        peek = nil;                    end                end            elseif (dest_port == 443 or sni_name ~= nil) then                local serv_host = nil;                                if (sni_name ~= nil and host == nil) then                    host = sni_name;                end                                local err = nil;                upstream, err = socket_connect_dest(upstream);                ngx.log(ngx.DEBUG, err);                                local ok, err = upstream:send(peek);                if (err ~= nil) then                    ngx.log(ngx.WARN, err);                end                                -- Parsing the ServerHello packet to retrieve the certificate                local offset = 1;                local data = "";                local size = 0;                local servercert = nil;                upstream:settimeouts(2000, 60000, 1000);                while (servercert == nil) do                    if (size == 0 or offset >= size) then                        local data2, err, partdata = upstream:receiveany(bufsize);                        if (data2 ~= nil) then                            data = data .. data2;                        elseif (data2 == nil and partdata ~= nil) then                            data = data .. partdata;                        elseif (err ~= nil) then                            ngx.log(ngx.WARN, err);                            break;                        end                        size = data:len();                        ngx.log(ngx.DEBUG, "UPSTREAM received for ServerHello certificate retrieval! "..size);                    end                    ngx.log(ngx.DEBUG, offset);                    if (offset < size) then                        local contenttype, version, length, subtype = struct.unpack(">BHHB", data, offset);                        if (contenttype ~= 22) then                            -- We got something other than handshake before we retrieved the cert, probably the server is sending the cert encrypted, fallback to legacy cert retrieval                            break;                        elseif (subtype ~= 11) then                            offset = offset + 5 + length;                        else                            local suboffset = offset + 5;                            local _, _, _, _, certslength, _, firstcertlength = struct.unpack(">BBHBHBH", data, suboffset);                            -- We need only the first cert, we don't care about the others in the chain                            local firstcert = data:sub(suboffset + 1 + 3 + 3 + 3, firstcertlength);                            servercert = firstcert;                        end                    end                end                upstream:settimeouts(2000, 60000, 60000);                                local cert = nil;                if (servercert ~= nil) then                    cert = openssl.x509.new(servercert, "DER");                    ngx.log(ngx.DEBUG, "Cert retrieved from ServerHello peeking");                else                    -- We employ a legacy method of gathering the certificate, involving connecting to the server and doing a SSL handshake by ourselves                    local serv = socket_create_with_type("TLSCHECK");                    local err = nil;                    serv, err = socket_connect_dest(serv);                    ngx.log(ngx.DEBUG, err);                    local session, err = serv:sslhandshake(false, sni_name, false, false);                    ngx.log(ngx.DEBUG, err);                    local sslsess, err = openssl.ssl.from_socket(serv);                    ngx.log(ngx.DEBUG, err);                    if (sslsess ~= nil) then                        cert = sslsess:get_peer_certificate();                        ngx.log(ngx.DEBUG, "Cert retrieved from secondary handshake");                    end                    serv:close();                end                                -- Parsing the certificate                if (cert ~= nil) then                    local sub = cert:get_subject_name();                    local alt = cert:get_subject_alt_name();                    for k, obj in pairs(sub) do                        ngx.log(ngx.DEBUG, k.." "..cjson.encode(obj));                        if (serv_host == nil and k == "CN" and obj.blob:find("*", 1, true) == nil) then                            serv_host = obj.blob;                        end                        if (k == "CN" and obj.blob == "megafon.ru" and (sni_name == nil or sni_name:find("megafon.ru", 1, true) == nil)) then                            ngx.log(ngx.DEBUG, k.." "..obj.blob);                            upstream:close();                            upstream = socket_create_with_type("UPSTREAM");                            intercept = true;                                                    end                    end                    for k, obj in pairs(alt) do                        ngx.log(ngx.DEBUG, k.." "..cjson.encode(obj));                        if (serv_host == nil and k == "DNS" and obj:find("*", 1, true) == nil) then                            serv_host = obj;                        end                    end                end                if (serv_host ~= nil and host == nil) then                    host = serv_host;                end                                if (intercept ~= true) then                    connected = true;                    local ok, err = socket:send(data);                    if (err ~= nil) then                        ngx.log(ngx.WARN, err);                    end                    peek = nil;                end            end            if (connected == false and intercept == false) then                local err = nil;                upstream, err = socket_connect_dest(upstream);                if (err ~= nil) then                    intercept = true;                    upstream = socket_create_with_type("UPSTREAM");                end            end            if (intercept == true) then                local ok, err = upstream:connect("192.168.120.1", 45213);                ngx.log(ngx.DEBUG, err);                ok, err = socks5.auth(upstream);                ngx.log(ngx.DEBUG, err);                local ok = nil;                local err = nil;                if (prefer_socks_hosts == true and host ~= nil) then                    ok, err = socks5.connect(upstream, host, dest_port);                    connect_type_last = "socks_host";                    if (err ~= nil) then                        upstream = socket_create_with_type("UPSTREAM");                        upstream:connect("192.168.120.1", 45213);                        ok, err = socks5.auth(upstream);                        ok, err = socks5.connect_ip(upstream, dest_ip, dest_port);                        connect_type_last = "socks_ip";                    end                else                    ok, err = socks5.connect_ip(upstream, dest_addr, dest_port);                    connect_type_last = "socks_ip";                    if (err ~= nil and host ~= nil) then                        upstream = socket_create_with_type("UPSTREAM");                        upstream:connect("192.168.120.1", 45213);                        ok, err = socks5.auth(upstream);                        ok, err = socks5.connect(upstream, host, dest_port);                        connect_type_last = "socks_host";                    end                end                ngx.log(ngx.DEBUG, err);            end            upstream:setoption("keepalive", true);            upstream:setoption("tcp-nodelay", true);            upstream:setoption("sndbuf", bufsize);            upstream:setoption("rcvbuf", bufsize);            ngx.log(ngx.INFO, "RESULT: "..tostring(host).."/"..dest_addr..":"..dest_port.." intercept:"..tostring(intercept).." connecttype:"..connect_type_last);            local ok = false;            if (peek ~= nil and peek:len() > 0) then                ok, err = upstream:send(peek);                if (err ~= nil) then                    ngx.log(ngx.WARN, err);                end            else                ok = true;            end            local pipe = function(src, dst)                while true do                    local data, err, partial = src:receiveany(bufsize);                    local errs = nil;                    local ok = false;                    if (data ~= nil) then                        ok, errs = dst:send(data)                    elseif (data == nil and partial ~= nil) then                        ok, errs = dst:send(partial)                    elseif (err == 'closed') then                        ngx.log(ngx.WARN, src.socktype..":"..err);                        return;                    elseif (err ~= nil and err ~= "timeout") then                        ngx.log(ngx.WARN, src.socktype..":"..err);                    end                    if (errs == 'closed') then                        ngx.log(ngx.WARN, dst.socktype..":"..errs);                        return;                    elseif (errs ~= nil) then                        ngx.log(ngx.WARN, dst.socktypeerr..":"..errs);                    end                end            end            if (ok ~= false) then                local co_updown = ngx.thread.spawn(pipe, upstream, socket);                local co_downup = ngx.thread.spawn(pipe, socket, upstream);                ngx.thread.wait(co_updown);                ngx.thread.wait(co_downup);            end            upstream:close();            ngx.flush(true);            socket:shutdown("send");        }    }}

Можно было бы наверное разделить lua код от конфига, но мне было немного лень =)

Замените 192.168.120.1 и 45213 на хост и порт вашего SOCKS5-сервера!

Размещаем его в /opt/nginxdpi/cfg/nginx.conf

Создаём файл /opt/nginxdpi/cfg/start.sh со следующим содержимым:

start.sh
#!/bin/sh/opt/nginxdpi/bin/openresty -c /opt/nginxdpi/cfg/nginx.confiptables -t nat -A PREROUTING -i br0 -p tcp -m tcp --dport 80 -j REDIRECT --to-ports 30443iptables -t nat -A PREROUTING -i br0 -p tcp -m tcp --dport 443 -j REDIRECT --to-ports 30443

Обратите внимание, что br0 - это интерфейс локальной сети, с которого подключаются клиенты!

Даём start.sh права на выполнение chmod +x /opt/nginxdpi/cfg/start.sh, и наконец запускаем всё это добро (от рута! В принципе теоретически может заработать и без рута, но я не пробовал...):

/opt/nginxdpi/cfg/start.sh

После этого весь ваш HTTP и HTTPS трафик будет проксироваться через этот сервер.

Что можно сделать ещё?

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

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

Во-вторых, пока что совершенно не поддерживается IPv6. Добавить поддержку на самом деле несложно... С другой стороны, как правило IPv6 трафик сейчас фильтруется редко (если вообще где-то фильтруется).

В-третьих, опять таки не поддерживается UDP. И если на текущем этапе это не столь критично (мало что по UDP сейчас блокируется), то с развитием HTTP3/QUIC эта проблема будет гораздо более критичной. Правда как проксировать QUIC я пока понятия не имею, да и DTLS отличается от TLS... Там будет хватать своих проблем. Это, наверное, самая сложная задача.

И в завершение...

На самом деле этот механизм можно использовать и для полноценного DPI. По сути мы получаем полноценную TCP-сессию, которую можем инспектировать "на лету" - достаточно, по сути, пропатчить pipe-функцию. С другой стороны, смысла в анализе сырых данных после завершения SSL-handshake нынче мало, а зашифрованного трафика становится только больше...

Этот же метод в принципе позволяет и записывать трафик - закон Яровой исполнять, например. Надеюсь я не открыл сейчас ящик Пандоры... =)

...вдруг кому-то из провайдеров этот метод позволит сэкономить на DPI-софте и уменьшить цену тарифов? =) Кто знает.

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

Желаю удачи в адаптации под своих провайдеров!

Подробнее..

DDoS атаки на 7 уровень защита сайтов

09.05.2021 22:19:11 | Автор: admin
DDoS атаки на 7 уровень (на уровень приложения) наиболее простой способ привести в нерабочее состояние сайт и навредить бизнесу. В отличие от атак на другие уровни, когда для отказа сайта необходимо организовать мощный поток сетевого трафика, атаки на 7 уровень могут проходить без превышения обычного уровня сетевого трафика. Как это происходит, и как от этого можно защищаться я рассмотрю в этом сообщении.

Атаки 7 уровня на сайты включают атаки на уровень веб-сервера (nginx, apache и т.д.) и атаки на уровень сервера приложений (php-fpm, nodejs и т.д.), который, как правило, расположен за проксирующим сервером (nginx, apache и т.д.). С точки зрения сетевых протоколов, оба варианта являются атакой на уровень приложения. Но нам, с практической точки зрения, нужно разделить эти два случая. Веб-сервер (nginx, apache и т.д.), как правило, самостоятельно отдает статические файлы (картинки, стили, скрипты), а запросы на получение динамического контента проксирует на сервер приложений (php-fpm, nodejs и т.д.). Именно эти запросы становятся мишенью для атак, так как в отличие от запросов статики, серверы приложений при генерировании динамического контента требуют на несколько порядков больше ограниченных системных ресурсов, чем и пользуются атакующие.

Как ни банально звучит, чтобы защититься от атаки, ее нужно сначала выявить. На самом деле, к отказу сайта могут привести не только DDoS атаки, но и другие причины, связанные с ошибками разработчиков и системных администраторов. Для удобства анализа, необходимо добавить в формат логов nginx (сорри, варианта с apache у меня нет) параметр $request_time, и логировать запросы к серверу приложений отдельный файл:

      log_format timed '$remote_addr - $remote_user [$time_local] '            '$host:$server_port "$request" $status $body_bytes_sent '            '"$http_referer" "$http_user_agent" ($request_time s.)';      location /api/ {           proxy_pass http://127.0.0.1:3000;           proxy_http_version 1.1;           proxy_set_header Upgrade $http_upgrade;           proxy_set_header Connection 'upgrade';           proxy_set_header Host $host;           proxy_cache_bypass $http_upgrade;           proxy_set_header X-Real-IP $remote_addr;           proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;           proxy_set_header X-NginX-Proxy true;           access_log /var/log/ngunx/application_access.log timed;       }


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

Выявив атаку можно, приступать к защите.

Очень часто, системные администраторы пытаются защитить сайт, ограничив количество запросов с одного IP-адреса. Для этого используют 1) Директиву limit_req_zone nginx (см. документацию), 2) fail2ban и 3) iptables. Безусловно, эти способы нужно использовать. Однако, такой способ защиты уже как 10-15 лет является малоэффективным. На это есть две причины:

1) Трафик, который генерирует сеть ботов при атаке на 7 уровень, по своему объему может быть меньше чем трафик обычного посетителя сайта, так как у обычного посетителя сайта на один тяжелый запрос к серверу приложений (php-fpm, nodejs и т.д.) приходится примерно 100 легких запросов на загрузку статических файлов, которые отдаются веб-сервером (nginx, apache и т.д.). От таких запросов iptables не защищает, так как может ограничивать трафик только по количественным показателям, и не учитывает разделение запросов на статику и динамику.

2) Вторая причина распределённость сети ботов (первая буква D в аббревиатуре DDoS). В атаке обычно принимает участие сеть из нескольких тысяч ботов. Они в состоянии делать запросы реже, чем обычный пользователь. Как правило, атакуя сайт, злоумышленник опытным путем вычисляет параметры limit_req_zone и fail2ban. И настраивает сеть ботов так, чтобы эта защита не срабатывала. Часто, системные администраторы начинают занижать эти параметры, отключая таким образом реальных клиентов, при этом без особого результата с точки зрения защиты от ботов.

Для успешной защиты сайта от DDoS, необходимо, чтобы на сервере были задействованы в комплексе все возможные средства защиты. В моем предыдущем сообщении на эту тему Защита от DDoS на уровне веб-сервера есть ссылки на материалы, как настроить iptables, и какие параметры ядра системы нужно привести в оптимальное значение (имеется в виду, прежде всего, количество открытых файлов и сокетов). Это является предпосылкой, необходимым, но не достаточным условием для защиты от ботов.

Кроме этого необходимо построить защиту, основанную на выявлении ботов. Все, что нужно для понимания механики выявления ботов, было подробно описано в исторической статье на Хабре Модуль nginx для борьбы с DDoS автора kyprizel, и реализована в библиотеке этого же автора testcookie-nginx-module

Это библиотека на С, и она продолжает развиваться небольшим сообществом авторов. Наверное, не все системные администраторы готовы на продакшин сервере прикомпилировать незнакомую библиотеку. Если же потребуется дополнительно внести изменения в работу библиотеки то это и вовсе выходит за рамки рядового системного администратора или разработчика. К счастью, в настоящее время появились новые возможности: скриптовый язык Lua, который может работать на сервере nginx. Есть две популярные сборки nginx со встроенным скриптовым движком Lua: openresty, разработка которого изначально споснировалась Taobao, затем Cloudfare, и nginx-extras, который включен в состав некоторых дистирибутивов Linux, например Ubuntu. Оба варианта используют одни и те же библиотеки, поэтому не имеет большой разницы, который из них использовать.

Защита от ботов может быть основана на определении способности веб-клиента: 1) выполнять код JavaScript, 2) делать редиректы и 3) устанавливать cookie. Из всех этих способов, выполнение кода JavaScript оказалось наименее перспективным, и от него я отказался, так как код JavaScript не выполняется, если контент загружается фоновыми (ajax) запросами, а повторная загрузка страницы средствами JavaScript искажает статистику переходов на сайт (так как теряется загловок Referer). Таким образом, остаются редиректы которые устанавливают cookie, значения котрых подчиняются логике, которая не может быть воспроизведена на клиенте, и не допускают на сайт клиентов без этих cookie.

В своей работе я основывался на библиотеке leeyiw/ngx_lua_anticc, которая в настоящее время не развивается, и я продолжил доработки в своем форке apapacy/ngx_lua_anticc, так как работа оригинальной библиотеки не во всем устраивала.

Для работы счетчиков запросов в библиотеке используются таблицы памяти, которые поддерживают удобные для наращивания значения счетчиков методы incr, и установку значений с TTL. Например, в приведенном ниже фрагменте кода наращивается значение счетчика запросов с одного IP-адреса, если у клиента не установлены cookie с определенным именем. Если счетчик еще не был инициализирован, он инициализируется значением 1 с TTL 60 секунд. После превышения количества запросов 256 (за 60 секунд), клиент на сайт не допускается:

local anticc = ngx.shared.nla_anticclocal remote_id = ngx.var.remote_addrif not cookies[config.cookie_name] then  local count, err = anticc:incr(remote_id, 1)  if not count then    anticc:set(remote_id, 1, 60)    count = 1   end   if count >= 256 then     if count == 256 then       ngx.log(ngx.WARN, "client banned by remote address")     end     ngx.exit(444)     return  endend


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

local whitelist = ngx.shared.nla_whitelistin_whitelist = whitelist:get(ngx.var.remote_addr)if in_whitelist then    returnend


Но не всегда это возможно. Одной из проблем является неопределенность с адресами ботов Google. Пропускать всех ботов подделывающихся под боты Google, равносильно снятию защиты с сайта. Поэтому, воспользуемся модулем resty.exec для выполнения команды host:

local exec = require 'resty.exec'if ngx.re.find(headers["User-Agent"],config.google_bots , "ioj") then    local prog = exec.new('/tmp/exec.sock')    prog.argv = { 'host', ngx.var.remote_addr }    local res, err = prog()    if res and ngx.re.find(res.stdout, "google") thenngx.log(ngx.WARN, "ip " .. ngx.var.remote_addr .. " from " .. res.stdout .. " added to whitelist")whitelist:add(ngx.var.remote_addr, true)        return    end    if res then        ngx.log(ngx.WARN, "ip " .. ngx.var.remote_addr .. " from " .. res.stdout .. "not added to whitelist")    else        ngx.log(ngx.WARN, "lua-resty-exec error: " .. err)    endend


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

apapacy@gmail.com
9 мая 2021 года
Подробнее..

Категории

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

  • Имя: Макс
    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