ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【Linux】线程同步与互斥

【Linux】线程同步与互斥 本文主题内容理解共享资源、临界资源与临界区掌握互斥量的使用与基本原理使用 RAII 管理互斥锁理解线程同步、条件变量与竞态条件实现阻塞队列和生产者消费者模型使用 POSIX 信号量实现环形队列设计固定线程数量的线程池理解线程安全、可重入与单例模式掌握死锁的必要条件与避免方法了解 STL、智能指针和常见锁的线程安全边界引言ticket--只有一行代码为什么仍然会卖出负数票条件变量为什么必须搭配互斥量为什么等待条件必须写成while线程池停止时为什么要广播唤醒所有工作线程这些问题都与原子性、互斥、同步和状态协议有关。本文先解决共享变量竞争再逐步构建阻塞队列、环形队列和线程池最后分析死锁与线程安全边界。一、并发编程的基本概念1.1 共享资源多个执行流都能访问的资源叫共享资源例如全局变量、堆对象、文件描述符和共享容器。只读共享通常比较安全只要至少有一个线程会修改共享对象就需要考虑并发控制。1.2 临界资源与临界区临界资源需要被保护的共享资源临界区访问临界资源的代码区域非临界区不访问临界资源的代码区域保护共享资源本质上是限制线程进入访问它的临界区。1.3 互斥互斥保证同一时刻只有一个执行流进入某个临界区。其他线程申请同一把锁时要么等待要么立即失败具体取决于接口和锁类型。1.4 同步同步是在保证数据安全的前提下进一步安排多个执行流的先后关系。例如队列为空时消费者应等待生产者放入数据队列已满时生产者应等待消费者取走数据。1.5 原子性原子操作从其他执行流的观察角度看不可分割只有完成与未完成两种状态不会暴露中间过程。互斥解决同一时刻谁能进入临界区同步解决线程应该在什么条件下继续执行。二、为什么需要互斥量2.1 售票问题例intticket100;void*SellTicket(void*argument){constchar*name(constchar*)argument;while(1){if(ticket0){usleep(1000);printf(%s sells ticket: %d\n,name,ticket);ticket--;}else{break;}}returnNULL;}多个线程同时执行时可能打印0、-1等错误结果。原因包括多个线程可以同时通过ticket 0判断usleep扩大了发生线程切换的时间窗口ticket--不是一个不可分割的操作2.2 自减不是原子操作自减通常需要类似下面的步骤load 把ticket从内存读入寄存器 update 寄存器中的值减一 store 把结果写回内存如果两个线程先后读取到相同旧值再分别减一并写回就会丢失一次更新。数据竞争不仅会产生逻辑错误。在 C/C 内存模型中无同步地并发读写同一个普通对象还会导致未定义行为。三、POSIX 互斥量3.1 初始化静态初始化pthread_mutex_tmutexPTHREAD_MUTEX_INITIALIZER;动态初始化pthread_mutex_tmutex;pthread_mutex_init(mutex,NULL);3.2 加锁与解锁intpthread_mutex_lock(pthread_mutex_t*mutex);intpthread_mutex_trylock(pthread_mutex_t*mutex);intpthread_mutex_unlock(pthread_mutex_t*mutex);pthread_mutex_lock锁已被占用时阻塞等待pthread_mutex_trylock锁已被占用时立即返回pthread_mutex_unlock释放当前线程持有的锁3.3 销毁intpthread_mutex_destroy(pthread_mutex_t*mutex);销毁前必须保证互斥量没有被锁住没有线程仍在等待或准备使用它使用动态初始化的互斥量只销毁一次3.4 修复售票程序例#includepthread.h#includestdio.h#includeunistd.hintticket100;pthread_mutex_tmutexPTHREAD_MUTEX_INITIALIZER;void*SellTicket(void*argument){constchar*name(constchar*)argument;while(1){pthread_mutex_lock(mutex);if(ticket0){printf(%s sells ticket: %d\n,name,ticket);ticket--;pthread_mutex_unlock(mutex);}else{pthread_mutex_unlock(mutex);break;}}returnNULL;}判断、打印和自减共同依赖ticket必须放在同一个临界区中。只给ticket--单独加锁仍然不能保证判断和更新之间的一致性。注意所有退出路径都必须释放锁。函数中存在多个return、break或异常路径时手动管理很容易漏解锁。四、互斥量的原理与使用原则4.1 锁为什么能保证互斥锁的状态切换必须依赖硬件提供的原子读改写能力例如 exchange、compare-and-swap 等指令。多个 CPU 同时竞争时只有一个执行流能成功改变锁状态。用户态互斥量通常采用两阶段策略锁空闲时使用原子指令在用户态快速获得竞争激烈时借助内核机制阻塞和唤醒避免长期空转具体实现会因线程库和锁属性不同而变化但互斥成立的基础始终是不可分割的状态修改。4.2 锁的粒度锁范围太大并行度下降线程等待时间增加容易扩大死锁影响锁范围太小无法覆盖完整不变量多次加解锁增加开销容易遗漏共享状态正确粒度应覆盖一次逻辑操作需要保持一致的全部共享状态同时把耗时 IO、复杂计算等与共享状态无关的操作移到临界区外。4.3 RAII 管理锁C 可以让对象构造时加锁、析构时解锁。例#includemutexstd::mutex mutex;intticket100;voidSellTicket(){while(true){std::lock_guardstd::mutexguard(mutex);if(ticket0){break;}ticket--;}}即使函数提前返回或抛出异常lock_guard析构时也会解锁。五、条件变量5.1 为什么需要条件变量互斥量只能保护共享状态不能高效地让线程等待某个状态出现。例如消费者发现队列为空如果反复执行下面的循环while(QueueEmpty()){}会持续占用 CPU。条件变量允许线程释放互斥锁并休眠直到其他线程修改状态并通知它。5.2 条件变量接口intpthread_cond_init(pthread_cond_t*cond,constpthread_condattr_t*attr);intpthread_cond_destroy(pthread_cond_t*cond);intpthread_cond_wait(pthread_cond_t*cond,pthread_mutex_t*mutex);intpthread_cond_signal(pthread_cond_t*cond);intpthread_cond_broadcast(pthread_cond_t*cond);signal至少唤醒一个等待线程broadcast唤醒所有等待线程5.3 pthread_cond_wait 做了什么调用前线程必须已经持有互斥量。pthread_cond_wait会原子地把当前线程加入条件变量等待集合释放互斥量阻塞当前线程被唤醒后它会重新竞争互斥量成功获得锁之后才返回。如果先手动解锁再调用等待解锁和等待之间存在时间窗口。其他线程可能在窗口内修改条件并发送通知当前线程随后进入等待就会永久错过这次变化。5.4 为什么必须使用 while正确写法pthread_mutex_lock(mutex);while(!ConditionReady()){pthread_cond_wait(cond,mutex);}UseSharedResource();pthread_mutex_unlock(mutex);不能简单使用if原因包括线程可能发生伪唤醒多个线程被唤醒后只有一个线程先获得资源当前线程重新获得锁时条件可能已经被其他线程改变条件变量不是事件计数器通知也不会永久保存。线程真正依赖的是由互斥量保护的共享谓词。5.5 修改状态与通知典型写法pthread_mutex_lock(mutex);ChangeSharedState();pthread_cond_signal(cond);pthread_mutex_unlock(mutex);核心要求是修改共享状态时持有同一把锁。通知放在解锁前还是解锁后需要结合协议和性能分析但等待方始终应使用同一互斥量检查条件。六、生产者消费者模型6.1 模型结构生产者产生数据消费者处理数据中间使用缓冲区解耦。常用记忆是 3、2、13 种关系生产者与生产者、消费者与消费者、生产者与消费者2 种角色生产者、消费者1 个交易场所缓冲区6.2 模型优点解耦生产者和消费者不直接调用彼此支持并发生产和消费可以重叠执行平衡忙闲缓冲区吸收短时间速度差异削峰突发任务先进入队列避免立即创建大量执行流缓冲区不是越大越好。无界队列可能在消费者跟不上时耗尽内存有界队列可以形成背压。七、阻塞队列7.1 阻塞队列的规则有界阻塞队列通常满足队列为空消费者等待队列已满生产者等待放入数据通知消费者取走数据通知生产者7.2 BlockingQueue例#pragmaonce#includepthread.h#includequeuetemplateclassTclassBlockingQueue{public:explicitBlockingQueue(std::size_t capacity):_capacity(capacity),_stopped(false){pthread_mutex_init(_mutex,NULL);pthread_cond_init(_not_empty,NULL);pthread_cond_init(_not_full,NULL);}boolPush(constTvalue){pthread_mutex_lock(_mutex);while(_queue.size()_capacity!_stopped){pthread_cond_wait(_not_full,_mutex);}if(_stopped){pthread_mutex_unlock(_mutex);returnfalse;}_queue.push(value);pthread_cond_signal(_not_empty);pthread_mutex_unlock(_mutex);returntrue;}boolPop(T*output){pthread_mutex_lock(_mutex);while(_queue.empty()!_stopped){pthread_cond_wait(_not_empty,_mutex);}if(_queue.empty()_stopped){pthread_mutex_unlock(_mutex);returnfalse;}*output_queue.front();_queue.pop();pthread_cond_signal(_not_full);pthread_mutex_unlock(_mutex);returntrue;}voidStop(){pthread_mutex_lock(_mutex);_stoppedtrue;pthread_cond_broadcast(_not_empty);pthread_cond_broadcast(_not_full);pthread_mutex_unlock(_mutex);}~BlockingQueue(){pthread_cond_destroy(_not_empty);pthread_cond_destroy(_not_full);pthread_mutex_destroy(_mutex);}private:std::queueT_queue;std::size_t _capacity;bool_stopped;pthread_mutex_t _mutex;pthread_cond_t _not_empty;pthread_cond_t _not_full;};停止时要唤醒所有可能等待的生产者和消费者让它们重新检查_stopped并退出。注意销毁队列前必须保证没有线程仍在等待条件变量或访问队列。析构函数不能替代完整的停止与等待协议。八、POSIX 信号量8.1 信号量接口#includesemaphore.hintsem_init(sem_t*sem,intpshared,unsignedintvalue);intsem_wait(sem_t*sem);intsem_post(sem_t*sem);intsem_destroy(sem_t*sem);sem_wait申请一个资源计数减一没有资源时等待sem_post归还一个资源计数加一并可能唤醒等待者线程间使用时pshared通常设为0。8.2 环形队列固定大小环形队列可以使用两个信号量room_sem空位置数量初始值为容量data_sem已有数据数量初始值为0生产步骤P(room_sem) - 写入数据 - V(data_sem)消费步骤P(data_sem) - 取出数据 - V(room_sem)多生产者之间还要保护生产下标多消费者之间还要保护消费下标。信号量解决资源计数与同步不自动消除所有共享变量竞争。九、线程池9.1 为什么使用线程池短任务到来时临时创建线程会产生创建、销毁、调度和栈空间开销。线程池预先维护固定数量的工作线程任务进入共享队列工作线程反复取任务执行。线程池适合大量相对短小的任务服务端请求处理需要限制最大并发数突发流量削峰9.2 基本结构线程池至少包含工作线程集合任务队列保护队列和状态的互斥量队列非空条件变量运行或停止状态9.3 工作线程循环例voidWorker(){while(true){Task task;pthread_mutex_lock(_mutex);while(_tasks.empty()_running){pthread_cond_wait(_cond,_mutex);}if(_tasks.empty()!_running){pthread_mutex_unlock(_mutex);break;}taskstd::move(_tasks.front());_tasks.pop();pthread_mutex_unlock(_mutex);task();}}任务要在解锁后执行否则一个耗时任务会长期占用任务队列的互斥量使其他工作线程无法取任务提交者也无法入队。9.4 提交任务例boolEnqueue(Task task){pthread_mutex_lock(_mutex);if(!_running){pthread_mutex_unlock(_mutex);returnfalse;}_tasks.push(std::move(task));pthread_cond_signal(_cond);pthread_mutex_unlock(_mutex);returntrue;}9.5 停止线程池常见停止策略有立即停止放弃尚未执行的任务平滑停止拒绝新任务处理完队列后退出平滑停止需要把_running改为false广播唤醒所有工作线程并在外部join。只修改状态不广播空队列上的工作线程可能永远睡眠。9.6 线程数量线程数量要结合CPU 核心数量任务是计算密集还是 IO 密集单个任务占用内存外部服务和连接池容量队列长度与延迟目标线程过多会增加上下文切换和缓存争用线程过少则可能无法充分利用硬件或隐藏 IO 等待。十、线程安全的单例10.1 饿汉与懒汉饿汉程序初始化阶段就创建实例懒汉第一次使用时才创建实例懒汉模式需要处理多个线程同时首次访问的问题。10.2 错误的双重检查仅使用普通指针、volatile和双重if并不能在 C 内存模型中可靠保证对象构造完成后再把指针安全发布给其他线程。if(instancenullptr){lock.lock();if(instancenullptr){instancenewObject;}lock.unlock();}编译器和 CPU 的重排序、数据竞争与异常路径都会让这种手写实现复杂且容易出错。10.3 函数局部静态对象C11 起函数局部静态对象的初始化由语言保证线程安全。例classThreadPool{public:staticThreadPoolInstance(){staticThreadPool instance;returninstance;}ThreadPool(constThreadPool)delete;ThreadPooloperator(constThreadPool)delete;private:ThreadPool()default;};也可以使用std::once_flag和std::call_once完成一次性初始化。注意volatile不能替代原子操作或互斥锁也不能单独保证线程安全的对象发布。十一、线程安全与可重入11.1 线程安全多个线程并发调用函数或访问对象时结果仍然正确不会破坏共享状态称为线程安全。常见线程不安全来源无保护地修改共享变量返回共享静态缓冲区调用其他线程不安全接口跨调用保存可变全局状态11.2 可重入一个函数尚未返回时又被另一个执行流再次调用叫作重入。函数仅使用参数和局部自动变量、不依赖共享可变状态时更容易做到可重入。重入可能来自多个线程同时调用信号处理函数打断主流程后再次调用11.3 二者的区别线程安全关注多个线程并发使用时是否正确可重入关注函数在嵌套或异步重复进入时是否正确。函数可以通过内部互斥锁做到线程安全但如果信号处理函数在持锁期间重入同一函数可能死锁因此它仍不一定是异步信号可重入的。十二、死锁12.1 什么是死锁多个执行流分别持有一部分资源同时等待其他执行流持有的资源所有执行流都无法继续形成永久等待。典型情况线程A持有锁1等待锁2 线程B持有锁2等待锁112.2 死锁的四个必要条件互斥条件资源一次只能被一个执行流使用请求与保持等待新资源时不释放已经持有的资源不可剥夺资源不能被外部强制收回循环等待多个执行流形成闭环等待关系四个条件同时成立才可能发生死锁破坏任意一个就能避免该类死锁。12.3 避免死锁常用方法全局规定锁的申请顺序一次性获取多把锁使用 RAII避免异常或提前返回导致漏解锁减少嵌套持锁使用trylock或超时机制不在持锁状态执行未知回调或阻塞 IO保持锁的层次结构清晰C 可以使用std::scoped_lock同时锁定多把互斥量。例std::mutex first_mutex;std::mutex second_mutex;voidUpdate(){std::scoped_lockguard(first_mutex,second_mutex);//同时访问两个资源}十三、STL 与智能指针的线程安全13.1 STL 容器标准容器不会自动为所有操作加锁。通常可以认为多个线程只读同一容器没有其他线程修改时可以并发多个线程操作不同对象需要结合具体容器和迭代器失效规则至少一个线程修改同一容器时调用者通常需要同步不能因为容器内部实现复杂就假定它自动线程安全。13.2 unique_ptrunique_ptr表示独占所有权。不同线程各自操作不同unique_ptr对象通常没有问题但多个线程无同步地读写同一个unique_ptr对象仍然是数据竞争。13.3 shared_ptr多个独立shared_ptr对象共享同一个控制块时引用计数更新是线程安全的。但这不表示被管理对象自动线程安全可以无同步地修改同一个shared_ptr变量需要并发读写同一个共享指针变量时可以使用相应的原子shared_ptr支持或外部互斥量。十四、其他常见锁与并发策略14.1 自旋锁等待线程反复检查锁状态不主动睡眠。适合临界区极短、等待时间很小的场景等待过久会浪费 CPU。14.2 读写锁允许多个读者同时进入但写者需要独占。适合读多写少且临界区具有一定长度的场景。读写锁并不必然比普通互斥锁快需要实际测量。14.3 悲观锁与乐观并发悲观锁先假定冲突会发生访问前加锁乐观并发先执行操作提交时通过版本号或 CAS 检查冲突14.4 CASCompare-And-Swap 比较内存当前值与预期值相等时写入新值否则失败。CAS 是许多原子类型和无锁算法的基础。CAS 也有 ABA、重试开销、内存顺序和对象生命周期等复杂问题不能简单地把使用 CAS 等同于正确的无锁设计。十五、常见问题15.1 锁内执行任务只在锁内从任务队列取出任务随后立即解锁再执行任务。否则线程池会退化成串行并可能因为任务再次提交任务而产生死锁。15.2 条件变量使用 if等待条件必须在锁保护下使用while反复检查。被唤醒只表示应该重新检查条件不表示资源一定归当前线程所有。15.3 忘记停止协议线程、队列、互斥量和条件变量的生命周期必须有明确顺序停止接收新任务设置退出状态唤醒等待线程等待线程退出最后销毁同步对象。15.4 用 volatile 做线程同步volatile主要限制编译优化不提供互斥、原子性或跨线程 happens-before 关系。线程同步应使用 mutex、condition variable、semaphore 或std::atomic。十六、总结多线程并发访问共享状态时普通读改写操作可能被拆成多步并与其他线程交叉形成数据竞争。互斥量保护临界区条件变量让线程在条件不满足时原子地释放锁并等待信号量使用计数表示可用资源数量。生产者消费者模型通过缓冲区解耦双方阻塞队列利用互斥量和两个条件变量实现有界等待环形队列可以使用空间与数据两个信号量线程池则在任务队列之上复用固定数量工作线程。停止线程池时要修改状态、广播唤醒并等待所有线程退出。线程安全、可重入和死锁是并发设计的核心边界。锁必须覆盖完整不变量条件等待必须使用while多把锁要遵循统一顺序volatile不能代替同步。只有把共享状态、锁、条件和生命周期设计成完整协议程序才真正具备可验证的并发正确性。并发编程不是简单地给代码加锁而是为共享状态建立清晰、完整并且所有线程共同遵守的访问协议。
返回列表