【Linux系统】线程的同步与互斥(1)——互斥量mutex
文章目录引入问题一、线程互斥1.1、相关概念1.2、简单介绍互斥量1.3、互斥量的相关接口1.4、互斥量的原理1.5、互斥量的封装引入问题通过对线程的相关概念的学习我们知道一个进程内部有多个线程而所有的线程都共享进程地址空间因此进程的大部分资源都会被线程共享。那么对于共享的资源如果多个线程同时访问它会产生什么后果呢我们在学习线程控制的相关操作时我们时常会见到这样一个现象多个线程向显示器打印数据时会出现严重的信息干扰。在Linux眼中显示器本质上也就是一个文件也是一个共享资源多线程访问共享资源必然会引发数据不一致问题。如何解决呢这就需要我们学习本文所讲的同步与互斥相关内容了。一、线程互斥1.1、相关概念临界资源多线程执行流被保护的共享资源就叫做临界资源。临界区每个线程内部访问临界资源的代码就叫做临界区。互斥任何时刻互斥保证有且只有一个执行流进入临界区访问临界资源通常对临界资源起保护作用。原子性不会被任何调度机制打断的操作该操作只有两态要么完成要么未完成。1.2、简单介绍互斥量大部分情况线程使用的数据都是局部变量变量处于对应线程的栈空间内这种情况变量归属单个线程其他线程是无法获得这种变量。但有时候很多变量都需要在线程间共享这样的变量称为共享变量可以通过数据的共享完成线程之间的交互。当多个线程并发的操作共享变量时会引发一些问题。我们来看一下代码// 操作共享变量会有问题的售票系统代码#includestdio.h#includestdlib.h#includestring.h#includeunistd.h#includepthread.hinttickets100;void*route(void*arg){char*id(char*)arg;while(true){if(tickets0){usleep(1000);// 模拟抢票花费时间printf(%s sells ticket:%d\n,id,ticket);tickets--;}else{break;}}returnnullptr;}intmain(){pthread_t t1,t2,t3,t4;pthread_create(t1,nullptr,route,(void*)thread 1);pthread_create(t2,nullptr,route,(void*)thread 2);pthread_create(t3,nullptr,route,(void*)thread 3);pthread_create(t4,nullptr,route,(void*)thread 4);pthread_join(t1,nullptr);pthread_join(t2,nullptr);pthread_join(t3,nullptr);pthread_join(t4,nullptr);return0;}结果如下这个结果显然不符合我们的期望并且还有点违反常识。问题1️⃣为什么会数据不一致呢我们在计算机组成原理这门课中学过CPU可以处理两种运算一个是算术运算另一个是逻辑运算。而我们代码中对tickes大小的判断就属于逻辑运算在运算之前CPU会将内存中对应的tickets值导入到寄存器中而这一过程正是导致问题的元凶之一。除了代码中的if语句可能会造成数据不一致问题代码中的--操作同样也不可忽视--操作本身也不是原子的。在C/C中tickets--是一条语句但在CPU眼中实际为三条汇编语句1️⃣将内存中的tickets移入寄存器exa中2️⃣对exa减13️⃣再将exa中的tickets传入内存。因此很有可能对exa减之前或将tickets数值更新前就进行线程切换而导致数据不一致问题数据重复。对全局变量或–非常容易引发线程安全问题。问题2️⃣我们如何解决这一问题呢tickets属于共享资源为了避免共享资源一次性被多次访问我们应该将共享资源保护起来而被保护的共享资源我们也称之为临界资源。因此一句话概括就是将共享资源转化为临界资源。保护共享资源不被多线程同时访问需要满足以下三点1️⃣代码必须要有互斥行为当代码进入临界区执行时不允许其他线程进入该临界区。2️⃣如果多个线程同时要求执行临界区的代码并且临界区没有线程在执行那么只能允许一个线程进入该临界区。3️⃣如果线程不在临界区中执行那么该线程不能阻止其他线程进入临界区。而要满足以上三点我们仅需要使用Linux提供的互斥量mutex也称互斥锁即可。在我们的代码中if判断语句中多次访问了临界资源因此根据临界区的定义可知临界区的范围如下因此保护临界资源的本质就是保护临界区保护临界区的手段就是利用互斥也就是利用锁。1.3、互斥量的相关接口上文我们简单引出了互斥量的相关话题接下来我们将简单介绍互斥量的相关接口。相关接口都包含在pthread.h中1️⃣初始化互斥量初始化互斥量有两种方法一种是静态分配也就是利用宏来初始化pthread_mutex_tmutexPTHREAD_MUTEX_INITIALIZER另一种则是动态分配。intpthread_mutex_init(pthread_mutex_t*restrict mutex,constpthread_mutexattr_t*restrict attr);其中mutex参数就是我们要初始化的互斥量attr是对应互斥量的状态一般我们不必理会将它设为NULL即可。2️⃣销毁互斥量intpthread_mutex_destroy(pthread_mutex_t*mutex)其中mutex参数就是我们要销毁的互斥量。在销毁互斥量的时候我们要注意以下三点使用静态分配初始化的互斥量不需要销毁。不要销毁一个已经加锁的互斥量。已经销毁的互斥量要确保后面不会有线程再尝试加锁。3️⃣加锁与解锁intpthread_mutex_lock(pthread_mutex_t*mutex);// 加锁intpthread_mutex_unlock(pthread_mutex_t*mutex);// 解锁当加锁或解锁成功返回0失败返回对应的错误号。简单介绍相关接口后接下来我们对先前的抢票系统进行优化。可以发现通过加锁等操作的确避免了数据不一致问题。但是从运行结果来看为什么抢到票的都是同一个线程呢要回答这个问题就必须等到下一节讲同步话题地时候了。此外关于互斥量相关的接口还有五个相关的细节问题问题1️⃣加锁的原则问题由于互斥量的特性同一时间段有且只能有一个线程进入临界区因此线程进入临界区后就会由并行转为串行。这样必然会导致效率降低而这样的操作又是无法避免的因此在实践中加锁的粒度必须足够的细。问题2️⃣mutex也是共享资源那么谁来保护它呢在我们先前的测试代码当中我们使用的时静态分配即定义了一个全局变量但是全局变量不就是一个典型的共享资源吗所有的线程都可以去使用既然它来保护别人那么谁来保护它呢实际上当年互斥量的设计师们也考虑过这个问题为了避免mutex还需要被保护于是就将加锁和解锁操作设计为原子性的也就是说加锁或者解锁这两个操作是一步到位的并不会因为CPU的调度而造成我们先前所讲的线程安全问题。问题3️⃣一些线程遵守先加锁再解锁而有一些线程不遵守呢这种情况基本上是不会发生的除非有人故意写bug。访问临界资源所有的线程都必须遵守加锁和解锁的规则绝不能有例外这是一个共识。问题4️⃣申请锁失败的线程在做些什么呢一般多线程同时申请互斥量但是只会有一个申请成功没有竞争到互斥量的线程会在pthread_ lock陷入阻塞(执行流被挂起)等待互斥量解锁。问题5️⃣临界区内部也会有多行代码那么会发生线程切换嘛当然即使是执行到加锁解锁操作内部也会发生线程切换。因为临界区本质是人为规定的一个概念而在CPU眼中本质上都是代码。因此不管是不是在临界区都会发生线程切换。1.4、互斥量的原理首先我们得了解一个知识点如果一个语句只有一行汇编代码就可以表示那么执行该语句就是原子的。或者简单来说一条汇编语句操作是原子的。互斥锁的加锁解锁操作的汇编伪码如下图其中lock的第一行就是将某一个寄存器存入0而第二行则是整个加锁逻辑中最关键的一行它将寄存器中的值与内存中的mutex值进行了交换我们知道CPU在调度线程的时候是以线程为载体执行的加锁逻辑。当寄存器的值与内存中mutex的值交换过后原本mutex的值就归属于当前线程了即使交换后立刻就发生了线程调度该值也会变成硬件上下文一直跟着这个线程。而交换后的mutex在内存中存储的值则会一直为零后来的线程执行到交换语句后也只会0与0交换完全没有任何影响。实际上锁就是我们上文中所谈到的1exchange始终没有拷贝也就是说始终只有一个1谁拥有这个1谁就拥有这个锁再回看先前的问题5️⃣临界区内部也会有多行代码那么会发生线程切换嘛现在来看当然不会锁只有一份只要线程1不还回来锁就属于线程1私有线程切换不影响。综上互斥锁的本质就是由1至0的过程互斥的本质就是独占除此以外锁的实现方式是多种多样的除了上述利用软件的方式实现互斥锁我们还可以利用硬件方式。互斥锁的存在本质就是让临界区中只存在一个线程那么如果当某一个线程执行到临界区的代码时立刻就将时钟中断关闭此时操作系统就无法再进行调度了线程则无法切换这样就无人能够打扰该线程执行临界区的代码了。这个操作在逻辑上一定是行得通的但是一定不建议这么做时钟中断可以说是操作系统的灵魂这种“触及灵魂”的事是具有极大风险的。之所以说这种实现方式只是想说明一个结论锁的实现是多种多样的1.5、互斥量的封装C中也有互斥量相关的接口只不过是利用面向对象的方式将他们封装起来了。现在我们也利用面向对象的方式将互斥量接口封装。classMutex{public:Mutex(){pthread_mutex_init(lock,nullptr);}voidLock(){pthread_mutex_lock(lock);}voidunLock(){pthread_mutex_unlock(lock);}~Mutex(){pthread_mutex_destroy(lock);}private:pthread_mutex_t lock;};此外我们亦可以按照RAII风格进行进一步封装。补充RAII是C中一种非常重要的编程惯用法核心思想是资源的获取与对象的初始化绑定资源的释放与对象的析构绑定从而利用 C 对象生命周期构造、析构的自动管理机制来安全、简洁地管理资源如动态内存、文件句柄、锁、套接字等。#includepthread.hclassMutex{public:Mutex(){pthread_mutex_init(lock,nullptr);}voidLock(){pthread_mutex_lock(lock);}voidunLock(){pthread_mutex_unlock(lock);}~Mutex(){pthread_mutex_destroy(lock);}private:pthread_mutex_t lock;};classLockGuard// RAII风格{public:LockGuard(Mutexlock)//引用了传进来的参数:_lock(lock){_lock.Lock();}~LockGuard(){_lock.unLock();}private:Mutex_lock;//引用的引用了};我们依旧用抢票程序进行测试完