Introduction
Game engines employ event-driven systems where you can invoke certain game logic in response to a (possibly user-initiated) trigger/stimuli. Naturally, this system works for all mutually-independent events. Yet, when multiple logically-overlapping events are fired at the same time, unintended consequences can then happen and possibly break the game flow. This is a classical issue every developer faces, and here we will cover on various approaches to deal with this problem.
Solution 1: Using locks
Conceptually speaking, this classical solution involves implementations to ensure that each process can take their turn to hold exclusive access to code logic for performing its own operation. Generally, you would employ this solution if you have a critical resource that is accessed and modified frequently by multiple processes.
This implementation generally looks like this:
// Non-critical code block
...
// Now, hold exclusive control over this critical code block first,
// before execution.
AttemptAcquireLockOrWait();
if (hasLock)
{
// Execute critical code block here, if you have exclusive access
}
ReleaseLock();
// Non-critical code block
...
Generally, enforcing a very strict lock system should be used only if the use case warrants it, as having to deal with the introduced locks may potentially overcomplicate your game logic system.
Solution 2: Enforcing an explicit order of execution
One problem that struck me fairly frequently was when I have a manager-component A that requires the functionality of another component B at the start, but attempting to access B resulted in 'null' reference since B was not yet fully instantiated. But because all scripts are implicitly assumed to be independent of each other in terms of execution order in Unity, enforcing a certain order appears to be required (at least for my own use case).
A naive but simplest way to do this would be to modify the Script Execution Order in the project settings, but the downsides to doing this is that:
It is not very user-intuitive - any issues with this setting may be hard to detect from the usual error logs
It is not very scalable - if you have a sizeable number of components to control, the Script Execution Order settings can get out of hand quite easily
You may employ a similar and more visible approach with Coroutines and boolean flags:
public class MyClass
{
public void DoSomethingWithB()
{
StartCoroutine(ExecuteBCoroutine());
}
protected IEnumerator ExecuteBCoroutine()
{
// Assumes this 'IsReady' flag is set once component B has been
// fully initialized and ready to use in the system
yield return new WaitUntil(() => B.IsReady);
B.DoSomething();
}
...
}
This solution is not as foolproof as the lock-system described earlier, but you can easily kill the Coroutine inside with a StopCoroutine() invocation somewhere else if it goes wrong.
Solution 3: Designing your code to avoid race conditions
An obscure problem that hit me recently was how I dynamically enabled an initially-disabled GameObject of a component with a 'SetActive(true)' function call, but yet a FindObjectsOfType() function call did not detect that GameObject.
When that GameObject was enabled, what should happen was something like:
1. Component A enables an initially-disabled child GameObject B
2. Rely on Unity's implicit Start() behavior: GameObject B's component fires a web request on Start() after B's GameObject is enabled
3. When web request completes, search for all GameObjects that have a implemented callback function interface, and invoke them (B's component should be found)
4. B implements the callback function, and it should be invoked
And what happened was:
1. Component A enables an initially-disabled child GameObject B
2. Rely on Unity's implicit Start() behavior: GameObject B's component fires a web request on Start() after B's GameObject is enabled
3. When web request completes, search for all GameObjects that have a implemented callback function interface, and invoke them (B's component was NOT found)
4. B's callback function was NOT invoked
This issue happened likely because a series of executions happened right at the moment when the target GameObject just became active. To resolve this issue, I made the following modifications:
1. GameObject B is now made initially active
2. GameObject A will now trigger B's logic at the juncture where it would previously set B's GameObject as active
Essentially, to avoid race conditions, the best way is still to avoid relying on implicit system behavior, and make all behaviors as explicit as possible.
Comments