.NET Threading: Solve the Unisex Bathroom Problem

.NET Threading: Solve the Unisex Bathroom Problem

Threading in .NET allows us to handle concurrent execution of code, making applications more responsive and efficient. A classic synchronization problem that illustrates threading concepts well is the Unisex Bathroom Problem. This problem requires us to manage access to a shared resource (a unisex bathroom) by multiple threads representing men and women.

In this blog post, we will explore how to solve this problem using C# with threading, locks, and semaphores to ensure fairness and avoid race conditions. Along the way, we will also cover key threading issues like race conditions, deadlocks, thread starvation, and livelocks.

Understanding the Unisex Bathroom Problem

The Unisex Bathroom Problem states that:

  • The bathroom can be used by multiple people at the same time, but only if they are of the same gender.

  • If people of a different gender want to enter, they must wait until the bathroom is empty.

  • The system should prevent race conditions and deadlocks while maintaining fairness.

This problem is a great way to understand synchronization techniques such as:

  • Mutex for ensuring exclusive access.

  • Semaphore for limiting the number of users.

  • Monitor and lock for managing shared state.

Brief Overview of Synchronization Constructs

Before diving into the solution, let's briefly understand the key synchronization constructs used in .NET:

Lock

  • A lock ensures that only one thread can enter a critical section at a time.

  • It is syntactic sugar for Monitor.Enter and Monitor.Exit.

  • Simple to use but can lead to deadlocks if not handled correctly.

  •   private static readonly object _lockObj = new object();
    
      public void SafeMethod()
      {
          lock (_lockObj)
          {
              // Critical section
              Console.WriteLine("Thread-safe operation");
          }
      }
    

Monitor

  • Provides more control than lock by allowing explicit use of Monitor.Wait and Monitor.Pulse.

  • Monitor.Wait(object lockObject): Releases the lock and makes the current thread wait until it is signaled.

  • Monitor.Pulse(object lockObject): Wakes up a single waiting thread.

  • Monitor.PulseAll(object lockObject): Wakes up all waiting threads.

  • Useful for implementing condition-based thread synchronization.

  •   private static readonly object _monitorObj = new object();
    
      public void SafeMonitorMethod()
      {
          bool lockTaken = false;
          try
          {
              Monitor.Enter(_monitorObj, ref lockTaken);
              // Critical section
              Console.WriteLine("Thread-safe operation using Monitor");
          }
          finally
          {
              if (lockTaken)
              {
                  Monitor.Exit(_monitorObj);
              }
          }
      }
    

Mutex

  • Similar to lock but can work across processes.

  • Only one thread can own a mutex at a time.

  • Slower than lock due to additional OS overhead.

  •   using System.Threading;
    
      public void SafeMutexMethod()
      {
          using (Mutex mutex = new Mutex(false, "GlobalMutexExample"))
          {
              if (mutex.WaitOne(TimeSpan.FromSeconds(5), false))
              {
                  try
                  {
                      // Critical section
                      Console.WriteLine("Thread-safe operation using Mutex");
                  }
                  finally
                  {
                      mutex.ReleaseMutex();
                  }
              }
              else
              {
                  Console.WriteLine("Could not acquire Mutex");
              }
          }
      }
    

Semaphore

  • Controls access to a limited number of resources.

  • Allows multiple threads to proceed up to a specified count.

  • Semaphore.Wait(): Decrements the semaphore count and blocks the thread if the count reaches zero.

  • Semaphore.Release(): Increments the semaphore count and allows a waiting thread to proceed.

    • Wait() is used to acquire a resource, ensuring that only a limited number of threads can proceed.

    • Release() is used to free up a resource, signaling that another thread can proceed.

  •   private static Semaphore semaphore = new Semaphore(2, 2); // Max 2 threads
    
      public void SafeSemaphoreMethod()
      {
          if (semaphore.WaitOne(TimeSpan.FromSeconds(3)))
          {
              try
              {
                  Console.WriteLine("Thread-safe operation using Semaphore");
                  Thread.Sleep(2000); // Simulating work
              }
              finally
              {
                  semaphore.Release();
              }
          }
          else
          {
              Console.WriteLine("Could not acquire Semaphore");
          }
      }
    

