← Back to list

C++ Multithreading from Scratch — Part 3

The foundation of working with threads is now established: creating a thread, controlling its completion, and keeping its execution within…

Francisco Zavala · 2026-05-29 06:39 · 4 claps · 12.5 min read paywalled
#programming #technology #cpp #multithreading #software-engineering
Open on Medium ↗
Wiki topics: 💻 · Programming 📚 · Books & Reading

C++ Multithreading from Scratch — Part 3

The foundation of working with threads is now established: creating a thread, controlling its completion, and keeping its execution within a safe framework. From this point forward, concurrency ceases to be just a matter of launching parallel tasks; it begins to depend on how those tasks communicate, how they share data, and how ownership of each thread is transferred.

Don’t have a Medium account? No worries — you can read the full article for free here: [Read it here]

This section focuses on those practical aspects. We will examine argument passing, thread ownership, and managing multiple running threads. Each of these elements defines how a concurrent program distributes its workload and truly leverages system resources.

[embed]C++ Multithreading from Scratch — Part 2 So far, I have covered the conceptual foundations of concurrency: what it is, when it is appropriate to use it, and why…levelup.gitconnected.com

Passing Arguments to a Thread Function

When creating a thread in C++ usingstd::thread, we can pass arguments to the function it will execute. This is done directly within the thread’s constructor, just as if we were calling the function itself:

void f(int i, std::string const& s);

int main() {
    std::thread t(f, 3, "hello");
    t.join();
}

In this case, the thread will execute the callf(3, "hello").However, even though it looks like a direct call, internally thestd::threadconstructor copies the arguments into internal storage and then passes them to the function as temporary values (rvalues) within the new thread of execution.

This introduces several important consequences.

Internal Copies and Delayed Conversions

Arguments are copied exactly as they are provided, before any type conversion expected by the function takes place.

For example, consider the following code:


void f(int i, std::string const& s);

void oops(int some_param) {
    char buffer[1024];
    sprintf(buffer, "%i", some_param);
    std::thread t(f, 3, buffer);  // <- Dangerous
    t.detach();
}

Here,buffer is a local array, so what is actually passed to the thread is a pointer (char*). Thestd::thread constructor copies that pointer without converting it to a std::string,because that conversion happens later when the thread begins execution.

The issue is that by the time the new thread attempts to perform the conversion, buffermight have already gone out of scope, resulting in undefined behavior.

The correct approach is to explicitly convert it to astd::string before passing the argument:

void not_oops(int some_param) {
    char buffer[1024];
    sprintf(buffer, "%i", some_param);
    std::thread t(f, 3, std::string(buffer));  // <- Correct
    t.detach();
}

In this scenario, the conversion to std::string happens on the main thread, ensuring that what gets copied internally is a completely valid and independent object.

Passing by Reference: std::ref and std::cref

By default, std::thread copies all arguments, even if the target function expects a reference.

Consequently, the following code will not compile:

void update_data_for_widget(widget_id w, widget_data& data);

void oops_again(widget_id w) {
    widget_data data;
    std::thread t(update_data_for_widget, w, data);  // <- Error
    t.join();
}

Here, update_data_for_widget expects a reference, but std::thread attempts to pass a copy of data as an rvalue, which is invalid for a non-const reference.

To explicitly state that we want to pass a reference, we must wrap the argument in std::ref (or std::cref for a const reference):

std::thread t(update_data_for_widget, w, std::ref(data));

Now, the thread will receive an actual reference to data, allowing the function to modify it properly.

Passing Non-Copyable Objects (Using std::move)

Some types cannot be copied, such as std::unique_ptr, but they can be moved. In these cases, you must use std::move to transfer ownership of the object to the thread:

void process_big_object(std::unique_ptr<big_object> ptr);

int main() {
    std::unique_ptr<big_object> p(new big_object);
    p->prepare_data(42);

    std::thread t(process_big_object, std::move(p));
    t.join();
}

Here, ownership of the pointer is transferred to the thread, leaving the object p in the main thread empty. This approach is highly effective for passing dynamic resources safely and efficiently.

