Потоки C#: задача, отмена и пул без доступа к Entity
Полный пример .NET Task и WorkerThreadPool: копия входа, главный поток результата и очистка при выходе.
Запустить пример
В C#-проект 1.0.0 добавьте пять файлов ниже. Создайте отдельный уровень с корнем Entity, прикрепите PrimeJobsDemo.cs, соберите проект и нажмите F6. Не прикрепляйте служебные классы вручную: демонстрация создаёт их сама.
Ожидаются строки JOB primes=9592 main_thread=True и POOL primes=1229,9592; порядок строк не важен. Первая задача считает числа до 100000, группа — до 10000 и 100000. Это простая проверяемая CPU-нагрузка, а не игровой алгоритм или тест быстродействия.
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;
}
}Разделение потоков
PrimeWork работает только с числами и CancellationToken. В Task.Run передаётся static-лямбда без захваченной Entity. PoolPrimeBatch передаёт отдельный Batch с копией массива входа; каждый индекс результата записывает один вызов. Во время вычисления главный поток не читает и не изменяет этот массив.
BackgroundJob проверяет завершение Task в _Process. Только после IsCompleted он получает результат и вызывает Settled; он не блокирует кадр ожиданием работающей задачи. Обработка ошибки также идёт здесь. Простое использование Task.Run или await не даёт разрешения трогать Entity из любого продолжения: поток выполнения зависит от контекста и способа ожидания. В этом примере место применения результата явно задано _Process.
Start/Cancel и управление компонентами выполняйте в главном потоке. Пока Busy, второй Start возвращает Error.Busy. Не создавайте новую задачу каждый кадр. Для нового запроса отмените старый, дождитесь завершения обработки и лишь затем запускайте следующий. Очередь последних запросов этим компонентом не реализована.
Отмена и выход из уровня
Cancel — запрос. PrimeWork регулярно проверяет token и выбрасывает OperationCanceledException; он не прерывает поток принудительно. Busy остаётся true до обработки окончания. Если отмена запрошена до принятия результата, готовый результат тоже отбрасывается. Ошибка вычисления даёт Failed и LastError; её нельзя выдавать за обычный результат 0.
При выходе BackgroundJob отменяет работу и отделяется от неё. Продолжение, которое наблюдает ошибку и освобождает CancellationTokenSource, не захватывает Entity и не вызывает Settled. Поэтому рабочая функция обязана владеть самостоятельными данными и завершаться по отмене. Удаление компонента не делает безопасной лямбду с захваченной Label, Resource или ссылкой на текущий уровень.
PoolPrimeBatch после IsGroupTaskCompleted обязательно вызывает WaitForGroupTaskCompletion: это завершает владение нативной группой. ID после ожидания больше не используют для опроса. При выходе из дерева компонент запрашивает отмену и дожидается группы, прежде чем освобождать её данные. Это может кратко задержать выход: отмена ограниченной CPU-операции здесь кооперативная. Не добавляйте в такой worker бесконечное ожидание главного потока, сетевой ответ или длинную блокировку.
Результаты становятся доступны после завершения; свойство Results выдаёт копию. Группа допускает 1–32 входа, каждый от 0 до 2000000. Ограничение относится к примеру. Число элементов не равно числу потоков: tasksNeeded управляет разделением на задачи, а фактическое выполнение зависит от пула.
Что выбирать
Для C#-задачи с исключениями и CancellationToken обычно достаточно .NET Task. WorkerThreadPool подходит для работы через пул движка, в том числе индексированных групп. Он не превращает Entity/API в потокобезопасные и не обеспечивает отмену ваших циклов сам. Для одиночного AddTask также вызовите WaitForTaskCompletion один раз после завершения и проверьте Error.
Если нужен отдельный ReckfellThread, Start принимает Callable, IsAlive показывает выполнение, а IsStarted остаётся true до WaitToFinish, даже если вычисление уже закончилось. Дождитесь WaitToFinish перед освобождением или повторным Start; возвращённый Variant содержит результат. Этот цикл отдельно проверен в runtime. Поток не должен ждать собственного завершения; вызов ожидания работающего потока в главном может задержать кадр.
Не смешивайте оба пула без измерений в реальной системе: демонстрация запускает их рядом лишь для сравнения API. Это может увеличить конкуренцию за CPU. Для HTTP/файлов с асинхронным API не добавляйте Task.Run автоматически; ожидание ввода-вывода и CPU-вычисление — разные задачи.
Проверены результат, отдельный рабочий поток .NET, главный поток Settled, отмена, исключение, выход/повторный вход, снимок входа, копия результата и очистка нативной группы. Это Windows runtime-проверки; экспорты и ускорение на конкретном CPU требуют собственных замеров. См. границы потокобезопасности.