tokio-rs / tokio

A runtime for writing reliable asynchronous applications with Rust. Provides I/O, networking, scheduling, timers, ...
https://tokio.rs
MIT License
26.69k stars 2.46k forks source link

Consider exposing simulation's types in Tokio #1845

Closed davidbarsky closed 10 months ago

davidbarsky commented 4 years ago

Despite the relatively limited use that tokio-rs/simulation has seen so far, @gardnervickers has been able to reproduce several complex bugs in H2 and Tonic. To make tokio-rs/simulation more accessible to more people, I think it's worth opening a discussion on re-exporting simulation's networking types from Tokio. I believe (and I might be misquoting @gardnervickers!) that the desired end state for this feature would enable a library like Hyper to be deterministically simulated just by changing which feature flags are enabled within Tokio.

To enable this, I think that Tokio will need to make the following changes:


A discussion between @gardnervickers, @carllerche, and I covering how this would be implemented. It's slightly messy, I apologize about that.

gvickers: I've walked back a bit lately on having the Environment handle. I think eventually we'll just want to have a way to swap TcpStream/TcpListener implementation in Tokio, like how we do with tokio_timer::Now/Delay/Timeout today.
[4:15 PM] davidbarsky: yeah, i see why it's clone right now—spawn requires 'static, so it has to be an owned value
[4:15 PM] davidbarsky: oh, can you say more about that?
[4:20 PM] gvickers: Tokio today allows you to use
tokio_timer::timer::set_default and tokio_timer::clock::with_default to setup
the internal clock used by tokio_timer. The DeterministicRuntime sets these to
the mock clock, so you don't actually need to use the Environment handle to
create Delay's or query simulation time with tokio::clock::now(). I want to have
something similar for TcpStream/TcpListener, where we can use
tokio::reactor::with_default() to set a global which can be used to create
TcpStream/TcpListener instances backed by the simulation library's network.
[4:21 PM] davidbarsky: oh, neat, yeah.
[4:21 PM] gvickers: This should let us use existing libs without too many
changes, much in the same way that existing libs which create timeouts with
tokio::timer::Timeout can work with Simulation today.
[4:25 PM] gvickers: This will probably require something like task locals though in order to scope TcpListener binds to a virtual net::SocketAddr.
[4:25 PM] gvickers: Also, to give some sort of virtual filesystem/namespacing to files
[4:26 PM] gvickers: Luckily it doesen't have to be fast or anything, so just installing a thread local at the start of every task poll should be sufficient if I understand correctly.
[4:28 PM] davidbarsky: makes sense. this would require hooks into tokio, correct?
[4:29 PM] gvickers: Yes, I think something like tokio::reactor::with_default
should be sufficient. Carl mentioned a while back that the timer hooks are going
away temporarily for the new Tokio release though, so things might change around
that.
[4:39 PM] davidbarsky: @gvickers small PR that makes it slightly more "idiomatic": https://github.com/tokio-rs/simulation/pull/9
GitHub
slightly more idiomatic rust by davidbarsky · Pull Request #9 · ...

