
Если вы хоть раз встраивали Lua в свой проект — будь то игровой движок, высоконагруженный веб-сервер на OpenResty или конфи��уратор сложного сетевого оборудования — вы знаете, за что мы его любим:)
А любим мы его — за компактность, быстроту, встраиваемость и предсказуемость. Не любим — за аскетичный синтаксис, отсутствие привычных конструкций и постоянное «изобретение велосипеда».
Эта статья — обзор диалекта Lattelua: зачем он нужен, чем отличается от других диалектов, и почему его особенно удобно использовать в уже существующих проектах, где Lua — встраиваемый язык.
LatteLua: Кофеиновый апгрейд
Lua считается языком конфигов, описания данных и встраиваемых систем, но я смотрю на него немного по-другому, он больше похож на библиотеку или фреймворк над Си, для его усиления или расширения, как бы странно это ни звучало.
Стековая машина в Lua API — это действительно «низкоуровневый» протокол связи, чем-то напоминает assembler, и такой, на первый взгляд, простой подход на практике дает нехилый boost в интерпретации, что повышает эффективность исполнения.
Но давайте честно: когда логика разрастается, синтаксис Lua начинает напоминать попытку собрать вертолёт с помощью одной отвёртки и такой-то матери. Бесконечные then/end/local, отсутствие нормальных классов и врожденная «немногословность» превращают поддержку кода в квест.
Да и чего уж греха таить, хочется, очень хочется, обложиться всякими синтаксически-сахарными плюшками, к примеру, как в том же Python. И именно из-за этой потребности и появляются MetaLua, Fennel и MoonScript, каждый с копированием в сторону симпатизируемого языка.
Lattelua же, напротив, он как CoffeeScript, другой диалект того же Lua.
Lattelua - это попытка собрать все лучшее из best-practice, взяв за основу парсер MoonScript, своего рода дружелюбный Lua. Но почему не MoonScript в оригинале, зачем изобретать новый диалект если все уже придумано до нас?
Основная претензия к MoonScript — это его «питоноподобность»:
В MoonScript, как и в Python отступы — это закон, малейшая ошибка в отступах (особенно при смешивании пробелов и табуляции) приводит к синтаксическим ошибкам, которые крайне тяжело отловить, так как компилятор может и��терпретировать блок кода как часть другой логической ветки. В Lua четкая блочная система, в MoonScript — мы гадаем по пустому пространству.
Сложность работы с анонимными функциями, MoonScript пытается это решить, но вложенные блоки внутри аргументов функций там выглядят как «лесенка», в которой легко запутаться.
API и минификация, код на Python/Moonscript невозможно минифицировать без потери смысла, так как пробелы — это и есть синтаксис. Любое искажение форматирования при пересылке «убивает» программу.
Метапрограммирование и кодогенерация, если нужно генерировать код на лету, то генерировать корректные отступы — это лишняя и сложная головная боль. Намного проще просто выплевывать токены и ставить
endв нужных местах.
Да чё я всё это перечисляю, все кому надо уже и так все знают. Я не хочу сказать что python-style — это плохо, плохая идея модифицировать в него язык с блочной разметкой. Куда проще сократить синтаксическую неуклюжесть (then/end, do/end, function/end) до блочного стиля, повысив «многословность» новыми синтаксическими конструкциями.
Синтаксис: меньше шума, больше смысла
Lattelua Language Reference
Базовый синтаксис
Блоки кода и разделители
В 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
Обычно значения из таблицы извлекаются и присваиваются локальным переменным, имеющим то же имя, что и ключ. Чтобы избежать повторения, возможно использовать префиксный оператор ::
{: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/Else/Unless
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"
Оператор Switch
Использует ключевое слово 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 (Числовой)
-- Без шага 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 }
For In (Итератор)
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.
Циклы 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
Цикл while также существует в двух вариантах:
i = 10 while i > 0 { print i i -= 3 } while running == true {some_func()}
Как и в случае с for, в цикле while также можно использовать выражение. Кроме того, чтобы функция возвращала накопленное значение цикла while, оператор должен быть возвращен явно.
Управление циклом
Break
Оператор break прерывает цикл (while или for), в теле которого встречается. В результате выполнения оператора break управление передаётся первой инструкции, следующей непосредственно за оператором цикла.
i = 0 while i < 10 { break if i > 5 print i i += 1 }
Continue
Оператор 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-контекст
Для создания функций предусмотрен специальный синтаксис =>, который автоматически включает аргумент 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 является общим для всех экземпляров, поэтому изменения, внесенные в него в одном экземпляре, будут отображаться в другом.
Правильный способ избежать такого поведения - создать изменяемое состояние объекта в конструкторе:
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 оператор
Блок 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 оператор
Использование оператора do работает так же, как и в Lua.
do { msg = "world" print "hello #{msg}" }
Оператор do также может использоваться как выражение. Результатом выражения do является последнее выражение в блоке.
print do { msg = "world" "hello #{msg}" }
Обработка ошибок (Try-Catch)
Блок 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 и в Lua, в частности, не позволяет множественного наследования, точнее в «ванильном» Lua с метатаблицами и рекурсией головного мозга, возможно закостылить хоть какую глубину наследования. AST-шаблон такого глубокого колодца реализовать трудно, не невозможно — но трудно. Куда проще использовать паттерн «встраивания», как в незамысловатом GO: просто, дёшево, надёжно.
Встроенные документы: Выполнение Lattelua кода в режиме встроенного документа, в пространстве 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 не добавляет новую модель исполнения, он компилируется в Lua используя те же таблицы и те же функции.
На выходе — обычный Lua‑код, который можно отладить, можно профилировать или оптимизировать руками.
Для разработки это огромная разница: маленькие скрипты, меньше визуального шума, и что самое главное, быстрый MVP:) Для больших проектов: меньше глобального состояния, меньше копипасты, как следствие проще рефакторить.
Для встраиваемых систем: не меняется ABI, не появляется второй VM и Lua остаётся главным.
Что тут думать, прыгать надо
Что ж, настало время показать все прелести Lattelua, так сказать на личном примере, никуда без велосипедостроения:)
Будем писать библиотеку, которая разукрасит Lattelua под golang-практики и как водится с псевдосервером для наглядности.
Используемый стек: Lanes, Linda и Cqueues, результатом будет библиотека упрощённой многопоточности, с её помощью можно насоздавать воркеров, затащить в них кооперативную многозадачность, прокинуть между ними каналы и обмениваться сообщениями без всяких там мьютексов (которые там, впрочем, есть).
Встроенный рантайм сам разрулит всё это дело, упрощая работу программиста такими конструкциями, как неблокирующий select, атомарные операции над данными и т.д. и т.п. Погнали ...
Архитектура spawn:
Диспетчер: Основной поток Lua, он не блокируется. При вызове
spawnон просто сериализует функцию и аргументы и кладет их в очередь задач.Очередь задач: Центральная шина, через которую задачи передаются воркерам.
У объекта
spawnодна центральная Lindabus.Воркеры: Набор системных потоков Lanes, которые крутятся в бесконечном цикле. Они конкурируют за задачи из
tasks. Как только воркер освобождается, он забирает следующую задачу.
spawn
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
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
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-операции
pseudo-server
#!/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 flow\nplease wait ...\n" skt::flush! cqueues.sleep 2 -- long work flow connections::add -1 skt::write "bye\n" 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 это еще и инструмент, позволяющий строить архитектуру, а не бороться с синтаксисом.
А стоило ли?
Вопрос, на самом деле, интересный. Если смотреть со стороны, это выглядит примерно так: есть маленький, простой и элегантный язык Lua — и вместо того, чтобы писать на нём, кто-то берёт и начинает писать другой язык поверх него.
С парсером, AST, трансформациями, компилятором и всеми сопутствующими радостями жизни.
Рациональная часть мозга периодически говорит:
«Может, проще было написать библиотеку?»
Или:
«Lua же и так минималистичный — зачем ещё один синтаксис?»
Но на практике всё оказалось чуть интереснее. Lua — очень хороший runtime, лёгкий, быстрый, встраиваемый, с предсказуемой моделью выполнения.
Но как язык для больших приложений он иногда заставляет писать много шаблонного кода:
бойлерплейт вокруг классов
однообразные паттерны обработки ошибок
инфраструктурные конструкции для потоков
повторяющиеся обёртки над API
Со временем начинаешь замечать, что половина кода — это не логика программы, а структурный шум.
И вот здесь возникает соблазн сделать то, что делали программисты уже десятки лет: не писать больше кода — а поднять уровень абстракции.
Lattelua — это про новый синтаксис и трансформации, которые в итоге всё равно превращаются в обычный Lua.
Он пытается расширить его выразительность, оставляя тот же runtime, те же библиотеки, ту же экосистему. Фактически, Lua остаётся машинным языком проекта, а Lattelua становится языком, на котором удобно писать людям.
С практической точки зрения это даёт несколько вещей:
можно добавлять конструкции, которых нет в Lua (и не будет)
можно сокращать повторяющийся код
можно экспериментировать с архитектурой языка, не трогая runtime
И самое интересное — всё это остаётся совместимым с существующим Lua-миром. Любая программа на Lattelua — это в конце обычный Lua-код. Просто Lua.
Стоило ли всё это делать?
Если смотреть строго прагматично — возможно, нет. Lua и так прекрасно работает. Но разработка языков редко бывает чисто прагматичным занятием.
Это больше похоже на исследование: что будет, если немного сдвинуть границы привычного инструмента? В процессе таких экспериментов иногда появляются вещи, которые потом начинают жить своей жизнью.
И если хотя бы несколько разработчиков посмотрят на Lua чуть по-другому — возможно, это уже было не зря.
