# Async Rust explained in 20 minutes

## Метаданные

- **Канал:** Let's Get Rusty
- **YouTube:** https://www.youtube.com/watch?v=wXtngLBkK4Q
- **Дата:** 08.05.2026
- **Длительность:** 19:14
- **Просмотры:** 249
- **Источник:** https://ekstraktznaniy.ru/video/49659

## Описание

👉Join the Rust Live Accelerator waitlist: https://letsgetrusty.com/join/lv-wXtngLBkK4Q

If you're coming from languages like Go, JavaScript, or C#, async code usually “just works.” In Rust, the same patterns don’t behave the way you expect, and that’s by design.

In this video, we break down the 3 core concepts that trip up even experienced developers. By the end, you’ll have the mental models needed to write production-grade async Rust with confidence.

Chapters:
0:00 Intro
0:47 Tasks vs. Futures
3:54 Structured Concurrency & Cancellation
14:30 Sync/Async Interop
18:05 Conclusion

## Транскрипт

### Intro []

Async Rust will break your brain. In other languages like Go, JavaScript, or C, async code just works. But in Rust, a similar code snippet won't even run. Why? Because Rust's async model is fundamentally different. Async Rust is more powerful, more performant, but also a lot more confusing if you expect it to work like other languages. In this video, we'll cover the three areas of Async Rust that trip up even senior developers. By the end of this video, you'll build the intuition to start writing production grade async Rust with confidence. Let's start with the first set of concepts. Exciting news, this video is part of the official launch series for the Rust Live Accelerator opening this June. More on that later. In Rust, calling an async function

### Tasks vs. Futures [0:47]

doesn't actually execute the async function. If you're coming from JavaScript or Go, this is genuinely confusing. In those languages, calling an async function starts something. Work begins. In Rust, it doesn't. And that's not a bug. That's a design decision. When you call an async function in Rust, you get back a future. A future is just data, a description of work to be completed. Futures are similar to promises in JavaScript, but with one key difference. In JavaScript, a promise is eager. The moment you create it, it starts executing. In Rust, futures are lazy. They won't start executing unless explicitly told to do so. This design decision allows developers to have full control over when and where async work starts. That means Rust never does work you didn't ask for. This matters a lot when you're building systems that require high performance and throughput. So, how do you actually make a future run? You turn it into a task. Tasks are futures that have been handed off to an async runtime for execution. So to make this into working async Rust code, we first have to introduce an async runtime. We'll use Tokyo, the most popular async runtime in Rust. You configure it with a single attribute above your main function and then you add async to the front of your main function. Finally, to turn a future into a task, you wrap it in Tokyo spawn. The moment you do that, the future is handed off to the Tokyo runtime. The task is now scheduled for execution on the Tokyo async runtime. But what exactly is an async runtime? An async runtime is a system that manages the execution of async work. Think of the async runtime like a manager. It decides which tasks get worked on, in which order, and on which threads. Without it, your futures just sit there forever. In JavaScript, C, and Go, the async runtime is built into the language. In Rust, the async runtime is explicitly not built into the language. This is another intentional design decision. By letting the Rust community create async runtimes as third party libraries, Rust is not locked into using one specific runtime. This allows developers to decide which runtime makes the most sense for their use case. For example, you may want to use the Embassy async runtime if you're working with embedded devices. That said, 99% of the time you'll use the Tokyo runtime in your Rust applications. Let me know in the comments if you'd like a dedicated video explaining how the Tokyo runtime works under the hood. So, let's lock in the mental model. Async functions return futures. A future is simply a description of async work to be completed. Unlike many other languages, futures in Rust are lazy, meaning they do absolutely nothing until something executes them. A task is a future that's been handed to an async runtime and scheduled for execution. And a runtime is the engine that pulls those tasks and drives them forward. In Rust, async runtimes are third party libraries that have to be imported. And the most popular is Tokyo. Okay, so now that we

