DEV Community

loading...
Cover image for Dart and Rust: the async story 🔃
Sunshine

Dart and Rust: the async story 🔃

shekohex profile image Shady Khalifa ・8 min read

In the last blog post: Dart Meets Rust: a match made in heaven we show how to connect both languages and build more secure, performant Flutter Applications.
In this blog post, we will talk about how to use Multithreaded async Rust code with async Dart and how we could build applications that have more than one runtime working together in a smooth way like how the sōzu works.

A Shishi-odoshi breaks the quietness of a Japanese garden with the sound of a bamboo rocker arm hitting a rock.

The Problem 🎯

Running more than async loops is not always easy to get it right, you know that Dart VM has it is own async runtime and in Rust, we could plug-in any one of the popular runtimes, here is a few for reference Tokio, async-std, and recently smol which is now built-in the async-std as it is low-level runtime.

The issue is that Dart as a language is designed to be single-threaded, that's not a problem in itself, but we will see later how would that be an issue when using Rust.
well, you could run more than Isolate which is like a small Dart VM instance they can work together and communicate by message passing, here is a video where Andrew from Flutter team talk about Dart Isolates and the Event Loop, I highly recommend watching it before continuing if you have no idea about Dart isolates (PS: it is less than 6 min 😅)

Let's Scrape the web 🌍

For making this post more exciting let's build a simple web scraping library in Rust and use it in Flutter Application.
using our experience from the last blog post on how to connect both languages, we will use the same idea here too.

Create the Rust crate first:

$ cargo new --lib native/scrap # yes, it is a scrap lol

cool, now we need a way to send async web requests, so let's use reqwest

[dependencies]
# we will use `rustls-tls` here since openssl is an issue when cross-compiling for Android/iOS
reqwest = { version = "0.10", default-features = false, features = ["rustls-tls"] }

Now let's write some rusty code 😀

// lib.rs
use std::{error, fmt, io};

/// A useless Error just for the Demo
#[derive(Copy, Clone, Debug)]
pub struct ScrapError;

impl fmt::Display for ScrapError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Error While Scrapping this page.")
    }
}

impl error::Error for ScrapError {}

impl From<reqwest::Error> for ScrapError {
    fn from(_: reqwest::Error) -> Self {
        Self
    }
}

impl From<io::Error> for ScrapError {
    fn from(_: io::Error) -> Self {
        Self
    }
}

/// Load a page and return its HTML body as a `String`
pub async fn load_page(url: &str) -> Result<String, ScrapError> {
    Ok(reqwest::get(url).await?.text().await?)
}

so as you see it is some simple code with sh**ty error handling 😂

Next, Let's Write our FFI binding to the scrap crate:

$ cargo new --lib native/scrap-ffi

in our Cargo.toml

[lib]
name = "scrap_ffi"
crate-type = ["cdylib", "staticlib"]

[dependencies]
scrap = { path = "../scrap" }

Now, it gets dirty because we need to consider a few things here

Setting up the Runtime 🔌

We will choose Tokio here as our runtime, it does not matter which runtime we use, but in our case, since reqwest works well with Tokio we going to use it.
also lazy_static it will be handy in a second.

tokio = { version = "0.2", features = ["rt-threaded"] }
lazy_static = "1.4"

Write some code to set up the runtime

// lib.rs
use tokio::runtime::{Builder, Runtime};
use lazy_static::lazy_static;
use std::io;

lazy_static! {
    static ref RUNTIME: io::Result<Runtime> = Builder::new()
        .threaded_scheduler()
        .enable_all()
        .core_threads(4)
        .thread_name("flutterust")
        .build();
}

/// Simple Macro to help getting the value of the runtime.
macro_rules! runtime {
    () => {
        match RUNTIME.as_ref() {
            Ok(rt) => rt,
            Err(_) => {
                return 0;
            }
        }
    };
}

This setup a Tokio Runtime with 4 threads and naming them flutterust for each thread.

Error Handling 🧰

Handling Errors between FFI is hard, so we will use a helper crate that will make our life easier.
add ffi_helpers to the dependencies

ffi_helpers = "0.2"

it provides useful functions and macros that could help us to handle errors, exposing last_error_length and error_message_utf8 so in Dart's side we could get readable error messages.

macro_rules! error {
    ($result:expr) => {
        error!($result, 0);
    };
    ($result:expr, $error:expr) => {
        match $result {
            Ok(value) => value,
            Err(e) => {
                ffi_helpers::update_last_error(e);
                return $error;
            }
        }
    };
}

macro_rules! cstr {
    ($ptr:expr) => {
        cstr!($ptr, 0);
    };
    ($ptr:expr, $error:expr) => {{
        null_pointer_check!($ptr);
        error!(unsafe { CStr::from_ptr($ptr).to_str() }, $error)
    }};
}


#[no_mangle]
pub unsafe extern "C" fn last_error_length() -> i32 {
    ffi_helpers::error_handling::last_error_length()
}

#[no_mangle]
pub unsafe extern "C" fn error_message_utf8(buf: *mut raw::c_char, length: i32) -> i32 {
    ffi_helpers::error_handling::error_message_utf8(buf, length)
}

Exposing load_page function 🔑

Now comes the issue, how we would expose async function? well C/Dart has no idea how Rust's async works.
thinking
One way could be writing a normal non-async function as a wrapper around the async function and call block_on to get the result out from the async fn, as you see the function name says it all, we will block that thread only to run that async task, so what the benefit in the first place?

