Skip to content

ActorPool

A Parallel Luau worker pool for pure CPU work, with one-job dispatch, self-balancing fork-join batches, event-driven readiness and per-job timeouts.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.ActorPool
Since
v0.1.0
Source
ActorPool/init.luau

ActorPool runs heavy CPU work on real parallel threads, Parallel Luau actors, behind two yielding calls: pool:dispatch(...) runs one job, and pool:forkJoin(items) spreads a batch across every actor and stitches the results back in order. The constraint that defines it: workers are pure functions with no shared state. Grid pathfinding batches, terrain and loot generation, bulk validation: plain data in, plain data out. Never anything that changes state the rest of the server can see. Work that touches game state belongs on the Scheduler instead.

ActorPool.new builds the machinery once:

  • A Folder named CKActorPool (under ServerScriptService by default) holding Size Actor instances named Worker1 to WorkerN.
  • Inside each actor: an ObjectValue named WorkerModule pointing at your module, a BindableEvent named CKResult for replies, and a clone of the pool’s worker script. The template ships disabled and returns at once when script:GetActor() is nil, so only the enabled clones inside actors run.

Each worker script requires your module once, inside a pcall, then binds two parallel message handlers (CKJob for dispatch, CKChunk for forkJoin) and sets the actor’s CKReady attribute. If the require fails, it sets CKDead to the error instead and binds nothing.

Actors silently drop messages sent before their handler is bound, so the pool never sends to an actor that is not ready. A readiness wait returns at once when CKReady or CKDead is already set. Otherwise it listens for either attribute to change, with one timeout of TimeoutSeconds. Nothing polls. A dead worker fails the wait with worker module failed to require: <error>, so every job sent to it fails fast instead of burning a timeout. destroy() settles every readiness wait at once with pool destroyed.

  1. dispatch(...) assigns a job id and picks the next actor round-robin.
  2. It waits for that actor’s readiness (above).
  3. It parks the calling thread under the job id, arms one timeout of TimeoutSeconds for the job, and sends Actor:SendMessage("CKJob", jobId, ...). The caller yields.
  4. On the actor, the handler runs pcall(workerFn, ...) in the parallel phase, where the actual parallelism happens, then calls task.synchronize() and fires CKResult with (jobId, ok, result).
  5. The pool’s listener cancels the job’s timeout and resumes the parked thread with (ok, result). When the timeout fired first, the job is already gone and the late reply is dropped.

Results route by job id, so concurrent dispatches from different threads never receive each other’s answers. A caller that was cancelled while it waited (task.cancel, a killed process) is dead and is never resumed.

