c++实现生产者消费者,由略懂到懵逼,进阶版

c++实现生产者消费者,由略懂到懵逼,进阶版

首先我们假定有两个生产者两个消费者,分别用两个线程替代,生产者生产固定数量的产品。

#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ProducerConsumer {
private:
  std::queue<int> buffer;          // 数据缓冲区
  std::mutex mtx;                  // 互斥锁,保护共享资源
  std::condition_variable cv_prod; // 生产者条件变量,用于等待缓冲区未满
  std::condition_variable cv_cons; // 消费者条件变量,用于等待缓冲区非空
  size_t max_size;                 // 缓冲区的最大容量
  const int total_data = 10;       // 一个生产者要生产的数据量

  // 生产者工作函数
  void producer_work(int id) {
    for (int i = 1; i <= total_data; ++i) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_prod.wait(
          lock, [&]() { return buffer.size() < max_size; }); // 等待缓冲区未满
      buffer.push(i);                                        // 生产数据
      std::cout << "Producer " << id << " produced: " << i << std::endl;
      cv_cons.notify_one(); // 通知消费者
    }
  }

  // 消费者工作函数
  void consumer_work(int id) {
    for (int i = 1; i <= total_data; ++i) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_cons.wait(lock, [&]() { return !buffer.empty(); }); // 等待缓冲区非空
      int data = buffer.front();                             // 消费数据
      buffer.pop();
      std::cout << "\tConsumer " << id << " consumed: " << data << std::endl;
      cv_prod.notify_one(); // 通知生产者
    }
  }

public:
  // 构造函数,初始化缓冲区大小
  ProducerConsumer(size_t size) : max_size(size) {}

  // 启动生产者和消费者线程
  void start(int num_producers, int num_consumers) {
    std::vector<std::thread> producers;
    std::vector<std::thread> consumers;

    // 创建生产者线程
    for (int i = 0; i < num_producers; ++i) {
      producers.emplace_back(&ProducerConsumer::producer_work, this, i);
    }

    // 创建消费者线程
    for (int i = 0; i < num_consumers; ++i) {
      consumers.emplace_back(&ProducerConsumer::consumer_work, this, i);
    }

    // 等待所有线程完成
    for (auto &producer : producers)
      producer.join();
    for (auto &consumer : consumers)
      consumer.join();
  }
};

int main() {
  ProducerConsumer pc(5); // 创建缓冲区大小为 5 的生产者消费者对象
  pc.start(2, 2);         // 启动 2 个生产者和 2 个消费者
  std::cout << "Program finished." << std::endl;
  return 0;
}

很好,上述代码,运行起来没啥问题,速度很快。现在我们讨论一下,每个生产者生产的总数据量由10变成1000W呢?想一下是不是运行速度很快?当然我们最好把打印注释掉,以避免IO造成的速度缓慢。实际上当我们把total_data 改成1000W后,速度会变得慢的很多,那么我们来简单思考一下为什么变慢了?

比较容易想到的是缓冲区大小问题,此时缓冲区为5,但是我们要生产千万级的数据,所以会出现频繁的缓冲区满的情况,导致生产者频繁的等待,等待消费者消费数据,造成大量的线程切换。所以性能会大大降低。所以解决办法增大queue的大小,我们先改成5000!同时我们可以增加一些时间函数来计算程序的耗时。消费者每百万次消耗打印一下。

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ProducerConsumer {
private:
  std::queue<int> buffer;          // 数据缓冲区
  std::mutex mtx;                  // 互斥锁,保护共享资源
  std::condition_variable cv_prod; // 生产者条件变量,用于等待缓冲区未满
  std::condition_variable cv_cons; // 消费者条件变量,用于等待缓冲区非空
  size_t max_size;                 // 缓冲区的最大容量
  const int total_data = 10000000; // 一个生产者要生产的数据量
  int produced_count = 0;          // 已经生产的数据量
  int consumed_count = 0;          // 已经消费的数据量

  // 生产者工作函数
  void producer_work(int id) {
    for (int i = 0; i < total_data; ++i) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_prod.wait(
          lock, [&]() { return buffer.size() < max_size; }); // 等待缓冲区未满

      // 生产数据
      buffer.push(++produced_count);
      if (produced_count % 1000000 == 0) { // 每生产 100 万数据打印一次
        std::cout << "Producer " << id << " produced: " << produced_count
                  << std::endl;
      }
      cv_cons.notify_one(); // 通知一个消费者
    }
  }

  // 消费者工作函数
  void consumer_work(int id) {
    for (int i = 0; i < total_data; ++i) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_cons.wait(lock, [&]() { return !buffer.empty(); }); // 等待缓冲区非空

      // 消费数据
      int data = buffer.front();
      buffer.pop();
      ++consumed_count;
      if (consumed_count % 1000000 == 0) { // 每消费 100 万数据打印一次
        std::cout << "\tConsumer " << id << " consumed: " << consumed_count
                  << std::endl;
      }
      cv_prod.notify_one(); // 通知一个生产者
    }
  }

