Welcome to the first lesson of our Advanced Concurrency Utilities course! In this lesson, we’ll explore semaphores, a critical tool for managing resource access in multithreaded environments. Semaphores help control the number of threads that can access shared resources, ensuring efficiency and preventing resource exhaustion. You’ll learn how semaphores work and how to implement them in real-world scenarios.
In this lesson, you'll gain a comprehensive understanding of:
- Counting semaphores and how to use them for controlled access to shared resources.
- How to acquire and release permits using methods like
acquire()
andrelease()
. - How semaphores can help you prevent resource exhaustion without busy waiting.
By the end of the lesson, you’ll be able to use semaphores to efficiently manage resources in your applications.
A semaphore is a shared integer variable that helps control access to a resource by multiple threads. At its core, a semaphore's primary function is to allow or deny access to shared resources based on the availability of "permits." A semaphore tracks the number of available permits and blocks threads if none are available, ensuring that only a certain number of threads can access the resource at any given time.
The main purposes of semaphores include:
-
Resource Management: Semaphores limit the number of threads that can access a particular resource (such as a database connection or a file) concurrently. This prevents resource exhaustion.
-
Non-Busy Waiting: One of the key advantages of semaphores is that they do not require "busy waiting." Instead of constantly checking whether the resource is available (which wastes CPU cycles), threads can block and wait for a permit to become available, allowing other threads to run.
-
Signaling Between Threads: Semaphores can also be used to signal between threads — for example, one thread can release a semaphore permit when it's done, and another thread can acquire it to proceed.
A semaphore is initialized with a number of permits (representing available resources). Every time a thread tries to access a resource, it must first acquire a permit by calling acquire()
. If no permits are available, the thread will block until one is released. Once a thread is done with the resource, it releases the permit using release()
.
Let’s implement a real-world example where semaphores manage access to a limited number of printers in a print queue. Below is the initial PrintQueue
class that uses a semaphore to control access to the printers. The semaphore is initialized with a number of permits equal to the available printers.
Java1import java.util.concurrent.Semaphore; 2 3public class PrintQueue { 4 private final Semaphore semaphore; 5 6 public PrintQueue(int numberOfPrinters) { 7 semaphore = new Semaphore(numberOfPrinters); 8 }
The constructor takes the number of available printers as input, and the semaphore limits the number of threads that can use the printers at once.
Next, we’ll implement methods that allow threads to acquire and release printers:
Java1 public void acquirePrinter() throws InterruptedException { 2 semaphore.acquire(); 3 } 4 5 public void releasePrinter() { 6 semaphore.release(); 7 }
-
acquire()
: This method blocks the thread if no permits are available. The thread will wait until another thread releases a permit. If a permit is available, the thread acquires it and proceeds. -
release()
: After a thread finishes printing, it callsrelease()
to return the permit. This allows another waiting thread to acquire the printer.
We’ll simulate the printing job with a delay to represent the time taken to process each print job. The following code adds a delay using Thread.sleep()
to mimic printing time:
Java1 public void printJob() { 2 try { 3 long duration = (long) (Math.random() * 5000); 4 System.out.println(Thread.currentThread().getName() + 5 ": Printing a Job for " + (duration / 1000) + " seconds"); 6 7 Thread.sleep(duration); 8 9 } catch (InterruptedException e) { 10 e.printStackTrace(); 11 } 12 } 13 14 public int getAvailablePrinters() { 15 return semaphore.availablePermits(); 16 }
The printJob()
method simulates the actual printing process by adding random delays and printing messages to the console. The availablePermits()
method returns the number of permits available, showing how many printers are free.
Now, let’s define the jobs that will use the PrintQueue
. The Job
class implements the Runnable
interface, allowing multiple threads to run concurrently. Each thread represents a print job that acquires a printer, prints a document, and releases the printer afterward.
Java1public class Job implements Runnable { 2 private final PrintQueue printQueue; 3 4 public Job(PrintQueue printQueue) { 5 this.printQueue = printQueue; 6 } 7 8 @Override 9 public void run() { 10 System.out.println(Thread.currentThread().getName() + ": Going to print a document"); 11 try { 12 // Acquire a printer before starting the print job 13 printQueue.acquirePrinter(); 14 System.out.println(Thread.currentThread().getName() + ": Acquired a printer. Available printers: " + printQueue.getAvailablePrinters()); 15 16 // Simulate the printing job 17 printQueue.printJob(); 18 } catch (InterruptedException e) { 19 e.printStackTrace(); 20 } finally { 21 // Release the printer after printing 22 printQueue.releasePrinter(); 23 System.out.println(Thread.currentThread().getName() + ": Finished printing. Available printers: " + printQueue.getAvailablePermits()); 24 } 25 } 26}
In the run()
method, the thread begins by announcing it will print a document. It then calls acquirePrinter()
, which blocks until a permit (printer) is available. Once a printer is acquired, the thread simulates the printing process by calling printJob()
. After the job is completed, the printer is released using releasePrinter()
, making it available for other threads. This ensures that no more threads than available printers can proceed at once.
Finally, we can run the system by simulating multiple threads trying to print documents using a limited number of printers. Here’s the Main
class:
Java1public class Main { 2 public static void main(String[] args) { 3 int numberOfPrinters = 2; 4 PrintQueue printQueue = new PrintQueue(numberOfPrinters); 5 6 Thread[] threads = new Thread[5]; 7 8 for (int i = 0; i < threads.length; i++) { 9 threads[i] = new Thread(new Job(printQueue), "Thread " + (i + 1)); 10 } 11 12 for (Thread thread : threads) { 13 thread.start(); 14 } 15 } 16}
In this example, we initialize the PrintQueue
with two printers and create five threads (representing five different jobs). Since only two printers are available, the other threads must wait until a printer becomes available.
Semaphores play a crucial role in controlling access to shared resources in concurrent applications. They ensure that no more threads than allowed can access a particular resource, making them ideal for managing situations like controlling access to a limited number of printers or database connections.
The key advantages of semaphores include:
-
Efficient Resource Management: Semaphores limit the number of concurrent threads accessing shared resources, preventing overuse.
-
Blocking Without Busy Waiting: Semaphores allow threads to block and wait for resource availability instead of wasting CPU cycles on busy waiting.
-
Fairness and Order: Semaphores can also be configured to ensure fair access to resources, allowing threads to be processed in a particular order.
Now that you’ve learned how to implement semaphores for resource management, let’s move on to the practice section, where you’ll apply this concept to real-world scenarios!