Concurrency-Safe Execution Using Ballerina Isolation
The concept of isolation in Ballerina simplifies development by ensuring the safety of shared resources during concurrent execution.
Join the DZone community and get the full member experience.
Join For FreeThe Ballerina language establishes a concurrent friendly approach to programming through light-weighted threads called strands. This is achieved by providing support for both preemptive and cooperative multitasking. When executing a concurrent program in a multi-threaded environment, the safe usage of shared resources is pivotal. This is obtained through a language concept called isolation. In this article, we will take an in-depth look at the concurrent safety support of Ballerina and see how HTTP services can be implemented to provide timely and accurate responses using isolation.
Race Condition in Concurrent Programming
In order to maintain the dynamic nature of a service, the following two aspects are considered during its implementation.
- Performance - Ensure the liveness through immediate and continuous responses
- Safety - Ensure responding with correct results and service not reaching a bad state
The performance of a service can be elevated through concurrent programming, as it allows independent operations to be executed concurrently using multiple threads. But the challenge in designing such a program is to avoid the state of shared resources getting modified simultaneously and producing erroneous results. An appropriate solution for this condition needs to detect the unwanted data races in a concurrent program without significant performance impact. Ballerina rectifies the race conditions in the program by constructs like lock
statements and isolated
qualifiers along with the immutability support.
Let’s take a look at some sample Ballerina code that shows us the usage of lock
statements to safely access mutable resources.
Lock Statements
A lock
statement can be used in a program to safely access the mutable states from multiple strands that run on separate threads. The code block that is enclosed within a lock
statement acts as an atomic block that prevents concurrent execution of the code block from different strands.
string[] menu = ["pizza", "hot dog", "fries"];
function modifyMenu(int index, string dish) {
lock {
menu[index] = dish;
}
}
The above sample shows a function that modifies a module-level variable menu
. As the scope of menu
is module level, it can be accessed and modified concurrently if multiple strands execute the function modifyMenu
. Having a lock
statement where we modify the variable menu
can solve this problem.
The usage of locks in a program is implementation-dependent. Users can have a naive implementation that contains a single global recursive lock. Alternatively, users can have an efficient implementation by having fine-grained locks using compile-time lock inference.
As using lock
statements in the correct manner is an added responsibility for the user, we require more controlled constructs when implementing a service to ensure the execution of methods in a service does not create a data race.
Let’s look at an example HTTP service that can result in a data race.
Sample HTTP Service With Possible Race Condition
import ballerina/http;
string[] menu = ["pizza", "hot dog", "fries"];
service / on new http:Listener(8080) {
resource function post menu(@http:Payload string dish, int index) {
menu[index] = dish;
}
}
The above code will start a service with an HTTP listener on port 8080 that contains an HTTP resource at the path /menu
. As the resource method mutates the module-level variable menu
, a data race is possible if concurrent requests are made to that method. Therefore, it is beneficial to identify the safety of strands executing a resource method on separate threads while developing the service.
The concept of isolation is introduced to improve this situation.
Isolated Functions
An isolated
function is a function that complies with the following constraints.
- It can only access mutable states through its arguments
- It can only call
isolated
functions - It can access immutable states without any restrictions
Execution of isolated
functions can ensure concurrency safety if the arguments passed to the functions are safe.
final string dish = "burger";
isolated function modifyMenu(string[] menu, int index) {
menu[index] = dish;
}
In the above sample, the isolated function modifyMenu
can access the module-level variable dish
without restrictions as it is immutable. But, there is still a possibility of a race condition as the user can pass a mutable common state as an argument. For example, if a mutable module-level variable is passed as the argument menu
, then we can’t ensure the concurrency safety of the isolated function modifyMenu
.
Isolated Functions Complementing Readonly Data
We can confirm a complete concurrency safe execution of an isolated function if all the arguments of that function are readonly
. In Ballerina, it is guaranteed that a value can never be mutated if its declared type is a subtype of readonly
. This restricts the isolated
function from mutating its arguments.
type Menu string[] & readonly;
final Menu menu = ["pizza", "hot dog", "fries"];
isolated function getLastDish() returns string {
return menu[menu.length() - 1];
}
In the above example, the type of variable menu
is string[] & readonly
. This indicates that the value of menu
is deeply immutable. In other words, we are not allowed to modify the elements of the string array menu
. And as it is declared final
, we are not allowed to assign a new value to the variable either. Therefore, an isolated function can access final
variables with readonly
type without using any lock
statements.
Isolated Expressions and Locks
Allowing only immutable states and mutable arguments in an isolated function can be too restrictive. The concept of Isolated root allows access to mutable states inside an isolated function.
A value is an isolated root if a mutable state is reachable from that value and cannot be reached from outside except through itself.
An expression is identified as an isolated
expression if it follows the rules that guarantee its value to be an isolated root. The language specifies a set of conditions to be fulfilled by an expression in order to consider it as an isolated
expression depending on the expression kind.
For example,
- If an expression is a subtype of
readonly
, then it is always isolated. - A list constructor expression
[E1, E2]
is isolated if its sub-expressionsE1
andE2
are isolated.
isolated
variables and isolated
objects are considered isolation roots. It is guaranteed that any mutable state which is freely reachable from an isolated object or an isolated variable is accessed only through a lock
statement. This ensures there will be no data race when accessing that mutable state.
Isolated Variables
An isolated variable is a non-public, module-level variable that is initialized with an isolated expression. A lock
statement must fulfill the following rules when accessing an isolated variable.
- It must be accessing only one
isolated
variable - It can call only
isolated
functions - The value transfers must be done using
isolated
expressions
isolated string[] menu = ["pizza", "hot dog", "fries"];
isolated function modifyMenu(int index, string dish) {
lock {
menu[index] = dish;
}
}
isolated function getLastDish() returns string {
lock {
return menu[menu.length() - 1];
}
}
In the above code, the isolated
variable menu
is initialized with an isolated
expression. And, regardless of its mutable state, it is accessed in the isolated
functions modifyMenu
and getLastDish
within lock
statements.
Service Concurrency Through Isolated Methods
Our initial goal was to achieve concurrency safety when executing resource/remote methods of services in order to improve their performance. This can be solved by using isolated
methods in service objects.
isolated string[] menu = ["pizza", "hot dog", "fries"];
class MenuService {
int servings = 0;
isolated function modifyMenu(int index, string dish) {
lock {
menu[index] = dish;
}
}
isolated function getLastDish() returns string {
lock {
return menu[menu.length() - 1];
}
}
function addServing() {
self.servings += 1;
}
}
An isolated
method is an object method that behaves similarly to an isolated function and treats the self
as a parameter. In the above sample, we have isolated methods modifyMenu
and getLastDish
in the object MenuService
. When a listener makes calls to these isolated methods, we can ensure concurrency safety, if both the object itself and the passed parameters are safe. But in this case, we have a mutable field servings
in the object MenuService
and it can be mutated during the concurrent calls to the methods. Therefore, we need more control over the service object to achieve complete concurrency safety.
Isolated Objects
The behavior of an isolated
object is similar to a module with isolated
variables.
In order to provide concurrency safety to the object, the mutable fields in an isolated
object must meet the following constraints.
- They must be
private
. Therefore it can be accessed only by usingself
- They must be initialized with an
isolated
expression - They can only be accessed within a
lock
statement - The
lock
statement that mutates the fields must follow the same rules forself
as for anisolated
variable - The field is mutable unless it is
final
and has type that is subtype ofreadonly
isolated string[] menu = ["pizza", "hot dog", "fries"];
isolated class MenuService {
private int servings = 0;
isolated function modifyMenu(int index, string dish) {
lock {
menu[index] = dish;
}
}
isolated function getLastDish() returns string {
lock {
return menu[menu.length() - 1];
}
}
isolated function addServing() {
lock {
self.servings += 1;
}
}
}
Consider the above code. We have a private
mutable field servings
in the isolated object MenuService
that is accessed only inside a lock
statement.
The language infers the objects without mutable fields as inherently isolated. Though, the user needs to use lock
statements in appropriate places, such as accessing self
with mutable state and accessing mutable module-level variables.
Sample HTTP Service That Supports Concurrency Safe Execution
Combining all these features, we can assure the safe execution of a concurrent service, if the listener handles calls to the service methods where:
- The parameters of the method are immutable or
isolated
- The method is an
isolated
method - The service object that contains the method is
isolated
isolated string[] menu = ["pizza", "hot dog", "fries"];
isolated service / on new http:Listener(8080) {
private int servings = 0;
isolated resource function post menu(@http:Payload string dish, int index) {
lock {
menu[index] = dish;
}
}
isolated resource function get lastDish() returns string {
lock {
return menu[menu.length() - 1];
}
}
isolated resource function post serving() {
lock {
self.servings += 1;
}
}
}
The above HTTP service provides complete support for concurrency safety by associating with isolation and lock
statements.
Summary
In this article, we explored the language constructs of Ballerina such as lock
statements, immutable states, and isolation, and how we can implement a concurrent service that assures the safety of its shared resources. We successfully developed a concurrency-safe HTTP service by combining these features.
For more information on this functionality, refer to Ballerina examples.
Opinions expressed by DZone contributors are their own.
Comments