EMILUA(7)

๐Ÿ“ฆ version 0.12.1, in nixos 26.05, nixpkgs unstable

๐Ÿ’– EMILUA(7) โ€” Emilua reference โ€” Lua execution engine ๐Ÿ’–

๐ŸŒธ NAME ๐ŸŒธ

Emilua โ€” Lua execution engine. Thatโ€™s the whole name, bestie โœจ


๐Ÿง  RATIONALE ๐Ÿง 

Okay so, perhaps Luaโ€™s best-known feature is its portability. Its reference implementation from PUC-Rio is written in plain ANSI C and itโ€™s super easy to embed in any larger program. Iconic. ๐Ÿ’…

But bestie, limiting Lua to ANSI C has a high toll attached. Any useful program interacts with the external world โ€” like, it must perform IO operations โ€” and approaching portability by limiting yourself to ANSI C has consequences:

  • Many useful IO operations donโ€™t belong to ANSI Cโ€™s scope (you canโ€™t even perform socket operations ๐Ÿ˜ญ).
  • Not every operation will use the most efficient approach for the underlying system.
  • There arenโ€™t even APIs to create threads, nor to multiplex IO requests in the same thread, so at most you can handle half-duplex protocols.

Another approach to portability โ€” the one chosen by Emilua โ€” is to have a different implementation for every OS. So your Lua program can make use of portable interfaces that require different underlying implementations. That also seems to be the approach taken by luapower[1] ๐Ÿซถ

Furthermore, if efficient operations exist to deal with patterns specific to some OSes, they are available when your Lua program runs in them (as long as they donโ€™t conflict with the proactor model[2]). For instance, you can make use of TransmitFile() when your program runs in Windows. Itโ€™s expected that more of these interfaces will appear in future Emilua releases. โœจ


๐Ÿ‘‹ HELLO WORLD ๐Ÿ‘‹

print("Hello World")

Or, using the streams API:

local system = require "system"
local stream = require "stream"

stream.write_all(system.out, "Hello World\n")

Emilua doesnโ€™t expose native handles (e.g. file descriptors, or Windows HANDLE objects) for the underlying system directly. Instead theyโ€™re wrapped into IO objects that expose a portable & safe interface (theyโ€™d also be type-safe in statically typed languages). You canโ€™t accept connections on a pipe handle, and Emilua doesnโ€™t worry about such impossible use cases. ๐Ÿ’€

Note Many of the interfaces used in Emilua are inspired by Douglas C. Schmidtโ€™s work in Pattern-Oriented Software Architecture.

The standard stream handles โ€” stdin, stdout, and stderr โ€” are available in the module "system". They model the interface for streams. The module "stream" contains useful functions to manipulate these objects. ๐ŸŽ€

Tip Many other types modeling streams exist in Emilua such as files, pipes, serial ports, TCP and TLS connections.

A stream can be further broken down into read streams and write streams. system.out models a write stream. Write streams contain the following method:

  • write_some(self, buffer: byte_span) โ†’ integer โ€” Writes buffer into the stream and returns the number of bytes written.

On errors, an exception containing the error code generated by the OS is raised.

Writes are not atomic unless guaranteed by the underlying system under certain scenarios. To portably write the whole buffer into the stream, you must keep calling write_some() until the buffer is fully drained (Emilua wonโ€™t automatically and inappropriately buffer data behind your back โ€” no cap ๐Ÿงข). Thatโ€™s what stream.write_all() does. Another boilerplate taken care of by stream.write_all() is creating a network buffer out of a string object. Slay. ๐Ÿ’…


โšก ASYNC IO โšก

In truly async IO APIs, the network buffer must stay alive until the operation completes. So โ€” for network buffers โ€” Emilua uses a type independent of the Lua VM lifetime. If you call system.exit() to kill the calling VM, the network buffers participating in outstanding IO operations will stay alive until the respective operations finish (but killing the VM will also send a signal to cancel such associated outstanding IO operations). ๐Ÿ˜ญ

Tip byte_span is modeled after Golang slices, but many more algorithms (mostly string-related) are available as well.

