My implementation of "Build a Stock-Tracking CLI with Async Streams in Rust" - The Actor Model
Stock-Tracking CLI with Async Streams
Two-Project Series: Build a Stock-Tracking CLI with Async Streams in Rust
The Original Description
In this liveProject, you’ll use some of the unique and lesser-known features of the Rust language to finish a prototype of a FinTech command line tool.
You’ll step into the shoes of a developer working for Future Finance Labs, an exciting startup that’s aiming to disrupt mortgages, and help out with building its market-tracking backend.
Your CTO has laid out the requirements for the minimum viable product, which must quickly process large amounts of stock pricing data and calculate key financial metrics in real time.
Additional Description
- The goal is to fetch all S&P 500 stock data from
- Data is fetched from the date that a user provides as a CLI argument to the current moment.
- Users also provide symbols (tickers) that they want on the command line.
- The fetched data include OLHC data (open, low, high, close prices), timestamp and volume, for each symbol.
- The data that we extract from the received data are minimum, maximum and closing prices for each requested symbol,
- As can be seen, the calculations performed are not super-intensive.
- We are implementing MUCH MORE than is required of us in the project description!
- The goal is to experiment:
CSV file,
- with async/await combined with Actors,
- with the Publisher/Subscriber model for Actors,
- with single-threaded implementation,
- with multithreaded implementation (with various libraries),
- with various combinations of the above.
- Some of that was suggested by the project author, and some of it (a lot of it) was added on own initiative.
- The goal was also to create a web service for serving the requests for fetching of the data.
- We measured execution time for various implementations.
Implementation Notes and Conclusions
Here follow implementation details and conclusions.
We also included comparison of different implementations.
Synchronous Single-Threaded Implementation
- We started with synchronous code, i.e., with a blocking implementation.
- The implementation was single-threaded.
- The yahoofinance_api crate that we use supports the blocking feature.
- This implementation was slow.
- Writing to file was not implemented at this stage.
Asynchronous Single-Threaded Implementation
- Then we moved to a regular (sequential, single-threaded)
asyncversion.
84 seconds for it to complete.
- Then we upgraded the main loop to use explicit concurrency with
async/awaitparadigm.
Future concurrently.
We are using the futures crate to help us achieve
this: futures::future::join_all(queries).await;.
This uses the waiting time more efficiently.
This is not multithreading.
This can increase the program's I/O throughput dramatically, which may be needed to keep the strict schedule with an
async stream (that ticks every n seconds, where n = 30 by default), without having to manage threads or data
structures to retrieve results.
We fetch the S&P 500 data every n seconds.
- This way it takes the program less than 2 seconds to fetch all S&P 500 data, instead of 84 seconds as
before, on the same computer and at the same time of the day.
- That's a speed-up of around 50 times on the computer.
- This further means that we can fetch data a lot more frequently than the default 30 seconds.
- Using the same explicit concurrency with
async/awaitparadigm but with configurable chunk size gives more or less
handlesymboldata() still processes one symbol, as
before.
- Namely, whether chunk size of symbols is equal 1 or 128, all symbols are processed in around 2.5 seconds.
- The function handlesymboldata() is a bottleneck in this case.
- If we modify
handlesymboldata()to take and process multiple symbols at the time, i.e., to work with chunks of
~2.5 s.
- For chunk size equal 3, it is around 1.3 s!
- For chunk size equal 5 or 6, it is around 1.2 s! Chunk size of 5 seems like a sweet-spot for this application
with this implementation.
- For chunk size equal 2 or 10, it is around 1.5 s.
- For chunk size equal 128, it rises to over 13 s!
- Thanks to the fact that the calculations performed on the data are not super-intensive, we can conclude
async/await can be fast enough, even though it's generally mostly meant for I/O.
- Namely, the calculations are light, and we are doing a lot of them frequently.
We have around 500 symbols and several calculations per symbol.
The data-fetching and data-processing functions are all asynchronous.
We can conclude that scheduling a lot of lightweight async tasks on a loop can increase efficiency.
- Fetching data from a remote API is an I/O-bound task, so it is all in the relative ratio between fetching and
processing of the data.
- It is probably the case that the I/O part dominates the CPU part in this application, and consequently
the async/await paradigm is a good choice in this case.
- We are using
asyncstreams for improved efficiency (asyncstreaming on a schedule). - Unit tests are also asynchronous.
- Writing to file was not implemented at this stage.
Asynchronous Multithreaded Implementation
- Using the rayon crate for parallelization keeps execution time at around
2.5
futures::future::join_all(queries).await; to join all tasks.
- Using
rayonwith chunks yields the same results as above implementation with chunks.
1.0 s! Chunk size of 5 again seems like a sweet-spot for this
application with this implementation.
- All CPU cores are really being employed (as can be seen in Task Manager).
- Using
rayonis easy. It has parallel iterators that support chunking. We can turn our sequential code into
rayon easily.
- We also implemented classical multithreading with
tokio::spawn().
once_cell::sync::OnceCell.
It is needed to initialize the variable symbols that holds symbols that a user provides on the command line.
- Starting with Rust 1.70.0,
we can use the standard library's std::sync::OnceLock
instead of once_cell::sync::OnceCell with same results.
- The functionality has been ported from the crate to the standard library.
- Note: This implementation doesn't employ rayon.
- Performance is the same as with explicit concurrency with async/await or with rayon.
- The sweet spot for the chunk size is again 5, and it yields execution time of 1.2 s.
- Wrapping
symbolsinstd::sync::Arcand usingstd::thread::scopeprovides a working solution that is almost as
- We can conclude that
rayonandTokioprovide equal performance in our case, and that the standard library
- A higher CPU utilization can be observed with chunk size of 5 than with chunk size of 128, for example, which is good,
- All measurements were performed with the 505 S&P symbols provided.
- If only 10 symbols are provided, instead of 505, then the fastest solution is with chunk size of 1, around
250ms.
600 ms.
- Chunk sizes of 10 or 128 are very slow, over 1 s!
- We are probably limited by the data-fetching time from the Yahoo! Finance API. That's probably our bottleneck.
getquotehistory() fetches data for one symbol
(called "ticker") at a time. It is asynchronous, but perhaps this is the best that we can do.
- Writing to file had not been implemented at this stage.
- When we added writing to file, using
rayonwith chunk size equal 5 yielded execution time of1.0! - With writing to file, using
Tokiowith chunk size equal 5 yields execution time of0.9s! - TODO: With writing to file,
stdlibwith chunk size equal 5 yields execution time of ??? s! - TODO/SKIP: With writing to file, using
crossbeamwith chunk size equal 5 yields execution time... - We used a barrier to write all results, from all threads, to the file at once.
- TODO: We also implemented writing individual chunks, by individual threads, to the file, by using a lock.
Synchronous Multithreaded Implementation: TODO
- TODO: Using
rayonwith chunk size equal 5 yields execution time equal1second! - TODO: Using
crossbeamwith chunk size equal 5 yields execution time...
crossbeam is best suited to CPU-bound tasks, while our task is I/O bound, so we could skip it this time around.
- TODO: Using
stdlibthreading with chunk size equal 5 yields execution time... - Writing to file was implemented at this stage. It requires synchronization, as multiple threads write to the same
Asynchronous Multithreaded Implementation with Actors
- We introduced
Actors(the Actor model). - This can be a fast solution to this problem, even with one type of actor that performs all three operations
- Having only one Actor doesn't make sense, but we started with one with the intention to improve from there.
- We initially kept the code asynchronous.
async code, i.e., ~80-90 seconds, when actors were
processing one symbol at a time (when a request message contained only one symbol to process). This applies in
case of the actix crate with a single MultiActor that does all three operations., and in case of three
actors when using the xactor crate.
- When we increased the chunk size to 128, the MultiActor performance with actix improved a lot, enough for
it to fit in the 30-second window.
- Interestingly, reducing the chunk size back to 1 now, in this implementation, was able to put the complete
execution in a 5-second slot, possibly in even less than 3 s.
- Making chunk size equal 5 or 10 reduced execution time to 1.5-2 s.
- We then moved on to a Two-Actor asynchronous implementation.
stdout.
- We only implemented actors that process one symbol at a time, i.e., not in chunks.
- We tried with a single instance of the FetchActor and multiple instances of ProcessorWriterActor, as well
as with multiple instances of both types of Actor. The number of instances was equal to the number of symbols.
- In either case, FetchActor spawns ProcessorWriterActor and sends it messages with fetched data.
- The two cases have the same performance, which is around 2.5 s.
- We tried without and with rayon.
- Neither implementation is ordered, meaning output is not in the same order as input.
- The rayon implementation uses multiple instances of both types of Actor, and has the same performance as
the non-rayon implementation, i.e., ~2.5 s.
- The Three-Actor implementation has the
ProcessorWriterActorsplit into two actors, for the total of three
FetchActor is responsible for fetching data from the Yahoo! Finance API.
- The ProcessorActor calculates performance indicators and prints them to stdout.
- The WriterActor writes the performance indicators to a CSV file.
- As for performance, the execution time is around 2.5 s without chunks.
- Each actor works with a single symbol.
- There are as many FetchActors and ProcessorActors as there are symbols.
- If we introduce chunks, the performance increases.
- We worked with chunk size equal 5.
- There are as many FetchActors and ProcessorActors as there are chunks.
- The execution time was possibly below 2 seconds.
- If the WriterActor also works with chunks (of the same size and with the same amount of them), the execution
time is again below 2 seconds, but possibly even shorter than in the previous case, i.e., around 1.5 s,
making it a very fast solution.
- This includes flushing of the buffer to file with every chunk, which makes it possible to write all rows
to the file, and to still get an even better performance in terms of execution speed.
- We are using std::io::BufWriter to improve the write performance. It wouldn't make sense to flush the
buffer unless we worked with chunks of symbols, i.e., it wouldn't make sense to flush it for a single
symbol, although, that would make the solution correct because it would write all rows to the file.
Still, to have both good performance and a correct solution, we should use chunks and flush the buffer
for every chunk.
- Using rayon is at least equally fast, but probably not faster.
- This implementation writes to a file, unlike previous implementations, so it is expected that its performance
is slightly worse because of that.
- With async code it was not possible to have the WriterActor write out all ~500 rows, i.e., performance
indicators for all symbols, in the file, if we only flushed when stopping the actor, i.e., in its stopped
method.
- Namely, we stop the main loop, which is infinite, by interrupting program by pressing CTRL+C, so
the stopped method doesn't have a chance to get executed and flush the buffer.
- Increasing the WriterActor's mailbox size didn't help.
- The course-author's xactor-based solution also wasn't able to write all rows to the file, but they only
flush the buffer in the actor's stopped method.
- By adding flushing of the buffer to the file in the WriterActor's handle method, we are able to solve this
issue.
- We do this in case the WriterActor also works with chunks.
- All ~500 rows do get printed to stdout regardless of the WriterActor, as the output to console is handled by
the ProcessorActor.
- We experimented with the Publisher/Subscriber model with the
Actixframework, to get a feel of it.
async blocks and lifetimes
(commit).
- Performance: N/A
- We are using actix as an Actor framework for Rust, and
- We implemented our own asynchronous Actor model from scratch.
Synchronous Multithreaded Implementation with Actors: TODO/SKIP
- Our task is I/O bound, so we can skip this.
The Web Application
- The actors are connected to the outside world.
- We create a web application for this.
- Only my own implementation of actors is used for this part.
- Available routes are (address and port are defined in src/constants.rs):
n is the number of the most recent batches that a user wants to examine.
Each batch contains processed data (performance indicators) for all S&P 500 symbols.
The batches are created at regular time intervals.
Returns batches in the JSON format.
- http://127.0.0.1:3000/tailstr/n - similar to tail, and also returns batches in the JSON format,
but formatted differently, to look like the CLI output (stdout or tracing output), which is also the same
as the CSV file format that we write.
Additional Explanation
Check out the files that are provided for additional explanation:
Most of those were provided by the course author, but were modified where it made sense.Notes on Data
- List of S&P 500 symbols (tickers) can be found at:
- The alphabetically-sorted list is provided in sp5002024_aug.csv.
stdout or in the generated output.csv.
- The official list should be looked up from time to time and the input CSV file in this repository,
that contains the list, should be updated accordingly.
- The file's name intentionally contains the date when it was constructed.
- The output is not in the same order as input because of concurrent execution of futures.
Notes on Code
Async Executors/Runners
- Use
#[tokio::main]for most of the code variants. - Use
#[actix::main]or#[actix_rt::main]if working with theactixactor framework. - Use
#[xactor::main]if working with thexactoractor framework. - Try
#[async_std::main]in some variants, too.
The Most Notable Crates Used
Not all of them are present in every commit. The git commit history contains descriptive comments.
- actix, as an Actor framework for Rust
- actix-rt, as Tokio-based single-threaded async runtime for the Actix ecosystem
- async-std, as an async library
- axum, as a web framework
- clap, for CLI arguments parsing
- futures, for an implementation of futures (required for explicit concurrency
async/await paradigm)
- rayon, as a data-parallelism library for Rust
- serde, as a framework for serializing and deserializing Rust data structures
- time, as a date and time library (used by
yahoofinance_api) - Tokio, as an asynchronous runtime - used both directly and as a dependency of some other crates
- tracing, as a tool for application-level tracing for Rust
- xactor, as a Rust Actors framework based on async-std (it also supports Tokio as
- yahoofinance_api, as an adapter for
Running the App
- Help is available, via
--helpor-hoption. - The application requires the
fromand thesymbolsarguments. - The
fromdate and time argument should be provided in the RFC3339
- The
symbolsargument should contain comma-separated S&P symbols (tickers), with no blanks between them. - The
todate and time are assumed as the current time instant, at the moment of execution of each iteration of the
- The examples below demonstrate how to run the app.
- The output date and time are also in the
RFC3339format. - The program runs in a loop with a specified interval (in src/constants.rs).
cargoalso catches theCTRL+Csignal, which interferes with this application's catching of the signal,
cargo.
- Since tracing is provided, you can enable the tracing output by
export RUST_LOG=INFO, orDEBUG, etc. - The
variantoption is available for deciding whether to userayon; see help. This hasn't been fully implemented.
Example 1: Provide Some Symbols On the Command Line
$ cargo run -- --from 2023-07-03T12:00:09+00:00 --symbols AAPL,AMD,AMZN,GOOG,KO,LYFT,META,MSFT,NVDA,UBER
2024-02-27 19:42:58.0795392 +00:00:00
period start,symbol,price,change %,min,max,30d avg 2023-07-03T12:00:09Z,AMD,$177.65,53.38%,$93.67,$181.86,$172.12 2023-07-03T12:00:09Z,GOOG,$140.12,16.23%,$116.87,$154.84,$146.27 2023-07-03T12:00:09Z,LYFT,$16.63,64.00%,$9.17,$19.03,$13.90 2023-07-03T12:00:09Z,META,$485.17,69.63%,$283.25,$486.13,$435.32 2023-07-03T12:00:09Z,UBER,$78.10,81.25%,$40.62,$81.39,$70.34 2023-07-03T12:00:09Z,NVDA,$791.87,86.70%,$403.26,$791.87,$671.35 2023-07-03T12:00:09Z,AMZN,$173.62,33.33%,$119.57,$174.99,$164.80 2023-07-03T12:00:09Z,MSFT,$407.20,20.48%,$312.14,$420.55,$405.11 2023-07-03T12:00:09Z,AAPL,$183.13,-4.85%,$166.89,$198.11,$187.16 2023-07-03T12:00:09Z,KO,$60.17,-0.67%,$52.38,$63.05,$59.97
Took 278.264ms to complete.
Only new symbols (tickers), added in 2024:
$ cargo run -- --from 2024-01-01T12:00:09+00:00 --symbols KKR,CRWD,GDDY,VST,GEV,SOLV,SMCI,DECK
Include a ticker that doesn't exist, BBB, for testing purposes, but at the same time have more than one chunk (default chunk size is 5), for better debugging:
$ cargo run -- --from 2024-01-01T12:00:09+00:00 --symbols AAPL,AMZN,BBB,GOOG,MSFT,NVDA,UBER
Example 2: Provide All Symbols From a File
$ cargo run -- --from 2024-07-03T12:00:09+00:00 --symbols "$(cat sp5002024aug.csv)"
Or, equivalently:
$ export SYMBOLS="$(cat sp5002024aug.csv)" && cargo run -- --from 2024-01-01T12:00:09+00:00 --symbols $SYMBOLS
Conclusion
- This application fetches data from a remote API, so it is relatively I/O-bound.
- There are some light calculations taking place, but I believe that the application is more on the I/O-bound side.
- The
async/awaitparadigm copes well with this kind of application. - We also implemented the Actor Model. It uses message passing between actors.
rayon.
- Working with chunks of data instead of with individual pieces of data improves performance.
- It probably helps even more so in a distributed setting (a distributed network of nodes) to send larger messages,
- We couldn't measure execution time 100% properly in case of Actors with asynchronous code, but it's close.
start time in a message and ultimately forward it to the Writer Actor which
then calculates and prints the execution time after it has written results to the file in each iteration,
but, it does so on a per-chunk basis, which slows execution down a little,
and doesn't look quite nice in the output.
- Writing to file happens on a per-chunk basis regardless of time measurement, so it's just the multiple
printouts, instead of a single printout per iteration, that slow down execution just a little bit, and it
doesn't look quite nice, but it means to us to measure execution time, so we can neglect those
small inconveniences.
- The fastest solution is asynchronous (using
tokio) without actors, but very close to it is asynchronous solution
rayon without actors.
- Graceful shutdown is implemented by means of waiting (sleeping) for some time in the
main()function,
Potential Modifications, Improvements or Additions
- Integrate our web application with Actix actors, and with the no-actors variant.
- Try to use stack memory in
CollectionActorinstead of heap memory for the two buffers.
VecDeque that we currently use is FIFO, and we make use of that,
so we'd have to maintain some pointers in the logic for always keeping its size under the limit.
- Find ways to publish and subscribe to messages without explicit calls.
actix is far more popular.
- Still, a xactor implementation with three different Actors and with publish/subscribe model can be found in
this commit.
- Try implementing graceful shutdown by using
WriterActor doesn't need it).
- Actors complicate this a little, and our current solution works well - it's fully-graceful.
- Actors or no Actors, we are working with chunks of data,
so do we have to handle CTRL+C all the way down to the calculate() functions in
async_signals.rs?
- Namely, we don't want to have some symbols processed and some omitted. If we start fetching and processing
symbols in a new iteration, we'd like to have them all processed.
- Add support for different implementation variants through CLI argument.
- Our blocking std::fs::File implementation works well,
- Read symbols from a file instead of from the command line.
- Sort output by symbol.