Asynchronous Programming with Seastar

Nadav Har’El - nyh@ScyllaDB.com

Avi Kivity - avi@ScyllaDB.com

Back to table of contents. Previous: 5 Continuations. Next: 7 Lifetime management.

6 Handling exceptions

An exception thrown in a continuation is implicitly captured by the system and stored in the future. A future that stores such an exception is similar to a ready future in that it can cause its continuation to be launched, but it does not contain a value – only the exception.

Calling .then() on such a future skips over the continuation, and transfers the exception for the input future (the object on which .then() is called) to the output future (.then()’s return value).

This default handling parallels normal exception behavior – if an exception is thrown in straight-line code, all following lines are skipped:

line1();
line2(); // throws!
line3(); // skipped

is similar to

return line1().then([] {
    return line2(); // throws!
}).then([] {
    return line3(); // skipped
});

Usually, aborting the current chain of operations and returning an exception is what’s needed, but sometimes more fine-grained control is required. There are several primitives for handling exceptions:

  1. .then_wrapped(): instead of passing the values carried by the future into the continuation, .then_wrapped() passes the input future to the continuation. The future is guaranteed to be in ready state, so the continuation can examine whether it contains a value or an exception, and take appropriate action.
  2. .finally(): similar to a Java finally block, a .finally() continuation is executed whether or not its input future carries an exception or not. The result of the finally continuation is its input future, so .finally() can be used to insert code in a flow that is executed unconditionally, but otherwise does not alter the flow.

TODO: give example code for the above. Also mention handle_exception - although perhaps delay that to a later chapter?

6.1 Exceptions vs. exceptional futures

An asynchronous function can fail in one of two ways: It can fail immediately, by throwing an exception, or it can return a future which will eventually fail (resolve to an exception). These two modes of failure appear similar to the uninitiated, but behave differently when attempting to handle exceptions using finally(), handle_exception(), or then_wrapped(). For example, consider the code:

#include <seastar/core/future.hh>
#include <iostream>
#include <exception>

class my_exception : public std::exception {
    virtual const char* what() const noexcept override { return "my exception"; }
};

seastar::future<> fail() {
    return seastar::make_exception_future<>(my_exception());
}

seastar::future<> f() {
    return fail().finally([] {
        std::cout << "cleaning up\n";
    });
}

This code will, as expected, print the “cleaning up” message - the asynchronous function fail() returns a future which resolves to a failure, and the finally() continuation is run despite this failure, as expected.

Now consider that in the above example we had a different definition for fail():

seastar::future<> fail() {
    throw my_exception();
}

Here, fail() does not return a failing future. Rather, it fails to return a future at all! The exception it throws stops the entire function f(), and the finally() continuation does not not get attached to the future (which was never returned), and will never run. The “cleaning up” message is not printed now.

We recommend that to reduce the chance for such errors, asynchronous functions should always return a failed future rather than throw an actual exception. If the asynchronous function calls another function before returning a future, and that second function might throw, it should use try/catch to catch the exception and convert it into a failed future:

void inner() {
    throw my_exception();
}
seastar::future<> fail() {
    try {
        inner();
    } catch(...) {
        return seastar::make_exception_future(std::current_exception());
    }
    return seastar::make_ready_future<>();
}

Here, fail() catches the exception thrown by inner(), whatever it might be, and returns a failed future with that failure. Written this way, the finally() continuation will be reached, and the “cleaning up” message printed.

Despite this recommendation that asynchronous functions avoid throwing, some asynchronous functions do throw exceptions in addition to returning exceptional futures. A common example are functions which allocate memory and throw std::bad_alloc when running out of memory, instead of returning a future. The future<> seastar::semaphore::wait() method is one such function: It returns a future which may be exceptional if the semaphore was broken() or the wait timed out, but may also throw an exception when failing to allocate memory it needs to hold the list of waiters. Therefore, unless a function — including asynchronous functions — is explicitly tagged “noexcept”, the application should be prepared to handle exceptions thrown from it. In modern C++, code usually uses RAII to be exception-safe without sprinkling it with try/catch. seastar::defer() is a RAII-based idiom that ensures that some cleanup code is run even if an exception is thrown.

Seastar has a convenient generic function, futurize_invoke(), which can be useful here. futurize_invoke(func, args...) runs a function which may return either a future value or an immediate value, and in both cases convert the result into a future value. futurize_invoke() also converts an immediate exception thrown by the function, if any, into a failed future, just like we did above. So using futurize_invoke() we can make the above example work even if fail() did throw exceptions:

seastar::future<> fail() {
    throw my_exception();
}
seastar::future<> f() {
    return seastar::futurize_invoke(fail).finally([] {
        std::cout << "cleaning up\n";
    });
}

Note that most of this discussion becomes moot if the risk of exception is inside a continuation. Consider the following code:

seastar::future<> f() {
    return seastar::sleep(1s).then([] {
        throw my_exception();
    }).finally([] {
        std::cout << "cleaning up\n";
    });
}

Here, the lambda function of the first continuation does throw an exception instead of returning a failed future. However, we do not have the same problem as before, which only happened because an asynchronous function threw an exception before returning a valid future. Here, f() does return a valid future immediately - the failure will only be known later, after sleep() resolves. The message in finally() will be printed. The methods which attach continuations (such as then() and finally()) run the continuation the same way, so continuation functions may return immediate values or, in this case, throw an immediate exception, and still work properly.