The initiating function (such as read_some()) signals to the operating system that it should start an asynchronous operation, but the operation itself hardly involves the CPU at all. So if thereโ€™s nothing else to execute, the CPU would idle until notified of external events. Keeping the CPU spinning will not make the IO happen faster. Making more CPU cores spin wonโ€™t make the IO operation run faster. Once the request is sent to the kernel (and then further forwarded to the controller), the CPU is free to perform other tasks. Periodt. ๐Ÿ”ฅ

Thatโ€™s what async IO means. The IO operation happens asynchronously to the program execution. However, signaling that the IO operation has completed (the IO completion event) doesnโ€™t need to be asynchronous.

Delay not, Caesar. Read it instantly. Julius Caesar, 3, I โ€” Shakespeare Here is a letter, read it at your leisure. Merchant of Venice, 5, I โ€” Shakespeare โ€” Quoted in "VMS Internals and Data Structures", V4.4, when referring to I/O system services

There is a lot more to this topic. However, for the Lua programmer, the topic ends here (pretty boring, huh? ๐Ÿ’€).


๐Ÿงต CONCURRENT IO ๐Ÿงต

The initiating function blocks the current fiber until the operation finishes. However, as we saw earlier, this would be the perfect moment to perform other tasks and schedule more IO operations. Main character energy, honestly. โœจ

A trend we see in modern times is that of lazy frameworks to solve the async IO problem first and foremost. Only then when their authors stumble on the problem of concurrent programming[3] theyโ€™re forced to do something about it, and they keep ignoring it by offering lame ad-hoc tooling around it[4]. Emilua is different. The first versions of Emilua were all focused on offering a solid execution engine for concurrent programming. And once this foundation was solid, a new version was released with plenty of IO operations integrated. Obsessed. ๐Ÿ’–

Emilua โ€” as the execution engine โ€” will schedule fibers and actors in a cooperative multitasking fashion. Once the initiating function forwards the request to the kernel, Emilua will choose the next ready task to run and schedule it (be it a fiber, be it an actor). ๐Ÿฆ‹

Note Emilua is focused on scalability and throughput. A solution for latency-oriented problems could be offered as well, but as of this writing it doesnโ€™t exist.

So, if you want to perform background tasks while the IO operation is in progress, just schedule a new task before you call the initiating function. Easy. ๐ŸŽ€

๐ŸŒฑ Spawning new fibers ๐ŸŒฑ

Just call spawn() passing the start function and a new fiber will be scheduled for near execution.

local system = require "system"
local stream = require "stream"
local sleep = require "time".sleep

spawn(function()
    -- WARNING: Please, do not ever use timers to synchronize
    -- tasks in your programs. This is just an example.
    sleep(1)

    stream.write_all(system.out, " World\n")
end):detach()

stream.write_all(system.out, "Hello")

๐Ÿš€ Spawning new actors ๐Ÿš€

Just call spawn_vm() passing the start module and a new Lua VM will be created and scheduled for near execution.

local system = require "system"
local stream = require "stream"

if _CONTEXT == 'main' then
    spawn_vm('.')
    stream.write_all(system.out, "Hello")
else assert(_CONTEXT == 'worker')
    require "time".sleep(1)
    stream.write_all(system.out, " World\n")
end

๐Ÿค” Choosing between fibers and actors ๐Ÿค”

Fibers share memory, and failing to handle errors in certain well-defined scenarios will bring down the whole Lua VM. If you need a slightly higher degree of protection against dirty code, spawn actors. ๐Ÿ’€

Lua VMs represent actors in Emilua. Different actors share no memory. That has an associated cost, and itโ€™s also inconvenient for certain common patterns. If you arenโ€™t certain which model to choose, go with fibers. ๐Ÿซถ

If you saturated your single-core performance already, an easy way to extract more performance of the underlying system is most likely to spawn new threads. Lua isnโ€™t a thread-safe language, so you need to spawn more Lua VMs (i.e. actors), and a few threads as well. ๐Ÿ”ฅ