public:
  // 构造函数,初始化缓冲区大小
  ProducerConsumer(size_t size) : max_size(size) {}

  // 启动生产者和消费者线程
  void start(int num_producers, int num_consumers) {
    std::vector<std::thread> producers;
    std::vector<std::thread> consumers;

    // 创建生产者线程
    for (int i = 0; i < num_producers; ++i) {
      producers.emplace_back(&ProducerConsumer::producer_work, this, i);
    }

    // 创建消费者线程
    for (int i = 0; i < num_consumers; ++i) {
      consumers.emplace_back(&ProducerConsumer::consumer_work, this, i);
    }

    // 等待所有线程完成
    for (auto &producer : producers)
      producer.join();
    for (auto &consumer : consumers)
      consumer.join();
  }
};

int main() {
  const size_t buffer_size = 5000;   // 缓冲区大小
  const int num_producers = 2;       // 生产者数量
  const int num_consumers = 2;       // 消费者数量

  ProducerConsumer pc(buffer_size); // 创建生产者消费者对象
  auto start_time = std::chrono::high_resolution_clock::now(); // 记录开始时间

  pc.start(num_producers, num_consumers); // 启动生产者和消费者

  auto end_time = std::chrono::high_resolution_clock::now(); // 记录结束时间
  auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
      end_time - start_time);

  std::cout << "Program finished. Total time: " << duration.count() << " ms"
            << std::endl;
  return 0;
}

可以看到数据处理的很快,几秒钟就处理好了,有聪明的同学就会想到,既然缓冲增加到了5000就可以大幅提升效率,那么改成1000W那不是起飞了!但是事实真的如此嘛?各位可以尝试将缓冲区改的更大一些,甚至可以和我们的总生产量的值一致。

应该会得出这样的结果,有提升,但是提升效果没有那么明显了,5→5000,提升的速度更明显,5000→到更大的值,提升速度不明显。如果数据量少,数据结构简单,那么这个缓冲区设置稍微大一些无妨,但是如果数据量大,且数据结构复杂,此时在将缓冲区设置为很大的值,就会导致以下问题:

  1. 内存占用过高
    • 缓冲区占用大量内存,可能导致系统内存不足,影响其他程序的运行。
  2. 资源浪费
    • 缓冲区大小与生产的总数据量相同,意味着大部分时间内缓冲区是未充分利用的,浪费内存资源。
  3. 启动延迟、消费者性能下降
    • 在某些单核cpu,线程长时间独占核心,线程调度策略不合理等会出现上述问题。所以选择一个合适的缓冲区大小有助于系统性能的最优。

解决了上述问题,我们在增加一下消费者的数量,我们由之前的2个消费者,变成一万个消费者。那么该如何编码?

有聪明的同学马上会想到,我们之前采用了两个线程充当消费者,那么1w个消费者同理,开启一万个线程即可!

首先直接说可能遇到的问题,有同学尝试改写代码后发现,程序可以编译,但是无法运行,过一会电脑冒烟了(玩笑),或者是将线程数量改小了,可以勉强运行,但是无法退出。

