面试宝典(十七):手写线程池与定时器

发布时间:2026/8/18 19:24:29
面试宝典(十七):手写线程池与定时器 0. 为什么需要线程池每次任务来都std::thread新建线程的代价创建成本高内核要分配 PCB、栈、调度结构约几十~上百微秒。数量失控突发流量下线程数爆炸上下文切换context switch开销反超业务。无管理无法限制并发度、无法队列化背压。线程池的核心思想预先起固定数量的 worker 线程任务以std::function形式入队worker 从队列取任务执行。达到线程复用 并发可控 队列削峰。1. 最简线程池条件变量版#includethread#includevector#includequeue#includemutex#includecondition_variable#includefunctional#includeiostreamclassThreadPool{public:explicitThreadPool(size_t n):stop_(false){for(size_t i0;in;i)workers_.emplace_back([this]{workerLoop();});}~ThreadPool(){{std::lock_guardstd::mutexlk(mtx_);stop_true;}cv_.notify_all();// 唤醒所有 worker 让其退出for(autot:workers_)t.join();// 等线程结束RAII 析构}// 提交任务无返回值版voidsubmit(std::functionvoid()task){{std::lock_guardstd::mutexlk(mtx_);tasks_.push(std::move(task));}cv_.notify_one();// 唤醒一个空闲 worker}private:voidworkerLoop(){while(true){std::functionvoid()task;{std::unique_lockstd::mutexlk(mtx_);// 用 while条件变量避免虚假唤醒stop_ 时无论队列空否都退出cv_.wait(lk,[this]{returnstop_||!tasks_.empty();});if(stop_tasks_.empty())return;taskstd::move(tasks_.front());tasks_.pop();}task();// 在锁外执行减小临界区}}std::vectorstd::threadworkers_;std::queuestd::functionvoid()tasks_;std::mutex mtx_;std::condition_variable cv_;boolstop_;};设计要点cv_.wait(lk, pred)的pred必须用while语义判断条件变量可能被虚假唤醒谓词里包含stop_保证退出信号也能唤醒。任务在锁外执行task()在unique_lock析构解锁之后调用避免持锁运行导致 worker 间串行化这是性能关键。析构函数先置stop_再notify_all再join保证所有 worker 干净退出不丢任务队列里剩余任务不再执行生产可改为drain 模式。2. 让 submit 有返回值std::future无返回值的池只能发了不管。用std::packaged_task把任务结果打包进std::future提交即可get()等待结果#includefuturetemplateclassFautosubmitWithResult(Ff)-std::futuredecltype(f()){usingRdecltype(f());autotaskstd::make_sharedstd::packaged_taskR()(std::forwardF(f));std::futureRfuttask-get_future();{std::lock_guardstd::mutexlk(mtx_);// packaged_task 不可拷贝用 lambda 包一层 shared_ptrtasks_.push([task]{(*task)();});}cv_.notify_one();returnfut;}用法auto r pool.submitWithResult([]{ return 12; }); r.get(); // 3。注意packaged_task不可拷贝必须存shared_ptr否则push进std::function会编译失败——这是高频坑。3. 任务异常怎么办若task()抛异常异常会在future.get()处重新抛出因为packaged_task捕获了异常进 future。但无返回值版submit直接task()抛异常会终止整个 worker 线程std::thread 析构时std::terminate。生产级做法worker 里包一层try/catch或统一要求任务自身不抛异常。面试常问任务抛异常会怎样——务必答清两条路径差异。4. 线程数怎么定经典经验公式CPU 密集型线程数 ≈ 核数std::thread::hardware_concurrency()多了只增加切换。I/O 密集型线程数可远大于核数公式 ≈ 核数 × (1 等待时间/计算时间)。靠压测调优。更高级的做法是分离线程池CPU 池核数个 I/O 池较多避免慢 I/O 阻塞计算任务。5. 无锁任务队列进阶std::queue mutex在超高并发提交时会成为瓶颈。可用MPMC多生产者多消费者无锁队列如 moodycamel::ConcurrentQueue或自己用 atomic 实现。核心思想用 CAS 维护头尾指针、每个节点独立、避免全局锁。本篇不展开完整 MPMC 实现见 S04 无锁环形队列的 SPSC 思路但面试要知道高吞吐线程池可换无锁队列。6. 定时器最小堆实现服务器需要300ms 后检查连接是否存活“每 5s 上报一次”。用**最小堆按到期时间排序**管理定时任务由一个 timer 线程wait_until(最近到期时刻)到点执行并重新入堆#includechrono#includequeue#includemutex#includecondition_variable#includefunctional#includethreadstructTimerTask{usingClockstd::chrono::steady_clock;Clock::time_point expire;std::functionvoid()cb;uint64_tid;booloperator(constTimerTasko)const{returnexpireo.expire;}// 小顶堆};classTimerHeap{public:uint64_tadd(std::chrono::milliseconds ms,std::functionvoid()cb){std::lock_guardstd::mutexlk(mtx_);TimerTask t{steady_clock::now()ms,std::move(cb),seq_};heap_.push(t);if(heap_.top().idt.id)cv_.notify_one();// 新任务更早到期唤醒returnt.id;}voidrun(){// 在独立线程调用while(true){std::unique_lockstd::mutexlk(mtx_);if(heap_.empty()){cv_.wait(lk);// 无任务则等}else{autotopheap_.top();cv_.wait_until(lk,top.expire);// 等至最近到期if(heap_.empty()||heap_.top().expiresteady_clock::now())continue;autotaskheap_.top();heap_.pop();lk.unlock();task.cb();// 执行回调锁外lk.lock();}}}std::priority_queueTimerTask,std::vectorTimerTask,std::greaterTimerTaskheap_;std::mutex mtx_;std::condition_variable cv_;uint64_tseq_0;};核心技巧cv_.wait_until(lk, top.expire)——当有人插入一个更早到期的任务时add里notify_one打断等待timer 线程重新计算最近到期避免新任务虽早却要等旧任务到点才触发的延迟 bug。这是定时器实现的关键正确性点。7. 定时器时间轮HashedWheelTimer当定时器数量极大十万级如海量连接心跳最小堆的O(log N)插入与每次 wakeup 都要扫堆顶会变慢。时间轮借鉴时钟一个环形数组槽指针按 tick 转动每 tick 推进一格执行该格所有任务。超时时间 一轮的用圈数round标记转到对应圈再执行。插入O(1)、删除O(1)适合大量短周期任务。Netty 的HashedWheelTimer即此思想。本篇给出概念骨架structWheelSlot{std::vectorTimerTasktasks;};std::vectorWheelSlotwheel_;// 如 512 槽size_t cursor_0;// tick(): cursor_ (cursor_1) % N; 执行 wheel_[cursor_] 中 round0 的任务// round0 的任务 round-- 并可能跨圈重挂面试对比堆定时器实现简单、精度高、适合少量定时任务时间轮插入删除 O(1)、适合海量定时但精度受 tick 粒度限制tick1ms 则误差1ms。8. 实战线程池 定时器 心跳模拟把两者结合线程池执行任务定时器每 3 秒打印一次心跳验证并发组件协作。#includeiostream#includechronointmain(){ThreadPoolpool(4);// 提交 10 个计算任务for(inti0;i10;i)pool.submit([i]{std::this_thread::sleep_for(std::chrono::milliseconds(100));std::couttask i done on std::this_thread::get_id()\n;});TimerHeap timer;std::threadtimer_thread([]{timer.run();});timer.add(std::chrono::seconds(3),[]{std::cout[heartbeat] alive\n;});timer.add(std::chrono::seconds(6),[]{std::cout[heartbeat] alive\n;});std::this_thread::sleep_for(std::chrono::seconds(7));// 真实项目里用原子 stop 标志优雅退出 timer 线程return0;}编译g -stdc17 pool.cpp -lpthread。你会看到 10 个 task 被 4 个 worker 复用执行定时器在 3s/6s 各触发一次。9. 与高性能服务器结合真实网络服务器如基于 epoll/io_uring通常用一个 accept 线程N 个 worker 线程每个跑一个事件循环即one loop per thread见 libevent/libev/muduo 思想。定时器集成进事件循环epoll 用timerfdio_uring 用IORING_OP_TIMEOUT而非独立线程——避免跨线程唤醒。计算密集任务才丢进独立线程池防止阻塞 I/O 事件循环。这是事件循环 线程池混合模型的典型架构面试常问为什么不能所有活都在事件循环线程做——答案长耗时任务会拖慢所有连接的事件处理必须卸载offload到线程池。10. 速答 12 题临场背诵线程池核心组成一组 worker 线程 任务队列 同步机制mutex/condvar 或无锁队列。为什么任务要在锁外执行防止持锁运行导致 worker 串行化最大化并发。条件变量为什么用 while 判断防虚假唤醒且 stop_ 也要能唤醒退出。submit 返回 future 怎么实现packaged_task包任务存shared_ptr进队列get_future()返回。packaged_task 为何要 shared_ptr它不可拷贝push 进std::function需共享所有权。任务抛异常会怎样有 future 版在 get() 重抛无返回值版会 terminate worker需 try/catch。CPU 密集 vs I/O 密集线程数前者≈核数后者可远大于核数靠压测定。最小堆定时器插入复杂度O(log N)取最近到期 O(1)。wait_until 的作用timer 线程等到最近到期新更早任务插入时 notify 打断重算。时间轮适用场景海量十万级定时任务插入删除 O(1)精度受 tick 限制。为什么长任务不能放事件循环线程会阻塞所有连接事件处理应 offload 到线程池。epoll 定时器怎么实现timerfd注册进 epoll超时即可读事件。11. 临场口诀池子复用线程队列削峰限并发任务锁外跑future 包结果堆定时器按点到时间轮海量快长活别占事件环卸载线程池才稳。12. 生产级增强方向基础版线程池能跑但离生产还差几步优雅停机drain 模式基础析构直接丢弃队列剩余任务。生产应提供shutdown(waittrue)置stop_后先处理完队列中已有任务再退出drain或shutdownNow立即丢弃。结合std::atomicbool而非裸bool避免数据竞争。工作窃取work-stealing单一全局队列在高并发提交时成为锁热点。更高阶做法是为每个 worker 配一个双端队列deque自己从头取、其他 worker 从尾部偷——std::deque 每线程独立 mutex或参照 Intel TBB /folly::MPMC实现。这把集中锁分散为局部锁 偶尔偷取吞吐更高。线程亲和性把 worker 绑到特定 CPUpthread_setaffinity_np减少跨核缓存失效配合 NUMA 把内存分配靠近线程所在节点。动态扩容固定线程数简单但 I/O 阻塞时可能不够。可设核心池 临时线程空闲超时回收类似CachedThreadPool但要防线程数爆炸——加上限与空闲回收。13. 线程池常见坑面试必考任务在锁内执行若task()在持mtx_时运行所有 worker 被串行化线程池退化为单线程。必须锁外执行见第 1 节。析构时任务抛异常无返回值版submit的task()若抛异常会std::terminate整个进程。生产须包try/catch或要求任务 noexcept。重复 join / 在 worker 内 submit 自身池在 worker 线程里向同一个池submit并future.get()会死锁线程被占用无人执行该任务。解决办法使用std::async(std::launch::async)或独立的继续continuation机制而非同池阻塞等待。stop_ 非原子用std::atomicbool stop_而非裸bool否则编译器优化可能让 worker 看不到更新。notify 遗漏向空队列 notify 无副作用但若submit在push前 notify 会丢唤醒。务必先push后notify。线程数大于任务数导致空转worker 全阻塞在cv_.wait无任务时仍占内存长时间空闲可考虑超时回收。14. 定时器取消与层级时间轮真实服务器常需要取消定时器如连接正常关闭不必再发心跳。最小堆取消需id - 堆内位置的额外映射如std::unordered_mapid, 堆索引 惰性删除标记。时间轮取消则是从对应槽移除节点O(1)更易实现。当定时跨度极大从毫秒到数小时单轮时间轮槽数爆炸改用层级时间轮hierarchical wheel类似时钟的秒针、分针、时针低精度轮溢出时把任务升级到高精度轮。Linux 内核timer wheel与 NettyHashedWheelTimer都基于此。面试答海量 长跨度定时时应主动提到层级时间轮体现深度。15. 与可观测性结合线程池与定时器是线上故障高发点任务堆积队列越来越长、某 worker 卡死线程长时间 100%、定时器泄漏不断 add 不 cancel。建议暴露指标pending_tasks队列长度、active_threads、completed_total。用 S03 的 perf /perf trace抓长时间运行的任务用gdb对卡死 worker 抓栈见 S09。定时器加上限监控防泄漏。把这些写完组件 能观测 能排查连起来正是 S01–S09 全系列想训练的工程闭环。16. 定时器融入事件循环timerfd epoll第 6、7 节的定时器用独立 timer 线程。但在 epoll 事件循环S01里更地道的是用timerfd创建一个定时器 fd把它EPOLL_CTL_ADD进 epoll超时后 epoll 报可读读一下即可触发到期任务。这样定时器与网络 I/O 共用一个事件循环无需跨线程唤醒inttfdtimerfd_create(CLOCK_MONOTONIC,0);structitimerspecits{};its.it_value{3,0};// 3 秒后首次its.it_interval{3,0};// 之后每 3 秒timerfd_settime(tfd,0,its,nullptr);epoll_ctl(epfd,EPOLL_CTL_ADD,tfd,ev);// 注册// 事件循环里tfd 可读 → read(tfd,...) 消耗通知 → 执行心跳这种一切皆 fd、统一进 epoll的思想和 io_uring 的IORING_OP_TIMEOUT异曲同工——都是把定时器变成事件源。面试若被问怎么在单线程事件循环里做定时答timerfdepoll 系或OP_TIMEOUTio_uring 系即可。17. 线程池 vs 协程C20C20 引入无栈协程co_await/task它和线程池解决不同层面的问题线程池解决并行执行多个独立任务、控制并发度任务间切换由 OS 调度开销是线程级。协程解决单个任务内部在 I/O 等待时让出不阻塞线程切换是用户态、开销极小适合海量连接各自顺序写逻辑的场景一个连接一个协程看起来像同步代码实则异步。现代框架如 cppcoro、asio 的 awaitable把协程跑在线程池的 worker 上线程池提供并行度协程提供异步不阻塞线程的写法。两者不是替代而是互补。面试答协程会不会取代线程池——答协程是任务内部挂起机制线程池是并行执行载体常结合使用体现体系化认知。18. 阻塞队列自测题附线程池的底层是线程安全的任务队列。常考手写一个阻塞队列templateclassTclassBlockingQueue{std::queueTq_;std::mutex m_;std::condition_variable cv_;booldone_false;public:voidpush(T v){std::lock_guardstd::mutexlk(m_);q_.push(std::move(v));cv_.notify_one();}boolpop(Tout){// 返回 false 表示已关闭std::unique_lockstd::mutexlk(m_);cv_.wait(lk,[this]{returndone_||!q_.empty();});if(q_.empty())returnfalse;// done_ 且空outstd::move(q_.front());q_.pop();returntrue;}voidclose(){std::lock_guardstd::mutexlk(m_);done_true;cv_.notify_all();}};这其实就是线程池任务队列的精简版掌握它线程池就通了。19. 速查并发组件决策表场景推荐方案理由少量定时心跳、超时最小堆定时器实现简单、精度高、O(logN) 可接受海量定时十万级连接时间轮 / 层级时间轮插入删除 O(1)tick 粒度控精度单线程事件循环里做定时timerfd epoll一切皆 fd统一事件源CPU 密集批量任务固定线程池核数个避免切换吃满算力I/O 密集任务线程池核数用等待时间换并发度长耗时任务怕阻塞 I/O卸载到独立线程池保护事件循环不被拖垮需要任务返回值submit 返回 futurepackaged_task 接异常与结果高频小回调、不存储function_ref零开销、非拥有、免分配这张决策表把前面所有点收敛成什么时候用什么。面试被问你怎么选定时方案/线程数时按表逐条给出权衡比背定义得分高得多。记住铁律事件循环只做 I/O 调度重活卸载线程池少量定时用堆、海量定时用轮热路径回调用 ref 而非 function。20. 临场自检三问并发组件“为什么任务要在锁外执行”—— 若持锁运行task()所有 worker 被串行化线程池退化成单线程锁只保护队列临界区越小并发越高。“定时器用堆还是轮”—— 少量定时用最小堆简单、O(logN)、精度高海量十万级用时间轮插入删除 O(1)精度受 tick 限制单线程事件循环用 timerfd 统一进 epoll。“长任务能放事件循环线程吗”—— 不能会阻塞所有连接事件处理必须卸载到线程池保护 I/O 调度不被拖垮。这三条是线程池与定时器面试的高频收口问题能倒背如流即过关。动手建议把第 1 节的线程池与第 8 节的定时器组合编译运行g -stdc17 -pthread故意提交一个会抛异常的任务观察 worker 是否被terminate再给 TimerHeap 插入一个比当前堆顶更早到期的任务验证wait_until是否被正确唤醒。踩过这些坑你讲出来的线程池才是有血有肉的工程经验而不是教科书定义。同时把这些组件与 S01 的 epoll、S05 的 io_uring 结合起来思考事件循环负责 I/O 调度线程池负责计算卸载定时器负责超时与心跳三者拼成一台完整的高并发服务器。记住线程池与定时器的价值不在能跑而在并发可控、任务可观测、停机可优雅——这三点是生产级组件与玩具的分界线。

相关新闻