Concurrent Task Engine with Qt and CMake

Build a C++17 thread pool desktop app with Qt 6, Conan, and CMake.

Introduction

30 Second Summary

Think about when your computer downloads a file, plays music, and checks for updates all at the same time. That's concurrency at work, and building it correctly is one of the hardest problems in software.

In this project, you will build a desktop application that processes tasks concurrently using a custom C++17 thread pool, a Qt 6 GUI with live progress updates, and a modern CMake + Conan build system.

What You'll Build

You will run your finished app, click a button, and watch eight tasks fan out across your CPU's threads in real time. Each task appears in a live dashboard, moves from queued to running to complete, and the progress bar ticks up as results stream back.

By the end of this project, you'll have:

  • A live task dashboard where you submit tasks from a JSON config and watch them process in parallel with real-time status updates and a progress bar.
  • A custom thread pool that distributes work across your CPU cores and safely communicates results back to the GUI without crashes or freezes.
  • A reproducible build pipeline where Conan pulls spdlog and nlohmann_json as dependencies and CMake wires everything into a single executable.
  • Secret Mission: Add cooperative task cancellation with stop tokens and a priority queue to the thread pool.

Are there any prerequisites?

You need Visual Studio 2026 installed (for the MSVC compiler), Git installed, and Python 3.8 or higher (for installing Conan). Step 1 walks you through setting up Qt and Conan.

Before We Start

Before we dive in, let's take a moment to commit to what you're building and why. You're about to combine C++17 concurrency primitives, a Qt 6 desktop GUI, and a modern CMake + Conan build pipeline into a single working application.

Scaffold the Project and Show a First Window

Before writing concurrent code, you need a working build pipeline. A project with Qt, Conan, and CMake requires all three tools to agree on compiler settings, library paths, and dependency versions.

This step wires them together and produces a visible Qt window on screen. That window proves your entire toolchain is working end-to-end.

In this step, get ready to:
  • Create the project directory with Conan and CMake configuration files.
  • Install dependencies and run a CMake build.
  • Write a minimal Qt window and verify it appears on screen.
Create the project directory and configuration files

Every terminal command in this project runs inside a Developer Command Prompt for VS 2026. You already have one open from the previous step.

  • Move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
  • Create your project folder and initialize a Git repository by running these commands:
mkdir task-engine
cd task-engine
git init

You should see a message confirming the empty Git repository was initialized.

  • Create the Conan configuration file by saving the following as conanfile.txt in the root of task-engine:
[requires]
spdlog/1.17.0
nlohmann_json/3.12.0

[generators]
CMakeDeps
CMakeToolchain

What does this file do?

The [requires] section declares the two libraries your project needs: spdlog for structured logging and nlohmann_json for reading JSON task configurations.

The [generators] section tells Conan to produce CMake-compatible files. CMakeDeps creates find_package targets for each library. CMakeToolchain creates a toolchain file that passes Conan's compiler and build-type settings into CMake.

  • Create the CMake build script by saving the following as CMakeLists.txt in the root of task-engine:
cmake_minimum_required(VERSION 3.22)
project(TaskEngine LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)

find_package(Qt6 REQUIRED COMPONENTS Widgets)
find_package(spdlog REQUIRED)
find_package(nlohmann_json REQUIRED)

add_executable(TaskEngine
    src/main.cpp
    src/MainWindow.cpp
)

target_include_directories(TaskEngine PRIVATE src)

target_link_libraries(TaskEngine PRIVATE
    Qt6::Widgets
    spdlog::spdlog
    nlohmann_json::nlohmann_json
)

What does this CMake file do?

  • CMAKE_CXX_STANDARD 17 locks the project to C++17 so you can use std::optional, std::shared_mutex, and structured bindings.
  • CMAKE_AUTOMOC ON tells CMake to automatically run Qt's Meta-Object Compiler on files containing Q_OBJECT.
  • find_package lines locate Qt 6, spdlog, and nlohmann_json through the files Conan generates.
  • target_link_libraries links all three libraries to your executable so you can include their headers and call their functions.
  • Confirm both configuration files exist by running:
dir

You should see conanfile.txt and CMakeLists.txt listed in the output.

Install dependencies with Conan

Conan reads your conanfile.txt, downloads the two libraries from the Conan Center remote, and generates CMake integration files inside a build folder.

  • Install the project dependencies by running:
conan install . --output-folder=build --build=missing

What does this command do?

  • --output-folder=build puts all generated files (toolchain, find-package configs) into a build/ subfolder.
  • --build=missing tells Conan to compile any library from source if a pre-built binary is not available for your compiler.

The command prints a dependency graph and ends with lines about the CMakeToolchain and CMakeDeps generators running.

  • Confirm the toolchain file was created by running:
dir build\conan_toolchain.cmake

You should see the file listed. This is the bridge that passes Conan's settings into CMake.

Seeing a download error?

If Conan reports a network error or cannot reach the remote, check your internet connection and confirm the default remote is configured by running conan remote list. You should see conancenter pointing to https://center2.conan.io.

