Multithreading & Concurrency
Theoretical Foundations of Multithreading and Concurrency
Multithreading and concurrency can be effectively understood through the analogy of a professional kitchen. In a single-threaded environment, one chef handles all tasks sequentially, such as chopping, grilling, and boiling. In this model, each dish is prepared one at a time, meaning tasks must run in strict order and slow tasks inevitably delay the start of all subsequent tasks. Conversely, multithreading is akin to multiple chefs working simultaneously, where one chops, another grills, and another boils. These tasks run in parallel rather than sequentially, allowing orders to be completed faster and with greater efficiency. In the context of the Java programming language, multiple threads can run at once, performing different tasks concurrently. However, just as in a real kitchen where chefs must be coordinated to ensure they do not attempt to use the same oven or ingredient at the same precise moment, multithreading requires careful management. This coordination is analogous to synchronization and the mitigation of race conditions in computer programming.
Fundamental Preamble: Processes versus Threads
To understand multithreading, it is necessary to distinguish between a process and a thread. A process is defined as a software program in execution and is considered a self-contained running program with its own dedicated address space. A multitasking operating system is capable of running more than one process at a time. Within a process, a thread is a smaller unit that represents a single sequential flow of control. Consequently, a single process can have multiple concurrently executing threads, making threads "lightweight processes." Every software program contains at least one thread, which is known as the Main thread. Additional threads can be spawned, or created, to handle different tasks. A multi-threaded program contains two or more parts that can run concurrently, allowing each part to handle a different task simultaneously. This design makes optimal use of available resources, particularly when a computer is equipped with multiple CPUs.
Objectives and Logic of Multithreading
Multithreading is driven by several core objectives related to resource management and efficiency. Firstly, multi-threading is used for resource utilization; programs often must wait for external operations, such as input or output (I/O), and during these wait periods, the program may do no useful work. It is more efficient to utilize this wait time by letting another thread run. Secondly, multithreading ensures fairness. Multiple users and programs may have equal claims on a machine's resources, and it is preferable to share those resources via finer-grained time slicing rather than allowing one program to run to completion before starting another. Finally, multithreading provides convenience for programs with multiple tasks. While objects provide a way to divide a program into independent sections, there is often a need to turn a program into separate, independently running subtasks. Each of these subtasks is a thread, consisting of a piece of code that runs concurrently with other threads.
The Life Cycle and Primary States of a Thread
A thread moves through various states during its existence. In the New state, the thread is considered not yet alive. The Runnable or Ready-to-run state occurs when a thread starts its life and is waiting for its turn on the processor. The Running state is reached when the thread is currently executing its code. The Dead state is achieved once the run() method completes its execution. Finally, a thread may enter a Blocked state when it is waiting for resources that are currently held by another thread. Exploration of threads involves several operations including setting priority, joining, yielding, sleeping, interrupting, and the management of daemon threads.
Mechanisms for Thread Creation and Priority
There are two primary ways to create a thread in Java: extending the java.lang.Thread class or implementing the java.lang.Runnable interface. The choice between these two is application-specific. If a programmer extends the Thread class, the resulting subclass cannot extend any other class due to Java's single inheritance model. However, implementing the Runnable interface avoids this limitation and can also avoid the full overhead of the Thread class, which may be excessive in some scenarios.
Thread priority is a mechanism that tells the scheduler how important a thread is, allowing the thread scheduler to determine the execution schedule. Priorities are represented as integer values ranging from a minimum to a maximum level.
The default priority level for a thread is .
Thread Scheduling: Joining, Yielding, and Sleeping
Control over a thread's execution can be managed through specific method calls. Joining occurs when one thread calls the join() method on another thread, causing the first thread to wait for the second to complete before it proceeds. Yielding, via the yield() method, causes the currently executing thread to pause to allow other threads to execute. It is important to note that yield() is merely a hint to the implementation; there is no guarantee that the system will listen to it, and it is generally used only in rare situations and not for serious application tuning. Sleeping involves the sleep() method, which causes the currently executing thread to pause for a specified number of milliseconds. Because it is possible for sleep() to be interrupted before its time expires, it must always be placed inside a try block. Unlike other methods, sleep() simply stops execution for a while and does not release any locks currenty held by the thread.
Interruption, Preemption, and Daemon Threads
Interruption is a mechanism where a thread that is waiting or sleeping can be made to prematurely stop waiting. Generally, an InterruptedException is thrown when another thread interrupts the thread that is calling a blocking method. Threading models are categorized as either preemptive or non-preemptive. In a preemptive model, a currently running thread can be interrupted to give CPU time to another thread, meaning a higher-priority thread can preempt a lower-priority one. In a non-preemptive model, a running thread cannot be interrupted; it runs until it voluntarily yields control to the scheduler or finishes its task.
Additionally, Java supports Daemon threads. A daemon thread is a low-priority service provider thread intended to provide general background services as long as the program is running. The garbage collector is a prime example of a daemon thread. The Thread.setDaemon() method is used to mark a thread as either a daemon or a user thread.
Thread Synchronization and Locking Mechanisms
Concurrency issues arise when multiple threads access the same resource, which can lead to unforeseen results or data corruption. For example, if multiple threads write to the same file simultaneously, they may conflict while opening and closing it or corrupt the data itself. To prevent this, synchronization is required so that only one thread can access a resource at a time. In Java, this is managed through monitors. Every object has a monitor that can be locked or unlocked, and only one thread at a time can hold the lock on a monitor. The Java language provides synchronized blocks and methods to handle these tasks.
A Mutex, or Mutual Exclusive locking mechanism, is a synchronization primitive that allows only one thread to access a resource at a time, ensuring that only the thread that locks the mutex can unlock it. A semaphore is a signaling mechanism that controls access through a counter. There are two types: a Binary Semaphore, which is similar to a mutex with values of or , and a Counting Semaphore, which allows a specified number of threads to access a resource.
Implementation Rules for Synchronization
Effective synchronization follows specific structural rules. Synchronized methods involve the use of the synchronized keyword, while synchronized blocks allow for fine-grained control by locking specific objects. Intrinsic locks refer to the implicit locking that occurs when using the synchronized keyword. Other tools include CountDownLatch and CyclicBarrier. It is critical to remember that only methods or blocks can be synchronized; classes and variables cannot. If two threads attempt to execute a synchronized method using the same instance of a class, only one can execute at a time. If one method in a class is synchronized, it is often recommended to synchronize all of them, though this is not strictly necessary. Furthermore, while constructors themselves cannot be synchronized, the code inside a constructor can be. The "Rule Zero" of concurrent programming is to never make any assumptions.
Inter-Thread Communication (IPC)
Most threads within an application need to communicate with one another. This is achieved using the wait(), notify(), and notifyAll() methods. These methods allow threads to communicate without race conditions, but they must be used in conjunction with a synchronized lock. Unlike sleep(), these methods are part of the base Object class rather than the Thread class. Crucially, while sleep() does not release a lock when called, the wait() method does release the lock. Furthermore, the only place you can legally call wait(), notify(), or notifyAll() is from within a synchronized method.
Potential Issues: Deadlock, Starvation, and Race Conditions
Multithreading introduces several potential issues that can hinder program performance or correctness. A Race Condition occurs when two or more threads access shared data and try to change it simultaneously, leading to unpredictable results. Starvation happens when a thread is perpetually denied needed resources because other threads are continuously given preference. Priority Inversion occurs when a lower-priority thread holds a resource needed by a higher-priority thread, effectively inverting their priority levels. Data Inconsistency results from threads reading and writing shared data without proper synchronization. Deadlock is a situation where two or more threads are unable to proceed because each is waiting for the other to release a resource, resulting in a standstill. To avoid deadlock, one must be careful about performing operations that take a long time while holding a lock.
The Four Necessary Conditions for Deadlock
For a deadlock to occur, four specific conditions must be met simultaneously. First is Mutual Exclusion, which requires that at least one resource is held in a non-sharable mode. Second is Hold and Wait, where a thread holding at least one resource is waiting to acquire additional resources held by others. Third is No Preemption, meaning resources cannot be forcibly taken away from a thread; they must be released voluntarily. Fourth is Circular Wait, which describes a scenario where a set of threads exists such that each thread is waiting for a resource held by the next thread in the cycle, forming a closed chain.