- BrainTools - https://www.braintools.ru -
Если вы хоть раз встраивали Lua в свой проект — будь то игровой движок, высоконагруженный веб-сервер на OpenResty [1] или конфигуратор сложного сетевого оборудования — вы знаете, за что мы его любим:)
А любим мы его — за компактность, быстроту, встраиваемость и предсказуемость. Не любим — за аскетичный синтаксис, отсутствие привычных конструкций и постоянное «изобретение велосипеда».
Эта статья — обзор диалекта Lattelua [2]: зачем он нужен, чем отличается от других диалектов, и почему его особенно удобно использовать в уже существующих проектах, где Lua — встраиваемый язык.
Lua считается языком конфигов, описания данных и встраиваемых систем, но я смотрю на него немного по-другому, он больше похож на библиотеку или фреймворк над Си, для его усиления или расширения, как бы странно это ни звучало.
Стековая машина в Lua API — это действительно «низкоуровневый» протокол связи, чем-то напоминает assembler, и такой, на первый взгляд, простой подход на практике дает нехилый boost в интерпретации, что повышает эффективность исполнения.
Но давайте честно: когда логика [3] разрастается, синтаксис Lua начинает напоминать попытку собрать вертолёт с помощью одной отвёртки и такой-то матери. Бесконечные then/end/local, отсутствие нормальных классов и врожденная «немногословность» превращают поддержку кода в квест.
Да и чего уж греха таить, хочется, очень хочется, обложиться всякими синтаксически-сахарными плюшками, к примеру, как в том же Python. И именно из-за этой потребности [4] и появляются MetaLua [5], Fennel [6] и MoonScript [7], каждый с копированием в сторону симпатизируемого языка.
Lattelua [2] же, напротив, он как CoffeeScript [8], другой диалект того же Lua.
Lattelua [2] – это попытка собрать все лучшее из best-practice [9], взяв за основу парсер MoonScript, своего рода дружелюбный Lua. Но почему не MoonScript в оригинале, зачем изобретать новый диалект если все уже придумано до нас?
Основная претензия к MoonScript — это его «питоноподобность»:
В MoonScript, как и в Python отступы — это закон, малейшая ошибка [10] в отступах (особенно при смешивании пробелов и табуляции) приводит к синтаксическим ошибкам, которые крайне тяжело отловить, так как компилятор может интерпретировать блок кода как часть другой логической ветки. В Lua четкая блочная система, в MoonScript — мы гадаем по пустому пространству.
Сложность работы с анонимными функциями, MoonScript пытается это решить, но вложенные блоки внутри аргументов функций там выглядят как «лесенка», в которой легко запутаться.
API и минификация, код на Python/Moonscript невозможно минифицировать без потери смысла, так как пробелы — это и есть синтаксис. Любое искажение форматирования при пересылке «убивает» программу.
Метапрограммирование и кодогенерация, если нужно генерировать код на лету, то генерировать корректные отступы — это лишняя и сложная головная боль [11]. Намного проще просто выплевывать токены и ставить end в нужных местах.
Да чё я всё это перечисляю, все кому надо уже и так все знают. Я не хочу сказать что python-style — это плохо, плохая идея модифицировать в него язык с блочной разметкой. Куда проще сократить синтаксическую неуклюжесть (then/end, do/end, function/end) до блочного стиля, повысив «многословность» новыми синтаксическими конструкциями.
В Lattelua отступы не имеют значения. Группировка выражений происходит с помощью { и }. Символ ; используется как разделитель инструкций, что позволяет писать код в одну строку:
-- Обычная запись
if true {
print("Hello")
}
-- Однострочная запись
if true { print("Hello") }
Комментарии игнорируются компилятором. Символ ; внутри комментариев и строк не обрабатывается препроцессором:
-- Это однострочный комментарий
--[[
Это многострочный комментарий.
Он работает точно так же, как в Lua.
В MoonScript такой тип комментариев не поддерживается!
--]]
По умолчанию все переменные являются локальными (local):
a = 1 -- local a = 1
str = "hello" -- local str = "hello"
x, y = 10, 20 -- local x, y = 10, 20
Доступны операторы быстрого обновления значений: +=, -=, *=, /=, %=, ..=:
count = 0
count += 1 -- count = count + 1
name = "Lattelua"
name ..= " Lang" -- Конкатенация
Чтобы создать глобальную переменную или экспортировать её из модуля, используется ключевое слово export:
export VERSION = "1.0"
Это особенно полезно при объявлении того, что будет видно извне в модуле:
-- some module.llua
export some_print
add = (x, y) -> { x + y }
some_print = (x, y) -> {print "Addition is: ", add x, y}
-- some script.llua
require "some_module"
some_module.some_print 5, 10 -- 15
print some_module.add 5, 10 -- errors, `add` not visible
Экспорт не будет иметь эффекта, если в области видимости уже есть локальная переменная с таким же именем.
В контексте переменных, часто требуется перенести некоторые значения из таблицы/модуля в текущую область как локальные переменные по их имени.
Для этого используется ключевое слово import:
import insert from table -- local insert = table.insert
Можно указать несколько имен, каждое через запятую:
import C, Ct, Cmt from lpeg -- local C, Ct, Cmt = lpeg.C, lpeg.Ct, lpeg.Cmt
Иногда требуется, чтобы таблица была передана в качестве self-аргумента. Для сокращения можно добавить префикс :: к имени, чтобы связать функцию с этой таблицей:
t = {
val: 100
add: (value) => {
self.val + value
}
}
import ::add from t
print add 22 -- equivalent to add(t, 22) or t::add(22)
num = 123
float = 1.5
str_double = "Text"
str_single = 'Text'
str_multi = [[
multi line
text
]]
bool = true
nothing = nil
Допускается смешивать выражения со строковыми литералами, используя #{} синтаксис:
print "This is #{math.random() * 100}% work, I'm sure" -- print("This is " .. tostring(math.random() * 100) .. "% work, I'm sure")
Интерполяция строк доступна только в строках, заключенных в двойные кавычки.
Как и в Lua, таблицы заключаются в фигурные скобки:
array = { 1, 2, 3, 4 }
В отличие от Lua, присвоение значения ключу в таблице выполняется с помощью : (вместо =):
config = {
port: 8080,
host: "localhost",
list: { 1, 2, 3 },
["key with spaces"]: "some value"
}
Перевод строки можно использовать для разделения значений вместо запятой (или и то, и другое):
config = {
port: 8080
host: "localhost"
list: { 1, 2, 3 }
["key with spaces"]: "some value"
}
Ключи таблицы могут быть ключевыми словами языка без экранирования:
t = {
do: "do"
end: "end"
}
Если создается таблица из переменных и требуется, чтобы ключи совпадали с именами переменных, можно использовать префиксный оператор ::
gender = "male"
age = 25
person = {
:gender -- gender: gender
:age -- age: age
key: "value" -- key: "value"
}
print :gender, :age -- {gender: gender, age: age}
Если требуется, чтобы ключ был результатом выражения, можно обернуть его в [], как и в Lua. Также возможно использовать строковый литерал непосредственно в качестве ключа, исключая квадратные скобки. Это полезно, если ключ содержит специальные символы:
t = {
[1 + 2]: "three",
["some value"]: true,
"another some value": false
}
Деструктуризация – это способ быстрого извлечения значений из таблицы по их имени или положению в таблицах на основе массива.
vec = { x: 10, y: 20, z: 30 }
{ :x, :y } = vec
print(x, y) -- 10 20
arr = {1, 2, 3}
{f,_,t} = arr
print f, t -- 1 3
Это также работает с вложенными структурами данных:
obj = {
array: {1, 2, 3, 4}
properties: {
align: "center"
vec: { x: 10, y: 20, z: 30 }
}
}
{
array: { first, second }
properties: {
:align
vec: { :x, :y }
}
} = obj
-- first, second, align, x, y = obj.array[1], obj.array[2], obj.properties.align, obj.properties.vec.x, obj.properties.vec.y
Обычно значения из таблицы извлекаются и присваиваются локальным переменным, имеющим то же имя, что и ключ. Чтобы избежать повторения [12], возможно использовать префиксный оператор ::
{:concat, :insert} = table -- local concat, insert = table.concat, table.insert
По сути, это то же самое, что и import, но мы можем переименовать поля, которые хотим извлечь:
{:mix, :max, random: rand } = math -- local mix, max, rand = math.mix, math.max, math.random
Деструктуризация также может проявляться в тех местах, где неявно выполняется присваивание:
array = {
{1, 2, 3, 4}
{5, 6, 7, 8}
}
for {first, second} in *array {
print first, second -- 1 2 & 5 6
}
Генераторы предоставляют удобный синтаксис для создания новой таблицы путем итерации по некоторому существующему объекту и применения выражения к его значениям.
Существует два типа генераторов: генератор списка и генератор таблицы.
Они оба создают таблицы Lua.
Генераторы списков накапливают значения в таблицу, подобную массиву, а генераторы таблиц позволяют устанавливать как ключ, так и значение на каждой итерации.
Следующий пример создаёт копию таблицы элементов, с удвоенными значениями:
array = { 1, 2, 3, 4 }
doubled = [item * 2 for i, item in ipairs array] -- doubled = { 2, 4, 6, 8 }
Элементы, включенные в новую таблицу, можно ограничить с помощью when выражения:
iter = ipairs array
slice = [item for i, item in iter when i > 1 and i < 3] -- slice = { 2 }
Операторы for и when возможно объединять в цепочки сколько угодно. Единственное требование, чтобы в выражении был хотя бы один оператор for.
Использование нескольких операторов for аналогично использованию вложенных циклов:
x = {4, 5, 6, 7}
y = {9, 2, 3}
points = [{x,y} for x in ipairs x for y in ipairs y]
Синтаксис генератора таблиц очень похож, отличается только использованием {} и получением двух значений на каждой итерации:
t ={
gender: "male",
age: 25
}
copy = {k,v for k,v in pairs t}
Генераторы таблиц, как и генераторы списков, также поддерживают несколько операторов for и when:
copy = {k,v for k,v in pairs t when k != "gender"}
if x > 10 {
print("Big")
} elseif x == 10 {
print("Equal")
} else {
print("Small")
}
-- Unless (если НЕ)
unless ready {
init()
}
-- Тернарный оператор / Однострочный if
val = if check { true; } else { false; }
Условные выражения также можно использовать в операторах возврата и присваиваниях:
test = (c)->{
if c {
true
} else {
false
}
}
out = if test true {
"is true"
} else {
"is false"
}
print out -- "is true"
Использует ключевое слово case для веток и else для значения по умолчанию:
value = 2
switch value {
case 1
print("One")
case 2
print("Two")
case 1,2,3
print "One..Three"
else
print("Other")
}
switch также можно использовать в качестве выражения, тем самым присвоить результат switch переменной:
out = switch value {
case 1
"One"
case 2
"Two"
case 1,2,3
"One..Three"
else
"Other"
}
print out -- "Two"
-- Без шага
for i = 1, 10 {
print(i) -- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
}
-- С шагом
for i = 0, 10, 2 {
print(i) -- 0, 2, 4, 6, 8, 10
}
t = { a: 1, b: 2 }
for k, v in pairs(t) {
print(k, v)
}
Цикл for также можно использовать в качестве выражения. Последний оператор тела цикла преобразуется в выражение и добавляется в таблицу накопительного массива.
Удвоение каждого четного числа:
doubled = for i=1,20 {
if i % 2 == 0 {
i * 2
} else {
i
}
}
print i for _,i in ipairs doubled -- 4, 8, 12, 16, 20, 24, 28, 32, 36, 40
Также возможно фильтровать значения, комбинируя выражения цикла for с оператором continue [13].
Циклы for в конце тела функции не накапливаются в таблице для возвращаемого значения (вместо этого функция вернет nil).
Возможно использовать явный оператор возврата, либо цикл можно преобразовать в генератор списка.
funca = -> {for i=1,10 {i}}
funcb = -> {return [i for i=1,10] }
print funca() -- prints nil
print funcb() -- prints table object
Это сделано для того, чтобы избежать ненужного создания таблиц для функций, которым не нужно возвращать результаты цикла.
Цикл while также существует в двух вариантах:
i = 10
while i > 0 {
print i
i -= 3
}
while running == true {some_func()}
Как и в случае с for, в цикле while также можно использовать выражение. Кроме того, чтобы функция возвращала накопленное значение цикла while, оператор должен быть возвращен явно.
Оператор break прерывает цикл (while или for), в теле которого встречается. В результате выполнения оператора break управление передаётся первой инструкции, следующей непосредственно за оператором цикла.
i = 0
while i < 10 {
break if i > 5
print i
i += 1
}
Оператор continue можно использовать для пропуска текущей итерации в цикле.
i = 0
while i < 10 {
continue if i % 2 == 0
print i
i += 1
}
Также continue можно использовать с выражениями цикла, чтобы предотвратить накопление этой итерации в результате.
В этом примере массив фильтруется только по четным числам:
array = {1,2,3,4,5,6}
odds = for x in ipairs array {
continue if x % 2 == 1
x
}
Все функции создаются с использованием функционального выражения. Простая функция обозначается стрелкой: ->
some_func = ->
some_func() -- call that empty function
Тело функции может представлять собой либо один оператор, либо серию, помещенных непосредственно в блок фигурных скобок, сразу после стрелки:
funca = -> {print "hello world"}
funcb = -> {
message = "world"
print "hello #{message}"
}
Если функция не имеет аргументов, ее можно вызвать с помощью оператора !, вместо пустых круглых скобок. ! вызов – предпочтительный способ вызова функций без аргументов.
funca!
funcb()
Функции с аргументами можно создать, указав перед стрелкой список имен аргументов в круглых скобках:
sum = (a, b) -> {
return a + b
}
Для аргументов функции можно указать значения по умолчанию. Аргумент считается пустым, если его значение равно нулю. Любые нулевые аргументы, имеющие значение по умолчанию, будут заменены перед запуском тела функции.
greet = (name = "World") -> {
print("Hello " .. name)
}
Значения аргументов по умолчанию вычисляется в теле функции в порядке объявления аргументов. Именно по этой причине значения по умолчанию имеют доступ к ранее объявленным аргументам.
(x=100, y=x+1000) -> {
print x + y
}
Функции можно вызывать, перечисляя аргументы после имени выражения, результатом которого является функция. При объединении вызовов функций аргументы применяются к ближайшей функции слева.
sum 10, 20 -- sum(10, 20)
print sum 10, 20 -- print(sum(10, 20))
a b c "a", "b", "c" -- a(b(c("a", "b", "c")))
Чтобы избежать двусмысленности при вызове функций, аргументы также можно заключать в круглые скобки. Это необходимо в примере ниже, чтобы гарантировать, что правильные аргументы будут отправлены в правильные функции.
print "x:", sum(10, 20), "y:", sum(30, 40) -- print("x:", sum(10, 20), "y:", sum(30, 40))
Между открывающей скобкой и функцией(sum) не должно быть пробела.
Как и в Lua, функции могут возвращать несколько значений. Последний оператор должен представлять собой список значений, разделенных запятыми:
some_func = (x, y) -> {x + y, x - y}
a, b = some_func 10, 20
Для создания функций предусмотрен специальный синтаксис =>, который автоматически включает аргумент self.
obj = {
val: 10
update: (num) => {
self.val = num -- self передается автоматически
}
}
obj::update(13)
print obj.val -- val = 13
Для удобства операторы цикла for и if можно применять к отдельным операторам в конце строки:
print "hello world" if 1 == 1
И с базовыми циклами:
print "value: #{v}" for _, v in ipairs {1,2,3,4,5,6}
Класс объявляется с помощью оператора class, за которым следует табличное объявление, в котором перечислены все методы и свойства.
class Animal {
new: (name) => {
self.name = name
}
speak: => {
print(self.name)
}
}
Объявление класса также можно использовать как выражение, которое можно присвоить переменной или вернуть явно.
Метод new, если определён, становится конструктором.
Создание экземпляра класса осуществляется путем вызова имени класса в качестве функции.
dog = Animal "woof woof"
Все свойства класса являются общими для всех экземпляров. Это нормально для методов, но для других типов объектов могут возникнуть нежелательные результаты:
class Animal {
speech: {}
new: (speech) => {
table.insert self.speech, speech
}
speak: (who) => {
print "#{who} say: #{speech}" for _, speech in ipairs self.speech
}
}
dog = Animal "woof"
cat = Animal "meow"
dog::speak("dog") -- will print both `woof` and `meow`
cat::speak("dog") -- will print both `woof` and `meow`
свойство speech является общим для всех экземпляров, поэтому изменения, внесенные в него в одном экземпляре, будут отображаться в другом.
Правильный способ избежать такого поведения [14] – создать изменяемое состояние объекта в конструкторе:
class Animal {
new: (speech) => {
self.speech = {} -- private property for instance
table.insert self.speech, speech
}
speak: (who) => {
print "#{who} say: #{speech}" for _, speech in ipairs self.speech
}
}
Ключевое слово extends можно использовать в объявлении класса для наследования свойств и методов другого класса.
class Dog extends Animal {
new: (speech) => {
super(speech)
}
speak: (who)=> {
print("#{who} say: WOOF")
}
}
Если в подклассе не определен конструктор, то при создании нового экземпляра вызывается конструктор родительского класса.
Если же конструктор определен, то для вызова конструктора родительского класса можно использовать метод super.
super – это специальное ключевое слово, которое можно использовать двумя способами: как объект или как функцию. super обладает функциональностью только внутри класса.
При вызове в качестве функции super вызовет функцию с тем же именем в родительском классе. В качестве первого аргумента автоматически будет передан текущий объект self.
При использовании super в качестве обычного значения, это ссылка на объект родительского класса.
К super можно обращаться как к любому объекту для получения значений в родительском классе.
При наследовании классом наследника, он отправляет сообщение родительскому классу, вызывая метод __inherited родительского класса, если он существует. Метод принимает два аргумента: наследуемый класс и дочерний класс:
class Animal {
__inherited: (child) => {
print "#{self.__name} was inherited by #{child.__name}"
}
}
class Dog extends Animal{}
Блок with позволяет сократить код при множественных обращениях к одному объекту. Внутри блока, свойства начинающиеся с . или методы с ::, относятся к целевому объекту.
user = { name: "John", age: 30 }
user.show = => { print self.name }
with user {
.name ..= " Doe" -- user.name = "John Doe"
::show() -- user:show()
print(.age) -- print(user.age)
}
Оператор with также можно использовать как выражение, возвращающее значение, к которому он предоставил доступ:
name = with user {
.name = 'Jane Smith'
}
name::show() -- Jane Smith
Выражение в операторе with также может быть присвоением, если требуется дать выражению имя:
name = with n = setmetatable{name: user.name},{__index: user} {
.name = 'John Doe'
}
name::show() -- John Doe
user::show() -- Jane Smith
Использование оператора do работает так же, как и в Lua.
do {
msg = "world"
print "hello #{msg}"
}
Оператор do также может использоваться как выражение. Результатом выражения do является последнее выражение в блоке.
print do {
msg = "world"
"hello #{msg}"
}
Блок try используется для обработки исключений. Это позволяет тестировать блок кода на наличие ошибок и корректно обрабатывать их, предотвращая неожиданный сбой программы.
try {
-- Код, который вызывает ошибку
error("Boom!")
} catch {
-- Обработка ошибки (self содержит текст ошибки)
print("Error caught: " .. self)
} finally {
-- Выполняется всегда, если присутствует
print("Cleanup")
}
Оператор try также может использоваться как выражение. Результатом выражения try является последнее выражение в блоках try/catch соответственно.
Основные отличия от MoonScript:
Блочная структура: Использование { и } вместо отступов.
Свободное форматирование: Игнорирование переносов строк и пробелов.
Разделители: Использование ; для разделения инструкций (препроцессор заменяет их на перевод строки).
Синтаксис методов: Оператор :: для вызова методов экземпляра.
switch: Ключевое слово case вместо when.
Обработка ошибок: Встроенная конструкция try/catch/finally.
Множественное наследование, через встраивание: Концепция ООП в MoonScript [7] и в Lua, в частности, не позволяет множественного наследования, точнее в «ванильном» Lua с метатаблицами и рекурсией головного мозга [15], возможно закостылить хоть какую глубину наследования. AST-шаблон такого глубокого колодца реализовать трудно, не невозможно — но трудно. Куда проще использовать паттерн «встраивания», как в незамысловатом GO: просто, дёшево, надёжно.
Встроенные документы: Выполнение Lattelua [2] кода в режиме встроенного документа, в пространстве lua-кода. К примеру, если уже есть тысячи строк кода на Lua, и нет возможности просто всё переписать, можно воспользоваться встроенными документами:
local __latte = require "lattelua"
local mt = {
age = 25
}
local msg = "hello world"
local test = function()
local copy = {}
for k,v in pairs(_G) do
copy[k] = v
end
return copy
end
local RESULT = __latte[[
getupenv(3) -- захватываем вышестоящее окружение, если нужно
print "#{msg}" -- hello world
print "#{mt.age}" -- 25
mt.age = 50
test!
]]
for k,v in pairs(RESULT) do
print(("t%s => %s"):format(tostring(k), tostring(v)))
end
print(mt.age) -- 50
Автономный интерпретатор: llua с возможностью листинга компиляции. За основу взят lua 5.1 интерпретатор, совместимый с версиями Lua 5.1–5.4. REPL-режим на основе { и } поддерживается.
Важно понимать: Lattelua [2] не добавляет новую модель исполнения, он компилируется в Lua используя те же таблицы и те же функции.
На выходе — обычный Lua‑код, который можно отладить, можно профилировать или оптимизировать руками.
Для разработки это огромная разница: маленькие скрипты, меньше визуального шума, и что самое главное, быстрый MVP:) Для больших проектов: меньше глобального состояния, меньше копипасты, как следствие проще рефакторить.
Для встраиваемых систем: не меняется ABI, не появляется второй VM и Lua остаётся главным.
Что ж, настало время показать все прелести Lattelua [2], так сказать на личном примере, никуда без велосипедостроения:)
Будем писать библиотеку, которая разукрасит Lattelua [2] под golang-практики и как водится с псевдосервером для наглядности.
Используемый стек: Lanes [16], Linda [17] и Cqueues [18], результатом будет библиотека упрощённой многопоточности, с её помощью можно насоздавать воркеров, затащить в них кооперативную многозадачность [19], прокинуть между ними каналы и обмениваться сообщениями без всяких там мьютексов (которые там, впрочем, есть).
Встроенный рантайм сам разрулит всё это дело, упрощая работу программиста такими конструкциями, как неблокирующий select, атомарные операции над данными и т.д. и т.п. Погнали …
Архитектура spawn:
Диспетчер: Основной поток Lua, он не блокируется. При вызове spawn он просто сериализует функцию и аргументы и кладет их в очередь задач.
Очередь задач: Центральная шина, через которую задачи передаются воркерам.
У объекта spawn одна центральная Linda bus.
Воркеры: Набор системных потоков Lanes, которые крутятся в бесконечном цикле. Они конкурируют за задачи из tasks. Как только воркер освобождается, он забирает следующую задачу.
lanes = require "lanes"
-- определение task
-- определение channel
spawn = class {
bus = {}
wait = ->{}
generator = {}
new: (config)=>{
lanes.configure(config['core'] or {}) if lanes.configure
bus = lanes.linda!
wait = (n)->{
true, bus::receive(n, "wait/#{os.clock!}/#{math.random!}")
}
self.workers = {}
self.config = config
self.idle = "idle/#{os.clock!}/#{math.random()}"
self.lock = "lock/#{os.clock!}/#{math.random()}"
self.tasks = "tasks/#{os.clock!}/#{math.random()}"
self.active = "active/#{os.clock!}/#{math.random()}"
bus::limit(self.lock, 1)
bus::set(self.active, 0)
generator = lanes.gen("*", {
required: {'lattelua'}
}, task)
for i = 1, config.workers {
self::expand!
}
}
channel: (capacity)->{
channel(bus, capacity)
}
expand: =>{
bus::send(nil, self.lock, true)
current = bus::get(self.active) or 0
if self.config.limit > 0 and current >= self.config.limit {
bus::receive(0, self.lock)
return false
}
bus::set(self.active, current + 1)
table.insert(self.workers, generator(bus, {
idle: self.idle,
lock: self.lock,
tasks: self.tasks,
active: self.active,
config: self.config
}))
bus::receive(0, self.lock)
current + 1
}
sleep: (n)->{
return (wait(n))
}
atomic: (init)->{
key = "atomic/#{os.clock!}/#{math.random()}"
bus::limit key, 1
bus::send 0, key, init or 0
return setmetatable {
key: key,
add: (v=0)=>{
local key, value
key, value = bus::receive nil, self.key
if type(value) == 'number' {
value += tonumber(v) or 0
}
bus::send 0, self.key, value
}
}, {__call:
(v)=>{
local key, value
key, value = bus::receive nil, self.key
if v {
bus::send 0, self.key, v
} else {
bus::send 0, self.key, value
}
value
}
}
}
select: (cases)->{
default = nil
if cases.default {
default, cases.default = cases.default, nil
}
while wait(0.001) {
done = false
for desc, callback in pairs(cases) {
assert(type(desc) == 'table', 'wrong channel description')
assert(type(callback) == 'function', 'callback is not a function')
ch = desc.chan
switch desc.op {
case "read"
key, val = ch::check!
if val != nil {
ch::syn! if ch.cap == 0
if type(callback) == 'function' {
done = { pcall(callback, val) }
break
}
}
case "write"
payload = (#desc.args > 1) and desc.args or desc.args[1]
sent = ch::push(payload)
if sent {
if ch.cap == 0 {
ch::syn(0.020)
}
if type(callback) == 'function' {
done = { pcall(callback) }
break
}
}
}
}
if type(done) == 'table' {
return select(2, unpack(done))
}
if type(default) == 'function' {
ok = { pcall(default) }
return select(2, unpack(ok))
}
}
}
__call: (func)=>{
return (...)->{
alive = {}
for _, w in ipairs(self.workers) {
switch w.status {
case "pending", "running", "waiting"
table.insert(alive, w)
}
}
self.workers = alive
k, is_idle = bus::receive(0, self.idle)
self::expand! unless is_idle
bus::send(self.tasks, { fn: func, args: {...} })
}
}
}
return {
init: (config)->{
config = config or {}
config.limit = ((tonumber(config.limit) or 0) > 0) and config.limit or 0
config.workers = ((tonumber(config.workers) or 0) > 0) and config.workers or 0
config.idle_timeout = ((tonumber(config.idle_timeout) or 0) > 0) and config.idle_timeout or 5
return spawn(config)
}
}
task это воркер который будет выполняться внутри каждого изолированного потока.
task = (bus, obj)->{
key = obj.tasks
idle = obj.idle
lock = obj.lock
active = obj.active
workers = obj.config.workers
timeout = obj.config.idle_timeout or 5
while true {
k, tsk = bus::receive(0, key)
if not tsk {
bus::send(0, idle, true)
k, tsk = bus::receive(timeout, key)
if not tsk {
bus::send(nil, lock, true)
count = bus::get(active) or 0
if count > workers {
bus::set(active, count - 1)
bus::receive(0, idle)
bus::receive(0, lock)
break
} else {
bus::receive(0, idle)
bus::receive(0, lock)
}
}
}
if tsk and type(tsk['fn']) == 'function' {
status, err = pcall(tsk.fn, unpack(tsk.args or {}))
if not status {
io.stderr::write("[Worker Error]: #{err}n")
}
}
}
}
В воркере реализован механизм динамического масштабирования пула потоков на основе семафора свободных задач.
Как это работает:
Токены: У нас будет отдельный канал, ключ в Linda. Когда воркер свободен и готов брать задачу, он отправляет туда токен (true).
Проверка диспетчером: При вызове spawn, диспетчер пытается забрать один токен без блокировки (таймаут 0).
Защита от ложных токенов: Перед тем как объявить себя свободным, воркер неблокирующе проверяет, нет ли уже ожидающей задачи.
Решение о масштабировании:
Если токен получен, значит хотя бы один воркер простаивает — отдаем задачу.
Если токена нет, значит все воркеры заняты. Если мы еще не достигли лимита, диспетчер создает нового воркера на лету.
Уменьшение пула при простое:
Таймаут: Вместо бесконечного блокирования на очереди задач, воркер ждет задачу timeout секунд.
Смерть по таймауту: Если время вышло, воркер проверяет текущее количество активных потоков. Если их больше, чем минимально заданное, поток завершает свою работу.
Мьютекс для синхронизации: Так как воркеры и диспетчер работают параллельно, нам нужно безопасно изменять счетчик active. Делаем это через блокирующий ключ в Linda с лимитом 1.
Каналы: Обертка над Linda для синхронизации и обмена данными, каждый канал — это просто уникальный ключ/строка на центральной шине. Так как Lanes и Linda не предоставляют «нативного» примитива wait_for_read_OR_write (Linda позволяет ждать только чтения), был реализован механизм Unbuffered Channels, протокол рукопожатия через логику двух ключей: sender кладет данные и ждет ack, receiver забирает данные и шлет ack.
channel = class {
bus = {}
new: (b, capacity=-1)=>{
bus = b
self.closed = false
self.cap = capacity
self.key = "channel/#{os.clock!}/#{math.random()}/key"
self.ack = "channel/#{os.clock!}/#{math.random()}/ack"
if self.cap != 0 {
bus::limit(self.key, self.cap)
} else {
bus::limit(self.key, 1)
}
}
put: (...)=>{
return if self.closed
args ={ ... }
payload = (#args > 1) and args or args[1]
if self.cap != 0 {
bus::send(self.key, payload)
} else {
sent = bus::send(self.key, payload)
bus::receive(self.ack) if sent
sent
}
}
get: =>{
return if self.closed
k, val = bus::receive(self.key)
bus::send(self.ack, true) if self.cap == 0 and val != nil
val
}
close: =>{
self.closed = true
}
check: =>{
bus::receive(0, self.key)
}
count: =>{
bus::count(self.key)
}
syn: (...)=>{
if ... {
n = ...
bus::receive(n, self.ack)
} else {
bus::send(self.ack, true)
}
}
push: (...)=>{
bus::send(0, self.key, ...)
}
__call: (...)=>{
return {
chan: self,
op: "closed"
} if self.closed
args = { ... }
if #args > 0 {
{
chan: self,
op: "write",
args: args
}
} else {
{
chan: self,
op: "read"
}
}
}
}
С архитектурой каналов тесно связан метод select:
К объекту канала добавлен метаметод __call, он анализирует аргументы .... Если они есть — это операция записи (write), возвращается дескриптор и тип операции с аргументами. Если нет — это операция чтения (read), возвращается дескриптор и тип операции.
select итерируется по переданной таблице и так как ключами являются таблицы-дескрипторы, мы проверяем поле операции внутри ключа, это своего рода Syntactic Sugar на go-like работу с каналами.
При записи, вариативные аргументы упаковываются и отправляются. При чтении они распаковываются и передаются в callback-функцию.
Наличие default делает select неблокирующим
Ниже листинг псевдо-сервиса, который комбинирует вытесняющую многозадачность (Lanes) для утилизации ядер CPU и кооперативную многозадачность (Cqueues) для удержания тысяч одновременных I/O соединений.
Псевдо-роли:
Главный поток: Его задачи — забиндить порт, запустить сервисные потоки и воркеры, запустить cqueues-цикл
Сервисные потоки: поток для accept и поток статистики подключений
Воркеры: типа выполняют полезную нагрузку, внутри каждого воркер-потока крутится свой cqueues-цикл в ограниченном наборе сопрограмм.
вся коммуникация через каналы и atomic-операции
#!/bin/llua
HOST, PORT, MAX, THRDS = '127.0.0.1', 12345, 300, 1
spawn = require("spawn").init({ workers: 1 })
HOST = arg[1] if arg[1]
PORT = tonumber arg[2] if arg[2]
MAX = tonumber arg[3] if arg[3]
THRDS = tonumber arg[4] if arg[4]
socket = require "socket"
cqueues = require "cqueues"
signal = require "cqueues.signal"
signal.block(signal.SIGINT, signal.SIGQUIT)
total = spawn.atomic(0)
coroutines = spawn.atomic(0)
connections = spawn.atomic(0)
queue = spawn.channel!
done = spawn.channel THRDS + 2
server = socket.bind HOST, PORT
thread = (queue)->{
local spawn, cqueues, socket
cqueues = require "cqueues"
spawn = require("spawn").init()
socket = require "cqueues.socket"
cq = cqueues.new!
while true {
quit = spawn.select{
[done!]: ->{"quit"}
default: ->{
if coroutines! < MAX {
spawn.select {
[queue!]: (...)->{
cq::wrap (...)->{
fd = ...
skt = socket.fdopen fd
if skt {
connections::add 1
skt::write "long work flownplease wait ...n"
skt::flush!
cqueues.sleep 2 -- long work flow
connections::add -1
skt::write "byen"
skt::flush!
try {
skt::close!
} finally {
coroutines(cq::count!)
}
}
}, ...
}
default: ->{ spawn.sleep 0.020 }
}
} else {
spawn.sleep 0.020
}
}
}
break if quit == "quit"
cq::step 0
}
}
spawn((fd, clients)->{
local spawn, socket, server
spawn = require("spawn").init()
socket = require "socket"
server = socket.tcp!
server::setfd fd
server::listen!
while true {
quit = spawn.select {
[done!]: ->{"quit"}
default: ->{
client = server::accept!
try {
clients::put client::getfd!
} finally {
total::add 1
}
}
}
break if quit == "quit"
}
})(server::getfd!, queue)
spawn(()->{
local spawn
i = 1
CR = "r"
EL = "27[K"
frames = { "/", "-", [[]], "|" }
greeting = "Connections[%s] queue[%s] coroutines[%s] processed[%s]: "
spawn = require("spawn").init()
while spawn.sleep 0.050 {
quit = spawn.select {
[done!]: ->{"quit"}
default: ->{
frame = ""
i = 1 if i > 4
frame = frames[(i % #frames) + 1]
io.write(CR .. EL .. greeting::format(connections!, queue::count!, coroutines!, total!) .. frame)
io.flush!
i += 1
}
}
break if quit == "quit"
}
})()
for _ = 1, THRDS {
spawn(thread)(queue)
}
cq = cqueues.new!
cq::wrap(->{
listener = signal.listen(signal.SIGINT, signal.SIGQUIT)
while true {
signo = listener::wait!
for i = 1, THRDS + 2 {
spawn.select {
[done(true)]: ->{}
default: ->{}
}
}
break
}
})
cq::loop!
Естественно, стоит отметить, что представленная реализация на базе spawn, channels и cqueues является концепцией, а не архитектурным паттерном. Её основная задача — продемонстрировать гибкость гибридной архитектуры, совмещающей вытесняющую многозадачность и кооперативный I/O.
Плюс всегда хотел показать злопыхателям, что Lua не только язык конфигов, на нем можно и нужно писать полноценные, многопоточные приложения, а с Lattelua [2] это еще и инструмент, позволяющий строить архитектуру, а не бороться с синтаксисом.
Вопрос, на самом деле, интересный. Если смотреть со стороны, это выглядит примерно так: есть маленький, простой и элегантный язык Lua — и вместо того, чтобы писать на нём, кто-то берёт и начинает писать другой язык поверх него.
С парсером, AST, трансформациями, компилятором и всеми сопутствующими радостями жизни.
Рациональная часть мозга периодически говорит:
«Может, проще было написать библиотеку?»
Или:
«Lua же и так минималистичный — зачем ещё один синтаксис?»
Но на практике всё оказалось чуть интереснее. Lua — очень хороший runtime, лёгкий, быстрый, встраиваемый, с предсказуемой моделью выполнения.
Но как язык для больших приложений он иногда заставляет писать много шаблонного кода:
бойлерплейт вокруг классов
однообразные паттерны обработки ошибок
инфраструктурные конструкции для потоков
повторяющиеся обёртки над API
Со временем начинаешь замечать, что половина кода — это не логика программы, а структурный шум.
И вот здесь возникает соблазн сделать то, что делали программисты уже десятки лет: не писать больше кода — а поднять уровень абстракции.
Lattelua [2] — это про новый синтаксис и трансформации, которые в итоге всё равно превращаются в обычный Lua.
Он пытается расширить его выразительность, оставляя тот же runtime, те же библиотеки, ту же экосистему. Фактически, Lua остаётся машинным языком проекта, а Lattelua [2] становится языком, на котором удобно писать людям.
С практической точки зрения [20] это даёт несколько вещей:
можно добавлять конструкции, которых нет в Lua (и не будет)
можно сокращать повторяющийся код
можно экспериментировать с архитектурой языка, не трогая runtime
И самое интересное — всё это остаётся совместимым с существующим Lua-миром. Любая программа на Lattelua [2] — это в конце обычный Lua-код. Просто Lua.
Стоило ли всё это делать?
Если смотреть строго прагматично — возможно, нет. Lua и так прекрасно работает. Но разработка языков редко бывает чисто прагматичным занятием.
Это больше похоже на исследование: что будет, если немного сдвинуть границы привычного инструмента? В процессе таких экспериментов иногда появляются вещи, которые потом начинают жить своей жизнью.
И если хотя бы несколько разработчиков посмотрят на Lua чуть по-другому — возможно, это уже было не зря.
Автор: zmc
Источник [21]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/27312
URLs in this post:
[1] OpenResty: https://openresty.org/
[2] Lattelua: https://github.com/mzujev/lattelua
[3] логика: http://www.braintools.ru/article/7640
[4] потребности: http://www.braintools.ru/article/9534
[5] MetaLua: https://github.com/fab13n/metalua
[6] Fennel: https://dev.fennel-lang.org/
[7] MoonScript: https://moonscript.org/
[8] CoffeeScript: https://coffeescript.org
[9] best-practice: http://lua-users.org/wiki/SampleCode
[10] ошибка: http://www.braintools.ru/article/4192
[11] боль: http://www.braintools.ru/article/9901
[12] повторения: http://www.braintools.ru/article/4012
[13] continue: #Continue
[14] поведения: http://www.braintools.ru/article/9372
[15] мозга: http://www.braintools.ru/parts-of-the-brain
[16] Lanes: https://lualanes.github.io/lanes/#embedding
[17] Linda: https://lualanes.github.io/lanes/#lindas
[18] Cqueues: https://25thandclement.com/~william/projects/cqueues.html
[19] многозадачность: http://www.braintools.ru/article/3673
[20] зрения: http://www.braintools.ru/article/6238
[21] Источник: https://habr.com/ru/articles/1011614/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1011614
Нажмите здесь для печати.