Dart asynchronous programming

Keywords: Mobile Programming network JSON Android

This article is [ From scratch, let's learn and develop a Flutter App together ]The second article on the road.

This article will solve the problem left by the previous article: how does asynchronous processing work in Dart? First, we briefly introduce Future, sync and await, which are commonly used in Dart; second, we try to analyze the asynchronous implementation principle of Dart as a single thread language, and further introduce IO model and event cycle model; finally, we introduce how to realize the mutual communication between multiple threads in Dart.

<!--more-->

If you are familiar with the Promise mode of JavaScript, you can start an asynchronous http request as follows:

new Promise((resolve, reject) =&gt;{
    // Initiate request
    const xhr = new XMLHttpRequest();
    xhr.open("GET", 'https://www.nowait.xin/');
    xhr.onload = () =&gt; resolve(xhr.responseText); 
    xhr.onerror = () =&gt; reject(xhr.statusText);
    xhr.send();
}).then((response) =&gt; { //Success
   console.log(response);
}).catch((error) =&gt; { // fail
   console.log(error);
});

Promise defines an asynchronous processing mode: do... success... or fail.

In Dart, it corresponds to the Future object:

Future<response> respFuture = http.get('https://example.com '); / / initiate the request
respFuture.then((response) { //Success, anonymous function
  if (response.statusCode == 200) {
    var data = reponse.data;
  }
}).catchError((error) { //fail
   handle(error);
});

This mode simplifies and unifies asynchronous processing. Even if no system has learned concurrent programming, it can put aside complex multithreading and use it out of the box.

Future

The Future object encapsulates the asynchronous operation of Dart. It has two states: incomplete and completed.

In Dart, all functions related to IO are encapsulated as Future object returns. When you call an asynchronous function, before the result or error returns, you get an uncompleted Future.

There are also two types of completed states: one is to represent the success of the operation and return the result; the other is to represent the failure of the operation and return the error.

Let's take an example:

Future<string> fetchUserOrder() {
  //Imagine this is a time-consuming database operation
  return Future(() =&gt; 'Large Latte');
}

void main() {
  fetchUserOrder().then((result){print(result)})
  print('Fetching user order...');
}

Call back the successful result through then. main will precede the operation in Future and output the result:

Fetching user order...
Large Latte

In the above example, () = & gt; 'large latte') is an anonymous function and = & gt; 'large latte' is equivalent to return 'Large Latte'.

The constructor with the same name of Future is factory Future (futureor < T > calculation()), whose function parameter return value is futureor < T > type. We found that there are many Future methods, such as Future.then and Future.microtask, whose parameter types are futureor < T >. It seems necessary to understand this object.

Futureor < T > is a special type. It has no class members, cannot be instantiated or inherited. It seems likely that it is just a syntactic sugar.

abstract class FutureOr<t> {
  // Private generative constructor, so that it is not subclassable, mixable, or
  // instantiable.
  FutureOr._() {
    throw new UnsupportedError("FutureOr can't be instantiated");
  }
}

You can think of it as a restricted dynamic type because it can only accept values of type future < T > or T:

FutureOr<int> hello(){}

void main(){
   FutureOr<int> a = 1; //OK
   FutureOr<int> b = Future.value(1); //OK
   FutureOr<int> aa = '1' //Compile error

   int c = hello(); //ok
   Future<int> cc = hello(); //ok
   String s = hello(); //Compile error
}

In Dart's best practice, it is clearly pointed out: please avoid declaring function return type as futureor < T >.

If you call the following function, you cannot know whether the type of return value is int or future < int > unless you enter the source code:

FutureOr<int> triple(FutureOr<int> value) async =&gt; (await value) * 3;

The right way to write:

Future<int> triple(FutureOr<int> value) async =&gt; (await value) * 3;

After a little explanation of futureor < T >, we continue to study Future.

If an exception occurs during the function execution in Future, you can handle the exception through Future.catchrror:

Future<void> fetchUserOrder() {
  return Future.delayed(Duration(seconds: 3), () =&gt; throw Exception('Logout failed: user ID is invalid'));
}

void main() {
  fetchUserOrder().catchError((err, s){print(err);});
  print('Fetching user order...');
}

