SrcForge

Lesson 56

Multithreading (part 2)

Covers passing arguments and references to threads, using lambdas with threads, race conditions, critical sections, std::mutex, std::lock_guard, deadlocks, and thread safety.

Lesson content

Passing arguments to threads

Threads often need input data to perform their work. You can pass arguments to a thread just like calling a normal function.

Example 4: Passing arguments to a thread

#include <iostream>
#include <thread>

// Function executed by the thread
void printSquare(int number)
{
    std::cout << "Square: "
              << number * number
              << '\n';
}

int main()
{
    std::thread worker(
        printSquare,
        8
    );

    worker.join();

    return 0;
}

// Output: Square: 64

Passing objects by reference

By default, thread arguments are copied. If you want to pass an object by reference, use std::ref(). Without std::ref(), the thread would receive a copy, leaving the original variable unchanged.

Example 5: Passing by reference

#include <iostream>
#include <thread>
#include <functional>

// Function increments the number
void increment(int& value)
{
    value++;
}

int main()
{
    int number = 10;

    std::thread worker(
        increment,
        std::ref(number)
    );

    worker.join();

    std::cout << number;

    return 0;
}

// Output: 11

Using lambda expressions with threads

Lambdas are commonly used because they allow thread logic to be written inline. They are especially useful for short-lived tasks and callbacks.

Example 6: Thread with a lambda

#include <iostream>
#include <thread>

int main()
{
    std::thread worker(

        []()
        {
            std::cout << "Running inside lambda thread.\n";
        }

    );

    worker.join();

    return 0;
}

// Output: Running inside lambda thread.

Shared data between threads

Threads in the same process share memory. For example, both Thread A and Thread B can access the same shared variable. This shared access is powerful but introduces potential problems.

What is a race condition?

A race condition occurs when two or more threads access the same data concurrently, and at least one thread modifies it without proper synchronization. The result depends on the timing of thread execution, making the program unpredictable.

Example 7: Race condition

#include <iostream>
#include <thread>

int counter = 0;

void increase()
{
    for (int i = 0; i < 100000; i++)
    {
        counter++;
    }
}

int main()
{
    std::thread t1(increase);

    std::thread t2(increase);

    t1.join();

    t2.join();

    std::cout << counter;

    return 0;
}

// Expected Output: 200000
// Actual Output (Possible): 183527, 197845, or another value.

The statement counter++; is not atomic. It involves multiple steps: read the current value, add one, and write the new value. If two threads perform these steps simultaneously, updates can be lost.

Critical section

A critical section is a part of the code where shared resources are accessed. Only one thread should execute a critical section at a time.

Using std::mutex

A mutex (short for mutual exclusion) ensures that only one thread accesses a critical section at a time. While one thread holds the lock, other threads attempting to lock the same mutex must wait.

std::mutex mtx;

mtx.lock();

// Critical section

mtx.unlock();

Example 8: Using a mutex

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

int counter = 0;

std::mutex mtx;

void increase()
{
    for (int i = 0; i < 100000; i++)
    {
        mtx.lock();

        counter++;

        mtx.unlock();
    }
}

int main()
{
    std::thread t1(increase);

    std::thread t2(increase);

    t1.join();

    t2.join();

    std::cout << counter;

    return 0;
}

// Output: 200000

The mutex prevents both threads from modifying counter simultaneously.

Why manual locking can be dangerous

Suppose an exception occurs between lock() and unlock(). The unlock() call never executes, so the mutex remains locked, potentially blocking other threads forever.

mtx.lock();

throw std::runtime_error("Error");

// unlock never executes

std::lock_guard

std::lock_guard automatically acquires the mutex when it is created and releases it when it goes out of scope. This follows the RAII (Resource Acquisition Is Initialization) principle.

Example 9: Safer locking

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

int counter = 0;

std::mutex mtx;

void increase()
{
    for (int i = 0; i < 100000; i++)
    {
        std::lock_guard<std::mutex> lock(mtx);

        counter++;
    }
}

int main()
{
    std::thread t1(increase);

    std::thread t2(increase);

    t1.join();

    t2.join();

    std::cout << counter;

    return 0;
}

std::lock_guard is preferred because it automatically unlocks the mutex, is exception-safe, produces cleaner code, and reduces the chance of programming errors.

What is a deadlock?

A deadlock occurs when two or more threads wait indefinitely for each other to release resources. For example, Thread A locks Mutex 1 and then waits for Mutex 2, while Thread B locks Mutex 2 and then waits for Mutex 1. Neither thread can continue because each is waiting for the other.

How to avoid deadlocks

  • Always lock multiple mutexes in the same order
  • Keep critical sections as short as possible
  • Avoid nested locking when possible
  • Prefer std::scoped_lock (C++17) to lock multiple mutexes safely
  • Release locks promptly

Thread safety

A piece of code is thread-safe if it behaves correctly when accessed by multiple threads simultaneously.

  • Protects shared data
  • Uses synchronization correctly
  • Avoids race conditions
  • Minimizes shared mutable state

Real-world example: processing files

Imagine an application that processes many files, where Thread 1 handles File A, Thread 2 handles File B, Thread 3 handles File C, and Thread 4 handles File D. Each thread works on a different file, allowing the work to be completed faster than processing them one by one.