- BrainTools - https://www.braintools.ru -

Lattelua — когда Lua уже мало

По мотивам CoffeeScript

По мотивам CoffeeScript

Если вы хоть раз встраивали Lua в свой проект — будь то игровой движок, высоконагруженный веб-сервер на OpenResty [1] или конфигуратор сложного сетевого оборудования — вы знаете, за что мы его любим:)
А любим мы его — за компактность, быстроту, встраиваемость и предсказуемость. Не любим — за аскетичный синтаксис, отсутствие привычных конструкций и постоянное «изобретение велосипеда».

Эта статья — обзор диалекта Lattelua [2]: зачем он нужен, чем отличается от других диалектов, и почему его особенно удобно использовать в уже существующих проектах, где Lua — встраиваемый язык.

LatteLua: Кофеиновый апгрейд

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

Стековая машина в Lua API — это действительно «низкоуровневый» протокол связи, чем-то напоминает assembler, и такой, на первый взгляд, простой подход на практике дает нехилый boost в интерпретации, что повышает эффективность исполнения.

Но давайте честно: когда логика [3] разрастается, синтаксис Lua начинает напоминать попытку собрать вертолёт с помощью одной отвёртки и такой-то матери. Бесконечные then/end/local, отсутствие нормальных классов и врожденная «немногословность» превращают поддержку кода в квест.

Да и чего уж греха таить, хочется, очень хочется, обложиться всякими синтаксически-сахарными плюшками, к примеру, как в том же Python. И именно из-за этой потребности [4] и появляются MetaLua [5], Fennel [6] и MoonScript [7], каждый с копированием в сторону симпатизируемого языка.
Lattelua [2] же, напротив, он как CoffeeScript [8], другой диалект того же Lua.

Lattelua [2] – это попытка собрать все лучшее из best-practice [9], взяв за основу парсер MoonScript, своего рода дружелюбный Lua. Но почему не MoonScript в оригинале, зачем изобретать новый диалект если все уже придумано до нас?
Основная претензия к MoonScript — это его «питоноподобность»:

  • В MoonScript, как и в Python отступы — это закон, малейшая ошибка [10] в отступах (особенно при смешивании пробелов и табуляции) приводит к синтаксическим ошибкам, которые крайне тяжело отловить, так как компилятор может интерпретировать блок кода как часть другой логической ветки. В Lua четкая блочная система, в MoonScript — мы гадаем по пустому пространству.

  • Сложность работы с анонимными функциями, MoonScript пытается это решить, но вложенные блоки внутри аргументов функций там выглядят как «лесенка», в которой легко запутаться.

  • API и минификация, код на Python/Moonscript невозможно минифицировать без потери смысла, так как пробелы — это и есть синтаксис. Любое искажение форматирования при пересылке «убивает» программу.

  • Метапрограммирование и кодогенерация, если нужно генерировать код на лету, то генерировать корректные отступы — это лишняя и сложная головная боль [11]. Намного проще просто выплевывать токены и ставить end в нужных местах.

Да чё я всё это перечисляю, все кому надо уже и так все знают. Я не хочу сказать что python-style — это плохо, плохая идея модифицировать в него язык с блочной разметкой. Куда проще сократить синтаксическую неуклюжесть (then/end, do/end, function/end) до блочного стиля, повысив «многословность» новыми синтаксическими конструкциями.

Синтаксис: меньше шума, больше смысла

Lattelua 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

Обычно значения из таблицы извлекаются и присваиваются локальным переменным, имеющим то же имя, что и ключ. Чтобы избежать повторения [12], возможно использовать префиксный оператор ::

{:concat, :insert} = table      -- local concat, insert = table.concat, table.insert

По сути, это то же самое, что и import, но мы можем переименовать поля, которые хотим извлечь:

{:mix, :max, random: rand } = math      -- local mix, max, rand = math.mix, math.max, math.random

Деструктуризация также может проявляться в тех местах, где неявно выполняется присваивание:

array = {
	{1, 2, 3, 4}
	{5, 6, 7, 8}
}

for {first, second} in *array {
	print first, second                   -- 1 2   &   5 6
}

Генераторы коллекций

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

Генераторы списков

Следующий пример создаёт копию таблицы элементов, с удвоенными значениями:

array = { 1, 2, 3, 4 }
doubled = [item * 2 for i, item in ipairs array]                   -- doubled = { 2, 4, 6, 8 }

