C++20 Resumable functions: Goodbye state machines, no one will mourn you

C++20 allows writing functions that suspend and can continue at the next line. This has an amazing application at avoiding writing annoying and error-prone state machines. This article showcases how coroutines can clean up a function that would usually need an ugly state machine.

In imperative programming, the logic of the code closely copies the sequence of actions needed to perform the operation, making it rather intuitive. However, if it the process needs to interact with the physical reality rather than abstractions, it often needs to be interrupted and be resumed again. This can, and often has to be, resolved by saving the state and using it to decide what should be done next. The state machine pattern can be implemented in many ways, with varying degrees of convenience, but it’s always nonlinear and thus less readable and more error prone (the simpler ones can be done as callbacks, but doing cycles with callbacks is even worse than state machines).

If a thread is dedicated to the process, the code can be written linearly, following the steps in sequence, writing loops naturally, which is much cleaner and less error prone. However, it also comes with a massive downside. It scales terribly. Each such process needs to have its own thread and threads are very resource heavy. C++20 has a new feature that deals with this problem: coroutines.

So, where to start?

The internet already has quite a collection of articles and talks about coroutines. They are part of the working version of GCC 11 and are available in GCC 10 if the -fcoroutines flag is present . So I wanted to try them out for myself. I looked at a common example of using a coroutine to implement a generator class similar to Python’s range.

