前言:本节内容主要是线程的同步与互斥。 本篇文章的主要内容都在讲解互斥的相关以及周边的知识。大体的讲解思路是通过数据不一致问题引出锁。 然后谈锁的使用以及申请锁释放锁的原子性问题。 那么, 废话不多说, 现在开始我们的学习吧!
ps:本节内容适合了解线程的相关概念的友友们进行观看哦
目录
数据不一致问题
锁
锁的使用
理解锁的竞争
申请锁与释放锁的原子性问题
数据不一致问题
我们之前写过g_val全局变量,也就是共享资源。 问题是,我们的线程在访问共享资源的时候, 会不会发生一个线程正在访问,但是另一个线程修改了共享资源的情况呢? 此时这个时候就可能造成因为共享导致的数据不一致问题!
我们这里写一个代码啊, 用来模拟一下多线程抢票的过程:
#include<iostream>
#include<cstdio>
#include<cstring>
#include<vector>
#include<unistd.h>
#include<pthread.h>
#include<string>
using namespace std;
int tickets = 1000;
#define NUM 4
//线程的属性描述方法
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
string threadname;
};
//多线程的执行代码
void* GetTicket(void* args)
{
threadData* td = static_cast<threadData*>(args);
//抢票
while(true)
{
if (tickets > 0)
{
usleep(1000);
cout << td->threadname << " get a tickets: " << tickets << endl;
tickets--;
}
else break;
}
//抢完票后退出
cout << "quit " << endl;
return nullptr;
}
int main()
{
//创建两个数组, 用来组织创建的多线程的pid和线程属性
vector<pthread_t> tids;
vector<threadData*>thread_datas;
//创建多线程
for (int i = 1; i <= NUM; i++)
{
//
pthread_t tid;
threadData* td = new threadData(i);
//创建多线程
pthread_create(&tid, nullptr, GetTicket, td);
tids.push_back(tid);
}
//等待多线程
for (auto e : tids)
{
pthread_join(e, nullptr);
}
//释放多线程的属性
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
这串代码的运行结果为:
我们可以看到,代码出现了负数的情况。 这是为什么呢?
我们从上面的知识就能知道, tickets是属于所有线程并发的共享变量。而这种票被减到负数的情况叫做共享数据在无保护的情况下被多线程并发访问, 造成了数据不一致问题。
什么是数据不一致问题呢? 首先我们知道, 数据不一致问题肯定是和多线程并发访问是有关系的, 那么假如我们的一个线程在tickets减减的时候其他的线程也来了, 就有可能造成数据不一致的问题, 我们下面就来理解数据不一致问题:
我们要理解上面的问题, 就要先谈一谈tickets--, 下面是cpu和内存:
首先我们定义全局变量, 不管我么如何定义全局变量, 这个全局变量的本质一定是在内存当中的。 就比如上面这个tickets。 假如一开始tickets是1000, 那么, 我们对tickets做减减操作, 它的本质就是在做计算。 在我们的整个计算机里面, 我们认为, 要在计算机里面做计算, 其实本质上就是在cpu里面做计算。 可是数据在内存中, 所以我们tickets的第一步就是线程tickets的数据读入到cpu的寄存器当中。然后第二步在cpu中做减减操作。 第三步再将数据写回内存。 ——这三步每一步都对应着一条汇编操作:1、move[XXX] eax; 2、 --; 3、move eax [XXX]
然后上面是单线程的情况, 那么多线程就是多个线程每一个线程都去做上面的三个步骤, 假如我们现在有两个线程:线程1和线程2
假如这是线程1, 一开始线程1要将内存中的数据读取到cpu当中。
读取完成之后,我们要知道, 任何一个线程, 在执行任何一个代码之后, 都有可能被切换(因为时间片的缘故)。 假如线程1刚刚执行完第一步, 正准备执行第二步的时候, 线程1就要被切走了, 那么他既然要被切走了, 那么就要把上下文也给带走。注意, 寄存器不等于寄存器的内容。 cpu的寄存器只有一套, 但是每一个线程在运行期间他都是要用cpu这一套寄存器。 但是当他走的时候要把寄存器里面保存的内容带走, 这叫保存上下文。 保存上下文的目的是为了恢复。 所以, 寄存器只有一套, 但是每个线程都有自己对应的寄存器对应的数据。 这个数据每个线程都有。
那么, 此时线程2来了:
线程2也要减减, 那么第一部数据加载到cpu, 第二步减减, 第三步cpu加载到内存。 问题是线程2很幸运地三步一口气执行完毕。 所以此时的tickets就变成999了。
而且, 假设线程2运气很好, 它重复减减, 一直到了tickets为10. 那么等到线程2再次进行--的时候, 刚刚加载到cpu, 就被切换走了。 所以此时线程2的上下文就是10.
但是线程1回来的第一件事情就是恢复上下文, 此时他认为cpu寄存器中应该是1000, 所以他就将数据加载到cpu。 然后--到999, 最后再加载到内存!
那么问题来了, 我们的线程2本来都已经减到10了, 但是线程1一下子又将数据变回来了。 ——这就是典型的数据不一致问题!
我们今天的抢票, 不仅仅是在减减, 而且还在进行判断。 所以呢, 为什么我们的抢票会出现负数? 我们知道, 判断其实就是运算, 叫做逻辑运算。 那么我们一个线程在逻辑运算的时候, 其他的线程可没可能也在进行逻辑运算呢? 答案是非常有可能。 所以就有一个情况——就是我们的线程1, 2, 3, 4同时都在进行判断, 并且此时的tickets为1. 然后, 他们都判断成功, 然后第一个线程减减到零。 第二个线程减减要先读取, 上一个已经减到零了, 所以他读取, 就是0减减, 然后得到-1. 第三个读到-1, 减减到-2, 第四个读到-2, 减减到-3. 所以就出现了图中的情况。 而这, 也就是数据不一致问题。(注意, 其实总结一下tickets--, 就是tickets--具备执行中的概念, 不是原子的!所以它可以正在--的时候被打扰。)
锁
锁使用解决数据不一致问题的。 下面我们来看一下锁的创建以及销毁:
我们所说的锁, 其实就是pthread_mutex_t类型的一种数据结构。 对应的, 有初始化这个数据结构的pthread_mutex_init函数, 有销毁这个数据结构的pthread_mutex_destroy函数。 其中, pthread_mutex_init的第一个参数是要初始化的锁对象, 第二个参数就是要设置的属性(后续我们会用init函数, 第二个参数我们本篇文章不关心)
我们在使用pthread_mutex_t的时候, 定义它有两种方案:第一种是直接定义成为全局的, 然后利用PTHREAD_MUTEX_INITIALIZER进行初始化(也可以使用init函数)、如果是一把常见的锁, 那么就只能使用init函数了。
现在我们来看一下具体的加锁函数:
pthread_mutex_lock这个函数就是利用当前的锁加上锁。 这个参数是锁对象的地址。 然后又lock, 就要有unlock, 也就是第三个函数是解锁。
关键是我们在哪里加锁——我们回忆一个问题, 就是一个tickets全局变量, 这个全局变量在线程中可以被叫做共享变量。 那么在并发访问时, 我们不想让他因为并发的原因出现问题, 所以我们就要对tickets的访问的地方加锁, 如果我们万一成功加锁了, 我们就把这个曾经被我们共享的全局变量叫做临界资源。 在我们的所有的代码当中, 是不是所有的代码, 都在访问临界资源呢? 答案是并不是, 我们的多线程在访问加锁是不是好的事情呢? ——加锁的本质其实就是让被加锁区域串行访问。 因为任何一个时刻只允许一个线程去访问这个代码区。 所以, 加锁的本质其实是利用时间换取安全。
而加锁的表现:线程对于临界区代码串行执行。 所以加锁的原则就是尽量的保证临界区代码越少越好!!
锁的使用
看下面的代码就是锁的一个使用例子。 这里使用的是我们上面的买票的代码。
void* getTicket(void* args)
{
threadData* td = static_cast<threadData*>(args);
const char* name = td->threadname.c_str();
while (true)
{
pthread_mutex_lock(td->lock); //加锁, 申请锁成功才能往后执行, 否则阻塞等待。
if (tickets > 0)
{
usleep(1000);
printf("who=%s, get a ticket: %d\n", name, tickets);
tickets--;
pthread_mutex_unlock(td->lock);
}
else
{
pthread_mutex_unlock(td->lock);
break;
}
usleep(13);
}
return nullptr;
}
这里的阻塞等待,如何理解? 其实就是一个线程当前没有拿到锁, 那么cpu就将这个线程的pcb放到对应的锁的调度队列当中去等待。
上面这串代码中, 我们要知道的是, 不同的线程对于锁的竞争力度是不一样的。 一般情况下, 我们如果不加上面的这个usleep(13), 肯定会造成线程们的竞争力不一样,导致的单个线程一直抢到锁的问题。就如同下图:
这也侧面证明了, 我们的每一个线程对于锁的竞争力度是不一样的!所以为了解决这个问题我们就把每一个线程解锁后都休息上几微妙,就能防止出现这种情况了:
理解锁的竞争
现在我们就着上面锁的竞争性不一样的情况讲一个故事来理解几个概念。
- 就比如有上面一间vip自习室。但是这个自习室依次只允许一个人进入。 所以呢, 每天早晨小王就来到自习室这里抢占位置。 当小王进入自习室学习的时候, 那么这个时候他把门一反锁。 这个时候这个自习室的占用权就是他的了。 但是呢, 这个时候是不是外边陆陆续续的又有人来了, 他们一看到门关着, 门外的钥匙没了, 人们就只能在外面等着。——这就是多线程的阻塞等待。
- 当小王学习了一会儿, 坐不住了, 想要出去溜溜。 然后小王出门将钥匙挂在墙上, 但是一看到这么多人, 下次回来不一定能够排上了, 所以就又拿着钥匙回去了。 但是呢, 一会又坐不住了, 又要出门, 但是看到这么多人又后悔了。反反复复, 因为小王是离钥匙最近的, 所以它的竞争力度是非常大的。 所以就导致了长时间拿不到钥匙的外面的人们的饥饿问题——这, 也是多线程的饥饿问题。
所以, 纯互斥环境, 如果锁分配不够合理, 容易导致其他线程的饥饿问题。 注意, 不是说只要有互斥必有饥饿。 而是说在互斥的条件下找到纯互斥的的场景, 就用互斥!
那么现在有个观察员
这个观察员看到小王光呆在自习室, 也不创造价值。 所以就设定了规则——
- 1:外面的人, 必须排队。
- 2:出来的人, 不能立马申请锁, 必须排队到队列的尾部!
这样, 就能让所有的人按照一定的顺序获取锁(钥匙)。而我们上面锁的使用里面使用的usleep(13)其实就是模拟的第二点!!而这,这种按照一定的顺序性获取的资源叫做同步!!
申请锁与释放锁的原子性问题
现在我们知道了, 我们通过加锁和解锁限制了一块临界区。 可是, 每一个线程在进入临界区访问临界资源的时候, 它的第一件事情都是申请同一把锁。 那么此处每一个线程它要执行临界区的代码,它就要先获得一把锁。 所以, 这把锁本身就应该是一个临界资源(共享资源)。 所以, 每个线程为了保护我们自己访问临界区是安全的, 但是我们在访问的时候, 谁来保证访问锁是安全的呢? ——其实, 申请锁和释放锁本身就被设计成为了原子性操作。 问题是, 如何做到的呢?
那么上面绿色框框就是我们的临界区。 首先我们需要知道的是, 处在临界区中线程可以被切换吗? ——我门说过tickets都不是原子的, 都是可以被切换的。 那么这么一大块代码, 就不可以切换了吗? 所以, 在临界区里面, 我们的线程是可以被切换的。
知道了这些, 那么我们就可以来看上面的问题了:我们还是使用小王的例子来说。 就比如小王想上厕所, 但是小王因为出去的话回来还要重新排队。 所以他想了一个办法, 就是出去的时候将钥匙不放回原位置, 将钥匙装在兜里, 这样回来的时候就不用排队了, 直接可以进入到屋子里。
所以, 线程虽然可以被切换, 但是我们的线程怕不怕被切换呢? 答案是不怕, 因为我们的线程被切出去的时候, 可以持有锁被切出去。 即便我线程没在被cpu执行, 但是只要我没有mutex_unlock, 那么其他线程就拿不到锁!!
所以,这个临界区的代码对于线程来说,只有两种是有意义的——要么已经释放了锁, 要么正在申请锁。 也就是说,当我们的其他线程知道自己没有机会的时候, 它也就不关心正在执行的线程的中间代码了, 而是去关心线程现在有没有把锁释放, 有没有在重新竞争锁。 所以, 其他的线程在关注正在执行的线程时, 他最关心这个线程是否已经释放完了锁!!! 因为他知道关心其他的一点意义都没有。 所以, 通过加锁, 我们就能保证, 我们当前线程在访问临界区期间对于其他线程来讲时原子的。 所以对于其他线程来讲, 一个线程要么申请锁, 要么释放锁。 所以, 它们是原子的!!!
——————以上就是本节全部内容哦, 如果对友友们有帮助的话可以关注博主, 方便学习更多知识哦!!!