Member Functions and Lambdas

std::thread can also invoke class member functions. When doing so, the first argument must be the pointer to the object on which the method will be called:

class X {
public:
    void do_lengthy_work() {
        std::cout << "Running lengthy work...\n";
    }
};

int main() {
    X x;
    std::thread t(&X::do_lengthy_work, &x);
    t.join();
}

This will execute x.do_lengthy_work() inside the new thread.

Similarly, you can use lambdas to encapsulate both the function and its arguments:

int main() {
    int value = 10;
    std::thread t([value]() {
        std::cout << "Value: " << value << '\n';
    });
    t.join();
}

Lambdas capture values according to their specific capture rules ([value], [&value], [=], [&]), providing explicit control over whether data is copied or passed by reference.

Lifetime Considerations

It is vital to guarantee that any objects referenced by a thread remain valid for as long as the thread uses them.

This means:

  • If you pass references (via std::ref), ensure the object outlives the thread’s execution.
  • If you pass pointers, avoid pointing to automatic (stack) variables that could go out of scope.
  • If you usedetach(),be extremely careful: the thread may continue running long after the local context has ended.

Generally, it is safer to pass copies or use std::shared_ptr if an object needs to survive beyond the lifetime of the thread that created it.

Transferring Thread Ownership

Each std::thread object owns a thread of execution: it is responsible for managing its lifecycle, either by waiting for it to finish with join() or releasing it with detach().

This exclusive relationship implies that only one std::thread object can own a specific thread at any given time.

Unlike copyable types, threads cannot be duplicated, as that would mean two separate objects attempting to control the same underlying operating system resource. However, they can be transferred between objects using move semantics — the same mechanism classes like std::unique_ptr use to manage unique resource ownership.

Ownership and Moving Threads

The following example demonstrates how thread ownership can be moved across different std::thread objects:

void some_function();
void some_other_function();

int main() {
    std::thread t1(some_function);        // t1 owns the thread
    std::thread t2 = std::move(t1);       // t2 takes ownership
    t1 = std::thread(some_other_function); // t1 creates and owns a new thread
    std::thread t3;                       // Empty thread object
    t3 = std::move(t2);                   // t3 takes the original thread
    t1 = std::move(t3);                   // Error! Calls std::terminate()
}

The ownership flow works as follows:

  1. t1 creates a thread executing some_function.
  2. t2 = std::move(t1) transfers ownership of the thread from t1 to t2. After this, t1 has no associated thread.
  3. t1 = std::thread(some_other_function) spawns a new thread and becomes its owner.
  4. t3 takes over the thread from t2 via std::move(t2).
  5. Finally, the reassignment t1 = std::move(t3) causes the program to terminate because t1 still owned an active, unjoined thread.

This last point is crucial: assigning a new thread to an object that already manages an active one triggers std::terminate(). The standard enforces this to maintain consistency with the std::thread destructor, which also requires a thread to be joined or detached before the object itself is destroyed.

Transferring Threads In and Out of Functions

Move support in std::thread allows functions to return or accept threads by value, which is incredibly useful for designing clean and safe interfaces.

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

void consume_thread(std::thread t) {
    if (t.joinable()) t.join();
}

int main() {
    std::thread t = create_thread();   // Thread ownership transferred via return value
    consume_thread(std::move(t));      // Ownership passed into the function
}

In this example:

  • create_thread() returns a moved std::thread, transferring ownership to the caller.
  • consume_thread() accepts the thread by value and can safely join it without worrying about interfering with other owners.

Explicitly transferring ownership via std::move() prevents illegal copies and ensures there is only ever one valid owner at any moment.

RAII Helpers: scoped_thread and joining_thread

It is often convenient to encapsulate thread management within an object that guarantees automatic synchronization when going out of scope. A simple way to achieve this is with a class like scoped_thread, which takes ownership of a thread in its constructor and joins it in its destructor:

class scoped_thread {
    std::thread t;
public:
    explicit scoped_thread(std::thread t_) : t(std::move(t_)) {
        if (!t.joinable())
            throw std::logic_error("No thread");
    }
    ~scoped_thread() {
        t.join();
    }

