Linux线程互斥(燃尽了兄弟!)

张开发
2026/4/8 3:29:56 15 分钟阅读

分享文章

Linux线程互斥(燃尽了兄弟!)
目录引入1 互斥量——mutex1.1 互斥量1.2 初始化互斥量1.2.1 静态初始化1.2.2 动态初始化1.3 销毁互斥量1.4 互斥量加锁和解锁1.4.1 加锁1.4.2 解锁1.5 理解三个问题2 互斥量的实现原理3 互斥量的封装4 线程安全与重入问题4.1 线程安全4.2 重入4.3 总结5 常见锁的概念5.1 死锁5.2 死锁的四个必要条件5.3 如何避免死锁6 SLT和智能指针的线程安全问题6.1 STL中的容器是线程安全的吗6.2 智能指针是线程安全的吗引入示例代码#include stdio.h #include pthread.h #include string #include unistd.h // 公共资源 int num 1000; // 入口函数 void* func(void* argc){ std::string name (char*)argc; // 访问公共资源 while(true){ if(num 0){ usleep(10000); printf(%s use %d\n, name.c_str(), num); num--; } else{ break; } } return (void*)0; } int main() { // 创建线程 pthread_t t1, t2, t3, t4; pthread_create(t1, nullptr, func, (void*)pthread-1); pthread_create(t2, nullptr, func, (void*)pthread-2); pthread_create(t3, nullptr, func, (void*)pthread-3); pthread_create(t4, nullptr, func, (void*)pthread-4); // 等待线程 pthread_join(t1, nullptr); pthread_join(t2, nullptr); pthread_join(t3, nullptr); pthread_join(t4, nullptr); return 0; }运行结果// ............ pthread-4 use 1 pthread-1 use 0 pthread-3 use -1 pthread-2 use -2问题一当判断条件 0时才会进行--为什么会出现负数的情况现假设有两个线程分别为线程A和线程B它们并发执行func函数现线程A将 num 从内存拷贝进CPU中进行算术运算完成后由于线程之间是不断在切换的因此A线程还没来得及做num--就被切走了但是此时在线程A已经进入了 if 条件判断的里面了CPU开始执行下一个线程在切换下一个线程之前线程A会保存当前的CPU上下文数据以便恢复现场极端一点现线程B一直占用CPU的资源并且将 num 的值已经减为了 0此后线程B的时间片结束开始切换线程线程A通过之前的CPU上下文数据恢复现场在执行 num-- 时会重新将内存中的 num 拷贝进CPU进行算数运算此时拷贝进来的 num 的就已经为 0 了在对其进行 num-- 操作自然就变为了负数问题二对全局变量进行--是安全的吗-- 操作并不是原子的在经过编译器编译后会形成三条语句load 将共享变量num从内存加载到寄存器中update : 更新寄存器⾥⾯的值执⾏-1操作store 将新值从寄存器写回共享变量num的内存地址同样现假设有两个线程分别为线程A和线程B它们并发执行func函数现线程A将 num 从内存拷贝进CPU中进行算术运算完成后执行 num-- 操作但是只执行到 load由于线程之间是不断在切换的因此A线程还没来得及做update和store就被切走了但是此时在线程A中所保存的 num 的值为1000CPU开始执行下一个线程在切换下一个线程之前线程A会保存当前的CPU上下文数据以便恢复现场极端一点现线程B一直占用CPU的资源并且将 num 的值已经减为了 0此后线程B的时间片结束开始切换线程线程A通过之前的CPU上下文数据恢复现场继续执行 num-- 时根据之前的上下文文数据会从update开始执行并且之前保存的num为1000此时运算的结果值就为999再将结果拷贝回内存中的 num写回的过程中就将原来线程B写回的数据进行了覆盖。线程B之前的操作就白白进行执行了num的值还是999为了解决这一问题便引入了线程锁这一概念1 互斥量——mutex1.1 互斥量互斥量(Mutual Exclusion)是一种特殊的二元信号量用于控制多个线程对共享资源的访问保证同一时间只有一个线程可以进入临界区。1互斥量的相关名词临界资源多线程执⾏流共享的资源就叫做临界资源临界区每个线程内部访问临界资源的代码就叫做临界区互斥任何时刻互斥保证有且只有⼀个执⾏流进⼊临界区访问临界资源通常对临界资源起保护作⽤原⼦性对于一段代码的操作只有两种情况要么完成要么未完成。称这段代码具有原子性1.2 初始化互斥量1.2.1 静态初始化#include pthread.h // 静态初始化(使用默认属性) pthread_mutex_t mutex PTHREAD_MUTEX_INITIALIZER;静态初始化的互斥量不需要手动销毁静态初始化的互斥量没有分配任何需要手动释放的资源其生命周期与程序相同。1.2.2 动态初始化pthread_mutex_init 是 POSIX 线程库中用于动态初始化互斥量的函数它比静态初始化方式更加灵活可以定制互斥量的各种属性。#include pthread.h // 方式2动态初始化 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);1参数说明mutex: 指向要初始化的互斥量对象的指针attr: 指向互斥量属性对象的指针如果为 NULL 则使用默认属性2返回值成功时返回 0失败时返回错误码非零值1.3 销毁互斥量pthread_mutex_destroy是 POSIX 线程库中用于销毁互斥量的函数它与pthread_mutex_init配对使用用于释放互斥量占用的资源。注意使⽤ PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁不要销毁⼀个已经加锁的互斥量已经销毁的互斥量要确保后⾯不会有线程再尝试加锁#include pthread.h int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);1参数说明mutex: 指向要初始化的互斥量对象的指针attr: 指向互斥量属性对象的指针如果为 NULL 则使用默认属性2返回值成功时返回 0失败时返回错误码非零值1.4 互斥量加锁和解锁1.4.1 加锁pthread_mutex_lock是 POSIX 线程库中用于获取互斥量的核心函数它提供了线程同步的基本机制调用pthread_mutex_lock时可能会遇到以下情况:互斥量处于未锁状态该函数会将互斥量锁定同时返回成功发起函数调⽤时其他线程已经锁定互斥量或者存在其他线程同时申请互斥量但没有竞争到互斥量那么pthread_ lock调⽤会陷⼊阻塞(执⾏流被挂起)等待互斥量解锁。#include pthread.h int pthread_mutex_lock(pthread_mutex_t *mutex);1参数说明mutex: 指向要锁定的互斥量对象的指针2返回值成功时返回 0失败时返回错误码非零值1.4.2 解锁pthread_mutex_unlock是 POSIX 线程库中用于释放互斥量的关键函数它与pthread_mutex_lock配对使用构成线程同步的基本机制。#include pthread.h int pthread_mutex_unlock(pthread_mutex_t *mutex);1参数说明mutex: 指向要解锁的互斥量对象的指针2返回值成功时返回 0失败时返回错误码非零值1.5 理解三个问题问题一静态的互斥量自己不就是全局的吗它怎么保证自己的线程安全静态的互斥量本身是原子的因此它是安全的问题二访问临界资源时所有线程必须要申请锁吗所有线程都必须要遵守规则负责就会产生线程安全的问题。大家申请成功才会继续向后运行负责就会线程阻塞问题三当在加锁和解锁之间运行时线程会被切走吗进而导致线程安全问题会被切换。但是在切换过程中线程是持有锁的状态进行的切换因此其它线程也不会被唤醒因此不会导致线程安全问题2 互斥量的实现原理为了实现互斥锁操作,⼤多数体系结构都提供了swap或exchange指令,该指令的作⽤是把寄存器和内存单元的数据相交换,由于只有⼀条指令,保证了原⼦性1理解 加锁和解锁 的伪代码lock: movb $0, %al; xchgb %al, mutex; if(al寄存器的内容 0){ return 0; } else{ 挂起等待; } goto lock; unlock: movb $1, mutex; 唤醒等待的mutex线程; return 0;加锁逻辑开始将 寄存器%al中的值初始化为0 即使当前被切换走了也没关系因为它不是核心语句。xchgb %al, mutex 是核心语句将内存中mutex和寄存器中的值进行交换不是拷贝表示当前线程已经获得的锁资源。即使即使当前被切换走了也没关系内存中的mutex此时已经表示没有锁资源了其他线程也申请不到锁资源会被挂起等待直到当前线程执行unlock归还锁资源解锁逻辑将 寄存器%al中的锁资源与内存中的mutex 重新进行交换归还锁资源之后唤醒争夺锁资源失败被挂起的线程它们重新争夺争夺失败的线程继续被挂起等待3 互斥量的封装Linux/demo/mutex/Mutex.hpp · lv-zhuo/AsCent - 码云 - 开源中国使用示例#include stdio.h #include pthread.h #include string #include unistd.h #include Mutex.hpp // 公共资源 int num 1000; // 定义锁 Mutex mutex; void* func(void* argc) { std::string name static_castchar*(argc); // 访问公共资源 while(true) { // RAII LockGuard lockguard(mutex); if(num 0) { usleep(10000); printf(%s use %d\n, name.c_str(), num); num--; } else { break; } } return (void*)0; } int main() { // 创建线程 pthread_t t1, t2, t3, t4; pthread_create(t1, nullptr, func, (void*)pthread-1); pthread_create(t2, nullptr, func, (void*)pthread-2); pthread_create(t3, nullptr, func, (void*)pthread-3); pthread_create(t4, nullptr, func, (void*)pthread-4); // 等待线程 pthread_join(t1, nullptr); pthread_join(t2, nullptr); pthread_join(t3, nullptr); pthread_join(t4, nullptr); // 销毁锁 //pthread_mutex_destroy(mutex); return 0; }4 线程安全与重入问题4.1 线程安全线程安全就是多个线程在访问共享资源时能够正确地执⾏不会相互⼲扰或破坏彼此的执⾏结果。⼀般⽽⾔多个线程并发同⼀段只有局部变量的代码时不会出现不同的结果。但是对全局变量或者静态变量进⾏操作并且没有锁保护的情况下容易出现该问题。常见的线程安全情况每个线程对全局变量或者静态变量只有读取的权限⽽没有写⼊的权限⼀般来说这些线程是安全的类或者接⼝对于线程来说都是原⼦操作多个线程之间的切换不会导致该接⼝的执⾏结果存在⼆义性常见的线程不安全情况不保护共享变量的函数函数状态随着被调⽤状态发⽣变化的函数返回指向静态变量指针的函数调⽤线程不安全函数的函数4.2 重入同⼀个函数被不同的执⾏流调⽤当前⼀个流程还没有执⾏完就有其他的执⾏流再次进⼊我们称之为重⼊。⼀个函数在重⼊的情况下运⾏结果不会出现任何不同或者任何问题则该函数被称为可重⼊函数否则是不可重⼊函数。学到现在其实我们已经能理解重⼊其实可以分为两种情况多线程重⼊函数信号导致⼀个执⾏流重复进⼊函数常见的可重入情况不使⽤全局变量或静态变量不使⽤ malloc或者new开辟出的空间不调⽤不可重⼊函数不返回静态或全局数据所有数据都有函数的调⽤者提供使⽤本地数据或者通过制作全局数据的本地拷贝来保护全局数据常见的不可重入情况调⽤了malloc/free函数因为malloc函数是⽤全局链表来管理堆的调⽤了标准I/O库函数标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构可重⼊函数体内使⽤了静态的数据结构4.3 总结可重入与线程安全联系函数是可重⼊的那就是线程安全的(其实知道这⼀句话就够了)函数是不可重⼊的那就不能由多个线程使⽤有可能引发线程安全问题如果—个函数中有全局变量那么这个函数既不是线程安全也不是可重⼊的。可重入与线程安全区别可重⼊函数是线程安全函数的—种线程安全不—定是可重⼊的信号导致一个执行流重复进入函数导致死锁问题⽽可重⼊函数则—定是线程安全的。如果将对临界资源的访问加上锁则这个函数是线程安全的但如果这个重⼊函数若锁还未释放则会产⽣死锁因此是不可重⼊的。注意如果不考虑信号导致⼀个执⾏流重复进⼊函数这种重⼊情况线程安全和重⼊在安全角度不做区分但是线程安全侧重说明线程访问公共资源的安全情况表现的是并发线程的特点可重⼊描述的是—个函数是否能被重复进⼊表⽰的是函数的特点5 常见锁的概念5.1 死锁死锁是多线程编程中一种常见的并发问题指两个或多个线程因互相持有对方所需的资源而无限阻塞导致程序无法继续执行造成结果5.2 死锁的四个必要条件死锁的发生必须同时满足以下四个条件Coffman条件互斥条件必须需要有一把锁并且⼀个资源每次只能被⼀个执⾏流使⽤请求与保持条件⼀个执⾏流因请求资源⽽阻塞时对已获得的资源保持不放不剥夺条件:—个执⾏流已获得的资源在末使⽤完之前不能强⾏剥夺循环等待条件:若⼲执⾏流之间形成—种头尾相接的循环等待资源的关系5.3 如何避免死锁避免死锁的宗旨在于破坏死锁产生的四个必要条件从根本解决问题尽量少使用锁想办法替代(1) 破坏“占有并等待”条件一次性申请所有资源线程在开始执行前先获取所有需要的锁。示例synchronized (lock1) { synchronized (lock2) { // 业务逻辑 } }(2) 破坏“不可抢占”条件使用tryLock()并设置超时避免无限等待if (lock1.tryLock(1, TimeUnit.SECONDS)) { if (lock2.tryLock(1, TimeUnit.SECONDS)) { // 业务逻辑 lock2.unlock(); } lock1.unlock(); }(3) 破坏“循环等待”条件固定锁的获取顺序确保所有线程按相同顺序加锁。// 所有线程必须先获取 lock1再获取 lock2 synchronized (lock1) { synchronized (lock2) { // 业务逻辑 } }(4) 使用更高级的并发工具使用ConcurrentHashMap、CountDownLatch、Semaphore等工具替代显式锁。6 SLT和智能指针的线程安全问题6.1 STL中的容器是线程安全的吗不是原因是, STL 的设计初衷是将性能挖掘到极致, ⽽⼀旦涉及到加锁保证线程安全, 会对性能造成巨⼤的影响.⽽且对于不同的容器, 加锁⽅式的不同, 性能可能也不同(例如hash表的锁表和锁桶).因此 STL 默认不是线程安全. 如果需要在多线程环境下使⽤, 往往需要调⽤者⾃⾏保证线程安全6.2 智能指针是线程安全的吗对于 unique_ptr, 由于只是在当前代码块范围内⽣效, 因此不涉及线程安全问题.对于 shared_ptr, 多个对象需要共⽤⼀个引⽤计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原⼦操作(CAS)的⽅式保证 shared_ptr 能够⾼效, 原⼦的操作引⽤计数.总结STL 容器默认不是线程安全的必须加锁或使用并发容器。shared_ptr的引用计数是线程安全的但数据访问仍需同步。unique_ptr不能跨线程共享必须使用shared_ptr或手动管理。

更多文章