出现上述原因如下解释:

  1. 将消费者线程数量改成10000,编译通过后,无任何打印,等待很久没有显示,电脑也会随之风扇转速加快。

    1. 操作系统对线程的数量有限制
    2. 创建线程导致系统资源资源耗尽,如内存等
    3. (概率最大)创建成功,但是我们打印是每百万次打印一次,打印频率低,导致我们看不到任何反应。针对此条有人就会问了,为什么我们采用两个线程的消费者的时候,每百万次打印速度很快,创建10000个消费者的时候就这么慢呢?
      1. 线程调度开销变大了,操作系统需要频繁地在大量线程之间进行上下文切换,这会显著增加调度开销。线程数量越多,上下文切换的频率越高,导致CPU时间主要用于线程调度,而不是实际的计算任务。
      2. 锁争用和互斥锁开销,大量线程同时竞争互斥锁(std::mutex),会导致线程频繁阻塞和唤醒。互斥锁的开销在高并发场景下会显著增加。
      3. 内存开销增大,导致内存利用率低。
  2. 将消费线程改成了比较小如5个,可以较快速的运行,但是无法退出。

    1. 主要是因为我们就生产了2000w的数据,实际消费需要消费 (线程数 * 1000w)的数据,所以有些线程在忙等,所以当我们完成生产消费后,唤醒所有等待的线程,让它们退出。

为了方便调试,我们采用5个消费线程举例

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

class ProducerConsumer {
private:
  std::queue<int> buffer;          // 数据缓冲区
  std::mutex mtx;                  // 互斥锁,保护共享资源
  std::condition_variable cv_prod; // 生产者条件变量,用于等待缓冲区未满
  std::condition_variable cv_cons; // 消费者条件变量,用于等待缓冲区非空
  size_t max_size;                 // 缓冲区的最大容量
  const int total_data = 10000000; // 一个生产者要生产的数据量
  int produced_count = 0;          // 已经生产的数据量
  int consumed_count = 0;          // 已经消费的数据量
  bool done = false;               // 生产者是否完成生产

  // 生产者工作函数
  void producer_work(int id) {
    for (int i = 0; i < total_data; ++i) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_prod.wait(
          lock, [&]() { return buffer.size() < max_size; }); // 等待缓冲区未满

      // 生产数据
      buffer.push(++produced_count);
      if (produced_count % 1000000 == 0) { // 每生产 100 万数据打印一次
        std::cout << "Producer " << id << " produced: " << produced_count
                  << std::endl;
      }
      cv_cons.notify_one(); // 通知一个消费者
    }
  }

  // 消费者工作函数
  void consumer_work(int id) {
    while (true) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_cons.wait(lock, [&]() {
        return !buffer.empty() || done;
      }); // 等待缓冲区非空或完成标志

      // 如果生产者完成且缓冲区为空,退出
      if (done && buffer.empty()) {
        break;
      }

      // 消费数据
      int data = buffer.front();
      buffer.pop();
      ++consumed_count;
      if (consumed_count % 1000000 == 0) { // 每消费 100 万数据打印一次
        std::cout << "\tConsumer " << id << " consumed: " << consumed_count
                  << std::endl;
      }
      cv_prod.notify_one(); // 通知一个生产者
    }
  }

public:
  // 构造函数,初始化缓冲区大小
  ProducerConsumer(size_t size) : max_size(size) {}

  // 启动生产者和消费者线程
  void start(int num_producers, int num_consumers) {
    std::vector<std::thread> producers;
    std::vector<std::thread> consumers;

    // 创建生产者线程
    for (int i = 0; i < num_producers; ++i) {
      producers.emplace_back(&ProducerConsumer::producer_work, this, i);
    }

    // 创建消费者线程
    for (int i = 0; i < num_consumers; ++i) {
      consumers.emplace_back(&ProducerConsumer::consumer_work, this, i);
    }

    // 等待所有生产者线程完成
    for (auto &producer : producers) {
      producer.join();
    }

    // 设置完成标志并通知所有消费者
    {
      std::unique_lock<std::mutex> lock(mtx);
      done = true;
      cv_cons.notify_all(); // 通知所有消费者线程
    }

    // 等待所有消费者线程完成
    for (auto &consumer : consumers) {
      consumer.join();
    }
  }
};

int main() {
  const size_t buffer_size = 5000; // 缓冲区大小
  const int num_producers = 2;     // 生产者数量
  const int num_consumers = 5;     // 消费者数量

  ProducerConsumer pc(buffer_size); // 创建生产者消费者对象
  auto start_time = std::chrono::high_resolution_clock::now(); // 记录开始时间

  pc.start(num_producers, num_consumers); // 启动生产者和消费者

  auto end_time = std::chrono::high_resolution_clock::now(); // 记录结束时间
  auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
      end_time - start_time);

  std::cout << "Program finished. Total time: " << duration.count() << " ms"
            << std::endl;
  return 0;
}