Output results:

Fetching user order...
Exception: Logout failed: user ID is invalid

Future supports chain call:

Future<string> fetchUserOrder() {
  return Future(() =&gt; 'AAA');
}

void main() {
   fetchUserOrder().then((result) =&gt; result + 'BBB')
     .then((result) =&gt; result + 'CCC')
     .then((result){print(result);});
}

Output results:

AAABBBCCC

async and await

Imagine a scene like this:

  1. Call the login interface first;
  2. Obtain the user information according to the token returned by the login interface;
  3. Finally, the user information is cached to the local computer.

Interface definition:

Future<string> login(String name,String password){
  //Sign in
}
Future<user> fetchUserInfo(String token){
  //Get user information
}
Future saveUserInfo(User user){
  // Cache user information
}

In Future, it can be written as follows:

login('name','password').then((token) =&gt; fetchUserInfo(token))
  .then((user) =&gt; saveUserInfo(user));

For async and await, you can do this:

void doLogin() async {
  String token = await login('name','password'); //await must be in the async function body
  User user = await fetchUserInfo(token);
  await saveUserInfo(user);
}

The function of async is declared. The return value must be a Future object. Even if you directly return T type data in the async function, the compiler will automatically help you package it as an object of type Future < T >. If it is a void function, it will return the object of type Future < void >. When await is encountered, Futrue type will be unpacked and the original data type will be exposed. Please note that async keyword must be added to the function where await is located.

An exception occurred in the code of await. The capture method is the same as synchronous call function:

void doLogin() async {
  try {
    var token = await login('name','password');
    var user = await fetchUserInfo(token);
    await saveUserInfo(user);
  } catch (err) {
    print('Caught error: $err');
  }
}

Thanks to the syntax sugar of async and await, you can use the idea of synchronous programming to deal with asynchronous programming, which greatly simplifies the processing of asynchronous code.

>Note: there are many grammar sugar in Dart, which improves our programming efficiency, but at the same time, it also makes beginners easily confused.

Send you another grammar sugar:

Future<string> getUserInfo() async {
  return 'aaa';
}

//Equivalent to:

Future<string> getUserInfo() async {
  return Future.value('aaa');
}

Dart asynchronous principle

Dart is a single thread programming language. For students who usually use Java, they may first respond: if an operation takes a long time, won't the main thread be stuck all the time? For example, for Android, in order not to block the main UI thread, we have to initiate time-consuming operations (network requests / access to local files, etc.) through another thread, and then communicate with the UI thread through the Handler. How did dart do it?

First give the answer: asynchronous IO + event loop. The following specific analysis.

I/O model

Let's first look at what blocking IO looks like:

int count = io.read(buffer); //Blocking wait

>Note: the IO model is at the operating system level. The code in this section is pseudocode, just for the convenience of understanding.

When the corresponding thread calls read, it will be there waiting for the result to return, doing nothing. This is blocking IO.

But our applications often handle several IOS at the same time. Even for a simple mobile App, the simultaneous IO may include: user gesture (input), several network requests (input and output), rendering results to the screen (output); let alone the service-side program, hundreds of concurrent requests are common.

Some people say that multi threading can be used in this situation. This is really an idea, but subject to the actual number of concurrent CPU, each thread can only handle a single IO at the same time, the performance limit is still very large, and also to deal with the synchronization between different threads, the complexity of the program is greatly increased.

If there is no blocking during IO, the situation is different:

while(true){
  for(io in io_array){
      status = io.read(buffer);// No matter whether there is data or not, return it immediately
      if(status == OK){
       
      }
  }
}

With non blocking IO, we can process multiple IO at the same time by polling, but there is an obvious disadvantage: in most cases, IO has no content (CPU speed is much higher than IO speed), which will cause CPU to idle most of the time, and computing resources are still not well utilized.

To further solve this problem, IO multiplexing is designed, which can monitor and set the waiting time for multiple IOS:

while(true){
    //If there is data returned from one of the IO channels, it will be returned immediately; if it has not been returned all the time, the maximum waiting time is no more than timeout
    status = select(io_array, timeout); 
    if(status  == OK){
      for(io in io_array){
          io.read() //Go back now. The data is ready
      }
    }
}

