尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

【Linux笔记】线程同步与互斥

【Linux笔记】线程同步与互斥 一、前置知识1.1 共享资源共享资源在多线程或多进程环境中即多个执行流共同访问的数据或设备资源。共享资源本身不是问题问题出在多个执行流同时、无协调地访问它时会产生数据不一致、结果错误等并发问题。举例说明 ​ 1.多个线程同时读写同一个全局变量 int counter 0; ​ 2.多个线程同时访问同一个文件 ​ 3.多个线程同时向同一块内存区域写入数据 ​ 4.多个线程竞争同一台打印机1.2 临界资源临界资源在多线程执行流中需要被保护的共享资源。关键点在于需要被保护并非所有共享资源都是临界资源只有那些一旦被并发访问就会出错的资源才是临界资源。举例说明 int g_count 0; // 全局计数器多线程共享 ​ // 线程A g_count; ​ // 线程B g_count;1.3 临界区临界区每个线程内部访问临界资源的那段代码。注意临界资源是资源临界区是代码临界区是保护临界资源的代码区域。// 线程函数 void* thread_func(void* arg) { printf(进入线程\n); // 非临界区随便执行 ​ // ---- 临界区开始 ---- int temp g_count; // 读取临界资源 temp temp 1; // 修改 g_count temp; // 写回临界资源 // ---- 临界区结束 ---- ​ printf(退出线程\n); // 非临界区随便执行 return NULL; }1.4 原子性原子性 指一个操作不会被任何调度机制打断该操作只有两种状态——要么完成要么未完成不存在中间状态。代码示例需要原子性的原因。// 线程函数 void* thread_func(void* arg) { printf(进入线程\n); // 非临界区随便执行 ​ // ---- 临界区开始 ---- g_count; // ---- 临界区结束 ---- ​ printf(退出线程\n); // 非临界区随便执行 return NULL; }对于 g_count 这一行代码在CPU层面实际上是三条指令1. 从内存读取 g_count 到寄存器Load ​ 2. 寄存器中的值加一Add ​ 3. 将结果写回内存中的 g_countStore非原子操作带来的问题:如果线程A执行完第1步后被调度走线程B完整地执行了三步然后线程A继续执行第2、3步就会丢失线程B的修改。二、线程互斥2.1 互斥的引入互斥 在任何时刻互斥机制保证有且只有一个执行流进入临界区访问临界资源从而对临界资源起到保护作用。类比理解假设一个房间有ATM机有且只能一个人进入该房间。一个人进去后锁上房间门 ↓ 其他人只能在门外等待 ↓ 里面的人出来并开门 ↓ 下一个等待者才能进入2.2 场景分析2.2.1 场景引入多线程进行抢票对于一个共享资源ticket如果没有对共享资源进行保护当多个线程需要修改它时会导致该共享资源的数据混乱。#include stdio.h #include string.h #include unistd.h #include pthread.h ​ ​ int ticket 10; void *route(void *arg) { char *id (char *)arg; while (1) { if (ticket 0) { usleep(1000); printf(%s sells ticket:%d\n, id, ticket); ticket--; } else { break; } } return NULL; } ​ int main() { pthread_t t1, t2, t3, t4; pthread_create(t1, NULL, route, (void *)thread 1); pthread_create(t2, NULL, route, (void *)thread 2); pthread_create(t3, NULL, route, (void *)thread 3); pthread_create(t4, NULL, route, (void *)thread 4); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); }输出结果thread 1 sells ticket:10 thread 3 sells ticket:10 ← 同一张票被卖了两次 thread 2 sells ticket:9 thread 4 sells ticket:9 ← 又重复了 thread 1 sells ticket:8 ... thread 3 sells ticket:1 thread 2 sells ticket:0 ← 非法0号票不存在 thread 4 sells ticket:-1 ← 非法超卖2.2.2 核心并发bug临界区未保护临界区未进行保护从if (ticket 0)到ticket--这整段代码就是临界区ticket就是临界资源。if (ticket 0) // 步骤A检查 { usleep(1000); // 步骤B故意延时放大问题 printf(%s sells ticket:%d\n, id, ticket); // 步骤C读取 ticket--; // 步骤D修改 }A. 错误类型一重复售卖同一张票时刻T1: thread1 执行 if(ticket0)此时ticket10条件成立 时刻T2thread1 打印 thread 1 sells ticket:10 时刻T3: thread1 被调度走usleep让出CPU 时刻T4: thread2 执行 if(ticket0)此时ticket仍10条件也成立 时刻T5: thread2 打印 thread 2 sells ticket:10然后 ticket-- → ticket9 时刻T5: thread1 恢复然后 ticket-- → ticket8B. 错误类型二超卖 (ticket变为负数)时刻T1: thread1 执行 if(ticket0)此时ticket1条件成立 时刻T2: thread1 被调度走usleep让出CPU 时刻T3: thread2 执行 if(ticket0)此时ticket仍1条件也成立 时刻T4: thread2 打印 thread 2 sells ticket:1然后 ticket-- → ticket0 时刻T5: thread1 恢复打印 thread 1 sells ticket:0然后 ticket-- → ticket-1C. 错误类型三数据竞争Data Raceticket-- 本身不是原子操作读-改-写三步多个线程同时对同一个内存地址执行非原子写操作2.2.3 剖析非原子写操作 ticket--对于这一行代码ticket--而言在CPU执行时分为三条指令LOAD R1, [ticket] ; // 第1条从内存读取 ticket 的值到寄存器 R1 SUB R1, R1, 1 ; // 第2条寄存器 R1 中的值减 1 STORE [ticket], R1 ; // 第3条将 R1 的新值写回内存中的 ticket 1. 从内存读取 ticket 到寄存器Load 2. 寄存器中的值减一Sub 3. 将结果写回内存中的 ticketStoreCPU的调度器可以在这三条指令之间的任意间隙打断当前线程切换到另一个线程从而导致了线程读到了旧值切换点1线程A执行完LOAD后被切走切换点2线程A执行完SUB后被切走切换点3线程A执行完STORE后被切走A. 切换点1线程A执行完LOAD后被切走线程A: LOAD R1, [ticket] → R1 5 *** 被切走 *** 线程B: LOAD R1, [ticket] → R1 5 SUB R1, R1, 1 → R1 4 STORE [ticket], R1 → 内存中 ticket 4 *** 被切走 *** 线程A: SUB R1, R1, 1 → R1 4 (注意R1仍是旧值5,每个线程有独立的上下文即有独立的寄存器) STORE [ticket], R1 → 内存中 ticket 4结果ticket 从 5 变成了 4而不是预期的 3两个线程各卖了一张票但只扣了一次丢失了一次更新。B. 切换点2线程A执行完SUB后被切走线程A: LOAD R1, [ticket] → R1 5 SUB R1, R1, 1 → R1 4 *** 被切走 *** 线程B: LOAD R1, [ticket] → R1 5 (内存中还是5) SUB R1, R1, 1 → R1 4 STORE [ticket], R1 → 内存中 ticket 4 *** 被切走 *** 线程A: STORE [ticket], R1 → 内存中 ticket 4结果同样是 ticket4 而非 3。本质原因相同——线程B在线程A写回之前读取了旧值。C. 切换点3线程A执行完STORE后被切走线程A: LOAD R1, [ticket] → R1 5 SUB R1, R1, 1 → R1 4 STORE [ticket], R1 → 内存中 ticket 4 *** 被切走 *** 线程B: LOAD R1, [ticket] → R1 4 (读到的是正确的新值) SUB R1, R1, 1 → R1 3 STORE [ticket], R1 → 内存中 ticket 3结果ticket3正确 因为线程A完整执行了三条指令后才被切走线程B读到的是已经更新的值。2.2.4 解决非原子操作A. 互斥性本质理解当代码进入临界区执行时不允许其他线程进入该临界区。B. 空闲让进本质理解如果多个线程同时要求执行临界区的代码并且临界区没有线程在执行那么只能允许一个线程进入该临界区。不能浪费机会临界区空闲时如果有线程想进入就必须让其中一个进去不能让临界区空着而所有申请者都在外面干等。只能放一个进去即使有多个线程同时申请也只能选择其中一个放行不能因为大家都等了很久就同时放进去多个。C. 无关不阻本质理解如果线程不在临界区中执行那么该线程不能阻止其他线程进入临界区。一个线程如果当前不在临界区内包括从未进入、已经离开、或者正在执行非临界区代码它对临界区的访问权就不应该对其他线程产生任何影响简而言之不在临界区里的线程无论它处于什么状态运行、阻塞、挂起、甚至崩溃都不应该成为其他线程进入临界区的障碍2.3 互斥锁接口的认识关于锁相关的接口 //全局变量 pthread_mutex_t fastmutex PTHREAD_MUTEX_INITIALIZER; //初始化互斥锁 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr); //加锁 int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex); //解锁 int pthread_mutex_unlock(pthread_mutex_t *mutex); //回收互斥锁 int pthread_mutex_destroy(pthread_mutex_t *mutex);2.4.1 互斥锁的类型声明pthread_mutex_t是 POSIX 线程库中定义互斥锁的类型本质是一个结构体或联合体。内部封装了锁的状态、持有者、等待队列等信息。//互斥锁的类型 pthread_mutex_t fastmutex PTHREAD_MUTEX_INITIALIZER;初始化有两种方式1. 静态初始化PTHREAD_MUTEX_INITIALIZER 适用场景 全局变量、静态变量编译期确定地址 2. 动态初始化pthread_mutex_init() 适用场景局部变量、堆上分配的锁运行期创建2.4.2 初始化互斥锁//初始化互斥锁 int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *mutexattr);2.4.3 加锁//加锁 int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_trylock(pthread_mutex_t *mutex);2.4.4 解锁//解锁 int pthread_mutex_unlock(pthread_mutex_t *mutex);2.4.5 回收互斥锁//回收互斥锁 int pthread_mutex_destroy(pthread_mutex_t *mutex);2.4 互斥锁的原理2.5 Demo演示:互斥量的封装#pragma once #include iostream #include pthread.h namespace MutexModule { class Mutex { public: Mutex() { pthread_mutex_init(_mutex, nullptr); } Mutex(const Mutex ) delete; Mutex operator(const Mutex ) delete; void lock() { pthread_mutex_lock(_mutex); } void unlock() { pthread_mutex_unlock(_mutex); } // 暴露底层句柄供条件变量等需要原生 pthread_mutex_t* 的场景使用 pthread_mutex_t *native_handle() { return _mutex; } ~Mutex() { pthread_mutex_destroy(_mutex); } private: pthread_mutex_t _mutex; }; class LockGuard { public: LockGuard(Mutex mutex) : _mutex(mutex) { _mutex.lock(); } LockGuard(const LockGuard ) delete; LockGuard operator(const LockGuard ) delete; ~LockGuard() { _mutex.unlock(); } private: Mutex _mutex; }; }三、线程同步3.1 同步的引入3.2 条件变量接口的认识#include pthread.h pthread_cond_t cond PTHREAD_COND_INITIALIZER; //初始化条件变量 int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr); //唤醒条件变量 int pthread_cond_signal(pthread_cond_t *cond); int pthread_cond_broadcast(pthread_cond_t *cond); //等待条件变量 int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime); //回收条件变量 int pthread_cond_destroy(pthread_cond_t *cond);3.3 生产者和消费者模型 -基于条件变量实现#pragma once #include iostream #include pthread.h #include string #include vector #include queue #include unistd.h #include myCond.hpp #include myMutex.hpp namespace CvQueueModule { const int defaultcap 5; // 该容量仅用于测试 template typename T class CvQueue { private: bool IsFull() { return (_p_step 1) % _cap _c_step; } bool IsEmpty() { return _p_step _c_step; } public: CvQueue(int capacity defaultcap) : _cap(capacity), _cv_q(capacity 1), _p_step(0), _c_step(0) {} void Enqueue(const T in) { // 获取互斥锁进入临界区。 // pthread_mutex_lock(_lock); { MutexModule::LockGuard lockguard(_mutex); // 为什么使用while而不使用if条件判断 // 1.消费者线程可能被虚假唤醒Spurious Wakeup而导致生成数据时出现越界 // 2.多个消费者线程都被唤醒 或 连续被唤醒也会导致生成数据时出现越界 while (IsFull()) { // 生产者线程等待队列 // 1.pthread_cond_wait调用成功释放锁并睡眠等待缓冲区有空位的通知进行唤醒。 // 2.当线程被唤醒时默认在临界区内唤醒需要pthread_cond_wait再次申请锁 // 3.若当前线程被唤醒时但是申请锁失败此时会在锁上阻塞等待。 // pthread_cond_wait(_full_cond, _lock); _full_cond.Wait(_mutex); } // 向缓冲区队列中添加数据 _cv_q[_p_step] in; _p_step (_p_step 1) % _cap; // 运行到此缓冲区可能有数据 → 缓冲区含有数据向消费者进行通知 // 1.消费者在 _empty_cond 的阻塞队列中唤醒消费者 // 2.消费者不在_empty_cond 的阻塞队列中signal 直接返回通知丢失条件变量不存储状态。 // pthread_cond_signal(_empty_cond); _empty_cond.Signal(); // 唤醒消费者 } //释放互斥锁 } T Pop() { T out; { // 获取互斥锁进入临界区。 // pthread_mutex_lock(_lock); MutexModule::LockGuard lockguard(_mutex); while (IsEmpty()) { // 消费者线程等待队列 // 1.pthread_cond_wait调用成功释放锁并睡眠等待缓冲区是否有数据的通知进行唤醒。 // 2.当线程被唤醒时默认在临界区内唤醒需要pthread_cond_wait再次申请锁 // 3.若当前线程被唤醒时但是申请锁失败此时会在锁上进行阻塞等待。 // pthread_cond_wait(_empty_cond, _lock); _empty_cond.Wait(_mutex); } // 向缓冲区队列中获取数据 out _cv_q[_c_step]; _c_step (_c_step 1) % _cap; // 运行到此缓冲区数据可能满了 → 缓冲区数据一定不满向生产者进行通知 // 1.生产者在 _full_cond 的阻塞队列中唤醒消费者 // 2.生产者不在_full_cond 的阻塞队列中signal 直接返回通知丢失条件变量不存储状态。 // pthread_cond_signal(_full_cond); _full_cond.Signal(); }//释放互斥锁 return out; } private: std::vectorT _cv_q; // 缓冲区 int _cap; // 缓冲区的容量大小 int _p_step; // 生产者在缓冲区中的位置 int _c_step; // 消费者在缓冲区中的位置 MutexModule::Mutex _mutex; // 互斥锁 CondModule::Cond _full_cond; // 生产者等待缓冲区非满 CondModule::Cond _empty_cond; // 消费者等待缓冲区非空 }; }四、POSIX信号量4.1 基本概念信号量Semaphore本质上是一个非负整数计数器用于控制多个执行流对共享资源的访问。P操作尝试将信号量值减1。P操作成功若当前值大于0则立即减1并返回P操作失败若值当前值为0则调用者阻塞等待直到信号量变为正数。V操作将信号量值加1。若有其他执行流正在等待该信号量则唤醒其中一个。4.2 信号量接口的认识#include semaphore.h //初始化信号量 int sem_init(sem_t *, int, unsigned); //回收信号量 int sem_destroy(sem_t *); //P操作尝试将信号量值减1 int sem_post(sem_t *); //V操作将信号量值加1。 int sem_wait(sem_t *);4.2.1 初始化信号量#include semaphore.h int sem_init(sem_t *sem, int pshared, unsigned int value); 参数 pshared:0表示线程间共享⾮零表示进程间共享 value信号量初始值4.2.2 回收信号量int sem_destroy(sem_t *sem);4.2.3 等待信号量 --P操作功能等待信号量会将信号量的值减1 int sem_wait(sem_t *sem); //P()4.2.4 发布信号量 --V操作功能发布信号量表示资源使用完毕可以归还资源了。将信号量值加1。 int sem_post(sem_t *sem);//V()4.3 生产者和消费者模型 -基于信号量实现#pragma once #include mySem.hpp #include myMutex.hpp #include vector namespace SemQueueModule { const int defaultcap 5; template typename T class SemQueue { public: SemQueue(int capacity defaultcap) : _cap(capacity), _sem_q(capacity), _blank_sem(capacity), _p_step(0), _data_sem(0), _c_step(0) { } void Enqueue(const T in) { // 生产者 // 1.生产者查看是否有空位 _blank_sem.P(); { MutexModule::LockGuard lockguard(_mutex); // 2.生产资源 _sem_q[_p_step] in; // 3.更新生产者在环形队列中的位置 _p_step (_p_step 1) % _cap; } // 4.通知消费者环形队列中增加了资源 环形队列增加了资源 _data_sem.V(); } T Pop() { T out; // 消费者 // 1.查看环形队列中是否有资源 _data_sem.P(); { MutexModule::LockGuard lockguard(_mutex); // 2.获取环形队列中的资源 out _sem_q[_c_step]; // 3.更新消费者在环形队列中的位置 _c_step (_c_step 1) % _cap; } // 4.通知生产者环形队列中释放了资源 环形队列增加了空位 _blank_sem.V(); return out; } ~SemQueue() {} private: std::vectorT _sem_q; // 模拟环形队列 int _cap; // 环形队列的有效长度 // 生产者 SemModule::Sem _blank_sem; // 生产者环形队列中的空位置 int _p_step; // 生产者在环形队列中的位置 // 消费者 SemModule::Sem _data_sem; // 消费者: 环形队列中的数据 int _c_step; // 消费者在环形队列中的位置 MutexModule::Mutex _mutex; //互锁 }; }五、线程安全5.1 什么是线程安全5.2 Linux 下常见的线程安全问题5.2.3 死锁死锁 是指两个或多个执行单元线程、进程、事务等因争夺资源而造成的一种永久阻塞状态每个单元都在等待另一个单元释放其所持有的资源且没有任何外部干预能打破这种僵局。A. 产生死锁的四个必要条件B. 死锁预防的策略5.3 线程安全的单例模式5.3.1 单例模式介绍5.3.2 实现单例模式 -基于饿汉方式5.3.3 实现单例模式 -基于懒汉方式5.3.4 实现线程安全的单例模式5.4 STL与线程安全5.5 智能指针与线程安全
返回列表