上述代码加入了退出机制,同时我们先用5个消费线程来测试,发现速度执行还可以,接着我们可以将消费者数量改成10、20、50等,依次增加,你会发现数量越多,速度越慢。

你可能会有一个疑问,不是线程数量越多,速度越快嘛?其实在一定范围内是这样的结论,超出后会大大降低效率。首先我们要明白,我们用户创建的线程最后执行是内核线程来执行的,也就是说你的电脑cpu上的线程数量决定的。比如说你的电脑是6核心12线程的cpu,那么你的用户级线程无论你创建多少,都是在这12个内核级线程上执行的,所以过多的线程数量会增加系统开销,如线程的上下文切换、调度算法的复杂性、竞争锁等都会大大降低效率

那么有没有最佳线程数量规定呢?遗憾的是没有的,不过普遍认为对于IO密集型程序,2*cpu核心数的数量较好,对于计算密集型的程序cpu核心数+1个线程较好。具体数量依赖于多种因素,包括硬件资源(如CPU核心数)、任务类型(CPU密集型或I/O密集型)、系统负载以及应用程序的具体需求。

回到我们讲授的消费者生产者模型来,我们上面的问题是创建1w个消费者,既然无脑开线程不行,那么该如何解决呢?

这里引入一个概念,叫做线程池,顾名思义,这个池子,里面都是线程。这些线程来处理任务。代码如下:

#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <future>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>

// 简单的线程池实现
class ThreadPool {
public:
  // 构造函数:创建指定数量的工作线程
  ThreadPool(size_t num_threads) {
    for (size_t i = 0; i < num_threads; ++i) {
      workers.emplace_back([this] {
        while (true) {
          std::function<void()> task;
          {
            std::unique_lock<std::mutex> lock(queue_mutex);
            // 等待条件:线程池停止或任务队列非空
            condition.wait(lock, [this] { return stop || !tasks.empty(); });
            // 如果线程池停止且任务队列为空,退出线程
            if (stop && tasks.empty())
              return;
            // 获取任务并从队列中移除
            task = std::move(tasks.front());
            tasks.pop();
          }
          // 执行任务
          task();
        }
      });
    }
  }

  // 析构函数:停止线程池并等待所有线程完成
  ~ThreadPool() {
    {
      std::unique_lock<std::mutex> lock(queue_mutex);
      stop = true; // 设置停止标志
    }
    condition.notify_all(); // 唤醒所有等待的线程
    for (std::thread &worker : workers) {
      worker.join(); // 等待线程完成
    }
  }

  // 提交任务到线程池
  template <class F, class... Args>
  auto enqueue(F &&f, Args &&...args)
      -> std::future<typename std::result_of<F(Args...)>::type> {
    using return_type = typename std::result_of<F(Args...)>::type;
    auto task = std::make_shared<std::packaged_task<return_type()>>(
        std::bind(std::forward<F>(f), std::forward<Args>(args)...));
    std::future<return_type> res = task->get_future();
    {
      std::unique_lock<std::mutex> lock(queue_mutex);
      if (stop)
        throw std::runtime_error("enqueue on stopped ThreadPool");
      tasks.emplace([task]() { (*task)(); });
    }
    condition.notify_one();
    return res;
  }

private:
  std::vector<std::thread> workers;        // 工作线程
  std::queue<std::function<void()>> tasks; // 任务队列
  std::mutex queue_mutex;                  // 保护任务队列的互斥锁
  std::condition_variable condition;       // 条件变量,用于线程同步
  std::atomic<bool> stop{false};           // 线程池停止标志
};

class ProducerConsumer {
private:
  std::queue<int> buffer;          // 数据缓冲区
  std::mutex mtx;                  // 互斥锁,保护共享资源
  std::condition_variable cv_prod; // 生产者条件变量,用于等待缓冲区未满
  std::condition_variable cv_cons; // 消费者条件变量,用于等待缓冲区非空
  size_t max_size;                 // 缓冲区的最大容量
  const int total_data = 10000000; // 一个生产者要生产的数据量
  int produced_count = 0;          // 已经生产的数据量
  int consumed_count = 0;          // 已经消费的数据量
  std::atomic<bool> done{false};   // 生产者是否完成生产

