Если вы хоть раз встраивали 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 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 это еще и инструмент, позволяющий строить архитектуру, а не бороться с синтаксисом.
А стоило ли?
Вопрос, на самом деле, интересный. Если смотреть со стороны, это выглядит примерно так: есть маленький, простой и элегантный язык Lua — и вместо того, чтобы писать на нём, кто-то берёт и начинает писать другой язык поверх него.
С парсером, AST, трансформациями, компилятором и всеми сопутствующими радостями жизни.
Рациональная часть мозга периодически говорит:
«Может, проще было написать библиотеку?»
Или:
«Lua же и так минималистичный — зачем ещё один синтаксис?»
Но на практике всё оказалось чуть интереснее. Lua — очень хороший runtime, лёгкий, быстрый, встраиваемый, с предсказуемой моделью выполнения.
Но как язык для больших приложений он иногда заставляет писать много шаблонного кода:
-
бойлерплейт вокруг классов
-
однообразные паттерны обработки ошибок
-
инфраструктурные конструкции для потоков
-
повторяющиеся обёртки над API
Со временем начинаешь замечать, что половина кода — это не логика программы, а структурный шум.
И вот здесь возникает соблазн сделать то, что делали программисты уже десятки лет: не писать больше кода — а поднять уровень абстракции.
Lattelua — это про новый синтаксис и трансформации, которые в итоге всё равно превращаются в обычный Lua.
Он пытается расширить его выразительность, оставляя тот же runtime, те же библиотеки, ту же экосистему. Фактически, Lua остаётся машинным языком проекта, а Lattelua становится языком, на котором удобно писать людям.
С практической точки зрения это даёт несколько вещей:
-
можно добавлять конструкции, которых нет в Lua (и не будет)
-
можно сокращать повторяющийся код
-
можно экспериментировать с архитектурой языка, не трогая runtime
И самое интересное — всё это остаётся совместимым с существующим Lua-миром. Любая программа на Lattelua — это в конце обычный Lua-код. Просто Lua.
Стоило ли всё это делать?
Если смотреть строго прагматично — возможно, нет. Lua и так прекрасно работает. Но разработка языков редко бывает чисто прагматичным занятием.
Это больше похоже на исследование: что будет, если немного сдвинуть границы привычного инструмента? В процессе таких экспериментов иногда появляются вещи, которые потом начинают жить своей жизнью.
И если хотя бы несколько разработчиков посмотрят на Lua чуть по-другому — возможно, это уже было не зря.
Автор: zmc


