reckfellENGINE

Потоки 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
C#
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
C#
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
C#
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
C#
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
C#
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 требуют собственных замеров. См. границы потокобезопасности.

Документация Reckfell EngineНаверх ↑
БЫСТРЫЙ ПЕРЕХОДНачало работыEntity и иерархияC# & .NET 10Сцены и префабы
Руководства и полный API Reckfell K
reckfellENGINE
RECKFELL ACCOUNT

Вход в аккаунт

Войдите в аккаунт Reckfell.

Один аккаунт для всей платформы Reckfell.