| ▲ | Looking at Unity made me understand the point of C++ coroutines(mropert.github.io) |
| 80 points by ingve 4 days ago | 68 comments |
| |
|
| ▲ | nananana9 3 hours ago | parent | next [-] |
| You can roll stackful coroutines in C++ (or C) with 50-ish lines of Assembly. It's a matter of saving a few registers and switching the stack pointer, minicoro [1] is a pretty good C library that does it. I like this model a lot more than C++20 coroutines: 1. C++20 coros are stackless, in the general case every async "function call" heap allocates. 2. If you do your own stackful coroutines, every function can suspend/resume, you don't have to deal with colored functions. 3. (opinion) C++20 coros are very tasteless and "C++-design-commitee pilled". They're very hard to understand, implement, require the STL, they're very heavy in debug builds and you'll end up with template hell to do something as simple as Promise.all [1] https://github.com/edubart/minicoro |
| |
| ▲ | pjc50 3 hours ago | parent | next [-] | | > You can roll stackful coroutines in C++ (or C) with 50-ish lines of Assembly I'm not normally keen to "well actually" people with the C standard, but .. if you're writing in assembly, you're not writing in C. And the obvious consequence is that it stops being portable. Minicoro only supports three architectures. Granted, those are the three most popular ones, but other architectures exist. (just double checked and it doesn't do Windows/ARM, for example. Not that I'm expecting Microsoft to ship full conformance for C++23 any time soon, but they have at least some of it) | | |
| ▲ | fluoridation an hour ago | parent | next [-] | | I think what they meant is that that what it takes to add coroutines support to a C/++ program. Adding it to, say, Java or C# is much more involved. | |
| ▲ | manwe150 an hour ago | parent | prev [-] | | Boost has stackful coroutines. They also used to be in posix (makecontext). |
| |
| ▲ | Sharlin an hour ago | parent | prev | next [-] | | C++ destructors and exception safety will likely wreak havoc with any "simple" assembly/longjmp-based solution, unless severely constraining what types you can use within the coroutines. | | |
| ▲ | fluoridation an hour ago | parent [-] | | Not really. I've done it years ago. The one restriction for code inside the coroutine is that it mustn't catch (...). You solve destruction by distinguishing whether a couroutine is paused in the middle of execution or if it finished running. When the coroutine is about to be destructed you run it one last time and throw a special exception, triggering destruction of all RAII objects, which you catch at the coroutine entry point. Passing uncaught exceptions from the coroutine up to the caller is also pretty easy, because it's all synchronous. You just need to wrap it so it can safely travel across the gap. You can restrict the exception types however you want. I chose to support only subclasses of std::exception and handle anything else as an unknown exception. | | |
| ▲ | pjc50 31 minutes ago | parent | next [-] | | > Passing uncaught exceptions from the coroutine up to the caller is also pretty easy, because it's all synchronous. You just need to wrap it so it can safely travel across the gap This is also how dotnet handles it, and you can choose whether to rethrow at the caller site, inspect the exception manually, or run a continuation on exception. | |
| ▲ | Sharlin an hour ago | parent | prev [-] | | Thanks, that's interesting. |
|
| |
| ▲ | Joker_vD 3 hours ago | parent | prev [-] | | Hmm. I'm fairly certain that most of that assembly code for saving/restoring registers can be replaced with setjmp/longjmp, and only control transfer itself would require actual assembly. But maybe not. That's the problem with register machines, I guess. Interestingly enough, BCPL, its main implementation being a p-code interpreter of sorts, has pretty trivially supported coroutines in its "standard" library since the late seventies — as you say, all you need to save is the current stack pointer and the code pointer. | | |
| ▲ | zabzonk 2 hours ago | parent | next [-] | | You can do a lot of horrible things with setjmp and friends. I actually implemented some exception throw/catch macros using them (which did work) for a compiler that didn't support real C++ exceptions. Thank god we never used them in production code. This would be about 32 years ago - I don't like thinking about that ... | |
| ▲ | lelanthran 2 hours ago | parent | prev | next [-] | | > Hmm. I'm fairly certain that most of that assembly code for saving/restoring registers can be replaced with setjmp/longjmp, and only control transfer itself would require actual assembly. Actually you don't even need setjmp/longjmp. I've used a library (embedded environment) called protothreads (plain C) that abused the preprocessor to implement stackful coroutines. (Defined a macro that used the __LINE__ macro coupled with another macro that used a switch statement to ensure that calling the function again made it resume from where the last YIELD macro was encountered) | | | |
| ▲ | gpderetta 2 hours ago | parent | prev [-] | | setjmp + longjump + sigaltstack is indeed the old trick. |
|
|
|
| ▲ | Joker_vD 3 hours ago | parent | prev | next [-] |
| Simon Tatham, author of Putty, has quite a detailed blog post [0] on using the C++20's coroutine system. And yep, it's a lot to do on your own, C++26 really ought to give us some pre-built templates/patterns/scaffolds. [0] https://web.archive.org/web/20260105235513/https://www.chiar... |
|
| ▲ | bullen an hour ago | parent | prev | next [-] |
| Coroutines generally imply some sort of magic to me. I would just go straight to tbb and concurrent_unordered_map! The challenge of parallelism does not come from how to make things parallel, but how you share memory: How you avoid cache misses, make sure threads don't trample each other and design the higher level abstraction so that all layers can benefit from the performance without suffering turnaround problems. My challenge right now is how do I make the JVM fast on native memory: 1) Rewrite my own JVM.
2) Use the buffer and offset structure Oracle still has but has deprecated and is encouraging people to not use. We need Java/C# (already has it but is terrible to write native/VM code for?) with bottlenecks at native performance and one way or the other somebody is going to have to write it? |
| |
| ▲ | pjc50 29 minutes ago | parent [-] | | > C# (already has it but is terrible to write native/VM code for?) What do you mean here? Do you mean hand-writing MSIL or native interop (pinvoke) or something else? |
|
|
| ▲ | cherryteastain 4 hours ago | parent | prev | next [-] |
| Not an expert in game development, but I'd say the issue with C++ coroutines (and 'colored' async functions in general) is that the whole call stack must be written to support that. From a practical perspective, that must in turn be backed by a multithreaded event loop to be useful, which is very difficult to write performantly and correctly. Hence, most people end up using coroutines with something like boost::asio, but you can do that only if your repo allows a 'kitchen sink' library like Boost in the first place. |
| |
| ▲ | spacechild1 an hour ago | parent | next [-] | | > that must in turn be backed by a multithreaded event loop to be useful Why? You can just as well execute all your coroutines on a single thread. Many networking applications are doing fine with just use a single ASIO thread. Another example: you could write game behavior in C++ coroutines and schedule them on the thread that handles the game logic. If you want to wait for N seconds inside the coroutine, just yield it as a number. When the scheduler resumes a coroutine, it receives the delta time and then reschedules the coroutine accordingly. This is also a common technique in music programming languages to implement musical sequencing (e.g. SuperCollider) | |
| ▲ | pjc50 3 hours ago | parent | prev | next [-] | | Much of the original motivation for async was for single threaded event loops. Node and Python, for example. In C# it was partly motivated by the way Windows handles a "UI thread": if you're using the native Windows controls, you can only do so from one thread. There's quite a bit of machinery in there (ConfigureAwait) to control whether your async routine is run on the UI thread or on a different worker pool thread. In a Unity context, the engine provides the main loop and the developer is writing behaviors for game entities. | |
| ▲ | inetknght 34 minutes ago | parent | prev | next [-] | | > From a practical perspective, that must in turn be backed by a multithreaded event loop to be useful Multithreaded? Nope. You can do C++ coroutines just fine in a single-threaded context. Event loop? Only if you're wanting to do IO in your coroutines and not block other coroutines while waiting for that IO to finish. > most people end up using coroutines with something like boost::asio Sure. But you don't have to. Asio is available without the kitchen sink: https://think-async.com/Asio/ Coroutines are actually really approachable. You don't need boost::asio, but it certainly makes it a lot easier. I recommend watching Daniela Engert's 2022 presentation, Contemporary C++ in Action: https://www.youtube.com/watch?v=yUIFdL3D0Vk | |
| ▲ | spacechild1 4 hours ago | parent | prev [-] | | ASIO is also available outside of boost! https://github.com/chriskohlhoff/asio | | |
| ▲ | lionkor 3 hours ago | parent [-] | | For anyone wondering; this isn't a hack, that's the same library, just as good, just without boost dependencies. | | |
| ▲ | spacechild1 an hour ago | parent [-] | | Thanks for pointing this out! This may not obvious not everybody. Also, this is not some random GitHub Repo, Chris Kohlhoff is the developer of ASIO :) |
|
|
|
|
| ▲ | pjmlp an hour ago | parent | prev | next [-] |
| As I mentioned on the Reddit thread, This is quite understandable when you know the history behind how C++ coroutines came to be. They were initially proposed by Microsoft, based on a C++/CX extension, that was inspired by .NET async/await implementation, as the WinRT runtime was designed to only support asynchronous code. Thus if one knows how the .NET compiler and runtime magic works, including custom awaitable types, there will be some common bridges to how C++ co-routines ended up looking like. |
|
| ▲ | pjc50 3 hours ago | parent | prev | next [-] |
| Always jarring to see how Unity is stuck on an ancient version of C#. The use of IEnumerable as a "generator" mechanic is quite a good hack though. |
| |
| ▲ | Deukhoofd 2 hours ago | parent | next [-] | | Thankfully they are actively working towards upgrading, Unity 6.8 (they're currently on 6.4) is supposed to move fully towards CoreCLR, and removing Mono. We'll then finally be able to move to C# 14 (from C# 9, which came out in 2020), as well as use newer .NET functionality. https://discussions.unity.com/t/coreclr-scripting-and-ecs-st... | | |
| ▲ | pjmlp an hour ago | parent | next [-] | | For several years now, I wonder if it will ever happen. | |
| ▲ | Rohansi an hour ago | parent | prev [-] | | One annoying piece of Unity's CoreCLR plan is there is no plan to upgrade IL2CPP (Unity's AOT compiler) to use a better garbage collector. It will continue to use Boehm GC, which is so much worse for games. | | |
| ▲ | pjc50 an hour ago | parent [-] | | Why wouldn't they use the GC that comes with the dotnet AOT runtime? | | |
| ▲ | pjmlp an hour ago | parent [-] | | Probably because the AOT runtime doesn't run on game consoles, straight out of the box. Capcom has their own fork of .NET for the Playstation, for example. I don't know what kind of GC they implemented. |
|
|
| |
| ▲ | Philip-J-Fry 2 hours ago | parent | prev | next [-] | | >The use of IEnumerable as a "generator" mechanic is quite a good hack though. Is that a hack? Is that not just exactly what IEnumerable and IEnumerator were built to do? | |
| ▲ | tyleo 3 hours ago | parent | prev | next [-] | | Unity is currently on C# 9 and that IEnumerable trick is no longer needed in new codebases. async is properly supported. | |
| ▲ | debugnik 3 hours ago | parent | prev | next [-] | | Not that ancient, they just haven't bothered to update their coroutine mechanism to async/await. The Stride engine does it with their own scheduler, for example. Edit: Nevermind, they eventually bothered. | | |
| ▲ | Rohansi 2 hours ago | parent | next [-] | | It's ancient. The latest version of Unity only partially supports C# 9. We're up to C# 14 now. But that's just the language version. The Mono runtime is only equivalent to .NET Framework 4.8 so all of the standard library improvements since .NET (Core) are missing. Not directly related to age but it's performance is also significantly worse than .NET. And Unity's garbage collector is worse than the default one in Mono. | |
| ▲ | nananana9 3 hours ago | parent | prev [-] | | Unity has async too [1]. It's just that in a rare display of sanity they chose to not deprecate the IEnumerator stuff. [1] https://docs.unity3d.com/6000.3/Documentation/ScriptReferenc... | | |
| ▲ | debugnik 2 hours ago | parent [-] | | Oh I totally missed this, thanks! I was overly confident they wouldn't have bothered, given how long it was taking. The last time I used Unity was 2022.3, which was apparently the last version without Awaitable. |
|
| |
| ▲ | ahoka 2 hours ago | parent | prev | next [-] | | IIRC generators and co-routines are equivalent in a sense that you can implement one with the other. | | |
| ▲ | Sharlin an hour ago | parent [-] | | Generators are a subset of coroutines that only yield data in one direction. Full coroutines can also receive more input from the caller at every yield point. |
| |
| ▲ | repelsteeltje 3 hours ago | parent | prev [-] | | Not too different from C++'s iterator interface for generators, I guess. |
|
|
| ▲ | twoodfin 2 hours ago | parent | prev | next [-] |
| As the author lays out, the thing that made coroutines click for me was the isomorphism with state machine-driven control flow. That’s similar to most of what makes C++ tick: There’s no deep magic, it’s “just” type-checked syntactic sugar for code patterns you could already implement in C. (Occurs to me that the exceptions to this … like exceptions, overloads, and context-dependent lookup … are where C++ has struggled to manage its own complexity.) |
| |
| ▲ | HarHarVeryFunny 2 hours ago | parent [-] | | If you need to implement an async state machine, couldn't that just as easily be done with std::future? How do coroutines make this cleaner/better? |
|
|
| ▲ | abcde666777 4 hours ago | parent | prev | next [-] |
| More broadly the dimension of time is always a problem in gamedev, where you're partially inching everything forward each frame and having to keep it all coherent across them. It can easily and often does lead to messy rube goldberg machines. There was a game AI talk a while back, I forget the name unfortunately, but as I recall the guy was pointing out this friction and suggesting additions we could make at the programming language level to better support that kind of time spanning logic. |
| |
| ▲ | manoDev an hour ago | parent | next [-] | | This is more evident in games/simulations but the same problem arises more or less in any software: batch jobs and DAGs, distributed systems and transactions, etc. This what Rich Hickey (Clojure author) has termed “place oriented programming”, when the focus is mutating memory addresses and having to synchronize everything, but failing to model time as a first class concept. I’m not aware of any general purpose programming language that successfully models time explicitly, Verilog might be the closest to that. | |
| ▲ | syncurrent 2 hours ago | parent | prev | next [-] | | This timing additions to a language is also at the core of imperative synchronous programming languages like Este rel, Céu or Blech. | |
| ▲ | repelsteeltje 3 hours ago | parent | prev [-] | | > There was a game AI talk a while back, I forget the name unfortunately, but as I recall the guy was pointing out this friction and suggesting additions we could make at the programming language level to better support that kind of time spanning logic. Sounds interesting. If it's not too much of an effort, could you dig up a reference? | | |
|
|
| ▲ | mgaunard 2 hours ago | parent | prev | next [-] |
| Coroutines is just a way to write continuations in an imperative style and with more overhead. I never understood the value. Just use lambdas/callbacks. |
| |
| ▲ | usrnm an hour ago | parent | next [-] | | > Just use lambdas/callbacks "Just" is doing a lot of work there. I've use callback-based async frameworks in C++ in the past, and it turns into pure hell very fast. Async programming is, basically, state machines all the way down, and doing it explicitly is not nice. And trying to debug the damn thing is a miserable experience | | |
| ▲ | mgaunard an hour ago | parent [-] | | You can embed the state in your lambda context, it really isn't as difficult as what people claim. The author just chose to write it as a state machine, but you don't have to. Write it in whatever style helps you reach correctness. | | |
| ▲ | Sharlin an hour ago | parent [-] | | You still need the state and the dispatcher, even if the former is a little more hidden in the implicit closure type. |
|
| |
| ▲ | affenape an hour ago | parent | prev | next [-] | | Not necessarily. A coroutine encapsulates the entire state machine, which might pe a PITA to implement otherwise. Say, if I have a stateful network connection, that requires initialization and periodic encryption secret renewal, a coroutine implementation would be much slimmer than that of a state machine with explicit states. | |
| ▲ | spacechild1 36 minutes ago | parent | prev | next [-] | | > Just use lambdas/callbacks. Lol, no thanks. People are using coroutines exactly to avoid callback hell. I have rewritten my own C++ ASIO networking code from callback to coroutines (asio::awaitable) and the difference is night and day! | |
| ▲ | Sharlin an hour ago | parent | prev | next [-] | | Did you read the article? As the author says, it becomes a state machine hell very quickly beyond very simple examples. | | |
| ▲ | kccqzy 41 minutes ago | parent [-] | | I just don’t agree that it always becomes a state machine hell. I even did this in C++03 code before lambdas. And honestly, because it was easy to write careless spaghetti code, it required a lot more upfront thought into code organization than just creating lambdas willy-nilly. The resulting code is verbose, but then again C++ itself is a fairly verbose language. |
| |
| ▲ | DonHopkins an hour ago | parent | prev [-] | | The Unity editor does not let you examine the state hidden in your closures or coroutines. (And the Mono debugger is a steaming pile of shit.) Just put your state in visible instance variables of your objects, and then you will actually be able to see and even edit what state your program is in. Stop doing things that make debugging difficult and frustratingly opaque. |
|
|
| ▲ | bradrn 2 hours ago | parent | prev | next [-] |
| In Haskell this technique has been called ‘reinversion of control’: http://blog.sigfpe.com/2011/10/quick-and-dirty-reinversion-o... |
|
| ▲ | momocowcow 2 hours ago | parent | prev [-] |
| No serious devs even uses Unity coroutines. Terrible control flow and perf. Fine for small projects on PC. |
| |
| ▲ | kdheiwns 2 hours ago | parent | next [-] | | In all of my years of professional game dev, I can verify that this is not even remotely true. They're used basically everywhere. They're very common when you need something to update for a set period of time but managing the state outside a very local context would just make the code a mess. Unity's own documentation for changing scenes uses coroutines | |
| ▲ | krajzeg 2 hours ago | parent | prev | next [-] | | Echoing the thoughts of the only current sibling comment: lots of "serious" developers (way to gatekeep here) definitely use coroutines, when they make sense. As mentioned, it's one of the best ways to have something update each frame for a short period of time, then neatly go away when it's not needed anymore. Very often, the tiny performance hit you take is completely outweighed by the maintanability/convenience. | | |
| ▲ | DonHopkins an hour ago | parent [-] | | ...and then crash when any object it was using gets deleted while it's still running, like when the game changes scenes, but it becomes a manual, error-prone process to track down and stop all the coroutines holding on to references, that costs much more effort than it saves. I've been a serious Unity developer for 16 years, and I avoid coroutines like the plague, just like other architectural mistakes like stringly typed SendMessage, or UnityScript. Unity coroutines are a huge pain in the ass, and a lazy undisciplined way to do things that are easy to do without them, using conventional portable programming techniques that make it possible to prevent edge conditions where things fall through the cracks and get forgotten, where references outlive the objects they depend on ("fire-and-forget" gatling foot-guns). Coroutines are great -- right up until they aren’t. They give you "nice linear code" by quietly turning control flow into a distributed state machine you no longer control. Then the object gets destroyed, the coroutine keeps running, and now you’re debugging a null ref 200 frames later in a different scene with an obfuscated call stack and no ownership. "Just stop your coroutines" sounds good until you realize there’s no coherent ownership model. Who owns it? The MonoBehaviour? The caller? The scene? Every object it has a reference to? The thing it captured three yields ago? The cure is so much worse than the disease. Meanwhile: No static guarantees about lifetime. No structured cancellation. Hidden allocation/GC from yield instructions. Execution split across frames with implicit state you can’t inspect. Unity has a wonderful editor that lets you inspect and edit the state of the entire world: EXCEPT FOR COROUTINES! If you put your state into an object instead of local variables in a coroutine, you can actually see the state in the editor. All of this to avoid writing a small explicit state machine or update loop -- Unity ALREADY has Update and FixedUpdate just for that: use those. Coroutines aren’t "cleaner" -- they just defer the mess until it’s harder to reason about. If you can't handle state machines, then you're even less equipped to handle coroutines. | | |
| ▲ | kdheiwns 21 minutes ago | parent | next [-] | | Never had a crash from that. When the GameObject is destroyed, the coroutine is gone. If you're using a coroutine to manage something outside the scope of the GameObject itself, that's a problem with your own design, not the coroutine itself. It'd be like complaining about arrays being bad because if you pass a pointer to another object, nuke the original array, then try to access the data, it'll cause an error. That's kind of... your own fault? Got to manage your data better. Unity's own developers use them for engine code. To claim it's just something for noobs is a bit of an interesting take, since, well, the engine developers are clearly using them and I doubt they're Unity noobs. They made the engine. | |
| ▲ | Arch485 42 minutes ago | parent | prev | next [-] | | I dunno, I've worked on some pretty big projects that have used lots of coroutines, and it's pretty easy to avoid all of the footguns. I'm not advocating for the ubiquitous use of coroutines (there's a time and place), but they're like anything else: if you don't know what you're doing, you'll misuse them and cause problems. If you RTFM and understand how they work, you won't have any issues. | | |
| ▲ | DonHopkins 38 minutes ago | parent [-] | | They're a crutch for people who don't know what they're doing, so of course they invite a whole host of problems that are harder to solve than doing it right in the first place. If you strictly require people to know exactly what they're doing and always RTFM and perfectly understand how everything works, then they already know well enough to avoid coroutines and SendMessage and UnityEvents and other footguns in the first place. It's much easier and more efficient to avoid all of the footguns when you simply don't use any of the footguns. |
| |
| ▲ | bob1029 an hour ago | parent | prev [-] | | > Who owns it? The MonoBehaviour? The caller? The thing it captured three yields ago? The monobehavior that invoked the routine owns it and is capable of cancelling it at typical lifecycle boundaries. This is not a hill I would die on. There's a lot of other battles to fight when shipping a game. | | |
| ▲ | DonHopkins an hour ago | parent [-] | | And then you're bending over backwards and have made so much more busy work for yourself than you would have if you'd just done it the normal way, in which all your state would be explicitly visible and auditable in the editor. The biggest reason for using Unity is its editor. Don't do things that make the editor useless, and are invisible to it. The problem with coroutines is that they generate invisible errors you end up shipping and fighting long after you shipped your game, because they're so hard to track down and reproduce and diagnose. Sure you can push out fixes and updates on Steam, but how about shipping games that don't crash mysteriously and unpredictably in the first place? |
|
|
| |
| ▲ | voidUpdate an hour ago | parent | prev [-] | | Just out of interest, how many serious unity devs have you talked to? | | |
| ▲ | DonHopkins 23 minutes ago | parent [-] | | I've talked to some non-serious unity devs, like Peter Molyneux... https://news.ycombinator.com/item?id=47110605 >1h 48m 06s, with arms spread out like Jesus H Christ on a crucifix: "Because we can dynamically put on ANY surface of the cube ANY image we like. So THAT's how we're going to surprise the world, is by giving clues about what's in the middle later on." Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Click. Moo! |
|
|