尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

【Linux线程同步】POSIX 信号量、环形队列、线程池、死锁与线程安全(下篇)

【Linux线程同步】POSIX 信号量、环形队列、线程池、死锁与线程安全(下篇) 本文定位这是 Linux 线程同步系列下篇。上篇解决了数据竞争、mutex、条件变量与 BlockingQueue本篇继续把同步原语组合成环形队列和线程池并讨论线程安全、可重入、单例初始化与死锁治理。学习目标读完本文你应该能分清semaphore 与 mutex/condition variable、空槽与数据许可、线程池停止与清空、线程安全与可重入、控制块安全与对象安全、双重检查与一次性初始化以及死锁四条件与工程预防手段。文章目录一、POSIX 信号量把可用资源抽象成计数器二、信号量实现环形生产消费队列三、线程池固定工作线程复用任务队列四、线程池的停止协议与生命周期五、线程安全、可重入与异步信号安全六、现代 C 单例的正确初始化方式七、死锁四个必要条件与工程预防八、STL、智能指针与常见锁概念九、高频面试题与排查清单总结一、POSIX 信号量把可用资源抽象成计数器1.1 mutex 表示所有权semaphore 表示许可数量mutex 的核心语义是“谁持有锁”计数信号量的核心语义是“当前还有多少份许可”。例如容量为 8 的缓冲区room_sem 8 // 可写空槽数量 data_sem 0 // 可读数据数量生产一项先消耗一个 room 许可写入后发布一个 data 许可消费一项正好相反。1.2 POSIX unnamed semaphore 接口#includesemaphore.hsem_t sem;sem_init(sem,0,initial_value);// pshared0同进程线程间共享sem_wait(sem);// 获取许可无许可时阻塞sem_trywait(sem);// 不阻塞尝试sem_post(sem);// 归还/发布一个许可sem_destroy(sem);与许多 Pthreads API 不同sem_wait()失败返回-1并设置errno。它还可能被信号中断并返回EINTRintwait_sem(sem_t*sem){while(sem_wait(sem)-1){if(errno!EINTR)return-1;}return0;}1.3 semaphore 不自动保护复杂数据结构许可数量正确不代表多个生产者可以同时安全修改同一个std::vector或索引。信号量负责“有没有资源”mutex 仍可能负责“谁在修改生产者游标或消费者游标”。二、信号量实现环形生产消费队列2.1 环形队列的关键状态固定数组通过模运算复用槽位producer_index(producer_index1)%capacity;consumer_index(consumer_index1)%capacity;仅比较两个下标时空队列与满队列都可能表现为head tail。传统解决方案包括额外维护元素计数预留一个槽位维护 full 标志使用两个信号量分别计数空槽和数据。2.2 两个信号量形成严格守恒关系始终满足room_sem data_sem capacity生产者wait(room_sem) ↓ 锁定 producer cursor ↓ 写入 ring[producer_index] ↓ 推进 producer_index ↓ 解锁 ↓ post(data_sem)消费者对称执行wait(data_sem)与post(room_sem)。2.3 多生产者、多消费者版本templateclassTclassRingQueue{public:explicitRingQueue(std::size_t capacity):ring_(capacity),capacity_(capacity){if(capacity_0){throwstd::invalid_argument(capacity must be positive);}sem_init(room_sem_,0,capacity_);sem_init(data_sem_,0,0);pthread_mutex_init(producer_mutex_,nullptr);pthread_mutex_init(consumer_mutex_,nullptr);}voidpush(constTvalue){wait_nointr(room_sem_);pthread_mutex_lock(producer_mutex_);ring_[producer_index_]value;producer_index_(producer_index_1)%capacity_;pthread_mutex_unlock(producer_mutex_);sem_post(data_sem_);}voidpop(Tout){wait_nointr(data_sem_);pthread_mutex_lock(consumer_mutex_);outstd::move(ring_[consumer_index_]);consumer_index_(consumer_index_1)%capacity_;pthread_mutex_unlock(consumer_mutex_);sem_post(room_sem_);}~RingQueue(){// 调用方必须先停止并 join 所有生产者/消费者pthread_mutex_destroy(producer_mutex_);pthread_mutex_destroy(consumer_mutex_);sem_destroy(room_sem_);sem_destroy(data_sem_);}private:staticvoidwait_nointr(sem_t*sem){while(sem_wait(sem)-1){if(errnoEINTR)continue;throwstd::system_error(errno,std::generic_category());}}std::vectorTring_;std::size_t capacity_;std::size_t producer_index_{0};std::size_t consumer_index_{0};sem_t room_sem_{};sem_t data_sem_{};pthread_mutex_t producer_mutex_{};pthread_mutex_t consumer_mutex_{};};两个 mutex 分别串行化生产者游标和消费者游标使生产与消费仍可并行推进。2.4 教学版本还缺少关闭协议如果消费者永远阻塞在sem_wait(data_sem)单纯设置stoppingtrue并不能把它唤醒。可选设计包括为每个等待消费者发布停止许可并用显式状态区分“数据许可”和“退出许可”使用 condition variable closed 谓词使用sem_timedwait()周期性检查停止状态通过额外 eventfd/pipe 等唤醒通道实现取消。因此环形队列代码只有在明确“谁负责停止、如何唤醒、何时销毁”后才是完整组件。三、线程池固定工作线程复用任务队列3.1 线程池解决什么问题线程池预先维护固定数量或受控数量的 worker让它们循环从任务队列取任务避免为每个短任务重复创建和销毁内核线程。适合任务数量大、单个任务相对短请求存在突发需要队列削峰希望控制并发度与资源占用任务可被独立调度。不代表线程数越多越快。CPU 密集任务通常围绕可用核心数规划阻塞 I/O 任务可以更多但仍受内存、连接数、下游容量和延迟目标约束。3.2 worker 的标准循环voidworker_loop(){while(true){Task task;mutex.lock();while(queue.empty()stateState::Running){cond.wait(mutex);}if(queue.empty()state!State::Running){mutex.unlock();break;}taskstd::move(queue.front());queue.pop();mutex.unlock();task();// 一定要在锁外执行}}关键点wait 使用while 谓词取任务时持锁执行任务前释放队列锁停止状态和队列状态必须一起判断task 抛出异常时不能让整个 worker 静默消失。3.3 任务为什么必须在锁外执行如果task()在队列 mutex 内执行其他 worker 无法取任务提交者也可能无法入队线程池退化成单线程执行器。更糟的是任务若反向调用线程池接口可能产生自死锁。3.4 日志本身也是并发组件PDF 使用策略模式切换控制台与文件日志这个设计适合教学但要注意多线程向同一 stream/file 写入需要定义原子记录边界日志锁不应与线程池队列锁形成相反加锁顺序低延迟系统常把日志记录放入独立队列由日志线程批量落盘日志中的 PID 不能区分同进程线程可按需记录 TID 或线程名时间转换应使用线程安全接口例如localtime_r()月份字段tm_mon从 0 开始格式化时需要1。四、线程池的停止协议与生命周期4.1 线程池不是一个 bool 就够了建议至少区分Created : worker 对象已建立但未接收任务 Running : 接收新任务worker 正常处理 Draining : 不再接收新任务处理完队列后退出 Stopping : 尽快停止剩余任务按策略取消/返回 Stopped : worker 全部退出并 join4.2 优雅停止的正确顺序在 mutex 内将 state 改为 Draining ↓ 拒绝新任务 ↓ broadcast 唤醒所有空闲 worker ↓ worker 处理完队列后退出 ↓ 控制线程 join 全部 worker ↓ 销毁队列、cond、mutex 与日志依赖PDF 示例通过sleep(5)再Wait()这只能演示输出不能构成同步。正确程序应依赖状态、broadcast 和 join而不是猜任务已经完成。4.3 Enqueue 与 Stop 必须线性化下面两个动作必须受同一 mutex 保护检查线程池是否仍接收任务把任务放进队列。否则可能发生提交线程观察到 Running停止线程切换到 Stopping提交线程却又把任务放入一个不会再被处理的队列。4.4 析构函数不能默默留下 joinable worker线程池析构策略应明确析构自动 drain join强制调用显式shutdown()否则终止程序或报告错误使用 RAII但记录析构可能阻塞的契约。不应让 worker 继续访问已经析构的this、mutex、cond、queue 或 logger。五、线程安全、可重入与异步信号安全5.1 三个概念不要混成一个线程安全多个线程按接口契约并发调用时不破坏共享状态或产生数据竞争。可重入一次调用尚未结束时同一函数再次被进入仍不依赖被前一次调用破坏的共享中间状态。严格可重入函数通常只使用调用者数据和局部状态不依赖非重入锁。异步信号安全函数可以在异步信号处理器的严格限制下调用。这比一般线程安全要求更苛刻。5.2 “加一把锁”可以线程安全但通常不表示可重入voidsafe_but_not_reentrant(){std::lock_guardstd::mutexguard(global_mutex);// 修改共享状态}多个线程调用时可能是安全的但同一线程在持锁期间通过回调再次进入该函数普通 mutex 会造成自死锁。因此“线程安全”不自动推出“可重入”。反过来严格可重入函数不依赖共享可变状态通常天然适合多线程并发调用。5.3 malloc、stdio 不能用一句话归类现代 libc 的malloc()和许多 stdio 接口通常会为多线程使用提供内部同步因此不能笼统说它们“线程不安全”。但它们通常不是异步信号安全函数也不满足严格可重入要求。所以一定要先问讨论的是多线程并发调用还是信号处理器中异步重入六、现代 C 单例的正确初始化方式6.1 朴素懒汉存在竞态staticT*instancenullptr;T*get_instance(){if(instancenullptr){instancenewT;}returninstance;}多个线程可能同时观察到空指针并创建多个对象同时对instance的普通读写本身构成数据竞争。6.2 volatile 不能修复双重检查PDF 中的volatile T* double-checked locking是常见旧式写法但volatile不提供跨线程原子性与 happens-before 关系不能替代 atomic 或 mutex。不要使用volatilestaticT*instance;// 不是正确同步原语6.3 首选函数局部 staticC11 起函数局部静态对象的初始化由语言保证只执行一次classThreadPool{public:staticThreadPoolinstance(){staticThreadPool pool;returnpool;}ThreadPool(constThreadPool)delete;ThreadPooloperator(constThreadPool)delete;private:ThreadPool()default;};若初始化逻辑不适合放在构造函数也可以使用std::once_flag flag;std::unique_ptrResourceresource;voidinit_resource(){std::call_once(flag,[]{resourcestd::make_uniqueResource();});}std::call_once正常返回后其他调用者能观察到初始化产生的副作用若初始化抛异常后续调用可再次尝试。6.4 单例线程安全不等于对象内部线程安全“只创建一次”只解决初始化竞态。单例对象中的队列、状态和资源仍要分别定义同步协议。单例也不自动解决销毁顺序、测试隔离和依赖注入问题。七、死锁四个必要条件与工程预防7.1 经典两锁循环等待线程 A 持有 lock1等待 lock2 线程 B 持有 lock2等待 lock1两条执行流都无法继续也不会主动释放已经持有的锁。7.2 死锁四个必要条件互斥条件资源同一时刻只能由一个执行流使用请求并保持等待新资源时仍持有已有资源不可剥夺资源不能被外部强制拿走循环等待执行流和资源形成闭环等待关系。四个条件同时成立才可能死锁。工程预防通常选择破坏其中一个最常见的是消除循环等待。7.3 统一加锁顺序规定全局锁层级L1 → L2 → L3所有代码只能按从低到高的顺序获取反向获取在代码审查中直接禁止。7.4 一次获取多把锁C17std::mutex m1;std::mutex m2;voidupdate_both(){std::scoped_locklock(m1,m2);// 同时访问两个受保护对象}较早标准std::unique_lockstd::mutexl1(m1,std::defer_lock);std::unique_lockstd::mutexl2(m2,std::defer_lock);std::lock(l1,l2);不要仅靠“先 lock1 再 lock2”的局部印象整个代码库必须一致。7.5 其他预防措施缩小同时持有多把锁的范围不在持锁时调用未知回调、用户代码或阻塞 I/O使用try_lock/timed lock 失败后释放并退避把多个强耦合状态合并到一把锁下建立锁顺序文档和调试断言使用 ThreadSanitizer、Helgrind、GDB 栈和 watchdog 辅助定位设计停止流程时检查 join 与 mutex 的依赖方向。八、STL、智能指针与常见锁概念8.1 STL 容器不是“完全不支持多线程”更准确的规则是不同线程只读同一容器通常可以不同线程操作彼此独立的对象没有问题对同一个容器进行并发修改或修改与可能冲突的读取并发发生通常需要外部同步个别容器/操作对不同元素有更细的保证必须查看具体接口契约。因此不能简单记成“STL 线程安全”或“STL 全部不线程安全”。8.2 shared_ptr 的控制块安全不等于对象安全不同shared_ptr副本共享同一控制块时引用计数操作可安全并发进行。但多线程无同步修改同一个shared_ptr对象仍会数据竞争C20 可使用std::atomicstd::shared_ptrT原子替换共享指针shared_ptr指向的T不会因为被智能指针管理就自动线程安全。autopstd::make_sharedstd::vectorint();// 引用计数安全 ≠ 多线程同时 push_back 安全8.3 悲观锁、乐观并发与 CAS悲观并发假设冲突会发生访问前先取得锁乐观并发先读取版本/状态提交时检测是否被修改CAS仅当内存值仍等于期望值时替换否则失败并由调用者决定重试自旋锁等待时忙循环适合极短临界区和特定底层场景不适合长时间阻塞读写锁区分共享读与独占写读多写少时可能有收益但复杂度与公平性也更高。CAS 不是“无成本无锁”。高竞争下可能频繁失败、消耗 CPU还要面对 ABA、内存回收与内存序问题。九、高频面试题与排查清单9.1 高频面试题问题 1semaphore 和 mutex 有什么区别mutex 强调互斥所有权通常由持有者解锁semaphore 表示可用许可数量wait 消耗许可、post 发布许可不要求同一执行流成对操作。二者解决的抽象不同。问题 2环形队列为什么需要两个信号量room_sem统计可写空槽data_sem统计可读数据。生产消耗 room 并发布 data消费消耗 data 并发布 room二者之和保持为容量。问题 3为什么环形队列仍可能需要 mutex信号量只保证存在可用槽位或数据不自动串行化多个生产者对同一 producer index 的修改也不保护非线程安全容器操作。问题 4线程池停止时为什么要 broadcast空闲 worker 可能全部睡在 condition variable 上。状态改成停止后必须唤醒它们重新检查“队列为空且不再运行”的退出谓词。问题 5线程安全函数一定可重入吗不一定。函数可通过普通 mutex 实现线程安全但同一线程在持锁期间重入可能自死锁。可重入是更强调重复进入时不依赖共享中间状态的性质。问题 6volatile 能实现线程安全单例吗不能。volatile不是线程同步原语不建立原子性与 happens-before。现代 C 使用函数局部 static、std::call_once或设计正确的 atomic 发布协议。问题 7shared_ptr 是线程安全的吗共享控制块的引用计数支持不同副本并发操作同一个shared_ptr实例的并发非 const 修改需要同步或 atomic shared_ptr被管理对象本身仍需单独保证线程安全。问题 8避免死锁最实用的方法是什么统一加锁顺序、尽量一次获取多把锁、缩短持锁范围、不要在锁内调用未知代码并为停止/join 流程建立清晰依赖方向。9.2 排查清单每个共享可变状态由哪把锁或哪个 atomic 保护所有读写是否遵守同一协议condition variable 的谓词是什么wait 是否位于 while 循环状态变化与通知是否可能丢失任务是否在队列锁外执行Stop、Enqueue、worker exit 是否由同一状态机约束是否存在相反的多锁获取顺序持锁期间是否调用日志、回调、I/O 或 join对象销毁前是否 join 了全部使用者shared_ptr是否只保护了生命周期却没有保护对象数据是否用sleep()代替真正同步9.3 权威参考sem_wait(3)POSIX semaphore 等待、EINTR 与示例futex(7)mutex、condition variable 与 semaphore 的 Linux 构建基础std::call_once一次性初始化与同步关系std::atomicshared_ptr控制块与同一 shared_ptr 实例的并发边界C 数据竞争与 happens-before总结上下两篇串起来线程同步的完整主线是mutex 保护共享不变量 ↓ condition variable 等待谓词变化 ↓ BlockingQueue 实现解耦与背压 ↓ semaphore 计数空槽与数据许可 ↓ 环形队列组合许可计数与游标互斥 ↓ 线程池复用 worker并用状态机完成停止与回收 ↓ 工程层面继续处理可重入、单例、死锁与库边界请牢记semaphore 管理许可数量mutex 管理互斥所有权计数正确不代表容器和游标天然线程安全线程池的任务必须在队列锁外执行停止不是改一个 bool而是状态、唤醒、清空与 join 的协议线程安全、可重入和异步信号安全属于不同层次volatile 不能修复双重检查单例shared_ptr 保护共享所有权不自动保护对象内容死锁治理优先从统一锁顺序和缩短持锁范围开始。如果本文对你有帮助欢迎点赞、收藏。至此Linux 线程同步与互斥的主线已经从基础原语延伸到了生产者消费者、环形队列和线程池工程设计。
返回列表