Элементы, включенные в новую таблицу, можно ограничить с помощью when выражения:

iter = ipairs array
slice = [item for i, item in iter when i > 1 and i < 3]            -- slice = { 2 }

Операторы for и when возможно объединять в цепочки сколько угодно. Единственное требование, чтобы в выражении был хотя бы один оператор for.

Использование нескольких операторов for аналогично использованию вложенных циклов:

x = {4, 5, 6, 7}
y = {9, 2, 3}

points = [{x,y} for x in ipairs x for y in ipairs y]

Генераторы таблиц

Синтаксис генератора таблиц очень похож, отличается только использованием {} и получением двух значений на каждой итерации:

t ={
	gender: "male",
	age: 25
}
copy = {k,v for k,v in pairs t}

Генераторы таблиц, как и генераторы списков, также поддерживают несколько операторов for и when:

copy = {k,v for k,v in pairs t when k != "gender"}

Управляющие конструкции

If/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 [13].

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

funca = -> {for i=1,10 {i}}
funcb = -> {return [i for i=1,10] }

print funca() -- prints nil
print funcb() -- prints table object

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

While

Цикл 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 является общим для всех экземпляров, поэтому изменения, внесенные в него в одном экземпляре, будут отображаться в другом.
Правильный способ избежать такого поведения [14] – создать изменяемое состояние объекта в конструкторе:

class Animal {
	new: (speech) => {
		self.speech = {}                         -- private property for instance
		table.insert self.speech, speech
	}
	speak: (who) => {
		print "#{who} say: #{speech}" for _, speech in ipairs self.speech
	}
}

Наследование

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

class Dog extends Animal {
	new: (speech) => {
		super(speech)
	}

	speak: (who)=> {
		print("#{who} say: WOOF")
	}
}

Если в подклассе не определен конструктор, то при создании нового экземпляра вызывается конструктор родительского класса.
Если же конструктор определен, то для вызова конструктора родительского класса можно использовать метод super.

super – это специальное ключевое слово, которое можно использовать двумя способами: как объект или как функцию. super обладает функциональностью только внутри класса.
При вызове в качестве функции super вызовет функцию с тем же именем в родительском классе. В качестве первого аргумента автоматически будет передан текущий объект self.
При использовании super в качестве обычного значения, это ссылка на объект родительского класса.
К super можно обращаться как к любому объекту для получения значений в родительском классе.

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

class Animal {
	__inherited: (child) => {
		print "#{self.__name} was inherited by #{child.__name}"
	}
}

class Dog extends Animal{}

With оператор

Блок 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 [7] и в Lua, в частности, не позволяет множественного наследования, точнее в «ванильном» Lua с метатаблицами и рекурсией головного мозга [15], возможно закостылить хоть какую глубину наследования. AST-шаблон такого глубокого колодца реализовать трудно, не невозможно — но трудно. Куда проще использовать паттерн «встраивания», как в незамысловатом GO: просто, дёшево, надёжно.

  • Встроенные документы: Выполнение Lattelua [2] кода в режиме встроенного документа, в пространстве lua-кода. К примеру, если уже есть тысячи строк кода на Lua, и нет возможности просто всё переписать, можно воспользоваться встроенными документами:

    local __latte = require "lattelua"
    
    local mt = {
     age = 25
    }
    
    local msg = "hello world"
    
    local test = function()
     local copy = {}
     for k,v in pairs(_G) do
      copy[k] = v
     end
    
     return copy
    end
    
    local RESULT = __latte[[
     getupenv(3)                 -- захватываем вышестоящее окружение, если нужно
    
     print "#{msg}"              -- hello world
     print "#{mt.age}"           -- 25
     mt.age = 50
     test!
    ]]
    
    for k,v in pairs(RESULT) do
     print(("t%s => %s"):format(tostring(k), tostring(v)))
    end
    
    print(mt.age)                -- 50
    
  • Автономный интерпретатор: llua с возможностью листинга компиляции. За основу взят lua 5.1 интерпретатор, совместимый с версиями Lua 5.1–5.4. REPL-режим на основе { и } поддерживается.

Важно понимать: Lattelua [2] не добавляет новую модель исполнения, он компилируется в Lua используя те же таблицы и те же функции.
На выходе — обычный Lua‑код, который можно отладить, можно профилировать или оптимизировать руками.

