Multithreading is one of the most powerful and vital capabilities of nearly any computer processor that exists today. Multithreading allows software to execute different code simultaneously in the same program. Web servers, web browsers, databases, mobile applications, and just about any production grade software wouldn’t function as well as it does without multithreading.
Multithreading often carries a reputation for being difficult. Compared with other concepts in software development, one could certainly make a case for that. However, multithreading isn’t really that different from general programming. It’s just potentially more dangerous . Learning to protect against the danger, though, can allow one to implement far more powerful algorithms and programs than you could in a single threaded manner.
To understand multithreading, it’s best to start from the least dangerous concepts and proceed toward the most potentially dangerous ones. This allows one to get comfortable with threading and work their way toward more critical and cautious code writing.
Perhaps the least threatening form of multithreading is concurrency. Concurrency is generally meant to mean multiple threads running at the same time, but not sharing any resources. This means no data structures, memory, or another is shared between threads. Concurrency is commonly utilized for tasks that can be split up between threads and worked on independently.
To illustrate this, let’s look at the example of each thread getting a pointer to an integer, and the thread increments that integer, then stops. Each thread gets to run until it increments the number a few hundred times. Then, those threads, often called “worker” threads, get joined by the main thread. All of the threads work simultaneously.
If you are new to multithreading, there’s a few parts of this code that might not make sense. The
join() method is probably one of them. An important detail to understand about starting new threads is that they work and function entirely separately from the main thread, the thread which begins in
main() . Because they are entirely separate, we have to decide a point in which we want to wait for them to complete their assigned work.
join() similarly to how two people might split up to do their own separate tasks, then “join” back together later on. If you are traveling or going somewhere with a friend, you don’t want top just abandon them! You should ideally wait for them to catch up again. The same logic goes for threads. Anytime additional threads are created, there’s an obligation to direct how you want the central, main thread to act in accordance with them.
Do you always have to join threads ? No. Actually, there is one other option. Just like with the friends example, it’s possible a friend might want to go their own way back home, and not meet back up with you. In the case of threads, that’s called detaching. Detaching a thread means allowing it to work and complete it’s work independently of the main thread. But, this can be dangerous. Take the following example, very similar to the one for
join() , for instance.
The first risk here is using the heap-allocated after it’s deleted. Unlike
detach() does not make the calling thread stop or wait for anything. This means as soon as the third call to
detach() ends, the calling thread will delete the
numbers array. If the created threads haven’t finished their work, they will be writing to a deleted array, which corrupts memory.
The second risk here is that the created threads can keep running even after the main thread finishes, if their work is not completed. Or they might be killed as soon as main ends. This is undefined behavior according to the C++ standard. Regardless what a specific compiler might guarantee, undefined behavior is something to avoid. There are valid use cases for
detach() , but any of them require some other form of synchronization between threads to be reliable.
A resource where two different threads can access the same memory address is called a shared resource. It’s critical to note the emphasis on address. In the prior example shown here with multiple threads accessing the same array, that is not a shared resource because no two threads are reading or writing from the same memory address. The array could have just been four separate integer pointers, there’s nothing about an array in and of itself that makes it a shared resource.
Unlike concurrency, a shared resource is used when it’s desirable for threads to perform work on the same data or object. This means objects which are not allocated on a thread’s own stack, and only one’s visible to other threads. What can make this tricky to understand is, although both threads can access some resource, they can never see the other threads accessing that resource.
A great example of a shared resource in real life is an airport runway at night. A runway has blinking lights to help guide planes align with it as they prepare for landing. But it’s very difficult if not impossible for other planes to see each other at night, due to the darkness and the speed at which they travel. If a plane were to attempt to land on the runway at the same time as another plane, it would be disastrous. The only way planes avoid such collisions is to coordinate through air traffic control.
Threads function the same way in the sense they depend on synchronization mechanisms to coordinate access to resources, such as not writing to them at the exact same time. The mechanism we will discuss here, which is perhaps the most common one, is a mutex. A mutex, known by the type
std::mutex , allows threads to acquire locks. Locks are a form of control that allows only one thread to proceed through a section of code at a time. Let’s look at this example.
In the above example, the
pop() methods of the class both happen while the calling thread constructs a lock on the mutex associated with the queue. This lock is best used as an RAII style object, where it’s only active under some scope of code. Once the program finishes that scope, the lock guard object is destroyed, allowing another thread to construct and acquire a lock on the mutex. This pattern continues to satisfy the condition only one thread can modify the queue at a time.
Mutexes are still potentially dangerous
Even though they do sound really neat and straight forward, mutexes can still be dangerous. When a thread acquires a lock on a mutex, it’s responsible for freeing or destroying that lock so other threads can access the secured scope of code as well. What happens if a thread never frees the lock it acquired ? Well, something really bad.
A leaked lock is when a thread locks a mutex but then that lock for some reason can never be unlocked. If this happens, all the threads will block and wait on the mutex indefinitely, making no progress or doing any work whatsoever.
The rule of thumb for mutexes is to think carefully and critically about what a thread does when it places a lock on a mutex. It’s vital that a thread only locks when it absolutely requires single thread access, and while doing so, does work as fast as possible. While mutexes provide a means to safely access the same resource, they do so at a performance cost.
Are there other ways to prevent illegal, dual access to resources among multiple threads ? Yes. But that’s a topic for another post.