From Dining Philosophers to Deadlocks: Navigating the Pitfalls of Concurrency

published:

Computer Systems concurrency , multithreading , java

1. Introduction#

In modern software development, Concurrent Programming is no longer just an option but a necessity for achieving optimal performance and responsiveness. From smooth and responsive user interfaces for heavy computation to back-end servers that handle thousands of requests simultaneously, we benefit from the power of multithreading in many applications. However, the implementation of high performance is challenging, and it filled with dangerous pitfalls. When multiple threads attempt to access shared resources concurrently, the program can fall into chaos and lead to unpredictable behavior and crashes if programmers do not carefully design it.

The famous computer scientist Dijkstra created a classic thought experiment: The Dining Philosophers Problem (Dijkstra, 1965). This problem elegantly illustrates the fundamental challenges of resource allocation and synchronization in concurrent systems. It is more than just an academic puzzle, but it reveals two of the most common and difficult issues that developers face: Race Conditions and Deadlocks.

This article aims to explore these two core issues in detail. We will use the Dining Philosophers problem as a starting point to analyze how race conditions can silently corrupt data integrity and how deadlocks can bring an entire system to a permanent halt. More importantly, through practical code examples, we will demonstrate how to use tools like synchronization and modern locking mechanisms to write robust and thread-safe code. The goal is to provide a clear guide for developers to navigate these common concurrency pitfalls and build more reliable software.

2. Background and Definitions#

Before understanding the details of deadlocks and race conditions, we must establish a clear context for this discussion. The following definitions of several key terms are crucial for understanding the rest of this article.

Concurrency and Parallelism#

Concurrency and parallelism are two concepts that are often confusing in the field of computer science, but they are two independent concepts:

  • Concurrency is a way of processing multiple tasks. It’s a concept related to structure, which means a program is designed to consist of multiple independent running parts. Even on a single CPU core, these tasks can be interleaved through round robin time slicing, allowing progress to be made over time.

  • Parallelism is a method for executing multiple tasks. It’s a hardware concept that allows multiple tasks to be executed at the same time. This can only be achieved on hardware with multiple computing units, such as multi-core processors.

In short, concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once (Pike, 2012).

Process and Thread#

Modern operating systems manage program execution in two main ways:

  • A process is an instance of an execution environment managed by the operating system. The key point is that each process has a completely isolated memory address space. This isolation provides robustness and security but also causes complex communication between different processes, making process creation and context switching relatively expensive.

  • A thread is a unit of execution within a process. A process can contain multiple threads, which share the process’s memory, address space and resources. This sharing model makes communication between threads very efficient compared to processes. However, each thread must also maintain its own private context to execute independently. This primarily contains a stack for function calls, a program counter (PC) and registers for tracking execution location (Tanenbaum & Bos, 2023).

Programming Paradigms and Concurrency#

The way we write concurrent code depends largely on the programming tools we use. As programming paradigms have evolved from procedural to object-oriented to functional, our strategies for solving concurrency problems have also changed fundamentally.

The earliest one is Procedural Programming. In this paradigm, concurrent programming often needs to be handled in a non-abstract method which is close to the low level of system. Programmers had to work directly with operating system primitives, which gave them a ton of control. But what are disadvantages? It was incredibly complex and dangerously easy to introduce bugs.

Then, Object-Oriented Programming (OOP) came along and gave us better tools to manage the chaos. The core idea of OOP—bundling data and methods into objects—was a natural way to control access to shared resources. Programming languages like Java and Python encapsulate a series of concurrent programming methods or functions, such as threads and locks. Using the synchronized keyword, for instance, a developer could lock an entire object, and other threads cannot access it. This certainly made things simpler, but it introduced a new problem: careless use of locks could seriously hurt performance.

More recently, Functional Programming (FP) has emerged as a popular and clever way to deal with concurrency. FP is built on two key principles: using data that never changes (immutability) and functions that don’t have side effects. This design avoids many classic concurrency issues from the root. After all, if data can’t be modified, the risk of race conditions can just disappear. This makes the program’s behavior far easier to predict.

For this article, we’ll be concentrating on the world of multithreading within a single process. It’s the ability of threads to share memory that makes them so powerful, but it’s also the root cause of challenges like data corruption and program freezes. These are the core problems we will explore next.

3. Core Concepts and Theory#

As we all know, the efficiency of multithreading comes from its shared memory model. However, this sharing is also a double-edged sword. When multiple threads read and write shared data simultaneously without any coordination mechanism, the behavior of the program becomes chaotic and unpredictable.

Race Conditions#

A Race Condition occurs when multiple threads attempt to operate on the same object in an unpredictable order. The root of the problem is that many operations that we think of as single operations are composed of multiple steps.

