Building Channels in C++: Part 6 - Adding timeouts

Picture of Matt Tolman
Written by
Published: Apr. 5, 2025
Estimated reading time: 8 min read

In Building Channels in C++: Part 5 - Writing a select statement we created a select statement for our channels which can handle sending and receiving values. If you haven't already, I recommend reading that post before continuing with this one. Don't worry, this post will still be here.

In this post, we're going to add timeouts to our select statement.

Previously, we had two cases for our select statement: send and receive. We're now going to add a third: before. Before will take a point in time, and will return a timeout if we exceed that point in time. We're doing it a "point in time" way so we don't have to keep updating the time after every cycle. So, let's get started!

Creating the before case

The first step is to create a new case we can pass to our select. The case should work with a standard clock, and it should store a time point. We can infer the clock by templatizing the parameters to the time point (which takes a clock and duration template parameter). We won't use the duration template parameter directly, but if we're templatizing parameters then we still need to templatize that paramter to avoid potential templating issues in the future. We'll also add a wrapper method for type inference.

Like send and receive, we'll also give it a callback so developers can print an error, clean up, etc. Unlike send and receive, we will only call the callback on timeout (or failure) - rather than success.

As for our action, we're going to compare the current time from the templatized type with the time point time. If we exceed the time point time, we will return a timeout error. We'll simply add a timeout error to our select errors, and return that error. Here's the code:


enum class SelectErrors {
  NOT_READY_YET,
  TIMEOUT,
};

template<typename Clock, typename Duration, typename Callback>
struct BeforeCase {
  std::chrono::time_point<Clock, Duration> end;
  Callback callback;

  Result<void, SelectErrors> attempt() {
    if (Clock::system_clock::now() > end) {
      callback();
      return error(SelectErrors::TIMEOUT);
    }
    return {};
  }
};

template<typename Clock, typename Duration, typename Callback>
BeforeCase<Clock, Duration, Callback> before_case(std::chrono::time_point<Clock, Duration> tp, Callback callback) {
  return BeforeCase<Clock, Duration, Callback>{tp, callback};
}

Adding a timeout case

Having a before case is nice if you are trying to ensure an entire task (and all it's operations) complete by a deadline. This works great for task-based programming where we ensure each request completes in 100ms - regardless of how long individual operations take.

However, a more common pattern I've seen is to ensure every operation completes within a relative time window. In these code bases, instead of thinking of tasks we think in operations. We ensure that talking to the database is done in 50ms, and saving to cache takes 5ms, and talking to a remote service takes 35ms. We get the same 100ms number, but it's broken down by operation, not by total task. If our database request takes 30ms but our remote service call takes 40ms we still timeout - even though we're within the 100ms limit.

There's a whole other conversation about which is better (task timeout or operation timeout), but we're not worried about that. Instead, we're going to support both - and we're going to do it without a new case type! We'll just create a wrapper around our current case. Here's the code:


template<typename Rep, typename Period, typename Callback>
auto timeout_case(std::chrono::duration<Rep, Period> duration, Callback callback) {
  using Clock = std::chrono::system_clock;
  return before_case(Clock::now() + duration, callback);
}

That's all there is to it!

Do note that this will behave very diffently in a loop compared to a normal before case. A before case is absolute - the select must finish before this time no matter what. It doesn't care that there's been 1, 100, or 1,000,000 loop iterations. It guarantees that the entire loop completes in a specific time point.

In contrast, a timeout gives each loop iteration a window to operate in (e.g. 50ms). It gives each iteration 50ms to complete, allowing it to have sufficient time regardless of how slow (or how many) previous iterations had. However, this means that there is no limit on how long the loop takes - if there's an infinite number of iterations the loop will happily continue forever.

If we combine both our before and timeout cases, we get a unique benefit. Our before case will limit the entire loop duration, so our loop is guaranteed to take only a specific amount of time. Additionally, our timeout case will limit the iteration duration, so we are guaranteed that no iteration runs too long. It's a nice way to both keep time frames and abort early if operations start getting off course.

Registering our Before Case

Now that we have our before case, we need to register it with our select statement. This is as simple as adding a new template specialization. However, we're going to operate a little differently.

In our previous specializations, we call the next step if our attempt method fails, but we return if our attempt method succeeds. For our before case we're going to do the opposite - call next on success and return on fail. This allows our timeout to stop execution on an error, while we permit execution on a success. This allows us to have really clear before case logic, while flipping it in the specialization. It's the whole reason we didn't abstract away earlier with just two cases - premature abstraction would ruin our case logic!

Here's the new specialization:


template<typename Clock, typename Duration, typename Callback, typename ...Args>
struct Select<BeforeCase<Clock, Duration, Callback>, Args...> {
  using Case = BeforeCase<Clock, Duration, Callback>;
  using Next = Select<Args...>;
  Case beforeCase;
  Next next;

  Select(Case beforeCase, Args... nextVals)
  : beforeCase(beforeCase), next({nextVals...}) {}

  Result<void, SelectErrors> step() {
    auto res = beforeCase.attempt();
    if (!res) {
      // since we're using SelectErrors in before,
      // we can just return our result here directly!
      return res;
    }
    return next.step();
  }

  void operator()() { while (!step()) { std::this_thread::yield(); } }
};

There's one sublte little edge case though. We're returning an error, but our operator() will keep looping so long as there's an error. What we need to do is update all of our operator() to break on a timeout. Something like the following would work:


void operator()() {
  Result<void, SelectErrors> res = step();
  while(res.is_error() && res.get_error() == SelectErrors::NOT_READY_YET) {
    std::this_thread::yield();
    res = step();
  }
}

We'll copy that to overwrite our operator() in our ReceiveCase, SendCase, and BeforeCase specializations. And with that, we've added a timeout to our select! We can use it as follows:


int main() {
  Channel<int, 1> c;
  Channel<int, 1> quit;

  auto t1 = std::thread([&]{
    int x = 1;
    bool done = false;
    using namespace std::chrono_literals;
    // make sure the entire loop takes less than 5 seconds
    auto loopDone = std::chrono::system_clock::now() + 5s;
    while (!done) {
      // our select statement
      select(
          send_case(c, x, [&](const auto&){
            x *= x + 1;
          }),
          receive_case(quit, [&](const Result<int, ChannelBlockError>& v){
            auto val = v.get_value();
            std::cout << "Ending with " << val << "\n";
            done = true;
          }),
          // Restrict each iteration to be less than 1 second
          timeout_case(1s, [&]{
            std::cerr << "LOOP ITERATION TIMED OUT!\n";
            done = true;
          }),
          // Restrict the entire loop
          before_case(loopDone, [&]{
            std::cerr << "FULL LOOP TIMED OUT!\n";
            done = true;
          })
      );
    }
  });

  for (int i = 0; i < 8; ++i) {
    std::cout << c.receive().get_value() << "\n";
  }

  quit.send(13);

  t1.join();
  return 0;
}

And we're done! Well, almost. There's on subtle bug in our select statement we'll be fixing next time - a little hidden infinite loop that could happen in just the right conditions. To see the bug (and the fix), read Building Channels in C++: Part 7 - Fixing an infinite loop.