[4:39 PM] gvickers: Nice, thank you!
[4:40 PM] davidbarsky: (take it or leave it! no need to accept it)
[4:42 PM] gvickers: I see this used a bit, type Err = Box<dyn std::error::Error + Send + Sync + 'static>;
Is the pattern to have that as the top level error type for rust applications?
[4:55 PM] davidbarsky: for examples, I like to place a boxed error up there.
[4:56 PM] davidbarsky: it's kinda like a RuntimeException; don't really care too much about what the specific error is.
[4:56 PM] davidbarsky: so to answer for your question—I think it is, for smaller applications.
[4:56 PM] davidbarsky: for bigger ones, a proper enum variant.
[4:56 PM] davidbarsky: and I see what you mean by relying on Tokio's plumbing: https://tokio-rs.github.io/tokio/doc/src/tokio/runtime/handle.rs.html#11-25
handle.rs.html -- source
Source to the Rust file `tokio/src/runtime/handle.rs`.
[5:00 PM] gvickers: Yea, I have no idea how to do it but I think it should be possible to have TcpStream/TcpListener creation dispatch to alternate internal implementations.
[5:03 PM] davidbarsky: I think some additional builder methods on
https://tokio-rs.github.io/tokio/doc/tokio/runtime/struct.Builder.html that let
you specify with_timer, with_clock (maybe last two can be collapsed?),
with_io_handle, with_spawner, etc. should do the trick.
tokio::runtime::Builder - Rust
API documentation for the Rust `Builder` struct in crate `tokio`.
[5:04 PM] gvickers: Yea that would be ideal
[5:05 PM] davidbarsky: not sure how FaultyTcpStream can be exposed, though
[5:05 PM] gvickers: The DeterministicRuntime in simulation pretty much just copies the CurrentThread runtime
[5:06 PM] gvickers: I think FaultyTcpStream could just be made to provide the backing implementation for TcpStream.
[5:06 PM] davidbarsky: (cc: @Carl Lerche, if you're online)
[5:06 PM] davidbarsky: hmm, yeah. i think see what you mean.
[5:07 PM] gvickers: My main concern is if there would be a performance impact,
but I guess each TcpStream/TcpListener now has to lookup and see if there is a
backing reactor to register with anyway, so maybe not too bad?
[5:09 PM] davidbarsky: in test or production?
[5:09 PM] davidbarsky: but yeah, i think this is something that can be benchmarked
[5:12 PM] gvickers: I guess it would be in both test and production. I think at the least it would incur a vtable lookup on poll for both TcpStream and TcpListener.
[5:19 PM] Carl Lerche: @gvickers there is a feature flag to enable this kind of stuff
[5:19 PM] Carl Lerche: test-util
[5:19 PM] Carl Lerche: Look at how time freezing is done on master now
[5:28 PM] gvickers: Right, but in order to support simulation the mock time
model on master will need to be changed a bit (or hooks introduced into the
runtime to drive it). I suppose the test-util feature flag probably couldn't be
overloaded to support both simulation and normal unit tests, since the time
models are different.
[6:05 PM] Carl Lerche: @gvickers you can add hooks in the runtime for testing that are enabled w/ test-util
[6:05 PM] Carl Lerche: @gvickers my main point is that it is ok to have "overhead" (minimal) to support testing, we just enable it w/ the feature flag
[6:05 PM] davidbarsky: would you accept a PR soonish, or only after 0.2 is out?
[6:05 PM] Carl Lerche: not until after 0.2
[6:06 PM] davidbarsky: makes sense.
[6:06 PM] Carl Lerche: basically, make a proposal
[6:09 PM] davidbarsky: sounds good. @gvickers do you have a sense as to how you'd like this API to look like?
[6:18 PM] gvickers: I think for the timer implementation, to replace Park we
need a runtime hook which fires when no task can make progress. The timer should
also allow accessing the Instant at which the next future can progress. These
two hooks can then be used to advance the clock with advance.
[6:21 PM] gvickers: As for the TcpStream/TcpListener API, I'm not sure. All the simulation really needs there is to provide alternate AsyncRead + AsyncWrite implementations and peer_addr()/local_addr().
[6:41 PM] davidbarsky: makes sense. I think it'll be possible to have the "same" TCPStream/TCPListener re-exported under the same path/name but activated under a different feature flag
[6:42 PM] davidbarsky: so, [cfg[not("simulated")]]—or whatever the syntax is—would export the FaultyVariant that's in simuation
[6:42 PM] davidbarsky: I don't know if that's what you have in mind.
[6:45 PM] gvickers: I'm not entirely sure what the limitations of compile flags
are, but the goal would be for libraries like Hyper which call TcpListener::bind
to work under simulation.
gardnervickers commented 4 years ago

Thanks @davidbarsky for capturing this, I have written out a few more details on how we could integrate tokio-rs/simulation with Tokio.

Simulation Testing for Tokio

tokio-rs/simulation is an initial POC bringing deterministic simulation testing support to tokio 0.2.0-alpha.6.

The way it currently works is by providing an Environment trait to users. Applications are made generic over the Environment trait.

    #[async_trait]
    pub trait Environment: Unpin + Sized + Clone + Send + 'static {
        type TcpStream: TcpStream + Send + 'static + Unpin;
        type TcpListener: TcpListener + Send + 'static + Unpin;

        /// Spawn a task on the runtime provided by this [`Environment`].
        fn spawn<F>(&self, future: F)
        where
            F: Future<Output = ()> + Send + 'static;

        /// Return the time now according to the executor.
        fn now(&self) -> time::Instant;

        /// Returns a delay future which completes after the provided instant.
        fn delay(&self, deadline: time::Instant) -> tokio_timer::Delay;

        /// Returns a delay future which completes at some time from now.
        fn delay_from(&self, from_now: time::Duration) -> tokio_timer::Delay {
            let now = self.now();
            self.delay(now + from_now)
        }

        /// Creates a timeout future which which will execute T until the timeout elapses.
        fn timeout<T>(&self, value: T, timeout: time::Duration) -> tokio_timer::Timeout<T>;

        /// Binds and returns a listener which can be used to listen for new connections.
        async fn bind<A>(&self, addr: A) -> io::Result<Self::TcpListener>
        where
            A: Into<net::SocketAddr> + Send + Sync;

        /// Connects to the specified addr, returning a [`TcpStream`] which can be
        /// used to send and receive bytes.
        ///
        /// [`TcpStream`]:`TcpStream`
        async fn connect<A>(&self, addr: A) -> io::Result<Self::TcpStream>
        where
            A: Into<net::SocketAddr> + Send + Sync;
    }