### Structured Concurrency & Cancellation [3:54]

understand what futures, tasks, and a runtime are, let's talk about the next core area that trips up even senior engineers in async rust. Structured concurrency and cancellation. Structured concurrency in async rust is about deciding how your futures run. And there are three common ways to do that. Using a wait to run futures sequentially inside one task. Using join to run multiple futures inside one task or using select to race multiple futures inside one task. Let's first look at await. Here we have our initial example of comparing futures in Rust to promises in JavaScript. Now, in JavaScript, we have the await keyword. It allows us to wait for our code to finish at specific points during execution. Luckily for us, Rust also has the same await syntax, which allows us to wait for the result of a future. But if you recall from the last section, we showed Tokyo spawn, and now we're showing await. Let's compare these two. We have spawn on the left and await on the right. Both seem to be doing something to drive futures forward. But which syntax is correct? They're both correct. Both compile, both run. But there are key differences between the two, which allows great developers to get the most out of their codebase. Let's talk about a way and bring in a visual that will help drive our understanding. First, by adding Tokyo main, it creates a thread pool and sets up an async runtime for us. Then we make main an async function which returns a future. The nice part is Tokyo automatically converts the future returned from main into the root task which is handed off to the async runtime. Inside the main function fetch user also returns a future and will be driven forward by the root task. The root task can start running on any open thread in the thread pool. Here we're showing that it starts running on thread 2, but in practice it can run on any open thread. Second, because we have the away syntax, this means wait for this future to complete while pausing the current task at that point. And that means you're able to use this result later on in your code. Now, let's compare this to the Tokyo spawn side for a second. We have the same initial step as last time. Tokyo main creates a thread pool and the root task starts running immediately. Here, on thread 1. However, this time the root task hits Tokyo spawn, which creates and schedules an entirely new task. Notice that fetch user is not run on the root task. It's now being driven by the newly spawned task. Then the spawn task will be placed in a task Q with potentially many other tasks and will be scheduled onto any of the open threads. Here, thread 2 is chosen to run the Tokyo spawn task. But what happens at step five? We don't have any await syntax to signal we plan to use the result. By only spawning a task and not awaiting it, the task is only scheduled and run. But we don't actually wait for the result. It may succeed. It may fail silently, but nothing comes back to us to actually use later on in our code. This is known as fire and forget. Although we're showing fetch user in order to remain true to our original example, we're going to update our code and add one of the most common cases for fire and forget logging. Notice that this is the same exact structure we have with fetch user. It's only showing a slightly different use case. A best effort approach is taken to log in the background, but we don't explicitly need to wait for anything to be returned to us. We're okay if the log attempt succeeds or fails silently. So let's do a quick recap of what we just talked about. We learned that although we can use fire and forget on fetch user, it really benefits from using a wait because we care about using the result later in our code. And as we've seen, we have the ability to spawn a completely new task using Tokyo spawn, which is great for scenarios like fire and forget. So far, both await and spawn solve a core problem of being able to drive futures forward on the async runtime in Rust. But currently we are only working on individual async functions which return futures. But what if we want to run multiple futures at the same time inside one task? There are useful macros Tokyo has that can help in these scenarios. Let's assume you want to run multiple independent futures at the same time inside the same task. This is where the join macro comes in. If you're coming from JavaScript, you can think of join like promise. all. In Rust, the join macro is the wait for everything tool. You can give it multiple futures and it drives them forward concurrently until all of them complete. This is great when you need multiple results before moving on. If we expand our fetch user example, imagine we need to load a config file and connect to a database before fetching the user. Neither the config or the connection to the database depend on each other. You need both. So it makes sense to do both at once. And this is exactly what join is for. If we look at a visual, we can see that two futures are inside the same root task and then run on a thread. The join macro pulls both futures concurrently until they both complete. Both connect to DB and load config are returned. Now, you might be wondering, this Tokyo join syntax looks great, but is there really a difference between just running both of these features using the await syntax we've seen? Yes, they are very different. This commonly trips up even senior developers new to Rust. Although both look to simply be running futures on the root task, it's how it's happening which is critical to understand. Let's assume that both load config and connect to DB would take one full second to run by themselves. On the left hand side, Tokyo join uses what's called interle. It runs both futures concurrently, allowing the runtime to switch back and forth until both are done. This comes with a minimal overhead. For simplicity, we're showing that the overhead took us an additional 100 milliseconds for that context switching, but this isn't fixed. Let's compare this to the await example. The await syntax causes us to completely wait for load config to finish before connect to DB can finish. Here we have no interleing. We must wait the full 1 second for each future to finish. This difference can be monumental in a production codebase where end users demand the lowest latency possible. So using the draw macro is a clear winner in this scenario. Now let's discuss a different scenario. What happens if we don't necessarily care about both futures finishing? Imagine you want to run multiple futures concurrently and you only care about the first future that finishes. Basically, what if we want to get the fastest future to the finish line? This is where the select macro comes in. Again, we have a nice JavaScript parallel which is promise. ra in Rust. Select is the race tool. You give it multiple futures and whichever one completes first wins. We've updated our example such that we've already loaded our config and DB. But this time we want to race our fetch user against a 5-second timeout. We do so because in a production setting we don't want to wait indefinitely on a slower hung operation. Let's bring in the visual to aid in our understanding. We can see that this is very similar to our join visual, but this time we can see that at step two, the select macro pulls multiple futures and whoever completes first wins. For example, if fetch user finishes first, it would win and the timeout branch would lose. Now, there's one extra piece that catches developers offguard. When the timeout branch loses, the future is canled immediately. It doesn't progress forward or run to completion. This is the idea of cancellation and it's different from other languages like Go. In Go, developers have to explicitly cancel Go routines. In Go, we must explicitly tell losing Go routines to stop so they can exit early instead of doing unnecessary work. But in Rust, you don't have to cancel losing futures yourself. The cancellation is implicit. Once one future wins, the others are automatically dropped, meaning Rust immediately stops them and cleans up their state. The benefit is this makes cancellation predictable and zero cost because Rust can clean up resources immediately and most of the time that's perfectly fine. But what happens if you're racing features and you end up in the middle of a multi-step operation like a transaction? This is where a common foot gun in Rust occurs and it has to do with something called cancellation safety. Imagine you're writing a protocol message to a TCP stream but you're doing it inside of a select macro. The send message future starts being pulled. The header gets written, but then you hit the 5-second timeout. It saw the beginning, but it never got the rust, and your protocol state is now corrupted. This is what the Rust community means by enforcing cancellation safety. A future is cancellation safe if dropping it midway does not leave your program or external state in a broken or partial condition. Because with select, you are not just choosing the fastest branch, you are also choosing to cancel the losers. So the question becomes, if select or a timeout can cancel a future in the middle of a right, what do we do instead? The idiomatic fix is not to pretend the right can safely resume. It usually can't. Once the timeout fires, the state of that connection is now questionable. So on the left, we still have our send message and we race that against sleep. If send message finishes in time, great. The message was fully sent. But if the timeout branch wins, we do something very important. We shut down the stream immediately. We are not making the right itself magically cancellation safe. The right can still be interrupted halfway through. What makes this pattern safe is that we refuse to keep using the connection afterward, and that's what gives us cancellation safety. With that in mind, let's review all that we've covered in this section regarding spawn, await, join, select, and common foot guns. Here are the rules of thumb. Use Tokyo Spawn if you need a completely different task to be created. Use a wait if you need to wait for a result to be returned. Use join if you have independent futures that can run concurrently. Use select if you plan to race futures against each other. And in the context of the select macro, cancellation simply means the losing futures are automatically dropped. You don't need to explicitly call a cancel method. This works well for things like timers or reads, but it becomes dangerous for operations like writes where the work can be interrupted halfway through like we saw in our select example. If you understand these common ways of deciding how futures should be run, you understand some of the biggest differences between async rust and async code in other languages.

