Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 18

Semaphore-Protected Resource Pool

Recommended resources

Hello! Let's dive into our next lesson.

In our previous session, you mastered the theory of semaphores for resource counting, using the classic producer-consumer problem as our guide. We established the crucial pattern of using semaphores for signaling (sem_wait/sem_post) and a mutex for protecting the critical section.

Today, we're going to apply that knowledge to a highly practical and common concurrency pattern. Your learning outcome is to implement a simple resource pool protected by a semaphore. This moves us from the data-flow model of producer-consumer to a model of managing a finite set of reusable objects, such as database connections or pre-allocated memory buffers.

1. What is a Resource Pool?

A resource pool is a collection of initialized, ready-to-use objects that can be "checked out" by threads, used, and then returned. This pattern is essential when creating a resource is expensive (e.g., establishing a network connection) or when you need to strictly limit the number of active resources.

A common example is a database connection pool. Establishing a connection involves network I/O and authentication, which is slow. A pool maintains a set of open connections, allowing application threads to quickly acquire one, run a query, and release it back to the pool for another thread to use.

Database Connection Pool Illustration
This diagram illustrates a database connection pool. Threads acquire connections to perform work. When all connections are in use, new requests must wait for a connection to be released, highlighting the need for a mechanism to manage this finite resource set.

The core synchronization challenge is clear: how do we make a thread wait when the pool is empty, and how do we efficiently notify a waiting thread when a resource is returned? This is a perfect job for a counting semaphore.


2. Designing the Resource Pool

Let's design the components for a generic resource pool. We'll need:

  1. A data structure to hold the resources. A simple array used as a stack or queue is sufficient.
  2. A counting semaphore to track the number of available resources in the pool.
  3. A mutex to provide exclusive access to the data structure itself when adding or removing a resource.

Our API will consist of three main functions:

  • pool_init(size_t size): Initializes the pool with a specific number of resources.
  • resource_t* pool_acquire(): Acquires a resource from the pool, blocking if none are available.
  • void pool_release(resource_t* resource): Returns a resource to the pool.

The combination of a semaphore and a data structure is a common pattern. The slides from a Princeton course on systems design show a high-level version of this.

Semaphores - cs.Princeton - Princeton University

The presentation "Semaphores" from Princeton University provides a concise example of a resource pool structure.

Please review the slide titled "Example: A Resource Pool". Notice the pool struct containing a semaphore s and a queue r, and the corresponding acquire and release functions. The provided code uses a hypothetical atomic_enqueue/dequeue, which we will implement explicitly using a mutex.

This gives us the basic blueprint. Now, let's refine the logic for acquire and release, drawing on what we learned from the complete producer-consumer solution in the previous lesson.


3. Implementing the acquire and release Logic

The correct synchronization order is critical for both correctness and performance. We'll use our semaphore to manage resource availability and our mutex to protect the integrity of the queue that holds the resource pointers.

pool_acquire()
A thread wanting a resource must first wait for one to be available.

  1. sem_wait(&pool->available): The thread waits on the semaphore. If the semaphore's value is greater than zero, it decrements the value and proceeds. If the value is zero (meaning no resources are available), the thread blocks here until another thread calls sem_post.
  2. pthread_mutex_lock(&pool->lock): Once the thread knows a resource is available, it must lock the mutex to safely manipulate the shared resource queue.
  3. resource = ...remove from pool queue...: The thread takes the resource.
  4. pthread_mutex_unlock(&pool->lock): The critical section is over, so the lock is released.

pool_release()
A thread returning a resource adds it back to the pool and signals that it's available.

  1. pthread_mutex_lock(&pool->lock): The thread locks the mutex to safely modify the resource queue.
  2. ...add resource to pool queue...: The resource is returned to the pool's data structure.
  3. pthread_mutex_unlock(&pool->lock): The critical section is now complete.
  4. sem_post(&pool->available): The thread increments the semaphore, signaling that a resource has been added. This will unblock one waiting thread (if any) in pool_acquire().

This logic is very similar to the producer-consumer pattern you studied. Acquiring a resource is analogous to a consumer taking an item, and releasing a resource is analogous to a producer adding an item. The key difference is that the "items" in a resource pool are reusable.

Test your understanding!

In our pool_release() function, we unlock the mutex before calling sem_post(). What is the performance implication of calling sem_post() inside the critical section (i.e., before calling pthread_mutex_unlock)?

Show answer

Calling sem_post() inside the critical section is functionally correct but can be inefficient. The sem_post() call may wake up a thread that is currently blocked in sem_wait() inside the pool_acquire() function. If the releasing thread still holds the mutex, the newly awakened thread will immediately execute its next instruction, pthread_mutex_lock(), and block again because the lock is still held.

This creates a small but unnecessary convoy: the woken thread wakes up only to go back to sleep waiting for the lock. By unlocking the mutex first, we ensure that when sem_post() wakes another thread, that thread can acquire the lock immediately and make progress. This minimizes lock contention and improves throughput.


