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.
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 forgetjoin(). (std::threadterminates the program if you don't join or detach it.)std::mutexandstd::lock_guard: protect data that several threads change.lock_guardis RAII: it locks in the constructor and unlocks when it leaves scope.std::asyncandstd::future: run a function and get its result later withfuture.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
#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. Usestd::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) andstd::async(returns results) over manual thread management. - Protect shared data with
std::mutexandstd::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.