Kyle Heller
Introducing THREADS
Imagine you're hosting a large dinner party and have an elaborate menu to prepare. You've got appetizers, entrees, desserts, and everything in between-- each dish requiring attention to detail and timing. In a conventional setting, you might be the only chef in the kitchen, moving from task to task, a scenario that not only stretches the limits of your culinary skills but also tests the patience of your guests. Now picture this same scenario, but instead of a solo act, you've got a team of specialized chefs at your disposal.
This is the power of concurrency of threads in action: a symphony of cooks working in unison, each chef an expert in their respective dish, all contributing to a grand feast that comes together in harmony.
Introducing Single-Threads
In a traditional, single-threaded approach, you, the sole chef, would tackle each dish one at a time. This method is straightforward but slow, and your guests might be waiting a long time before they can eat.
With the traditional, single-threaded approach, tasks are completed sequentially, aka one after the other - much like cooking a meal course by course.
Lets see this in code:
public class SingleChefKitchen {
public static void main(String[] args) {
cook("Soup"); //Start the soup - this first
cook("Chicken"); //Roast the chicken - this second
cook("Cake"); //Bake the cake - this third
// Everything is done step by step
}
static void cook(String task) {
System.out.println(task + "is ready!");
}
}
Soup is ready!
Chicken is ready!
Cake is ready!
Single-threaded environments, where one command executes at a time, have a straightforward execution model, but they also come with limitations. On one hand they are simple and deterministic but on the other they aren't as performant as they could be due to blocking calls and inability to squeeze the most out of resources available. (among other reasons)
Introducing Multi-Threaded (Concurrency)
Now, envision a professional kitchen at a restaurant. Instead of one chef, there are several, each assigned to different tasks: one chef is dedicated to soups, another to main courses, and a third to desserts. One might be prepping ingredients while others cook or plate dishes. This is the essence of concurrency in Java. Just as having multiple chefs working simultaneously can drastically reduce the time it takes to prepare a meal, concurrency allows multiple threads to run in parallel, speeding up the execution of a program.
Here's how introducing concurrency with multiple chefs (or threads) works in a Java program:
public class ConcurrentKitchen extends Thread {
private String task;
public ConcurrentKitchen(String task) {
this.task = task;
}
@Override
public void run() {
System.out.println(task + " is underway.");
// Mimic task execution, which happens in parallel with other tasks
}
public static void main(String[] args) {
// Start tasks concurrently, like chefs starting their preparations
new ConcurrentKitchen("Soup").start(); // Chef 1 starts the soup
new ConcurrentKitchen("Chicken").start(); // Chef 2 roasts the chicken
new ConcurrentKitchen("Cake").start(); // Chef 3 bakes the cake
// All these tasks are now happening at the same time, not one after the other
}
}
Chicken is ready!
Cake is ready!
Soup is ready!
Here notice the soup is the first thread created and yet the last to be printed to console (this is for the purpose of the metaphor, not logic implemented in code snippet). For this specific recipe the soup takes longer than the chicken and the cake to finish cooking. In this case our threads really came in handy as we weren't waiting for that soup to finish before we even started the chicken and waiting for that to finish before we even got around to starting the cake.
Each ConcurrentKitchen thread is like a chef assigned to a specific task. When the .start() method is invoked, it's akin to telling a chef to begin their preparation. Each task runs in its own thread, allowing for multiple operations to occur simultaneously, much like multiple chefs working on different dishes at the same time. This parallel execution means that the overall program (or dinner preparation) completes more quickly than it would if tasks were executed sequentially.
However, just as in a real kitchen, coordination is crucial. Chefs must ensure they're not getting in each other's way, especially when accessing shared resources like ovens or cutting boards. In Java, this coordination is achieved through synchronization, ensuring that threads interact with shared resources in a safe and predictable manner.
Introducing Synchronization
In our example above imagine the soup is cooked on the stove on low. The cake needed to cook at 350 degrees while the chicken must be roasted at 450 degrees. In our previous example the cake and the chicken threads were initiated separately however they were using the same oven at the same temperature and the cake came out burnt! What synchronization allows us to do is ensure that the shared resource (our oven) is only used to cook one item at a time and thus we can cook the cake first, roast the chicken second and all the while the soup (which takes the longest) has been cooking the entire time! This way we can ensure that our dishes that need baked are baked one at a time at their specified temperature.
Note: This has been simplified to help explain conceptually the idea of concurrency. In actual applications details like how long each task takes or how threads are scheduled can vary and are managed by the JVM, not explicitly controlled by the programmer. So while you cannot dictate the exact execution schedule of threads, the way you structure your code and use synchronization can influence the order in which synchronized tasks access shared resources. In other words the cake might initiate before the chicken or vice versa while the other waits.
Here we introduce the ovenLock. This is like having a digital lock on the oven that requires a unique code (the ovenLock object) to operate. When a chef wants to use the oven, they must "unlock" it with this code. If another chef is already using the oven, any subsequent chefs must wait until the oven is free and the lock is released before they can proceed with their task. So in our example the chef with the chicken must wait for the chef who's currently using the oven to bake our cake.
Lets see this in code:
class KitchenTask extends Thread {
private String task;
private static final Object ovenLock = new Object();
public KitchenTask(String task) {
this.task = task;
}
@Override
public void run() {
switch (task) {
case "Soup":
cookOnStove(task);
break;
case "Cake":
case "Chicken":
synchronized (ovenLock) {
bakeInOven(task);
}
break;
}
}
private void cookOnStove(String dish) {
System.out.println(dish + " is simmering on the stove...");
// Simulate cooking time
System.out.println(dish + " is ready!");
}
private void bakeInOven(String dish) {
System.out.println(dish + " is baking in the oven...");
// Simulate baking time
System.out.println(dish + " is ready!");
}
public static void main(String[] args) {
new KitchenTask("Soup").start(); // Soup can start right away
new KitchenTask("Cake").start(); // Needs oven, starts right away synchronized
new KitchenTask("Chicken").start(); // Also needs oven, synchronized with Cake so needs to // wait for it to finish first
}
}
Soup is simmering on the stove...
Cake is baking in the oven...
Cake is ready!
Chicken is baking in the oven...
Chicken is ready!
Soup is ready!
In a real kitchen, efficient use of shared resources goes beyond just waiting for turns. Chefs must communicate, plan, and sometimes adjust their workflows to optimize the overall efficiency and output of the kitchen. Similarly, in Java, synchronization is part of a broader strategy for managing concurrency. Developers must carefully consider which parts of their code need to be synchronized to ensure data integrity while also striving to avoid bottlenecks or deadlocks, which can occur when threads wait on each other indefinitely.
To Wrap It Up
Mastering synchronization and multi-threading is about finding the right balance between allowing concurrent execution for efficiency and imposing necessary controls to ensure safety and correctness. Just as a well-run kitchen smoothly produces meal after meal with precision and flair, well-designed concurrent Java applications can perform complex, multitasked operations with speed and reliability.
I hope that by understanding concurrency as a team of chefs working together in a kitchen, it becomes easier to grasp how multiple threads can operate simultaneously in a Java program, each executing a different task, yet working together to complete the overall objective more efficiently.
I'll see you in the next one! Bon appetit!