On this page
  1. tecs.workers
  2. Waiting on each side
  3. Several calls at once
  4. Polling and shutdown
  5. Module contents
    1. Constructors
    2. Types
    3. Functions
    4. Values
  6. Constructors
    1. newChannel
  7. Types
    1. Channel
    2. Self
    3. SpawnOptions
    4. Worker
  8. Functions
    1. current
    2. parked
    3. spawn
  9. Values
    1. path

tecs.workers

Worker threads and channels.

A worker is source text and two queues. It shares nothing with the state that spawned it, so it reaches its own ends through workers.current, and spawn pairs with stop:

local worker <const> = tecs.workers.spawn({
    source = [[
    local tecs <const> = require("tecs")
    local self <const> = tecs.workers.current()
    while true do
        local job = self:receive()
        if job == nil then break end
        self:send({name = job.name, hash = tecs.data.fnv1a64(job.bytes)})
    end
]],
})

worker:send({name = "level1", bytes = "..."})
local answer <const> = worker:receive(1000)
worker:stop()

A worker that answers requests rather than streaming results is a call server, and Worker:call reads as an ordinary function call on this side while Self:serve runs the loop on the other:

local worker <const> = tecs.workers.spawn({
    source = [[
    local tecs <const> = require("tecs")
    tecs.workers.current():serve(function(job)
        return {name = job.name, hash = tecs.data.fnv1a64(job.bytes)}
    end)
]],
})

local answer <const> = worker:call({name = "level1", bytes = "..."})
worker:stop()

A channel is a stream, so call numbers each request and takes the reply that carries the same number. Messages the worker sends outside a call stay queued for receive, and a reply nobody awaits any more is discarded.

The worker's source writes a require line because it is a separate state with a separate global table, which is the one place in a game that does.

This is the only sanctioned way to run work off the main thread. Raw thread creation is deliberately not exposed: a LuaJIT FFI callback invoked from a thread the VM did not create is unsafe, and a thread entry point written in Lua is exactly that mistake.

LuaJIT has no shared mutable heap across threads, so a worker cannot see the spawning state's objects at all. Values cross as serialized bytes, encoded here with string.buffer.

What can cross is therefore what string.buffer can encode: numbers, strings, booleans, and tables of those. Not functions, not userdata, and not cdata pointers into another state's heap.

Waiting on each side

The two receive calls have opposite defaults, and the asymmetry is deliberate rather than an oversight.

Worker:receive runs on the thread SDL drives, so it polls by default and never blocks that thread when asked to wait. A timeout suspends the calling system cooperatively and resumes it where it left off; outside a system it blocks the caller while the worker makes progress, which is what startup, shutdown, and headless tools want.

Self:receive, the worker side, waits by default. It runs on the worker's own thread, where blocking costs nothing a frame can see, and an idle worker that polled instead would spin a core.

A cooperative wait is served by the runtime pump, which runs once per frame, so a result costs up to one extra frame against the arrival that produced it. That is the same trade every other Tecs producer makes, and it buys a frame that keeps rendering while the worker computes. Worker:call pays the same frame, and outside a system it blocks its caller and pays none of it.

Several calls at once

One wait parks the whole logical update, so two calls written in a row cost the sum of both. tecs.batch runs them at the same time and returns their results in the order the callbacks were given, whatever order the replies arrive in:

local answers <const> = tecs.batch({
    function(): any
        return hashers[1]:call({name = "level1", bytes = first})
    end,
    function(): any
        return hashers[2]:call({name = "level2", bytes = second})
    end,
})

One worker serves its own requests one at a time, so overlapping the waits buys time when the calls go to different workers and buys none when they do not.

Polling and shutdown

Closing the inbox wakes the worker and returns nil after queued messages drain. The worker closes its outbox when its source ends, so a spawner waiting on a result learns that no result is coming rather than waiting forever.

