C# workers: jobs, cancellation and a pool without Entity access
Complete .NET Task and WorkerThreadPool examples: input ownership, main-thread results and exit cleanup.
Run the example
Add the five files below to a 1.0.0 C# project. Create a separate level with an Entity root, attach PrimeJobsDemo.cs, build and press F6. Do not attach helper classes manually: the demo creates them.
Expect JOB primes=9592 main_thread=True and POOL primes=1229,9592, in either order. The first job counts through 100000; the group counts through 10000 and 100000. This is a simple verifiable CPU workload, not a gameplay algorithm or speed benchmark.
PrimeWork.cs
using System;
using System.Threading;
public static class PrimeWork
{
public const int MaxLimit = 2_000_000;
public static int Count(int limit, CancellationToken token = default)
{
if (limit < 0 || limit > MaxLimit)
throw new ArgumentOutOfRangeException(nameof(limit));
token.ThrowIfCancellationRequested();
int count = 0;
for (int number = 2; number <= limit; number++)
{
if ((number & 127) == 0) token.ThrowIfCancellationRequested();
bool prime = true;
for (int divisor = 2; divisor <= number / divisor; divisor++)
if (number % divisor == 0) { prime = false; break; }
if (prime) count++;
}
token.ThrowIfCancellationRequested();
return count;
}
}BackgroundJob.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Reckfell;
public partial class BackgroundJob : Entity
{
public enum Outcome { Idle, Running, Completed, Cancelled, Failed }
public bool Busy => _task is not null;
public Outcome State { get; private set; }
public int Result { get; private set; }
public string LastError { get; private set; } = "";
public event Action? Settled;
private Task<int>? _task;
private CancellationTokenSource? _cancellation;
public Error Start(Func<CancellationToken, int>? work)
{
if (!ReckfellThread.IsMainThread()) return Error.Unavailable;
if (!IsInsideTree()) return Error.Unconfigured;
if (work is null) return Error.InvalidParameter;
if (Busy) return Error.Busy;
var source = new CancellationTokenSource();
CancellationToken token = source.Token;
_cancellation = source;
Result = 0;
LastError = "";
State = Outcome.Running;
_task = Task.Run(() => work(token), token);
SetProcess(true);
return Error.Ok;
}
public void Cancel()
{
if (!ReckfellThread.IsMainThread())
throw new InvalidOperationException("Cancel must run on the main thread");
_cancellation?.Cancel();
}
public override void _Process(double delta)
{
if (_task is not { IsCompleted: true } task) return;
var source = _cancellation!;
_task = null;
_cancellation = null;
SetProcess(false);
try
{
int result = task.GetAwaiter().GetResult();
State = source.IsCancellationRequested ? Outcome.Cancelled : Outcome.Completed;
if (State == Outcome.Completed) Result = result;
}
catch (OperationCanceledException) when (source.IsCancellationRequested)
{
State = Outcome.Cancelled;
}
catch (Exception error)
{
State = Outcome.Failed;
LastError = error.GetType().Name + ": " + error.Message;
}
finally { source.Dispose(); }
Settled?.Invoke();
}
public override void _ExitTree()
{
var task = _task;
var source = _cancellation;
_task = null;
_cancellation = null;
State = Outcome.Idle;
SetProcess(false);
if (task is null || source is null) return;
source.Cancel();
_ = task.ContinueWith(static (finished, state) =>
{
_ = finished.Exception;
((CancellationTokenSource)state!).Dispose();
}, source, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}PoolPrimeBatch.cs
using System;
using System.Threading;
using Reckfell;
public partial class PoolPrimeBatch : Entity
{
private sealed class Batch(int[] limits)
{
public readonly int[] Limits = limits;
public readonly int[] Results = new int[limits.Length];
public readonly Exception?[] Errors = new Exception?[limits.Length];
public readonly CancellationTokenSource Cancellation = new();
public void Compute(int index)
{
try { Results[index] = PrimeWork.Count(Limits[index], Cancellation.Token); }
catch (Exception error) { Errors[index] = error; }
}
}
public bool Busy => _group >= 0;
public BackgroundJob.Outcome State { get; private set; }
public int[] Results => (int[])_results.Clone();
public string LastError { get; private set; } = "";
public event Action? Settled;
private long _group = -1;
private Batch? _batch;
private int[] _results = [];
public Error Start(int[]? limits)
{
if (!ReckfellThread.IsMainThread()) return Error.Unavailable;
if (!IsInsideTree()) return Error.Unconfigured;
if (Busy) return Error.Busy;
if (limits is null || limits.Length < 1 || limits.Length > 32)
return Error.InvalidParameter;
int[] copy = (int[])limits.Clone();
foreach (int limit in copy)
if (limit < 0 || limit > PrimeWork.MaxLimit) return Error.InvalidParameter;
_batch = new Batch(copy);
_results = [];
LastError = "";
State = BackgroundJob.Outcome.Running;
_group = WorkerThreadPool.AddGroupTask(Callable.From<int>(_batch.Compute),
copy.Length, description: "Prime counts");
if (_group < 0)
{
_batch.Cancellation.Dispose();
_batch = null;
State = BackgroundJob.Outcome.Failed;
LastError = "Could not schedule group";
return Error.CantCreate;
}
SetProcess(true);
return Error.Ok;
}
public void Cancel()
{
if (!ReckfellThread.IsMainThread())
throw new InvalidOperationException("Cancel must run on the main thread");
_batch?.Cancellation.Cancel();
}
public override void _Process(double delta)
{
if (!Busy || !WorkerThreadPool.IsGroupTaskCompleted(_group)) return;
WorkerThreadPool.WaitForGroupTaskCompletion(_group);
_group = -1;
Batch batch = _batch!;
_batch = null;
SetProcess(false);
State = batch.Cancellation.IsCancellationRequested
? BackgroundJob.Outcome.Cancelled : BackgroundJob.Outcome.Completed;
foreach (Exception? error in batch.Errors)
{
if (error is null || error is OperationCanceledException &&
batch.Cancellation.IsCancellationRequested) continue;
State = BackgroundJob.Outcome.Failed;
LastError = error.GetType().Name + ": " + error.Message;
break;
}
if (State == BackgroundJob.Outcome.Completed) _results = batch.Results;
batch.Cancellation.Dispose();
Settled?.Invoke();
}
public override void _ExitTree()
{
SetProcess(false);
if (!Busy) return;
_batch!.Cancellation.Cancel();
WorkerThreadPool.WaitForGroupTaskCompletion(_group);
_batch.Cancellation.Dispose();
_batch = null;
_group = -1;
State = BackgroundJob.Outcome.Idle;
}
}JobTelemetry.cs
using System;
using Reckfell;
public partial class JobTelemetry : Entity
{
public string SecondsId { get; private set; } = "";
public string CountId { get; private set; } = "";
public double Seconds { get; private set; }
public int Count { get; private set; }
private bool _registered;
public override void _EnterTree()
{
string category = "docs_perf_" + GetInstanceId();
SecondsId = category + "/prime_seconds";
CountId = category + "/prime_count";
Performance.AddCustomMonitor(SecondsId, Callable.From(() => Seconds),
type: Performance.MonitorType.Time);
Performance.AddCustomMonitor(CountId, Callable.From(() => Count),
type: Performance.MonitorType.Quantity);
_registered = true;
}
public void Publish(double seconds, int count)
{
if (!ReckfellThread.IsMainThread())
throw new InvalidOperationException("Publish must run on the main thread");
if (!double.IsFinite(seconds) || seconds < 0 || count < 0)
throw new ArgumentOutOfRangeException(nameof(seconds));
Seconds = seconds;
Count = count;
}
public override void _ExitTree()
{
if (!_registered) return;
Performance.RemoveCustomMonitor(SecondsId);
Performance.RemoveCustomMonitor(CountId);
_registered = false;
}
}PrimeJobsDemo.cs
using System.Diagnostics;
using Reckfell;
public partial class PrimeJobsDemo : Entity
{
private readonly Stopwatch _clock = new();
private BackgroundJob _job = null!;
private PoolPrimeBatch _pool = null!;
public JobTelemetry Telemetry { get; private set; } = null!;
public override void _Ready()
{
Telemetry = new JobTelemetry();
_job = new BackgroundJob();
_pool = new PoolPrimeBatch();
AddChild(Telemetry);
AddChild(_job);
AddChild(_pool);
_job.Settled += OnJobSettled;
_pool.Settled += OnPoolSettled;
_clock.Restart();
Error error = _job.Start(static token => PrimeWork.Count(100_000, token));
if (error != Error.Ok) ReckfellApi.PrintErr("JOB start: " + error);
error = _pool.Start([10_000, 100_000]);
if (error != Error.Ok) ReckfellApi.PrintErr("POOL start: " + error);
}
private void OnJobSettled()
{
_clock.Stop();
if (_job.State == BackgroundJob.Outcome.Completed)
{
Telemetry.Publish(_clock.Elapsed.TotalSeconds, _job.Result);
ReckfellApi.Print($"JOB primes={_job.Result} main_thread={ReckfellThread.IsMainThread()}");
}
else ReckfellApi.PrintErr("JOB: " + _job.State + " " + _job.LastError);
}
private void OnPoolSettled()
{
if (_pool.State == BackgroundJob.Outcome.Completed)
ReckfellApi.Print("POOL primes=" + string.Join(",", _pool.Results));
else ReckfellApi.PrintErr("POOL: " + _pool.State + " " + _pool.LastError);
}
public override void _ExitTree()
{
_job.Settled -= OnJobSettled;
_pool.Settled -= OnPoolSettled;
}
}Thread boundaries
PrimeWork uses only numbers and a CancellationToken. Task.Run receives a static lambda without a captured Entity. PoolPrimeBatch uses a separate Batch containing an input-array copy; one invocation writes each output index. The main thread neither reads nor modifies that array during computation.
BackgroundJob polls Task completion in _Process. Only after IsCompleted does it retrieve the result and invoke Settled; it does not block a frame waiting for running work. Fault handling happens there too. Using Task.Run or await does not authorize Entity access from arbitrary continuations: execution depends on context and how work is awaited. Here _Process explicitly defines where results are applied.
Run Start/Cancel and component management on the main thread. A second Start returns Error.Busy while Busy. Do not create a task every frame. To replace a request, cancel the old one, wait until completion is processed, then start another. This component does not implement a latest-request queue.
Cancellation and level exit
Cancel is a request. PrimeWork regularly checks its token and throws OperationCanceledException; it does not forcibly terminate a thread. Busy remains true until completion is consumed. A cancellation request before result consumption discards even an already computed result. A fault produces Failed and LastError; do not represent it as a normal result of 0.
On exit, BackgroundJob cancels and detaches from the work. Its continuation observes faults and disposes CancellationTokenSource without capturing the Entity or invoking Settled. The worker must therefore own independent data and cooperate with cancellation. Removing the component cannot make a lambda capturing a Label, Resource or current-level reference safe.
After IsGroupTaskCompleted, PoolPrimeBatch always calls WaitForGroupTaskCompletion to release ownership of the native group. Do not poll its ID again afterward. On tree exit it requests cancellation and joins the group before releasing its data. This can briefly delay exit: cancellation of this bounded CPU operation is cooperative. Do not add indefinite main-thread waits, network responses or long blocking operations to such a worker.
Results become available after completion; Results returns a copy. The group accepts 1–32 inputs, each from 0 through 2000000. These are example bounds. Element count is not thread count: tasksNeeded controls task partitioning, while execution depends on the pool.
Choose an API
For C# work with exceptions and CancellationToken, .NET Task is usually sufficient. WorkerThreadPool runs through the engine pool, including indexed groups. It neither makes Entity/API thread-safe nor cancels your loops automatically. For a single AddTask, also call WaitForTaskCompletion once after completion and check Error.
For a dedicated ReckfellThread, Start accepts a Callable, IsAlive reports execution, and IsStarted remains true until WaitToFinish even after computation ends. Join with WaitToFinish before disposal or another Start; its Variant contains the result. That cycle is separately runtime-checked. A thread cannot join itself; joining running work on the main thread can stall a frame.
Do not combine both pools in a real system without measurement: this demo runs them side by side only to show the APIs. That can increase CPU contention. Do not automatically wrap an asynchronous HTTP/file API in Task.Run; waiting for I/O and CPU computation are different tasks.
Checks cover results, a separate .NET worker thread, main-thread Settled, cancellation, faults, exit/reentry, input snapshot, result copy and native group cleanup. These are Windows runtime checks; exports and speedups on a particular CPU need their own measurements. See thread-safety boundaries.