One of the most classic examples is a simple self-increment operation:

java
public class Counter {
    private int count = 0;
    
    public void add(int value) {
        this.count = this.count + value;
    }
    
    public int getCount() {
        return this.count;
    }
}

This code looks like a single step, but it consists of three separate steps:

  1. Read: Read the current value of this.count
  2. Modify: Calculate the result of current value + value
  3. Write: Write the new value back to this.count

Now, imagine that two threads (thread A and thread B) call this method add(1) at the same time when count is initially 0. A possible catastrophic scenario is as follows:

  • Thread A reads the value of count and gets 0
  • At this point, the system switches to thread B. Thread B also reads the value of count and also gets 0.
  • Thread B modifies 0+1=1 and then writes 1 to count. Now the value of count is 1.
  • The system switches back to thread A. Thread A also calculates 0+1=1 based on the old value 0 earlier and then writes 1 to count.
  • Final result: count is 1, even though we performed the increment operation twice. One update was silently overwritten.

This unpredictable error caused by the non-atomic nature of the operation is called a Race Condition.

Deadlocks#

If race conditions are chaos at the data level, deadlock is a complete gridlock at the process level. Deadlock describes a situation: two or more threads are stuck in an infinite wait because each thread is waiting for a resource that is already held by another thread (Coffman, et al., 1971).

The Dining Philosophers Problem perfectly illustrates the reason for the deadlock. Consider if every philosopher’s action strategy was “pick up the fork in your left hand first, then the fork in your right hand.” At some unfortunate moment, all five philosophers simultaneously picked up their left forks. At this time, each philosopher held a resource (the fork in their left hand) and was waiting for the next resource (the fork in their right hand), but the next resource was already held by their neighbor. This creates a perfect circular wait chain: no one could move forward, and no one could release their own resource. The entire system was stuck in a permanent stalemate, a deadlock.

Starvation#

Another problem related to deadlock is Starvation. It refers to a situation where one or more threads are unable to move forward because they can’t gain the resources they need consistently and unfairly (Tanenbaum & Bos, 2023). Unlike deadlock, the entire system may still be running (other philosophers may be eating normally) in the state of starvation, but a certain unlucky thread is always starved.

4. Practical Examples and Code Walkthroughs#

Now we have explained the basic concepts and challenges in concurrent programming, how do we solve them? So in this section, we will explore how to resolve race conditions and deadlocks through specific code examples. We will mainly use the Java language because its concurrency keywords are very clear.

The Power of Synchronization#

Let’s return to the previous counter example. An unprotected increment method is not thread-safe:

java
public class Counter {
    private int count = 0;
    
    public void add(int value) {
        this.count = this.count + value;
    }
    
    public int getCount() {
        return this.count;
    }
}

The most direct way to solve this problem is to use synchronization. In Java, we can use synchronized keyword to protect this code and ensure that it can only be executed by one thread at any time.

java
public class Counter {
    private int count = 0;
    
    public synchronized void add(int value) {
        this.count = this.count + value;
    }
    
    public int getCount() {
        return this.count;
    }
}

By adding synchronized to the method, we create a mutex lock for this method. Any thread must acquire the lock before calling the add() method. While a thread is executing this method, all other threads attempting to call it will be blocked until the first thread finishes executing and releases the lock. This transforms the non-atomic read-modify-write operation into an indivisible atomic operation, resolving race conditions.

A Smarter Locking Strategy#

The synchronized keyword provides us with a powerful tool to prevent race conditions. However, its strategy of either successfully acquiring the lock or waiting indefinitely is a double-edged sword. In complex scenarios, it is this waiting that creates the conditions for deadlocks.

To build robust concurrent systems, we need a more sophisticated and flexible mechanism, which allows threads to change their strategies and give up waiting if a resource cannot be acquired after being blocked for a long period.

Java’s concurrency library provides an advanced lock implementation called ReentrantLock. Unlike synchronized, ReentrantLock provides a method called .tryLock() that releases locks after the specified timeout.

We can provide a timeout parameter to .tryLock(), which gives our thread a limited waiting time:

java
private final ReentrantLock lock1 = new ReentrantLock();
private final ReentrantLock lock2 = new ReentrantLock();

public void performAction() throws InterruptedException {
    // waiting at most 100 milliseconds to acquire the first lock
    if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
        try {
            // try to acquire second lock with at most 100 milliseconds waiting
            if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
                try {
                    // successfully acquired two locks and executed tasks
                    System.out.println("Action performed successfully.");
                } finally {
                    lock2.unlock(); // release second lock
                }
            } else {
                // give up when failed to acquire second lock
                System.out.println("Could not acquire lock2, backing off.");
            }
        } finally {
            lock1.unlock(); // ensure that lock1 always is released
        }
    } else {
        // give up when failed to acquire first lock
        System.out.println("Could not acquire lock1, backing off.");
    }
}

