Object system: relations

Mach object relations are a key concept of the Mach object system and enable you to arbitrarily create parent-child relations between objects.

Among other things, this allows for attaching your own arbitrary data to someone else’s object - a form of relaxed type constraint which enable you to quickly iterate on your codebase. *Mach’s object system is not an Entity Component System (it’s better!), but ECS typically enables this form of relaxed type constraint to enable fast iteration - and object relations are how we provide this flexibility.

Relations are ObjectID <-> ObjectID

As discussed in the objects section, objects have IDs which pack a bunch of information including which module and set of objects the ID came from, the actual array index for O(1) lookups, a generation counter for use-after-free detection, and more.

Mach stores parent-child relations as a mapping of object IDs pointing to other object IDs. This means that you can attach say a monster object to a window object - they don’t have to be from the same module or same list of objects.

The parallelism problem

One challenge in representing object relations on top of mach.Objects is that - since each is a list of objects owned by someone - potentially different threads! If we want to allow objects from one list of objects to have parents and children in another list of objects which are owned by a different thread.. things get tricky!

A naive solution to this might be to introduce a global mutex around the graph of object relations. Unfortunately, this would mean that any thread interacting with object relations is blocked on every other thread looking to do the same (Hello, Python interpreter GIL, anyone?)

Mach uses a much more clever solution (thanks to some fantastic advice from kprotty, the famous program optimizer, concurrency/IO guy, and Zig core team member.)

How Mach’s object system stores relations

Mach maintains a single global object graph, where objects owned by any threads’ can have parents/children which are themselves objects owned by other threads'.

The trick is that instead of maintaining a global mutex (or using any locks at all), we enable reads/writes/mutations to the graph in parallel from any thread by representing all interactions with the graph as /operations/ enqueued to a lock-free Multi Producer, Single Consumer (MPSC) queue, and a background thread processes operations submitted to the queue.

When an operation is desired (adding a parent to a child, querying the children or parent of a node, etc.) it is enqueued. Write operations (like adding a parent to a child) require only a lock-free submission to the queue, making writes super efficient even when there would otherwise be contention - and read operations are similarly handled (just with an atomic ‘done’ signal and a memory pool for getting results out.)

Lastly, we use lock-free pools of nodes and queue entries to allow for pre-allocating things like nodes in the graph and queue entries - potentially based on the applications’ actual measured usage to eliminate runtime allocations in future builds of the program.

API usage

Object relations are accessible through any mach.Objects list, using the following APIs:

  • .is(object_id) returns a bool indicating if the given object is alive, valid, and from this pool of objects.
  • .getParent(child_id) -> returns an error, null, or parent object ID
  • .setParent(child_id, parent_id)
  • .addChild(parent_id, child_id)
  • .removeChild(parent_id, child_id)
  • .getChildren(parent_id) -> returns an error or results object with dynamic-size .items []const ObjectID field
    • Call results.deinit() to return memory for reuse by future getChildren() calls.

Consult the actual code for specific behavior.

That’s all there is to it!

Congratulations, you’ve read all the documentation for the Mach object system!