    scoped_thread(const scoped_thread&) = delete;
    scoped_thread& operator=(const scoped_thread&) = delete;
};

Using it is straightforward and safe:

void task();

int main() {
    scoped_thread worker(std::thread(task));  // The thread joins automatically upon exiting scope
}

This RAII pattern ensures you never forget to call join(), minimizing the risk of resource leaks and abrupt program crashes.

A more flexible variation is a joining_thread class, which behaves like a standard std::thread but automatically joins in its destructor. This makes it much easier to use inside dynamic containers or functions that return threads:

class joining_thread {
    std::thread t;
public:
    joining_thread() noexcept = default;

    template <typename Callable, typename... Args>
    explicit joining_thread(Callable&& f, Args&&... args)
        : t(std::forward<Callable>(f), std::forward<Args>(args)...) {}

    joining_thread(joining_thread&& other) noexcept
        : t(std::move(other.t)) {}

    joining_thread& operator=(joining_thread&& other) noexcept {
        if (joinable()) join();
        t = std::move(other.t);
        return *this;
    }

    ~joining_thread() noexcept {
        if (joinable()) join();
    }

    bool joinable() const noexcept { return t.joinable(); }
    void join() { t.join(); }
    void detach() { t.detach(); }
};

In this way, joining_thread combines the safety of scoped_thread with the native flexibility of std::thread.

Thread Containers

Move support also allows threads to be stored inside dynamic containers like std::vector. This is highly useful for launching a batch of tasks and later synchronizing them as a group:

void do_work(unsigned id) {
    std::cout << "Working on thread " << id << "\n";
}

int main() {
    std::vector<std::thread> threads;

    for (unsigned i = 0; i < 8; ++i)
        threads.emplace_back(do_work, i);

    for (auto& t : threads)
        if (t.joinable()) t.join();
}

Each thread is constructed and placed inside the vector via implicit movement, and all of them are joined at the end of the program. This technique allows you to manage a dynamic number of threads without declaring multiple variables, making it easy to implement thread pools or parallel task systems.

Choosing the Number of Threads at Runtime

Determining how many threads to spawn is one of the most critical decisions when designing an efficient parallel application. Creating too many threads can saturate the operating system with unnecessary context switches, while creating too few fails to fully exploit the hardware’s capacity for parallelism.

Therefore, the optimal number of threads should be chosen at runtime, balancing available physical hardware resources against the nature of the workload.

Querying the Hardware: std::thread::hardware_concurrency()

The C++ Standard Library provides a function that acts as a baseline guide for deciding how many threads can truly run concurrently on the host system:

unsigned int n = std::thread::hardware_concurrency();

This function returns the number of hardware threads (typically the number of physical cores or logical cores via hyper-threading) available to the program. For example, on a CPU with 4 physical cores and hyper-threading enabled, it might return 8.

However, it is important to remember that this value is merely a hint. The implementation may return 0 if the information cannot be retrieved, so it is always good practice to provide a fallback value:

unsigned int num_threads = std::thread::hardware_concurrency();
if (num_threads == 0)
    num_threads = 2; // A reasonable default fallback

While this number is an excellent starting point for splitting up workloads, it isn’t always the magic number. For extremely lightweight operations or tasks with heavy thread-to-thread synchronization overhead, mapping exactly one thread per core might not yield the best efficiency.

Core Strategy: Splitting the Workload

A simple way to apply this principle is to split a dataset across multiple threads. The following example implements a parallel version of the std::accumulate algorithm. It sums elements across a range by dividing them among several threads based on available CPU cores.

accumulate_CD_04

template <typename Iterator, typename T>
struct accumulate_block {
    void operator()(Iterator first, Iterator last, T& result) {
        result = std::accumulate(first, last, result);
    }
};