stop closes the inbox, joins the thread, and releases both channels. Worker source must leave its receive loop when the inbox closes or shutdown can wait indefinitely. stop also releases every suspended Worker:receive on that worker with nil, so shutting a worker down never strands a system.

Set TECS_TRACEPROF to print a worker's trace aborts when its inbox closes. Repeated failed to allocate mcode memory messages identify LuaJIT machine-code allocation failures.

Module contents

Constructors

Constructor Description
newChannel Creates an independent channel.

Types

Type Kind Description
Channel record Carries serialized messages between two states.
Self record Contains the worker's two channel endpoints returned by workers.current.
SpawnOptions record Options for workers.spawn.
Worker record Represents a thread with its own Lua state and two message channels.

Functions

Function Kind Description
current Static Returns the channels for the current worker.
parked Static Reports how many Worker:receive and Worker:call waits are suspended.
spawn Static Starts a worker running options.source on its own thread and state.

Values

Value Type Description
path string Read-only. Contains the absolute path of the loaded native library.

Constructors

tecs.workers.newChannel Static

Creates an independent channel.

function tecs.workers.newChannel(): Channel

Arguments

None.

Returns

Type Description
Channel An owning handle. destroy releases the native queue, so exactly one state should hold it. Raises when the queue cannot be created.

Types

tecs.workers.Channel record

Carries serialized messages between two states.

The channel copies every value across and never shares it, so the receiver gets a separate object and mutating either one is invisible to the other. A nil is encodable and does cross, but receive also answers nil for "nothing here", so nil is not usable as a message a reader can recognize. Read-only. Exposes the Channel type.

record tecs.workers.Channel is Closeable
    handle: loader.CPtr

    wrap: function(handle: loader.CPtr): Channel
    count: function(self): integer
    destroy: function(self)
    isClosed: function(self): boolean
    receive: function(self, timeoutMs: number): any
    send: function(self, value: any)
end

Interfaces

Interface
Closeable

tecs.workers.Channel.handle field

Engine-owned. Stores the native queue pointer until destroy runs. Ordinary game code should ignore this field.

tecs.workers.Channel.handle: loader.CPtr

tecs.workers.Channel.wrap Static

Wraps a channel pointer handed over by the native side.

function tecs.workers.Channel.wrap(handle: loader.CPtr): Channel
Arguments
Name Type Description
handle loader.CPtr The native side keeps this pointer alive for the wrapper's lifetime.
Returns
Type Description
Channel A borrowing handle. destroy does nothing, so the state that created the queue remains responsible for releasing it.

tecs.workers.Channel:count Instance

function tecs.workers.Channel.count(self): integer
Arguments
Name Type Description
self Channel
Returns
Type Description
integer

tecs.workers.Channel:destroy Instance

function tecs.workers.Channel.destroy(self)
Arguments
Name Type Description
self Channel
Returns

None.

tecs.workers.Channel:isClosed Instance

Reports whether the channel is closed.

Closed and empty is the one state that will never produce another value, which is what separates a reader that should give up from one that should wait. Queued messages still arrive after a close, so a closed channel that still has a count is not yet finished.

function tecs.workers.Channel.isClosed(self): boolean
Arguments
Name Type Description
self Channel
Returns
Type Description
boolean True once close has run on either side, whether or not messages remain queued. A destroyed channel also reports true.

tecs.workers.Channel:receive Instance

Takes the next value, or nil if none arrived.

A zero timeout polls, a negative timeout waits indefinitely, and any other value waits that many milliseconds.

function tecs.workers.Channel.receive(self, timeoutMs: number): any
Arguments
Name Type Description
self Channel
timeoutMs number The timeout uses milliseconds. Omit it to poll, which is the opposite of the worker-side receive, where omission waits.
Returns
Type Description
any The next value, or nil. Nil covers an empty queue, a timeout, and a closed and drained channel. A sent nil also decodes to nil, so callers cannot distinguish those cases and should not send nil.

tecs.workers.Channel:send Instance

Serializes value and queues it without blocking.

