【Linux】线程互斥
一、互斥概念大部分环境,线程使用的数据都是局部变量,变量的地点空间在线程栈空间内,这种环境,变量归属单个线程,其他线程无法获得这种变量。但偶然候,许多变量都需要在线程间共享,如许的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操纵共享变量,会带来一些问题。
例如下面我们模拟一个多线程抢票的步伐。使用一个全局变量 ticket 表示票的数目,创建多个线程举行抢票,代码如下:
#define NUM 5
int ticket = 100;
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
void* getTicket(void* args)
{
threadData *td = static_cast<threadData*>(args);
while(1)
{
if(ticket > 0)
{
usleep(1000); // 模拟抢票的时间
printf("%s is running, get a ticket: %d\n", td->threadname.c_str(), ticket);
ticket--;
}
else
break;
}
return nullptr;
}
int main()
{
vector<pthread_t> tids;
vector<threadData*> thread_datas;
for(int i = 1; i <= NUM; i++)
{
threadData* td = new threadData(i);
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, td);
tids.push_back(tid);
thread_datas.push_back(td);
}
for(auto e : tids)
pthread_join(e, nullptr);
for(auto td : thread_datas)
delete td;
return 0;
}
我们运行起来之后,会看到线程抢到了负数的票!
https://i-blog.csdnimg.cn/blog_migrate/9232047ad77f9113acee9fa6bfd3709d.png
为什么会出现这种环境呢?这种环境我们称为共享数据在无保护的环境下,被多线程并发访问,造成了数据不一致问题!所以对于一个全局变量举行多线程并发减减或者加加,不是安全的!下面我们来分析一下。
首先需要对 ticket- -,先要将 ticket 读入到 CPU 的寄存器中,然后在 CPU 中要举行计算操纵,末了再将 ticket 数据写回内存中。至此就完成了一次 ticket- -,所以上面三个步调,都会对应每一个汇编语句。
那么假设我们如今有两个线程,分别为线程1和线程2,在线程实验的代码间隙中,线程是随时有大概会被切换的!而线程在实验的时候,将共享数据加载到 CPU 寄存器的本质就是把数据的内容变成了自己上下文的内容!也就是以拷贝的方式给自己单独拿了一份!
https://i-blog.csdnimg.cn/blog_migrate/95628ad48b538073040b54573c327ba8.png
那么如果线程1刚好读取到内存中的数据,假设此时数据照旧100,此时它要被切换了,那么它就要把自己上下文数据保存起来,而保存上下文的本质就是以拷贝的方式,给自己单独拿了一份!那么此时线程1就把100保存到自己的上下文中了。
https://i-blog.csdnimg.cn/blog_migrate/2a540ffc34460185e867386c73c45566.png
接下来线程2就开始抢票了,此时线程2在它的时间片内已经抢了99张票了!此时内存中只剩下一张票!
https://i-blog.csdnimg.cn/blog_migrate/047d544a7eb87ba7a09793eb58f56184.png
那么当线程2切换后,线程1继续拿着它的上下文数据放回CPU中计算,留意,此时线程1中的 ticket 照旧100,那么计算完后为99,再将 99 写回内存中!此时就导致了 ticket 的数据不一致问题!所以 ticket- - 操纵是不安全的!也就是它不具备原子性!
https://i-blog.csdnimg.cn/blog_migrate/e12ebfbbaca62e86c2a0933527b7f84d.png
另外,我们不仅仅在对 ticket- -,这种叫做数值计算,而且还在对 ticket 做判断是否大于0,这个过程也是在对 ticket 计算,这种叫做逻辑运算!所以,假设当前 ticket 为1了,在判断期间,大概会有多个线程在举行判断!因为一个线程在判断的期间有大概会被切走!此时它们每一个线程的上下文中都认为 ticket 是1,所以会将 ticket 减到负数!而且判断完毕之后,ticket 就不会被用了,在计算 ticket- - 的时候要重新到内存中读取数据!
那么这个问题要怎么解决呢?对于共享数据的访问,需要包管任何时候只有一个实验流访问,这就是互斥!所以我们需要通过互斥的方式来解决,也就是互斥锁!接下来我们就开始学习互斥锁。
二、互斥锁
1. 互斥锁接口
在 Linux 中,pthread 库给我们提供了一种互斥锁解决上面多线程访问共享数据不一致的问题。接下来我i们认识一下互斥锁的相干接口:
[*] pthread_mutex_init()
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
该接口是对一把锁初始化。我们可以看到第一个参数的范例是 pthread_mutex_t,这是库给我们提供的一种数据范例!第一个参数是输入型参数,我们定义一个 pthread_mutex_t 范例的锁传入它的地点即可;第二个参数代表这把锁的属性,我们也不管,设置为 nullptr 即可。
其实,初始化一把锁有两种方式,以上是一种方式,下面另有一种方式是定义一把全局的锁,如果我们使用下面的方法定义了一把锁,就不需要使用上面的方式了;而且也不用开释这把锁了,但是开释也没有问题。其中定义全局锁是固定的,如下:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
[*] pthread_mutex_destroy()
int pthread_mutex_destroy(pthread_mutex_t *mutex);
该接口是开释一把锁。第一个参数和初始化时的第一个参数一样。留意如果我们使用 pthread_mutex_init() 的方式初始化一把锁,必须要使用 pthread_mutex_destroy() 举行开释;但是使用全局锁就可以不用开释。
[*]pthread_mutex_lock()
该接口就是对一把锁举行加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
[*]pthread_mutex_unlock()
该接口就是对一把锁举行解锁:
int pthread_mutex_unlock(pthread_mutex_t *mutex);
2. 使用接口以及说明问题
下面我们就使用上面抢票的代码举行加锁,对 ticket 加锁。根据我们从前的知识,这个 ticket 就是临界资源,而临界资源并不是全部代码都在访问,而是只有一小部分在访问,我们就把这一小部分的代码的区域称为临界区。其实加锁的本质是用时间来调换安全,加锁的表现就是线程对于临界区代码需要串行实验,也就是类似于列队,所以加锁的原则就是只管要包管临界区代码越少越好!所以上述的代码中,临界区应该是对 ticket 举行访问的区域!如下代码:
首先我们在类中定义一把锁,方便每个线程都有自己的锁:
class threadData
{
public:
threadData(int number, pthread_mutex_t* lock)
:_lock(lock)
{
_threadname = "thread-" + to_string(number);
}
public:
string _threadname;
pthread_mutex_t* _lock;
};
接下来我们在主函数中定义一把锁,留意,这里定义的锁,是在 main() 函数的栈帧中的,也就是主线程中的,由于我们抢票的步伐也在主函数中,所以如许定义不会有问题;末了在主函数返回前开释锁,代码如下:
int main()
{
pthread_mutex_t lock;
pthread_mutex_init(&lock, nullptr);
vector<pthread_t> tids;
vector<threadData*> thread_datas;
for(int i = 1; i <= NUM; i++)
{
threadData* td = new threadData(i, &lock);
pthread_t tid;
pthread_create(&tid, nullptr, getTicket, td);
tids.push_back(tid);
thread_datas.push_back(td);
}
for(auto e : tids)
pthread_join(e, nullptr);
for(auto td : thread_datas)
delete td;
pthread_mutex_destroy(&lock);
return 0;
}
接下来是抢票的步伐:
void* getTicket(void* args)
{
threadData *td = static_cast<threadData*>(args);
while(1)
{
pthread_mutex_lock(td->_lock);
if(ticket > 0)
{
usleep(1000); // 模拟抢票的时间
printf("%s is running, get a ticket: %d\n", td->_threadname.c_str(), ticket);
ticket--;
pthread_mutex_unlock(td->_lock);
usleep(10);
}
else
{
pthread_mutex_unlock(td->_lock);
break;
}
}
cout << td->_threadname.c_str() << " quit!" << endl;
return nullptr;
}
如上代码,在加锁息争锁锁的区域中,就称为临界区。其中,实验流在申请锁的时候,如果申请锁乐成,才能往后实验后面的代码,如果不乐成,就会阻塞等待!
实验的结果如下:
https://i-blog.csdnimg.cn/blog_migrate/e0340a74623b03dc157a10099e56ca8c.png
我们可以看到,ticket 不会出现 0 和负数的环境了,也就是说,临界资源被并发访问导致数据不一致问题已经解决了!
但是代码中有些细节我们还需要讲解一下。
[*] 在抢票的步伐中,我们可以看到,在一个线程抢完票后,解锁后,我们在其后面加了一句 usleep(10);,这是什么意思呢?很简单,当一个线程加锁后,别的线程就被阻塞等待挂起了,那么当该线程解锁时,别的线程还没来得及从阻塞状态转为运行状态,该线程又去申请锁了,也就是说,唤醒线程的成本更大;而且,我们抢完票后另有后续的代码需要实验,比如处置惩罚票的后续动作,这里我们就没有实现。也就是说,所以我们在一个线程解锁后,加上短暂的休眠时间,一是为了偶然间唤醒别的线程,二是为了模拟抢票后的后续动作。
[*] 如果我们没有加上 usleep(10); 这句代码,那么该线程就会不停占用这把锁,所以导致票就被它抢完了。那么也就是说,这种纯互斥环境,如果锁分配不敷合理,轻易导致别的线程的饥饿问题!但是不是说只要有互斥,必有饥饿,而是适合纯互斥的场景,就用互斥!
[*] 新来的线程,必须要从等待队列的末了开始列队;解锁的线程,不能马上重新申请锁,必须也要从等待队列的末了开始列队。这就可以让所有的线程获取钥匙,按照一定的次序,这种按照一定次序性获取资源的称为同步,这个我们后面详谈。
[*] 每一个线程进入临界区访问临界资源的时候,首先需要申请加锁,所以锁自己就是共享资源,也就是临界资源!所以申请加锁息争锁自己就被计划为原子性的操纵了!如何做到的呢?我们后面讲原理再谈。
[*] 那么在临界区中,线程可以被切换吗?可以切换!因为在线程被切出去的时候,是持有锁被切走的,所以在该线程被切换的时候,其他线程也不能进临界区访问临界资源,因为锁只有一把!所以对于别的线程来说,一个线程要么没有锁,要么开释锁,当火线程访问临界区的过程,对于别的线程是原子的!
3. 锁的原理
我们已经知道,ticket- - 不是原子的,因为这个操纵会被分为三个汇编语句,那么什么是原子的呢?在计算机底层,我们认为,一条汇编语句就是原子的!
为了实现互斥锁操纵,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据交换,由于只有一条汇编指令,包管了原子性。如今我们把 lock 和 unlock 的伪代码演示一下:
https://i-blog.csdnimg.cn/blog_migrate/e32576a1a947d0a46234c2f7f941414b.png
首先 movb 就是把 0 写入 al 寄存器,可以理解为 eax 寄存器:
https://i-blog.csdnimg.cn/blog_migrate/c379b2d5789c11b92ff7a3224a279472.png
接下来 xchgb 就是将 al 寄存器中的内容和内存中定义的一个变量 mutex 举行交换,其中 mutex 的初始值为1:
https://i-blog.csdnimg.cn/blog_migrate/402c0e47b5c1784021295365afd5e653.png
接下来对 al 寄存器中的值举行判断,如果大于 0,说明申请加锁乐成,否则申请加锁失败,挂起等待。
上面我们演示的都是一个线程来申请加锁,如果有两个线程来申请加锁呢?例如,线程1和线程2来申请加锁,而加锁的语句是一句,但是它被分为上面多个汇编语句,所以当一个线程实验到某一个汇编语句的时候,随时都有大概被切换!
假设线程1申请加锁的过程中,刚刚实验完第一步,即将 0 写入了 al 寄存器中,实际上是写入线程的硬件的上下文中。此时线程2来了,线程1要被切走,所以线程1将 al 寄存器中的内容保存起来,即将 0 保存起来,当切换回来的时候实验 xchgb 语句。
https://i-blog.csdnimg.cn/blog_migrate/674021394891507ab7ef824d15b8bd62.png
线程2来的时候,再次将0写入 al 寄存器中,然后实验xchgb语句,将 al 寄存器中的内容和内存的内容交换,交换完成后,al 寄存器中的内容变成1,线程2中的上下文内容也变成1,正常来说线程2此时做判断,此时al寄存器的值大于0,所以可以直接返回。
https://i-blog.csdnimg.cn/blog_migrate/081e82d9752dd8f22c44fd42e2362324.png
但是如果在线程2做判断的时候,线程2需要被切走,线程1切回来,首先先要将上下文规复回来,此时将 al 寄存器中的内容规复成为0,然后和内存中的值交换,交换完后发现 al 寄存器中的值为 0,此时线程1就被挂起等待了。
https://i-blog.csdnimg.cn/blog_migrate/eac59f4f23bd5d849114ca02e604c0d2.png
线程1被挂起等待后不会被调理,所以此时线程2被切回来,规复上下文,把1放回al寄存器中,然后做判断,大于0,申请加锁乐成,返回0。
所以从上面的过程我们可以看出,其实 xchgb 的语句最重要。交换的本质就是把内存中的数据,交换到CPU的寄存器中,也就是将数据交换到线程的上下文中!而线程上下文是线程私有的!另外,内存中的数据是被所有线程共享的,而锁只有一把,所以申请加锁的本质就是把一把共享的锁,让一个线程以一条汇编的方式,交换到自己的上下文中,就代表当火线程持有锁了!
那么解锁的汇编语句如下:
https://i-blog.csdnimg.cn/blog_migrate/1860be62e11d3d94c32bb0e17d02ee76.png
其实就是将内存中的数据重置为1即可。并没有将上一次线程申请加锁的 1 交换回内存中,因为并不需要,因为每一个线程在申请加锁的时候首先需要将 0 写入 al 寄存器中,也就是写入自己的硬件上下文中,此时就相当于将原来申请过加锁的 1 覆盖掉。
三、可重入和线程安全
[*]概念
[*]线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量举行操纵,并且没有锁保护的环境下,会出现该问题;
[*]重入:同一个函数被不同的实验流调用,当前一个流程还没有实验完,就有其他的实验流再次进入,我们称之为重入。一个函数在重入的环境下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
也就是说,如果一个函数不可重入,那么在多线程实验时,大概会出现线程安全问题。如果一个函数可被重入的,那么就一定不会出现线程安全问题。
[*]可重入与线程安全接洽
[*]函数是可重入的,那就是线程安全的;
[*]函数是不可重入的,那就不能由多个线程使用,有大概引发线程安全问题,如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
[*]可重入与线程安全区别
[*]可重入函数是线程安全函数的一种;
[*]线程安全不一定是可重入的,而可重入函数则一定是线程安全的;
[*]如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未开释则会产存亡锁,因此是不可重入的。
末了总结就四个结论:
[*]线程安全形貌的是并发的问题
[*]可重入形貌的是函数特点的问题
[*]不可重入函数在多线程访问时,大概会出现线程安全问题
[*]可重入函数在多线程访问时,不会有线程安全问题
四、死锁
1. 死锁概念
死锁是指在一组实验流中的一个线程持有一把锁,另一个线程持有另一把锁,但因互相申请对方的锁,并不开释自己的锁而处于的一种永世等待状态。
2. 死锁的必要条件
首先我们了解一下什么叫做死锁的必要条件,也就是只要产生了死锁,必定所有的条件都要满意。也就是以下四个条件都要满意:
[*]互斥条件:一个资源每次只能被一个实验流使用
[*]请求与保持条件:一个实验流因请求资源而阻塞时,对已获得的资源保持不放
[*]不剥夺条件:一个实验流已获得的资源,在末使用完之前,不能强行剥夺
[*]循环等待条件:若干实验流之间形成一种头尾相接的循环等待资源的关系
3. 避免死锁
[*]破坏死锁的四个必要条件之一
[*]加锁次序一致
[*]避免锁未开释的场景
[*]资源一次性分配
https://i-blog.csdnimg.cn/blog_migrate/119c94949327e57e67239eacbcea6b10.png
其实我们除了 pthread_mutex_lock() 加锁之外,另有 pthread_mutex_trylock(),也就是申请锁失败会立即返回,不会阻塞等待。所以我们申请锁的时候也可以使用 pthread_mutex_trylock() 避免死锁,也就是破环请求与保持条件。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页:
[1]