线程的大部分资源是共享的,包括定义的全局变量等等,全局变量是能够让全部线程共享的。大部分资源共享带来的优点是通信方便,缺点是缺乏访问控制,同时如果因为一个线程的操作问题,给其它线程造成了不可控,或者引起崩溃异常,逻辑不正确等现象,就会造成线程安全问题!所有需要进行后续的访问控制:同步与互斥!
先来一些概念:
1.临界资源:凡是被线程共享访问的资源都是临界资源。比如说打印数据到显示器,显示器就是一个临界资源。
2.临界区:我们写的代码中,访问临界资源的那段代码称为临界区。
3.需要对临界区进行保护,本质是对临界资源的保护。方法同步和互斥。
4.互斥:在任意时刻,只允许一个执行流访问某段代码(访问某部分资源),称之为互斥。
5.原子性:如果需要执行printf("hello world");访问临界资源(显示器),为了安全,加上互斥锁:lock();printf();unlock();在加上锁到解锁的这段过程内,只能执行锁内的代码,并且要么不执行,要么就一次执行完毕!
6.同步:一般而言,让访问临界资源的过程在安全的前提下(这个前提一般是互斥和原子性),让访问资源的执行流具有一定的顺序性!
互斥量mutex
多线程并发操作带来的问题
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量,但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互,多个线程并发的操作共享变量,会带来一些问题。下面用示例代码举个例子:
写一个测试代码:操作共享变量会有问题的售票系统代码。出售1千张票,5个线程去抢这一千张,但是到最后会出现一个错误,出现了负数的票数!
#include <iostream> #include <string> #include <pthread.h> #include <unistd.h> //抢票逻辑,1000张票,5个线程同时抢 int tickets = 1000;//tickets是临界资源 void *ThreadRoutine(void *args) { int id = *(int*)args; delete (int*)args; while(true) { if(tickets > 0) { //抢票 usleep(1000); std::cout << "我是[" << id << "] 我要抢的票是: " << tickets << std::endl; tickets-- ; printf(""); } else { break; //没有票了 } } } int main() { //创建线程 pthread_t tid[5]; for(int i = 0;i<5;i++) { int *id = new int(i); pthread_create(tid+i,nullptr,ThreadRoutine,id); } //线程等待 for(int i = 0;i<5;i++) { pthread_join(tid[i],nullptr); } return 0; }
可以看到在代码中,全局变量tickets属于临界资源,多线程直接访问临界资源,出现问题了!下面分析一下问题:
tickets作为全局变量,保存在内存当中,而抢票的操作就一行的代码:tickets--;而这个操作并非原子的!
因为tickets--是运算,是运算就会在CPU中进行,在CPU中运算完成,就写回到内存当中,所以这一操作看起来就一行代码,但是在汇编语言中是多行代码。于是,当线程A准备执行tickets--,就被切换成线程B了,线程A切换时线程A的上下文就被保存起来,此时的tickets还是原来那个数值的。而此时是线程B被切换过来后,就不断地进行运算,假设线程B的优先级很高,直至把票数变成剩下10张,才被切换出去,再次切换成线程A。此时线程A保存的上下文继续上一次的操作,在CPU运算,然后写回内存,此时就会把原本内存中tickets为10的票数,变成999!这就是造成的问题的原因之一!
上面的过程,只是tickets--这一个操作,更何况还有判断票数的操作!
代码中的临界区,就是这一段代码:
总结一下无法获取正确结果的原因:
1、if 语句判断条件为真以后,代码可以并发的切换到其他线程
2、usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
3、ticket-- 操作本身就不是一个原子操作
要解决这个问题,就要多临界区进行加锁,这把锁就叫做互斥量。
互斥量接口
首先定义一个互斥量:
互斥变量使用特定的数据类型:pthread_mutex_t。
pthread_mutex_t mtx;
初始化互斥量
初始化互斥量有两种方法:
①静态分配。使用宏PTHREAD_MUTEX_INITIALIZER来初始化互斥量。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
②动态分配。
函数原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 参数: mutex:要初始化的互斥量 attr:对于这个参数我们直接设为nullptr即可
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
x销毁互斥量要注意以下三点:
1.使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
2.不要销毁一个已经加锁的互斥量
3.已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量的加锁和解锁
加锁:int pthread_mutex_lock(pthread_mutex_t *mutex); 解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex); 返回值:成功返回0,失败返回错误号
在调用 pthread_mutex_lock的时可能会遇到以下情况:
1.互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
2.发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
对上面的售票系统加锁改进:
#include <iostream> #include <string> #include <pthread.h> #include <unistd.h> //抢票逻辑,1000张票,5个线程同时抢 //定义一个锁的类 class Ticket{ private: int tickets; pthread_mutex_t mtx; public: Ticket() :tickets(1000) { pthread_mutex_init(&mtx,nullptr); } bool GetTicket() { //这里的bool变量不是被所有线程共享,是属于某个线程自己的,它是局部变变量 bool res = true; //加锁 pthread_mutex_lock(&mtx); //加锁后,执行这部分代码的执行流是互斥的,是串行执行的! if(tickets > 0) { //抢票 usleep(1000); std::cout << "我是[" << pthread_self() << "] 我要抢的票是: " << tickets << std::endl; tickets-- ; printf(""); } else { printf("票被抢空了\n"); res = false; //没有票了 } //解锁 pthread_mutex_unlock(&mtx); return res; } ~Ticket() { pthread_mutex_destroy(&mtx); } }; void *ThreadRoutine(void *args) { Ticket *t = (Ticket*)args; while(true) { if(!t->GetTicket()) { break; } } } int main() { Ticket* t = new Ticket(); //创建线程 pthread_t tid[5]; for(int i = 0;i<5;i++) { int *id = new int(i); pthread_create(tid+i,nullptr,ThreadRoutine,(void*)t); } //线程等待 for(int i = 0;i<5;i++) { pthread_join(tid[i],nullptr); } return 0; }
加锁之后,临界资源tickets就变得安全了。因此,在访问临界资源tickets前,需要先访问mtx,要访问mtx,前提是所有线程能够看得到mtx,这意味着,mtx这把锁本身也是一个临界资源!是临界资源就要受保护,必须有安全性,那么该如何保证锁本身的安全呢?接下来我们得去了解互斥量实现的原理!
互斥量实现原理
让一行代码拥有原子性,是让它的汇编只有一行!我们先记住这个点。
对于加锁lock和解锁unlock,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下:
lock: movb $0, %al xchgb %al, mutex if (al寄存器的内容 > 0) { return 0; } else { 挂起等待; } goto lock; unlock: movb $1, mutex 唤醒等待mutex的线程; return 0;
对锁的进一步理解:
在临界区中,可能会不止一行代码,而是会有很多行代码,比如上面售票系统的临界区。在一个线程在在临界区执行一半的时候,是有可能被切换的!但是在线程被切走的时候,它的上下文会被保存,并且锁的数据也会被保存在这个线程的上下文中!这代表着,这个拥有锁的线程被切走时,是带着锁走的!在此期间其它线程休想申请锁资源,休想进入临界区!这就保证了锁的作用,保证了线程安全!站在其它线程的视角来看,对它们有意义的状态,要么就是线程A没有申请锁,要么线程A申请锁后已经使用完了,那么其它线程就可以去竞争锁了!这就保证了线程访问临界区的原子性!
可重入与线程安全
概念
1.线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
2.重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
线程不安全的一些常见情况
1.不保护共享变量的函数。
2.函数状态随着被调用,状态发生变化的函数。
3.返回指向静态变量指针的函数。
4.调用线程不安全函数的函数。
线程安全的一些常见情况
1.每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作。
2.多个线程之间的切换不会导致该接口的执行结果存在二义性。
不可重入的一些常见情况
1.调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
2.调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
3.可重入函数体内使用了静态的数据结构。
可重入的一些常见情况
1.不使用全局变量或静态变量。
2.不使用用malloc或者new开辟出的空间。
3.不调用不可重入函数。
4.不返回静态或全局数据,所有数据都有函数的调用者提供。
5.使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
可重入与线程安全的关系
1.函数是可重入的,那就是线程安全的。
2.函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
3.如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入和线程安全的区别
1.可重入函数是线程安全函数的一种。
2.线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
3.如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的。