>There are many implementations of IO multiplexing, such as select, poll, epoll, etc., which we will not expand specifically.

With IO multiplexing, CPU resource utilization efficiency has been improved.

Sharp eyed students may find that in the above code, the thread may still block on select or generate some idling. Is there a more perfect solution?

The answer is asynchronous IO:

io.async_read((data) =&gt; {
  // dosomething
});

With asynchronous IO, we don't have to keep asking the operating system: are you ready for the data? But as soon as there is a data system, it will be delivered to us through message or callback. This seems perfect, but unfortunately, not all operating systems support this feature very well. For example, there are various defects in Linux asynchronous IO, so in the specific implementation of asynchronous IO, there may be a compromise between different IO modes, such as the libeio library behind Node.js, which in essence uses the asynchronous I/O simulated by thread pool and blocking I/O[ 1].

Dart also mentioned in the document that it uses Node.js, EventMachine, and Twisted for reference to realize asynchronous io. We will not go into its internal implementation for the moment (the author searches the source code of Dart VM and finds that it seems to be implemented through epoll on android and linux). In dart layer, we just need to treat IO as asynchronous.

Let's go back to the Future code above:

Future<response> respFuture = http.get('https://example.com '); / / initiate the request

Now you know that the network request is not completed in the main thread. It actually leaves the work to the runtime or the operating system. This is also the reason that Dart, as a single process language, does not block the main thread when performing IO operations.

Finally, it solves the problem that Dart single thread will not be stuck in IO, but how does the main thread deal with a large number of asynchronous messages? Let's continue to discuss Dart's Event Loop mechanism.

Event Loop

In Dart, each thread runs in an independent environment called isolate. Its memory is not shared with other threads. It is constantly doing one thing: Taking events from the event queue and processing them.

while(true){
   event = event_queue.first() //Take out event
   handleEvent(event) //Handling events
   drop(event) //Remove from queue
}

For example, the following code:

RaisedButton(
  child: Text('click me');
  onPressed: (){ // Click events 
     Future<response> respFuture = http.get('https://example.com'); 
     respFuture.then((response){ // IO return event
        if(response.statusCode == 200){
           print('success');
        }
     })
  }
)

When you click the button on the screen, an event will be generated, which will be put into the event queue of isolate; then you initiate a network request, which will also generate an event, and enter the event cycle in turn.

When the thread is idle, isolate can also do garbage collection (GC) and have a cup of coffee.

Future, Stream, async and await of API layer are actually abstractions of event loops in code layer. Combined with the event cycle, back to the definition of future object (an object representing a delayed calculation.), we can understand it as follows: brother isolate, I'll send you a code package, open the box after you get it, and execute the code in sequence.

In fact, there are two queues in the isolate, one is the event queue, and the other is called the micro task queue.

Event queue: used to process external events, such as IO, click, draw, timer and message events between different isolations.

Micro task queue: it processes tasks from Dart and is suitable for tasks that are not particularly time-consuming or urgent. The processing priority of micro task queue is higher than that of event queue. If micro task processing is time-consuming, it will cause event accumulation and slow application response.

You can submit a micro task to isolate through Future.microtask:

import 'dart:async';

main() {
  new Future(() =&gt; print('beautiful'));
  Future.microtask(() =&gt; print('hi'));
}

Output:

hi
beautiful

Summarize the operation mechanism of the event loop: when the application starts, it will create an isolate, start the event loop, and process the micro task queue in the order of FIFO, and then deal with the event queue again and again.

Multithreading

>Note: when we talk about isolate below, you can equate it with thread, but we know it is more than just a thread.

Thanks to the asynchronous IO + event loop, although Dart is a single thread, general IO intensive App applications can usually achieve excellent performance. But for some computing intensive scenarios, such as image processing, deserialization, and file compression, only one thread is not enough.

In Dart, you can use Isolate.spawn to create a new isolate:

void newIsolate(String mainMessage){
  sleep(Duration(seconds: 3));
  print(mainMessage);
}

void main() {
  // Create a new isolate, new ioslate
  Isolate.spawn(newIsolate, 'Hello, Im from new isolate!'); 
  sleep(Duration(seconds: 10)); //Main thread blocking waiting
}

