本文的主题是线程互斥,但是我们不能光单独的把概念引出来,我们肯定要一个场景,所以我们将抢票这个场景引出来,模拟一下抢票的场景,随即引出今天的主题。
那么对于线程互斥这个主题,我们从以下几点介绍:
认识锁和它的接口->解决历史问题->原理角度理解锁->实现角度理解锁。
当然了,因为没有理解抢票这个场景,所以我们暂时不知道锁是什么是正常的,那么,直接进入主题吧!
抢票的基本逻辑是多个线程一起抢,所以我们需要创建多线程,多线程创建好了之后,都执行同一个函数,即抢票函数。
同时,我们将票的数量固定到只有10000张,让4个线程在规定时间之内抢票,因为cpu里面存在时间片的概念,所以我们不妨设置一个死循环,一个线程在规定时间之内能抢多少就抢多少。
基本逻辑我们已经捋顺了,现在直接实现吧!
int tickets = 10000;
void Rounte(const std::string& name)
{
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("who: %s, get a ticket: %d\n", name.c_str(), tickets);
tickets--;
}
else
{
break;
}
}
}
int main()
{
Thread t1("thread -1", Rounte);
Thread t2("thread -2", Rounte);
Thread t3("thread -3", Rounte);
Thread t4("thread -4", Rounte);
t1.Start();
t2.Start();
t3.Start();
t4.Start();
t1.Join();
t2.Join();
t3.Join();
t4.Join();
return 0;
}
当然了,使用的线程是我们自己实现的线程,使用起来还是比较丝滑的。
那么我们认为的现象应该是最后tickets到了0,循环结束,整个代码完美结束。
不过……?
抢到了负数?
抢到了相同的票?
以上的两种情况都是来源于上面的代码。
可是这是为什么呢?我们这里需要引入部分偏硬件的知识了:
对于计算机的运算类型来说,分为了算数运算和逻辑运算,那么在判断的是否还有tickets的时候,是逻辑运算,但是不管是算数运算还是逻辑运算,都需要用到寄存器,那么假设存在寄存器eax,它需要将数据从内存读取到寄存器里面吧?
拿到了对应的数据之后要进行判断吧?那么判断之后要返回结果吧?
所以对于上述抢票的行为,也就是对这个公共资源的使用,一共分为三步:
从内存里面取数据到寄存器->在寄存器里面进行判断->返回结果
那么正常运行到了tickets--的时候,也是有三个操作,第一个是重读数据,第二个是--数据,第三个是写回数据。
就像这样。
同时,在前文我们也是介绍过当线程被切换的时候,会带走自己的数据吧?重新调度到该线程的时候,寄存器里面的值是会恢复到之前的值的。虽然寄存器只有一套,但是寄存器的值可不是只有一套哦。
问题来啦:
如果一个线程刚拿到tickets的值,还没有执行--的操作,就被cpu切换了呢?此时A线程被切换了,B线程来了,B线程拿到了和A线程一样的值,同时,进行了--操作,好了,A又被调度到了,A对应的寄存器的值还是之前的值,此时还是对相同的tickets--。这种情况就是两个线程抢到了相同的票。
可能上面解释有点晦涩,但是实际上,多线程同时访问公共资源的时候,如果不使用某种方式解决多个线程访问同一个资源的情况,就会导致数据不一致性问题。
那么,使用的方式是:锁。
以上是场景的复现,现在我们来介绍锁。
对于锁的理解多简单,比如咱们去自习,而一个教室只有一个座位,那么如果多个人同时访问该教室就完蛋啦,我们可以进行如下操作,一个人进去之后,将教室锁上,此时,就不存在多个线程访问同一个资源的情况了。
对于锁的理解就可以到这里了。
那么全局和静态的锁是一大类,定义出该锁之后只需要init即可,局部的锁是一大类,定义出来需要destroy。
有意思的是在Ubuntu环境下man不了以上的函数,pthread_mutex_destroy,pthread_mutex_init
甚至连pthread_mutex等也查不了,只能使用centos查询。
我们先试试全局的锁,锁的类型是pthread_mutex_t:
源码是这样的,是联合体,里面存在数据块啊啥的,咱们也先不管。
对于全局的锁我们使用第三行的initiallizer初始化,当我们把锁定义好了之后,就需要用到加锁的函数了,当然了,既然有加锁,那么肯定也是有解锁的函数的:
其中trylock函数我们暂时先不用管。就用lock函数和unlock函数即可。
我们同样使用该代码举例:
int tickets = 10000;
void Rounte(const std::string& name)
{
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("who: %s, get a ticket: %d\n", name.c_str(), tickets);
tickets--;
}
else
{
break;
}
}
}
现在的场景是多个线程访问同一个资源,那么我们约定,该资源的名字我们叫它为临界资源,对临界资源进行访问的代码片段,我们叫做临界区代码。所以我们保护资源,本质就是想办法把访问资源的代码的保护起来。
那么对于上述代码,临界区的开始就是if(tickets>0),那么加锁理应加到if的上面,可是如果我们加锁加到了while上面呢?那可就牛了,一个线程直接将所有的票全部抢完,其他线程根本进不去。所以加锁应该是在if的上面。
那么在哪里解锁呢?else的第二个大括号之后?看起来似乎不错?但是实际上,如果线程在tickets--的时候被切换了,此时锁还没有解锁,那么其他线程仍然不能访问临界资源。虽然问题不是很大,但是实际上会影响效率,所以我们加在else里面不是很好,正确的加锁应该是:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int tickets = 10000;
void Rounte(const std::string& name)
{
while(true)
{
pthread_mutex_lock(&mutex);
if(tickets > 0)
{
usleep(1000);
printf("who: %s, get a ticket: %d\n", name.c_str(), tickets);
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
此时看看效果,如果我们将加锁和不加锁的两段代码对比之后,会发现加锁之后的代码慢了很多,但是不会出错:
这就是锁的基本应用。
而这种锁,成为互斥锁,也就是用来防止多线程访问相同资源导致的数据不一致性问题。
好了,现在我们来解决部分历史问题。
1.加锁的范围,粒度一定要尽量小。
这个点上面已经验证过了,实际上如果范围过大,很可能导致一个线程将所有的票抢完的问题。
2.任何线程进行抢票的时候都应该先申请锁,不应该有例外。
如果有例外,那就可能导致数据不一致性问题了。
3.加锁的过程应该是原子的。
对于锁来说,因为每个线程都要申请锁,所以锁是临界资源,那么加锁的过程就一定要是原子的,不能说加到一半,就会切换了。
对于原子性,前文其实简单提及到过,这里简单描述:要么做,要么不做,转换为汇编语句就是,只能有一条汇编语句。没有中间状态就是原子的。
我们拿++举例,这个简单的操作都分为了三条汇编语句,所以++操作不是原子的。
那么加锁怎么就是原子的呢?我们后面解释。
4.如果线程申请锁失败了,线程应该阻塞。
如果线程申请锁失败了,意味着线程没有进入到“教室”的权利,此时它干啥呢?它啥也不能干,只能干坐着。
5.如果线程申请锁成功了,就继续向后运行。
这个就是4的反例,它既然能进入到“教室”了,那么肯定是要有后续操作的咯。
6.在临界区里面能被切换吗?
我们不能将锁认为是一种多特殊的手段,它不过就是一种防止其他线程共享操作资源的一种手段,没多特殊,所以它一定能被切换。不过是说它执行临界区的代码的时候没人打扰它而已。
那么从上面的6个历史问题,我们可以得到一个结论,一个线程,要么申请锁失败,要么释放了锁,也就是说,该线程对于临界区代码的访问在其他线程看来是原子的!!
加锁的函数是pthread_mutex_lock,当线程执行到该函数的时候,如果成功了,也就是函数返回了,此时就是线程申请锁成功了。
那么反之,如果申请锁失败了,该函数就不返回了,线程就阻塞了,当原来的锁被解开的时候,在该函数里面的线程就应该重新申请锁了。申请锁失败的本质就是因为锁没有就绪而已,锁没有就绪,线程阻塞,此时就达到了我们期望的结果。
对于婴儿来说,他不是天生就听得懂语言的,肯定是要经过后面的联系,此时他有了自己的一套语言系统,那么同样,对于cpu来说,我们将它发明出来的时候,我们直接给它安了一套指令集,也就是说cpu本身拥有一套指令系统,知道碰到什么指令的时候该做什么事。
那么,cpu内部给我们提供了两个指令,从它的指令集里面来,实际上就是交换指令。
那么
在cpu里面存在一个寄存器叫做al,加锁的过程是将al里面的元素放个0,同时,直接交换在内存开辟的锁的空间里面的值,比如1,交换的这个指令,防止里面至少需要三条语句,但是cpu的指令集直接提供了一个指令,交换,那么因为交换的指令只有一条,也就是对于公共资源的访问只有一条语句,所以加锁的过程就是原子性的。
判断的时候,只需要判断al寄存器里面的值是否满足1,就能判断该线程是否申请锁成功了,有意思的是,因为锁这个公共资源只有一个1,线程切换的时候会带走1,所以!就将临界区的资源,锁住啦!
那么为什么这样做可以呢?
因为该行为的本质,就是将数据从公有,变成线程私有!!
好了,主要内容我们已经介绍完了,我们可以对锁进行小小的优化,使用一个类即可:
class LockGuard
{
public:
LockGuard(pthread_mutex_t *mutex):_mutex(mutex)
{
pthread_mutex_lock(_mutex);
}
~LockGuard()
{
pthread_mutex_unlock(_mutex);
}
private:
pthread_mutex_t *_mutex;
};
是不是非常熟悉?
那么代码就可以变成:
while (true)
{
LockGuard lockguard(td->_lock); // RAII风格的锁
if (tickets > 0)
{
// 抢票过程
usleep(1000); // 1ms -> 抢票花费的时间
printf("who: %s, get a ticket: %d\n", td->_name.c_str(), tickets);
tickets--;
}
else
{
break;
}
}
此时,线程互斥的全部内容就介绍完咯!
感谢阅读!