If no remote is listed, add it by running:

conan remote add conancenter https://center2.conan.io

Then re-run the install command. Help me troubleshoot my Conan install error.

Write the Qt window and build the project

With dependencies installed, you can now write the application source. The project needs three source files: a MainWindow class (header and implementation) and main.cpp to launch it.

  • Create the source directory by running:
mkdir src
  • Create src/MainWindow.h with the following content:
#pragma once

#include <QMainWindow>

class MainWindow : public QMainWindow {
    Q_OBJECT

public:
    explicit MainWindow(QWidget* parent = nullptr);
};

What does this header do?

This declares a minimal MainWindow class that inherits from QMainWindow. The Q_OBJECT macro enables Qt's signal/slot system. You will expand this class in later steps to add widgets and thread-safe signals.

  • Verify the header was created by running:
dir src

You should see MainWindow.h listed.

  • Create src/MainWindow.cpp with the following content:
#include "MainWindow.h"

MainWindow::MainWindow(QWidget* parent)
    : QMainWindow(parent) {
    setWindowTitle("Task Engine");
    setFixedSize(600, 400);
}
  • Create src/main.cpp with the following content:
#include <QApplication>
#include <spdlog/spdlog.h>
#include "MainWindow.h"

int main(int argc, char* argv[]) {
    spdlog::set_level(spdlog::level::info);
    spdlog::info("Task Engine starting");

    QApplication app(argc, argv);

    MainWindow window;
    window.show();

    return app.exec();
}

What does main.cpp do?

  • spdlog::set_level configures spdlog to print info-level messages to the console.
  • QApplication app initializes the Qt event loop that powers the GUI.
  • window.show() makes the window visible on screen.
  • app.exec() starts the event loop and blocks until the window is closed.

Now configure and build the project with CMake. The configure step needs two paths: the Conan toolchain file and your Qt installation.

  • Configure the CMake build by running:
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake -DCMAKE_PREFIX_PATH=C:/Qt/6.11.1/msvc2022_64

What do these flags mean?

  • -S . points CMake at your source directory (current folder).
  • -B build places all build artifacts in the build/ folder (same folder Conan used).
  • -DCMAKE_TOOLCHAIN_FILE injects Conan's compiler and library settings.
  • -DCMAKE_PREFIX_PATH tells CMake where your Qt 6 installation lives so find_package(Qt6) succeeds.

Adjust the Qt path if needed

If you installed Qt to a different location than the default C:\Qt\6.11.1\msvc2022_64, replace the path in the command above with your actual installation directory. Use forward slashes in the path.

CMake prints the configuration results. You should see lines confirming that Qt6, spdlog, and nlohmann_json were found.

  • Build the project in Release mode by running:
cmake --build build --config Release

The compiler output ends with a line confirming the TaskEngine target was built successfully.

Build failed?

  • If CMake reports it cannot find Qt6, double-check that CMAKE_PREFIX_PATH matches your Qt installation folder exactly.
  • If you see linker errors about missing symbols, confirm CMAKE_AUTOMOC is set to ON in your CMakeLists.txt.
  • If spdlog or nlohmann_json is not found, re-run conan install and confirm it completed without errors.

Help me fix my CMake build error.

Before you run the executable, what do you expect to see? A console log from spdlog, then a Qt window appearing on your desktop.

  • Run the built executable:
build\Release\TaskEngine.exe

A window titled Task Engine appears on screen at 600x400 pixels. In the terminal behind it, you should see spdlog output: [info] Task Engine starting. Close the window to return to the command prompt.

Window does not appear?

  • Make sure you are running from a Developer Command Prompt, not a plain PowerShell or cmd window. The MSVC runtime DLLs must be on your PATH.
  • If you see a missing DLL error mentioning Qt, add C:\Qt\6.11.1\msvc2022_64\bin to your PATH for this session by running set PATH=C:\Qt\6.11.1\msvc2022_64\bin;%PATH% and re-run the executable.

Help me fix the missing DLL or window not appearing.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all five files: conanfile.txt, CMakeLists.txt, src/MainWindow.h, src/MainWindow.cpp, and src/main.cpp.

ⓧ I'd like to double check the full code

Scroll up and confirm you have each of these files with the content shown above, in this order:

  • conanfile.txt (7 lines, two sections: requires and generators).
  • CMakeLists.txt (20 lines, sets C++17, AUTOMOC, finds packages, defines the executable and links libraries).
  • src/MainWindow.h (declares the MainWindow class with Q_OBJECT).
  • src/MainWindow.cpp (constructor sets title to "Task Engine" and size to 600x400).
  • src/main.cpp (initializes spdlog, creates QApplication, shows MainWindow).

Your full build pipeline is working. CMake, Conan, Qt, and MSVC are all talking to each other. Next up, you will build the thread pool that processes tasks concurrently across multiple hardware threads.

Build the Thread Pool

Your app shows a window, but it does not process anything yet. The goal of this project is to run multiple tasks at the same time, distributing work across your CPU cores.

