Sharing the Loop

Overview

Bessy python’s main() function takes over the program. Once main() is called, it will not return until there is no more data (offline), or loop forever (online).

We want Bessy to share the “main loop” such that a program could do things while Bessy is processing. For example, updating a user interface, providing data visualizations or just cleanly telling Bessy’s loop when to stop.

Use threads?

One solution is to run main() in a thread. This is what ThinkLab does right now, but as can be seen by the BessyController class, this is far from a trivial thing to do.

Solving a problem with multi-threading? You now two problems have.
Read the second line carefully…

The main challenge with threads is communication. Something has to coordinate reading/writing of memory. If this isn’t done, then threads will end up reading/writing from the same memory location at the same time, leading to very tricky bugs.

There are many ways to address the thread communication problem, such as semaphores, sockets, message queues, and other forms of async IO tools. However, all of these approaches increase the complexity of code and therefore the cost to maintain it. Essentially, there’s a “thread tax” to every feature. For example, instead of calling a function to trigger an action, a message must be composed, written to a socket, read from the socket and then decoded.

In the spirit of keeping code simple and easy to maintain - if you can avoid threads, you should.

Ok, don’t use threads

A solution that avoids threads is to provide a break in the loop that allows the program to do other things. In the example below, the function step() runs one loop of Bessy’s main(). This makes it possible to move the loop “up one level” so that the program can do other things, ex:

bessy = EegData(...)
while running:
    bessy.step()
    view.display_training_feedback()
    running = was_quit_button_pressed()

There are some caveats to using this approach in the “online” case. Bessy needs to be given enough opportunities to process input/output in ~real time or data could be lost. Fortunately, EEG data rates are not that high and modern computers are very fast. We can rely to some degree on sockets (ex: LSL) to buffer data. As long as bessy.step() is called a few times a second, data will be processed fast enough.

Proposed Solution

Provide a way to execute one cycle of the “main loop” with a step() function.

Continue to support the existing use case, where the loop will run forever. In keeping with the “step” metaphor, main() should be renamed to “run”. The run() function would just call step() in a loop.

The existing main() function takes several arguments that control the loop, such as online/offline, iterative training and provide feedback. There will need to be a way to provide the same arguments to step().

Below are a few different ways to implement this solution:

1. move loop arguments to setup function

A setup function processes the loop arguments before run() or step() is called. This would require changing all existing examples, but the interface would be more uniform. This pattern is similar to how PyQt and Electron start their event loop:

bessy.setup(...loop arguments...)
bessy.run()
bessy.setup(...loop arguments...)
while running:
  bessy.step()

2. setup function only for step

Avoid changing existing code, just provide a setup function for step(). This may be more confusing to users, as to when setup() should be called.

bessy.run(...loop arguments...)
bessy.setup(...loop arguments...)
while running:
  bessy.step()

3. always pass in the loop arguments

This also avoids changing existing code, but it makes it possible to change loop arguments mid loop. For example, switching from online to offline. Would that ever make sense? These edge cases would either have to be handled, or the user needs to “know” not to do that.

bessy.run(...loop arguments...)
while running:
  bessy.step(...loop arguments...)

4. make Bessy persist state between main() calls

This is probably the least invasive change. Instead of a separate step function, modify main() (or.. run) such that it preserves state between calls. If a user wants to break the loop, they can set max_loops to stop the loop. Subsequent calls to main() will pick up where the loop left off.

This may already work, just limit max_loops, possibly set “eeg_start” to 1 and keep calling run() (ie “main”). Need a way to test this.

This solution opens up the possibility of changing loop arguments mid loop, which creates edge cases.

It might be better to implement this with a separate argument, ex: loop_once.

bessy.run(...loop arguments...)
while running:
  bessy.run(...loop arguments..., max_loops=1)

Resolution

Brian Irvine suggested going with option 1. It’s no big deal to change the interface now, it’s cleaner. It also offers a path to supporting multiple users (multiple instances of bessy running at once using interleaved step() calls). There’s no use case for changing arguments mid-loop, so we should avoid an interface that allows it (options 3, 4).