一、线程互斥
1.1 相关概念介绍
临界资源: 多线程执行流共享的资源叫做临界资源
临界区: 每个线程内部访问临界资源的代码,被称为临界区
互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用
原子性: 不会被任何调度机制打断的操作,该操作只有两态:要么完成,要么未完成
下面模拟实现一个抢票系统,将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程进行抢票,当票被抢完后这四个线程自动退出
#include <iostream> #include <string> #include <pthread.h> #include <unistd.h> using namespace std; const int thread_num = 4; int tickets = 1000; void* GetTickets(void* args) { while (true) { if (tickets > 0) { usleep(10000);//抢票所耗费的时间 printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets); } else { break; } } printf("%s quit!\n", (char*)args); pthread_exit((void*)0); } int main() { pthread_t tids[thread_num]; pthread_create(tids, nullptr, GetTickets, (void*)"thread 1"); pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2"); pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3"); pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4"); for(int i = 0;i < thread_num; ++i) { pthread_join(tids[i], nullptr); } return 0; }
运行结果显然不符合预期,最终票数变为了负数
票数为负原因:
if语句判断条件为真以后,代码可以切换到其他线程,usleep用于模拟漫长业务的过程,在这个业务过程中可能有线程会进入该代码段
--tickets操作并不是原子的
--tickets操作
对一个变量进行--,实际需要三个步骤:
load:将共享变量tickets从内存加载到寄存器中
update:更新寄存器里面的值,执行-1操作
store:将新值从寄存器写回共享变量tickets的内存地址
-- 操作对应的汇编代码如下:
-- 操作需要三个步骤才能完成,有可能当thread1刚把tickets的值读进CPU寄存器就被切走了,假设此时thread1读取到的值是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文数据,因此需要被保存起来,之后thread1就被挂起了
假设此时thread2被调度,由于thread1只执行了 -- 操作的第一步,因此thread2此时在内存中看到tickets的值仍是1000,假设系统给thread2的时间片可能较多,thread2一次性执行了100次 -- 操作才被切走,最终tickets由1000减到了900
此时系统再把thread1恢复上来,继续执行thread1的代码并且将thread1曾经的硬件上下文信息恢复,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行 -- 操作的第二步和第三步,最终将999写回内存
此时,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。 -- 操作并不是原子的,虽然--tickets看起来就是一行代码,但这行代码被编译器编译后本质上是三行汇编;相反,对一个变量进行++也需要对应的三个步骤,即++操作也不是原子操作
1.2 互斥量mutex
若线程使用的数据是局部变量,变量的地址空间在线程栈空间内,变量归属单个线程,其他线程无法获得这种变量;但有些变量需要在线程间共享(共享变量),可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量就会带来一些问题
要解决上述抢票系统的问题,需要做到三点:
代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
若多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区
若线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
这时就需要一把锁,Linux中提供的这把锁被称为互斥量
1.3 互斥量接口
1.3.1 初始化互斥量
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:需要初始化的互斥量的地址
attr:初始化互斥量的属性,一般设置为nullptr即可
返回值:互斥量初始化成功返回0,失败返回错误码
使用pthread_mutex_init()函数初始化互斥量的方式被称为动态分配,还可以使用静态分配进行初始化,即下面这种方式:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
1.3.2 销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数mutex:需要销毁的互斥量的地址
返回值:互斥量销毁成功返回0,失败返回错误码
注意:
使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
1.3.3 互斥量加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数mutex:需要加锁的互斥量的地址
返回值:互斥量加锁成功返回0,失败返回错误码
注意:
互斥量处于未锁状态时,该函数会将互斥量锁定,同时返回成功
发起函数调用时,若其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么线程会在pthread_mutex_lock()函数内部阻塞至互斥量解锁
1.3.4 互斥量解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数mutex:需要解锁的互斥量的地址
返回值:互斥量解锁成功返回0,失败返回错误码
1.3.5 使用案例
在上述的抢票系统中引入互斥量,以解决打印错乱和票数为负的问题:
#include <iostream> #include <string> #include <pthread.h> #include <unistd.h> using namespace std; const int thread_num = 4; int tickets = 1000; pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; void* GetTickets(void* args) { while (true) { pthread_mutex_lock(&mtx); if (tickets > 0) { usleep(1000);//抢票所耗费的时间 printf("[%s] get a ticket, left: %d\n", (char*)args, --tickets); pthread_mutex_unlock(&mtx); usleep(10);//避免全部为同一线程抢占锁 } else { pthread_mutex_unlock(&mtx); break; } } printf("%s quit!\n", (char*)args); pthread_exit((void*)0); } int main() { pthread_t tids[thread_num]; pthread_create(tids, nullptr, GetTickets, (void*)"thread 1"); pthread_create(tids + 1, nullptr, GetTickets, (void*)"thread 2"); pthread_create(tids + 2, nullptr, GetTickets, (void*)"thread 3"); pthread_create(tids + 3, nullptr, GetTickets, (void*)"thread 4"); for(int i = 0;i < thread_num; ++i) { pthread_join(tids[i], nullptr); } return 0; }
在大部分情况下,加锁本身都是有损于性能的,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的
应该在合适的位置进行加锁和解锁,减小锁的粒度,可以减少加锁带来的性能开销成本
进行临界资源的保护,是所有执行流都应该遵守的标准,程序员在编码时需要注意
1.4 互斥量实现原理
加锁后的原子性如何体现?
引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。
例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态(线程1持有锁)时也就被阻塞了。此时对于线程2、3、4而言,线程1的整个操作过程是原子的
临界区内的线程可能被切换吗?
临界区内的线程是可能进行线程切换。但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
互斥锁是否需要被保护?
多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,只需要保证申请锁的过程是原子的,那么锁就是安全的
如何保证申请锁是原子的?
为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是把寄存器和内存单元的数据相交换。由于只有一条指令,保证了原子性。
lock和unlock的伪代码:
可以认为mutex的初始值为1,al是计算机中的一个寄存器
当线程申请锁时,需要执行以下步骤:
先将al寄存器中的值清0
然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换
最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁
例如,此时内存中mutex的值为1,线程申请锁时先将al寄存器中的值清0,然后将al寄存器中的值与内存中mutex的值进行交换
交换完成后检测该线程的al寄存器中的值为1,则该线程申请锁成功,可以进入临界区对临界资源进行访问。而此后的线程若是再申请锁,与内存中的mutex交换得到的值就是0了,此时该线程申请锁失败,需要被挂起等待,直到锁被释放后再次竞争申请锁。
当线程释放锁时,需要执行以下步骤:
将内存中的mutex置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,即"将锁的钥匙放回去"
唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁
注意:
在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的
在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令
CPU内的寄存器每个线程使用的都是同一套。当线程被调度时,会将上下文数据加载到寄存器中;当发生线程切换时,会将上下文数据保存,以便下次被调度时可以将上下文数据重新加载到寄存器中