A thread pool is the standard pattern for bounded concurrency. You create a fixed set of worker threads, feed them tasks from a shared queue, and let the pool manage scheduling. In this step you will implement this pattern from scratch using C++17 concurrency primitives.

In this step, get ready to:
  • Implement a ThreadPool class with std::shared_mutex, std::condition_variable, and std::scoped_lock.
  • Create a Task struct that simulates work and returns results via std::optional.
  • Verify concurrent execution by observing interleaved thread IDs in console output.
Create the ThreadPool header

The thread pool lives in a single header file. It manages a vector of worker threads, a task queue protected by a mutex, and a shared status map for thread-safe reads.

  • Create a new file called src/ThreadPool.h inside the task-engine folder.
  • Start the file with the include guard and standard library headers by pasting this code:
#pragma once

#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <shared_mutex>
#include <condition_variable>
#include <functional>
#include <future>
#include <atomic>
#include <string>
#include <unordered_map>

// Thread pool that distributes tasks across hardware threads
class ThreadPool {
public:
    // Spawns worker threads equal to the number of CPU cores
    explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency())
        : stop_flag_(false) {
        for (size_t i = 0; i < num_threads; ++i) {
            workers_.emplace_back([this] { worker_loop(); });
        }
    }

What does this code do?

The #pragma once guard prevents the header from being included twice in a single compilation unit.

The constructor takes a num_threads parameter that defaults to std::thread::hardware_concurrency() which returns the number of CPU cores on your machine. Each worker thread runs the worker_loop() function (which you will define shortly).

  • Below the constructor, add the destructor and the submit() method:
    ~ThreadPool() {
        {
            // Signal all workers to stop
            std::scoped_lock lock(queue_mutex_);
            stop_flag_ = true;
        }
        condition_.notify_all();
        for (auto& worker : workers_) {
            if (worker.joinable()) {
                worker.join();
            }
        }
    }

    // Submits a callable and returns a future holding the result
    template <typename F, typename... Args>
    auto submit(F&& f, Args&&... args) -> std::future<std::invoke_result_t<F, Args...>> {
        using return_type = std::invoke_result_t<F, Args...>;

        auto task = std::make_shared<std::packaged_task<return_type()>>(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );

        std::future<return_type> result = task->get_future();

        {
            std::scoped_lock lock(queue_mutex_);
            tasks_.emplace([task]() { (*task)(); });
        }
        condition_.notify_one();

        return result;
    }

What does this code do?

The destructor uses std::scoped_lock to safely set the stop flag, wakes all sleeping workers with notify_all(), then joins each thread so no work is abandoned.

The submit() method wraps any callable into a std::packaged_task, pushes it onto the queue, and returns a std::future the caller can use to retrieve the result later. The condition_.notify_one() call wakes exactly one sleeping worker to pick up the new task.

  • Below the submit() method, add the status-tracking methods and the worker loop:
    // Writes a status entry (exclusive lock for writes)
    void update_status(int task_id, const std::string& status) {
        std::unique_lock lock(status_mutex_);
        status_map_[task_id] = status;
    }

    // Reads a status entry (shared lock allows concurrent reads)
    std::string get_status(int task_id) const {
        std::shared_lock lock(status_mutex_);
        auto it = status_map_.find(task_id);
        if (it != status_map_.end()) {
            return it->second;
        }
        return "unknown";
    }

private:
    // Each worker loops forever, waiting for tasks
    void worker_loop() {
        while (true) {
            std::function<void()> task;
            {
                std::unique_lock lock(queue_mutex_);
                condition_.wait(lock, [this] {
                    return stop_flag_ || !tasks_.empty();
                });
                if (stop_flag_ && tasks_.empty()) {
                    return;
                }
                task = std::move(tasks_.front());
                tasks_.pop();
            }
            task();
        }
    }

What does this code do?

The update_status() method uses a std::unique_lock on a std::shared_mutex for exclusive write access. The get_status() method uses a std::shared_lock so multiple threads can read concurrently without blocking each other.

The worker_loop() sleeps on the condition variable until either a task arrives or the stop flag is set. When it wakes, it pops one task from the front of the queue and executes it.

  • Finally, add the private member variables and close the class:
    // Worker threads
    std::vector<std::thread> workers_;
    // Shared task queue
    std::queue<std::function<void()>> tasks_;
    // Protects the task queue
    std::mutex queue_mutex_;
    std::condition_variable condition_;
    std::atomic<bool> stop_flag_;

    // Read-heavy status map uses shared_mutex
    mutable std::shared_mutex status_mutex_;
    std::unordered_map<int, std::string> status_map_;
};

Why mutable on shared_mutex?

The get_status() method is const because it only reads data. But locking a mutex modifies the mutex itself. The mutable keyword lets a const method lock the mutex without violating const-correctness.

  • Save src/ThreadPool.h.

✔️ Awesome, I've got everything!

Great. Double check you saved the file.

ⓧ I'd like to double check the full code