In this method, the thread will only wait for a maximum of 100 milliseconds for each lock. If the attempt fails after the timeout, .tryLock() returns false, and the thread can execute alternative logic instead of waiting indefinitely.

Ensuring Lock Release#

One of the most common and dangerous mistakes is forgetting to release a lock. If a thread acquires a lock but throws an exception while executing a task, but there is no mechanism to catch it, the lock may never be released. This causes all other threads to be blocked forever.

To prevent this, the best practice is to place the unlock() call inside a finally block within a try…finally code block. The finally block guarantees that the code will always be executed whether the code completes correctly or exits due to an exception. This ensures the release of the lock. It is the cornerstone of robust concurrent code.

java
public class LockReleaseExample {
    private final ReentrantLock lock = new ReentrantLock();
    
    public void performSafeAction() {
        lock.lock();
        
        try {
            System.out.println("Task is running...");
        } finally {
            // Guaranteed to execute, ensuring the lock is always released
            lock.unlock();
            System.out.println("Lock released.");
        }
    }
}

5. Considerations and Trade-offs#

Although synchronization and locks are essential tools for building reliable concurrent programs, they are not without cost. Developers must carefully consider the impact of these mechanisms when deciding to use them.

Safety and Performance#

An important balance in concurrent programming is between thread safety and program performance.

The purpose of using locks is to ensure data consistency or safety. However, when a thread is blocked waiting for a lock, it cannot perform useful work. If the lock granularity is too coarse, it will severely limit concurrency. For example, using a lock to protect a large method that contains multiple independent resources will actually decrease performance. This could cause the multi-threaded program to become an almost serial execution flow, which decreases throughput. Developers need to carefully design locking strategies, such as using fine-grained locking, but this introduces the next problem.

Increased Complexity#

Maintaining concurrent code is much more difficult than traditional serial code. Introduction of locking mechanism means that developers must manually manage the state of resources. You have to precisely control over when locks are acquired and when they are released, and you must consider all possible execution paths to avoid problems such as deadlocks.

Additionally, debugging concurrent programs is extremely difficult because bugs like race conditions are often hard to reproduce. It is difficult to control the order of program execution at the operating system level in modern coding.

Therefore, we are not only solving a performance problem but also introducing some new challenges for correctness and complexity when using concurrency. But in any case, maintaining the atomicity of object operations is the key to avoiding all problems.

6. Conclusion#

We have explored the fundamental challenges of multithreaded programming and solutions. This article began by identifying the core issues when multiple threads interact with shared resources. Our focus was on two major problems: Race Conditions and Deadlocks.

We analyzed how race conditions can destroy data due to non-atomic operations and how deadlocks can cause an entire system to halt due to circular resource dependencies. We then examined the primary solutions for these issues, like Synchronization and Lock mechanisms. While these tools are effective, they introduce their own trade-offs between safety, performance, and code complexity.

So, let’s summarize what we have learned:

  • Shared memory is the root cause. The efficiency of multithreaded programming comes from shared memory, but this is also the primary source of concurrency problems. Any “write” access to shared data must be protected.

  • Identify the specific problems. It is critical to distinguish between Race Conditions, which lead to incorrect data, and Deadlocks, which cause program freeze. Understanding the cause of each is the first step to solving them.

  • Use locks carefully. Synchronization is necessary for thread-safety. However, locks must be managed correctly to avoid performance issues and potential deadlocks. The best practice is to release locks reliably for instance in a finally block.

Next Steps#

For readers interested in further study, we recommend you explore advanced concurrency models like the Actor Model (Hewitt, et al., 1973) or Software Transactional Memory (STM) (Shavit & Touitou, 1995). To apply these concepts in practice, a valuable next step would be to build a multithreaded application, such as a simple web crawler or a producer-consumer queue.

7. References#

Coffman, E., Elphick, M. & Shoshani, A., 1971. System Deadlocks. ACM Computing Surveys, 3(2), pp. 67-78.

Dijkstra, E., 1965. Solution of a problem in concurrent programming control. Communications of the ACM, 8(9), p. 569.

Hewitt, C., Bishop, P. & Steiger, R., 1973. A Universal Modular ACTOR Formalism for Artificial Intelligence. s.l., s.n., pp. 235-245.

Pike, R., 2012. Concurrency is not Parallelism. [Online] Available at: https://go.dev/blog/waza-talk [Accessed 8 October 2025].

Shavit, N. & Touitou, D., 1995. Software Transactional Memory. s.l., s.n., pp. 204-213.

Tanenbaum, A. & Bos, H., 2023. Modern Operating Systems. 5th ed. London: Pearson.