Each direction accepts at most 1024 messages and 256 MiB of serialized bytes. The call raises when either bound is full; it never blocks the sending thread.

function tecs.workers.Channel.send(self, value: any)
Arguments
Name Type Description
self Channel
value any The channel copies this value before returning. A function, userdata, cdata, a table nested past 32 levels, or a table with a key of any of those raises and names the path to it.
Returns

None.

tecs.workers.Self record

Contains the worker's two channel endpoints returned by workers.current.

global record tecs.workers.Self
    receive: function(self, timeoutMs: number): any
    send: function(self, value: any)
    serve: function(self, handler: function(any): any)
end

tecs.workers.Self:receive Instance

Takes the next task, or nil when the inbox closes.

function tecs.workers.Self.receive(self, timeoutMs: number): any
Arguments
Name Type Description
self Self
timeoutMs number Milliseconds. Omitted waits indefinitely, which is the opposite of Channel:receive; zero polls. Only the waiting form's nil means the spawner has asked this worker to stop, since a poll answers nil for a merely empty queue.
Returns
Type Description
any The next task, decoded into this state's own tables, so it shares nothing with what the spawner sent and mutating it is safe. Nil is the stop signal or an empty queue, on the terms above.

tecs.workers.Self:send Instance

Returns a result to the spawner.

function tecs.workers.Self.send(self, value: any)
Arguments
Name Type Description
self Self
value any Subject to the same item and byte limits as Channel:send, and raises the same way.
Returns

None.

tecs.workers.Self:serve Instance

Answers Worker:call requests until the inbox closes.

This is the worker side of a call: it reads a request, runs handler, and sends what the handler returned back under the identifier the request arrived with, which is what lets the spawner match a reply to the call that is waiting for it.

Every message reaches handler, so a worker that also receives ordinary Worker:send messages serves both from one loop. A message that is not a call request takes no reply, and the handler's return value for one is discarded; answer those with send instead.

A handler that raises does not end the worker. Its reason crosses back and Worker:call raises it at the call site, and the loop reads the next request.

local tecs <const> = require("tecs")
tecs.workers.current():serve(function(job: any): any
    return {hash = tecs.data.fnv1a64(job.bytes)}
end)
function tecs.workers.Self.serve(self, handler: function(any): any)
Arguments
Name Type Description
self Self
handler function(any): any The worker runs this once per message and sends its first return value back as the reply. A return value that cannot be serialized reaches the caller as a failure rather than raising on this thread. Nil raises.
Returns

None.

tecs.workers.SpawnOptions record

Options for workers.spawn.

global record tecs.workers.SpawnOptions
    source: string
    luaPath: string
end

tecs.workers.SpawnOptions.source field

Caller-writable. Sets the Lua source the worker runs. It reaches its channels through workers.current(). Required; nil raises. Source text, not a path, and it runs in a state that shares nothing with this one, so it cannot close over anything here.

tecs.workers.SpawnOptions.source: string

tecs.workers.SpawnOptions.luaPath field

Caller-writable. Sets package.path for the worker's state. Defaults to this state's, so a worker resolves the same modules the spawner does.

tecs.workers.SpawnOptions.luaPath: string

tecs.workers.Worker record

Represents a thread with its own Lua state and two message channels.

The worker shares no globals, upvalues or loaded modules with the spawning state. The worker reruns its own requires against luaPath. Read-only. Exposes the Worker type.

record tecs.workers.Worker
    pending: integer

    available: function(self): integer
    call: function(self, value: any): any
    receive: function(self, timeoutMs: number): any
    send: function(self, value: any)
    stop: function(self): integer
end

tecs.workers.Worker.pending field

Read-only. Reports the messages the worker had not taken after the last send. The value does not refresh between sends.

tecs.workers.Worker.pending: integer

tecs.workers.Worker:available Instance

function tecs.workers.Worker.available(self): integer
Arguments
Name Type Description
self Worker
Returns
Type Description
integer

tecs.workers.Worker:call Instance

Sends a request, waits for its answer, and returns it.

