Async Rust: Panics vs. Cancellation
smallcultfollowing.com
smallcultfollowing.com
I've written quite a bit of async Rust code, and I haven't hit the problem described in the blog post. I think there are a couple of reasons:
• Rust already has programming patterns (like Drop guards) that avoid leaving the program in an inconsistent state in case of panics, and this solves the problem for aborted futures too. If your tasks owns a TCP stream, then it will always close the stream when it's aborted.
• Aborting and error handling are usually tied together, e.g. `timeout(copy()).await?` aborts a future, but turns that into an error. Especially for network-related tasks it's natural to treat timeouts as yet another case of an I/O error. Each end needs to handle suddenly closed connections anyway, regardless whether they're closed by Rust or a network error along the way.
• Rust uses layered abstractions for async code, e.g. you have request/response servers or streams for protocols. This way you can't leave the network protocol in an inconsistent state, even if your request handling code is aborted. If you were writing to a multiplexed HTTP/2 stream, then the underlying protocol handler will send an end-of-stream packet for you when your higher-level response stream is dropped.
async fn foo() {
let guard = Guard;
// async stuff
}
impl Drop for Guard {
fn drop(&mut self) {
// do cleanup
}
}If you really have to finish some work asynchronously on drop, you can spawn another task from your drop function. But most of the time I've found it's not even necessary, because you can restructure the code to separate the "abortable" part from the "must finish" part (e.g. some network protocol serializer must always terminate a message it writes, but generation of the message itself is typically done elsewhere, so you can set it up so that abort of the message-generating future doesn't abort the protocol-framing future).
What dropping futures does in Rust is much more forceful, and possibly meant for a different usecase, or as a lower-level primitive.
Unless you treat threads like a full process (no shared state) then you need cooperative cancellation (CancellationToken) which generally works well its nice to have common agreed upon cancellation message most commonly used in async I/O calls like a long running a DB query and wanting to cancel it if the client disconnects because they navigated away.
If you have uncooperative code that may say go into an infinite loop then you need to somehow preempt it if it runs on too long so then you have Thread.Abort or really Process.Kill which is the only safe way to do this in .Net (run it in another process).
[1] https://docs.microsoft.com/en-us/dotnet/api/system.threading...
It also references Javas deprecated Thread.stop which looks very similar to .Nets Thread.Abort
Emphasis mine. In Rust, "cancellation" happens at well defined "await points".
In Thread.Abort the .Net runtime is just injecting a call to throw into the instructions at potentially any point in the threads code which is obviously problematic although throwing on any await can still cause problem if you're not expecting it. There is no try catching involved because you really can't catch ThreadAbortException instead you are just waiting for the thread to stop with Thread.Join or what ever.
That’s not at all what happens when the Future is dropped in Rust: Yes, it only happens at .await, but when it does, execution is simply stopped dead, no chance to continue.
Rust seems to just have exceptions you can't catch (Panics) they are like ThreadAbortExceptions in .Net. That is if task threw ThreadAbortExceptions instead of TaskCanceledException and you called await with an already cancelled token you would get similar behavior it seems.
https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.h...
I remember trying to use it once, in my more reckless days and despite the warnings, thinking it's all fine if I just wrap all intermediate states in try/finally. It turns out that sometimes this will cause a throw in the finally clean-up, so you need to use a try/catch+rethrow and repeat yourself a bit. It's still not quite right, though, because you can still throw in the catch...
I usually code my programs assuming the system will work (reads/writes/sends). While I know this isn't guaranteed, it's a lot more likely my filesystem will work than that a file is assured to contain parsable data.
I encounter these issues quite commonly. Permissions, being in the wrong dir, buggy setup that forgot to create directories or copy files etc.
Isn't the `read()` and `send()` in rust concerned with those less-exceptional exceptions as well?
Could totally be an artifact of the example that was chosen to prove a point, it's just what jumped out at me.
I understand this. It does make sense when seen as an artifact of the educational nature, though.
If the article wanted to explain about exceptions in the parser, the author had to explain the domain as well. Whereas we all are familiar with the exceptions in the file-io-domain already.
I didn't read it that way. In my view, the article is explaining a mental model where async code & panics are similar/related in a possible abstract mental model. He's using that snippet of code which from an async perspective, one could reasonably expect that the file or network IO is worth async waiting on but parsing is not.
But I don't think the author assumes parsing couldn't raise an exception since he states at the beginning: "If the parse function or the send function were to throw an exception, whatever data had just been read (and maybe parsed) would be lost.".
None, which is why, in Rust, parse never throws an exception on a parsing error, and instead, returns a Result<Ok=T,Err=ParseError>, which is an ADT with either an Ok(T), which means parse succeeded, or an Err(ParseError), which means that an error happened, and contains state about where, etc.
See its documentation: https://doc.rust-lang.org/stable/std/primitive.str.html#meth...
The author isn't talking about this, probably cause its not the point of the article, but in Rust, you can't avoid handling parsing errors. If you want to actually get the value, you need to handle the possibility that an error happened. The programming model does not give the user a choice here.
That's why you use a condition system like Common Lisp[1] - conditions can be recovered from using restarts. The thrown condition signals the kind of error (or, even, exceptional non-error circumstance), while the defined restarts provide various error-recovery strategies, from which one can be chosen programmatically or manually.
I think that this part:
> In most programs, you have some kind of invariants that you are maintaining to ensure your data is in a valid state. It’s relatively straightforward to ensure that these invariants hold at the beginning of every operation and that they hold by the end of every operation. It’s really, really hard to ensure that those invariants hold all the time.
Can be fixed by adding immutability - instead of mutating your program's state in a low-level function, either generate a "transaction" object that is finished and applied to some other piece of state, or an exception is thrown and the whole transaction object is thrown away (or a particular loop could be restarted, or the program could be entirely restarted or quit, etc., depending on what restart you choose).
This:
> The problem is that exceptions make errors invisible, which means that programmers don’t think about them.
Can be fixed using smarter tooling (or checked exceptions, but I don't think anyone wants those) that ensures that you handle exceptions/conditions, unless I'm mistaken.
[1] https://en.wikipedia.org/wiki/Common_Lisp#Condition_system