Skip to content
michael.caisse.io michael.caisse.io
c++ senders concurrency

Senders: Exclusive Regions

Hey! I was using that.

M

Michael Caisse

6 min read
Senders: Exclusive Regions

That Was Mine!

I’m continuing to work on the new i2c driver for the Embedded Demo Project. The STM32 devices I use have multiple i2c controllers per device. This is nice for latency or bandwidth concerns, you can just sprinkle i2c devices across multiple busses. More often a single i2c bus will have multiple devices. Those devices are usually queried and controlled by logically separate software components. And here lies the problem, how do we arbitrate access to this shared bus.

i2c schematic

auto display_temperature =
     time_scheduler{}.sender()
   | get_temperature()
   | transform_to_display_data()
   | write_display()
   | periodic(200ms)
   ;

auto on_button_pressed =
     time_scheduler{}.sender()
   | get_button_value()
   | periodic_until(50ms, [](auto v){ return v == button::pressed; })
   ;

auto launch_drones = 
   | on_button_pressed
   | send_killer_drones()
   ;
   
start_detached(display_temperature);
start_detached(launch_drones);

Querying the temperature sensor and updating the display are sequenced by the display_temperature sender chain. But polling the killer drone launch button runs independently and now all kinds of click-bait worthy headlines can ensue.

You might try to convince yourself that it is all ok. The micro only has one core. There is only one timer interrupt and it services the next item in the list. Therefore, only one timer item can be active at a time. We can ensure that all functions that use the i2c run at the same interrupt priority. If we structured our code as sequential functions that poll the i2c controller status then we can contro … Is this sounding like a code base you have worked in before? If so, free yourself from the madness and employ senders to handle the concurrency/asynchronicity challenges. This “solution” is awful and violates everything about our event driven mindset.

Something must manage access to avoid corrupting data and hanging hardware. One of the questions at the end of my CppCon 2025 Groov talk was how groov handles arbitrating the i2c bus. I answered that it is up to the driver to handle this problem. This is true, you want the driver to protect the bus it is controlling and not somehow have coordination across the application. The “exercise is left to the student”, isn’t a very compelling answer for this common problem. So let’s solve it generically!

Exclusive Regions

If our code was sequential functions we could do something like:

auto send_data(auto data) {
    lock_guard l(bus_mutex);
    send_data_impl(begin(data), end(data));
}

Functions calling send_data will wait in line until the mutex is available. This is good. Functions will be released in the order that they started waiting [1]. This is also good. Functions will block their thread of execution until they can acquire the lock. This is bad.

In a feature rich OS that provides a nice thread scheduler, blocking the thread might be perfectly fine. We don’t live in such a world with our baremetal embedded system. We never want to block. We don’t have a system scheduler that is suspending a thread of execution and resuming another while juggling stack frames. If we can remove blocking we will eliminate priority inversion dead-locks. There are many benefits from living in an event driven world, but that is a different blog post.

We want to live in our asynchronous function world and if we take inspiration from our sequential function cousin, we might have the start of a solution.

Thinking about the problem as:

auto send_data(auto data) {
    return
         acquire()
       | send_data(begin(data), end(data))
       | release()
       ;
}

might lead us down a reasonable path.

We want to maintain the following attributes:

  • only one sender execution path can acquire a region at a time — it is exclusive
  • senders that are unable to acquire a region are “queued” in FIFO order
  • releasing the region will start the first waiting sender of the region (if there is one)
  • senders never block execution waiting for a region

Because we aren’t monsters, let’s instead add a function within that takes a composable sender adapter and will do the acquire and release dance for us.

auto within(auto s) {
   // moral equivalent to
   return acquire() | s | release();
}

Senders have the all-important value, error, and stop channels. It would be a shame if we messed that up, so let’s add a couple more requirements for our facility:

  • Upstream completions are forwarded into the region
  • Completions from within the region are forwarded out of the region

e.g. s1 | within(s2) | s3 is equivalent to s1 | s2 | s3

If s2 is a sender instead of a composable adapter, the equivalence would be:

s1 | within(s2) | s3 is equivalent to s1 | seq(s2) | s3

in which case, the upstream completion would not make it to s2 because seq takes a sender that will receive no value on the value channel.

Region (as in, an exclusive region) seems to be a good name for the thing being protected and we should give it a compile-time identifier.

Our API now looks something like:

namespace region {

template <stdx::ct_string Name, async::sender S>
auto within(S &&s);

}

where the return type is a composable adapter.

We can now write something like:

auto write() {
   return async::let_value([](i2c_write_spec auto &spec) {
      return region::within<region_name>(write_xfer(spec));
   });
}

auto read() {
   return async::let_value([](i2c_read_spec auto &spec) {
      return region::within<region_name>(read_xfer(spec));
   });
}

With this formulation, our logical reads and writes on the i2c bus are atomic.

This is great because reading and writting on an i2c bus requires multiple byte transfers and the protocol is byte oriented.

Exclusive regions diagram

Compile-time Identifier

Why the compile-time identifier? Why not pass an object or handle around that represents the region? In practice, passing around objects in these systems often becomes knowing the canonical spelling of the global object. Instead, we are using a well-known tag value… which happens to be the compile-time string identifier. This pattern follows throughout the Intel Senders library, groov, and even CIB. Could an object be used instead? Probably. Let’s see how this plays out in use-cases.

Back to Blog
Share:

Related Posts