Hacker News new | ask | show | jobs
by jude- 1684 days ago
"Maybe we could just have kept Rust as it was circa 2016, and let the crazy non-blocking folks write hand-crafted epoll() loops like they do in C++. I honestly don’t know, and think it’s a difficult problem to solve."

I think this is the most underappreciated part of this article. Since when did everything have to be async? There are other ways to represent concurrency that more accurately reflect what the computer is actually doing.

For example, an alternative to async is to represent a workstream as a state machine, where state transitions happen between I/O. Then, your state machine can be a struct, and each state transition can be an impl function on that struct that takes one or more completed I/O requests as input and emits one or more I/O requests as output. This saves you from having to implement everything as a closure, which this article rants about. Your top-level epoll loop merely services I/O requests from state machine instances, and invokes your global application logic to start and stop state machines to carry out business logic tasks.

I realize that many complicated workstreams could have many states due to all the I/O they might do, but the task of converting a high-level workstream into a state machine could be automated by the tooling.

2 comments

> but the task of converting a high-level workstream into a state machine could be automated by the tooling

That tooling is literally async/await. async/await transforms functions into state machines

Except doing so introduces a bunch of needless programming difficulties, including but not limited to the ones discussed in this article. For example, if you make the state machines and epoll loop explicit, you don't have the function coloring problem -- all your I/O requests from state machines to the poll loop (which may or may not cross thread boundaries) are explicit synchronization points with your global business logic, which gives you fine-grained control over how your state machines handle things like request timeouts, resource quota limits, cancellations, execution suspend/resume, and so on. As another example, you're not limited to factoring your state transitions into closures. As a third example, your business logic would have global, explicit control over I/O scheduling, which greatly simplifies end-to-end QoS and request prioritization.
> let the crazy non-blocking folks write hand-crafted epoll() loops like they do in C++.

> I think this is the most underappreciated part of this article.

I think it's incredibly silly actually. Abandon all async for a difficult and error prone epoll model?

> Since when did everything have to be async?

It doesn't! No one is forcing anyone to use async. I'm not sure why the author implies that.

But if you do want to use async, Rust is attempting to solve the async problem with the same guarantees it has for blocking code. Turns out that is hard.

>It doesn't! No one is forcing anyone to use async.

Well, hang on there... this isn't entirely fair.

Rust does not force you to be async, but the community in some ways pushes you to be async even where it might not make sense for it to be the default. I can't say for certain (and this is all my opinion, to be clear) but it feels like this started happening when async-fervor hit its peak.

My go-to example is reqwest, which if you want a blocking HTTP call, still just needs all of Tokio in the background. I find it really odd that the blocking API is just a wrapper for a finagled async API; if I'm choosing the blocking one, I probably don't want Tokio in my project.

There are other HTTP request libraries, to be clear - but they're often less battle tested and/or have their own lurking bugs. Reqwest is the de-facto one and it'd be nice to be able to use it without the heaviness it brings in.

Depending on the domain you're programming in, it can often feel like async-by-default is the norm. It can be frustrating in Rust.

(It's nowhere near enough to deter me from using the language, mind you)

That is an absolutely fair distinction.

I think a blocking implementation being a `await(async impl)` is typically fine...

the problem with that is the lack of async interop in the current state of things, more than the async runtime which mostly gets compiled away.

I'm not saying that we should go back to hand-rolling our own epoll loops. I'm saying that we can do better than async/await by making both the state machines and event loop explicit. For example, here's an API I'd prefer to use over async/await:

   /// A state machine that adds three numbers and uploads them to a web server
   struct AddAndUpload {
      /// I/O handle to the event loop
      io: IOClient,
      /// Buffer to store numbers I load
      nums: [u64; 3],
      /// URL to upload the data to
      url: String
   }

   impl AddAndUpload {
      /// Constructor
      pub fn new(io: IOClient, url: String) -> AddAndUpload {
         AddAndUpload {
            io,
            nums: [0u64; 3],
            url
         }
      }

      /// Entry point to this state machine
      pub fn inner_main(&mut self) -> Result<(), IOClient::Error> {
         /// go and get the data
         let n1_fut : IOClient::Future<u64> = self.io.sql_async("SELECT n1 FROM table1", &[])?;
         let n2_fut : IOClient::Future<u64> = self.io.sql_async("SELECT n2 FROM table2", &[])?;
         let n3_fut : IOClient::Future<u64> = self.io.sql_async("SELECT n3 FROM table3", &[])?;

         // wait for all I/O operations to finish
         IOClient::wait_all(&[&n1_fut, &n2_fut, &n3_fut])?;

         // extract results
         let n1 = n1_fut.into_inner();
         let n2 = n2_fut.into_inner();
         let n3 = n3_fut.into_inner();

         // upload them
         let sum = n1 + n2 + n3;
         let upload_fut : IOClient::Future<IOClient::HTTPStatus> = self.io.http_post_async(&self.url, &["content-type: application/octet-stream"], &sum.to_be_bytes())?;

         let upload_http_status = upload_fut.wait()?.into_inner();

         match upload_http_status.as_u16() {
            200 => {
               Ok(())
            }
            400..499 => {
               Err(IOClient::Error::Custom("client error"))
            }
            500..599 => {
               Err(IOClient::Error::Custom("server error"))
            }
            x => {
               Err(IOClient::Error::Custom("Nonsensical HTTP code"))
            }
         }
      }
   }

   impl IOClient::StateMachine for AddAndUpload {
      type Return = ();
      fn main(&mut self) -> Result<(), IOClient::Error> {
         self.inner_main()
      }
   }

   /\* somewhere else \*/

   fn main() {
       let io_server = IOServer::spawn().unwrap();
       let io_client = io_server.client().unwrap();
       let add_and_upload = AddAndUpload::new(io_client, "http://example.com".to_string());
       loop {
         io_server.run().unwrap();
         match add_and_upload.get_machine_status() {
            Ok(IOClient::StateMachine::Finished(result)) => {
               eprintln!("add_and_uploaded exited with {:?}", &result);
               break;
            }
            Ok(_) => {},
            Err(e) => {
               panic!("add_and_upload aborted: {:?}", &e);
            }
         }
      }
      io_server.terminate();
   }
okay, but that doesnt solve basically the main thing that async paradigms seek to solve: sharing of resources between waiting disjoint processes.

your statemachine blocks the thread. if you had a more complicated state machine, maybe nested machines, theyd block each other because they dont know how to cooperate.

This code is just an example. The state machine can easily run in its own thread, separate from the main thread. As long as the state machine had an IOClient instance that lets it send I/O requests to the IOServer and receive I/O results, you're good. Also, you could imagine an IOClient having an API that takes a StateMachine instance as input, and returns an IOClient::Future that resolved to the machine's main() return value.