Multithreading in C++

Multithreading is an essential feature of modern programming languages, including C++. It allows developers to write programs that can execute multiple tasks concurrently, leading to more efficient and responsive applications. In this guide, we’ll cover the fundamentals of multithreading in C++, including threads, synchronization, and communication between threads.

Table of Contents

  1. Introduction to Multithreading
  2. Creating Threads
  3. Joining and Detaching Threads
  4. Mutexes and Locks
  5. Condition Variables
  6. Futures and Promises
  7. Thread-Local Storage
  8. Conclusion

1. Introduction to Multithreading

A thread is the smallest unit of execution in a process. A single-threaded program executes one task at a time, whereas a multithreaded program can execute multiple tasks concurrently. Multithreading can help improve the performance of programs by allowing them to take advantage of multiple CPU cores or by overlapping I/O-bound and CPU-bound tasks.

C++11 introduced support for multithreading in the C++ Standard Library, providing a high-level abstraction for working with threads, synchronization, and communication. The <thread> header includes the std::thread class, which represents a thread of execution. The <mutex>, <condition_variable>, and <future> headers provide synchronization and communication primitives.

2. Creating Threads

To create a new thread, construct an std::thread object and provide a callable object (such as a function, a functor, or a lambda) that the thread will execute. The thread begins executing as soon as it’s created:

#include <iostream>
#include <thread>

void print_hello() {
std::cout << "Hello from thread!" << std::endl;
}

int main() {
std::thread t(print_hello);

// Continue executing in the main thread

t.join(); // Wait for the thread to finish
return 0;
}

You can also pass arguments to the thread function:

#include <iostream>
#include <thread>

void print_numbers(int a, int b) {
std::cout << "a: " << a << ", b: " << b << std::endl;
}

int main() {
std::thread t(print_numbers, 42, 1337);
t.join();
return 0;
}

3. Joining and Detaching Threads

When a thread finishes executing, its resources must be cleaned up. You can either join or detach a thread to accomplish this.

Joining a thread means waiting for it to complete before continuing execution in the calling thread. To join a thread, call the join() method on the std::thread object:

std::thread t(print_hello);
t.join(); // Wait for the thread to finish

Detaching a thread means allowing it to execute independently of the calling thread. The detached thread’s resources will be cleaned up automatically when it finishes executing. To detach a thread, call the detach() method on the std::thread object:

std::thread t(print_hello);
t.detach(); // Allow the thread to finish independently

Note that a thread must be either joined or detached before its std::thread object is destroyed. If a thread is neither joined nor detached, the program will terminate.

4. Mutexes and Locks

When multiple threads access shared data, data races can occur, leading to undefined behavior. To avoid data races, you must synchronize access to shared data using mutexes and locks.

A mutex (short for “mutual exclusion”) is an object that can be locked and unlocked by threads. When a thread locks a mutex, it gains exclusive access to the shared data protected by the mutex. If another thread tries to lock the mutex while it’s already locked, the second thread will block until the mutex is unlocked.

C++ provides the std::mutex class for this purpose:

#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx;
int shared_data = 0;

void increment() {
for (int i = 0; i < 1000000; ++i) {
std::unique_lock<std::mutex> lock(mtx);
++shared_data;
lock.unlock();
}
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

std::cout << "Shared data: " << shared_data << std::endl;
return 0;
}

In the example above, two threads increment a shared counter. The std::unique_lock class is used to lock and unlock the mutex automatically, ensuring that the shared data is accessed safely. When a lock is acquired, it locks the mutex; when the lock goes out of scope or is explicitly unlocked, the mutex is unlocked.

5. Condition Variables

Condition variables are used for signaling between threads. They allow one or more threads to wait for a specific condition to be met before continuing execution. C++ provides the std::condition_variable class for this purpose:

#include <iostream>
#include <mutex>
#include <condition_variable>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void print_hello() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return ready; });
std::cout << "Hello from thread!" << std::endl;
}

int main() {
std::thread t(print_hello);

// Perform some work in the main thread
std::this_thread::sleep_for(std::chrono::seconds(2));

{
std::unique_lock<std::mutex> lock(mtx);
ready = true;
}

cv.notify_one();
t.join();
return 0;
}

In the example above, the print_hello thread waits for the ready flag to be set before printing its message. The cv.wait() function blocks until the condition is met, and the cv.notify_one() function signals the waiting thread to continue.

6. Futures and Promises

Futures and promises are another way to synchronize and communicate between threads. A promise is an object that stores a value or an exception, while a future is an object that can retrieve the value or exception set by the promise.

C++ provides the std::promise and std::future classes for this purpose:

#include <iostream>
#include <future>
#include <thread>

int compute_result() {
// Simulate some work
std::this_thread::sleep_for(std::chrono::seconds(2));
return 42;
}

int main() {
std::promise<int> prom;
std::future<int> fut = prom.get_future();

std::thread t([&prom] {
int result = compute_result();
prom.set_value(result);
});

std::cout << "Waiting for the result..." << std::endl;
int result = fut.get();
std::cout << "Result: " << result << std::endl;

t.join();
return 0;
}

In the example above, a worker thread computes a result and stores it in a promise. The main thread waits for the result using a future. When the worker thread sets the value in the promise, the main thread retrieves the value using the fut.get() function.

You can also use std::async to create a future that automatically executes a function in a separate thread:

#include <iostream>
#include <future>

int compute_result() {
// Simulate some work
std::this_thread::sleep_for(std::chrono::seconds(2));
return 42;
}

int main() {
std::future<int> fut = std::async(std::launch::async, compute_result);

std::cout << "Waiting for the result..." << std::endl;
int result = fut.get();
std::cout << "Result: " << result << std::endl;

return 0;
}

In this example, std::async creates a future that automatically manages the worker thread, so you don’t need to explicitly create a thread or join it.

7. Thread-Local Storage

Thread-local storage (TLS) is a mechanism that allows each thread to have its own instance of a variable. TLS is useful when you want to maintain separate state for each thread without using synchronization mechanisms.

To declare a thread-local variable, use the thread_local keyword:

#include <iostream>
#include <thread>

thread_local int tls_counter = 0;

void increment() {
for (int i = 0; i < 1000000; ++i) {
++tls_counter;
}
std::cout << "Thread " << std::this_thread::get_id() << ": " << tls_counter << std::endl;
}

int main() {
std::thread t1(increment);
std::thread t2(increment);

t1.join();
t2.join();

return 0;
}

In the example above, each thread increments its own thread-local counter. The tls_counter variable has separate instances for each thread, so no synchronization is needed.

8. Conclusion

In this comprehensive guide, we’ve covered the fundamentals of multithreading in C++, including creating threads, joining and detaching threads, mutexes and locks, condition variables, futures and promises, and thread-local storage. By understanding and applying these concepts, you can write more efficient and responsive applications that take advantage of modern hardware.

As you continue to explore C++ and its features, keep in mind that multithreading can introduce complexity and potential issues, such as data races, deadlocks, and contention. Be sure to practice safe synchronization techniques and test your programs thoroughly to ensure correct behavior.

With a solid foundation in C++ multithreading, you’re now ready to tackle more advanced topics, such as parallel algorithms, asynchronous I/O, and lock-free data structures. Happy coding!