Asynchronous & synchronous Programming In Dart
Ahmed_Greynoon
Posted on September 1, 2024
Introduction
Previously in the last article, we talked about the fundamental concepts of single and multi-threaded programming, and understanding how they relate to concurrency and parallelism. Single-threaded programs execute tasks sequentially, one at a time, while multi-threaded programs can handle multiple tasks concurrently or in parallel, depending on the system's capabilities. Also, we talked about how single-threaded programming has the ability to handle multiple tasks at once using the concurrency concept only by switching between tasks, giving each task a small slice of time to execute before moving on to the next task, and can't do parallelism.
Now, let's talk about synchronous & asynchronous programming concepts and differences in Dart to understand how it deals with tasks.
Synchronous Programming
As we said previously, Synchronous programming means that code is executed sequentially from top to bottom, each statement is completed before the next one begins.
Synchronous code is often more straightforward to read, predictable, and easy to understand. But because the operations execute sequentially, one by one, it blocks the main thread, leading to unresponsiveness in the user interface, especially for time-consuming tasks like network requests or file operations. Also, CPU cycles are wasted during blocking operations as the application cannot perform other tasks while waiting for the current one to complete.
Asynchronous Programming
Asynchronous programming allows multiple operations to execute independently without blocking each other, which means that the statements do not have to wait for other operations to finish before they can run.
Asynchronous code allows the main thread to remain responsive, ensuring a smooth user experience even during time-consuming tasks, and also improving the overall efficiency of the application, as other tasks can continue while waiting for asynchronous operations to be completed. But the code introduces a level of complexity, especially for developers who are new to the paradigm, also debugging code can be more complex.
Overall, asynchronous programming is a powerful tool that can significantly enhance an application's performance and user experience.
What about Dart?
Dart is a single-threaded system, which means that tasks are executed sequentially, blocking the main thread until each operation completes. It does not support multi-threading which makes it inherently non-concurrent and non-parallel, because there's only one sequence of operations in a single-threaded environment.
Let's see a simple example of a synchronous program:
void main() {
print(1);
print(2);
print(3);
}
Output:
1
2
3
In this program, the main()
function runs line by line from top to bottom until it completes. This program runs synchronously because it never displays the number 2 before 1 for example.
For more understanding, let's talk another example:
import 'dart:io';
void synchronousOperation() {
for (int i = 1; i <= 3; i++) {
print(i);
sleep(Duration(seconds: 1));
}
}
void main() {
print("Start");
synchronousOperation();
print("End");
}
Output:
Start
1
2
3
End
In this example, “Start” is printed, followed by numbers 1 to 3, each with a one-second delay, and finally, “End” is printed. Notice that during the delay, the entire application is unresponsive.
Sleep Function: refers to a synchronous operation that pauses code execution for a specified duration, perhaps to wait for some external process to complete or simply to delay execution, But during that time the entire program will stop. The way of using it is by passing the
Duration
object into theSleep
function when you call it.
So... what if there's a time-consuming task, like for example waiting for a file to load or fetching data from a remote server... how can Dart deal with that?
Well, actually it can by using asynchronous programming. Dart allows us to do asynchronous programming which runs our program without getting blocked, also concurrency can be achieved using isolates.
But before going any further, let's understand how Dart manages events and what are the isolates.
Concept of Isolates in Dart
We know that most platforms have Multi-threaded systems, which allow true parallel execution of tasks, and to take advantage of that, developers traditionally use shared-memory threads running concurrently. However, shared-state concurrency is error-prone and can lead to complicated code.
Unlike other programming languages, Dart is a single-threaded system and it can approach parallelism and concurrency in different ways by using isolates.
An isolate is an independent execution context that has its own memory and a single thread of execution.
So, instead of shared-memory threads, Dart code runs inside of isolates, which each isolate has its own memory to ensure that no isolate’s state is accessible from any other isolate, but they communicate by passing messages ensuring clear and controlled data exchange.
Isolates are Dart's way of achieving concurrency without shared memory, avoiding the complexities and potential issues of traditional multi-threading, and since Isolates run independently they can execute code in parallel, taking full advantage of multi-core processors. This is how Dart achieves true parallelism.
All Dart code runs in isolates, starting in the default main isolate, and optionally expanding to whatever subsequent isolates you explicitly create. When you spawn a new isolate, it has its own isolated memory and its own event loop. The event loop is what makes asynchronous and concurrent programming possible in Dart.
How it works?
All code execution begins in what is known as the default main isolate, which is automatically created when the Dart application starts. The main isolate has its own memory space, and event loop, and can handle tasks such as I/O operations, user input, and updates to the UI (in the context of Flutter).
If the application requires concurrency execution that should run separately from the main isolate or a parallel execution to fully utilize multi-core processors, then you can create a new isolate by 'spawn' it.
As we mentioned previously, each isolate has its own memory, event loop, and tasks, also they communicate with each other by passing messages.
Example:
import 'dart:isolate';
void sayhii(var msg) {
print('execution from sayhii ... the message is :${msg}');
}
void main() {
Isolate.spawn(sayhii, 'Hello!!');
Isolate.spawn(sayhii, 'Whats up!!');
Isolate.spawn(sayhii, 'Welcome!!');
print('execution from main1');
print('execution from main2');
print('execution from main3');
}
output:
execution from sayhii ... the message is :Hello!!
execution from main1
execution from main2
execution from main3
execution from sayhii ... the message is :Welcome!!
execution from sayhii ... the message is :Whats up!!
Exited.
In this example, we have two functions sayhii()
and main()
function might not run in the same order each time cause the Isolate.spawn
method creates a new isolate for the function sayhii
, and executed it in parallel with the remaining code. If you run the code again, the output will be different each time as we can see in the second output.
Another example:
import 'dart:isolate';
void isolateFunction(SendPort sendPort) {
sendPort.send('Message from the new isolate');
}
void main() async {
ReceivePort receivePort = ReceivePort();
await Isolate.spawn(isolateFunction, receivePort.sendPort);
receivePort.listen((message) {
print('Main isolate received: $message');
});
}
output:
Main isolate received: Message from the new isolate
As we saw, isolates do not share memory, so they communicate with each other by passing messages using SendPort
and ReceivePort
. The main isolate can send data to the new isolate via a SendPort
, and the new isolate can reply using another SendPort
.
But what is the Event loop, and what is the deal with async
and await
?
Don't worry I will explain each one.
The Event loop
As we said earlier, All the code you write within the main()
function and any synchronous or asynchronous operations that do not involve other isolates are executed in the main isolate.
Event: refers to a unit of work or a task that is scheduled to be processed by the event loop, it can be anything from server requests to repaint the UI, to user taps and keystrokes, to I/O from the disk.
Event loop: refers to an invisible process that manages the execution of events and callbacks. Dart’s runtime model is based on an event loop, which is responsible for executing the program's code, processing scheduled events, and more.
How Does Event Loop Work?
The Dart event loop main job is to handle events, like mouse taps, buttons tap, or request data from a remote server. And it works by continuously checking two queues:-
- Micro-task queue.
- Event queue.
The micro-task queue is used for short asynchronous internal actions that come from your Dart code like for example long-running tasks, and it is what the event loop considers first ensuring that all pending micro-tasks are completed before moving on to the next event in the event queue.
The event queue is used for handling asynchronous external events such as I/O operations, timers, user interactions, and messages between isolates.
The micro-task queue is ideal for tasks that you want to have completed as soon as possible, but not immediately. For instance, you might use the micro-task queue to delay some computation or to allow the UI to update between tasks.
This priority system allows Dart to handle smaller, internal tasks immediately while deferring larger or external tasks until the micro-tasks are complete, maintaining efficiency and responsiveness in asynchronous operations.
Remember: both the micro-task queue and the event queue are parts of Dart's asynchronous programming model, and they are designed to manage asynchronous operations.
The event loop is always running. It continuously checks the synchronous tasks, micro-task queue, and event queue. So, when the Flutter application runs :
- The main isolate is created and the event loop starts, The
void main()
is the first to execute synchronously. - It always runs synchronous tasks immediately, but that doesn't mean it can't be possible to interrupt them.
- If all the tasks in the main isolate are completed, the event loop moves the tasks (if available) from the micro-task queue to the main isolate for execution until the micro-task queue is empty.
- If both synchronous tasks and the micro-task queue are empty, the event loop moves the tasks from the event queue to the main thread for execution.
- The event loop continues until all the queues are empty.
Here's also an animated youtube diagram for the event loop lifecycle:
Asynchronous programming in Dart involves the use of Future
objects and async
functions (we will explain it later). Whenever we write asynchronous code we schedule tasks to be run later. For example, when we use Future
objects and async
functions, we’re telling the Dart event loop to complete other Dart codes first and come back when the Future is complete.
Here is a simple example of how the Dart event loop works:
import 'dart:async';
void main() {
print('Dart app starts');
Future(() => print('This is a new Future'));
scheduleMicrotask(() => print('This is a micro task'));
print('Dart app ends');
}
Output:
Dart app starts
Dart app ends
This is a micro task
This is a new Future
The main function executes synchronously. The print(‘Dart app starts’)
and print(‘Dart app ends’)
are executed immediately. The Future
and scheduleMicrotask
calls are added to their respective queues and are executed once the main function has completed.
Another example:
import 'dart:async';
void main() {
print('Dart app starts');
scheduleMicrotask(() => print('Microtask 1'));
scheduleMicrotask(() => print('Microtask 2'));
Future(() => print('This is a new Future'));
print('Dart app ends');
}
Output:
Dart app starts
Dart app ends
Microtask 1
Microtask 2
This is a new Future
In this sample code, scheduleMicrotask
functions are processed before the new Future, although the Future
was added to the event queue before scheduleMicrotask
was added to the micro-task queue. This demonstrates how Dart prioritizes micro-tasks over events.
Conclusion
Now we know everything about synchronous programming and asynchronous programming models, synchronous code is executed sequentially blocking the main thread until each operation completes, while asynchronous code allows tasks to run independently without blocking which keeps the main thread responsive and improves overall performance.
Dart is a single-threaded system and it handles operations concurrency through isolates. Isolates provide a way to achieve concurrency and parallelism by running in separate memory spaces and communicating via message-passing, avoiding the pitfalls of shared memory.
The event loop in Dart manages both synchronous and asynchronous tasks using micro-task and event queues, ensuring smooth task execution and responsiveness.
By understanding these foundational concepts, now we know how to implement synchronous code in Dart... but what about asynchronous code?
Well, in the next article, we will dive into how to implement it in Dart cause this is already a long one and that is enough.
Thank you for your time and I will see you in the next one.
Posted on September 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.