generator<int> range(start) {
//...

And it didn’t work. There is no generator class. None of the classes returned in the examples isn’t in the standard. The coroutine header contains only some helper classes and the std::coroutine_handle class that expects to be templated to a certain class… that isn’t anywhere. Apparently, I was supposed to implement it myself, but there wasn’t much information on that topic on cppreference.

Eventually, I found out that while coroutines did make it into C++20, the classes that use it, like std::net::executor, didn’t. They’re part of Networking TS that will probably make it into C++23. Boost Asio 1.74 come with some useful classes, but that is also quite new and examples are rare.

I found only one article that showed how to use coroutines only with standard headers. I used it to create a class I called Resumable, with an interface that somewhat imitated std::function:

template <typename T>
class Resumable {
public:
	struct promise_type {
		T _returned;
		auto get_return_object() {
			return handle_type::from_promise(*this);
		}
		auto initial_suspend() {
			return std::suspend_always();
		}
		auto final_suspend() {
			return std::suspend_always();
		}
		void unhandled_exception() {
			throw(UnhandledExceptionInCoroutine());
		}
		auto yield_value(const T& value){
			_returned = value;
			return std::suspend_always();
		}
		void return_value(const T& value) {
			_returned = value;
		}
	};
	using handle_type = std::coroutine_handle<promise_type>;
	Resumable(handle_type handle) : _handle(handle) { }
	Resumable(const Resumable&) = delete;
	T operator()() {
		if (_handle.done())
			throw CallToFinsihedCoroutine();
		_handle.resume();
		return _handle.promise()._returned;
	}
	explicit operator bool() {
		return !_handle.done();	
	}
	~Resumable() {
		_handle.destroy();
	}
private:
	handle_type _handle;
};

It could not handle coroutines returning non-void values, so I needed to write a specialisation that didn’t hold the return type. The Resumable instance allows resuming the coroutine with operator(). It can be used as a boolean expression if the coroutine is finished.

Using it was rather straightforward:

// Definition of a function that returns a coroutine instance
Resumable<std::string> poetry() {
	co_yield "Du";
	co_yield "Du hast";
	co_yield "Du hast mich";
}

// Usage of the coroutine
auto sing = poetry();
std::cout << sing() << std::endl; // Prints: Du
std::cout << sing() << std::endl; // Prints: Du hast
std::cout << sing() << std::endl; // Prints: Du hast mich

Coroutines can have automatic variables, blocks, branching, loops, can call other functions and can even throw and catch exceptions. Member functions and lambdas can also be coroutines (constructors and destructors cannot and you don’t want them to be anyway).

How does it work on low level?

Internally, the coroutine is class. Its automatic variables that persist when it’s suspended are the member variables of the class. It is capable of saving the position of its instruction counter when suspended and to return to that position when resumed.

The coroutine object is saved on heap, but the compiler does not have to do so. If a normal function is called from a coroutine, then its automatic variables are saved on the stack, just after the code that called the coroutine.

An example of avoiding a nasty state machine with a nice coroutine

During my studies of physics, I used a device that slowly, linearly, heated a sample to a certain temperature, kept it there for some time and then cooled it down linearly. This could be done either to study the gases produced by chemical reactions in the sample in order to investigate its chemistry or to anneal the sample in an exact and repeatable way for further analysis.

Later, I had to implement a controller of such a heating process myself. The process was somewhat more complex and the state machine that did the work was a huge nasty tangle of a function with lots of repetitive code, even if I did my best to keep up with best practices. It took me days to debug.

I realised how well can a coroutine shorten the code. Just to try it out, I wrote the main part of the algorithm with coroutines and I was able to write it, debug it and adjust its parameters in less than an hour. The process controls the movement of the temperature over a curve that increases linearly, stays at the maximum temperature and then linearly decreases, using a pattern known as PI controller (it’s a simplified version of the more known PID controller). The control part that calculates the power that needs to be fed into the heating is repeated a lot and also not very important, so I put it in a lambda:

auto fullStep = [&] (int time) {
	integral += (wanted - *currentTemperature) * time / 1000.0;
	if (fabsf(*currentTemperature - wanted) > maxDeviation)
		throw std::runtime_error("Temperature is out of control");
	return (targetTemperature - *currentTemperature) * coeffP
			+ integral * coeffI;
};

If you don’t understand it, it doesn’t really matter. This is the code that goes over the curve. After calculating the power, it returns it and suspends, to be resumed when the value needs to be updated.

try {
	// Heat up
	std::cout << "Heating" << std::endl;
	while (wanted < targetTemperature) {
		auto time = tickTime();
		wanted += upRamp / 1000.0 * time;
		co_yield fullStep(time);
	}
	// Keep it at max temperature
	std::cout << "Hot" << std::endl;
	auto stayStarted = std::chrono::steady_clock::now();
	while (std::chrono::steady_clock::now() - stayStarted
			< std::chrono::seconds(stay)) {
		co_yield fullStep(tickTime());
	}
} catch (std::exception& e) {
	std::cerr << e.what() << std::endl;	
}
// Cool down, when done or if there is a problem
maxDeviation *= 2; // Increase error tolerance
std::cout << "Cooling" << std::endl;
try {
	while (wanted > roomTemperature) {
		auto time = tickTime();
		wanted -= downRamp / 1000.0 * time;
		co_yield fullStep(time);
	}
} catch (std::exception& e) {
	std::cerr << "Cooling broken: " << e.what() << ", aborting" << std::endl;	
} // Just showcasing exceptions, I have doubts if it is really suitable here

In the case if the progress wasn’t linear, it would be easily solvable with cycles. State transitions tend to be some kind of invisible goto, so it’s good to replace them with something else.

Meanwhile, communication with the physical device was simulated by a lambda, also a coroutine:

auto updateDevice = [&] () -> Resumable<float> {
	struct Connection { // Pretend we are communicating with something
		Connection() {
			std::cout << "Initialising" << std::endl;
		};
		~Connection() {
			std::cout << "Terminating" << std::endl;
		};
	};
	Connection conn;
	
	while (true)
		co_yield currentTemperature + power / 10
				- (currentTemperature - roomTemperature) / 10;
} (); // Note: the lambda creates the coroutine instance when called

Then, every 100 milliseconds, the updateDevice coroutine was used to calculate the simulated temperature while taking the power fed to the heating in consideration.

while (controller) {
	currentTemperature = updateDevice();
	power = controller();
	std::cout << "Temperature\t" << currentTemperature << std::endl;
	std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

The whole code can be found and tried out here.

Is that all?

No, absolutely not. While co_yield is extremely useful, it’s only a small introduction to the whole thing (actually, if you check the code of Resumable, you’ll see that even co_yield can be made to behave differently). With another keyword, co_await, there are much more possibilities, for example suspendable functions can wait for other suspendable functions to finish, allowing much cleaner and error resilient code in networking, GUI, embedded systems or games. This however requires much more code to get working and it’s readily available in a more recent version of Boost Asio.

Leave a Reply

Your email address will not be published.