333 lines
11 KiB
Ucode
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
|
|
} |