Your src/ThreadPool.h should contain these four sections in order:

  • The #pragma once guard, 12 standard library includes, and the constructor that spawns worker threads.
  • The destructor (sets stop flag, notifies all, joins workers) and the submit() template method that wraps callables into packaged tasks.
  • The update_status() and get_status() methods, followed by the private worker_loop() function.
  • The private member variables (workers_, tasks_, queue_mutex_, condition_, stop_flag_, status_mutex_, status_map_) and the closing };.

Seeing red squiggles in your editor?

The file is not compiled on its own yet, so IntelliSense may show warnings about forward-declared functions. This is expected. The build in the verification substep below will confirm everything compiles correctly.

help me understand why my ThreadPool.h has red underlines in Visual Studio

Create the Task struct

Each unit of work in the engine is a Task. It carries an ID, a name, and a simulated duration. The process() method returns its result wrapped in std::optional so callers can distinguish success from absence.

  • Create a new file called src/Task.h and paste the following code:
#pragma once

#include <string>
#include <optional>
#include <chrono>
#include <thread>
#include <spdlog/spdlog.h>

struct Task {
    int id;
    std::string name;
    int duration_ms;

    // Simulates work and returns a result string
    std::optional<std::string> process() const {
        spdlog::info("Task {} [{}] started on thread {}",
                     id, name, std::hash<std::thread::id>{}(std::this_thread::get_id()));

        std::this_thread::sleep_for(std::chrono::milliseconds(duration_ms));

        std::string result = name + " completed (" + std::to_string(duration_ms) + "ms)";

        spdlog::info("Task {} [{}] finished", id, name);
        return result;
    }
};

What does this code do?

  • std::hash<std::thread::id>{}(std::this_thread::get_id()) produces a numeric thread identifier that spdlog prints in the console. When multiple tasks run concurrently, you will see different IDs interleaving.
  • sleep_for simulates real compute work. Later steps will replace this with actual data transformations.
  • Returning std::optional<std::string> means the caller can check whether a result exists using has_value() before accessing it.
  • Save src/Task.h.
Verify concurrent execution

Both header files are saved, but nothing uses them yet. You will temporarily modify main.cpp to submit 8 test tasks, build, and confirm that multiple threads pick them up in parallel.

  • In src/main.cpp, select all the existing code and replace it with this temporary test harness:
#include <spdlog/spdlog.h>
#include <chrono>
#include <thread>
#include "ThreadPool.h"
#include "Task.h"

// Temporary test: submits 8 tasks to verify concurrency
int main() {
    spdlog::set_level(spdlog::level::info);
    spdlog::info("Task Engine starting - thread pool test");

    ThreadPool pool;

    for (int i = 1; i <= 8; ++i) {
        Task task{i, "Task " + std::to_string(i), 300 + (i * 100)};
        pool.submit([task]() { task.process(); });
    }

    // Give workers time to finish
    std::this_thread::sleep_for(std::chrono::seconds(3));
    spdlog::info("All tasks processed");
    return 0;
}

Why a temporary test?

This test bypasses the Qt GUI so you can verify the thread pool in isolation. Once you confirm concurrency works, you will restore the original main.cpp and integrate the pool with the Qt window in the next step.

  • Save src/main.cpp.
  • Rebuild the project by running these commands in your Developer Command Prompt:
cmake --build build --config Release

Before you run the executable, think about this: if the thread pool is truly concurrent, do you expect all 8 tasks to start at roughly the same time, or one after another?

  • Run the built executable:
build\Release\TaskEngine.exe

You should see spdlog output with multiple different thread IDs appearing interleaved. Tasks start in parallel rather than sequentially, and different hash values confirm different threads are doing the work.

What should I see?

Your output looks something like this (the exact hash numbers differ on every machine):

[info] Task Engine starting - thread pool test
[info] Task 1 [Task 1] started on thread 14389201556
[info] Task 2 [Task 2] started on thread 73920184522
[info] Task 3 [Task 3] started on thread 29481023774
[info] Task 4 [Task 4] started on thread 14389201556
...

The key evidence is that thread hash values repeat and interleave. If tasks ran sequentially, you would see only one thread ID throughout.

Build fails with unresolved symbols?

  • Make sure you saved both src/ThreadPool.h and src/Task.h before building.
  • Verify that #include "ThreadPool.h" and #include "Task.h" appear at the top of your test main.cpp.
  • If the linker complains about spdlog, re-run conan install . --output-folder=build --build=missing and reconfigure with CMake.

help me fix build errors in my C++17 thread pool project with spdlog and CMake

  • Now that concurrency is confirmed, restore the original main.cpp. Select all the test code and replace it with:
#include <QApplication>
#include <spdlog/spdlog.h>
#include "MainWindow.h"

int main(int argc, char* argv[]) {
    spdlog::set_level(spdlog::level::info);
    spdlog::info("Task Engine starting");

    QApplication app(argc, argv);

    MainWindow window;
    window.show();

    return app.exec();
}
  • Save src/main.cpp.