SemaphoreSlim

  • A more efficient, lightweight alternative to Semaphore for managing concurrency within the same process.

  • Uses a counting mechanism to allow a limited number of threads to access a resource concurrently.

  • Supports asynchronous programming via WaitAsync.

  •   class Program {
          static SemaphoreSlim semaphore = new SemaphoreSlim(2); // Allow 2 concurrent accesses
    
          static async Task UseResourceAsync(string name) {
              Console.WriteLine($"{name} is waiting to enter...");
              await semaphore.WaitAsync(); // Non-blocking wait
    
              try {
                  Console.WriteLine($"{name} has entered.");
                  await Task.Delay(2000); // Simulate work
                  Console.WriteLine($"{name} is leaving.");
              }
              finally {
                  semaphore.Release();
              }
          }
    
          static async Task Main() {
              Task[] tasks = new Task[]
              {
                  UseResourceAsync("Task 1"),
                  UseResourceAsync("Task 2"),
                  UseResourceAsync("Task 3"),
                  UseResourceAsync("Task 4")
              };
    
              await Task.WhenAll(tasks);
          }
      }
    

Program-Level vs. Process-Level Synchronization

Synchronization MechanismScopeLevelDescription
LockSingle processProgram-LevelSimplified wrapper around Monitor, used for synchronizing threads within the same process.
MonitorSingle processProgram-LevelProvides fine-grained control over locking, allowing TryEnter, timeout-based locks, and explicit releases.
MutexMultiple processesProcess-LevelUsed for synchronizing threads within the same process or across processes.
SemaphoreMultiple processesProcess-LeveAllows controlling a fixed number of concurrent threads or processes accessing a resource.

Implementing the Unisex Bathroom Solution

using System;
using System.Threading;

namespace UniSexBathroom;


public class UnisexBathroom {
    private string inUseBy;
    private int usersInBathroom;
    private object padlock;
    private Semaphore maxUsersSem = new Semaphore(3, 3);

    public UnisexBathroom() {
        inUseBy = "none";
        usersInBathroom = 0;
        padlock = new object();
    }

    public void UseBathroom(string name, string gender) {
        Console.WriteLine($"\n{name} ({gender}) is using the bathroom. {usersInBathroom} employees in bathroom");
        Thread.Sleep(3000);
        Console.WriteLine($"\n{name} ({gender}) is done using the bathroom");
    }

    public void EnterBathroom(string name, string gender) {
        lock (padlock) {
            while (inUseBy != "none" && inUseBy != gender) {
                Monitor.Wait(padlock);
            }
            maxUsersSem.WaitOne();
            usersInBathroom++;
            inUseBy = gender;
        }

        UseBathroom(name, gender);
        maxUsersSem.Release();

        lock (padlock) {
            usersInBathroom--;
            if (usersInBathroom == 0) {
                inUseBy = "none";
            }
            Monitor.PulseAll(padlock);
        }
    }

    public int GetUsersInBathroom() {
        return usersInBathroom;
    }
}
using UniSexBathroom;

class Program {
    static void Main() {
        new UnisexBathroomTest().run();
    }
}
public class UnisexBathroomTest {
    public void run() {
        UnisexBathroom bathroom = new UnisexBathroom();

        Thread[] users = new Thread[]
        {
            new Thread(() => bathroom.EnterBathroom("Lisa", "F")),
            new Thread(() => bathroom.EnterBathroom("John", "M")),
            new Thread(() => bathroom.EnterBathroom("Bob", "M")),
            new Thread(() => bathroom.EnterBathroom("Natasha", "F")),
            new Thread(() => bathroom.EnterBathroom("Mary", "F")),
            new Thread(() => bathroom.EnterBathroom("Tom", "M")),
            new Thread(() => bathroom.EnterBathroom("Mike", "M")),
            new Thread(() => bathroom.EnterBathroom("Stephany", "F")),
            new Thread(() => bathroom.EnterBathroom("Rocky", "M")),
            new Thread(() => bathroom.EnterBathroom("Paul", "M"))
        };

        foreach (var user in users) {
            user.Start();
            Thread.Sleep(1000);
        }

        foreach (var user in users) {
            user.Join();
        }

        Console.WriteLine($"User in bathroom at the end {bathroom.GetUsersInBathroom()}");
    }
}

