Advanced: Multithreaded Simulations

On this page

This tutorial builds on Basilisk Process and Task Creation, Adding Basilisk Modules, and Creating Stand-Alone Messages. Once a simulation has several independent groups of modules, Basilisk can execute those groups concurrently on worker threads. For example, a constellation simulation can assign each independent spacecraft and its associated modules to a separate Basilisk process.

Processes and worker threads

A Basilisk process is a group of tasks within the same application. The simulation assigns each process to one C++ worker thread, and that worker executes the process’s tasks and modules in their scheduled order. A worker can execute more than one process. Creating additional tasks inside a single process does not distribute those tasks across threads.

The default configuration uses one worker. To request more workers, call scSim.TotalSim.resetThreads(num_threads) before initializing the simulation. The count must be positive. During initialization, Basilisk assigns processes without an explicit thread assignment in round-robin order across the workers.

Choosing independent work

Processes on different workers must be able to advance independently. Keep modules that share mutable state, coupled dynamics, or changing messages on the same worker. Each module instance should be scheduled on only one worker.

Warning

Basilisk’s standard message reads and writes do not synchronize access between worker threads. A process priority does not establish execution order across different workers. For example, placing spacecraft dynamics on one worker and its message-dependent flight software on another does not provide a safe producer-consumer schedule. Keep those processes on the same worker.

Adding workers is useful when there is enough independent computation to offset thread coordination costs. More workers than independent processes leaves workers idle. Measure runtime for the intended simulation before selecting a thread count; the short example below demonstrates configuration, rather than speedup.

Two independent spacecraft

This example creates two spacecraft with constant velocities and no applied forces. Each spacecraft has its own process, task, and state recorder. The recorder runs in the same task as its spacecraft so that reading the state message follows the spacecraft update on the same worker.

 1import numpy as np
 2
 3from Basilisk.simulation import spacecraft
 4from Basilisk.utilities import SimulationBaseClass, macros
 5
 6
 7def run(num_threads=2):
 8    """Propagate two independent spacecraft on the requested number of workers.
 9
10    :param num_threads: Positive number of simulation worker threads.
11    """
12    scSim = SimulationBaseClass.SimBaseClass()
13    scSim.TotalSim.resetThreads(num_threads)
14    time_step = macros.sec2nano(0.1)  # [ns]
15    duration = 1.0  # [s]
16    speeds = [1.0, 2.0]  # [m/s]
17    recorders = []
18
19    for index, speed in enumerate(speeds):
20        process = scSim.CreateNewProcess(f"spacecraftProcess{index}")
21        task_name = f"spacecraftTask{index}"
22        process.addTask(scSim.CreateNewTask(task_name, time_step))
23
24        body = spacecraft.Spacecraft()
25        body.ModelTag = f"spacecraft{index}"
26        body.hub.mHub = 1.0  # [kg]
27        body.hub.IHubPntBc_B = np.eye(3).tolist()  # [kg*m^2]
28        body.hub.r_CN_NInit = [0.0, 0.0, 0.0]  # [m]
29        body.hub.v_CN_NInit = [speed, 0.0, 0.0]  # [m/s]
30        scSim.AddModelToTask(task_name, body, 1)
31
32        recorder = body.scStateOutMsg.recorder()
33        scSim.AddModelToTask(task_name, recorder, 0)
34        recorders.append(recorder)
35
36    scSim.InitializeSimulation()
37    scSim.ConfigureStopTime(macros.sec2nano(duration))  # [ns]
38    scSim.ExecuteSimulation()
39
40    position_tolerance = 1e-12  # [m]
41    for index, (recorder, speed) in enumerate(zip(recorders, speeds)):
42        expected_position = [speed * duration, 0.0, 0.0]  # [m]
43        position = recorder.r_BN_N[-1]  # [m]
44        np.testing.assert_allclose(position, expected_position, rtol=0, atol=position_tolerance)
45        print(f"spacecraft{index}: x = {position[0]:.3f} m")
46
47
48if __name__ == "__main__":
49    run()

The two processes are automatically assigned to the two workers. The normal InitializeSimulation(), ConfigureStopTime(), and ExecuteSimulation() calls are unchanged. Both spacecraft start at the origin and propagate for one second. Their final positions should therefore be:

spacecraft0: x = 1.000 m
spacecraft1: x = 2.000 m

The script checks these positions against constant-velocity motion. Calling run(num_threads=1) runs the same processes on one worker and produces the same results. For a larger example, see scenario_BasicOrbitMultiSat_MT.

Assigning processes explicitly

Use addProcessToThread() when particular processes must share a worker. Thread indices start at zero and must be less than the configured thread count. For existing dynamics and flight-software processes, the following assignments keep both on worker zero:

scSim.TotalSim.resetThreads(2)
scSim.TotalSim.addProcessToThread(dynProcess.processData, 0)
scSim.TotalSim.addProcessToThread(fswProcess.processData, 0)
scSim.InitializeSimulation()

Make explicit assignments after configuring the pool and before the first initialization. Processes that are not assigned explicitly still use automatic assignment. Automatic assignment follows the simulation’s process order. For explicit assignments, processes are added to a worker in the order of these calls; place the producer before its consumer when both are due at the same simulation time. Task and module priorities still apply within each process. Priorities do not coordinate different workers.

Reinitialization, ownership, and shutdown

The simulation owns its worker threads. Calling InitializeSimulation() again rebuilds the worker pool before resetting the simulation. This clears explicit process-to-thread assignments and returns to automatic assignment. Recreate the simulation configuration when repeating a run that requires an explicit mapping. To continue an existing run without resetting it, set a later stop time and call ExecuteSimulation() again.

Resetting or destroying the pool requests shutdown and joins each worker before releasing its state. scSim.TotalSim.deleteThreads() explicitly releases the pool and is safe to call repeatedly, including after a module throws an exception. To reuse a deleted pool, call resetThreads(num_threads) and then InitializeSimulation(). Call these lifecycle methods from the simulation’s controlling thread, after the current simulation call returns, rather than from a module callback.

Processes, tasks and modules remain borrowed by the C++ scheduler; SimulationBaseClass retains their Python references. Normal simulation scripts configure workers through the methods described above. The internal threadList and threadContext fields are no longer exposed to Python.

Custom C++ scheduling code

SimModel::threadList now contains std::unique_ptr<SimThreadExecution> and SimThreadExecution::threadContext is a std::thread value. Use .get() when borrowing an execution record, and do not manually delete either owned object. Compiled extensions must be rebuilt against extension ABI version 3.

Use SimThreadExecution::requestStop() to request shutdown and wake an idle worker. killThread() remains available as a compatibility alias with the same behavior; a separate unlockThread() call is no longer needed. Repeated calls through either method release the worker semaphore only on the first stop request.

A stop request does not join the worker. Keep its execution record alive until the worker finishes and has been joined. The execution record’s destructor requests shutdown and joins automatically. SimModel::deleteThreads() first requests shutdown of every worker, then destroys and joins the records, preserving the rule that all workers are signaled before any join begins. Call pool lifecycle methods from the controlling thread as described above.