一、Linux线程的互斥
1、互斥的相关背景
我们先来看一段多线程抢票的代码,票数有10000张,共有4个线程
#include <iostream> #include <cstdio> #include <cstring> #include <pthread.h> #include <unistd.h> using namespace std; // 票数 int tickets = 10000; void* threadRoutine(void* args) { char* s = static_cast<char*>(args); while (true) { if (tickets > 0) { usleep(2000); //抢票花费的时间 cout << s << " get a ticket, surplus number is :" << --tickets << endl; } else { break; } } cout << "The tickets are sold out" << endl; return s; } int main() { pthread_t tname[4]; int n = sizeof(tname) / sizeof(tname[0]); // 创建线程抢票 for (int i = 0; i < n; i++) { char* str = new char[64]; snprintf(str, sizeof(str), "线程-%d", i); pthread_create(tname + i, nullptr, threadRoutine, str); usleep(2000); } // 回收线程以及内存 void* ret = nullptr; for (int i = 0; i < n; i++) { int error = pthread_join(tname[i], &ret); if (error == 0) { delete[] (char*)ret; } else { cerr << strerror(error) << endl; } } return 0; }
运行结果:
我们看到抢票时把票数抢到了负数,这是为什么呢,我们一起来分析一下:
要解决上述抢票系统的问题,需要做到三点:
- 代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁,Linux
上提供的这把锁叫互斥量。
2、互斥量的接口
①初始化互斥量
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
- mutex:要初始化的互斥量的地址 。
- attr: 初始化互斥量的属性,一般设置为NULL即可 。
返回值说明:
- 互斥量初始化成功返回0,失败返回错误码。
pthread_mutex_t
是一种类型,可以用来定义一把互斥锁。- 静态分配的的互斥锁,不需要销毁,但是必须定义在全局。
②销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数说明:
- mutex:需要销毁的互斥量的地址。
返回值说明:
- 互斥量销毁成功返回0,失败返回错误码。
销毁互斥量需要注意:
- 使用
PTHREAD_ MUTEX_ INITIALIZER
初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
③互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数说明:
- mutex:需要加锁的互斥量的地址。
返回值说明:
- 互斥量加锁成功返回0,失败返回错误码。
调用pthread_mutex_lock
时,可能会遇到以下情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么
pthread_mutex_lock
调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
有了这些知识,我们就可以解决上面的问题了,我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程可能申请到锁。
#include <iostream> #include <cstdio> #include <cstring> #include <pthread.h> #include <unistd.h> using namespace std; int tickets = 10000; // 定义一把锁 pthread_mutex_t mutex; void* threadRoutine(void* args) { char* s = static_cast<char*>(args); while (true) { // 加锁 pthread_mutex_lock(&mutex); if (tickets > 0) { usleep(1000); //抢票花费的时间 cout << s << " get a ticket, surplus number is :" << --tickets << endl; // 解锁 pthread_mutex_unlock(&mutex); } else { // 解锁 pthread_mutex_unlock(&mutex); break; } // 抢票以后的后续的处理 usleep(1000); } cout << "The tickets are sold out" << endl; return s; } int main() { // 对锁进行初始化 pthread_mutex_init(&mutex, nullptr); pthread_t tname[4]; int n = sizeof(tname) / sizeof(tname[0]); for (int i = 0; i < n; i++) { char* str = new char[64]; snprintf(str, 64, "线程-%d", i); pthread_create(tname + i, nullptr, threadRoutine, str); usleep(1000); } // 回收线程以及内存 void* ret = nullptr; for (int i = 0; i < n; i++) { int error = pthread_join(tname[i], &ret); if (error == 0) { delete[] (char*)ret; } else { cerr << strerror(error) << endl; } } // 销毁锁 pthread_mutex_destroy(&mutex); return 0; }
运行结果正常:
- 此外加锁本身都是有损于性能的事,它让多执行流由并行执行变为了串行执行,这是不可避免的。
- 我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。
- 进行临界资源的保护,是所有执行流都应该遵守的标准,这是在编码时需要注意的。
3、互斥量实现原理探究
- 单纯的
i++
或者++i
都不是原子的,有可能会有数据一致性问题。
例如:取出ticket- -部分的汇编代码
objdump -d a.out > test.objdump 152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket> 153 400651: 83 e8 01 sub $0x1,%eax 154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>
- - ,++操作并不是原子操作,而是对应三条汇编指令:
- load:将共享变量ticket从内存加载到寄存器中
- update: 更新寄存器里面的值,执行-1/+1操作
- store:将新值,从寄存器写回共享变量ticket的内存地址
- 为了实现互斥锁操作,大多数体系结构都提供了
swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
现在我们把lock
和unlock
的伪代码改一下。
我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:
- 先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(用来存储线程上下文信息),执行该动作本质上是将线程自己的al寄存器清0。
- 然后交换al寄存器和
mutex
中的值。xchgb
(exchange)是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换。 - 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。
当一个线程申请锁成功以后,其他线程再进行申请时,由于mutex里面是0
,al里面再进行交换拿到的依然是0
,继续向后执行时会被挂起。
当线程释放锁时,需要执行以下步骤:
- 将内存中的
mutex
置回1。使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是“将锁放回去”。 - 唤醒等待
mutex
的线程。唤醒因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。
注意点:
- 在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。
- 在申请锁时本质上就是哪一个线程先执行了交换指令,那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
- CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。
问题1:临界区内的线程可能进行线程切换吗?
临界区内的线程完全有可能进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问,因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。
问题2:锁是否需要被保护?
我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。
既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
锁实际上是自己保护自己的,因为申请锁的过程是原子的,那么锁就是安全的。