4. A Complete Implementation

Now, let's put all the pieces together into a complete, runnable program. The following code implements a resource pool for a simple resource_t object. Worker threads will acquire these resources, use them for a simulated task, and then release them.

Pay close attention to the implementation of pool_init, pool_acquire, and pool_release.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <semaphore.h>

#define POOL_SIZE 3
#define NUM_THREADS 5

// A simple structure to represent a resource
typedef struct {
    int id;
} resource_t;

// The resource pool structure
typedef struct {
    resource_t** resources; // Array of pointers to resources
    size_t size;
    size_t head; // For our simple stack-like pool
    
    pthread_mutex_t lock;
    sem_t available;
} resource_pool_t;

resource_pool_t pool;

// Initializes the resource pool
void pool_init(size_t size) {
    pool.resources = malloc(sizeof(resource_t*) * size);
    pool.size = size;
    pool.head = 0; // The stack is empty

    pthread_mutex_init(&pool.lock, NULL);
    // Initialize semaphore to the number of available resources
    sem_init(&pool.available, 0, size);

    // Create and add the actual resources to the pool
    for (size_t i = 0; i < size; ++i) {
        pool.resources[i] = malloc(sizeof(resource_t));
        pool.resources[i]->id = i + 1;
    }
    pool.head = size; // Stack pointer is at the top
    printf("Pool initialized with %zu resources.\n", size);
}

// Acquires a resource from the pool
resource_t* pool_acquire() {
    // Wait for a resource to become available.
    // If sem_wait returns, we are guaranteed a resource is available for us.
    sem_wait(&pool.available);

    // Lock the mutex to protect the pool's internal data structure.
    pthread_mutex_lock(&pool.lock);

    // We use the array as a simple stack. Pop a resource.
    pool.head--;
    resource_t* res = pool.resources[pool.head];
    printf("Thread %ld: Acquired resource %d.\n", (long)pthread_self(), res->id);

    // Unlock the mutex.
    pthread_mutex_unlock(&pool.lock);

    return res;
}

// Releases a resource back to the pool
void pool_release(resource_t* res) {
    // Lock the mutex to protect the pool's internal data structure.
    pthread_mutex_lock(&pool.lock);

    // Push the resource back onto our stack.
    pool.resources[pool.head] = res;
    pool.head++;
    printf("Thread %ld: Released resource %d.\n", (long)pthread_self(), res->id);
    
    // Unlock the mutex before posting to the semaphore.
    pthread_mutex_unlock(&pool.lock);

    // Signal that a resource is now available.
    sem_post(&pool.available);
}

// Worker thread function
void* worker(void* arg) {
    long id = (long)arg;
    printf("Thread %ld: starting.\n", (long)pthread_self());

    resource_t* my_resource = pool_acquire();

    // Simulate doing work with the resource
    sleep(2); 

    pool_release(my_resource);
    
    printf("Thread %ld: finished.\n", (long)pthread_self());
    return NULL;
}

int main() {
    pthread_t threads[NUM_THREADS];
    pool_init(POOL_SIZE);

    for (long i = 0; i < NUM_THREADS; ++i) {
        pthread_create(&threads[i], NULL, worker, (void*)i);
    }

    for (int i = 0; i < NUM_THREADS; ++i) {
        pthread_join(threads[i], NULL);
    }
    
    // Cleanup
    sem_destroy(&pool.available);
    pthread_mutex_destroy(&pool.lock);
    for(size_t i=0; i < pool.size; ++i) {
        free(pool.resources[i]);
    }
    free(pool.resources);
    
    printf("All threads finished. Pool cleaned up.\n");

    return 0;
}

Compile and run this code (gcc -o pool pool.c -pthread). You will observe that with POOL_SIZE=3 and NUM_THREADS=5, the first three threads acquire resources immediately. The next two threads will block in pool_acquire until the first threads start releasing their resources. This demonstrates the semaphore correctly managing access to the limited pool.


Conclusion

You have successfully moved from theory to practice by implementing a thread-safe resource pool. This is a fundamental building block in concurrent systems design.

Key Takeaways:

  • A resource pool manages a finite set of reusable objects.
  • A counting semaphore is the ideal primitive to track the number of available resources, causing threads to block when the pool is empty.
  • A mutex is still necessary to protect the internal data structure (e.g., queue, stack) that holds the resources during add/remove operations.
  • The optimal pattern for releasing a resource is to unlock the mutex before posting to the semaphore to reduce unnecessary lock contention.

Preview of the Next Lesson:
We've used semaphores to manage resources. Next, we'll look at a primitive designed to manage time and synchronization points. You will learn how to synchronize the start of a multi-stage computation using a pthread_barrier, ensuring that a group of threads all reach a specific point in their execution before any of them are allowed to proceed.