/** * Author: dkanus * Home repo: https://www.insultplayers.ru/git/AcediaFramework/AcediaCore * License: GPL * Copyright 2022-2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * * Acedia is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License, or * (at your option) any later version. * * Acedia is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ class SchedulerApi extends AcediaObject config(AcediaSystem); //! This API is meant for scheduling various actions over time to help emulating //! multi-threading by spreading some code executions over several different //! game/server ticks. //! //! UnrealScript is inherently single-threaded and whatever method you call, //! it will be completely executed within a single game's tick. // How often can files be saved on disk. // // This is a relatively expensive operation and we don't want to write a lot of different files // at once. // But since we lack a way to exactly measure how much time that saving will take, AcediaCore falls // back to simply performing every saving with same uniform time intervals in-between. // This variable decides how much time there should be between two file writing accesses. // Negative and zero values mean that all writing disk access will be granted as soon as possible, // without any cooldowns. var private config float diskSaveCooldown; // Maximum total work units for jobs allowed per tick. // // Jobs are expected to be constructed such that they don't lead to a crash if they have to perform // this much work. // Changing default value of `10000` is not advised. var private config int maxWorkUnits; // How many different jobs can be performed per tick. // // This limit is added so that `maxWorkUnits` won't be spread too thin if a lot of jobs // get registered at once. var private config int maxJobsPerTick; // We can (and will) automatically tick var private bool tickAvailable; // `true` == it is safe to use server API for a tick // `false` == it is safe to use client API for a tick var private bool tickFromServer; // Our `Tick()` method is currently connected to the `OnTick()` signal. // // Keeping track of this allows us to disconnect from `OnTick()` signal when it is not necessary. var private bool connectedToTick; // How much time if left until we can write to the disk again? var private float currentDiskCooldown; // There is a limit (`maxJobsPerTick`) to how many different jobs we can perform per tick and if we // register an amount jobs over that limit, we need to uniformly spread execution time between them. // // To achieve that we simply cyclically (in order) go over `currentJobs` array, each time executing // exactly `maxJobsPerTick` jobs. // // `nextJobToPerform` remembers what job is to be executed next tick. var private int nextJobToPerform; var private array currentJobs; // Storing receiver objects, following example of signals/slots, is done without increasing their // reference count, allowing them to get deallocated while we are still keeping their reference. // // To avoid using such deallocated receivers, we keep track of the life versions they've had when // their disk requests were registered. var private array diskQueue; var private array receivers; var private array receiversLifeVersions; /// Registers new scheduler job to be executed in the API. /// /// Does nothing if given `newJob` is already added. public function AddJob(SchedulerJob newJob) { local int i; if (newJob == none) { return; } for (i = 0; i < currentJobs.length; i += 1) { if (currentJobs[i] == newJob) { return; } } newJob.NewRef(); currentJobs[currentJobs.length] = newJob; UpdateTickConnection(); } /// Requests another disk access. /// /// Use it like signal: `RequestDiskAccess().connect = `. /// Since it is meant to be used as a signal, so DO NOT STORE/RELEASE returned wrapper object /// [`SchedulerDiskRequest`]. /// /// Same as for signal/slots, [`receiver`] is an object, responsible for the disk request. /// If this object gets deallocated - request will be thrown away. /// Typically this should be an object in which connected method will be executed. /// Returns wrapper object that provides `connect` delegate. /// /// # Examples /// /// ``` /// _.scheduler.RequestDiskAccess(self).connect = MethodThatSaves(); /// ``` public function SchedulerDiskRequest RequestDiskAccess(AcediaObject receiver) { local SchedulerDiskRequest newRequest; if (receiver == none) return none; if (!receiver.IsAllocated()) return none; newRequest = SchedulerDiskRequest(_.memory.Allocate(class'SchedulerDiskRequest')); diskQueue[diskQueue.length] = newRequest; receivers[receivers.length] = receiver; receiversLifeVersions[receiversLifeVersions.length] = receiver.GetLifeVersion(); UpdateTickConnection(); return newRequest; } /// Returns amount of incomplete jobs are currently registered in the scheduler. public function int GetJobsAmount() { CleanCompletedJobs(); return currentJobs.length; } /// Returns amount of disk access requests are currently registered in the scheduler. public function int GetDiskQueueSize() { CleanDiskQueue(); return diskQueue.length; } /// Performs another batch of scheduled tasks. /// /// In case neither server, nor client core is registered, scheduler must be ticked manually. /// For that call this method each separate tick (or whatever is your closest approximation /// available for that). /// Before manually invoking this method, you should check if scheduler actually started to tick /// *automatically*. /// Use `_.scheduler.IsAutomated()` for that. /// /// Argument is a time (real, not in-game one) that is supposedly passes from the moment /// [`SchedulerApi::ManualTick()`] was called last time. /// Used for tracking disk access cooldowns. /// How [`SchedulerJob`]s are executed is independent from this value. /// /// Returns time (real, not in-game one) that is supposedly passes from the moment /// [`SchedulerApi::ManualTick()`] was called last time. /// /// # Examples /// /// ``` /// if (!_.scheduler.IsAutomated()) { /// _.scheduler.ManualTick(0.05); /// } /// ``` /// /// # Note /// /// If neither server-/client- core is created, nor [`SchedulerApi::ManualTick()`] is invoked /// manually, [`SchedulerApi`] won't actually do anything. public final function ManualTick(optional float delta) { Tick(delta, 1.0); } /// Returns whether scheduler ticking automated. /// /// It can only be automated if either server or client level cores are created. /// Scheduler can automatically enable automation and it cannot be prevented, but can be helped by /// using [`SchedulerApi::UpdateTickConnection()`] method. public function bool IsAutomated() { return tickAvailable; } /// Causes `SchedulerApi` to try automating itself by searching for level cores (checking if /// server/client APIs are enabled). public function UpdateTickConnection() { local bool needsConnection; local UnrealAPI api; if (!tickAvailable) { if (_server.IsAvailable()) { tickAvailable = true; tickFromServer = true; } else if (_client.IsAvailable()) { tickAvailable = true; tickFromServer = false; } if (!tickAvailable) { return; } } needsConnection = (currentJobs.length > 0 || diskQueue.length > 0); if (connectedToTick == needsConnection) { return; } if (tickFromServer) { api = _server.unreal; } else { api = _client.unreal; } if (connectedToTick && !needsConnection) { api.OnTick(self).Disconnect(); } else if (!connectedToTick && needsConnection) { api.OnTick(self).connect = Tick; } connectedToTick = needsConnection; } private function Tick(float delta, float dilationCoefficient) { delta = delta / dilationCoefficient; if (currentDiskCooldown > 0) { currentDiskCooldown -= delta; } if (currentDiskCooldown <= 0 && diskQueue.length > 0) { currentDiskCooldown = diskSaveCooldown; ProcessDiskQueue(); } // Manage jobs if (currentJobs.length > 0) { ProcessJobs(); } UpdateTickConnection(); } private function ProcessJobs() { local int unitsPerJob; local int jobsToPerform; CleanCompletedJobs(); jobsToPerform = Min(currentJobs.length, maxJobsPerTick); if (jobsToPerform <= 0) { return; } unitsPerJob = maxWorkUnits / jobsToPerform; while (jobsToPerform > 0) { if (nextJobToPerform >= currentJobs.length) { nextJobToPerform = 0; } currentJobs[nextJobToPerform].DoWork(unitsPerJob); nextJobToPerform += 1; jobsToPerform -= 1; } } private function ProcessDiskQueue() { local int i; // Even if we clean disk queue here, we still need to double check // lifetimes in the code below, since we have no idea what `.connect()` // calls might do CleanDiskQueue(); if (diskQueue.length <= 0) { return; } if (diskSaveCooldown > 0) { if (receivers[i].GetLifeVersion() == receiversLifeVersions[i]) { diskQueue[i].connect(); } _.memory.Free(diskQueue[0]); diskQueue.Remove(0, 1); receivers.Remove(0, 1); receiversLifeVersions.Remove(0, 1); return; } for (i = 0; i < diskQueue.length; i += 1) { if (receivers[i].GetLifeVersion() == receiversLifeVersions[i]) { diskQueue[i].connect(); } _.memory.Free(diskQueue[i]); } diskQueue.length = 0; receivers.length = 0; receiversLifeVersions.length = 0; } // Removes completed jobs private function CleanCompletedJobs() { local int i; while (i < currentJobs.length) { if (currentJobs[i].IsCompleted()) { if (i < nextJobToPerform) { nextJobToPerform -= 1; } currentJobs[i].FreeSelf(); currentJobs.Remove(i, 1); } else { i += 1; } } } // Remove disk requests with deallocated receivers private function CleanDiskQueue() { local int i; while (i < diskQueue.length) { if (receivers[i].GetLifeVersion() == receiversLifeVersions[i]) { i += 1; continue; } _.memory.Free(diskQueue[i]); diskQueue.Remove(i, 1); receivers.Remove(i, 1); receiversLifeVersions.Remove(i, 1); } } defaultproperties { diskSaveCooldown = 0.25 maxWorkUnits = 10000 maxJobsPerTick = 5 }