Для разработки это огромная разница: маленькие скрипты, меньше визуального шума, и что самое главное, быстрый MVP:) Для больших проектов: меньше глобального состояния, меньше копипасты, как следствие проще рефакторить.
Для встраиваемых систем: не меняется ABI, не появляется второй VM и Lua остаётся главным.

Что тут думать, прыгать надо

Что ж, настало время показать все прелести Lattelua [2], так сказать на личном примере, никуда без велосипедостроения:)
Будем писать библиотеку, которая разукрасит Lattelua [2] под golang-практики и как водится с псевдосервером для наглядности.
Используемый стек: Lanes [16], Linda [17] и Cqueues [18], результатом будет библиотека упрощённой многопоточности, с её помощью можно насоздавать воркеров, затащить в них кооперативную многозадачность [19], прокинуть между ними каналы и обмениваться сообщениями без всяких там мьютексов (которые там, впрочем, есть).
Встроенный рантайм сам разрулит всё это дело, упрощая работу программиста такими конструкциями, как неблокирующий select, атомарные операции над данными и т.д. и т.п. Погнали …

Архитектура spawn:

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

  • Очередь задач: Центральная шина, через которую задачи передаются воркерам.

  • У объекта spawn одна центральная Linda bus.

  • Воркеры: Набор системных потоков Lanes, которые крутятся в бесконечном цикле. Они конкурируют за задачи из tasks. Как только воркер освобождается, он забирает следующую задачу.

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 [2] это еще и инструмент, позволяющий строить архитектуру, а не бороться с синтаксисом.

А стоило ли?

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

Рациональная часть мозга периодически говорит:

«Может, проще было написать библиотеку?»

Или:

«Lua же и так минималистичный — зачем ещё один синтаксис?»

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

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

  • бойлерплейт вокруг классов

  • однообразные паттерны обработки ошибок

  • инфраструктурные конструкции для потоков

  • повторяющиеся обёртки над API

Со временем начинаешь замечать, что половина кода — это не логика программы, а структурный шум.
И вот здесь возникает соблазн сделать то, что делали программисты уже десятки лет: не писать больше кода — а поднять уровень абстракции.
Lattelua [2] — это про новый синтаксис и трансформации, которые в итоге всё равно превращаются в обычный Lua.
Он пытается расширить его выразительность, оставляя тот же runtime, те же библиотеки, ту же экосистему. Фактически, Lua остаётся машинным языком проекта, а Lattelua [2] становится языком, на котором удобно писать людям.

С практической точки зрения [20] это даёт несколько вещей:

  • можно добавлять конструкции, которых нет в Lua (и не будет)

  • можно сокращать повторяющийся код

  • можно экспериментировать с архитектурой языка, не трогая runtime

И самое интересное — всё это остаётся совместимым с существующим Lua-миром. Любая программа на Lattelua [2] — это в конце обычный Lua-код. Просто Lua.

Стоило ли всё это делать?

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

Автор: zmc

Источник [21]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/27312

URLs in this post:

[1] OpenResty: https://openresty.org/

[2] Lattelua: https://github.com/mzujev/lattelua

[3] логика: http://www.braintools.ru/article/7640

[4] потребности: http://www.braintools.ru/article/9534

[5] MetaLua: https://github.com/fab13n/metalua

[6] Fennel: https://dev.fennel-lang.org/

[7] MoonScript: https://moonscript.org/

[8] CoffeeScript: https://coffeescript.org

[9] best-practice: http://lua-users.org/wiki/SampleCode

[10] ошибка: http://www.braintools.ru/article/4192

[11] боль: http://www.braintools.ru/article/9901

[12] повторения: http://www.braintools.ru/article/4012

[13] continue: #Continue

[14] поведения: http://www.braintools.ru/article/9372

[15] мозга: http://www.braintools.ru/parts-of-the-brain

[16] Lanes: https://lualanes.github.io/lanes/#embedding

[17] Linda: https://lualanes.github.io/lanes/#lindas

[18] Cqueues: https://25thandclement.com/~william/projects/cqueues.html

[19] многозадачность: http://www.braintools.ru/article/3673

[20] зрения: http://www.braintools.ru/article/6238

[21] Источник: https://habr.com/ru/articles/1011614/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1011614

www.BrainTools.ru

Rambler's Top100