### Sync/Async Interop [14:30]

Now that we've covered structured concurrency and some of the major async foot guns, the other very important piece we need to understand is sync and async interop. By the way, these are exactly the kinds of async rust concepts that we tackle in depth inside the Rust live accelerator. The next cohort opens this June. more details at the end. This is exactly what it sounds like, mixing asynchronous code with synchronous code. And this is one of the easiest ways for production async systems to accidentally slow down or stall entirely. Let's set the stage with a common scenario a highfrequency options trader would face. You need to fetch a large amount of data from a highfrequency data provider like data bento. First, you add Tokyo main and an async main function like we've seen in the previous sections. Then we make a call to fetch options and calc Greeks for three different tickers. This makes calls to fetch option quotes to get the data. And then a call to calc Greeks which does a heavy CPU computation to calculate the implied volatility. But notice we're calling a weight on the futures which runs them sequentially. We don't want to do that. Instead, let's bring in one of the macros we learned about, join, which we know can process many futures at once inside one task and gives us a large speed up over sequential weights due to interle. In our case, it helps us with the ability to fetch three different API requests at once. So, the IO piece of this code is now sped up, but we are still calling calc Greeks, which does CPU heavy work. At some point, one of our futures will hit this large CPU computation and hog the async runtime from moving forward. Let's picture this. We have our root task which is driving futures A, B, and C to completion. The root task moves onto worker thread 1 and starts executing. But eventually those futures hit calc Greeks, a heavy CPU computation. And here's the problem. This work doesn't yield. It doesn't hit a weight. It just keeps the CPU busy until it finishes. So the root task gets stuck on thread 1 the entire time. That means the runtime can't schedule other tasks on thread 1 while the CPU computation is running. So what do we do? We need a way to run the CPU heavy work without blocking the async runtime. And that's exactly what Tokyo spawn blocking does. It lets us move that computation onto a separate thread pool designed specifically for CPUbound work. In other words, blocking work. Now let's walk through what happens. Step one, the root task starts on the non-blocking thread pool, the same one we've been using throughout the video. This pool is optimized for async work that can pause and yield. Step two, when we hit calcs, we call spawn blocking. This offloads the CPUheavy work to a separate thread pool for blocking work. And here's the key detail. The root task doesn't just sit there waiting. It actually comes off the thread entirely. That frees up the runtime to immediately run other async tasks. Meanwhile, the blocking work runs independently on the blocking pool. Step three. Once the work finishes, the root task resumes and it doesn't have to resume on the same thread. It can continue on any available thread in the non-blocking pool. So, the mental model is start on the async non-blocking pool. Offload CPUheavy work to the blocking pool. Let the task step off the thread while the work runs, then resume later on any available thread. That separation is what allows Tokyo to handle large amounts of async work efficiently without letting one CPUheavy task stall the entire runtime. So far, we've

### Conclusion [18:05]

covered the three areas of async rust that trip up even senior engineers. But an understanding of these topics isn't enough. You need to apply these to real world production scenarios, which is why we created the Rust Live Accelerator mentorship program. We're opening 30 spots for the next cohort very, very soon. A lot of people have been waiting for this next cohort, and honestly, I'm excited, too, because we've got some big plans this time around. In fact, some of the things we're working on are only going to be shared to the people on the wait list first. This program is specifically designed for engineers who are serious about making the switch into Rust, but have struggled to get to the point where they feel truly confident building production grade systems, acing Rust interviews, and landing real Rust jobs. So, click the link below to join the wait list. You'll be the first to know when applications open, and you'll also get access to the bonuses and updates we're only sharing with weight list members starting today. So, tap the link, join the weight list, and I'll see you inside.
