Lattelua — когда Lua уже мало. AST.. AST. lua.. AST. lua. moonscript.. AST. lua. moonscript. Open source.. AST. lua. moonscript. Open source. parsers.. AST. lua. moonscript. Open source. parsers. Алгоритмы.. AST. lua. moonscript. Open source. parsers. Алгоритмы. Компиляторы.. AST. lua. moonscript. Open source. parsers. Алгоритмы. Компиляторы. Параллельное программирование.
По мотивам CoffeeScript

По мотивам CoffeeScript

Если вы хоть раз встраивали 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 одна центральная Linda bus.

  • Воркеры: Набор системных потоков 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

Источник

Rambler's Top100