rott/kf_sources/AcediaCore/Classes/SchedulerAPI.uc
2026-07-14 20:27:09 +07:00

333 lines
11 KiB
Ucode

/**
* 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 <https://www.gnu.org/licenses/>.
*/
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<SchedulerJob> 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<SchedulerDiskRequest> diskQueue;
var private array<AcediaObject> receivers;
var private array<int> 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(<receiver>).connect = <handler>`.
/// 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
}