In Java, developers rely on a built-in multi-threading system that uses shared data and locks to prevent conflicts. To utilize this system, they identify the data to be shared among multiple threads and mark the code sections that access this data as "synchronized".
This approach incorporates a locking mechanism, ensuring that only one thread can access the shared data at a time. While this prevents race conditions, it also introduces the risk of deadlocks. In contrast, Scala favors the "Actor model" for concurrency, which can be used alongside Java threads, offering a more efficient way to achieve scalability, as explained on computerstechnicians.
The Actor model provides a more manageable concurrency approach, helping to avoid the complexities associated with Java's native concurrency model.
Understanding Actors in Concurrency
- When creating an Actor, Akka provides an ActorRef, enabling message sending and allowing actors to communicate with each other.
- Akka executes actors on actual threads, with multiple actors potentially sharing a single thread, making it an efficient way to utilize system resources.
- An Actor can spawn multiple child actors, creating a hierarchical structure for concurrent execution.
- Actors exclusively interact through asynchronous messages, never via direct method calls, ensuring loose coupling and flexibility.
- Each actor has a unique address and a mailbox, where other actors can deliver messages, enabling efficient communication.
- The actor processes all mailbox messages in sequential order, following a FIFO (First-In-First-Out) implementation, ensuring that messages are processed in the order they were received.
Let's explore an example to see how actors can be used to achieve concurrency in practice.
case class Add(num1: Int, num2: Int)
case class Substract(num1: Int, num2: Int)
case class Divide(num1: Int, num2: Int)
class Calculator extends Actor {
def receive = {
case Add(num1, num2) => context.actorOf(Props[Addition]) ! Add(num1, num2)
case Substract(num1, num2) => context.actorOf(Props[Substraction]) ! Substract(num1, num2)
case Divide(num1, num2) => context.actorOf(Props[Division]) ! Divide(num1, num2)
}
}
class Addition extends Actor {
def receive = {
case Add(num1, num2) => println(num1 + num2)
}
}
class Substraction extends Actor {
def receive = {
case Substract(num1, num2) => println(num1 - num2)
}
}
class Division extends Actor {
def receive = {
case Divide(num1, num2) => println(num1 % num2)
}
}
We have a simple example of a calculator that performs three operations: addition, subtraction and division, and also have created three messages and has taken child actors accordingly. Now let’s create the main method:
val system = ActorSystem("Demo")
val actor1 = system.actorOf(Props[Calculator])
println("Started Calculating.....")
println("Addition")
actor1 ! Add(2, 3)
println("Substraction")
actor1 ! Substract(3, 2)
println("Divide")
actor1 ! Divide(2,3)
And the output of this is as follows:
Started Calculating.....
Addition
Substraction
Divide
5
1
2
Upon examining the output, it becomes apparent that the primary actor dispatches all three messages to the parent actor, Calculator, concurrently, and all three operations are executed in an asynchronous manner.
Coordinating Shared Resources and Non-Blocking Operations
In a scenario where two actors transmit messages to the same actor to access the same resource simultaneously, the receiving actor will store the messages in its mailbox and execute them in a sequential order.
Notably, the sender thread does not await a return value when transmitting a message to another actor, ensuring asynchronous operations.
In a multi-threaded environment, you don’t need to concern yourself with synchronization, as all messages are processed sequentially, and actors do not share each other’s data.
The diagram below illustrates this concept: while Actor B is processing A’s message, C’s message will remain in the mailbox. Once A’s message is complete, Actor B will execute C’s message.
Error Handling Strategies
Akka provides supervision strategies to handle errors when an actor fails during execution, allowing the parent actor to supervise and manage the failure.
There are two key supervision strategies:
-
OneForOne
: applies exclusively to a failed child actor. -
allForOne
: applies to all sibling actors.
For instance, if we pass the following message to the Calculator actor, it will throw an exception:
actor1 ! Divide(2, 0)
This can be handled by adding the following code snippet to the parent actor:
override val supervisorStrategy = OneForOneStrategy(){
case _: ArithmeticException => Resume
case _ => Restart
}
This article aims to have provided you with valuable insights and practical guidance.