The desired outcome of this is an application which is generic over sources of nondeterminism, such as parallel scheduling, network and time. This works in practice for simple applications, and existing libraries such as Hyper which allow parameterization of the executor and TcpListener/TcpStream types can be adapted to use the Environment trait.

However, while this can be made to work in some cases, there are 3rd party libraries for which this is not possible. It is also cumbersome to have to pass the Environment trait through entire application. Lastly, changes to the public API in tokio 0.2.0 have removed some hooks which tokio-rs/simulation relied upon, motivating this proposal.

Proposal

We propose adding simulation support directly to Tokio via a feature flag. This proposal assumes the feature flag will be the existing test-util flag, but that can be decided separately.

There are types which will need to be mocked when the feature flag is enabled.

Mocks

Scheduling

When the feature flag is enabled, all runtime creation will use the basic_scheduler. The remote queue of the basic scheduler will be effectively disabled as scheduling tasks from remote threads would introduce nondeterminism. We could either log a warning or panic if scheduling from a remote thread was attempted.

By disabling the remote queue, it appears that the basic_scheduler will execute tasks in a deterministic order between program runs.

Each spawned task will be wrapped in a top-level task which will provide metadata about the task, and allow for manipulating the work done by the task. Metadata includes things like a logical location (datacenter/rack/machine), as well as hostname and IP address. On each poll invocation, the task metadata will be set on a well-known thread-local for other components to pickup. The task metadata can be used by fault injection, networking and disk IO to uniquely identify the task.

Tasks which are spawned via `tokio::spawn` will automatically inherit the parents task metadata.

The task metadata will be optional to begin with, but in the future we would like to support users specifying it explicitly when setting up a simulation run.

    #[tokio::simulation]
    fn simulation(seed: u64) {
      tokio::simulation::spawn_process(
        "us-east-1a", "rack-1", "server-1.svc.cluster.local", spawn_server()
      );
      tokio::simulation::spawn_process(
        "us-east-1b", "rack-2", "server-2.svc.cluster.local", spawn_server()
      );
      tokio::simulation::spawn_process(
        "us-east-1c", "rack-3", "server-3.svc.cluster.local", spawn_server()
      );
      tokio::spawn(tokio::simulation::clog_connections("us-east-1c", "us-east-1b"));

      verify_linearizability().await;
    }

