top of page
Buscar

Race Conditions in Concurrency



Concurrent programming is a powerful tool that allows the execution of multiple tasks simultaneously, increasing a system's efficiency and responsiveness. However, along with its benefits come complex challenges, such as race conditions. In this article, we will explore this problem and the techniques that can be used to solve it.


What are Race Conditions?

Race conditions occur when the behavior of software depends on the sequence or timing of uncontrollable events, such as thread execution. This usually happens when two or more threads access and modify a shared resource simultaneously.


To put it simply, race conditions are a problem where two or more threads access the same resource (variable, database, disk, etc.) at the same time and modify this resource simultaneously. If the order of operations is not properly controlled, it can result in undesired behavior or inconsistent results.


An Example of Race Conditions:

Imagine we have a Counter class that has an increment method to increase a count value and a getCount method to retrieve this value:


public class Counter {private int count = 0;
    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

And here is our main class where three threads try to increment the counter simultaneously:


public class RaceConditionExample {
	public static void main(String[] args) {
        Counter counter = new Counter();

        Runnable task = () -> {
            counter.increment();
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);
        Thread thread3 = new Thread(task);

        thread1.start();
        thread2.start();
        thread3.start();

        try {
            thread1.join();
            thread2.join();
            thread3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Final count: " + counter.getCount());
    }
}

When we run this code, each thread will increment the counter, resulting in a final count of 3, right?

Unfortunately, no. Since the threads execute the same task simultaneously, we cannot guarantee this result.


Why Not?

As mentioned, the threads execute the increment simultaneously, meaning that the following scenario, or similar scenarios, can happen:

  • Counter = 0, thread 1 increments, counter = 1.

  • Counter = 1, thread 2 increments, counter = 2.

  • Counter = 1, thread 3 increments, counter = 2.


In this example, threads 2 and 3 incremented the counter at the same moment, where it had the value of 1 for both threads.


What Now? What Should I Do?



Calm down, young grasshopper, let’s see some strategies to solve this.


Using the synchronized Keyword:

The synchronized method is a way to ensure that only one thread can execute a critical section of code at a specific time, meaning only one thread at a time will pass through the method or class that has the synchronized keyword. Essentially, the method or class using synchronized gets locked until the thread passes through it, and only after the current thread passes through the entire method can the next thread execute the method.


Remember, if you are using a framework like Spring Boot, you can use its own @Synchronized annotation.


Problems with using synchronized:

  • Synchronization can impact system performance, especially if many threads try to access the same resource, resulting in thread contention.

  • If not used correctly, synchronized can lead to deadlocks, where two or more threads are blocked indefinitely, waiting for locks held by each other.


Let’s put it into practice.

In our example, we only need to use the synchronized keyword in our methods to ensure flow synchronization.


public class Counter {
	private int count = 0;
    	public synchronized void increment() {
    		count++;
    	}

    	public synchronized int getCount() {
     	return count;
    	}
}

We Can Also Use ReentrantLock:

ReentrantLock is a more flexible and powerful implementation of the lock concept in Java. It offers greater control over locks and comes with several advanced features not available with synchronized.


ReentrantLock can be triggered in one method and deactivated in another.

What does this mean?

If you have a method (A) that needs to be synchronized and this method calls another (B) that also needs to be in the same synchronization, you can activate the ReentrantLock in method A and only deactivate it in method B. However, since there is a need to lock and unlock manually, care must be taken to ensure no method remains locked.


Let’s use ReentrantLock in our example.

In our Counter class, we use the lock to ensure one thread at a time:


import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    	private int count = 0;
    	private final Lock lock = new ReentrantLock();
    
	public void increment() {
        	lock.lock();
        	try {
            count++;
        	} finally {
         	lock.unlock();
        	}
    	}

    	public int getCount() {
        	lock.lock();
        	try {
          	return count;
        	} finally {
           	lock.unlock();
        	}
    	}
}

Conclusion

Race conditions are a common problem in concurrent programming that can lead to undesired behaviors and crashes. Understanding this problem and applying appropriate techniques, such as synchronization, locks, atomic variables, fixed acquisition order, and timeouts, is crucial for developing robust and efficient software.


The key to dealing with these challenges is always to plan the use of shared resources carefully and rigorously test your concurrent solutions.


 
 
 

Comments


  • Linkedin
  • GitHub

© 2024 by Thalles Vieira All Rights Reserved

Subscribe for me!

Thanks for submitting!

bottom of page