There is another way, what about using callbacks?!
Good, that would be an option and use javascript Promise style to handle the async tasks by passing two callbacks one called on the success and the other one called on err.

Theoretically, that would work, but

Dammit

As I said before, Dart is designed to be single-threaded so you can't just call the callback from other threads it basically will break Dart semantics (you could read more here).

So what would be the solution then?
What if I told you that we could communicate between Dart and Rust using an Isolate 😦.

Every new Isolate get a SendPort and ReceivePort these are used to communicate with the Isolate and you only need SendPort to send messages to the Isolate, also every SendPort have what is called a NativePort this the underlying number of that port.

Just a small note, the term of Port here is not related at all to a Networking Port or something like that, it is just numbers used as Keys for a Map implemented internally in Dart VM, think of them as a Handle to that Isolate

What if we used these numbers to send our async fn result on them from any thread we want, is that possible? YES

Enter: Allo Isolate 📞

allo-isolate is a crate we built at Sunshine to help us Running Multithreaded Rust along with Dart VM (in isolate) 🌀
It only needs a Port of that isolate and you could send any message on it from your Rust code.

Under the hood, it uses the Dart API Dart_PostCObject function and it can convert most (almost all?) of Rust types to Dart types, see the docs for more information.

Allo (pronounced Hello, without the H) usually used in communication over the phone in some languages like Arabic :).

Back to our example, we now know how to expose the load_page function lets use allo-isolate

allo-isolate = "0.1"

use it

use allo_isolate::Isolate;

...

#[no_mangle]
pub extern "C" fn load_page(port: i64, url: *const raw::c_char) -> i32 {
    // get a ref to the runtime
    let rt = runtime!();
    let url = cstr!(url);
    rt.spawn(async move {
        // load the page and get the result back
        let result = scrap::load_page(url).await;
        // make a ref to an isolate using it's port
        let isolate = Isolate::new(port);
        // and sent it the `Rust's` result
        // no need to convert anything :)
        isolate.post(result);
    });
    1
}

Great, we are ready to go, after generating our binding.h and building everything for iOS and Android we need also to write the Dart side FFI... ugh not again 🤦‍♂️

Matrix

What if I told you that you could now generate your Dart FFI binding from a C Header File?

dart-bindgen: a new way to write Dart FFI

dart-bindgen is a tool for generating Dart FFI bindings to C Header file, it comes as a CLI and a Library.

and we going to use it to help us generate the FFI, just add it to your build-dependencies

[build-dependencies]
cbindgen = "0.14.2" # Rust -> C header file
dart-bindgen = "0.1" # C header file -> Dart FFI

and in our build.rs

use dart_bindgen::{config::*, Codegen};

fn main() {
    ...
    let config = DynamicLibraryConfig {
        ios: DynamicLibraryCreationMode::Executable.into(),
        android: DynamicLibraryCreationMode::open("libscrap_ffi.so").into(),
        ..Default::default()
    };
    // load the c header file, with config and lib name
    let codegen = Codegen::builder()
        .with_src_header("binding.h")
        .with_lib_name("libscrap")
        .with_config(config)
        .build()
        .unwrap();
    // generate the dart code and get the bindings back
    let bindings = codegen.generate().unwrap();
    // write the bindings to your dart package
    // and start using it to write your own high level abstraction.
    bindings
        .write_to_file("../../packages/scrap_ffi/lib/ffi.dart")
        .unwrap();
}

So far so good, let's write our higher-level abstraction over the ffi.dart file.

// packages/scrap_ffi/lib/scrap.dart

import 'dart:async';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// isolate package help us creating isolate and getting the port back easily.
import 'package:isolate/ports.dart';

import 'ffi.dart' as native;

class Scrap {
  // this only should be called once at the start up.
  static setup() {
    // give rust `allo-isolate` package a ref to the `NativeApi.postCObject` function.
    native.store_dart_post_cobject(NativeApi.postCObject);
    print("Scrap Setup Done");
  }

  Future<String> loadPage(String url) {
    var urlPointer = Utf8.toUtf8(url);
    final completer = Completer<String>();
    // Create a SendPort that accepts only one message.
    final sendPort = singleCompletePort(completer);
    final res = native.load_page(
      sendPort.nativePort,
      urlPointer,
    );
    if (res != 1) {
      _throwError();
    }
    return completer.future;
  }

  void _throwError() {
    final length = native.last_error_length();
    final Pointer<Utf8> message = allocate(count: length);
    native.error_message_utf8(message, length);
    final error = Utf8.fromUtf8(message);
    print(error);
    throw error;
  }
}

in our flutter application, we could use that package as so:

...
class _MyHomePageState extends State<MyHomePage> {

  ...
  Scrap scrap;
  @override
  void initState() {
    super.initState();
    scrap = Scrap();
    Scrap.setup();
  }  

  // somewhere in your app
  final html = await scrap.loadPage('https://www.rust-lang.org/');

That's it, Fire up Android Emulator or iOS Simulator and Run:

$ cargo make # build all rust packages for iOS and Android

Then,

$ flutter run # 🔥

All the Code is available in flutterust which is only clone and Hack 😀.

Stay tuned for other Hacks in Dart and Rust, you could follow me on twitter and github

Thank you 💚.

Discussion (0)

pic
Editor guide