Lesson 1
Using Semaphores for Resource Management
Introduction to Using Semaphores for Resource Management

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.

What You'll Learn

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() and release().
  • 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.

Understanding Semaphores

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().

Setting Up the Print Queue

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.

Java
1import 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.

Acquiring and Releasing Printers

Next, we’ll implement methods that allow threads to acquire and release printers:

Java
1 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 calls release() to return the permit. This allows another waiting thread to acquire the printer.

Printing Jobs

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:

Java
1 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.

Creating Print Jobs

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.

Java
1public 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.

Running the Print Queue

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:

Java
1public 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.

Why It Matters

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!

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.