Settings

Theme

C++ Exceptions: Under the Hood (2013)

monkeywritescode.blogspot.com

115 points by arcatek 4 years ago · 115 comments

Reader

WalterBright 4 years ago

Working with and implementing C++ exceptions for 30 years now, including implementing exception handling for Windows, DOS extenders, and Posix (all very different), and then re-implementing them for D, I have sadly come to the conclusion that exceptions are a giant mistake.

1. they are very hard to understand all the way down

2. they are largely undocumented in how they're implemented

3. they are slow when thrown

4. they are slow when not thrown

5. it is hard to write exception-safe code

6. very few understand how to write exception-safe code

7. there is no such thing as zero-cost exception handling

8. optimizers just give up trying to do flow analysis in try-exception blocks

9. consider double-fault exceptions - there's something that shows how rotten it is

10. has anyone yet found a legitimate use for throwing an `int`?

I have quit using exceptions in my own code, making everything 'nothrow'. I regret propagating exception handling into D. Constructors that may throw are an abomination. Destructors that throw are even worse.

  • WalterBright 4 years ago

    P.S. I implemented Structured Exception Handling for Win32, with help from a couple very smart people. Microsoft completely changed it for Win64, and the documentation on it is completely unhelpful to nonexistent.[1] I simply gave up on it. D on Win64 uses the exception handling mechanism I invented for the 32 bit DOS extender from the old Zortech days. It works fine, except that it cannot interact with C++ exceptions thrown by VC++ code.

    [1] I attended a presentation on it by MS soon after Win64 came out. All I could think of at the end was "what's a cubit". I understood exactly nothing about it.

    • Iwan-Zotow 4 years ago

      Ah, Zortech C++. That indeed brings back some memories.

      Walter, what do you think about Herb Sutter "new" C++ exceptions (which is basically about throwing an int/a word) ?

      • WalterBright 4 years ago

        If there's anyone who can think of a better way, it's Herb. I'd take anything he has to say about C++ very very seriously.

  • grandinj 4 years ago

    On the other hand

    () they solve a handful of use-cases really really well.

    () if you are writing relatively decent C++, most code is pretty much exception safe already

    () lots of abstractions are dangerous when mis-used

    () they are sufficiently low cost that they almost never show up in the fairly extensive perf profiling I do on a large real-world application (LibreOffice). And LibreOffice throws exceptions __a lot__

    (*) Except for toolchain writers, nobody cares how they are implemented

    • tialaramex 4 years ago

      > LibreOffice throws exceptions __a lot__

      They're terrible for this. C++ Exceptions are a perfectly good exception mechanism, but what C++ programmers are trained to do with them isn't exceptions but error handling and they're not suitable for that.

      "I tried to create the file but it already existed" is an error - and now you're going to write the unhappy path code, this is the wrong place to have exceptions.

      "I tried to create the file but the OS now says the abstract concept of files is alien to it" is an exception. You are not prepared for this eventuality, the best you can do is explain to the user as best possible what happened and hope a human knows what to do.

      • TeMPOraL 4 years ago

        > error handling and [exceptions are] not suitable for that

        I disagree :).

        Exceptions are fundamentally equivalent to "return sum type" error handling pattern. In an exception-enabled environment, you can imagine every function returning some Foo is really returning a {Foo, Error}. Then, every call like below, outside of try/catch block:

          value = SomeFunction();
        
        is secretly translated to:

          maybeValue = SomeFunction();
          if(!maybeValue) { return maybeValue.error(); }
        
        You can devise analogous translations for code in try/catch blocks.

        The fundamental difference between exceptions and an Expected/Maybe mechanism is that exceptions don't force you to be explicit about all those Maybe values. If you want to handle some Error three layers up in the call stack, you don't have to litter the intermediary layers with explicit Maybes everywhere.

        (This is, unfortunately, also their drawback in typical implementations - the set of possible Error types in the hidden Maybes becomes effectively open-ended, when with explicit Maybes, it's constrained and visible in source code - and, perhaps more importantly, in the ABI.)

        The other day I did an experiment - I wrote two equivalent pieces of nontrivial production code, one using C++ exceptions, and other using the tl::expected library. If you looked past the syntactic noise, they mapped almost 1:1 in terms of error handling and error recovery patterns.

        • tialaramex 4 years ago

          Obviously we can translate one Turing complete paradigm into another, but that's not very interesting.

          I argue that as so very often C++ the defaults are wrong. You can easily do the wrong thing, or you can go to a lot of effort to do the right thing, and since the right thing was technically possible C++ practitioners proudly declare C++ got this correct, and I say it did not.

          [See also: everything about const from West Const being endorsed in NL26 despite being silly, through to the fact that the default is mutable for no good reason; the fact char isn't necessarily signed or unsigned you need to pick one if you care; need to explicitly use a provided replacement for the array type because the default built-in array type is broken; the default meaning of the literal "Hello, world" is this awful NUL-terminated byte array using that broken default array type; Way too many dubious implicit coercions, including narrowing conversions everywhere; I could go on]

          Because we're not handling truly exceptional cases we will often want to treat the OK and error cases similarly. We tried to go outside and it was raining so maybe we should get an umbrella before venturing out again, but it wasn't on fire out there so we don't need to freak out and abandon our remaining plans to flee the fire immediately.

          Exceptions make this needlessly difficult whereas sum types don't. The exception deliberately changes program execution, that is in fact its purpose, whereas the sum type lets you carry around the error and its context just as you would an "OK" result, until you need it for something or you decide you didn't need it and drop it on the floor.

          Bad defaults get replicated for consistency. There are a lot of bad practices -- things you definitely shouldn't do -- that are now enshrined permanently in the ABI of the C++ standard library and so for consistency you're going to inherit those practices.

          As a result I agree that Expected doesn't feel nicer in C++ today than exceptions but I argue that's a language defect, in a better language you'd find Expected worked better for the unhappy paths of your program and exceptions remained available for those truly exceptional cases that the programmer did not anticipate happening. Now, one programmer might feel that even "File already exists" truly is exceptional for their scenario, while another considers "Disk I/O error" to be merely an error they can cope with and no big deal (maybe the second programmer is writing an IT forensics program). That's going to vary, but the way for a standard library to reflect that is to use Expected almost everywhere and allow the developer who thinks "File already exists" is exceptional to throw for it, not have the standard library throw everything and then you race around trying to catch what you need to and hope you didn't miss anything.

          • mikdore 4 years ago

            I so feel the last point, and I'd like to add, that not having [as of now] any pattern matching really hurts when working with all sorts of sum types. Coming from Rust, something like `tl::expected` and `Result` feel like night and day.

      • grandinj 4 years ago

        I guess people's experiences are different :-)

        I find exceptions to be a perfectly fine error handling mechanism.

        I certainly prefer it to cluttering my code with explicit checks for return codes and such like.

        • dnautics 4 years ago

          returning errors doesn't have to have cluttered code. Just because Go messed it up doesn't mean it's bad.

  • dataflow 4 years ago

    Don't you need exceptions though? How do you terminate arbitrary operations without exceptions?

    Like say you call an algorithm (like std::sort) and during a callback (e.g. in the comparator) you decide to cancel the operation (perhaps user-requested). With exceptions it's easy; you just throw an exception and then catch it. No need to touch or even know the intermediate callers. But without exceptions what do you do? You have to go modify or reimplement the source code of every intermediate function, which is a giant waste of effort at best, and in reality a likely vector for introducing code duplication, brittleness, and bugs.

    • ilammy 4 years ago

      But with exceptions what do you do? You have to go modify or reimplement the source code of every intermediate function to be correct and safe when an exception is thrown at every point where it can be thrown, which is a giant waste of effort at best, and in reality a likely vector for introducing code duplication, brittleness, and bugs.

      The point is, retrofitting exceptions onto existing codebase is a lot of pain.

      Interruptible functions have the API they have because they have been designed with exceptions in mind for interruptions. If there were no exceptions, the callbacks would have had a different API. A special return value could be used to signal an interruption.

      • dataflow 4 years ago

        No you don't, you're massively exaggerating. The standard library already has at least basic if not strong exception-safety all over it. And RAII is pretty darn standard practice and guarantees basic exception safety in your own code too. You don't need strong exception safety here, just basic is sufficient for most such cases.

        Go try this with std::sort (or std::adjacent_find or whatever) and tell me which of their implementations you had to modify.

        • ilammy 4 years ago

          Well but of course! These functions are already implemented with basic exception safety in mind. What if they weren't? This is exactly the same situation as a function that has

              callback();
          
          which cannot be changed into

              if Err(error) = callback() {
                  return error;
              }
          
          because that would break some invariants.

          Changing return type from "void" into some "result" is a mechanical change.

          • dataflow 4 years ago

            As I already explained: RAII is pretty darn standard practice and guarantees basic exception safety in your own code too. The music is already there and people are already dancing to it.

            > What if they weren't?

            Obviously the language wasn't designed for rebels. The implicit understanding with tools is that you use them the way they're meant to be used. Only in that case do you get to assume you'll reap the benefits they claim to provide. If you insist on deliberately dancing to a different tune, then you get exactly what you asked for. You can't drive against traffic and then complain people run into you.

            • hyperman1 4 years ago

              There are interesting non-rebel cases of What if they weren't. I have a library (object only, no source) written for C (not C++) which wants callbacks. It is the only interface provided by the vendor for something that shall remain nameless. Every callback has to be wrapped in a try catch, or hell will break loose.

          • minipci1321 4 years ago

            > Changing return type from "void" into some "result" is a mechanical change.

            .. but then checking the returned val for error in every call sites is very far from mechanical change. (Attribute about unused return result can help here, with obvious drawbacks.)

    • ribit 4 years ago

      I agree that a modern high-level programming language model needs an ergonomic error model. But C++ exceptions are not the only way to go. You can have error model that have similar (or even better ergonomy) than C++ while not having any of the drawbacks (like extremely complicated runtime stack, slow exception handling, messed up control flow etc.). Basically, in my personal opinion, any error handling that involves automatic stack unwinding is a failure.

      • dataflow 4 years ago

        Note you didn't really answer my question at all.

        Right now lots of algorithms like std::search, std::find_if, etc. are not only exception-safe, but in fact exception-agnostic. Neither the algorithm, nor you, need to know a priori if your predicates will throw exceptions (which are things that may be literally impossible to know upfront), and yet despite that, (a) the algorithms will work completely correctly if any exception is thrown, (b) if you do need to do something like canceling the operations in the middle, you have a means to do that via exceptions, and (c) you will get extremely high performance as long as you don't throw an exception. That's a lot of flexibility even the most trivial implementations of many such algorithms get absolutely for free. (!) I don't know about you, but to me the fact that I can suddenly decide to "cancel" many functions halfway despite their authors never having to even think about that possibility is pure awesomeness.

        So I asked "how would you do achieve {the benefits of the exception model} without exceptions" but you just said "it is possible" and... left me hanging. Well if that's really true, then how?

        > You can have error model that have similar (or even better ergonomy) than C++ while not having any of the drawbacks

        I don't buy it. Unless you're intentionally allowing yourself to introduce drawbacks that never existed in C++'s model. If you're really saying you can find a strictly better solution, then we're all definitely interested in hearing... and I'll believe it when I see it.

        You have to realize ergonomicity (word?) isn't the only axis here. Performance is also a big one, and C++ is designed for maximizing performance in non-exceptional executions. I don't know what error models you're thinking of, but anything along the obvious stuff I've seen (like the usual "replace T with maybe<T>/optional<T>/fancy<T>") would come with far greater performance hits even in the 'happy' paths than C++ has (not to mention potential increases in memory usage, etc. in more complex cases), and even their ergonomics would be debatable depending on the situation.

        • ribit 4 years ago

          > Neither the algorithm, nor you, need to know a priori if your predicates will throw exceptions (which are things that may be literally impossible to know upfront)

          I don't think this property is desirable at all. I prefer to know whether a function can or cannot result in an error, ideally encoded within the type system. The C++ "everything can throw" paradigm yo describe here obfuscates the program logic and promotes bad coding practices. I know, C++ programers like to argue that "everything throws" is a natural property of any real world code, but somehow folks are able to work with Rust and Swift without too much hassle.

          > If you're really saying you can find a strictly better solution, then we're all definitely interested in hearing... and I'll believe it when I see it.

          A strictly better solution has been found long time: error sum types.

          > Performance is also a big one, and C++ is designed for maximizing performance in non-exceptional executions. I don't know what error models you're thinking of, but anything along the obvious stuff I've seen (like the usual "replace T with maybe<T>/optional<T>/fancy<T>") would come with far greater performance hits n the 'happy' paths than C++ has (not to mention potential increases in memory usage, etc. in more complex cases)

          This is again a very popular argument I've seen used by many in the C++ community, but the simple fact is that this argument is simply not true. Already very naive result type implementations using C++ show no measurable performance difference in the "good" path (with a non-trivial function), and using an optimized calling convention makes error sum types zero-cost on modern hardware.

          For example, Swift uses a dedicated register to signal exceptional function result. On the "good" path, you have to zero this register in the callee and conditionally jump on its value in the caller. These operations are essentially free on any modern CPU with superscalar execution, register renaming and branch prediction. The only cost is a register and a few extra instructions which won't carry any performance impact. One can optimize this even further by using condition flags to signal exceptional result (frees up a register and saves an instruction).

          To sum it up, using result types with optimized calling conventions gives you the same performance as the C++ exceptions on the good path, much better performance on the exception path, saves space (few bytes of extra instructions take much less space than the unwind information), radically simplifies the compiler (no long jumps, functions enter and exit regularly), radically simplifies cleanup (function exits regularly and can run destructors as usual), simplifies the control flow and so on.

          In fact, the only disadvantage I see with this implementation is that exception propagation might be slower than a longjump if you have hundreds of nested functions. But I think you have much bigger problems if you call stack looks like that...

      • Akronymus 4 years ago

        IMO algebraic data types pretty much solve everything that exceptions try to solve.

        while also encoding into the type system that it can fail/what failure modes there are, while also forcing you to handle it locally.

        • barrkel 4 years ago

          Local handling is, of course, an anti-pattern in code using exceptions, and not to be encouraged. It's rare that you handle exceptions; normally, you just abort what you're doing and unwind.

          If you have an API which fails often enough that you want to handle exceptions from it, it probably shouldn't use exceptions, and use some kind of conditional result or ADT equivalent instead. A concrete example would be the TryParse methods in .NET.

          • Akronymus 4 years ago

            I think that it is much better to return an adt with "common exceptional cases" and reserve exceptions for the truly exceptional ones. For example, a lost network connection shouldn't be one, but a put of memory one makes sense.

            Local handling was meant in terms of locally seeing pitential errors

  • tux3 4 years ago

    What do you think of languages that use sum types for error handling, but can still unwind in a few scenarios?

    Reasonnable compromise, or should we get rid of all unwinding always? And if so, do we abort() or do we ask users to handle any and all possible errors.

    • AndyKelley 4 years ago

      Now that I've been using Zig's error handling language primitives for some time, I've come to realize what the paradigm really is: a way to encode a "forwards" and a "backwards" at the same time, for the same block of code.

      The usual way control flow progresses is forwards, but when an error occurs, it goes backwards, over the defers and errdefers.

      C++ exception handling and other languages with destructors force you to do this declaratively, but then don't give enough control over exactly the situations they matter in: setup and teardown.

      Meanwhile with explicit control, you just encode exactly what happens in the "backwards" control flow. No surprises, no trying to figure out what happens based on declarative rules. Once I figured this out, I was able to use it to simplify the logic of some things in the self-hosted compiler that are extremely error prone in the C++ implementation:

      * Lazy source locations: passing in "none" for a source location, and then handling the "error.SourceLocationNeeded" and then doing the expensive calculations to find the source locations before retrying the operation.

      * Generic instantiations: returning "error.GenericPoison" for when a type parameter cannot be determined without information from the callsite. In this case, the analysis is cleanly aborted and function marked as generic.

      I'm pleased with how this turned out, and I've started to think of other languages in terms of how they map to this "forwards" and "backwards" control flow concept.

    • WalterBright 4 years ago

      I've read the proposals for it. It certainly looks good, yet exception handling looked good 30 years ago, too. I haven't used sum types myself, and often it takes years to discern whether things are really good ideas or not.

      What I personally use is the "poisoning" technique. This involves marking an object as being in an error state, much like a floating point value can be in a NaN state. Any operation on a poisoned object produces another poisoned object, until eventually this is dealt with at some point in the program.

      I've had satisfactory success with this technique. It does have a lot of parallels with the sum type method.

      • lerno 4 years ago

        I'm experimenting with a solution in C3 that has this behaviour. I don't have a sum type as such, but the binding acts as one. I call the binding a "failable".

            int! a = getMayError();
            // a is now either an int, or contains an error value.
        
            // foo(a) is only conditionally invoked.
            int! b = foo(a);
        
            // The above works as if it was written:
            // int! b = "if a has error" ? "the error of a" : foo("real value of a");
        
            // A single if-catch with return will implicitly unwrap:
            if (catch err = b) {
               /* handle errors */
               return;
            }
            /* b is unwrapped implicitly from here on and is treated as int */
        
            if (try int x = a) {
               /* conditionally execute if a isn't an error */
            }
        
        I think this is kind of formalizing the poison technique but external from the call (that is, "foo" does not need to know about "failables" or return one, the call is skipped on the caller side). Here are some more examples: http://www.c3-lang.org/errorhandling/

        I'd be interested in hearing what you think about this (experimental) solution Walter.

        • mananaysiempre 4 years ago

          So you’ve built in monadic bind for the Either monad into the language:

            Right x >>= f = Right (f x) -- normal case
            Left y  >>= f = Left y -- error propagation case
          
          (The slogan is “monadic bind is an overload for the semicolon”.)

          I don’t expect this knowledge will dramatically change what you’re doing, but now that you know that’s how some people call it you have one more place to steal ideas from :)

          • lerno 4 years ago

            No I'm quite aware of this. It's a restricted, implicit variant of it. But not also that it's not the type but the binding, which makes it slightly different from using a `Result`.

            • mananaysiempre 4 years ago

              Hm. OK. I tried writing a response several times but I still feel confused. Can you explain what you mean by “not the type but the binding”? Note that I know the Haskell but not the Rust (guessing from the “Result” name) way of working in this style.

              (Not necessarily relevant or correct thoughts:

              - Your language still seems to mark potentially-failed values in the type system, even if it writes them T! not Either Error T or Result<Error, T>;

              - The way Haskell’s do-notation [apparently implemented as a macro package in Rust] is centred around name binding seems very close to what you’re doing, although it [being monadic, not applicative] insists on sequencing everything, so fails the whole block immediately once an error value occurs;

              - Of course, transparently morphing a T-or-error into a T after a check for an error either needs to be built into the language or requires a much stronger type system; Haskell circumvents this by saying that x <- ... either gives you a genuine T or returns failure immediately, which is indeed not quite what you’re doing.)

              • lerno 4 years ago

                What I mean by saying "it's a binding" is that it is a property of the variable (or return channel of a function) rather than a real sum type. Consequently it does not participate in any type conversions and you cannot pass something "of type int!" because the type does not exist.

                Here is an example:

                    int! x = ...
                    int*! y = &x;
                    int**! z = &y;
                
                    // If it had been a type then
                    // int!* y = &x;
                    // int!** z = &y;
                
                    // int*! y = &x;
                    // means 
                    // int*! y = "if x is err" ? "error of x" 
                    //                         : "the address holding the int of x"
                
                This also means that `int!` cannot ever be a parameter, nor a type of a member inside of a struct or union.

                The underlying implementation is basically that for a variable `int! x` what is actually stored is:

                    // int! x;
                    int x$real;
                    ErrCode x$err;
                
                    // int*! y;
                    int* y$real;
                    ErrCode y$err;
                
                    // y = &x;
                    if (x$err) {
                      y$err = x$err;
                    } else {
                      y$real = &x$real;
                    }
                
                    int z;
                    // y = &z;
                    y$err = 0;
                    y$real = &x;
                
                
                The semantics resulting from this is different from if `int!` had been something like

                    struct IntErr { 
                      bool is_err_tag;
                      union {
                        int value;
                        ErrCode error;
                      };
                    };
                
                Which is what a Result based solution would work like. In such a solution:

                    int! x ... ;
                    int!* y = &x; // Ok
                    int z = ...
                    y = &z; // <- Type error!
      • c-cube 4 years ago

        You should give a serious try to sum types, btw. They're unambiguously good, and have been in use for the last 40 years at least. To me, not having them is an immediate disqualifier for a modern static language (along with some basic form of pattern matching that goes hand in hand with them).

      • winstonewert 4 years ago

        So that sounds like the way invalid floating point operations give NaN, and then the NaN propagates everywhere. I've always found this super annoying because its often hard to figure out where the NaN comes from. Does your solution differ from this in a way that's less annoying?

        • WalterBright 4 years ago

          The FPU does not include the source in the NaN, but that doesn't mean your own objects can't.

          What I do is have the error reported at the source, and then return the poisoned object. A better way would possibly be put the error message in the poisoned object, and report the error somewhere up the call stack.

          • Gibbon1 4 years ago

            I do that a lot with the firmware I write in C.

              typedef struct
              {
                err_t error;
                int error_line;
                char *error_msg;
                ...
                ...
              } thing_t;
            
              // set out of range error
              thing->error = THING_ERROR_OOR;
              thing->error_line = __LINE__;
              thing->error_msg = "outofrange"
            
            You can grep on 'outofrange' and find where the error was set.

            I originally started doing that to mark 'bad' analog readings in process control equipment. I wrote my filters and control loops to be able to 'eat' occasional bad readings without barfing. Worked very well.

            • Const-me 4 years ago

              I often using something like this in C++

                  if( nullptr != pfnErrorSink )
                      pfnErrorSink( "outofrange", __FILE__, __LINE__ );
                  return E_BOUNDS; // Or sometimes throw E_BOUNDS;
              
              Where pfnErrorSink is either global, thread_local or a field keeping C function pointer provided by whoever consumes the code.
          • erik_seaberg 4 years ago

            What are the return values from a poisoned object’s methods? Does a poisoned vector have a poisoned integer as its size?

            • WalterBright 4 years ago

              > What are the return values from a poisoned object’s methods?

              That's up to you. You can do it as:

              1. return a poisoned value

              2. return a safe value, like `0` for its size

              3. treat it as a programming bug, and assert fail

              4. I know `null` is hated, but it is the ultimate poisoned value. Try to call a method on it, and you are rewarded with a seg fault, which can be considered an assert fail.

              5. design your poisoned object to be a real object, with working methods and all. It can be the same as the object's default initialized state.

              In other words, it's necessary to think about what the poisoned state means for your use case. I use all those methods as appropriate.

      • oconnor663 4 years ago

        How does this work with operations that return basic types like int or string? Can a string be poisoned?

    • mbrubeck 4 years ago

      Rust is one such language. I wrote a bit about exception safety in Rust here: https://users.rust-lang.org/t/c-pitfalls-hard-to-avoid-that-...

      In short, while the problem is mitigated somewhat compared to C++, it's still one of the most common causes of bugs in unsafe Rust code.

      Rust programs can choose to abort on all panics, rather than unwind. Firefox does this, for example.

      • volta83 4 years ago

        We do this on all our Rust code as well (1.1 million LOC at this point).

        While we can benefit from #[no_std] crates on crates.io, unfortunately we can't use any crates that require standard because the standard library does not propagate errors properly, so we maintain our own implementation of most of the standard library for Linux only, that propagates all errors using Result..

        It's a huge pain point, but at least Rust allows us to do it.

        • oconnor663 4 years ago

          > does not propagate errors properly

          Is "panicking on allocation failure" the only example of this, or are there others?

  • otabdeveloper4 4 years ago

    Forcing the programmer to manually write stack unwinding code is not a solution.

    That's like saying "garbage collecton is slow and complex, just use malloc() and free() instead".

    • jstimpfle 4 years ago

      Nobody said to manually write stack unwinding code or to only use malloc() and pair each of them with free().

      There are other very good solutions that involve explicit structure. For example

      - do not free things at all, just reserve a big chunk of address space and let the OS populate it as needed. When the process quits the OS frees everything automatically.

      - do the same thing for parts of the program but implement the "OS part" in the program itself. There are variations of this known by terms such as "memory arena" or "pools". Basically, just take care to group allocations by end of lifetime. Then you can free everything in one go without tracking each lifetime individually in a stack frame (which is insane).

  • blub 4 years ago

    Those look like problems for compiler implementers (tiny subset of users) or those writing code with very tight performance requirements (large amount of C++ code does not have such reqs). In spite of the reasons given, exceptions are successfully used (in C++ too) for error handling, because they can be much nicer that shuffling error codes/result types up the stack.

    Really, as an end-user the issue with exceptions in C++ is another:

    a) it's impossible to figure out what throws by looking at code.

    b) it's (nearly) impossible to ensure that something doesn't throw

    This means on one hand that one has to assume that any code can throw and manage resources appropriately, which is by now known and there are well-established idioms around it. On the other hand though it also means that the silliest error from a tiny library can bubble up into the event loop/main function and terminate an application.

    Swift's syntax for exceptions illustrates what I mean, even though Swift does not unwind the stack.

  • neeeeees 4 years ago

    > they are slow when not thrown

    I thought the not-thrown case was pretty much zero-cost, at least in newer versions of Clang... How much of a slow down are we talking here?

    • WalterBright 4 years ago

      The main reason is the optimizer abandons trying to figure out flow-of-control when half the expressions can throw and present a path to the catch blocks. Furthermore, this all inhibits en-registering variables, because exception unwinding doesn't restore registers.

      If you want your code to be fast, use 'nothrow' everywhere.

      I don't know about newer versions of Clang, but I recall Chandler Carruth mentioning that LLVM abandons much optimization across EH blocks as infeasible.

      • titzer 4 years ago

        Java JITs are considerably more advanced than this. It's expensive to put in all the additional control flow edges to model exceptions, but once done, control and data flow analyses just work, as well as loop optimizations, code motion, inlining, register allocation--all of it "just works" (TM). Then you have to spit out a metric crapton of metadata to allow searching for the correct handler at runtime, but that's the slow path.

        All of that is to say that Java try blocks do not have any direct dynamic cost when exceptions are not thrown.

        Even throwing exceptions and catching them locally in Java can be fast. If everything gets inlined into one compilation unit and the exception object is escape analyzed, HotSpot will absolutely just emit a jump.

        Not that I disagree with your overall point--Virgil just doesn't have exceptions--but from your description here it just sounds like your compiler is far behind the state of the art in terms of optimizing exception-heavy code.

        • WalterBright 4 years ago

          Java has a far, far more restricted view of exceptions than C++ and D have,[1] and hence more opportunities for optimization. I did implement exceptions in the Javascript compiler I implemented 20 years ago, and they're a cakewalk compared to C++. I also implemented a native Java compiler, including EH.

          As for clang, see what Chandler said. But maybe things have changed in the last couple years.

          [1] for example, Java doesn't have objects on the stack that need their destructors run. That's a massive simplification.

          • titzer 4 years ago

            I don't know the internals of clang very well, but everything I have heard second hand (and third hand) makes me think that its approach to modeling exceptions isn't very good.

        • WalterBright 4 years ago

          Regarding putting in all the additional control flow edges: more edges mean fewer optimizations. That's not zero cost.

          • titzer 4 years ago

            I actually agree that they technically aren't zero cost (notice I didn't even write that), but the cost is indirect. I've worked on a number of Java JITs and in practice, not a lot of hot code has catch blocks, and even when so, inlining is typically so deep that lots of exception edges (e.g. arising because a possible NPE) get optimized away.

            Most of the lost optimization opportunities are second-order costs, not first-order costs. Java JITs make up for the extra flow edges by focusing more on global optimizations rather than local (e.g. GVN vs LVN, global code motion, global flow-sensitive load/store elimination), etc. Generally a possible exception edge splitting a basic block doesn't hurt because the non-exceptional control flow will still benefit from flow-sensitive optimizations (i.e. it has only one predecessor anyway).

            We're splitting hairs anyway. Like I said, Java JITs are significantly more advanced at optimizing exception-heavy code. I'd be really surprised if you saw anything more than a 1% increase in performance, actually, no, scratch that. I doubt you can even reliably any speedup distinguishable from noise from just disabling all support for exceptions in most Java code, unless you are talking about metadata. Top-tier JITs really are that good.

            • WalterBright 4 years ago

              Enregistering of variables is also lost, because to restore them during unwinding they have to be retrieved from the stack.

              • titzer 4 years ago

                Not sure what you mean here, but generally Java JITs generally don't use callee-saved registers at all because they need precise stack maps for GC. So whatever small amounts performance they might lose here isn't due to exceptions.

                • pron 4 years ago

                  OpenJDK's (HotSpot) stack maps do support callee-saved registers -- and they are used in some special cases, like safepoint stubs (that spill all registers at a poll-point if a safepoint is triggered) -- but you're correct that they've been removed from ordinary Java calls altogether on all platforms, now.

                • WalterBright 4 years ago

                  > Not sure what you mean here

                  Allocating local variables into registers rather than assigning stack locations for them. Registers are faster than memory. EH unwinders restore the stack before jumping to the catch block, but not the register contents.

                  Stack maps wouldn't be necessary for non-pointers, like an integer variable. Stack maps also have their own performance problems, which is why D doesn't use them.

                  • pron 4 years ago

                    Inside a single physical frame, that contains many inlined Java methods (the current default is inlining up to depth of 15, not counting "trivial" methods that are always inlined), locals are always in registers (unless they're exhausted), and are spilled only at safepoints, e.g. when another physical call is made, which is where all languages have to spill, too. Stack maps include only pointers and incur no runtime overhead on the fast-path, and are not used when throwing exceptions, even outside the current physical frame. There's additional debug info metadata associated with compiled code, which also incurs no runtime overhead on fast paths, that maps even primitives to their registers/spill locations; that debug info also maps compiled code locations to their logical, VM bytecode, source ("de-inlining"), and is consulted in the creation of the exception when a stack trace is requested.

                  • titzer 4 years ago

                    The term used most often for this is "spilling". I figured this is what you meant by "deregistering" but I wasn't sure, so I didn't want to assume.

                    > Registers are faster than memory. EH unwinders restore the stack before jumping to the catch block, but not the register contents.

                    I get that, which is why Java JITs don't use callee-saved registers. I mean, they use all the physical registers, of course, but their calling convention does not have callee-saved registers.

                    • WalterBright 4 years ago

                      "spilling" usually means a variable is sometimes in a register, sometimes on the stack. "Enregistering" means it is full time in a register.

                      • titzer 4 years ago

                        But what's a variable, really? After SSA renaming, optimization, SSA deconstruction, then liveness analysis, coalescing, and finally live-range splitting, variables are history and the register allocator is only dealing with live ranges, typically.

      • erincandescent 4 years ago

        > Furthermore, this all inhibits en-registering variables, because exception unwinding doesn't restore registers.

        I'm sorry, but this is just not true of at least the Itanium exception handling system used by Linux, macOS, etc on most architectures.

        It does make the exception handling data quite large and throwing extra slow, of course.

  • WalterBright 4 years ago

    At least with D a thrown exception must be a subtype of `Throwable`. I.e. you can't throw an `int`, oh, and the incredibly confusing throwing of an object that itself can throw.

  • minipci1321 4 years ago

    > 10. has anyone yet found a legitimate use for throwing an `int`?

    I use that a lot in constexpr computations -- to stop the compilation, I usually do 'throw __LINE__'.

    -- Using a more complex type is not warranted -- there is no catching end in constexpr.

    -- And in case the same routine ends up called non-constexpr, it will be easy to identify the place that called 'throw' -- line numbers are unique without additional effort. Just don't put two throws on the same line.

  • omegalulw 4 years ago

    +1 plain old return error codes and the related modern status codes are the way to go. Lots of people say this is more work. I would your comment does a good job explaining why that work is immensely useful.

    • josefx 4 years ago

      > plain old return error codes and the related modern status codes are the way to go

      Why yes I just love to get a "Error one of the billion files this application tried to load wasn't available ErrorCode: ERR_MISSING_FILE_FUCK_WHO_KNOWS_WHICH". What I like about exceptions is that they make information that can't be encoded in a 32 bit integer value available to top level error handlers.

  • stinos 4 years ago

    I have quit using exceptions in my own code, making everything 'nothrow'.

    Assuming not all code you use is your own, how does this work in combination with other code (like the STL) which is not nothrow?

    • WalterBright 4 years ago

      Generic code (like you'd find in a library) is usually done with templates. Templates in D infer `nothrow`, giving them the advantage of being implicitly `nothrow` when their arguments are also nothrow. Inferring attributes this way is a major way D works.

      • stinos 4 years ago

        Ah, sorry, I was thinking you were talking about not using exceptions in C++ anymore.

  • renox 4 years ago

    That's funny: I thought you were a proponent of exceptions because I read (a lot of years ago) a mail from you which said "who is going to check that printf failed"?

    And this remain true: a lot of things can fail (arithmetic operations, every IO, etc) so the error system must be very "lean" otherwise the "happy path" is drowned in the error propagatio/handling code..

  • freesoftware 4 years ago

    I'd suggest to use Lisp, which is a under-rated yet powerful language.

    [Why?] https://gigamonkeys.com/book/introduction-why-lisp.html

    [Exceptions] https://gigamonkeys.com/book/beyond-exception-handling-condi...

  • cjfd 4 years ago

    "has anyone yet found a legitimate use for throwing an `int`?"

    I could image throwing an int when writing a shell utility and throwing the return value of main as an int but I suppose doing that usefully would be pretty rare. Usually one cares more about whether a shell utility is successful or not not so much about the precise reason it failed. So, while I could imagine doing that, I don't see myself going for that option too likely.

    • WalterBright 4 years ago

      Yes, but you couldn't mix that code with code that throws an `int` for other porpoises. It becomes a global straightjacket for your code.

      • cjfd 4 years ago

        Yes that is true. One really should not start throwing integers in code that one hopes to reuse someplace else.

  • Const-me 4 years ago

    > 10. has anyone yet found a legitimate use for throwing an `int`?

    When an integer is the only value I need in the catch handler, I sometimes throw them, but only negative integers.

    https://docs.microsoft.com/en-us/openspecs/windows_protocols...

  • saagarjha 4 years ago

    > has anyone yet found a legitimate use for throwing an `int`?

    Not sure if you consider this legitimate, but I have seen code that throws an errno.

  • daenz 4 years ago

    Can you describe your ideal error handling mechanisms? Or at least other mechanisms that feel more correct?

    • WalterBright 4 years ago

      One technique I try first is to write code that cannot fail. For example, a sort function should never fail.

      Consider the case of running out of memory. One option is to pre-allocate all the memory the algorithm will need, then it can't run out of memory. Another option is to regard out-of-memory as a fatal error, not one that needs to be thrown and caught.

      Another example is UTF-8 processing. Early on, I did the obvious when invalid UTF-8 sequences were discovered - throw an exception. But this got in the way of high speed string processing (exceptions, even in the happy path, are slow). But what does one do anyway with such input? abort the display of the text? Nope. The bad sequence gets replaced with the Unicode "replacement character". This turns out to be common practice, and now my UTF-8 processing code cannot fail! And it's smaller and faster, too.

      It's a fun challenge to figure out how to organize the program so it can't fail.

      • tialaramex 4 years ago

        > Another option is to regard out-of-memory as a fatal error, not one that needs to be thrown and caught.

        This is in practice almost invariably the case for large programs. Somebody (Herb Sutter maybe?) asked the major C++ Standard library implementers, and none of them really bothers to handle the tricky parts of this. If you write code to try to pre-allocate a 10TB vector of 'Z's you can probably get that to throw you the exception that you read about in the documentation, but if the library code for opening a file can't find 64 bytes for a temporary object they aren't going to bubble up an exception, they're going to crash your program and too bad.

        If you write an operating system kernel, you care about running out of memory, if you write the embedded firmware for a jet engine, you care (actually you likely never allocate memory at runtime, so in that sense you don't care), but in both those cases you live in a world where many other problems are far above you out of sight, so you can afford to care about stuff like how much RAM there actually is. You don't want the C++ standard library down where you live, and they don't want your problems. Everybody who lives up above the C++ standard library doesn't care, which is why the people implementing the library don't care either.

        Yes, all of Unicode processing should use U+FFFD (the replacement character). Not just UTF-8, if you have any reason to do anything Unicode related and you're in a state where other paths forward are nonsense, emit U+FFFD. Take XML. Because the people involved hated ASCII control codes XML says you can't express them in XML 1.0 (which you will in practice have to use). I don't mean they need to be escaped, I mean you intentionally cannot express them. So if you have some arbitrary ASCII text that might include control codes, you can't write that as valid XML. What to do? Emit U+FFFD whenever this problem arises. Your users go "Huh, my Vertical Tab turned into this weird character in the XML output" and you send them to talk to the XML committee which will tell them they're a sinner and must repent of the evil of Vertical Tab and now your user knows you aren't crazy and maybe they stop using XML or maybe they don't but either way your code works.

      • brundolf 4 years ago

        IMO this is one of the advantages of the sum-type approach: the added friction of dealing with those explicit types and values encourages you to write as much code as possible that simply can't error in the first place

    • WalterBright 4 years ago

      I'm not going to recommend any technique I don't personally have years of experience with. Too many times a paper that makes something look great tends to have fatal flaws that only emerge years later. Sort of like WW1 strategies that sounded good but in practice produced only mud and dead bodies.

      As I mentioned in another comment, I've had good success in the trenches with the poisoning technique.

    • gHosts 4 years ago

      I would go with the Erlang approach. Just die FFS. Let the process monitor restart you if you deserve to live.

      • TeMPOraL 4 years ago

        That only makes sense if units of your code run in a loop and communicate asynchronously :). But, if you want a simple supervisor pattern in C++, then... try/catch block is your supervisor, exceptions are how your process dies. Consider:

          template<typename Fn>
          auto CallWithSupervision(Fn fn) -> decltype(fn()) {
            // supervision loop
            // configure conditions as needed
            while(true) {
              try {
                return fn();
              }
              catch(std::exception& e) {
                // log failure details
              }
              catch(...) {
                // optional: exceptions out of handled set?
                // kill supervisor.
                throw;
              }
            }
          }
        
          //elsewhere
          CallWithSupervision([relevant=state,&nd=captures]() { return Client(relevant, nd); });
        
        Modify as you see fit. It's the simplest, synchronous Erlang supervisor, in C++. And it will already work with your code - exception handling is very composable this way.
        • gHosts 4 years ago

          Except it isn't robust.

          Exceptions occur when "Very Bad No Good Undefined Horrible Things" have (at last, been found to have) happened.

          So if you have no guarantee that the memory space is uncorrupted, you have no guarantee that all resources have been recovered, you have no guarantee that in attempting to deallocated resources that they have been allocated in the first place. (Read the fine fine print on exceptions in constructors and destructors.) TL;DR; Don't do that. But exceptions are what happen when people do what they shouldn't.

  • jokoon 4 years ago

    Who invented exceptions in the first place? Did they first appear in Java, encouraging C++ to imitate a java feature?

nicolasbrailo 4 years ago

Author here; worth noting this article was written a decade ago, and while the concepts it describes are probably still useful (or so I'm told) the text is starting to show its age. Most notably, the examples are completely broken for x86-64, as I didn't have a 64bit processor when writing this.

cecilpl2 4 years ago

> When the personality function doesn't know what to do it will invoke the default exception handler, meaning that in most cases throwing from a nothrow method will end up calling std::terminate.

This is an interesting tidbit that cost me a week of debugging recently - a try/catch block at the top of the call stack wasn't catching an exception.

We set up an exception handler that calls main in a try/catch block, so that any thrown exceptions can be caught, processed, and dispatched to our crash-logging system.

But destructors are marked nothrow by default. So we had a case where an object was destroyed, and about 10 levels down from its destructor some other system threw an exception, intending it to be caught by the top-level catch block.

But during stack unwinding we passed through the nothrow destructor and std::terminate got called before unwinding got to the top-level try/catch.

  • jcelerier 4 years ago

    How can that take a week to debug ? gdb would stop at the std::terminate call in your dtor, and catch throw would allow you to see exactly where the exception was thrown

    • cecilpl2 4 years ago

      Well first of all I wasn't using gdb since it's not available on the platform this code was running on.

      Second, the std::terminate call doesn't get called from the dtor, it gets called from the stdc runtime in the call frame of the throw (with OS code in between). The stack isn't actually unwound at this point, it's more like the stdc runtime is walking up the call stack looking for a landing pad at each frame.

      Third, I didn't know about how this all worked, so I was trying to piece it all together for the first time.

      Yes, I saw the throw happen. But the symptom was then that the program just... terminated.

      • jcelerier 4 years ago

        > Second, the std::terminate call doesn't get called from the dtor, it gets called from the stdc runtime in the call frame of the throw (with OS code in between). The stack isn't actually unwound at this point, it's more like the stdc runtime is walking up the call stack looking for a landing pad at each frame.

        what I mean is that, if you run that in a debugger, you're going to see something akin to this when the crash occurs (and have your ide stop exactly where the offending exception was thrown ; I don't even have to set `catch throw` for this to work): https://ibb.co/hK3skvz - at least on windows, mac, linux. What platform are you running that does not support gdb at all ? pretty much anything that isn't a PIC16F or Z80 supports it..

adzm 4 years ago

Implementation in Windows is quite different. Especially x86 vs x86-64 which has much less overhead. Also gets quite complicated with the Windows built-in Structured Exceptions/ asynchronous exceptions which can be translated into c++ exceptions -- and even more complexity due to different compiler options that handle these!

AshamedCaptain 4 years ago

[2013]ish if I remember.

Also note the ABI for C++ exceptions followed by G++ et al is actually documented as part of the Itanium C++ ABI :

https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html

  • gpderetta 4 years ago

    The doc is actually incomplete, it refers to implementation defined ABI entry points for the actual unwinding. The actual unwind tables and compensation opcodes are, IIRC, part of the DWARF standard, but last time I looked at it that standard was also incomplete and left a lot unspecified. Many of the details (including bugs that have now become part of the ABI) are basically folklore.

    IIRC Ian LAnce Taylor had a series of blog posts that did shed light on a lot of these details.

    • jcranmer 4 years ago

      There's essentially three separate moving pieces here.

      At the bottom layer you have the unwind API. This is actually generally defined by the platform-specific ABI, although the definition here on most Unixes is "we have a .eh_frame section that's basically the same as .debug_frame in DWARF." The API is provided by some platform library (libunwind), and it's language-agnostic.

      Part of the generic unwind structure is that each stack frame has a personality function and a Language-Specific Data Area, and the unwind semantics will call the personality function to figure out whether to drop off into the stack frame, and where in the function to drop off, or continue unwinding the stack. The personality function itself uses the LSDA to figure out what to do. The personality function lives in the language standard library (e.g., libstdc++), and the LSDA format is of course entirely undocumented (i.e., read Ian Lance Taylor's blog posts as the best documentation that exists).

      The final level of the ABI is how you actually represent the exceptions themselves. This is provided by the Itanium ABI and describes the names of the functions needed to effect exception handling (e.g., __cxa_begin_catch), the structure layouts involved, and even some nominal data on how to handle foreign exceptions which don't really exist.

      And that's not entirely true for all systems. ARM notably uses a different ABI exception handling, which is rather close to the standard Itanium exception handling except the details are different. Some operating systems choose to forgo unwinding-based exceptions in lieu of setjmp/longjmp as the standard ABI, which of course requires different versions of everything. And Windows has an entirely different form of exception handling which isn't centered around unwinding but actually calling exception handlers as new functions on the stack, requiring frame links to the original-would-be-unwound-to function.

  • nicolasbrailo 4 years ago

    According to GitHub, published in February 2013, written during 2012. I feel old now.

zabzonk 4 years ago

Under the hood of GCC specifically.

  • arcatekOP 4 years ago

    That's the article I used when I implemented exceptions in an LLVM-based compiler, so it's applicable to more than just GCC.

  • cogman10 4 years ago

    Doesn't GCC support multiple exception handling options?

    • mhh__ 4 years ago

      I'm not sure exactly what you mean but there was a switch to DWARF EH ages ago (GCC 2?)

    • zabzonk 4 years ago

      I'm not sure what your point is - mine was that the original post is specifically about GCC, not Standard C++.

      • gumby 4 years ago

        “Standard C++” just specifies `throw` and `catch` (plus what happens when you traverse a block, nothrow declaration etc)

        Every implementation has to do something to actually be standard C, and this is an example. It’s rather similar on other hardware, but as far as the standard goes that is irrelevant.

      • cogman10 4 years ago

        Mainly that exception handling doesn't have a single solution, even in GCC. I'm agreeing with you that this article only highlights one version of exception handling for one compiler.

        There's also potential operating system involvement that's not really covered in this article.

pjmlp 4 years ago

Very interesting read, however it is under the hood on a specific implementation.

monkeycantype 4 years ago

Just dropping by to say hi to a fellow c++ing monkey

Keyboard Shortcuts

j
Next item
k
Previous item
o / Enter
Open selected item
?
Show this help
Esc
Close modal / clear selection