Why restore the original?

The next step will integrate the thread pool with the Qt GUI using signals and slots. The Qt application entry point needs QApplication and MainWindow back in place for that to work.

Your thread pool distributes work across CPU cores and the Task struct wraps each job with structured logging. Next up, you will connect this engine to the Qt window so task progress updates the GUI in real time.

Attempt Naive GUI Integration

Your thread pool crunches tasks in the background. The logical next step is showing that progress inside the Qt window so you can watch tasks execute in real time.

You'll add a task list widget, a progress bar, and a button to MainWindow. Then you'll update those widgets directly from the worker threads as each task completes.

In this step, get ready to:
  • Add a task list, progress bar, and button to MainWindow.
  • Call widget update methods directly from worker threads.
  • Build, run, and observe what happens.
Create the MainWindow header with widgets

The existing MainWindow only shows an empty window. You need to declare the widgets and methods that will display task progress.

  • Open src/MainWindow.h and replace its entire contents with the following code:
#pragma once

#include <QMainWindow>
#include <QListWidget>
#include <QProgressBar>
#include <QPushButton>
#include <QLabel>
#include <atomic>
#include <memory>
#include "ThreadPool.h"

class MainWindow : public QMainWindow {
    Q_OBJECT

public:
    explicit MainWindow(QWidget* parent = nullptr);
    void updateTaskStatus(int id, QString status);

private slots:
    void onRunTasks();

private:
    QListWidget* task_list_;
    QProgressBar* progress_bar_;
    QPushButton* run_button_;
    QLabel* status_label_;

    std::unique_ptr<ThreadPool> pool_;
    std::atomic<int> completed_count_{0};
    int total_tasks_{0};
};

What does this header declare?

  • Q_OBJECT enables Qt's meta-object system (signals, slots, and runtime type info).
  • updateTaskStatus() is public so worker threads can call it directly to update the list widget.
  • onRunTasks() is a slot connected to the button click.
  • The ThreadPool lives in a std::unique_ptr so the window owns its lifetime.
  • Save src/MainWindow.h.
Implement the naive MainWindow

Now write the implementation. The constructor sets up the layout, and the task methods update widgets when work starts and finishes.

  • Open src/MainWindow.cpp and replace its contents with the constructor below:
#include "MainWindow.h"
#include "Task.h"
#include <QVBoxLayout>
#include <QWidget>
#include <spdlog/spdlog.h>

MainWindow::MainWindow(QWidget* parent)
    : QMainWindow(parent)
    , pool_(std::make_unique<ThreadPool>()) {
    setWindowTitle("Task Engine");
    setFixedSize(600, 400);

    // Central widget and vertical layout
    auto* central = new QWidget(this);
    auto* layout = new QVBoxLayout(central);

    // Create all UI widgets
    run_button_ = new QPushButton("Run Tasks", central);
    task_list_ = new QListWidget(central);
    progress_bar_ = new QProgressBar(central);
    progress_bar_->setRange(0, 100);
    progress_bar_->setValue(0);
    status_label_ = new QLabel("Ready", central);

    // Add widgets to layout
    layout->addWidget(run_button_);
    layout->addWidget(task_list_);
    layout->addWidget(progress_bar_);
    layout->addWidget(status_label_);

    setCentralWidget(central);

    connect(run_button_, &QPushButton::clicked,
            this, &MainWindow::onRunTasks);
}

What does the constructor do?

It creates a vertical layout with four widgets: a button to trigger task execution, a list widget for status updates, a progress bar, and a label showing overall status. The button's click signal is connected to the onRunTasks() slot.

  • Below the constructor in the same file, add the updateTaskStatus and onRunTasks methods:
void MainWindow::updateTaskStatus(int id, QString status) {
    // Directly modifying the list widget from whatever thread calls this
    task_list_->addItem(QString("Task %1: %2").arg(id).arg(status));
}

void MainWindow::onRunTasks() {
    run_button_->setEnabled(false);
    task_list_->clear();
    completed_count_ = 0;
    total_tasks_ = 8;

    for (int i = 1; i <= total_tasks_; ++i) {
        Task task{i, "Task-" + std::to_string(i), 500 + (i * 200)};

        pool_->submit([this, task]() {
            // Call updateTaskStatus from this worker thread
            updateTaskStatus(task.id,
                QString::fromStdString(task.name + " - running"));

            auto result = task.process();
            if (result.has_value()) {
                int completed = ++completed_count_;
                updateTaskStatus(task.id,
                    QString::fromStdString(result.value()));
            }
        });
    }
}

What do these methods do?

updateTaskStatus() adds a row to the QListWidget each time a task changes state.

onRunTasks() creates 8 tasks with increasing durations and submits each to the thread pool. The lambda captures this and calls updateTaskStatus() from inside the worker thread when a task starts and finishes.

  • Save src/MainWindow.cpp.
Build and run the app
  • Rebuild the project by running:
cmake --build build --config Release

The build should succeed with no compilation errors.

