We've been hacking away at chronos and there are two interesting features in development / developed:
First, we've eliminated cyclic references between the future and the closure when using async - to give a bit of background, when you use async in Nim, a hidden closure iterator is created that takes a copy of all data that you use in the async function - this includes a full copy of all data you're sending via a socket, for example - if your async function contains multiple await steps, all data is kept alive until after the last await has completed, even if it is no longer needed.
To make matters worse, the closure and the iterator, in previous versions, would reference each other, making the problem even worse in two ways: as long as the future was referenced, the closure iterator would not be released, and because of the cyclic references between the two, nothing would be released until the (extremely slow) mark and sweep pass would run, leading to large spikes in memory usage.
https://github.com/status-im/nim-chronos/pull/243 fixes this and the effect is dramatic: no longer are hundreds of MB of data held hostage by the garbage collector and we get a nice and stable memory profile as result.
The other issue concerns exceptions, or rather the general lack of error handling in Nim: few are the nim libraries (including the std library) that correctly handle exceptions, and no wonder: the default in the language is that they are ignored at the call site until they cause a crash and / or a resource leak - this is fine for scripts and small personal apps but not so in the kind of long-running server applications that we deal with. For synchronous code, we've already moved away exceptions mostly, or use raises tracking to enforce call-site checking at function level via push raises: ...
Unfortunately, raises tracking does not work with async- the type of the raised exceptions is erased during the process of storing exceptions in the Future and re-raising them - until now, that is: https://github.com/status-im/nim-chronos/pull/251 adds a new asyncraises keyword that tightens the type erasure to a more limited set of exceptions and transfers this information to the resume point - the end result, when used consistently, is that async code now can use raises tracking similar to sync code - we also can produce warnings when these annotations are missing.
This latter feature is experimental, but we're happy to hear feedback - these features are courtesy of Menduist that heads our libp2p efforts :)
Really cool! Does this mean that Chronos can be used with ARC without leaks now?
And you seem to riff on exceptions in Nim in particular (or maybe just how Nim libraries tend to no catch them), why do you feel this is worse in Nim, and which languages are you comparing it to?
Really cool! Does this mean that Chronos can be used with ARC without leaks now?
not sure, would love to hear about it - we're waiting for the compiler to work with orc/arc before venturing in this direction :)
why do you feel this is worse in Nim
Looking at outcomes, it's unusually bad in Nim if you think of nim as a "systems programming langauge" and not a "scripting language", and I suspect there are many contributing factors - a lot of it probably comes down to a mix of:
there's more which is more subtle, but arguably "out of sight, out of mind" is the zen of the feature in Nim, and the end result is what it is.
Really cool! Does this mean that Chronos can be used with ARC without leaks now?
That’d be awesome! Given that both closures and iterators are ARC compatible it seems likely chronos async could work with ARC. I’ll have to try it out and see if it’ll work on an embedded service. :-)
poor integration with other features - "destructors" in nim aren't really destructors but finalizers that in some cases behave like destructors (the difference being determinism)
ARC/ORC is great in this aspect. Having proper “destructors” simplifies certain code. Exceptions seem to work well with ARC destroy calls.
poor separation between "catchable" errors and "defects", causing dialects and confusion as to what the difference is - specially because underneath, they rely on the same "mechanism" and sometimes defects are caught by regular exception handlers (vs rust)
Yah, I’ve been bitten by this on my embedded rpc server. I really want to catch “all exceptions” to ensure my MCU doesn’t crash. Its easy for a programmer to forgot something in their rpc method or not know of some possible defect. I don’t mind the lack of call site handling of exceptions if I can stop it from crashing the server though. But it’s not clear how to catch all possible defects including odd ones like int overflows.
Though I will say I’ve seen a lot of Rust code that just “panics”, which is even worse IMHO.
But it’s not clear how to catch all possible defects including odd ones like int overflows.
try ... except: ... does the job. It catches overflows too. With --panics:on it doesn't, so use panics:off which is the default anyway.
Though I will say I’ve seen a lot of Rust code that just “panics”, which is even worse IMHO.
rust panic = Nim Defect, more or less: it's a design choice of the author of a library when trigger them (sort of), they can both "mostly" be caught, but in rust you have a dedicated mechanism for catching them separate from the "normal" control flow.
In Nim, the language manual says Defect won't get caught while the compiler says there's a flag with a default that contradicts the manual - flipping the panic flag changes the meaning of try: .. except: .. which makes it hard to develop libraries that depend on either setting - you also can't use one setting for one library and another for another - if you control all code that gets executed, you might get away with panics:off but you won't necessarily catch them at top-level: any code that has an except: will catch them there and then, so bugs like overflows likely get the same treatment as expected errors. I guess the fact that almost nobody writes exception handlers works in your favor, in this particular case :)
ARC/ORC is great in this aspect. Having proper “destructors” simplifies certain code. Exceptions seem to work well with ARC destroy calls.
Be careful though, the cycle fixes in chronos focus on internals as a memory usage optimization technique first and foremost: you can still create circles in your own code by accident and suddenly determinism is gone and so are destructors - de facto you're back in finalizer land - in libraries at least, it's usually better to not make too many assumptions about the determinism of Nim destructors as it makes for pretty fragile code.
The manual says literally:
The current implementation allows to switch between these different behaviors via --panics:on|off. When panics are turned on, the program dies with a panic, if they are turned off the runtime errors are turned into exceptions.
The implementation does not contradict the manual. And --panics:off is the default mode and also what old Nim versions did before --panics got introduced. Probably it was a mistake to ever introduce the switch but in practice you can simply assume that it doesn't exist and that your dependencies all use --panics:off.
Be careful though, the cycle fixes in chronos focus on internals as a memory usage optimization technique first and foremost: you can still create circles in your own code by accident and suddenly determinism is gone and so are destructors - de facto you're back in finalizer land - in libraries at least, it's usually better to not make too many assumptions about the determinism of Nim destructors as it makes for pretty fragile code.
True, cycles can cause issues with that. Another reason I prefer to ensure my code is ARC safe and to treat cycles as errors. But as you say that's not really feasible in library code. :-) I'm excited to try out Chronos async with ARC though! The memory optimization is critical for using async on embedded devices. Even a few extra of ram stuck in a cycle and waiting to be reclaimed can cause OOM issues. Writing embedded code and high performance servers share a lot the same of concerns.
What I like to do is to use Nim style destructors with an explicit try/finally "close" at an appropriate step in the lifecycle. In this scenario =destroy's are used as an eager resource reclamation method, but an explicit "close" code would still need to exist at an appropriate place(s). Also I'd say C++ RAII has edge cases that make using it for resource reclamation tricky as well (putting things in shared_ptr or something).
try ... except: ... does the job. It catches overflows too. With --panics:on it doesn't, so use panics:off which is the default anyway.
@araq thanks! I'll check that I have panics:off. It's very likely I turned panics on at some point and got it stuck in my brain that overflow defects weren't caught.
Exceptions that indicate programming bugs inherit from system.Defect (which is a subtype of Exception) and are strictly speaking not catchable as they can also be mapped to an operation that terminates the whole process.
This is also what the manual says: the intent is that they're "not catchable" and used to indicate bugs, so when you're writing library code, you can't know what the user of your library will do with them - this creates a problem over time.
If you allow except to catch Defect, you're contradicting the intent of the manual: bugs that under normal conditions should have caused the application to terminate (for example assert which indicates that the application is in an indeterminate state) get swallowed together with "regular" exceptions that are expected (say .. ValueError) because of language defaults (the easiest thing to type is naked except: which changes meaning / semantic)
The outcome is that the distinction between Defect and CatchableError becomes mostly pointless in practice, which leads back to my original point, that the whole idea is confusing for everyone involved, which in turn contributes to poor error handling outcomes in Nim code, as a trend, over time.
Bugs get swallowed and become deep instead of shallow, dialects proliferate and if you want to write library code with intentional error handling to grow the base of high-quality nim libraries, you're shooting for a moving target and have to fight the language defaults that promote "out of sight, out of mind".
In this post, since the question was asked, I'm merely observing the current state of things after writing and reviewing lots of code in lots of projects, both those that use and don't use exceptions (we do both).
That said, it does get confusing and difficult at times, above all because of Defect that was half-introduced, but not carried to completion (as the inconsistencies and dialects show) - one way forward here that would minimize impact of breakage would likely be that a "naked" except: should never catch Defect, no matter the panic: flag - that would neutralize most semantic differences between the two panic modes. Further, to give panic:off a semantic that can be reasoned about at runtime (and therefore gives library authors a chance to work with it), an explicit except Defect would catch the defects - at that point, one can probably deprecate the flag, get rid of the compile-time dialects and start fresh.
There's a number of issues and RFC:s open (and/or closed unimplemented) about these things which talk about possible resolutions and action plans (unlike complaints, which stop at the observation phase).
one way forward here that would minimize impact of breakage would likely be that a "naked" except: should never catch Defect
That's a breaking change of the bad kind of sort; it changes semantics secretly. In the stdlib except: is misused more than it is used properly but some usages of it are correct.
and if the except clause will only be a "naked" except when compiled with a compiler directive which changes the default except behaviour to miss Defects, combined with a new exceptDefect clause
in that way the stdlib will only miss defects when explicitly compiled with the compiler directive.
and also an explicit exceptError and exceptDefect clause.
just a quick suggestion :)
This looks (at least to a beginner, like self) as if bare except clauses should be deprecated and replaced by except Defect: or except CatchableError:, depending on what's really intended.
Sounds like a solid plan. :-)
sounds like a solid plan. :-)
we've done this already across our entire codebase :) https://github.com/nim-lang/Nim/issues/19580 would start this journey for the rest of the community.