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.
Mental model
Section titled “Mental model”ActorPool.new builds the machinery once:
- A
FoldernamedCKActorPool(underServerScriptServiceby default) holdingSizeActorinstances namedWorker1toWorkerN. - Inside each actor: an
ObjectValuenamedWorkerModulepointing at your module, aBindableEventnamedCKResultfor replies, and a clone of the pool’s worker script. The template ships disabled and returns at once whenscript: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.
Readiness
Section titled “Readiness”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.
A dispatch
Section titled “A dispatch”dispatch(...)assigns a job id and picks the next actor round-robin.- It waits for that actor’s readiness (above).
- It parks the calling thread under the job id, arms one timeout of
TimeoutSecondsfor the job, and sendsActor:SendMessage("CKJob", jobId, ...). The caller yields. - On the actor, the handler runs
pcall(workerFn, ...)in the parallel phase, where the actual parallelism happens, then callstask.synchronize()and firesCKResultwith(jobId, ok, result). - 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.
Fork-join
Section titled “Fork-join”forkJoin(items, options?) turns one batch into balanced parallel work. The worker function runs once per item as
fn(item, context).
- The batch splits into chunks. The default chunk size is
ceil(#items / (Size x 4)): four waves per actor, so uneven items even out.ChunkSizeoverrides it. - The call waits for every actor to be ready, under one shared deadline. An early message would be dropped and the join would stall.
- 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.
- Results are written back at their absolute offsets and the caller resumes once with
(true, results), whereresults[i]isfn(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.
--!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 safeendA batch goes through forkJoin, with a worker that takes (item, context):
--!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 ScoreendA 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:
--!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 == trueend
-- 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 nilend
function SightService.stop(self: any) if self.Sight then self.Sight:destroy() self.Cover:destroy() endend
return SightServiceWriting a worker
Section titled “Writing a worker”The worker template (Worker.server.luau) sets the contract:
- Return one function. Its arguments are the dispatch arguments (or
item, contextfor fork-join), and its return value is the result. - It runs desynchronized. Call only members the engine reference marks thread safe:
WorldRootRaycast,Blockcast,Spherecast,GetPartBoundsInBox,GetPartBoundsInRadiusandGetPartsInPart,Instance:GetAttributeandFindFirstChild,CollectionService:GetTagged, andServiceProvider:FindService.GetService,SetAttribute,Shapecast,Model:GetBoundingBox,Players:GetPlayerFromCharacterand 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.
Crossing the actor boundary
Section titled “Crossing the actor boundary”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.
Actors and the scheduler
Section titled “Actors and the scheduler”| 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.
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”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.