The call reads as an ordinary function call: the worker runs the request and the reply arrives at this line. A channel is a stream, so each call carries an identifier and takes only the reply that carries the same one. Messages the worker sends outside a call stay queued for receive and are never consumed here.

A wait suspends the calling system cooperatively and resumes it at this call, so other systems, rendering, and input keep running; outside a system the call blocks its own caller instead. A suspended call is served by the runtime pump, which runs once per frame, so a reply can arrive up to one frame after the worker sent it. Several calls placed in tecs.batch run at the same time and their results come back in argument order.

Both halves cross as serialized bytes, exactly as send does, so a request and a reply carry numbers, strings, booleans, and tables of those. A live handle, a socket, a file, or cdata cannot cross, and a worker that returns one has its call fail rather than its thread.

The worker answers with Self:serve, which is the loop that reads a request and sends its handler's result back under the same identifier.

function tecs.workers.Worker.call(self, value: any): any
Arguments
Name Type Description
self Worker
value any The request the worker receives, under the same limits and errors as Worker:send.
Returns
Type Description
any The value the worker's handler returned for this request. Raises the worker's own reason when its handler failed, and raises when the worker ended, was stopped, or had already been stopped, because no reply can follow any of those.

tecs.workers.Worker:receive Instance

Takes a result, waiting for one when asked.

A ready result returns inline. A wait suspends the calling system cooperatively and resumes it at this call, so other systems, rendering, and input keep running; outside a system the call blocks its caller instead. Never blocks the SDL thread from inside a system.

A suspended wait is served by the runtime pump, which runs once per frame, so a result can arrive up to one frame after the worker sent it.

function tecs.workers.Worker.receive(self, timeoutMs: number): any
Arguments
Name Type Description
self Worker
timeoutMs number The timeout uses milliseconds. Omit it or pass zero to poll and return immediately, which is the opposite of the worker-side receive, where omission waits. A negative value waits until a result arrives or the worker ends, and any other value waits at most that long.
Returns
Type Description
any The next result, or nil. Nil covers an empty queue, an expired timeout, and a worker that has ended without sending one. A stopped worker answers nil without waiting.

tecs.workers.Worker:send Instance

Queues a value for the worker and refreshes pending.

function tecs.workers.Worker.send(self, value: any)
Arguments
Name Type Description
self Worker
value any The worker applies the same limits and errors as Channel:send.
Returns

None.

tecs.workers.Worker:stop Instance

function tecs.workers.Worker.stop(self): integer
Arguments
Name Type Description
self Worker
Returns
Type Description
integer

Functions

tecs.workers.current Static

Returns the channels for the current worker.

Only valid in a worker's state, where the native entry point installed the pointers as globals before running the source.

function tecs.workers.current(): Self

Arguments

None.

Returns

Type Description
Self A fresh object each call, borrowing channels the spawner owns. Call it once and keep the result: a second call makes a second pair of wrappers over the same two queues, and under TECS_TRACEPROF a second trace session, which raises.

tecs.workers.parked Static

Reports how many Worker:receive and Worker:call waits are suspended.

A suspended receiver is a system parked on a worker result or a call reply. The count exists for tests and debug tooling that need to see the wait rather than infer it; ordinary game code has no use for it.

function tecs.workers.parked(): integer

Arguments

None.

Returns

Type Description
integer The number of receivers waiting across every worker. Zero once they have all resumed, been canceled, or had their worker stopped.

tecs.workers.spawn Static

Starts a worker running options.source on its own thread and state.

Returns as soon as the thread starts, not when the source has run, so a failure inside the source surfaces as stop's status rather than here.

function tecs.workers.spawn(options: SpawnOptions): Worker

Arguments

Name Type Description
options SpawnOptions

Returns

Type Description
Worker A worker the caller must stop. Collection does not join the thread or release its channels.

Values

tecs.workers.path variable

Read-only. Contains the absolute path of the loaded native library.

tecs.workers.path: string