Async Void in C#: The Trap Card You Keep Playing
The C# `init` accessor supports object initializers while preventing properties from being changed after initialization.
Transcript 6 topics
Why async void fails
0:00Years ago I pushed a tiny change on a Friday. I know everything looked green until an exception decided to go on a stealth mission. No logs, no stack trace, just poof. And the culprit, an innocent looking async void. I'm Michael Jolley and I'm the bald bearded builder.
And today we're gonna talk about why you should stay away from async void when it's actually okay to use and how to keep them from sneaking into your code like a raccoon in a server room. Let's set the stage a little bit. When you mark a method as async and return a task or generic task of t, the C compiler basically makes like a little state machine for you.
How async state machines work
0:39In fact, it's a lot like fishing. For instance, you've got a fishing rod that you're gonna cast. At the other end, you've got some bait that will hopefully attract a fish. You've got a hook that will hopefully catch a fish. Well, that task that you have in between is essentially the fishing line.
It allows you to follow the journey of the bait and the hook to know when you've got a nibble or when you've got a fish on the line or something goes wrong. But when you use an async void that's like making the cast and then immediately cutting the line. The bait and the hook are still there. Maybe they're attracting fish. Heck, maybe they even caught a fish but you'll never know it and you're certainly not gonna eat that fish later And that's just sad.
Let's start with a simple little repro. I've got a console app and I'll paste in some code here. We've got boom from task async, which is just delaying for a hundred milliseconds and throwing an exception, but it returns a task. And then we've also got the boom from avoid async that essentially does the same thing, but it's not returning anything for you.
Okay. So with those two in place, let's write some code that will actually call them and see what happens.
Catch task exceptions
1:48Like we'll throw in a try catch and we'll say await, boom from task async. And let's catch that. And we'll say exception EX. And then we'll just do console dot writeline with something like come on Mike. We'll do let's see boom from task with EX dot message maybe.
And you know what? Let's just copy paste this and we'll do the same code again. But this time we'll call boom from void and there's no wait here because it doesn't return anything. It's a void. We'll say boom from void and because this is gonna fire and forget it, we need to add a delay here to get make sure it has time to actually throw that exception.
So let's do a wait task dot delay three hundred milliseconds. We know the other one's gonna take a 100 so that should be good. What happens when we run this now though? Dot net run we should get it to console log out that task. There it is.
Boom from task. But look, unhandled exception. We had it in a try catch. What the heck, Mike? The issue is that the exception for the task actually propagated back to the caller.
Whereas the void, it doesn't know. So it just kinda raises up to the next synchronization context or maybe even just your app overall, maybe a global exception handler. But in the words of that great republic general, async void, well It's a trap. And depending on how you've wired up a global exception handler or something like that, it's thing may just bring down your whole application. Bottom line on all of those is that if you need to monitor completion, success, failure, return a task.
But what the heck, Michael? That's so terrible. Why would Microsoft even allow that? That just seems like a tip number. I get it.
It's a great question. I'm glad I asked. There are actually two legitimate use cases for an async void. The first use case is event handlers.
Fire-and-forget with tasks
4:00By default, .net expects event handlers to be voids. But there are times we need to run asynchronous code inside there. So async void is the right choice. But we wanna make sure that we write all that with a try catch. We want to make sure that we're handling exceptions within that.
And in this case, like make t async, yeah, it should return a task that the async void can then process and handle appropriately. So what's the other use case? Well, it's fire and forget, but that's a lot more rare and is really only viable if you don't care about failure conditions. Now, when would I ever wanna fire a method and not care if it actually completed? Well, think of use cases like sending telemetry data or perhaps like best effort cache warm ups.
But I'll say that when I do fire and forget, I still don't use async void. Check out this method. I wrap that in a task. I still make it return a task just so I can gobble up those exceptions and know what's going on throughout my application. Now that discard makes it clear that we're not awaiting this task, but wrapping it in that try catch allows us to bubble up exceptions where we know about it rather than allowing it to go willy nilly everywhere and possibly bringing down our whole process.
Well let's look at a real world example like an ASP dot NET Core pipeline. For all of our requests we always want to return a task or task of T so that our requests fail properly, our logging behaves appropriately, and the whole pipeline just lives in harmony with any middleware we've got going on. But let's say in the case of that async method, really want that to happen after this request has finished. You need to use a background service or perhaps send that over on a channel to get processed fully off this thread with its own logging and error checking built in.
That way you get clear life cycles, logging, cancellation, and no async void. Now this doesn't even get us into the quality gorilla in the room where our tests know how to handle async tasks, but they don't have enough visibility into async void beyond that first await.
Tests can miss failures
6:09That means your tasks can go green and pass, but the code would fail in production. I refactored our original methods here to be inside of a class in a namespace called void versus task so that we can run some tests against it. So if we switch over to this test, can you believe that we're writing tests on this channel like animals. If I do a test and say public void, let's just do a test task. I don't know.
It doesn't really matter. And then if we assert that throws a sink, I think it was an invalid operation exception that we were throwing there. And then we'll say that void versus task dot boom from task. And I'll new line that so it's a little easier to read. That's gonna test, it handles that async just fine.
What if we threw in another test, test test test that said public void void a test void I guess. Test void. And in that we just say void to Versus 10, there we go. Boom from void, bam like that. Now this is going to fire.
Yeah, it is, but it may go green. The problem with that is we know that's gonna throw an exception. What happens when we run these tests? Ido.net test. We should get, oh, geez.
They both passed. We know that async void would be throwing an exception and it should have thrown an exception in that test to break the test, but we fired and forgot. Look, async void isn't evil. It's just sharp.
Use async void sparingly
7:57Use it where the runtime expects void and only there. Everywhere else, return a task. That way your code can be awaited, tested, retried, and observed. I'm Michael Jolley, and this has been your friendly intervention for that one async void in your code base.
Go give it a task. Until next time.
Sign in to join in. Reading needs nothing.