template <typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init) {
    unsigned long const length = std::distance(first, last);
    if (!length)
        return init;

    unsigned long const min_per_thread = 25;
    unsigned long const max_threads =
        (length + min_per_thread - 1) / min_per_thread;

    unsigned long const hardware_threads =
        std::thread::hardware_concurrency();

    unsigned long const num_threads =
        std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);

    unsigned long const block_size = length / num_threads;

    std::vector<T> results(num_threads);
    std::vector<std::thread> threads(num_threads - 1);

    Iterator block_start = first;
    for (unsigned long i = 0; i < (num_threads - 1); ++i) {
        Iterator block_end = block_start;
        std::advance(block_end, block_size);
        threads[i] = std::thread(
            accumulate_block<Iterator, T>(),
            block_start, block_end, std::ref(results[i])
        );
        block_start = block_end;
    }

    accumulate_block<Iterator, T>()(
        block_start, last, results[num_threads - 1]
    );

    for (auto& t : threads)
        t.join();

    return std::accumulate(results.begin(), results.end(), init);
}

Algorithm Analysis

  1. Input Size Verification: If the range is empty, the function immediately returns the initial value init. This prevents spawning unnecessary threads when there is no work to do.
  2. Minimum Work Per Thread: A minimum threshold of elements per thread (min_per_thread) is defined to avoid the overhead of creating threads for tiny datasets.
  3. Calculating Maximum Useful Threads: Based on the dataset size and the minimum workload threshold, the algorithm establishes the maximum number of threads that make practical sense to create.
  4. Actual Thread Count Selection: The code picks the smaller value between the calculated maximum useful threads and the available hardware cores. If the hardware query fails, it defaults to 2.
  5. Work Division: The data chunk (block_size) assigned to each thread is calculated by dividing the total length by the determined thread count.
  6. Thread Creation and Spawning: It launches num_threads - 1 worker threads, each processing its respective slice of the range. The main thread processes the final block itself to save the overhead of spawning an extra thread.
  7. Final Synchronization: All spawned threads are synchronized using join(), and the partial results are aggregated into the final sum via a final std::accumulate pass.

Performance Considerations

The ultimate goal of this design pattern is to maximize hardware utilization while preventing oversubscription. Oversubscription occurs when there are more active threads than available processing cores, forcing the operating system into aggressive context switching, which hurts performance instead of improving it.

Key Takeaways:

  • Creation Overhead: Spawning a thread comes at a non-trivial cost; only do it if the workload justifies it.
  • Load Balancing: If threads process blocks of varying sizes or complexities, some cores will sit idle while others finish up.
  • CPU Affinity: For high-demand optimization, pinning specific threads to physical cores can be helpful, though this is managed at the OS level.
  • Thread Reuse: For repetitive tasks, it is better to implement a thread pool that manages a fixed set of reusable threads rather than continuously creating and destroying them.

Practical Improvement: Dynamic Scaling by Load

A solid real-world optimization is to scale the thread count based on the real workload and not just the hardware capacity. For instance:

unsigned int hardware = std::thread::hardware_concurrency();
unsigned int num_threads = std::min(hardware != 0 ? hardware : 2,
                                     total_tasks / min_work_per_thread);
num_threads = std::max(1u, num_threads); // Guarantee at least one thread

This strategy prevents over-allocating threads for trivial workloads while still leveraging full CPU power when massive data demands it.

Identifying Threads

When working with multiple threads, you often need a way to distinguish which thread is executing a specific part of your code. For instance, you might want to log which thread is handling a task, allocate resources based on thread affinity, or generate diagnostic traces to debug concurrent code.

The C++ Standard Library offers a safe, efficient way to manage this using the std::thread::id class.

Obtaining a Thread Identifier

There are two main ways to acquire a std::thread::id:

  1. From a std::thread object
std::thread t(f);
std::thread::id id = t.get_id();

The get_id() method returns the unique ID of the thread associated with that object. If the std::thread object is not associated with an active thread (e.g., it was default-constructed, or has already been joined or detached), it returns a default-constructed ID that represents "not any thread".

  1. From the current running thread
std::thread::id id = std::this_thread::get_id();

This function returns the identifier of the specific thread calling it. This is highly useful inside shared functions that are run concurrently by multiple threads, allowing you to track exactly who is executing the call.