Functionality of the UnisexBathroom Class

The class simulates a shared unisex bathroom where multiple Users can enter under specific conditions while ensuring thread safety.

Fields & Initialization

  • inUseBy: Tracks whether the bathroom is currently occupied by "male", "female", or "none".

  • usersInBathroom: Keeps count of how many employees are inside.

  • padlock: A synchronization object used to coordinate thread access using lock and Monitor.

  • maxUsersSem: A semaphore that limits maximum simultaneous users to 3.

EnterBathroom(string name, string gender)

Handles a user trying to enter the bathroom:

  • Checks if the bathroom is occupied by another gender

    • If occupied by the same gender, they can enter (max 3 people).

    • If occupied by the opposite gender, they must wait (Monitor.Wait).

  • Acquires semaphore (maxUsersSem.WaitOne())

    • Ensures no more than 3 users enter simultaneously.
  • Updates state (usersInBathroom++, inUseBy = gender)

    • Marks bathroom as being used by the current gender.
  • UseBathroom(string name, string gender)

    • Simulates the employee spending 3 seconds in the bathroom before leaving.

Exit Logic inside EnterBathroom

  • Releases semaphore (maxUsersSem.Release())

    • Allows the next waiting person to enter.
  • Updates user count (usersInBathroom--)

    • If the last person leaves, resets inUseBy = "none" so the opposite gender can enter.
  • Notifies waiting threads (Monitor.PulseAll(padlock))

    • Wakes up any waiting employees (of any gender) to check if they can enter.

GetUsersInBathroom()

  • Simply returns the current number of users in the bathroom.

Run the program—

Common .NET Threading Issues and Solutions

  • Race Conditions

    • Issue: When multiple threads access shared data without proper synchronization, inconsistent results occur.

    • Solution: Use locks (lock statement), Monitor, or concurrent collections like ConcurrentDictionary to synchronize access.

  • Deadlocks

    • Issue: Two or more threads wait indefinitely for each other to release resources.

    • Solution: Avoid nested locks, use timeout-based locking (Monitor.TryEnter), and implement lock ordering to prevent circular dependencies.

  • Thread Starvation

    • Issue: Low-priority threads may never execute because high-priority threads consume all CPU time.

    • Solution: Use Task.Run with TaskScheduler.Default, or balance thread priorities to ensure fairness.

  • Thread Safety in Collections

    • Issue: Non-thread-safe collections (List, Dictionary<K,V>) cause data corruption when accessed concurrently.

    • Solution: Use ConcurrentBag, ConcurrentQueue, or ConcurrentDictionary<K,V> to ensure safe access.

  • Blocking Calls in Async Code

    • Issue: Using Task.Result or .Wait() inside async code causes thread pool exhaustion and deadlocks.

    • Solution: Always use await instead of blocking calls and propagate async patterns throughout the application.

  • Memory Leaks Due to Unreleased Threads

    • Issue: Threads that are not properly disposed continue to consume memory.

    • Solution: Use CancellationToken to gracefully terminate tasks and dispose Task objects when they are no longer needed.

In this post on solving the Unisex Bathroom Problem clearly highlights the significance of synchronization in real-world scenarios. The threading issues discussed in this article further emphasize the need for well-structured thread management. By understanding and applying best practices for handling locks, avoiding race conditions, and leveraging concurrency patterns, we can build more efficient and reliable multi-threaded applications in .NET.