On this page
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(): ChannelArguments
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)
endInterfaces
| 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.wrap Static
Wraps a channel pointer handed over by the native side.
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): integerArguments
| 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): booleanArguments
| 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): anyArguments
| 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)
endtecs.workers.Self:receive Instance
Takes the next task, or nil when the inbox closes.
function tecs.workers.Self.receive(self, timeoutMs: number): anyArguments
| 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.
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: stringtecs.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: stringtecs.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
endtecs.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:available Instance
function tecs.workers.Worker.available(self): integerArguments
| 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): anyArguments
| 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): anyArguments
| 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): integerArguments
| 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(): SelfArguments
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(): integerArguments
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): WorkerArguments
| 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