forkJoin(items, options?) turns one batch into balanced parallel work. The worker function runs once per item as fn(item, context).

  1. The batch splits into chunks. The default chunk size is ceil(#items / (Size x 4)): four waves per actor, so uneven items even out. ChunkSize overrides it.
  2. The call waits for every actor to be ready, under one shared deadline. An early message would be dropped and the join would stall.
  3. Each actor gets one chunk. The moment an actor’s chunk comes back, it gets the next one, so a slow chunk never idles the rest of the pool.
  4. Results are written back at their absolute offsets and the caller resumes once with (true, results), where results[i] is fn(items[i], context).

A chunk fails on its first item error, reported with the item’s absolute index (item 5: poisoned 5). A failure stops new sends, chunks already out drain, and the call returns (false, firstError). A chunk timeout fails the join the same way. When the caller is cancelled while it waits, the join stops sending chunks. An empty batch returns (true, {}) at once. Compose phases by calling forkJoin again with the joined results.

A worker module returns one function. Its top level runs serially when the actor requires it, so resolve services and build lookup tables there; the returned function runs in parallel.

src/Server/Game/Workers/SightWorker.luau
--!strict
-- SightWorker: line-of-sight tests for the pool. Pure: no writes, no state between jobs.
local Workspace = workspace -- resolved at require time, which runs serially
return function(eye: Vector3, target: Vector3): boolean
return Workspace:Raycast(eye, target - eye) == nil -- Raycast is thread safe
end

A batch goes through forkJoin, with a worker that takes (item, context):

src/Server/Game/Workers/CoverWorker.luau
--!strict
-- CoverWorker: scores one cover request against a shared grid. fn(item, context) per item.
return function(request: { X: number, Z: number }, context: any): number
local Score = 0
for DZ = -2, 2 do
for DX = -2, 2 do
local Cell = context.Cells[(request.Z + DZ - 1) * context.Width + request.X + DX]
if Cell == true then
Score += 1
end
end
end
return Score
end

A service owns the pools and hides the yields behind two methods. Large read-only data goes through shareContext, so every chunk reads one frozen SharedTable instead of receiving its own copy:

src/Server/Game/Services/SightService.luau
--!strict
-- SightService: parallel line-of-sight tests and cover scoring for NPC planning.
local ServerScriptService = game:GetService("ServerScriptService")
local ActorPool = require(ServerScriptService.ChloeKernelServer.ActorPool)
local Workers = script.Parent.Parent.Workers
local SightService = {
Name = "SightService",
}
function SightService.init(self: any, _kernel: any)
self.Sight = ActorPool.new({ Size = 4, WorkerModule = Workers.SightWorker })
self.Cover = ActorPool.new({ Size = 4, WorkerModule = Workers.CoverWorker })
end
-- Yields until a worker replies. Call from a spawned thread, never from a Scheduler job.
function SightService.isClear(self: any, eye: Vector3, target: Vector3): boolean
local Ok, Clear = self.Sight:dispatch(eye, target)
return Ok and Clear == true
end
-- Yields. Scores[i] belongs to requests[i]; nil when a chunk failed or timed out.
function SightService.scoreCover(self: any, requests: { any }, cells: { boolean }, width: number): { number }?
-- Build the shared grid once and keep it when the map does not change between calls
local Grid = ActorPool.shareContext({ Cells = cells, Width = width })
local Ok, Scores = self.Cover:forkJoin(requests, { Context = Grid })
return if Ok then Scores else nil
end
function SightService.stop(self: any)
if self.Sight then
self.Sight:destroy()
self.Cover:destroy()
end
end
return SightService

The worker template (Worker.server.luau) sets the contract:

  • Return one function. Its arguments are the dispatch arguments (or item, context for fork-join), and its return value is the result.
  • It runs desynchronized. Call only members the engine reference marks thread safe: WorldRoot Raycast, Blockcast, Spherecast, GetPartBoundsInBox, GetPartBoundsInRadius and GetPartsInPart, Instance:GetAttribute and FindFirstChild, CollectionService:GetTagged, and ServiceProvider:FindService. GetService, SetAttribute, Shapecast, Model:GetBoundingBox, Players:GetPlayerFromCharacter and PathfindingService (CreatePath, ComputeAsync) are unsafe there. Writing instances or firing remotes is never safe.
  • Errors are contained. The function runs under pcall, and a throw comes back as (false, message).
  • A failed require marks the worker dead (CKDead), and every job sent to it fails at once.
  • Each actor requires its own copy of the worker module and of everything it requires. No module state is shared with other actors or the main thread, and framework singletons such as Kernel.current() are not booted there.

Arguments and results are copied across the boundary (SendMessage in, BindableEvent out). Actors share no Luau heap:

Crosses Does not cross
Numbers, strings, booleans Functions and threads
Roblox data types: Vector3, CFrame, Color3 and so on Metatables: tables arrive as plain data, so no objects with methods
Tables of the above, deep-copied per crossing Anything self-referential
Instance references Writes to a passed table: the worker changes a copy the caller never sees
A SharedTable from shareContext, shared instead of copied

Design payloads as plain data in and plain data out. Big tables copy on every crossing. For data every job needs, either build it in the worker module’s top level (once per actor) or pass it through shareContext as the fork-join Context. shareContext accepts string or whole-number keys and values that are booleans, numbers, strings, vectors, nested tables or other serializable engine types.

Scheduler ActorPool
Concurrency Cooperative slices on the main thread under a frame budget Real parallelism on separate Luau VMs
Best for Work that touches game state: it runs serially, so there are no races Pure functions over plain data: search math, generation, validation
Cost Spreads work across frames; total CPU unchanged Multiplies throughput by core count; pays copy costs at the boundary
Yields Jobs must not yield dispatch and forkJoin yield: call them from a spawned thread, never inside a Scheduler job

If the work reads or writes anything another system owns, it belongs on the Scheduler. If it is a function you could unit-test without a DataModel, it belongs on actors. Pathfinding searches over a grid with an injected IsWalkable are the model actor workload; grids on the default rasterizer and the engine navmesh are not.

Member Description
ActorPool.new(options) -> pool Builds the folder and actors. Asserts Size >= 1 and an Instance WorkerModule.
pool:dispatch(...) -> (ok, result) Yields until the round-robin actor replies or the job times out. (true, result) or (false, message).
pool:forkJoin(items, options?) -> (ok, resultsOrError) Yields until every item is processed or a chunk fails. options: { ChunkSize?, Context? }. (true, results) in item order, or (false, firstError).
ActorPool.shareContext(context) -> SharedTable A deep-frozen SharedTable copy of a read-only table, for forkJoin’s Context.
pool:destroy() Settles readiness waits, cancels job timeouts, resumes every pending caller with (false, "pool destroyed"), and destroys the folder and actors.

ActorPool.new options:

Option Default Description
Size required Actor count. More actors than cores only queues.
WorkerModule required The ModuleScript that returns the worker function. Required once per actor.
Parent ServerScriptService Where the CKActorPool folder lives.
TimeoutSeconds 10 Per-job deadline, and the readiness wait cap.

Failure messages:

Message Meaning
pool destroyed The call came after destroy(), or destroy() ran while it waited.
worker module failed to require: <error> The module errored at require. Every job to that actor fails at once until the module is fixed.
worker never became ready The actor did not bind its handlers within TimeoutSeconds.
worker timed out No reply within TimeoutSeconds. The job may still be running.
item <n>: <error> forkJoin: the worker function threw on item n.
anything else dispatch: the worker function’s own error.

ActorPool publishes and consumes nothing on the Bus. It is a call-and-return primitive; wrap it in a service if you want events around job completion.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.