type
status
slug
date
summary
tags
category
password
icon
参考链接:
The first simple webserver code:webserver’s Problem:But wait, so what’s wrong with threads?But what if there was a way to perform I/O without yielding to the kernel?
thinking…:wait, do we use poll to do that? Just query the status again and again. Well let’s go and say what author say:Let’s say what’s different in handle_connection:Let’s try to summarize the code aboveA Multiplexed Serverthe whole code as follows:FuturesA ReactorScheduling TasksAn Async ServerI make a graph to illustrate all the detail🖱️:A Functional ServerSO we change the handle to be:A Graceful ServerLooking BackBack To RealityPinningasync/awaitA Tokio Server
The first simple webserver code:
webserver’s Problem:
The key insight regarding thread-per-request is that our server is I/O bound. Most of the time inside
handle_connection
is not spent doing compute work, it's spent waiting to send or receive some data across the network. Functions like read
, write
,and flush
perform blocking I/O. We submit an I/O request, yielding control to the kernel, and it returns control to us when the operation completes. In the meantime, the kernel can execute other runnable threads, which is exactly what we want!
But wait, so what’s wrong with threads?
You may have heard that threads are too heavyweight and context switching is very expensive. Nowadays, that's not really true. Modern servers can manage tens of thousands of threads without breaking a sweat(毫不费力地).
The issue is that blocking I/O yields complete control of our program to the kernel until the requested operation completes. We have no say in when we get to run again. This is problematic because it makes it very difficult to model two operations: cancellation, and selection.
Imagine we wanted to implement graceful shutdown for our server. When someone hits ctrl+c, instead of killing the program abruptly, we should stop accepting new connections but still wait for any active requests to complete. Any requests that take more than thirty seconds to finish are killed as the server exits.
This poses a problem when dealing with blocking I/O. Our accept loop blocks until the next connection comes in. We can check for the ctrl+c signal before or after a new connection comes in, but if the signal is triggered during a call to
accept
, we have no choice but to wait until the next connection is accepted. The kernel has complete control over the execution of our program.This poses a problem when dealing with blocking I/O. Our accept loop blocks until the next connection comes in. We can check for the ctrl+c signal before or after a new connection comes in, but if the signal is triggered during a call to
accept
, we have no choice but to wait until the next connection is accepted. The kernel has complete control over the execution of our program.Problems like these are where threads and blocking I/O fall apart. Expressing event-based logic becomes very difficult when the kernel holds so much control over the execution of our program.
There are ways to accomplish this using platform specific interfaces such as Unix signal handlers. While this approach is simple and can work well, signal handlers often become quite cumbersome to work with outside of simple use cases. By the end of the post you'll see another method of expressing complex control flow and decide what is better for your use case.
But what if there was a way to perform I/O without yielding to the kernel?
It turns out there is a second way to perform I/O, known as non-blocking I/O. As the name suggests, a non-blocking operation will never block the calling thread. Instead it returns immediately, returning an error if the given resource was not available.
We can switch to using non-blocking I/O by putting our TCP listener and streams into non-blocking mode.
Non-blocking I/O works a little differently. If the I/O request cannot be fulfilled immediately, instead of blocking, the kernel simply returns the
WouldBlock
error code. Despite being represented as an error, WouldBlock
isn't really an error condition. It just means the operation could not be performed immediately, giving us the chance to decide what to do instead of blocking.Our I/O doesn't block, great! But what do we actually do when something isn't ready?
WouldBlock
is a temporary state, meaning at some point in the future the socket should become ready to read or write from. So technically, we could just spin until the socket becomes ready.thinking…:wait, do we use poll to do that? Just query the status again and again. Well let’s go and say what author say:
But spinning is really just worse than blocking directly. When we block for I/O, the OS gives other threads a chance to run. So what we really need is to build some sort of a scheduler for all of our tasks, doing what the operating system used to handle for us.
we can't just continue serving that connection directly and forget about everyone else. Instead, we have to keep track of all our active connections.
But we can't keep accepting connections forever. We don't have the luxury of OS scheduling anymore, so we need to handle running a little bit of everything in every iteration of the main loop. After trying to
accept
once, we need to deal with the active connections.For each connection, we have to perform whatever operation is needed to move the processing of the request forward, whether that means reading the request, or writing the response.
Let’s say what’s different in handle_connection
:
We perform three different I/O operations,
read
, write
, and flush
. With blocking I/O we could write our code sequentially, but now we have to deal with the fact that at any point when performing I/O, we could face WouldBlock
and won't be able to make progress.
We can't simply drop everything and move on to the next active connection, we need to keep track of its current state in order to resume from the correct point when we come back.We can represent the three possible states of
handle_connection
in an enum.Remember, we don't need separate states for things like converting the request to a string, we only need states for places where we might encounter
WouldBlock
.
The Read
and Write
states also need to hold on to some local state for the request/response buffers and the number of bytes that have already been read/written. These used to be local variables in our function, but now we need them to persist across iterations of our main loop.Connections start in the
Read
state with an empty buffer and zero bytes read, the same variables we used to initialize at the very start of handle_connection
.Now we can try to drive each connection forward from its current state.
If the connection is still in the read state, we can continue reading the request same as we did before. The only difference is that when we receive
WouldBlock
, we have to move on to the next connection.
We also have to deal with the case where we read zero bytes. Before we could simply return from the connection handler and the state would be cleaned up for us, but now we have to remove the connection ourselves. Because we're currently iterating through the connections list, we'll store a separate list of indices to remove after we finish.
And after we successfully flush the response, we can mark the connection as completed and have it removed from the list.
the whole non-blocking code is as follows:
Let’s try to summarize the code above
We eliminate the previous thread blocking part in which the thread would blocking in accept or write or read or flush during the interaction with client. We now change this to a spin, we try to accept the connection every time and if we have a connection ,we will collect it into a connections(as we won’t do spin for a single connection, we want to switch when one connection blocking). And after we make a connection, we will do do read write and flush which are blocking method. We change them to nonblocking and if it didn’t make it the first time, we will save some important message and switch to another connection in connections. The way we save the status of Connection behaves like a state machine where we don’t use separate stack for each of connection(they can just in one thread!)
A Multiplexed Server
Our server can now handle running multiple requests concurrently on a single thread. Nothing ever blocks. If some operation would have blocked, it remembers the current state and moves on to run something else, much like the kernel scheduler was doing for us. However, our new design introduces two new problems.
The first problem is that everything runs on the main thread, utilizing only a single CPU core. We're doing the best we can to use that one core efficiently, but we're still only running a single thing at a time. With threads spread across multiple cores, we could be doing much more.
There's a bigger problem though.
Our main loop isn't actually very efficient.
We're making an I/O request to the kernel for every single active connection, every single iteration of the loop, to check if it's ready. A call to
read
or write
, even if it returns WouldBlock
and doesn't actually perform any I/O, is still a syscall. Syscalls aren't cheap. We might have 10k active connections but only 500 of them are ready. Calling read
or write
10k times when only 500 of them will actually do anything is a massive waste of CPU cycles.
As the number of connections scales, our loop becomes less and less efficient, wasting more time doing useless work.
How do we fix this? With blocking I/O the kernel was able to schedule things efficiently because it knows when resources become ready. With non-blocking I/O, we don't know without checking. But checking is expensive.What we need is an efficient way to keep track of all of our active connections, and somehow get notified when they become ready.
It turns out, we aren't the first to run into this problem. Every operating system provides a solution for exactly this. On Linux, it's called
epoll
.epoll(7)
- I/O event notification facilityThe epoll API performs a similar task topoll(2)
: monitoring multiple file descriptors to see if I/O is possible on any of them. The epoll API can be used either as an edge-triggered or a level-triggered interface and scales well to large numbers of watched file descriptors.
epoll
is a family of Linux system calls that let us work with a set of non-blocking sockets. It isn't terribly ergonomic to use directly, so we'll be using the epoll
crate, a thin wrapper around the C interface.We pass in the file descriptor of the resource we are registering, the TCP listener, along with an
Event
. An event has two parts, the interest flag, and the data field. The interest flag gives us a way to tell epoll which I/O events we are interested in. In the case of the TCP listener, we want to be notified when new connections come in, so we pass the
EPOLLIN
flag.
The data field lets us store an ID that will uniquely identify each resource. Remember, a file descriptor is a unique integer for a given file, so we can just use that. You'll see why this is important in the next step.
Now for the main loop. This time, no spinning. Instead we can call epoll::wait
.epoll_wait(2)
- wait for an I/O event on an epoll file descriptorTheepoll_wait()
system call waits for events on theepoll(7)
instance referred to by the file descriptor epfd. The buffer pointed to by events is used to return information from the ready list about file descriptors in the interest list that have some events available.A call toepoll_wait()
will block until either:
- a file descriptor delivers an event;
- the call is interrupted by a signal handler; or
- the timeout expires.
The fact that
epoll::wait
is "blocking" might put you off, but remember, it only blocks if there is nothing else to do, where previously we would have been spinning and making pointless syscalls. This idea of blocking on multiple operations simultaneously is known as I/O multiplexing.
epoll::wait
accepts a list of events that it will populate with information about the file descriptors that became ready. It then returns the number of events that were added.
here comes the answer why we use fd as data field:Remember when we used the file descriptor for the data field? We can use it to check whether the event is for the TCP listener, which means there's an incoming connection ready to accept:
Now we have to register the new connection in epoll, just like we did the listener.
This time we set both
EPOLLIN
and EPOLLOUT
, because we are interested in both read and write events, depending on the state of the connection.Now that we register connections, we'll get events for both the TCP listener and individual connections. We need to store connections and their states in a way that we can look up by file descriptor.
Instead of a list, we can use a
HashMap
.Before we had to check every single connection to see if something became ready, but now
epoll
handles that for us, so we avoid any useless syscalls.the whole code as follows:
But we still have problems:
Futures
Alright, our server can now process multiple requests concurrently on a single thread. And thanks to epoll, it's pretty efficient at doing so. But there's still a problem.
We got so caught up in gaining control over the execution of our tasks, and then scheduling them efficiently ourselves, that in the process the complexity of our code has increased dramatically.
What went from a simple, sequential accept loop has become a massive event loop managing multiple state machines.
And it's not pretty.
Making our original server multi-threaded was as simple as adding a single line of code in
thread::spawn
. If you think about it, our server is still a set of concurrent tasks, we just manage them all messily in a giant loop.This doesn't seem very scalable. The more features we add to our program, the more complex the loop becomes, because everything is so tightly coupled together.
What if we could write an abstraction like
thread::spawn
that let us write our tasks as individual units, and handle the scheduling and event handling for all tasks in a single place, regaining some of that sequential control flow?
This idea is generally referred to as asynchronous programming.
Let's take a look at the signature of
thread::spawn
.thread::spawn
takes a closure. Our version of thread::spawn
however, could not take a closure, because we aren't the operating system and can't arbitrarily preempt code at will. We need to somehow represent a non-blocking, resumable task.Handling a request is a task. Reading or writing from/to the connection is also a task. A task is really just a piece of code that needs to be run, representing a value that will resolve sometime in the future.
Future
, that's a nice name isn't it.Hmm.. that signature doesn't really work. Having
run
return the output directly means it must be blocking, which is what we're trying so hard to avoid. We instead want a way to attempt to drive the future forward without blocking, like we've been doing with all our state machines in the event loop.
What we're really doing when we try to run a future is asking it if the value is ready yet, polling it, and giving it a chance to make progress.That looks more like it.
Except wait,
poll
can't take self
if we want to call it multiple times, it should probably take a reference. A mutable reference, if we want to mutate the internal state of the task as it makes progress, like ConnectionState
.Alright, imagine a scheduler that runs these new futures.
That doesn't look right.
After initiating the future, the scheduler should only try to call
poll
when the given future is able to make progress, like when epoll returns an event. But how do we know when that happens?If the future represents an I/O operation, we know it's able to make progress when epoll tells us it is. The problem is the scheduler won't know which epoll event corresponds to which future, because the future handles everything internally in
poll
.
What we need is for the scheduler to pass each future an ID, so that the future can register any I/O resources with epoll using the same ID, instead of their file descriptors. That way the scheduler has a way of mapping epoll events to runnable futures.You know, it would be nice if there was a more generic way to tell the scheduler about progress than tying every future to epoll. We might have different types of futures that make progress in other ways, like a timer running on a background thread, or a channel that needs to notify tasks that a message is available.
What if we gave the futures themselves more control? Instead of just an ID, what if we give every future a way to wake itself up, notifying the scheduler that it's ready to make progress?
A simple callback should do the trick.
解释一下:struct Waker(Arc<dyn Fn() + Send + Sync>);
这是在 Rust 中定义一个名为
Waker
的结构体。这个结构体有一个字段,这个字段是一个 Arc
指针,指向一个实现了 Fn() + Send + Sync
trait 的动态类型。Arc
: 这是一个原子引用计数(Atomic Reference Counting)指针,用于在多个线程之间共享数据。
dyn Fn() + Send + Sync
: 这是一个动态类型,表示任何实现了Fn() + Send + Sync
trait 的类型。Fn()
是一个 trait,表示无参数、无返回值的函数或闭包。Send
和Sync
是两个 marker trait,分别表示可以在多个线程之间安全地传递和共享。
The scheduler can provide each future a callback, that when called, updates the scheduler's state for that future, marking it as ready(that’s the waker). That way our scheduler is completely disconnected from epoll, or any other individual notification system.
Waker
is thread-safe, allowing us to use background threads to wake futures. Right now all of our tasks are connected to epoll anyways, but this will come in handy(派上用场) later.A Reactor
Consider a future that reads from a TCP connection. It receives a
Waker
that needs to be called when epoll returns the relevant EPOLLIN
event, but the future won't be running when that happens, it will be idle in the scheduler's queue. Obviously, the future can't wake itself up, someone else has to.
All I/O futures need a way to give their wakers to epoll. In fact, they need more than that, they need some sort of background service that drives epoll, so we can register wakers with it.This service is commonly known as a reactor.
The reactor is a simple object holding the epoll descriptor and a map of tasks keyed by file descriptor, just like we had before. The difference is that instead of the map holding the TCP connections themselves, it holds the wakers.
To keep things simple, the reactor is a thread-local object, mutated through a
RefCell
. This is important because the reactor will be modified and accessed by different tasks throughout the program.The reactor needs to support a couple simple operations.
Adding a task:
And driving epoll.
We'll be running the reactor in a loop, just like we were running epoll in a loop before. It works exactly the same way, except all the reactor has to do is wake the associated future for every event. Remember, this will trigger the scheduler to run the future later, and continue the cycle.
Great, now we have a simple reactor interface.
But all of this is still a little abstract. What does it really mean to call
wake
?Scheduling Tasks
We have a reactor, now we need a scheduler to run our tasks.
One thing to keep in mind is that the scheduler must be global and thread-safe because wakers are
Send
(在 Rust 中,Send
是一个特殊的 trait,它表示一个类型的值可以安全地在多个线程之间传递。如果一个类型实现了 Send
trait,那么它的值可以从一个线程移动(move)到另一个线程), meaning wake
may be called concurrently from other threads.We want to be able to spawn tasks onto our scheduler, just like we could spawn threads. For now, we'll only handle spawning tasks that don't return anything, to avoid having to implement a version of
JoinHandle
.
To start, we'll probably need some sort of list of tasks to run, guarded by a Mutex
to be thread-safe.Remember, futures are only polled when they are able to make progress. They should always be able to make progress at the start, but after that we don't touch them until someone calls
wake
.
There are a couple of ways we could go about this. We could just store a HashMap
of tasks with a status flag that indicates whether or not the task was woken, but that means we would have to iterate through the entire map to find out which tasks are runnable. While this isn't incredibly expensive, there is a better way.
Instead of storing every spawned task in the map, we'll only store runnable tasks in a queue.The types will make sense soon.(
Arc<Mutex<dyn Future<Output = ()> + Send>>
)When a task is spawned, it's pushed onto the back of the queue:
When a task is spawned, it's pushed onto the back of the queue:
The scheduler pops tasks off the queue one by one and calls
poll
:Notice that we don't even really need aMutex
around the task because it's only going to be accessed by the main thread, but removing it would meanunsafe
. We'll settle withtry_lock().unwrap()
for now.
Now for the important bit, the waker. The beautiful part of our run queue is that when a task is woken, it's simply pushed back onto the queue.
This is why the task needed to be reference counted(
Arc<Mutex<dyn Future<Output = ()> + Send>>
) — it's not owned by the scheduler, it's referenced by the queue, as well as wherever the waker is being stored. In fact the same task might be on the queue multiple times at once, and the waker might be cloned all over the place.Once we've dealt with all runnable tasks, we need to block on the reactor until another task becomes ready. Once a task becomes ready, the reactor will call
wake
and push the future back onto our queue for us to run it again, continuing the cycle.Perfect.
...ignoring the
Arc<Mutex<T>>
clutter.Alright! Together, the scheduler and reactor form a runtime for our futures. The scheduler keeps tracks of which tasks are runnable and polls them, and the reactor marks tasks as runnable when epoll tells us something they are interested in becomes ready.
An Async Server
It's time to actually write the tasks that our scheduler is going to run. Like before, we'll use enums as state machines to manage the different states of our program. The difference is that this time, each task will manage it's own state independent from other tasks, instead of having the entire program revolve around a messy event loop.
To start everything off, we need to write the main task. This task will be pushed on and off the scheduler's run queue for the entirety of our program.(这个main task会在scheduler的run queue上 进出,使得我们的整个程序的处理方式统一)
Our task starts off just like before, creating the TCP listener and setting it to non-blocking mode.
Now we need to register the listener with epoll. We can do that using our new
Reactor
.Notice how we give the reactor the waker provided to us by the scheduler. When a connection comes, epoll will return an event and the
Reactor
will wake the task, causing the scheduler to push our task back onto the queue and poll
us again. The waker keeps everything connected.
We now need a second state for the next time we're run, Accept
. The main task will stay in the Accept
state for the rest of the program, attempting to accept new connections.If the listener is not ready, we can simply return
None
. Remember, this tells the scheduler the future is not yet ready, and it will be rescheduled once the reactor wakes us.If we do accept a new connection, we need to again set it to non-blocking mode.
And now we need to spawn a new task to handle the request.
The handler task looks similar to before, but now it manages the connection itself along with its current state, which is identical to
ConnectionState
from earlier.The handler task starts by registering its connection with the reactor to be notified when the connection is ready to read/write to. Again, it passes the waker so that the scheduler knows when to run it again.
The
Read
, Write
, and Flush
states work exactly the same as before, but now when we encounter WouldBlock
, we can simply return None
, knowing that we'll be run again once our future is woken.Notice how much nicer things are when tasks are independent, encapsulated objects?
At the end of the task's lifecycle, it removes its connection from the reactor and returns
Some
. It will never be run again after that point.Perfect! Our new server looks a lot nicer. Individual tasks are completely independent of each other, and we can spawn new tasks just like threads.
I make a graph to illustrate all the detail🖱️:
A Functional Server
With this new future abstraction, our server is much nicer than before. Futures get to manage their state independently, the scheduler gets to run tasks without worrying about epoll, and tasks can be spawned and woken without worrying about any of the lower level details of the scheduler. It really is a much nicer programming model.
It is nice that tasks are encapsulated, but we still have to write everything in a state-machine like way. Granted, Rust makes this pretty easy to do with enums, but could we do better?
Looking at the two futures we've written, they have a lot in common. Each future has a number of states. At each state, some code is run. If that code completes successfully, we transition into the next state. If it encounters
WouldBlock
, we return None
, indicating that the future is not yet ready.This seems like something we can abstract over.
What we need is a way to create a future from some block of code, and a way to combine two futures, chaining them together.
Given a block of code, we need to be able to construct a future... sound like a job for a closure?
The closure probably also needs to mutate local state.
about the difference of FnMut:Fn:FnOnce:
‣
And it also needs access to the waker.
And.. it needs to return a value. An optional value, in case it's not ready yet. In fact, we can just copy the signature of
poll
, because that's really what this closure is.Implementing
poll_fn
doesn't seem too hard, we just need a wrapper struct that implements Future
and delegates poll
to the closure.Alright. Let's try reworking the main task to use the new
poll_fn
helper. We can easily stick the code of the Main::Start
state into a closure.Remember,
Main::Start
never waits on any I/O, so it's immediately ready with the listener.We can also use
poll_fn
to write the Main::Accept
future.On the other hand,
accept
always returns None
because we want it to be called every time a new connection comes in. It runs for the entirety of our program.We have our two task states, now we need a way to chain them together.
Hm, that doesn't really work.
The second future will need to access the output of the first, the TCP listener.
Instead of chaining the second future directly, we have to chain a closure over the first future's output. That way the closure can use the output of the first future to construct the second.
That seems better.
We might as well be fancy and have
chain
be a method on the Future
trait. That way we can call .chain
as a postfix method on any future.That looks right, let's try implementing it!
The
Chain
future is a generalization of our state machines, so it itself is a mini state machine. It starts off by polling the first future, holding onto the transition closure for when it finishes.Once the first future is finished, it constructs the second future using the
transition
closure, and starts polling it:Notice how the same
waker
is used to poll both futures. This means that notifications for both futures will be propagated to the Chain
parent future, depending on its state.Hm... that doesn't actually seem to work:
Oh right,
transition
is an FnOnce
closure, meaning it is consumed the first time it is called. We only ever call it once based on our state machine, but the compiler doesn't know that.
We can wrap it in an
Option
and use take
to call it, replacing it with None
and allowing us to get an owned value. This is a common pattern when working with state machines.Perfect. Now the
chain
method can simply construct our Chain
future in it's starting state.Alright. Where were we... oh right, the main future!
We can combine the two futures using our new
chain
method:Huh, that seems really nice! Gone is our manual state machine, our listen method can now be expressed in terms of simple closures!
It's too good to be true!
We can convert the connection handler to a closure-based future just like we did with the main one. To start we'll separate it out into a function that returns a
Future
.The first state,
HandlerState::Start
, is a simple poll_fn
closure that registers the connection with the reactor and immediately returns.The second state,
HandlerState::Read
, can be chained on quite easily. It initializes its local request state on the stack and moves it into the future, allowing the future to own its state.HandlerState::Write
and HandlerState::Flush
can be chained on the same way.It's perfect.
Uhhhh...
Hmm....
All of our futures use
move
closures, meaning they take ownership of the connection. There can only be one owner of the connection though. Guess they shouldn't be move
closures?That doesn't seem to work either. The
connection
needs to live somewhere. What if we only move it into the first future, and have the rest of the futures borrow it?Nope, that doesn't work either.
Under the hood(在内部工作过程中), our chained futures look something like this. The first future owns the connection, and the rest borrow from it.
SO we change the handle to be:
A Graceful Server
Whew, that was a lot.
One last thing before we finish. To put our task model to the test, we can finally implement the graceful shutdown mechanism we discussed earlier.
Imagine we wanted to implement graceful shutdown for our server. When someone hits the keys ctrl+c, instead of killing the program abruptly, we should stop accepting new connections and wait for any active requests to complete. Any requests that take more than 30 seconds to handle are killed as the server exits.
There are a couple things we have to do to set this up. Firstly, we have to actually detect the signal. On Linux, ctrl+c triggers the
SIGINT
signal, so we can use the signal_hook
crate to wait until the signal is received.Looking Back
Well, that was quite the journey.
Our server is looking pretty good now. From threads, to an epoll event loop, to futures and closure combinators, we've come a long way. There is some manual work that we could abstract over even further, but overall our program is relatively clean.
Compared to our original multithreaded program, our code is still clearly more complex. However, it's also a lot more powerful. Composing futures is trivial, and we were able to express complex control flow that would have been very difficult to do with threads. We can even still call out to blocking functions without interrupting our async runtime.
There must be a price to pay for all this power, right?
Back To Reality
Now that we've thoroughly explored concurrency and async ourselves, let's see how it works in the real world.
The standard library defines a trait like
Future
, which looks remarkably similar to the trait we designed.However, there are a few noticeable differences.
The first is that instead of a
Waker
argument, poll
takes a &mut Context
. It turns out this isn't much of a difference at all, because Context
is simply a wrapper around a Waker
.And
Waker
, along with a few other utility methods, has the familiar wake
method.Constructing a
Waker
is a little more complicated, but it's essentially a manual trait object just like the Arc<dyn Fn()>
we used for our version. All of that happens through the RawWaker
type, which you can check out yourself.
The second difference is that instead of returning an Option
, poll
returns a new type called Poll
.. which is really just a rebranding(重命名) of Option
.The final difference is a little more complicated.
Pinning
Instead of
poll
taking a mutable reference to self
, it takes a pinned mutable reference to self — Pin<&mut Self>
.What is
Pin
, you ask?Huh. That doesn't seem very useful.
It turns out, what makes
Pin
special is how you create one:在Rust中,type
关键字用于定义一个类型别名。在这个上下文中,type Target = P::Target;
定义了一个名为Target
的类型别名,它等于P::Target
。Deref
是Rust的一个trait,它用于重载解引用运算符*
。Deref
trait有一个关联类型Target
,这个类型表示解引用后的类型。在你给出的代码中,impl<P: Deref> Deref for Pin<P>
表示为Pin<P>
类型实现Deref
trait,其中P
是任何实现了Deref
trait的类型。在这个实现中,type Target = P::Target;
表示Pin<P>
解引用后的类型就是P
解引用后的类型。例如,如果P
是Box<String>
,那么P::Target
就是String
,所以Pin<Box<String>>
解引用后的类型也是String
。
So you can only create a
Pin<&mut T>
safely if T
is Unpin
... what's Unpin
?Unpin
seems to be automatically implemented for all types except PhantomPinned
. So creating a Pin
is safe, except for types that contain PhantomPinned
? And Pin
just dereferences to T
normally? All of this seems a little useless.
There is a point to it all though, and it goes back to a problem we ran into earlier. Remember when we tried creating a self-referential struct to hold our task state but it wouldn't work, so we ended up having to allocate our task state with an Arc
? It was a bit unfortunate, and it turns out that you actually can create self-referential structs with a little bit of unsafe code, and avoid that Arc
allocation.
The problem is that you can't just go handing out a self-referential struct in general, because as we realized, moving a self-referential struct breaks its internal references and is unsound.
This is where
Pin
comes in. You can only create a Pin<&mut T>
if you guarantee that the T
will stay in a stable location until it is dropped, meaning that any self-references will remain valid.
For most types, Pin
doesn't mean anything, which is why Unpin
exists. Unpin
essentially tells Pin
that a type is not self-referential, so pinning it is completely safe and always valid. Pin
will even hand out mutable references to Unpin
types and let you use mem::swap
or mem::replace
to move them around. Because you can't safely create a self-referential struct, Unpin
is the default and implemented by types automatically.
If you did want to create a self-referential future though, you can use the
PhantomPinned
marker struct to make it !Unpin
. Pinning a !Unpin
type requires unsafe
, so because poll
requires Pin<&mut Self>
, it cannot be called safely on a self-referential future.
Notice that you can move around the future all you want before pinning it because the self-references are only created after you first call
poll
. Once you do pin it though, you must uphold the Pin
safety contract.
There are a couple safe ways of creating a pin though, even for
!Unpin
types.The first way is with
Box::pin
.At first glance this may seem unsound, but remember,
Box
is an allocation. Once the future is allocated it has a stable location on the heap, so you can move around the Box
pointer all you want, the internal references will remain stable.
The second way you can safely create a pin is with the pin!
macro.
With
pin!
, you can safely pin a struct without even allocating it! The trick is that pin!
takes ownership of the future, making it impossible to access except through the Pin<&mut T>
, which remember, will never give you a mutable reference if T
isn't Unpin
. The T
is completely hidden and thus safe from being tampered with.Pin
is a common point of confusion around futures, but once you understand why it exists, the solution is pretty ingenious.async/await
Alright, that's the standard
Future
trait.So how do we use it?
The
futures
crate is where all the useful helpers live. Functions like poll_fn
that we wrote before, and combinators like map
and and_then
, which we called chain
.But even with these helpers, as we found out, it's a little cumbersome to write async code. It's still a shift from the simple synchronous code we're used to. Maybe not as drastic as a manual
epoll
event loop, but still a big change.
It turns out there's actually another way to write futures in Rust, with the async/await syntax.Instead of using
poll_fn
to create futures, you can attach the async
keyword to functions:An async function is really just a function that returns an async block:
Which is really just a function that returns a
poll_fn
future:The magic comes with the
await
keyword. await
waits for the completion of another future, propagating Poll::Pending
until the future is resolved.
Under the hood, the compiler transforms this into manual state machines, similar to the ones we created with those combinators:
Which, as we know all too well, translates into a huge manual state machine that looks something like this:
... but async/await removes all of that headache. Of course, we aren't actually doing any I/O here so the futures are mostly useless, but you can imagine how helpful this would be for our web server.
In fact, it's even better than the combinators. With
async
functions, you can hold onto local state across await
points!
After going through implementing futures ourselves, we can really appreciate the convenience of this. Under the hood the compiler has to generate a self-referential future to give
bar
and baz
access to the state.
The compiler takes care of all the unsafe code involved in this, allowing us to work with local state just like we would in a regular function. For this reason, the futures generated by
async
blocks or functions are !Unpin
.async/await removes any complexity that remained with writing futures compared to synchronous code. After implementing futures manually, it almost feels like magic!
A Tokio Server
So far we've only been looking at how
Future
works, we haven't discussed how to actually run one, or do any I/O. The thing is, the standard library doesn't provide any of that, it only provides the bare essential types and traits to get started.If you want to actually write an async application, you have to use an external runtime. The most popular general purpose runtime is
tokio
. tokio
provides a task scheduler, a reactor, and a pool to run blocking tasks, just like we wrote earlier, but it also provides timers, async channels, and various other useful types and utilities for async code. On top of that, tokio
is multi-threaded, distributing async tasks to take advantage of all your CPU cores. The core ideas behind tokio
are very similar to the async runtime we wrote ourselves, but you can read more about it's design in this excellent blog post.- 作者:liamY
- 链接:https://liamy.clovy.top/article/OScamp_prj6_pre03
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。