  // 生产者工作函数
  void producer_work(int id) {
    for (int i = 0; i < total_data; ++i) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_prod.wait(
          lock, [&]() { return buffer.size() < max_size; }); // 等待缓冲区未满

      // 生产数据
      buffer.push(++produced_count);
      if (produced_count % 1000000 == 0) { // 每生产 100 万数据打印一次
        std::cout << "Producer " << id << " produced: " << produced_count
                  << std::endl;
      }
      cv_cons.notify_one(); // 通知一个消费者
    }
  }

  // 消费者工作函数
  void consumer_work(int id) {
    while (true) {
      std::unique_lock<std::mutex> lock(mtx);
      cv_cons.wait(lock, [&]() {
        return !buffer.empty() || done;
      }); // 等待缓冲区非空或完成标志

      // 如果生产者完成且缓冲区为空,退出
      if (done && buffer.empty()) {
        break;
      }

      // 消费数据
      int data = buffer.front();
      buffer.pop();
      ++consumed_count;
      if (consumed_count % 1000000 == 0) { // 每消费 100 万数据打印一次
        std::cout << "\tConsumer " << id << " consumed: " << consumed_count
                  << std::endl;
      }
      cv_prod.notify_one(); // 通知一个生产者
    }
  }

public:
  // 构造函数,初始化缓冲区大小
  ProducerConsumer(size_t size) : max_size(size) {}

  // 启动生产者和消费者线程
  void start(int num_producers, int num_consumers) {
    // 创建线程池,线程池大小与CPU核心数相关
    ThreadPool pool(
        std::thread::hardware_concurrency()); // 使用CPU核心数作为线程池大小

    // 创建生产者线程
    std::vector<std::thread> producers;
    for (int i = 0; i < num_producers; ++i) {
      producers.emplace_back(&ProducerConsumer::producer_work, this, i);
    }

    // 将消费者任务提交到线程池
    for (int i = 0; i < num_consumers; ++i) {
      pool.enqueue([this, i] { this->consumer_work(i); });
    }

    // 等待所有生产者线程完成
    for (auto &producer : producers) {
      producer.join();
    }

    // 设置完成标志并通知所有消费者
    {
      std::unique_lock<std::mutex> lock(mtx);
      done = true;
      cv_cons.notify_all(); // 通知所有消费者线程
    }
  }
};

int main() {
  const size_t buffer_size = 5000; // 缓冲区大小
  const int num_producers = 2;     // 生产者数量
  const int num_consumers = 10000; // 消费者数量

  ProducerConsumer pc(buffer_size); // 创建生产者消费者对象
  auto start_time = std::chrono::high_resolution_clock::now(); // 记录开始时间

  pc.start(num_producers, num_consumers); // 启动生产者和消费者

  auto end_time = std::chrono::high_resolution_clock::now(); // 记录结束时间
  auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
      end_time - start_time);

  std::cout << "Program finished. Total time: " << duration.count() << " ms"
            << std::endl;
  return 0;
}

上述代码基本实现了较为复杂的生产者消费者模型。当然对于线程池中入队这里的代码语法上有些复杂可以使用简易版本实现基本功能。

template <typename F> void enqueue(F task) {
  {
    std::unique_lock<std::mutex> lock(queue_mutex);
    if (stop)
      throw std::runtime_error("enqueue on stopped ThreadPool");
    tasks.emplace(std::move(task));
  }
  condition.notify_one();
}

对于上述代码依旧可以有很多优化的地方,可以思考一下。比如:

  1. 线程池的优化
    • 当前的线程池实现是基于固定大小的线程池。虽然这已经是一个很大的改进,但可以进一步优化线程池的管理方式,例如动态调整线程数量或使用现成的线程池库。
  2. 锁的优化
    • 当前代码中,生产者和消费者共享一个互斥锁,这可能会导致锁竞争。可以尝试引入多个锁或使用无锁编程技术来减少锁的开销。
  3. 缓冲区的优化
  4. 代码结构的优化
    • 当前代码中,生产者和消费者的功能被封装在同一个类中,这可能会导致代码难以维护。将生产者和消费者的功能分离到不同的类中,提高代码的可读性和可维护性。
  5. 条件变量的优化
    • 比如根据缓冲区的大小来调整通知次数,选择合适的唤醒方式,比如说任务队列里任务非常多,那么可以使用notify_all()来唤醒所有线程,如果是任务很少,那么可以使用notify_one()。错误的使用会导致性能上的下降,比如少量任务,采用notify_all()会导致更多的上下文切换和锁竞争。如果是大量任务,使用notify_one()可能导致某些线程永远无法被唤醒。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值