Properties of Thread Identifiers

Objects of type std::thread::id are completely copyable and comparable. This makes checking whether two thread objects represent the same executing thread incredibly simple:

if (t1.get_id() == t2.get_id())
     std::cout << "Both objects represent the same thread\n";

If two identifiers match, they either represent the same thread or both represent “no thread”. Furthermore, the class provides a total ordering: they can be compared using <, >, etc. This allows them to be used as keys in associative containers, both ordered (std::map) and unordered (std::unordered_map), thanks to the built-in specialization of std::hash<std::thread::id>.

Example:

std::unordered_map<std::thread::id, std::string> thread_names;
thread_names[std::this_thread::get_id()] = "Main Thread";

Application in Logging and Debugging

Thread identifiers are exceptionally handy for generating execution traces and logs. For example, in a parallel processing pipeline, you can print which thread is working on a specific block of data:

void process_task(int task_id) {
    std::cout << "Thread " << std::this_thread::get_id()
              << " is processing task " << task_id << '\n';
}

Every execution will print a distinct value for std::thread::id, allowing you to visualize the breakdown of labor. The precise printing format of the ID depends on the implementation, but the C++ standard guarantees that distinct threads will produce different outputs, and matching threads will always match.

Example: Differentiating a Master Thread from Workers

Imagine a scenario where the main thread spawns multiple workers but needs to perform an exclusive setup task that workers should skip. You can store the main thread’s ID before spawning workers and perform a quick check inside the shared code path:

std::thread::id master_thread;

void some_core_part_of_algorithm() {
    if (std::this_thread::get_id() == master_thread) {
        do_master_thread_work();   // Exclusive to the master thread
    }
    do_common_work();              // Run by all threads
}

int main() {
    master_thread = std::this_thread::get_id();
    std::thread worker1(some_core_part_of_algorithm);
    std::thread worker2(some_core_part_of_algorithm);

    some_core_part_of_algorithm(); // Executed by the master

    worker1.join();
    worker2.join();
}

Even though every thread calls the exact same function, only the master thread executes the specialized section by comparing its live std::this_thread::get_id() against the saved master ID.

Mapping Data to Threads via Identifiers

Sometimes you want to maintain thread-specific metadata like performance stats or local configurations. If you prefer not to use native thread-local storage, you can easily map this data using thread IDs as keys:

std::map<std::thread::id, ThreadStats> stats_map;

void log_event(std::string event) {
     stats_map[std::this_thread::get_id()].events.push_back(event);
}

This pattern is highly effective when an outside manager thread (like a controller or monitoring thread) needs to look up metrics belongin g to other worker threads via their IDs.

Caveat: Reusable IDs

While thread identifiers are completely unique throughout the lifetime of an active thread, operating systems can and do reuse thread IDs once a thread terminates and its resources are recycled. Therefore, you should never cache a std::thread::id for long-term tracking after that thread has completed, as a completely new thread might later inherit that exact same ID.

Conclusion

Managing arguments, tracking data ownership, and coordinating multiple threads form the practical foundation for writing readable, maintainable concurrent programs. These mechanisms make it easier to distribute work systematically, monitor individual threads, and avoid common traps tied to object lifespans. From here, the natural progression is to look into how multiple threads can safely access and manipulate shared data without creating race conditions. That will be the core focus of our next section: sharing data between threads.

[embed]GitHub - Nobody-1321/multithreading_cpp: multithreading examples with cpp multithreading examples with cpp. Contribute to Nobody-1321/multithreading_cpp development by creating an account on…github.com


메타데이터
post_id
70f0d602dbf8
slug
c-multithreading-from-scratch-part-3-70f0d602dbf8
url
https://medium.com/@fjzavala/c-multithreading-from-scratch-part-3-70f0d602dbf8
canonical_url
https://medium.com/@fjzavala/c-multithreading-from-scratch-part-3-70f0d602dbf8
author_url
https://medium.com/@fjzavala
status
ok
fetched_at
2026-06-09 15:37:30