Hello! Welcome to the next lesson in our module on performance analysis and optimization.
In our previous session, we learned how to measure lock contention using tools like perf and manual instrumentation. We now know how to identify that a lock is a bottleneck and which lock it is. Today, we'll address the most fundamental strategy for fixing this problem: reducing the amount of time a lock is held.
This lesson is centered on a single, powerful optimization principle. By the end, you will be able to optimize a critical section by reducing lock hold time by moving work outside the lock. This is one of the most common and effective ways to improve the scalability of multithreaded code.
The Problem: Lengthy Critical Sections
Let's start by visually defining our target. A critical section is the block of code that operates on shared resources and must be protected by a lock to ensure mutual exclusion.

When a thread holds a lock, all other threads attempting to acquire that same lock are forced to wait. This is known as contention. If the critical section is long, threads will spend more time waiting than working, and the benefits of multithreading are lost. The key to performance is to hold the lock for the shortest possible duration.
This is a core principle of concurrent programming, as the following short video clip emphasizes.
Thread synchronization with mutexes in C
To reinforce this fundamental idea, watch this very brief segment from the 'Thread synchronization with mutexes in C' video by Engineer Man.
Watch from 04:01 to 04:21. The key takeaway is the author's explicit advice to keep a mutex locked 'only for the minimum amount of time'.
Strategy 1: Simple Code Motion
The most straightforward way to reduce lock hold time is to identify operations inside the critical section that do not actually depend on or modify the shared state. These operations can be moved outside the lock.
This may seem obvious, but it's a common mistake. Work like preparing data, performing read-only calculations, or allocating memory can often be done before acquiring the lock.
Let's look at a clear example of this.
Common Multithreading Mistakes
The article 'Common Multithreading Mistakes' provides a perfect, simple illustration of this optimization. Please read the section on 'Lengthy Critical Sections'.
Read the short section titled '4. Lengthy Critical Sections'. The C++ code uses std::lock_guard, which is an RAII object that automatically locks the mutex on creation and unlocks it on destruction at the end of its scope. The principle is identical to manually calling pthread_mutex_lock/unlock. Focus on what work (Read_five_thousand_entries()) is moved and why.
To translate this concept directly to C with Pthreads, consider this "before" and "after" scenario. Imagine we're processing data and appending a summary to a shared log file.
Before Optimization (Bad):
void process_and_log_data(int data_id) {
pthread_mutex_lock(&log_mutex); // Lock is acquired early
// 1. Expensive, data-specific work that is NOT shared.
char summary[1024];
prepare_summary_from_database(data_id, summary); // Takes 50ms
// 2. Writing to the shared resource.
fprintf(shared_log_file, "%s\n", summary);
pthread_mutex_unlock(&log_mutex); // Lock is held for the whole duration
}
In this version, the lock is held for the entire 50ms it takes to generate the summary, even though prepare_summary_from_database doesn't touch the shared shared_log_file. All other threads wanting to write to the log are blocked.
After Optimization (Good):
void process_and_log_data(int data_id) {
// 1. Do the expensive work BEFORE the lock.
char summary[1024];
prepare_summary_from_database(data_id, summary); // Takes 50ms
// 2. Lock ONLY for the shared resource access.
pthread_mutex_lock(&log_mutex);
fprintf(shared_log_file, "%s\n", summary);
pthread_mutex_unlock(&log_mutex); // Lock is now held for microseconds
}
By moving the prepare_summary_from_database call outside the critical section, we've reduced the lock hold time from milliseconds to microseconds. This dramatically increases the throughput of the logging system.
Test your understanding!
You are writing a function that processes an image, generates a thumbnail, and updates a global counter for the number of thumbnails created. The original code looks like this:
void generate_and_count(const char* image_path) {
pthread_mutex_lock(&counter_mutex);
Image* full_image = load_image(image_path);
Image* thumbnail = resize_image(full_image, 128, 128);
save_image(thumbnail, "thumb.jpg");
g_thumbnails_created++;
free_image(thumbnail);
free_image(full_image);
pthread_mutex_unlock(&counter_mutex);
}
Identify which operations can be moved out of the critical section to optimize it. How would you rewrite this function?
Show answer
The only operation that requires the lock is the increment of the shared global variable g_thumbnails_created. All the image processing work (loading, resizing, saving, freeing memory) is local to this function call and does not depend on shared state protected by this specific mutex.
The optimized function would be:
void generate_and_count(const char* image_path) {
// All this work is local and can be done without the lock.
Image* full_image = load_image(image_path);
Image* thumbnail = resize_image(full_image, 128, 128);
save_image(thumbnail, "thumb.jpg");
free_image(thumbnail);
free_image(full_image);
// Now, acquire the lock only for the brief moment needed to update the counter.
pthread_mutex_lock(&counter_mutex);
g_thumbnails_created++;
pthread_mutex_unlock(&counter_mutex);
}
This reduces the lock hold time from potentially hundreds of milliseconds (for image I/O and processing) to just a few nanoseconds.
Strategy 2: Local Computation and Merging
Simple code motion is effective, but sometimes the work inside the critical section seems inherently incremental. Consider counting words in a large text file using multiple threads. A naive approach might be:
// Naive thread function
void* worker(void* arg) {
for (each word in my assigned chunk) {
pthread_mutex_lock(&map_mutex);
// Increment count for 'word' in global hash map
global_word_counts[word]++;
pthread_mutex_unlock(&map_mutex);
}
return NULL;
}
Here, the critical section is already small—just a single increment. However, it's called millions of times inside a loop. The overhead of constant locking and the high contention will make this perform terribly.
A much better pattern is to perform the work on thread-local data first and then merge the results into the shared data structure once at the end.
This next resource provides an excellent, detailed walkthrough of this exact pattern.
Common Multithreading Mistakes in C# - Ben Bowen's Blog
The blog post 'Common Multithreading Mistakes in C# - Ben Bowen's Blog' explains this 'work locally' pattern perfectly. Though the code is in C#, the architectural concept is universal and is a cornerstone of high-performance concurrent design.
Read the section 'Working Locally'. First, understand the 'heavily over-contented' code example. See how the lock is acquired frequently inside the main work loop, causing a bottleneck. Then, study the refactored 'Local-Working Word Calculator' example. Pay close attention to how each thread now operates on a localWordCountDict. Notice that the main processing loop has no locks at all. Finally, see how the lock is acquired only once at the very end to merge the localWordCountDict into the global wordCountDict.
Let's illustrate this powerful pattern with a C example. Imagine multiple threads are processing chunks of a large array of numbers and we want to compute the sum of all numbers that are prime.
The Local Computation and Merging Pattern in C:
#include <stdio.h>
#include <pthread.h>
#include <stdbool.h>
#define NUM_THREADS 4
#define CHUNK_SIZE 250000
int data[NUM_THREADS * CHUNK_SIZE]; // 1 million numbers
// Shared state
long long g_total_prime_sum = 0;
pthread_mutex_t g_sum_mutex = PTHREAD_MUTEX_INITIALIZER;
// A simple (and inefficient) primality test for demonstration
bool is_prime(int n) {
if (n <= 1) return false;
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) return false;
}
return true;
}
void* prime_sum_worker(void* arg) {
int thread_id = *(int*)arg;
int start_index = thread_id * CHUNK_SIZE;
int end_index = start_index + CHUNK_SIZE;
// --- Local Computation ---
// Each thread computes its sum into a local variable. NO LOCKS NEEDED.
long long local_sum = 0;
for (int i = start_index; i < end_index; i++) {
if (is_prime(data[i])) {
local_sum += data[i];
}
}
// --- Merge Step ---
// The lock is held only once, for a single addition.
pthread_mutex_lock(&g_sum_mutex);
g_total_prime_sum += local_sum;
pthread_mutex_unlock(&g_sum_mutex);
return NULL;
}
// Main function to set up data and threads omitted for brevity...
In this example, the expensive work (is_prime) and the accumulation of sums happen in the local_sum variable, which is private to each thread's stack. There is zero contention during this phase. The highly contended g_sum_mutex is only locked for the single, fast operation of adding the final local_sum to the global total. This approach scales beautifully.
This pattern is applicable to a wide range of problems, from collecting statistics and building histograms to generating partial results that are later combined.
Conclusion
You have now learned the most important principle for resolving lock contention: minimizing lock hold time. This isn't just a micro-optimization; it's a fundamental shift in how you structure concurrent logic, moving from a model of frequent, small updates to one of local computation followed by a single, batched update.
Key Takeaways:
- The Goal: Hold locks for the shortest possible duration to reduce thread waiting time and increase throughput.
- Simple Code Motion: Move any computations or I/O operations that do not strictly require shared state protection to outside the critical section.
- Local Computation Pattern: For iterative tasks, perform work on thread-local variables or data structures. Acquire the lock only once at the end to merge the local results into the shared state. This transforms a high-contention problem into a low-contention one.
Preview of the Next Lesson:
We've focused on making critical sections as short as possible. But for these extremely short, highly contended critical sections, is a pthread_mutex—which may put a thread to sleep—always the best choice? In our next lesson, we will compare the performance of a mutex versus a spinlock, a different type of lock that might be better suited for this exact scenario.