线程池学习(一)
一、线程池
1、线程与进程的区别?
进程是操作系统分配资源的基本单位,线程是操作系统进行CPU调度的单位。
2、线程池的好处?
复用线程资源,减少创建和销毁线程的开销。
3、线程池是什么?
维持管理一定数量线程的池式结构。
- 维持:复用资源
- 管理:线程是参与CPU调度的,如果暂时不需要线程执行,就将其进行休眠,能最大限度的使用资源。
- 为什么是一定数量,而不是固定数量?:操作系统运行线程是通过CPU的核心去运行的,而CPU的核心数量是有限的,如果线程池中的线程数过多,超过了核心的数量,那么会带来线程切换的开销,反而降低了效率。
4、线程池解决什么问题?
- 异步执行耗时任务,不过度占用核心线程资源(生产者线程)
- 耗时:耗时等待(io),耗时计算。
- 充分利用多核
5、线程池是如何解决问题的?
生产消费模型角度来看:
- 生产者:核心线程,抛出耗时任务到队列,并唤醒一个休眠线程。
- 消费者:线程池线程,从队列中取出任务执行;当队列为空时,线程进入休眠状态。
二、具体实现
1、面向生产者线程
发布任务到线程池。
void ThreadPoll::Post(std::function<void()> task)
{m_queuePlus->Push(task);
}
2、任务队列
std::queue<std::function<void()>> m_queue; //任务队列
//但该队列属于临界资源,不利于多线程安全,需要加互斥锁,读写锁等来保证线程安全。
使用阻塞队列,将锁封装在BlockQueue中
BlockQueue<std::function<void()>> m_queue; //任务队列
3、维持管理一定数量线程
std::vector<std::thread> m_workthreads; //线程集合
4、线程池线程数量的确定
-
耗时计算任务:核心线程数 = CPU核心数,因为计算任务是CPU密集型,不会涉及到用户态和内核态的切换,全是用户态执行,所以核心线程数要和CPU的核心数一致。不过这个值是理论值,实际上,需要在该值的基础上,+1,+2,-1,-2等去测试,找到最优值。(测试单位时间内执行任务数来决定)
-
耗时等待任务:IO密集型任务,IO在Linux下主要分为文件IO和网络IO,因为涉及到用户态和内核态的切换以及IO的等待,所以核心线程数 = 2 * CPU核心数,让一部分线程在等待IO时,能有更多的线程去执行其他任务。同理,也需要测试找到最优值。(测试单位时间内执行任务数来决定)
-
(线程等待时间 + cpu运算时间) * cpu核心线程数 / cpu运算时间 = 线程池最优核心数
5、核心线程抛出任务到队列,并唤醒一个休眠线程
void Push(const T &value) // 入队操作
{std::lock_guard<std::mutex> lock(m_mutex);m_queue.push(value);m_cond.notify_one(); // 唤醒一个等待的线程
}
6、线程池线程从队列中取出任务执行;当队列为空时,线程进入休眠状态。
void ThreadPoll::Worker()
{while (true){std::function<void()> task;if (!m_queuePlus->Pop(task))break;task();}
}bool Pop(T &value) // 出队操作,考虑正常情况与异常情况(队列为空)
{std::unique_lock<std::mutex> lock(m_mutex); // 与lock_guard相比,unique_lock可以延迟锁定,可手动调用unlock,并且同样在析构时自动释放锁。m_cond.wait(lock, [this]{ return !m_queue.empty() || m_nonblock; }); // 若队列为空,则阻塞等待,直到队列不为空或者m_nonblock为true时才继续执行。if (m_queue.empty())return false;value = m_queue.front();m_queue.pop();return true;
}
7、执行完毕,销毁线程
ThreadPoll::~ThreadPoll()
{m_queuePlus->CancelBlock();for (auto &thread : m_workthreads){if (thread.joinable())thread.join();}
}
8、结果:
9、优化
之前生产者和消费者共用一个队列,并且用一个互斥锁,这样会导致生产者线程和消费者线程都在等待同一个锁;线程数量少,以及任务量少时,感觉不出来,当线程数量变多,任务量多时,效率就会变得低下。
- 优化方案:生产者对应生产者队列,消费者对应消费者队列。
//在之前阻塞队列里面封装了两个队列,一个生产者队列和一个消费者队列,两把互斥锁,一个生产者队列对应一把锁,一个消费者队列对应一把锁。
std::queue<T> prod_queue_;
std::queue<T> cons_queue_;
std::mutex prod_mutex_;
std::mutex cons_mutex_;int SwapQueue()
{std::unique_lock<std::mutex> lock(m_prod_mutex); // 与lock_guard相比,unique_lock可以延迟锁定,可手动调用unlock,并且同样在析构时自动释放锁。m_cond.wait(lock, [this]{return !m_prod_queue.empty() || m_nonblock; }); // 若队列为空,则阻塞等待,直到队列不为空或者m_nonblock为true时才继续执行。std::swap(m_prod_queue, m_cons_queue);return m_cons_queue.size();
}
三、总结:
-
进程与线程的关系:
- 进程作为操作系统资源分配的基本单位
- 线程作为CPU调度的基本执行单元
-
线程池的核心优势:
- 实现线程资源的高效复用
- 显著降低频繁创建和销毁线程的系统开销
-
线程池的设计原则:
- 采用池化技术管理固定数量的线程
- 遵循"适量最优"原则,而非盲目增加线程数量
-
线程池的核心价值:
- 充分发挥多核CPU的计算优势
- 实现主线程与耗时任务的异步解耦执行
四、问题:
1、为什么使用队列?
队列是一种双端口操作的数据结构。
- 职责:生产者对应一个端口,消费者对应另一个端口。
- 操作队列的时间复杂度为O(1),而vector为O(n),后续加锁灵活。
代码链接:
Code
0voice·Github