Modern C++ Fundamentals: From Basics to Smart Pointers and Threads · Ownership, Templates, Errors and Concurrency

Capstone: A Parallel Word Counter with Threads

Run work on several threads with std::jthread, protect shared data with std::mutex and lock_guard, collect results with std::async, and combine everything in a parallel word counter.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A parallel word counter. It splits a large text into chunks, counts words in each chunk on a separate thread, merges the partial results under a mutex, and also computes the same totals with std::async futures, so you can compare two styles.

The idea

Threads let a program do several things at once. C++ gives you:

  • std::jthread (C++20): starts a thread running a function and joins automatically in its destructor, so you can't forget join(). (std::thread terminates the program if you don't join or detach it.)
  • std::mutex and std::lock_guard: protect data that several threads change. lock_guard is RAII: it locks in the constructor and unlocks when it leaves scope.
  • std::async and std::future: run a function and get its result later with future.get(). No explicit locking, because each task returns its own value.
  • std::atomic<T>: lock-free updates for a single counter.

Data races (two threads touching the same data, one writing, with no synchronization) are undefined behavior. The safest designs share nothing: each thread builds its own result and you merge afterwards. Use a mutex only around the small merge step.

The code

Counting words in parallel with jthread, mutex and async
#include <algorithm>
#include <future>
#include <iostream>
#include <map>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>

using Counts = std::map<std::string, int>;     // ordered, so the output is deterministic

Counts count_words(const std::string& text) {
    Counts counts;
    std::istringstream stream{text};
    std::string word;
    while (stream >> word) ++counts[word];
    return counts;
}

int main() {
    const std::vector<std::string> chunks{
        "red green blue red", "blue blue green", "red yellow red green", "yellow blue",
    };

    // Style 1: jthreads merge into shared state under a mutex
    Counts total;
    std::mutex total_mutex;
    {
        std::vector<std::jthread> workers;
        for (const auto& chunk : chunks) {
            workers.emplace_back([&total, &total_mutex, &chunk] {
                Counts local = count_words(chunk);                    // no sharing while counting
                std::lock_guard<std::mutex> lock{total_mutex};        // lock only to merge
                for (const auto& [word, n] : local) total[word] += n;
            });
        }
    }   // the jthreads join here, automatically

    for (const auto& [word, n] : total) std::cout << word << ": " << n << '\n';

    // Style 2: std::async returns each result through a future, with no explicit locking
    std::vector<std::future<Counts>> futures;
    for (const auto& chunk : chunks) {
        futures.push_back(std::async(std::launch::async, count_words, chunk));
    }
    Counts merged;
    for (auto& future : futures) {
        for (const auto& [word, n] : future.get()) merged[word] += n;
    }

    std::cout << "same result: " << std::boolalpha << (merged == total) << '\n';
    std::cout << "chunks processed: " << chunks.size() << '\n';
}

Output

blue: 4
green: 3
red: 4
yellow: 2
same result: true
chunks processed: 4

Run this on your computer — see “Get Started” for setup.

Run it repeatedly: because every thread builds a private Counts and only the short merge is locked, the totals are identical every time. Remove the lock_guard and the program has a data race: results may vary, or crash.

Common mistakes

  • Sharing data between threads without synchronization. Any unsynchronized write is a data race; use a mutex, atomics, or give each thread its own data.
  • Locking for the whole task. Hold locks briefly; do the heavy work outside them.
  • Capturing locals by reference in a detached thread that outlives them. Pass by value or make sure the thread joins first.
  • Forgetting to join std::thread. Use std::jthread, which joins automatically.
  • Creating a thread per tiny task. Threads cost time; batch work, or use a thread pool for many small jobs.

Key points

  • Prefer std::jthread (joins automatically) and std::async (returns results) over manual thread management.
  • Protect shared data with std::mutex and std::lock_guard (RAII locking), but better still share nothing.
  • Build partial results per thread and merge them under a short lock.
  • Data races are undefined behavior; test concurrent code with ThreadSanitizer (-fsanitize=thread).

Further reading: cppreference: Concurrency support library.

Practice questions

Why is `std::jthread` safer than `std::thread`?

Answer

std::jthread joins in its destructor, so a forgotten join cannot terminate the program. A std::thread that is destroyed while still joinable calls std::terminate.

Why have each thread count into a local map and merge at the end rather than update one shared map?

Answer

Each thread works on its own data with no locking, so there are no data races and no contention. Only the brief merge step needs a lock, which keeps the program both correct and fast.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?