mikeash.com: just this guy, you know?

Posted at 2017-08-25 13:13 | RSS feed (Full text feed) | Blog Index
Next article: Corporate Training, NYC Workshop, and Book Update
Previous article: Friday Q&A 2017-08-11: Swift.Unmanaged
Tags: assembly fridayqna swift
Friday Q&A 2017-08-25: Swift Error Handling Implementation
by Mike Ash  
This article is also available in Korean (translation by pilgwon).

Swift's error handling is a unique feature of the language. It looks a lot like exceptions in other languages, but the syntax is not quite the same, and it doesn't quite work the same either. Today I'm going to take a look at how Swift errors work on the inside.

Semantics
Let's start with a quick refresher on how Swift errors work at the language level.

Any Swift function can be decorated with a throws keyword, which indicates that it can throw an error:

    func getStringMightFail() throws -> String { ...

To actually throw an error from such a function, use the throw keyword with a value that conforms to the Error protocol:

        throw MyError.brainNotFound

When calling a throws function, you must include the try keyword:

    let string = try getStringMightFail()

The try keyword doesn't do anything, but is a required marker to indicate that the function might throw an error. The call must be in a context where throwing an error is allowed, either in a throws function, or in a do block with a catch handler.

To write a catch handler, place the try call in a do block, and add a catch block:

    do {
        let string = try getStringMightFail()
        ...
    } catch {
        print("Got an error: \(error)")
    }

When an error is thrown, execution jumps to the catch block. The value that was thrown is available in error. You can get fancy with type checking and conditions and multiple catch clauses, but these are the basics. For more information about all the details, see the Error Handling section of The Swift Programming Language.

That's what it does. How does it work?

Implementation
To find out how it works, I wrote some dummy code with error handling that I could disassemble:

    struct MyError: Error {
        var x: Int
        var y: Int
        var z: Int
    }

    func Thrower(x: Int, y: Int, z: Int) throws -> Int {
        throw MyError(x: x, y: y, z: z)
    }

    func Catcher(f: (Int, Int, Int) throws -> Int) {
        do {
            let x = try f(1, 2, 3)
            print("Received \(x)")
        } catch {
            print("Caught \(error)")
        }
    }

Of course, now that Swift is open source, I could just go look at the compiler code and see what it does. But that's no fun, and this is easier.

It turns out that Swift 3 and Swift 4 do it differently. I'll briefly discuss Swift 3, then look a bit deeper at Swift 4, since that's up and coming.

Swift 3 works by essentially automating Objective-C's NSError convention. The compiler inserts an extra, hidden parameter which is essentially Error *, or NSError **. Throwing an error consists of writing the error object to the pointer passed in that parameter. The caller allocates some stack space and passes its address in that parameter. On return, it checks to see if that space now contains an error. If it does, it jumps to the catch block.

Swift 4 gets a little fancier. The basic idea is the same, but instead of a normal extra parameter, a special register is reserved for the error return. Here's what the relevant assembly code in Thrower looks like:

    call       imp___stubs__swift_allocError
    mov        qword [rdx], rbx
    mov        qword [rdx+8], r15
    mov        qword [rdx+0x10], r14
    mov        r12, rax

This calls into the Swift runtime to allocate a new error, fills it out with the relevant values, and then places the pointer into r12. It then returns to the caller. The relevant code in Catcher looks like this:

    call       r14
    mov        r15, rax
    test       r12, r12
    je         loc_100002cec

It makes the call, then checks if r12 contains anything. If it does, it jumps to the catch block. The technique on ARM64 is almost the same, with the x21 register serving as the error pointer.

Internally, it looks a lot like returning a Result type, or otherwise returning some sort of error code. The throws function returns the thrown error to the caller in a special place. The caller checks that place for an error, and jumps to the error handling code if so. The generated code looks similar to Objective-C code using an NSError ** parameter, and in fact Swift 3's version of it is identical.

Comparison With Exceptions
Swift is careful never to use the word "exception" when discussing its error handling system, but it looks a lot like exceptions in other languages. How does its implementation compare? There are a lot of languages out there with exceptions, and many of them do things differently, but the natural comparison is C++. Objective-C exceptions (which do exist, although pretty much nobody uses them) use C++'s exceptions mechanism on the modern runtime.

A full exploration of how C++ exceptions work could fill a book, so we'll have to settle for a brief description.

C++ code that calls throwing functions (which is the default for C++ functions) produces assembly exactly as if it called non-throwing functions. Which is to say, it passes in parameters and retrieves return values and gives no thought to the possibility of exceptions.

How can this possibly work? In addition to generating the no-exceptions code, the compiler also generates a table with information about how (and whether) the code handles exceptions and how to safely unwind the stack to exit out of the function in the event that an exception is thrown.

When some function throws an exception, it walks up the stack, looking up each function's information and using that to unwind the stack to the next function, until it either finds an exception handler or runs off the end. If it finds an exception handler, it transfers control to that handler which then runs the code in the catch block.

For more information about how C++ exceptions work, see C++ ABI for Itanium: Exception Handling.

This system is called "zero-cost" exception handling. The term "zero-cost" refers to what happens when no exceptions are ever thrown. Because that code is compiled exactly as it would be without exceptions, there's no runtime overhead for supporting exceptions. Calling potentially-throwing functions is just as fast as calling functions that don't throw, and adding try blocks to your code doesn't result in any additional work done at runtime.

When an exception is thrown, the concept of "zero-cost" goes out the window. Unwinding the stack using the tables is an expensive process and takes a substantial amount of time. The system is designed around the idea that exceptions are thrown rarely, and performance in the case where no exceptions are ever thrown is more important. This assumption is likely to be true in almost all code.

Compared to this, Swift's system is extremely simple. It makes no attempt to generate the same code for throws and non-throws functions. Instead, every call to a throws function is followed by a check to see if an error was returned, and a jump to the appropriate error handling code if so. These checks aren't free, although they should be pretty cheap.

The tradeoff makes a lot of sense for Swift. Swift errors look a lot like C++ exceptions, but in practice they're used differently. Nearly any C++ call can potentially throw, and even basic stuff like the new operator will throw to indicate an error. Explicitly checking for a thrown exception after every call would add a lot of extra checks. In contrast, few Swift calls are marked throws in typical codebases, so the cost of explicit checks is low.

Conclusion
Swift's error handling invites comparison with exceptions in other languages, such as C++. C++'s exception handling is extremely complicated internally, but Swift takes a different approach. Instead of unwind tables to achieve "zero-cost" in the common case, Swift returns thrown errors in a special register, and the caller checks that register to see if an error has been thrown. This adds a bit of overhead when errors aren't thrown, but avoids making things enormously complicated the way C++ does. It would take serious effort to write Swift code where the overhead from error handling makes any noticeable difference.

That's it for today! Come back again for more excitement, fun, and horror. As I have occasionally mentioned before, Friday Q&A is driven by reader suggestions. As always, if you have a topic you'd like to see covered here, send it in!

Did you enjoy this article? I'm selling a whole book full of them. It's available for iBooks and Kindle, plus a direct download in PDF and ePub format. It's also available in paper for the old-fashioned. Click here for more information.

Comments:

Tinus at 2017-08-25 14:44:35:
It can't be free to store the unwind code (in c++), can it?

mikeash at 2017-08-25 15:59:54:
Tinus: Indeed, you still have to pay for the space. The term "zero-cost" just refers to runtime execution overhead.

Joe Groff at 2017-08-25 16:46:58:
Some more fun trivia: r12 and x21 were specifically chosen because they're normally callee-saved registers, so a non-throwing function can be "toll-free bridged" as a throwing one, since the non-throwing ABI will always preserve null in the error register.

mikeash at 2017-08-25 16:49:03:
Joe Groff: Nice! I suspected that was the case when I looked at it, but didn't want to go too crazy with speculation in the article. What was the reason for switching away from the extra "out" parameter as done in Swift 3? Just a performance thing?

Joe Groff at 2017-08-25 16:55:21:
We had planned from the beginning to have a special calling convention for error handling, but the LLVM backend work didn't land until last year. One of the arguments against manual propagation of errors has long been the code size and performance cost at each call, but Swift's ABI does a pretty good job of minimizing the cost without getting too avant-garde—it's one instruction to zero out the error register at the boundary where you enter a "throws" context, and one instruction (on ARM64 at least, though still one fused uop on Intel) to jump-if-not-zero into the catch block after each call that might throw.

Thomas Tempelmann at 2017-08-26 18:19:56:
So, what about stack traces for unhandled exceptions - how'd I do that?

I.e., in my apps (I often write code in C++ and Xojo, formerly RealBasic) I like to be able to throw an exc in deep functions if something is wrong, and since these languages do not force me to write a catch handler at every level (unlike Java and Swift), I end up with writing exception handlers at the top level (i.e. where an event originates, though, Xojo makes this especially easy by forwarding all unhandled exceptions to the initial Application object), where I'd then write a log file and tell the user that something went wrong. That log file would then optionally sent by the user to me, so that I could figure out where and possibly why something went wrong there.

This error log would include a stack trace because Xojo would include the stack trace on a throw, (and C++, well, see this, for example: https://stackoverflow.com/a/26883211/43615).

Of course, the advantage in Xojo is that even the runtime uses exceptions when reporting things like null pointers, out-of-bounds errors (for array access, for instance) and other low level errors. Swift doesn't do that, unfortunately (and I understand the reasoning behind some of it, but not all - I still want back a language that throws when encountering a div-by-zero or a value overflow in an expression or assignment).

So I wonder if I should change my programming paradigms and stop using exceptions for reporting unexpected states, or if I keep using them, whether I'll lose the ability to catch them at the highest level, along with learning the stack trace?

mikeash at 2017-08-29 17:12:22:
Thomas Tempelmann: There's no such thing as an unhandled error in Swift. You're only allowed to write throw or try in a context where it's guaranteed that something will catch it.

If you want to be able to throw from anywhere and catch at the top level, then all of your functions would need to be declared as throws. Which of course you could do, if you wanted to! If you wanted to take it to an extreme, you could write versions of force-unwrapping, subscripting, division, etc. that throw rather than crashing when their preconditions are violated.

I don't think top-level exception handlers, and throwing exceptions for every single error, is a good idea. We should distinguish between programmer errors and external errors. Programmer errors can't be anticipated in code, pretty much by definition. Any attempt to handle them is fraught with peril. If, for example, some Optional contains nil when you thought it couldn't possibly do so, what else is wrong with the state in your program? Trying to continue is a potential disaster.

External errors are things like disk failures, network failures, or badly formatted data. These you can anticipate and deal with in code. To do it properly, you need to test all of that error handling code with the failures you've written it to handle.

If the goal is better diagnostics for users when they hit bugs, I think the best bet is to crash on failure, and use some sort of crash reporting tool to get the stack traces to you.


Comments RSS feed for this page

Add your thoughts, post a comment:

Spam and off-topic posts will be deleted without notice. Culprits may be publicly humiliated at my sole discretion.

Name:
Web site:
Comment:
Formatting: <i> <b> <blockquote> <code>. URLs are automatically hyperlinked.
Code syntax highlighting thanks to Pygments.
Hosted at DigitalOcean.