5.4 KiB
Design
To understand why the design of the launcher is the way it is we need to first understand all the use cases. There are three ways the launcher can be used:
- using an interactive GUI aimed at normal users,
- using an interactive CLI aimed at running by users on servers using SSH,
- using a non-interactive CLI aimed at running in an automated fashion.
These use cases are implemented on top of a common core. This common core corresponds to:
internal/core
contains functions for interfacing with the benchmark script and the platform,internal/rpc
contains functions for interfacing with/website
and/launcher_authenticator
.internal/config
contains the configuration.
CLI
The CLI interfaces are defined in internal/cli
. The implementations are
relatively simple wrappers around the core packages. Most of the code is
parsing and validating command line arguments. The interactive mode is
implemented in internal/cli/interactive.go
the other files are for the
non-interactive mode.
Interactive GUI
The interactive GUI is implemented in internal/ui
using ImGui with a
functional/immutable/Redux-like state management approach.
Writing a GUI is a bit harder than writing a CLI because we almost always have at least two threads modifying the state. This is because we always have one thread responsible for rendering the UI, which we cannot block because than the UI would stall. Therefore, if we want to do any blocking operations we will have to do them in a separate thread. We deal with this using a pattern explained in the following section.
State management
There are three main types we need to understand to understand the whole
state management pattern. First, there is a centralized
StateStore
which is the single source of truth
for the latest state. Second, the state itself is implemented as immutable
structs implementing the State
interface.
Finally, State transitions are implemented as pure functions mapping a State
to a new State
, this is the
StateTransform
interface.
We initialize the pattern by instantiating a StateStore
with an initial state.
Afterwards, these three types work together using the
StateStore.pushStateTransform
method. This method accepts a
StateTransform
and pushes that onto a queue internal to the StateStore
.
Whenever we request the latest state in the main loop of the program using the
StateStore.GetLatestState
method these StateTransforms
are applied to the
last known state in a serialized fashion, and the resulting state is stored as
the current state and returned.
This pattern works very well in our scenario because we have very limited
concurrency issues: whenever the user is changing the state the computer is
waiting and vice versa. Therefore we can just use the
StateStore.pushStateNaive
method which accepts a State
and calls
StateStore.pushStateTransform
with a StateTransform
that returns the former
State
. This amounts to serializing the state modifications using a
last-write-wins strategy.
However, if in the future we do need concurrent state modification there are multiple ways to deal with that. The easiest is to partition the state into pieces which are not modified concurrently. Meaning that each part of the state is only written to in a serialized fashion. For example, this is the case when we have multiple threads reading from a specific part of the state but only one thread writing to that part.
If you really have multiple threads mutating the same part and there is no way
of partitioning that part into independent pieces, you need to implement a
StateTransform
that handles the conflicts in a domain specific fashion.
To illustrate, suppose we need to keep track of an integer value written to by
multiple threads and we are interested in the highest value encountered.
We could then implement a StateTransform
that assigns to that field either the
value of the field in the old state or the value we encountered in this thread,
whichever is larger. This is an instance of what is called a
CRDT. There
are various patterns you can use for varying data types.
ImGui
ImGui is what is called an immediate mode UI library. This is dual to a retained mode UI library like Qt, or the DOM in a browser.
In a retained mode UI library the UI is defined using a set of widgets which encapsulate their state. The UI library encapsulating the state is problematic since we ourselves then no longer own that state, resulting in either duplicating the state and the corresponding synchronization issues, or having the UI library intrude into code which has nothing to do with the UI but just wants to modify some state.
In contrast, an immediate mode UI library gives you utility functions to render widgets and deal with user input. It is then your own responsibility to call the right widget rendering functions based on your state and to modify the state in reaction to the user input. This way we get to be the owner of our state whilst there still is a single source of truth.
The state management from the previous section composes nicely with ImGui. To
add a UI we just need to implement a State.Render
method accepting a State
and which calls the right ImGui methods to render the required widgets whilst
handling user input by calling StateStore.pushStateTransform
with the required
changes.