Time

The mock clock will be advanced automatically by the executor whenever Park::park_timeout is called. It will be advanced for exactly the std::time::Duration passed to Park::park_timeout. This provides two desirable properties for simulation testing.

  1. Time is only advanced once there are no tasks which can make forward progress. This couples time to task execution, allowing for users to pinpoint the timestep where a regression occurs for gating things like logging or bug fixes on a timestamp.
  2. Ordering/prioritization can be naturally expressed through the use of Timeout/Delay. It's guaranteed that all tasks waiting on later timeouts execute before tasks which are waiting on earlier timeouts.

Networking

When the feature flag is enabled, the TcpStream and TcpListener types will be swapped out for types which register themselves with a process-global in-memory networking implementation. The existing TcpStream will wrap something akin to this SimulatedTcpStream type, and the existing TcpListener will return TcpStream's which wrap the SimulatedTcpStream type. The ToSocketAddrs implementations which are gated behind the DNS feature flag will be swapped out to use the global in-memory networking implementation for DNS lookups. DNS will be derived from the task metadata.

    pub struct SimulatedTcpStream {
        tx: mpsc::Sender<Bytes>,
        rx: mpsc::Receiver<Bytes>,
        staged: Option<Bytes>,
        shutdown: bool,
        local_addr: net::SocketAddr,
        peer_addr: net::SocketAddr,
    }

TcpStream::bind will register a new connection queue with the global networking object, allowing subsequent TcpStream::connect calls to enqueue connection objects. TcpStream::bind will use the task metadata when binding, allowing for DNS resolution to occur.

Disk IO

Like networking, disk IO will be done using an in-memory mock filesystem. The mock filesystem will be namespaced by task metadata.

This is not needed initially, and has not been explored yet with tokio-rs/simulation. However, I'm including it in this proposal for completeness.

Faults

Beyond providing a deterministic environment for executing simulations, it's also desirable to expose an API for fault injection. Scheduling, networking and disk IO all depend on a process-global state store to back the simulated types they expose.

We can take advantage of this global state to also support an API for fault injection. Targeted faults can be injected based on the spawned task metadata by user tasks. This allows for users to write fault injectors as normal async tasks.

    async fn swizzle_clog_fault() {
      loop {
        let connections: Vec<_> = tokio::simulation::get_connections();

        for connection in connections {
          connection.clog();
          warn!("clogging connection {:?}", connection);
          tokio::timer::delay_from(time::Duration::from_secs(10)).await;
        }

        warn!("waiting 30s before unclogging connections {:?}", connection);
        tokio::timer::delay_from(time::Duration::from_secs(30)).await;

        for connection in connections {
          connection.unclog();
          warn!("unclogging connection {:?}", connection);
          tokio::timer::delay_from(time::Duration::from_secs(10)).await;
        }
      }
    }

Some of the faults that will be supported:

LucioFranco commented 4 years ago

This is a fantastic write-up @gardnervickers! I am super excited to start seeing some of this stuff being used.

I think overall the proposal is very very solid. The one area I am a bit fuzzy on is how we integrate with tokio directly. It may make sense to explore providing an enumed TcpStream that matches the tokio api 1-1 that lives within the simulation crate. This way we could move away from the giant Environment trait and explore what it might look like to have some of this code live within tokio and enabled via a feature flag.

ultrasaurus commented 4 years ago

I can't speak to the details of the tokio integration, but in general this seems like a great direction. It certainly would be very valuable for tokio to have good support for in-memory testing (or every developer or protocol implementor would need to separately implement their own).

Will try to follow along!

Darksonn commented 10 months ago

Since simulation has been archived, I'll close this.