线程池设计:面试官让我手写一个线程池,我差点把mutex忘了
多线程板块的前几篇把基础聊完了——线程创建、互斥锁、条件变量、原子操作。今天把这些知识串起来,做一个完整的组件:线程池。
线程池在机器人系统里用得非常多。ROS2的executor底层就是一个线程池,负责分发回调函数。你的SLAM系统、路径规划模块,凡是有批量异步任务的地方,线程池都是标配。
先说为什么需要线程池。
假设你的机器人每帧要处理100个特征点的匹配任务。如果每个任务都创建一个线程,就是100个线程。线程的创建和销毁是有开销的——创建线程需要分配栈空间、调用系统API,大概耗时几十微秒。100个线程就是几毫秒,而你的帧周期可能只有10ms。
更糟的是,100个线程同时跑,CPU频繁做上下文切换,实际效率可能还不如串行执行。
线程池的思路是:预先创建固定数量的线程,让它们从任务队列里取任务执行。线程不用反复创建销毁,数量也可控。
手写一个线程池
面试的时候,面试官可能让你手写一个简化版的线程池。把前面学的mutex、条件变量、thread全用上:
class ThreadPool {
vector<thread> workers_;
queue<function<void()>> tasks_;
mutex mtx_;
condition_variable cv_;
bool stop_ = false;
public:
ThreadPool(size_t threadCount) {
for (size_t i = 0; i < threadCount; ++i) {
workers_.emplace_back([this] {
while (true) {
function<void()> task;
{
unique_lock<mutex> lock(mtx_);
cv_.wait(lock, [this] {
return stop_ || !tasks_.empty();
});
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 执行任务(在锁外执行)
}
});
}
}
void enqueue(function<void()> task) {
{
lock_guard<mutex> lock(mtx_);
tasks_.push(std::move(task));
}
cv_.notify_one();
}
~ThreadPool() {
{
lock_guard<mutex> lock(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& w : workers_) {
w.join();
}
}
};
核心逻辑很简单:构造函数里创建N个工作线程,每个线程循环从任务队列里取任务执行。enqueue方法把任务塞进队列并通知一个线程。析构函数设置stop标志,唤醒所有线程,然后join等待它们退出。
使用方式:
ThreadPool pool(4); // 4个工作线程
// 提交100个特征点匹配任务
for (const auto& feature : features) {
pool.enqueue([feature] {
matchFeature(feature);
});
}
线程池的参数选择
线程池的大小怎么定?一个常见的经验公式是:线程数 = CPU核心数。因为线程数超过核心数也没有意义——CPU同一时刻只能执行那么多任务,多余的线程只是在排队等。
但如果是IO密集型任务(比如读写文件、网络通信),线程数可以设大一些。因为IO操作会让线程阻塞,阻塞期间CPU可以执行其他线程。
// 计算密集型:线程数 = 核心数
unsigned int computeThreads = std::thread::hardware_concurrency();
// IO密集型:线程数 = 核心数 * 2 或更多
unsigned int ioThreads = std::thread::hardware_concurrency() * 2;
在机器人系统里,大部分任务是计算密集型的(矩阵运算、点云处理),所以线程数一般等于核心数。
不过线程池也有局限:它适合互相独立的任务。如果任务之间有依赖关系(比如任务B必须等任务A完成),普通的线程池就不太好处理了。这种情况需要更高级的任务调度框架,或者用future链来编排依赖。
在机器人开发中的应用
ROS2的executor就是一个线程池。你可以创建一个MultiThreadedExecutor,指定线程数,然后把节点加进去:
rclcpp::executors::MultiThreadedExecutor executor(
rclcpp::ExecutorOptions(), 4); // 4个线程
executor.add_node(myNode);
executor.spin(); // 4个线程并行执行回调
在SLAM系统里,线程池常用于并行处理特征提取。比如一帧图像要提取500个ORB特征点,每个特征点的描述子计算是独立的,可以丢给线程池并行处理:
ThreadPool pool(std::thread::hardware_concurrency());
vector<future<void>> futures;
for (auto& keypoint : keypoints) {
futures.push_back(pool.enqueueWithFuture([&keypoint] {
computeDescriptor(keypoint);
}));
}
// 等待所有任务完成
for (auto& f : futures) f.get();
面试中的关键考点
"线程池的核心组件有哪些?"工作线程、任务队列、同步机制(mutex+条件变量)。工作线程循环取任务执行,任务队列缓冲待处理的任务,同步机制保证队列的线程安全。
"怎么让线程池支持返回值?"用std::packaged_task和std::future。把任务包装成packaged_task,返回future给调用者,调用者通过future.get()获取结果。
"线程池怎么优雅地关闭?"设置一个stop标志,通知所有线程。线程检查到stop标志为true且任务队列为空时退出循环。主线程join所有工作线程,确保它们都安全退出。注意要在锁的保护下设置stop标志,防止竞态条件。
线程池的常见坑
实际用线程池最容易踩的坑有三个。第一个是"任务里抛异常导致线程退出"——如果任务函数里抛了异常但没catch,线程就直接退出了,线程池的线程数会越来越少,最终可能导致线程全部退出。解决办法是在任务执行的外层加一层try-catch:
try {
task();
} catch (const std::exception& e) {
std::cerr << "Task exception: " << e.what() << std::endl;
}
第二个坑是"任务提交太快队列爆掉"。如果生产者提交任务的速度远大于消费者处理的速度,任务队列会无限增长。解决方案是加一个队列上限,满了就阻塞等待或者选择丢弃新任务。第三个坑是"线程池析构时有任务还没执行完"——上面的实现在析构时会丢弃队列里未执行的任务,如果需要保证所有任务都执行完毕再退出,要加一个drain逻辑。面试的时候能提到这些实际踩坑的宝贵经验,面试官会觉得你不是只会写demo,而是真正在生产环境中摸爬滚打过的开发者。
给正在准备面试的你
线程池是面试里的"综合题"——它把线程创建、mutex、条件变量、function、move语义这些知识点全串在一起了。能完整写出来,说明你的C++多线程编程基础是扎实的。
不要求一字不差地背下来,但要理解每个组件的作用和设计原因:为什么用条件变量(等任务)、为什么在锁外执行任务(减少锁竞争)、为什么析构要notify_all(唤醒阻塞的线程让它们退出)。
下篇进入网络编程板块——socket编程与机器人之间的通信。机器人之间的协作、机器人与云端服务器的通信,都离不开网络编程的基础知识。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。 你的支持是我持续更新的最大动力。
「机器人软件开发面试·从入门到精通」连载系列 上一篇:第57篇 多线程之原子操作与内存序——高性能无锁编程入门 下一篇预告:第59篇 C++网络编程基础——socket编程与机器人通信
有任何问题欢迎评论区留言,我会尽量回复。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)