7 ms·
The Downsides of C++ Coroutines
- cshokie 3y agoThis article hits on a number of interesting points. There is a lot of complexity to be aware of when using C++ coroutines. And a number of “normal” practices become dangerous in them, such as pass by reference. That said, I think they are still very much worth it. Older asynchronous programming libraries in C++ are so verbose and so much worse than coroutines that it’s an obvious choice to use coroutines. Also, there is another hazard that the author does not mention in this article: RAII lock wrappers. Holding a lock across suspension points is super dangerous. At best it wastes performance to leave it locked when blocked. At worst it can create deadlocks or corrupt the lock if it is released on a different thread than it was acquired.
- commonlisp94 3y ago> it’s an obvious choice to use coroutines. I agree, but the other choice is to have traditional threads of execution that block. This simple strategy has delivered more successful projects than any other.
- mgaunard 3y agoI don't understand how it is any more verbose.
- meindnoch 3y ago>RAII lock wrappers You don't even need coroutines for this to be dangerous. Holding locks over callback invocations is a pet peeve of mine in PR reviews. Callback invocations, like suspension points, can inject arbitrary operations into our code, which can easily break prior invariants, yet look innocuous for the casual reader.
- surajrmal 3y agoI often just add a task to a runtime queue which gets called once the stack fully unwinds to avoid these sorts of issues. You may not be aware of what locks were acquired prior to your current function being called. Reentrant safe code is considerably more challenging. This might have some overhead as callback parameters have to be placed in the heap, but it's usually worthwhile.
- rockwotj 3y agoAs someone who writes in C++ and uses coroutines everyday for work, I find for our use case this is actually helpful. We use seastar.io a thread per core framework and locks are "async" friendly in that they yield for access instead of blocking. Also embracing fully async message passing between threads simplifies the programming model a ton.
- mkoubaa 3y agoExperience teaches me that the worst time to use a new design pattern or technique is _right after you learn about it_. The problem in your code base you thought about while learning the pattern was a useful proxy for where it could be applied, but that doesn't mean it's the right fit. Do it in a scratch refactoring, and wait a week or two before you consider merging it. And make sure you are emotionally as ready to discard as you are to land it.
- tomcam 3y agoAll true, but another trick is to do extensive web searches to see what kind of problems people have had with the new approach.
- TillE 3y agoI agree that you should always be ready and happy to discard or refactor code as needed. Requirements change, your assumptions may be wrong. But in practice I've more often seen the opposite problem, where organizations end up stuck on C++11 for a decade for no technical reason. It's good to explore the new stuff and eventually adopt what you can use.
- bluGill 3y agoBetter than c++98 which a lot of organizations are still on.
- jupp0r 3y agoThere is nothing inherently asynchronous about coroutines. You can use them to model concurrency or even parallelism, but that's only a subset of their use cases.
- Calavar 3y agoWhat are some of the other use cases?
- spacechild1 3y agoGenerators come to my mind.
- Jtsummers 3y agoMany coroutine uses are not asynchronous, but synchronous, they block when resumed and do not execute in parallel. This permits cooperative multitasking, versus preemptive (or preemptive with a bunch of locks to imitate cooperative which is, of course, a waste). Since they can, in principle, execute within the same thread (with C++'s implementation and some others you the programmer can send them off to other threads for execution, but that's an explicit choice) this can simplify concurrent system design and execution (in the concurrency is not parallelism sense). In the single threaded case, it's also faster than multithreaded asynchronous code since the context switching (modulo cache misses) is greatly reduced. Especially useful in the case where you want synchrony and not asynchrony. They're also very useful if you've ever had to create a bare metal multitasking system. Much easier for state management than older style "while (true)" loops with a million state variables so functions can resume via a switch/case as pseudo-coroutines. (Well, easier if you don't have to implement the coroutine mechanism yourself.)
- yakubin 3y ago> Just like a normal function arguments are passed using registers and the stack, coroutines are using the same ABI as previously specified, however the code different is vastly different. > Finally at the end of the function the stack space initially reserved get’s reset to where it was initially when the function first call happens then returns to the caller. This post could use some editing. I'm having to reread each paragraph several times to figure out its intended meaning. Most sentences are separate paragraphs with careless mistakes that make me feel the author was being chased by someone when writing them and couldn't take a breath.
- i-use-nixos-btw 3y agoIt is due a bit of proof reading. There are some readability issues. That being said, it is a great article. C++ and coroutines is a story that has been going on for a long time, and the result surprised me. In a bad way. One bit me right from the start. I copied out an example and it crashed, and it turned out (after hours of searching, reading - the compiler and sanitisers sure weren’t any help) that the problem was that I’d inadvertently made a parameter const& (force of habit) and bound a temporary to it. My answer to this is simply that I choose not to use coroutines. If I can’t force a compilation failure when I do something dumb, that spooks me. For a feature released in 2020 it has far too many footguns. Ranges was similar when it came to lifetime footguns. It’s just something that makes it hard to take seriously the claims that it is legacy code that is the reason C++ has a bad rap for safety. Coroutines and ranges are modern features that can shoot your foot off if you don’t know the implementation, which is kind of contrary to the point of making a friendly wrapper over it all.
- fasterik 3y agoThe compiler won't stop you storing a pointer to a local variable and dereferencing it later, either. That's just the nature of programming without managed memory. Calling a coroutine is essentially the same as temporarily "returning" from the current stack frame, so all of the usual practices around taking pointers apply. I agree with your conclusion of not using C++ coroutines, though. It seems like the design falls somewhere in the "worst of both worlds". I would rather either use a library that implements coroutines with the minimal amount of C and inline assembly if performance is critical, or some higher level abstraction that works well with all other language features.
- 7e 3y agoIt’s in fashion to dislike fibers, but they’re a simpler solution that, IMHO, beats coroutines for the wide majority of cases. Even threads are a better solution for most cases. Coroutines are like the checked exceptions of C++.
- mgaunard 3y agoThe best solution remains writing asynchronous code in a way that is explicitly asynchronous. Who would have thought?
- sshaw 3y ago"C++" and "Coroutines". Who would have thought. But, considering the accelerated releases post C++ 11, I guess I'm not surprised.
- pjmlp 3y agoAnyone that has read "Design and Evolution of C++".
- shadowgovt 3y agoBesides that they are yet another feature in the bloated hash mess that is c++?
- germandiago 3y agoI do not understand yet (open to explanations!) what is the difference between stackless and stackful coroutines in the fact that stackless should be cheap and even "collapsable" when nested in lifetimes but if it is not the case... stackful is cheaper. Are not stackless supposed to be more performant? In which cases? Yes I know their virality, potential heap allocations, etc.
- comex 3y agoTwo differences: First, stackful coroutines use the coroutine stack for everything they do. Stackless coroutines can use the normal thread stack for synchronous calls, and that stack can be shared across any number of coroutines. Per-coroutine allocation is only needed for asynchronous calls. Second, for stackful coroutines you need to allocate the entire stack up front, and usually you have no way of knowing how much stack might be needed, so you need a conservative upper bound. Normal thread stacks have sizes in megabytes. (That doesn't necessarily correspond to actual memory consumption, since the OS will only reserve physical memory as needed, but the physical reservation for a given stack can only grow, not shrink. And even just allocating the virtual space has a cost.) Most of the time you can get away with stacks that are much smaller, only a few kilobytes, but at the cost of potentially crashing when you've consumed too much stack; it's hard to statically analyze maximum stack usage. Stackless coroutines will, in general, only allocate memory as needed for each coroutine invocation, so not only are you wasting less memory, you don't have to worry about hitting an arbitrary limit. Allocation elision makes things more complicated since, as the blog post notes, you can end up wasting some memory, but compared to stackful coroutines it's peanuts. But they have the downside that heap allocations and deallocations are expensive; plus, splitting a "stack" of nested calls into separate heap allocations, usually far away from each other in memory, is worse for cache locality.
- gpderetta 3y agoTechnically you could manually grow coroutine stacks the same way the kernel does for thread stacks, by mapping on fault and periodically unmapping everything beyond the red zone. But the complexity would be significant and hard to make it efficient without kernel support.
- ninepoints 3y agoEh, I use them and am quite productive with them. Some of the downsides I don't really buy, for example, the argument regarding allocations. In a typical task engine, you're allocating state per task anyways. Sure you could have custom arenas and such, but you can do that with C++ coroutines also by overriding operator new/delete on the promise object. The lifetime concerns are par for the course when it comes to async stuff (assuming that's how you're using C++ coroutines).
- Conscat 3y agoGenerators, which already exist in the stdlib, is an example where we can see heap elision being useful, but is currently unreliable in C++. There is a paper "Explicit Coroutine Allocation" that will likely solve this in C++26. The Clang IR project will also improve HALO for the future of (Clang) C++ projects.
- alphanullmeric 3y agoStill simpler than rust.
- JonChesterfield 3y agoCoroutines have a performance cost associated with allocating a stack. C++ solved this by shipping something that are not coroutines and branding it coroutines anyway. Sometimes it still needs heap allocation, but of a constant size and smaller thing. A coroutine is a thread of execution that can yield to another. The scheduler is thus under userspace control. The C++ thing is syntax sugar over a control flow transform which looks kind of similar, except you have to annotate all the functions in the call tree and can't do anything that can't be desugared to the same runtime system that was there anyway. The primary downside of C++ coroutines is then clear. They aren't coroutines, and by squatting on the name, make it borderline impossible that C++ will ever have coroutines. This is annoying as they're one of the things which really needs compiler support to do well.
- gdwatson 3y agoC++ solved this by shipping something that are not coroutines and branding it coroutines anyway. … They aren't coroutines, and by squatting on the name, make it borderline impossible that C++ will ever have coroutines. I am not a C++ person, but I protest this characterization. You obviously know the categories and I suppose you know the history, but I will recount them so that everyone else understands my objection. O.G. coroutines emerged in a world where subroutines were, by and large, not reëntrant. Function parameters, local variables, and return addresses were static, and there was no call stack in the modern sense. This is why the old timers said that coroutines were a generalization of subroutines; only the jump instruction of the call/return really needed to change. Once call stacks became common, coroutines fit awkwardly, and people tried to adapt them in various ways.¹ The world has settled on two designs for reconciling coroutines to the call stack: thick and thin coroutines. Thin coroutines allow suspension within the body of the coroutine but not within subroutine calls; this way the size of the coroutine’s state can be known at compile time and its interaction with the stack is relatively clear. Thick coroutines (i.e., green threads) can be suspended within a subroutine call, and thus require their own slices of stack — either separate from the main stack or copied from and to it as the coroutine is suspended and resumed. Thin coroutines are absolutely coroutines. They are truer to the original definition than thick coroutines are! They are more limited than thick coroutines, true; whether that makes them better or worse is a matter of design trade-offs. But they certainly deserve the name. [1] Simula 67, to my understanding, treated objects as a kind of coroutine instance where function definitions in the coroutine body became methods that closed over its local variables.
- marsven_422 3y ago[dead]
- captainmuon 3y agoA lot of these seem to be downsides of manual memory managment in C++, not stackless coroutines. The same kind of coroutines work fine in Python, Javascript and C#. Some parts like the one about lazy coroutines seem to only be an issue because coroutines in C++ can theoretically resume on any thread. If they were restricted to the current thread by default (like in Python+Twisted I think?) then you would still be able to use them for many use cases but with less cognitive overhead. The author seems to prefer stackful coroutines aka green threads, which are essentially just user mode threads. Handoff is implicit deep inside 'blocking' functions. I rarely see the downsides of them discussed, but they have their own problems: They are still threads so you often need locks. You don't know where a function will hand off, and you could accidentially call a really blocking function or run a long computation and ruin responsiveness. And the type of the function no longer reflects if it is blocking or not (the famous colored functions).
- pjmlp 3y agoThey don't work just fine in C#, there is a reason why one of ASP.NET architects has written a guide of best practices. https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/b... Ironically, C++'s design is heavily related to C#, as the initial proposal was done by Microsoft and shares many of the same ideas, including how to create runtime aware awaitable types.
- captainmuon 3y agoI've never used ASP.NET, I mean they work fine in GUI apps where you click a button, start downloading a file, and then show a message when the file has been downloaded. Making code nonblocking without threads and without callbacks = happy case for async. Writing multithreaded servers focussed on throughput is a whole other can of worms, which is basically my point.
- flohofwoe 3y agoIsn't this wrong (the "green threads are just threads" part)? The green thread / stack switching implementations I've seen so far all used cooperative multitasking, eg you know exactly where control is handed back to the scheduler and don't need synchronization between green threads - assuming the scheduler keeps all green threads on the same OS thread of course) Blocking vs non-blocking can be solved with naming conventions, like Sync vs Async suffix (works well in node.js for instance) (also getting rid of colored functions is a good thing!)
- hannes_s 3y agoOne problem I have with C++ coroutines is the implicit capture of the "this" pointer in member functions. One can also accidentally pass arguments by reference resulting in lifetime issues, but at least the parameter types are explicitly stated. The post mentions that it might be the caller's or the callee's responsibility to keep the object alive until the end of the coroutine. This is purely based on conventions however, and different libraries might have different conventions. If the caller is responsible for it, extra care needs to be taken whenever the function is called -- the code shown in the post seems fairly complex to me and easy to get wrong. Also, I am not sure how the callee could safely implement keeping the object alive if lazy coroutines are used: Even if the first statement in the coroutine is retaining a strong reference on the object, there might still be a time between the call and the initial resume of the coroutine where the object is destroyed. I think it would have been great to provide explicit capture lists for coroutines, similar to lambdas. All of this gets especially confusing once you try to use lambdas together with coroutines. AFAIK, C++ lambdas are basically just structs overloading operator(). In a coroutine, only the "this" pointer of the structure is captured and the caller needs to ensure that the object is not only alive, but also at the same memory address until the end of the coroutine. This is very easy to get wrong in my experience.