Интеграция с Burst и ECS
Valkarn Tasks включает опциональную интеграцию с компилятором Unity Burst, Unity Collections и пакетом Entities (ECS). Весь этот функционал компилируется условно — он активен только при наличии необходимых пакетов и установленных соответствующих символов определения скриптинга.
Требования
| Возможность | Необходимый пакет | Символ определения |
|---|---|---|
NativeTimerHeap, NativeScheduler, BurstSchedulerRunner | Unity Burst 1.8+, Unity Collections 2.0+ | VTASKS_HAS_BURST и VTASKS_HAS_COLLECTIONS |
AsyncSystemUtilities | Unity Entities 1.0+ | VTASKS_HAS_ENTITIES |
Все исходные файлы Burst/ECS обёрнуты в защитники #if, соответствующие этим символам. Ничто в этих файлах не компилируется и не компонуется, пока символы не установлены.
Настройка
-
Установите необходимые пакеты через Unity Package Manager:
com.unity.burst1.8 или новееcom.unity.collections2.0 или новееcom.unity.entities1.0 или новее (только для утилит ECS)
-
Добавьте символы определения скриптинга в Project Settings > Player > Scripting Define Symbols:
VTASKS_HAS_BURST;VTASKS_HAS_COLLECTIONS;VTASKS_HAS_ENTITIESДобавляйте только символы для установленных пакетов.
NativeTimerHeap
Пространство имён: UnaPartidaMas.Valkarn.Tasks.Burst
Защитник: #if VTASKS_HAS_BURST && VTASKS_HAS_COLLECTIONS
NativeTimerHeap — это двоичная мин-куча, совместимая с Burst, для планирования таймеров. Хранит значения TimerEntry, упорядоченные по дедлайну, давая O(log n) вставку и O(log n) удаление для каждого истёкшего таймера.
Ключевые типы
// Запись, хранимая в куче. Упорядочена по Deadline.
public struct TimerEntry : IComparable<TimerEntry>
{
public long Deadline;
public int Id;
}
public struct NativeTimerHeap : IDisposable
{
public bool IsCreated { get; }
public int Count { get; }
// Создаёт кучу. Используйте Allocator.Persistent для долгоживущих куч.
public NativeTimerHeap(int initialCapacity, AllocatorManager.AllocatorHandle allocator);
// Вставляет новый таймер. Возвращает ID таймера (используется для идентификации коллбэка).
// Дедлайн и значение, передаваемое в DrainExpired, должны использовать одну единицу
// (BurstSchedulerRunner использует тики DateTime через Time.realtimeSinceStartupAsDouble).
[BurstCompile]
public int Schedule(long deadline);
// Удаляет и добавляет ID всех таймеров, у которых Deadline <= currentTimestamp.
// Возвращает количество слитых таймеров.
[BurstCompile]
public int DrainExpired(long currentTimestamp, NativeList<int> expiredIds);
public void Dispose();
}
NativeTimerHeap — это неуправляемая структура. Она не может хранить управляемые делегаты — ID сопоставляются с управляемыми коллбэками в словаре BurstSchedulerRunner в главном потоке.
NativeScheduler
Пространство имён: UnaPartidaMas.Valkarn.Tasks.Burst
Защитник: #if VTASKS_HAS_BURST && VTASKS_HAS_COLLECTIONS
NativeScheduler — это очередь работ, совместимая с Burst, поддерживаемая NativeQueue<ScheduledWork>. Скомпилированные в Burst задания ставят элементы работ в очередь; главный поток опустошает их каждый кадр.
Ключевые типы
public enum WorkType : byte
{
TimerExpired = 0,
JobCompleted = 1,
Custom = 2
}
public struct ScheduledWork
{
public int Id;
public WorkType Type;
public long Payload;
}
public struct NativeScheduler : IDisposable
{
public bool IsCreated { get; }
public NativeScheduler(int initialCapacity, AllocatorManager.AllocatorHandle allocator);
// Ставит элемент работы в очередь из Burst-скомпилированного задания.
[BurstCompile]
public void Enqueue(ScheduledWork work);
// Опустошает всю ожидающую работу в `results`. Вызывается только из главного потока.
// Возвращает количество слитых элементов.
public int Drain(NativeList<ScheduledWork> results);
// Возвращает параллельный писатель, подходящий для использования в заданиях IJobParallelFor.
public NativeQueue<ScheduledWork>.ParallelWriter AsParallelWriter();
public void Dispose();
}
Очередь — это точка пересечения мира Burst и управляемого мира. Enqueue вызывается из Burst; Drain работает только в главном потоке.
BurstSchedulerRunner
Пространство имён: UnaPartidaMas.Valkarn.Tasks.Burst
Защитник: #if VTASKS_HAS_BURST && VTASKS_HAS_COLLECTIONS
BurstSchedulerRunner — это управляемый мост между нативным планировщиком/таймерной кучей и остальной частью вашей игры. Он реализует IPlayerLoopItem, поэтому Unity вызывает MoveNext() один раз за кадр при зарегистрированной фазе. Каждый кадр он:
- Опустошает
NativeSchedulerи вызывает зарегистрированные управляемые коллбэки, сопоставленные по ID работы. - Опустошает
NativeTimerHeapдля истёкших таймеров и вызывает их управляемые коллбэки.
Исключения, брошенные коллбэками, перенаправляются в ValkarnTask.PublishUnobservedException, а не распространяются через PlayerLoop.
API
public sealed class BurstSchedulerRunner : IPlayerLoopItem, IDisposable
{
// Создаёт runner и регистрирует его в PlayerLoop. Возвращает экземпляр.
// Освободите возвращённый runner, когда он больше не нужен.
public static BurstSchedulerRunner Create(
PlayerLoopTiming timing = PlayerLoopTiming.Update,
int initialCapacity = 64);
// Прямой доступ к NativeScheduler для постановки в очередь из Burst-заданий.
public NativeScheduler Scheduler { get; }
// Планирует управляемый коллбэк таймера. Вызывается только из главного потока.
// Возвращает ID таймера (только для идентификации; API отмены нет).
public int ScheduleTimer(TimeSpan delay, Action callback);
// Связывает управляемый коллбэк с ID работы, поставленной в очередь Burst-заданием.
// Вызывается из главного потока, до или в течение кадра завершения задания.
public void RegisterCallback(int workId, Action callback);
// Освобождает все нативные контейнеры и отменяет регистрацию из PlayerLoop.
public void Dispose();
}
Чем BurstSchedulerRunner отличается от планировщика по умолчанию
Планировщик по умолчанию Valkarn Tasks интегрируется напрямую с конечными автоматами async/await и управляет диспетчеризацией продолжений через PlayerLoopHelper. BurstSchedulerRunner добавляет отдельный канал специально для сигнализации из Burst-скомпилированных заданий:
| Планировщик по умолчанию | BurstSchedulerRunner | |
|---|---|---|
| Тип продолжения | Управляемый Action через ISource | Управляемый Action, зарегистрированный по ID |
| Источник сигнала | Паттерн awaiter в C# | Неуправляемый NativeScheduler.Enqueue |
| Источник таймера | ValkarnTask.Delay (управляемый) | NativeTimerHeap (неуправляемый) |
| Потокобезопасность | Продолжения главного потока | Enqueue безопасен для Burst; drain только в главном потоке |
Паттерн использования
// 1. Создать runner один раз (например, в bootstrap-MonoBehaviour или ISystem.OnCreate).
var runner = BurstSchedulerRunner.Create(PlayerLoopTiming.Update, initialCapacity: 128);
// 2. Запланировать коллбэк таймера (главный поток).
runner.ScheduleTimer(TimeSpan.FromSeconds(2.0), () =>
{
Debug.Log("Прошло две секунды (неуправляемый таймер).");
});
// 3. Из Burst-задания поставить элемент работы в очередь.
// NativeScheduler.ParallelWriter безопасен для использования из IJobParallelFor.
var writer = runner.Scheduler.AsParallelWriter();
// Внутри Execute(int index):
writer.Enqueue(new ScheduledWork { Id = myWorkId, Type = WorkType.Custom });
// 4. Зарегистрировать управляемый коллбэк в главном потоке до завершения задания.
runner.RegisterCallback(myWorkId, () =>
{
Debug.Log($"Получен сигнал завершения задания для работы {myWorkId}.");
});
// 5. Освободить runner по завершении (например, OnDestroy или перезагрузка домена).
runner.Dispose();
AsyncSystemUtilities
Пространство имён: UnaPartidaMas.Valkarn.Tasks.ECS
Защитник: #if VTASKS_HAS_ENTITIES
AsyncSystemUtilities предоставляет два вспомогательных расширения для написания асинхронных ECS-систем.
GetWorldCancellationToken
public static CancellationToken GetWorldCancellationToken(
this World world,
PlayerLoopTiming timing = PlayerLoopTiming.Update)
Возвращает CancellationToken, автоматически отменяемый при уничтожении данного World. Внутри запускает fire-and-forget ValkarnTask, который вызывает ValkarnTask.Yield(timing) каждый кадр, пока world.IsCreated истинно, затем отменяет CancellationTokenSource, когда цикл завершается.
Если мир уже уничтожен при вызове этого метода, возвращает токен, который уже находится в отменённом состоянии.
Передавайте этот токен каждому async-методу, запускаемому из системы, чтобы незавершённая работа автоматически останавливалась при исчезновении мира.
SafeEntityExists
public static bool SafeEntityExists(this EntityManager entityManager, Entity entity)
Вызывает entityManager.Exists(entity) и возвращает false, если брошен ObjectDisposedException. Это может произойти при обращении к EntityManager после удаления мира, что является реальным состоянием гонки в асинхронном коде, работающем через границы кадров.
Используйте после каждой точки await перед записью обратно в сущность.
Рабочий пример: Асинхронная ECS-система
Следующий пример из Samples~/ECS/AsyncLoadSystem.cs. Он демонстрирует канонический паттерн одноразовой асинхронной инициализации из ISystem.
#if VTASKS_HAS_ENTITIES
using System.Threading;
using Unity.Entities;
using UnaPartidaMas.Valkarn.Tasks;
using UnaPartidaMas.Valkarn.Tasks.ECS;
public partial struct AsyncLoadSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
// Получить токен отмены, привязанный к времени жизни этого World.
// Если World уничтожается, вся асинхронная работа, запущенная с этим токеном,
// будет автоматически отменена.
var worldCt = state.World.GetWorldCancellationToken();
// Запустить асинхронную инициализацию и забыть задачу.
// Forget() направляет любое необработанное исключение в ValkarnTask.PublishUnobservedException.
InitializeAsync(state.WorldUnmanaged, worldCt).Forget();
}
public void OnUpdate(ref SystemState state) { }
public void OnDestroy(ref SystemState state) { }
static async ValkarnTask InitializeAsync(WorldUnmanaged world, CancellationToken ct)
{
// Фаза 1: Загрузить данные в фоновом потоке.
// RunOnThreadPool переключается на рабочий поток, выполняет делегат,
// и автоматически возвращается в главный поток.
var configData = await ValkarnTask.RunOnThreadPool(
() => LoadFromDisk(),
cancellationToken: ct);
// Фаза 2: Применить результаты в главном потоке.
// Проверить отмену на случай уничтожения World во время загрузки.
ct.ThrowIfCancellationRequested();
ApplyConfiguration(world, configData);
}
static ConfigData LoadFromDisk()
{
// Только чистый C# — здесь нельзя вызывать Unity или ECS API.
return new ConfigData { MaxEnemies = 100, SpawnRadius = 50f };
}
static void ApplyConfiguration(WorldUnmanaged world, ConfigData data)
{
// Безопасно: мы в главном потоке.
UnityEngine.Debug.Log($"Loaded config: MaxEnemies={data.MaxEnemies}");
}
struct ConfigData
{
public int MaxEnemies;
public float SpawnRadius;
}
}
#endif
Пример с регулированием ИИ (Samples~/ECS/AISystemExample.cs) строится на этом паттерне и добавляет AsyncThrottle для ограничения количества одновременно выполняющихся async-задач. Подробности о этом паттерне см. в документации по Throttling.
Ограничения
Следующие ограничения применяются ко всему асинхронному коду Burst/ECS. Их нарушение приведёт к ошибкам редактора, нарушениям безопасности заданий или молчаливому повреждению данных.
Внутри Burst-скомпилированного кода
- Никаких управляемых типов. Burst не может компилировать код, который выделяет, обращается или ссылается на управляемые объекты (классы, делегаты, массивы, строки,
List<T>и т.д.). Допускаются только blittable-структуры и нативные контейнеры. - Никаких исключений. Burst не поддерживает
try/catch/throw. Используйте коды возврата или флаги для передачи ошибок. - Никакого
async/await. Асинхронные конечные автоматы C# являются управляемыми и не могут быть скомпилированы Burst.NativeSchedulerиNativeTimerHeapпредоставляют побочный канал для сигнализации управляемых продолжений, но сами продолжения выполняются в главном потоке. - Никакого изменяемого статического управляемого состояния. Burst-задания могут читать статические поля readonly, но не должны писать в управляемые статики.
Через точки await в ECS-системах
- Время жизни сущностей. Сущности могут быть уничтожены, пока async-метод приостановлен. Всегда вызывайте
entityManager.SafeEntityExists(entity)после каждогоawaitперед записью обратно. - Устаревание ComponentLookup.
ComponentLookup,RefRWи другие типы на основе chunk-указателей становятся недействительными после структурных изменений, которые могут произойти в любом кадре. Не кэшируйте их через точкиawait. Повторно получайте их изSystemStateпосле возобновления, или используйтеEntityManagerнапрямую. - Параметры
ref. Async-методы не могут иметь параметрыref,inилиout(ошибка C# CS1988). Извлекайте все данные ECS синхронно в синхронном методеOnUpdateи передавайте их в async-метод по значению. SystemAPIв async-методах.SystemAPIгенерируется исходным кодом и работает только внутри partial-методовISystem. Вasync-методах он недоступен. Выполняйте все запросыSystemAPIдо первогоawait.- Потокобезопасность.
EntityManager,ComponentLookupи структурные изменения работают только в главном потоке. ИспользуйтеValkarnTask.RunOnThreadPoolтолько для чистых C#-вычислений без вызовов Unity или ECS API.