Output:

Hello, Im from new isolate!

spawn has two required parameters, the first is the new isolate entry function (entrypoint), and the second is the parameter value (message) of this entry function.

If the main isolate wants to receive the message of the sub isolate, you can create a ReceivePort object in the main isolate, pass in the corresponding receivePort.sendPort as the parameter of the new isolate entry function, and then bind the SendPort object through the ReceivePort to send the message to the main isolate:

//New isolate entry function
void newIsolate(SendPort sendPort){
  sendPort.send("hello, Im from new isolate!");
}

void main() async{
  ReceivePort receivePort= ReceivePort();
  Isolate isolate = await Isolate.spawn(newIsolate, receivePort.sendPort);
  receivePort.listen((message){ //Listen for messages sent from the new isolate
   
    print(message);
     
    // Close pipe when no longer in use
     receivePort.close();
     
    // Close the isolate thread
     isolate?.kill(priority: Isolate.immediate);
  });
}

Output:

hello, Im from new isolate!

We have learned how the main isolate listens for messages from the sub isolate. If the sub isolate also wants to know some states of the main isolate, what should we do? The following code will provide a two-way communication:

Future<sendport> initIsolate() async {
  Completer completer = new Completer<sendport>();
  ReceivePort isolateToMainStream = ReceivePort();

  //Listen for messages from child threads
  isolateToMainStream.listen((data) {
    if (data is SendPort) {
      SendPort mainToIsolateStream = data;
      completer.complete(mainToIsolateStream);
    } else {
      print('[isolateToMainStream] $data');
    }
  });

  Isolate myIsolateInstance = await Isolate.spawn(newIsolate, isolateToMainStream.sendPort);
  //Return to sendPort from child isolate
  return completer.future; 
}

void newIsolate(SendPort isolateToMainStream) {
  ReceivePort mainToIsolateStream = ReceivePort();
  //Key implementation: send the SendPort object back to the primary isolate
  isolateToMainStream.send(mainToIsolateStream.sendPort);

  //Listen for messages from the main isolate
  mainToIsolateStream.listen((data) {
    print('[mainToIsolateStream] $data');
  });

  isolateToMainStream.send('This is from new isolate');
}

void main() async{
  SendPort mainToIsolate = await initIsolate();
  mainToIsolate.send('This is from main isolate');
}

Output:

[mainToIsolateStream] This is from main isolatemain end
[isolateToMainStream] This is from new isolate

In Flutter, you can also start a new isolate with a simplified version of the compute function.

For example, in the deserialization scenario, serialization is performed directly in the main isolate:

List<photo> parsePhotos(String responseBody) {
  final parsed = json.decode(responseBody).cast<map<string, dynamic>&gt;();

  return parsed.map<photo>((json) =&gt; Photo.fromJson(json)).toList();
}

Future<list<photo>&gt; fetchPhotos(http.Client client) async {
  final response =
      await client.get('https://jsonplaceholder.typicode.com/photos');
  //Convert directly in the main isolate
  return parsePhotos(response.body); 
}

Start a new isolate:

Future<list<photo>&gt; fetchPhotos(http.Client client) async {
  final response =
      await client.get('https://jsonplaceholder.typicode.com/photos');
  // Using the compute function, start a new isolate
  return compute(parsePhotos, response.body);
}

>The full version of this example: Parse JSON in the background

To summarize, you can open a new isolate to perform tasks concurrently when you encounter time-consuming operations that are computationally intensive. Unlike the multithreading we usually know, memory cannot be shared between different isolations, but message channels between different isolations can be built through ReceivePort and SendPort. In addition, messages from other isolations also need to go through event loops.

Reference material

  1. Dart asynchronous programming: isolate and event loops
  2. The Event Loop and Dart
  3. Asynchronous I/O implementation of Node.js
  4. Dart Isolate 2-Way Communication
  5. Fully understand Dart asynchrony

about AgileStudio

We are a team composed of senior independent developers and designers with solid technical strength and many years of product design and development experience, providing reliable software customization services.

Posted by shalinik on Mon, 13 Jan 2020 21:01:23 -0800