Before you run the app, think about what you expect to happen. Multiple worker threads will call updateTaskStatus() at the same time, and that method modifies a Qt widget. Do you think the GUI will update smoothly, or will something go wrong?

  • Run the executable:
build\Release\TaskEngine.exe

You will see one of these outcomes: the application crashes, the GUI freezes and becomes unresponsive, or you see garbled output in the list widget. You may also see a Qt warning in the console about accessing widgets from a non-GUI thread.

This failure is intentional

You just hit a fundamental constraint in Qt. The Qt documentation states: "the GUI classes, notably QWidget and all its subclasses, are not reentrant. They can only be used from the main thread."

Your updateTaskStatus() method modifies a QListWidget from multiple worker threads simultaneously. Qt's internal painting and event delivery systems are not designed for this. The crash or undefined behavior you just observed is the expected result.

The next step introduces the proper solution: Qt's queued signal/slot mechanism, which safely marshals data from any thread to the GUI thread's event loop.

✔️ Awesome, I've got everything!

Great. Make sure both src/MainWindow.h and src/MainWindow.cpp are saved.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these two chunks present in src/MainWindow.cpp, in order:

  • The constructor (includes, MainWindow::MainWindow with layout and widget setup).
  • The two methods: updateTaskStatus() and onRunTasks().

And confirm src/MainWindow.h matches the header block above (class with Q_OBJECT, public updateTaskStatus, private slot onRunTasks, widget pointers, pool, counters).

You now know that directly calling widget methods from worker threads breaks Qt's threading contract. In the next step, you'll fix this with Qt's queued signal/slot mechanism to safely bridge threads.

Implement Thread-Safe Qt Communication

The crash from the previous step proved a fundamental constraint: Qt GUI objects can only be accessed from the main thread. Worker threads that call widget methods directly cause undefined behavior.

The proper solution is Qt's queued signal/slot mechanism. When a worker thread emits a signal connected with Qt::QueuedConnection, Qt posts the call to the main thread's event loop, where the slot runs safely.

In this step, get ready to:
  • Replace direct widget calls with a queued signal/slot connection.
  • Load task definitions from a JSON file using nlohmann_json.
  • Wire up a Run Tasks button with atomic progress tracking and a real-time status bar.
Define the signal/slot interface in MainWindow.h

The old header exposes a public updateTaskStatus() method that workers called directly. You need to replace it with a signal that workers emit and a private slot that only the GUI thread executes.

  • Replace the entire contents of src/MainWindow.h with the following code:
#pragma once

#include <QMainWindow>
#include <QListWidget>
#include <QProgressBar>
#include <QPushButton>
#include <QLabel>
#include <atomic>
#include <memory>
#include "ThreadPool.h"

class MainWindow : public QMainWindow {
    Q_OBJECT

public:
    explicit MainWindow(QWidget* parent = nullptr);

signals:
    void taskStatusChanged(int id, QString status, int progress);

private slots:
    void onTaskStatusChanged(int id, QString status, int progress);
    void onRunTasks();

private:
    void loadAndRunTasks();

    QListWidget* task_list_;
    QProgressBar* progress_bar_;
    QPushButton* run_button_;
    QLabel* status_label_;

    std::unique_ptr<ThreadPool> pool_;
    std::atomic<int> completed_count_{0};
    int total_tasks_{0};
};

What changed in the header?

The public updateTaskStatus() method is gone. In its place you have a signals: section with taskStatusChanged and a private slots: section with onTaskStatusChanged.

Workers will emit the signal from any thread. Qt's event loop delivers it to onTaskStatusChanged on the GUI thread, where widget access is safe.

  • Save src/MainWindow.h.
Rebuild the constructor with a queued connection

The constructor sets up the same widgets as before, but now it connects taskStatusChanged to the slot using Qt::QueuedConnection. This is the single line that fixes the crash.

  • Replace the entire contents of src/MainWindow.cpp with these includes at the top of the file:
#include "MainWindow.h"
#include "Task.h"

#include <QVBoxLayout>
#include <QWidget>
#include <QFile>
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <fstream>
  • Immediately below the includes, add the constructor:
MainWindow::MainWindow(QWidget* parent)
    : QMainWindow(parent)
    , pool_(std::make_unique<ThreadPool>()) {
    setWindowTitle("Task Engine");
    setFixedSize(600, 400);

    auto* central = new QWidget(this);
    auto* layout = new QVBoxLayout(central);

    run_button_ = new QPushButton("Run Tasks", central);
    task_list_ = new QListWidget(central);
    progress_bar_ = new QProgressBar(central);
    progress_bar_->setRange(0, 100);
    progress_bar_->setValue(0);
    status_label_ = new QLabel("Ready", central);

    layout->addWidget(run_button_);
    layout->addWidget(task_list_);
    layout->addWidget(progress_bar_);
    layout->addWidget(status_label_);

    setCentralWidget(central);

    connect(run_button_, &QPushButton::clicked, this, &MainWindow::onRunTasks);
    connect(this, &MainWindow::taskStatusChanged,
            this, &MainWindow::onTaskStatusChanged,
            Qt::QueuedConnection);
}

Why Qt::QueuedConnection fixes the crash

With Qt::QueuedConnection, when a worker thread calls emit taskStatusChanged(...), Qt does not execute the slot immediately. Instead it posts an event to the main thread's event loop.

The slot onTaskStatusChanged runs later, on the GUI thread, where modifying widgets is safe. The worker thread continues executing without blocking.

  • Below the constructor, add the two slot implementations:
void MainWindow::onTaskStatusChanged(int id, QString status, int progress) {
    task_list_->addItem(QString("Task %1: %2").arg(id).arg(status));
    progress_bar_->setValue(progress);
    status_label_->setText(QString("%1/%2 tasks complete")
                               .arg(completed_count_.load())
                               .arg(total_tasks_));
}

void MainWindow::onRunTasks() {
    run_button_->setEnabled(false);
    task_list_->clear();
    completed_count_ = 0;
    progress_bar_->setValue(0);

    loadAndRunTasks();
}

What do these slots do?

  • onTaskStatusChanged updates the list widget, progress bar, and status label. Because it only runs on the GUI thread, there is no race condition.
  • onRunTasks disables the button to prevent double-clicks, resets counters, and calls loadAndRunTasks() to dispatch work.
  • Save src/MainWindow.cpp and build to confirm the signal/slot wiring compiles:
cmake --build build --config Release

Seeing a linker error about undefined slots?

Make sure CMAKE_AUTOMOC is set to ON in your CMakeLists.txt. Qt's Meta-Object Compiler needs to process your header to generate signal/slot glue code.

If you still see errors, delete the build folder and re-run the full configure + build sequence. Help me fix my CMake AUTOMOC error

Load tasks from JSON and submit them to the thread pool

The loadAndRunTasks() method reads a tasks.json file, parses each task definition with nlohmann_json, and submits them to the thread pool.

  • Create a file called tasks.json in the root of your task-engine folder with this content:
{
    "tasks": [
        {"id": 1, "name": "Parse dataset", "duration_ms": 1200},
        {"id": 2, "name": "Validate schema", "duration_ms": 800},
        {"id": 3, "name": "Transform records", "duration_ms": 1500},
        {"id": 4, "name": "Compute statistics", "duration_ms": 2000},
        {"id": 5, "name": "Generate report", "duration_ms": 1000},
        {"id": 6, "name": "Compress output", "duration_ms": 900},
        {"id": 7, "name": "Upload results", "duration_ms": 1800},
        {"id": 8, "name": "Send notification", "duration_ms": 500}
    ]
}

What is tasks.json for?

Each object defines a simulated task with an id, a human-readable name, and a duration_ms that controls how long the simulated work takes. The thread pool processes all 8 tasks concurrently, so the total wall time is roughly the duration of the longest task, not the sum.

  • Confirm the file exists by running:
ls tasks.json

You should see tasks.json listed in the output.

  • Back in src/MainWindow.cpp, add the first half of loadAndRunTasks() below the onRunTasks function. This opens and parses the JSON file:
void MainWindow::loadAndRunTasks() {
    std::ifstream file("tasks.json");
    if (!file.is_open()) {
        spdlog::error("Failed to open tasks.json");
        status_label_->setText("Error: tasks.json not found");
        run_button_->setEnabled(true);
        return;
    }

    nlohmann::json config = nlohmann::json::parse(file);
    auto tasks = config["tasks"];
    total_tasks_ = static_cast<int>(tasks.size());

    spdlog::info("Loaded {} tasks from tasks.json", total_tasks_);
  • Directly below (still inside the loadAndRunTasks function), add the task submission loop that emits signals instead of touching widgets:
    for (const auto& [key, value] : tasks.items()) {
        Task task{
            value["id"].get<int>(),
            value["name"].get<std::string>(),
            value["duration_ms"].get<int>()
        };

        emit taskStatusChanged(task.id, QString::fromStdString(task.name + " - queued"), 0);

        pool_->submit([this, task]() {
            pool_->update_status(task.id, "running");
            emit taskStatusChanged(task.id,
                                   QString::fromStdString(task.name + " - running"), 0);

            auto result = task.process();

            if (result.has_value()) {
                pool_->update_status(task.id, "done");
                int completed = ++completed_count_;
                int progress = (completed * 100) / total_tasks_;
                emit taskStatusChanged(task.id,
                                       QString::fromStdString(result.value()),
                                       progress);

                if (completed == total_tasks_) {
                    spdlog::info("All tasks complete");
                    QMetaObject::invokeMethod(run_button_, [this]() {
                        run_button_->setEnabled(true);
                        status_label_->setText("All tasks complete!");
                    }, Qt::QueuedConnection);
                }
            }
        });
    }
}

What does the submission loop do?

  • Structured bindings (const auto& [key, value]) iterate the JSON array. Each entry becomes a Task struct.
  • emit taskStatusChanged(...) is safe to call from any thread because the connection is queued. The slot handles all widget updates on the GUI thread.
  • std::atomic<int> completed_count_ tracks completions without a mutex. When all tasks finish, QMetaObject::invokeMethod re-enables the button from the GUI thread.
  • Save src/MainWindow.cpp.

Before building, predict: will the app crash like before, or will the GUI update smoothly?

  • Build and run the application:
cmake --build build --config Release
.\build\Release\TaskEngine.exe

The window appears with a Run Tasks button. Click it.

You should see tasks appearing in the list widget in real-time, the progress bar advancing, and the status label counting completions. In the console, spdlog shows interleaved thread IDs confirming concurrent execution. No crash.

Why this approach instead of direct mutex locks?

You could wrap every widget call in a mutex, but Qt's event loop already provides a thread-safe dispatch mechanism. Queued connections keep widget code single-threaded without any manual locking. This is the idiomatic pattern for concurrent Qt applications.

App runs but no tasks appear in the list?

Make sure tasks.json is in the working directory where the executable runs. If you built in the build folder, copy tasks.json there or run the executable from the project root.

Check the console output for the spdlog error Failed to open tasks.json which confirms the file path issue. Help me fix my tasks.json path issue

  • Commit the working state to Git by running:
git add -A && git commit -m "feat: concurrent task engine with Qt GUI"

You should see a summary of files committed including MainWindow.h, MainWindow.cpp, and tasks.json.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: src/MainWindow.h, src/MainWindow.cpp, and tasks.json.

ⓧ I'd like to double check the full code

Scroll up and confirm you have all chunks in order. Your src/MainWindow.cpp should contain these sections from top to bottom:

  • Includes (9 lines starting with #include "MainWindow.h").
  • Constructor (MainWindow::MainWindow through the closing }).
  • onTaskStatusChanged slot.
  • onRunTasks slot.
  • loadAndRunTasks function (JSON parse + submission loop + closing }).

Your src/MainWindow.h should have signals: with taskStatusChanged and private slots: with onTaskStatusChanged and onRunTasks. The updateTaskStatus public method should no longer exist.

Your concurrent task engine now processes work across multiple threads and updates the GUI safely through queued connections. Next up, you will extend this foundation with cooperative cancellation and priority scheduling in the Secret Mission.

Secret mission

Add Cancellation and Priority Scheduling

Your thread pool processes tasks, but what happens when a long-running task needs to stop? And what if some tasks are more urgent than others? Add cooperative cancellation with stop tokens and a priority queue to make your task engine production-grade.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on local, open-source tools. There are no cloud resources or paid services, so there are no ongoing costs.

Decide whether to keep your resources installed, pause them to come back later, or delete them entirely.

Resources you used:

  • task-engine project folder on your Desktop.
  • Qt 6.11.1 installation (approximately 5GB at C:\Qt\6.11.1\msvc2022_64).
  • Conan 2.31.1 (pip package) and its local package cache.
  • Git repository inside the project folder.

Keep everything running

No action needed. Choose this if you plan to continue building on this project or start the follow-on CEF integration project.

  • Your task-engine folder stays on your Desktop.
  • Qt 6.11.1 and Conan remain installed and ready.
  • The Conan cache retains downloaded packages (spdlog, nlohmann_json) for faster future builds.

Pause - I'll come back to this later

Free up disk space while keeping your project files intact.

  • Clear the Conan package cache by running this command in your terminal:
  • Your project files and Qt installation remain untouched for when you return.
conan remove "*" -c

This removes all downloaded packages from the local Conan cache. Conan will re-download them automatically the next time you run conan install.

Delete - I don't want to use this again

Remove all project resources and tools installed during this project.

  • Delete the project folder by running this in your terminal:
cd %USERPROFILE%\Desktop
rmdir /s /q task-engine
  • Clear the Conan cache and uninstall Conan by running:
conan remove "*" -c
pip uninstall conan -y
  • Uninstall Qt 6.11.1 using the Qt Maintenance Tool. Open a terminal and run:
C:\Qt\MaintenanceTool.exe purge

What does the purge command do?

The purge command uninstalls all installed Qt packages. The Maintenance Tool will ask for confirmation before proceeding.

Nice Work!

Nice Work!

You just built a concurrent desktop application from scratch using C++17 concurrency primitives, Qt 6.11.1, and a fully reproducible CMake + Conan build pipeline.

You've learned how to:

  • Build a custom C++17 thread pool using std::shared_mutex, std::condition_variable, std::optional, and structured bindings to process tasks concurrently across hardware threads.
  • Integrate Qt 6.11.1 with Conan-managed dependencies (spdlog, nlohmann_json) via a modern CMake build system using CMakeDeps and CMakeToolchain generators.
  • Experience and resolve the Qt cross-thread GUI crash by implementing queued signal/slot communication with Qt::QueuedConnection to safely marshal data from worker threads to the GUI thread.
  • Secret Mission: Implement cooperative cancellation tokens and a priority queue in the thread pool for production-grade task scheduling.

Ready to quiz yourself?