Почему твоя программа тормозит, и как это исправить
Представь: ты написал крутой парсер на C++, который должен обработать 10 000 URL-адресов. Ты запускаешь его, идёшь за кофе, возвращаешься — а он всё ещё грузит первый сайт. Знакомо? Проблема в том, что программа работает синхронно: пока один запрос выполняется, остальные ждут своей очереди, как в советском универмаге.
В современном мире пользователи не прощают задержек. Если сервер отвечает дольше 200 мс — ты теряешь клиентов. Но как заставить C++ работать быстрее, не переписывая всё с нуля? Ответ — очереди и фоновые задачи. Это не просто модный термин, а фундаментальный паттерн, который используют в ядре Linux, игровых движках и высоконагруженных сервисах.
В этой статье мы разберёмся, как устроены очереди задач на C++, напишем свой простой thread pool и научимся обрабатывать данные в фоне без гонок данных и дедлоков. Готов прокачать свой код? Поехали.
Что такое очередь задач и зачем она нужна
Очередь задач (task queue) — это структура данных, которая работает по принципу FIFO (First In, First Out). Представь себе конвейер: ты добавляешь задачи в один конец, а потоки-исполнители забирают их с другого. Это основа паттерна Producer-Consumer (производитель-потребитель).
Зачем это нужно в C++? Вот три главные причины:
- Параллельная обработка — несколько потоков одновременно берут задачи из очереди, ускоряя работу в разы.
- Асинхронность — основной поток не блокируется, а продолжает принимать новые запросы или обновлять интерфейс.
- Балансировка нагрузки — если одна задача тяжёлая, другие потоки подхватят лёгкие, и система не зависнет.
Например, веб-сервер на C++ может складывать входящие HTTP-запросы в очередь, а пул потоков обрабатывает их параллельно. Пока один поток парсит JSON, другой пишет в базу данных — и всё это без блокировки основного цикла.
Пишем простую очередь на C++ с нуля
Начнём с базового примера. В стандартной библиотеке C++ есть std::queue, но для многопоточности её нужно обернуть в защищённый класс. Вот минимальная реализация потокобезопасной очереди:
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <thread>
class ThreadSafeQueue {
private:
std::queue<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
public:
void push(std::function<void()> task) {
std::lock_guard<std::mutex> lock(mtx);
tasks.push(task);
cv.notify_one(); // Будим один ожидающий поток
}
std::function<void()> pop() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] { return !tasks.empty(); });
auto task = tasks.front();
tasks.pop();
return task;
}
};
Здесь мы используем std::mutex для защиты доступа к очереди и std::condition_variable, чтобы потоки не тратили процессор впустую, ожидая новые задачи. Когда задача появляется, мы будим один поток с помощью notify_one().
Но это только половина дела. Нам нужен пул потоков (thread pool), который будет выполнять эти задачи.
Создаём пул потоков: сердце фоновых задач
Пул потоков — это набор заранее созданных потоков, которые ждут работы. Вместо того чтобы создавать и уничтожать потоки для каждой задачи (это дорого), мы переиспользуем их. Вот классическая реализация:
class ThreadPool {
private:
std::vector<std::thread> workers;
ThreadSafeQueue queue;
bool stop = false;
void worker_loop() {
while (!stop) {
auto task = queue.pop();
if (task) task();
}
}
public:
ThreadPool(size_t num_threads) {
for (size_t i = 0; i < num_threads; ++i) {
workers.emplace_back(&ThreadPool::worker_loop, this);
}
}
void enqueue(std::function<void()> task) {
queue.push(task);
}
~ThreadPool() {
stop = true;
// Добавляем фиктивные задачи, чтобы разбудить все потоки
for (size_t i = 0; i < workers.size(); ++i) {
queue.push([] {});
}
for (auto& worker : workers) {
if (worker.joinable()) worker.join();
}
}
};
Обрати внимание на деструктор: мы добавляем пустые задачи, чтобы каждый поток вышел из pop() и завершился. Это элегантный способ завершить работу без дедлоков.
Теперь мы можем запускать фоновые задачи просто:
int main() {
ThreadPool pool(4); // 4 потока
for (int i = 0; i < 10; ++i) {
pool.enqueue([i] {
std::cout << "Task " << i << " executed by thread "
<< std::this_thread::get_id() << std::endl;
});
}
std::this_thread::sleep_for(std::chrono::seconds(1));
return 0;
}
Запустив этот код, ты увидишь, как задачи распределяются между потоками. Красота, правда?
Как обрабатывать результаты фоновых задач
Просто запустить задачу в фоне — мало. Часто нужно получить результат. Для этого используем std::future и std::promise. Давай модернизируем наш пул, чтобы он возвращал результаты:
#include <future>
template<typename F, typename... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<decltype(f(args...))>
{
using return_type = decltype(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();
queue.push([task]() { (*task)(); });
return result;
}
Теперь мы можем делать так:
auto future = pool.enqueue([](int a, int b) {
return a + b;
}, 5, 3);
int sum = future.get(); // Блокируемся, пока не получим результат
std::cout << "Sum: " << sum << std::endl;
Это даёт гибкость: ты можешь запустить несколько задач, а потом собрать результаты в любом порядке.
Реальный пример: парсинг веб-страниц в фоне
Допустим