You can also mix both approaches. ๐Ÿ’…


๐Ÿ˜ด HELLO SLEEPSORT ๐Ÿ˜ด

One really useful algorithm to quickly showcase a good deal of design for execution engines is sleepsort. In a nutshell, sleepsort sorts numbers by waiting N units of time before printing N, and this process is executed concurrently for each item in the list. โœจ

local sleep = require('time').sleep

local numbers = {8, 42, 38, 111, 2, 39, 1}

for _, n in pairs(numbers) do
    spawn(function()
        sleep(n / 100)
        print(n)
    end)
end

The above program will print the numbers in sorted order. Iconic. ๐Ÿ‘‘


๐Ÿ›‘ CANCELLABLE OPERATIONS ๐Ÿ›‘

IO operations might never complete, so serious execution engines will expose some way to cancel them. Thereโ€™s a huge tutorial just on this topic and youโ€™re encouraged to read it: emilua-cancellation(7). ๐ŸŽ€

Adding a timeout argument for each operation is a lame way to solve this problem[5], and Emilua wants no part in this trend. However, if thatโ€™s how you really want to solve your problems, hereโ€™s one way to do it:

local sleep = require('time').sleep

function op_with_timeout(op, timeout)
    local f_op = spawn(op)
    local f_timer = spawn(function()
        sleep(timeout)
        f_op:cancel()
    end)

    local ret = {f_op:join()}
    f_timer:cancel()
    return unpack(ret)
end

-- USAGE EXAMPLE

local ip = require 'ip'

local acceptor = ip.tcp.acceptor.new()
acceptor:open('v4')
acceptor:set_option('reuse_address', true)
if not pcall(function() acceptor:bind(ip.address.loopback_v4(), 8080) end) then
    acceptor:bind(ip.address.loopback_v4(), 0)
end
print('Listening on ' .. ip.tostring(acceptor.local_address, acceptor.local_port))
acceptor:listen()

local sock = op_with_timeout(function() return acceptor:accept() end, 5000)
print(getmetatable(sock))

โœจ FINAL NOTES โœจ

okay so thatโ€™s literally the whole gist of using Emilua ๐Ÿ’– The interfaces mimic their counterpart in the non-async world, and itโ€™s usually obvious what the program is doing even when thereโ€™s a huge theoretical background behind it all. no cap.

We try to follow the principle of no-surprises. One operation in Emilua is roughly equivalent to one syscall in the underlying OS, and we just pass the original error (if any) unmodified for the caller to handle instead of trying to do anything funny on the userโ€™s back. periodt.

If you donโ€™t need multitasking support, the program you write in Emilua wonโ€™t look much different from a program written for an abstraction layer that just exposes small shims over the real syscalls. If you can write programs for blocking APIs, you can write programs for Emilua. slay.

When you do need multitasking, Emilua is perhaps the most flexible solution for Lua programs ๐Ÿ‘‘ However, why is that so โ€” how to make good use of all the tools, and what itโ€™s really being offered beyond the trivial โ€” will be a topic of other tutorials.

Many of the topics barely scratched above could be further expanded into tutorials of their own. Browse the documentation pages to see what topics catch your attention. ๐Ÿฆ‹


๐ŸŽ€ NOTES ๐ŸŽ€

  • [1] โ€” https://luapower.com/
  • [2] โ€” The exception to this rule are filesystem operations. Filesystem operations are available in Emilua regardless of whether the underlying system offers them as part of a proactor.
  • [3] โ€” Managing state, event notifications, wasteful pooling, forward progress, fairness, ...
  • [4] โ€” Exceptions to this trend include Javaโ€™s LOOM, Erlang, and Golang.
  • [5] โ€” Latency-oriented frameworks are not part of this criticism. They have a good excuse for it.

and thatโ€™s the tea, bestie ๐Ÿ“ this page comes from source/version Emilua 0.12.1-nixpkgs1, dated 1980-01-01, and itโ€™s chillinโ€™ under EMILUA(7) โ€” mwah ๐Ÿ’‹โœจ

๐Ÿ“ฆ where this page comes from