Basic Thread Management in C++

Master basic C++ thread management! Learn to launch threads with functions, functors, and lambdas, pass arguments, and choose between join() vs. detach().

In the previous lesson, you learned how to create a simple thread using std::thread and join().

Now, let’s explore a few more ways to launch threads and manage their lifetime — using functions, functors, and lambdas — plus how to wait or let them run independently.

1. Launching a thread with a function

The simplest way to start a thread is to pass a function to the std::thread constructor.

#include <iostream>
#include <thread>

void work() {
    std::cout << "Working in thread\n";
}

int main() {
    std::thread t(work);
    t.join(); // Wait for the thread to finish
}

Output:

Working in thread

Explanation:
The new thread begins running work().
Calling join() ensures the main thread waits until it finishes.

2. Launching a thread with a callable object (functor)

Any object that overloads the function call operator operator() can also be used as a thread entry point.

This is powerful because a functor can store state (configuration, counters, etc.) and reuse it across calls.

#include <iostream>
#include <thread>

struct Counter {
    int count = 0;

    void operator()() {
        ++count;
        std::cout << "Running task #" << count << "\n";
    }
};

int main() {
    Counter worker;

    // Each thread gets its own copy
    std::thread t1(worker);
    std::thread t2(worker);

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

    std::cout << "Final count in main: " << worker.count << "\n";
}

Output:

Running task #1
Running task #1
Final count in main: 0

Each thread receives a copy of the worker, so the changes happen on the copies.

To share the same object between threads, use std::ref():

#include <iostream>
#include <thread>

struct Counter {
    int count = 0;

    void operator()() {
        ++count;
        std::cout << "Running task #" << count << "\n";
    }
};

int main() {
    Counter worker;

    std::thread t1(std::ref(worker));
    std::thread t2(std::ref(worker));

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

    std::cout << "Final count in main: " << worker.count << "\n";
}

Output:

Running task #Running task #2
2
Final count in main: 2

Warning: There might be a data race here in this simple example, since one count is shared across threads t1 and t2.

Why Output looks strange

  • Both threads share the same std::cout stream, and its output is not synchronized between threads by default.

  • When multiple threads write to std::cout at the same time, their text can interleave — producing partial or jumbled lines.

  • The thread scheduler decides when each thread runs. One thread might start printing but get interrupted before finishing a line, letting the other continue immediately.

  • ++count is performed right t1 and t2 are created, so count might be already 2 before it is passed to std::cout.

Why functors matter

They’re useful when:

  • You want to keep per-thread state (like configuration or counters).
  • You prefer organizing thread logic inside a class instead of global functions.
  • You want to reuse the same callable object across multiple threads.

3. Launching a thread with a lambda expression

Lambdas make threading even simpler, especially for short, inline tasks.

#include <iostream>
#include <thread>

int main() {
    std::thread t([] {
        std::cout << "Working in lambda thread\n";
    });

    t.join();
}

Output:

Working in lambda thread

This is ideal when the code is small and only used once.

4. Passing arguments to threads

Arguments are copied by default when passed to a thread.

#include <iostream>
#include <thread>

void greet(std::string name) {
    std::cout << "Hello, " << name << "\n";
}

int main() {
    std::thread t(greet, "C++ threads");
    t.join();
}

Output:

Hello, C++ threads

To pass by reference, use std::ref():

#include <iostream>
#include <thread>

void increment(int& x) {
    ++x;
}

int main() {
    int value = 0;
    std::thread t(increment, std::ref(value));
    t.join();

    std::cout << "Value = " << value << "\n";
}

Output:

Value = 1

5. join() vs detach()

join() waits for the thread to finish before continuing — this is synchronous and safe.

std::thread t(work);
t.join(); // Waits for work() to finish

detach() lets the thread run independently in the background.
The main thread does not wait for it.

#include <iostream>
#include <thread>

void background() {
    std::cout << "Background task done\n";
}

int main() {
    std::thread t(background);
    t.detach(); // Run in background

    std::cout << "Main is done\n";
}

If the main thread runs too fast, you might see the output only Main is done when the program terminates. The t thread continues running even after t goes out of scope. Its ownership and control are passed over to the C++ Runtime Library.

Use case: logging in the background

Detached threads are useful for background tasks such as logging.

#include <iostream>
#include <thread>
#include <chrono>

void logMessage(std::string msg) {
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    std::cout << "[LOG] " << msg << "\n";
}

int main() {
    std::thread logger(logMessage, "Application started");
    logger.detach(); // runs independently

    std::cout << "Main continues working...\n";
    std::this_thread::sleep_for(std::chrono::seconds(1));
}

Output:

Main continues working...
[LOG] Application started

Warning

  • Detached threads are useful, but dangerous if they access data owned by the main thread.
  • Make sure they operate only on data that remains valid while they run.

Common pitfall: forgetting join() or detach()

If neither is called before the thread object is destroyed, the program terminates with:

terminate called without an active exception

Always ensure every thread is either joined or detached.


Takeaway

From this lesson, you learned:

  • Three ways to launch threads — with a function, functor, or lambda.
  • How to pass arguments (by value or by reference using std::ref()).
  • The purpose and behavior of join() and detach().
  • Why functors are useful for maintaining state across calls.
  • What happens with a detached thread’s object and when to use it safely.

You now understand the basics of thread management — how to launch, synchronize, and safely clean up threads in a C++ program.

Subscribe to Modern, High-Performance C++ Tutorials and Insights

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe