多线程的运作
在说线程安全的问题之前,必须先说清楚一件事——多线程是如何工作的
用土木理解线程
在A市,有一块开发区准备被开发,说人话就是有一块荒地被一家土木老板看上了。刚开始,只有一家由刀哥带领的施工队在这里施工,土木老板给他规划图纸,刀哥负责根据规划图纸打好地基建起高楼。
但是干着干着,老板发现了一件事——刀哥团队每天都有一段时间要开车去其他地方,把建材水泥往工地运,这段时间工地完全是空的。这不对啊,这多影响效率啊,工地空的这段时间,难道我就不能叫其他团队施工了吗?
于是,老板想出了一个办法——让虎哥团队过来,当刀哥不在工地的时候,虎哥团队就负责另一个项目的建设,这样就能保证,工地时时刻刻都不闲着,原本2年的开发周期可以直接缩短一半成一年。
但是,刀哥都建一半了,让虎哥插手刀哥的项目也不合适:虎哥不知道他们建到哪里了,刀哥也不知道虎哥给他们项目动了什么手脚,所以,我就让虎哥另起炉灶,让他们去做两个不同的项目吧!
刚开始,两个施工队还非常融洽,项目进展也非常顺利,但是没过几天,两个施工队就吵起来了——
原因也很简单。刀哥施工队的建筑比较简单,一会就做完了一会就要运水泥了;而虎哥的建筑需要慢工出细活,要做好久才需要腾出工地去运水泥。刀哥自然不高兴,虽然虎哥做什么和他没关系,但是虎哥一直占着工地,却同时拉低了刀哥施工队的施工效率。所以,老板决定约法三章——
- 每个施工队都有着自己的施工时间,而且他们的施工时间是相同的。
- 工地只允许同时一个施工队施工,在他的施工时间里,其他施工队不能干扰。
- 每个施工队只负责自己的施工区域,最后发工资只检查自己的施工区域施工了多少
这样一来,虽然可能会造成,在刀哥施工队施工时间里,刀哥跑出去运材料了,空地是空的。因为这不属于虎哥施工时间,所以虎哥没办法进来。
但是,这约法三章,让每个施工队都能保证自己施工时间里有最大效率;同时,因为每个队伍的施工时间是相同的,也不会因为一个施工队的效率低下导致拖累了另一个施工队。两个施工队都能按自己的节奏进行自己的施工,就算有工地空闲的情况,也比只有一个刀哥施工效率要高得多。
多线程是怎么工作的?
多线程是怎么工作的?
多线程就是这样工作的。
我们把系统,想象成那个土木老板。
刚开始,系统想让一个程序去执行一项任务,就让这个程序在一个CPU的内存上运行。
但是,这个程序要等待用户的IO操作,比如这个程序为:
//刀哥施工队int main()
{while(1){int a;scanf("%d",&a);printf("我运水泥回来啦!");}return 0;
}
等待用户IO操作的时候,便是一直占着cpu的内存空间,但是没有做任何有意义的事。这个时候,完全可以让另一个程序来用这块cpu空间做些有意义的事情,于是就把在等待IO的程序放在一边,让其他暂时不用等待IO的程序用cpu做事。
不过,问题又来了,有的程序,完全不需要IO操作,比如:
//虎哥施工队int main()
{int count = 0;while(1){count++;}return 0;
}
你说把他逐离CPU吧,他又没有占着CPU内存不做事;你说让他一直执行吧,那其他程序一眼看不到头。所以,只能保证公平第一,每个程序在CPU上运行的时间是相同的,到了时间轮到下一个程序上;就算CPU上的程序没做事,也必须让他把自己的时间跑完,其他程序不能干扰他。
这样的操作,就算有不合理之处,但是相比让一个程序占着一个CPU一直跑,效率也要高不少就是了。
所以,多线程是如何工作的?多个线程在一个CPU前排队,一个线程工作相同的时间后,切换到下一个线程,如此往复工作。
补丁
举这个例子,其实也有不合理的地方。稍微懂一点操作系统的会问,这不是进程的工作吗?
对,这就是进程。
其实不然。
- 进程是什么?进程是操作系统让CPU去执行一段代码。
- 进程是什么?进程是操作系统让CPU去执行一段代码。
他们的关系图可以是这样的:
并且,其实在Linux中,线程就是用进程来实现的。所以,刚刚那一大堆,虽然大部分都有点进程的意思,但是,这只是为了让不太了解多线程的读者更好去理解多线程的工作原理,后面也是同理。
线程安全
用苹果理解线程安全
现在班上,马上要举行颁奖仪式,把苹果奖励给5名学生。你作为老师,老早就买好了一袋子苹果,放在办公室的桌子上。你没有细数苹果的个数,一直到颁奖典礼当天才打开袋子,看看里面有多少苹果:
于是,你得出了一个结论——苹果足够颁发给5个学生。你把装有苹果的袋子放在了办公室,人跑去上课了。但是,这个时候,另一个老师来到了办公室,看见桌上的苹果,于是顺手拿了一个,因为你不在办公室,所以也就没有通知你,一直到了晚上的颁奖仪式...
你回到办公室,拿起了装有苹果的袋子。因为你早上确认过,得出了结论——苹果是足够的,于是你没有再去数袋子里苹果的数量,但是一直到颁奖仪式开始,你分发苹果的时候突然发现——苹果怎么突然就不够了呢?
是你早上得出的结论错了吗?不是,早上苹果是5个,苹果可以分发给5个学生,早上得出的结论没问题。那为什么晚上苹果不够用了?因为在你离开办公室的时间,有其他人和你进入了同一个办公室,并且对你已确认的东西进行了修改,等到你回到办公室的时候,你并不知道自己的东西被修改了,你还是用修改之前的结论,来进行以后的工作。
线程安全是什么?
线程安全是什么?
同样,线程安全就是刚刚的例子。
同一个CPU上,会有多个线程排队运行,运行到一定的时间后,让下一个线程运行。但是,每个线程运行到哪一步,是自己不可控的,比如这个线程:
int main()
{int* count = new int(0);while(1){if(*count==200){break;}count++;}return 0;
}
正常情况,并不会产生多大问题,因为每个线程之间的数据都是相互独立的,我运行完了,带着我的数据离开cpu,你的数据再进来;你运行完了,带着你的数据离开cpu,我的数据再进来,就算我们采用了同一块cpu,我们也不会影响各自的数据。
但是,如果我点名道姓,我要用你的空间呢?
int i=0;void ModifyInt(int* i)
{while(1){if(i==100){break;}i++;}
}int main()
{Thread thread1(ModifyInt,&i);//让线程1去执行ModifyInt函数,参数为i的地址Thread thread2(ModifyInt,&i);//让线程2去执行ModifyInt函数,参数为i的地址thread1.join();thread2.join();
}
按道理,应该是两个线程同时对i++,然后1线程将i加50次,2线程将i加50次,最后i到了100,两个线程同时退出循环,对吧?
但是,有没有可能这种情况:
当不同线程对同一块空间进行操作的时候,一个线程得出了对这块空间的判断结论,在当前时刻肯定没问题,但是等到其他线程也对同一块空间修改后,过去的结论就不一定适用于现在的情况了。
空间就像装有苹果的袋子,线程只可以确保当前时刻的正确性,而在线程离开的时候,这块空间发生了什么变化,袋子里的苹果被谁拿走了,线程是完全不知道的。等到回来,如果我们还用过去的结论,去判断现在的空间,那肯定是不合理的。为了避免这一情况,也有一个很好的解决方案——
锁和原子性
锁很好理解,那原子性又是什么?
我们回到工地
虎哥和刀哥施工队又回来了。虽然两个施工队有了自己独立的工作时间,两个施工队是公平的,但是,虎哥在施工的时候发现了一个问题:
虎哥发现,给自己的施工区域,光是放砖就用了一大半。但是,又不能干扰到刀哥的施工区域,于是,虎哥便找来了一部分没有被框定的空地,把自己的砖放到了那块空地上。
虎哥施工队走后,刀哥施工队回来了。刀哥施工队一看——
刀哥用完之后,虎哥回来傻眼了——
于是,虎哥这次学聪明了。他决定把自己的规划上报给土木老板,让土木老板在规划图上,把那块空地暂时划给虎哥,这样刀哥也不会随便乱动了。这是什么?虎哥给这块刀哥虎哥都能用的空地,自己上锁了。上锁之后,暂时只有虎哥能用这块空地放砖,刀哥就没办法用这块空地了。
可是,虎哥规划上报到土木老板,到土木老板修改规划图,把新的规划图交给刀哥,起码得花两三天的时间。
在这个时候,虎哥以为自己的规划已经被刀哥知道了,便把砖放到了自己规划的区域。殊不知此时土木老板还在修改规划图,刀哥屁都不知道,回来一看这么多砖,再看看规划图——
于是,虎哥回来一看又傻眼了——
锁
什么是锁?
很简单,锁就是对一块多个线程都能访问到,且都会去使用和修改的区域,关闭其他线程的访问权限,只能由上锁的线程访问。当A线程对区域I上锁了,就算A线程的运行时间到了,退出了cpu,其他线程来到区域I一看,区域I有一把锁,而且不是自己设置的锁,说明这块区域我不能进,我就必须在这里等着,直到这块锁被解开,我才能再访问到里面的数据。
那怎么上锁呢?为什么一个区域会有一把锁呢?
还是回到刚刚的程序
int i=0;
mutex lock;//定义一个锁,这个锁是被两个线程共享的void ModifyInt(int* i)
{lock.lock();//对这个函数区域上锁while(1){if(i==100){break;}i++;}lock.unlock();//对这个函数区域解锁
}int main()
{Thread thread1(ModifyInt,&i);//让线程1去执行ModifyInt函数,参数为i的地址Thread thread2(ModifyInt,&i);//让线程2去执行ModifyInt函数,参数为i的地址thread1.join();thread2.join();
}
lock()函数的作用可以用一张图来概括:
所以,这个时候这个程序会如何运行?
原子性
在刚刚虎刀之争中,又产生了一个新问题:为什么虎哥明明已经对一块空地进行上锁操作了,但是刀哥还是有资格搬走那块空地上的砖?
因为,虎哥的上锁操作太繁琐了。
虎哥对一块空地上锁,至少要分为三步:
- 虎哥上报规划给土木老板
- 土木老板修改规划图
- 土木老板将新的规划图给刀哥
但是,当虎哥第一步上报规划给土木老板的时候,就认为自己已经对这块空地上锁了;而刀哥认为的上锁,是在第三步土木老板将规划图给刀哥。在这中间,他们对一个锁的认知是有偏差的,自然无法保证这个锁的可靠性。
所以,为了保证一个锁是可靠的,我们起码要保证,所有线程对一个锁的开关状态的更新是同时的。
换人话来说:
一个锁的上锁可能是:
void lock()
{CheckMutexIsLock();//检查是否被上锁...LockTheMutex();//将锁上锁
}
所以,我们必须要保证:
- 上锁的那一步是瞬间完成的,不能存在上锁上一半这个线程运行时间到了
- 检查上锁的操作也应该被上锁,即当一个锁被检查是否锁上的时候,其他线程不能去访问这个锁
而第一个条件,一个操作是被瞬间完成的,不会存在运行时间终止而导致该操作被中断,这个操作就被称作为原子的。在我们通常认知能力内,可以认为,汇编语句(不是C语言!)的一行操作,就是一个原子操作,不会因为运行时间的终止而被打断。
再往细说,就会涉及太多硬件的知识,只给学过数电的读者稍微说说浅显的理解:
当一个芯片是上升沿触发的时候,我们按一下冲激信号,这个芯片就会进行一次计算。计算机的运行时间就是通过一个个冲激信号来计算的,所以,单个冲激信号是一定不会被打断的,这单个冲激信号的计算,就是原子的。
锁的实现方法
最后,我们再来看看锁的可靠性,需要满足的条件——
- 上锁的那一步是瞬间完成的,不能存在上锁上一半这个线程运行时间到了
- 检查上锁的操作也应该被上锁,即当一个锁被检查是否锁上的时候,其他线程不能去访问这个锁
第一步好理解,只要保证,上锁是原子的就可以了。而什么是原子,那是汇编语言的事情,我们不要太去在意,因为操作系统已经帮我们做好了。
而第二步,我就不能理解了——
检查锁A,要对这个锁进行上锁,我们叫作锁B。而当这个锁B被上锁的时候,上锁函数中又包含着检查锁,检查锁又要对锁B上锁...
void lock()
{CheckMutexIsLock();//检查是否被上锁...LockTheMutex();//将锁上锁
}bool CheckMutexIsLock()
{lock();...unlock();
}
操作系统中,对上锁有着近乎天才的解决方案:
锁的天才实现
锁,其实只是一个bool值,false或true。true表示上锁了,false代表没上锁。
class mutex
{bool lock;
}
当一个线程上锁的时候,并非是我们之前说的先检查锁,然后上锁,而是进行以下三步:
- 定义一个bool变量,假设命名为check,将其值置为true
- 交换check和锁中bool变量lock的值,这个操作能保证是原子的
- 检查交换后,check的值是true还是false
我们来分两种情况去讨论:
我们发现,无论哪种情况,其目的都只有一个:
先将锁上锁,然后再检查原来锁的值
- 当这个锁没被上锁的时候,第二步直接将锁置为上锁,然后再第三步检查check,即交换前lock锁的值。如果是false,则表示这个锁原来没有被上锁,我可以继续往后运行了。
- 而当这个锁被上锁的时候,第二步将已经被上锁的锁置为了上锁,等于没有对锁产生任何影响。然后,我们再检查原先lock锁的值,发现为true,即这个锁原来已经被上锁了,我不能继续运行。
而等待解锁,其实就是不断进行这三步操作。一旦锁被解开,锁就会被置为false,就进入了第一种情况,线程可以继续向后运行。否则,就是第二种情况,锁会一直交换check和lock两个为true的值,在自己的运行时间里重复这个循环。
用代码来说就是:
void lock()
{while(1){//第一步:定义checkbool cheak = true;//第二步:交换check和lock的值swap(&cheak,&(mutex.lock));//第三步:检查check的值,即检查交换前lock的值if(cheak==false){break;}}
}
线程安全的检查
但是,这个操作真的是线程安全的吗?
还是分为三种情况来看:
1. 当第一步被打断
当第一步被打断时,即定义check完之后,线程便中断了。
在这里,我们只是在线程内部定义了一个临时变量,这个变量都无法被其他线程访问,都不会涉及多线程的问题,当然是线程安全的
2. 当第二步被打断
当第二步被打断时,即交换check和lock完之后,线程便中断了。
这里,lock是被共享的变量,所以我们只需要考虑lock的值。
导致线程不安全的原因是什么?是一个线程A先拿到了lock的值,然后线程B对lock的值进行修改了,最后A根据lock被修改之前的值往后判断了。
但是这里,当且仅当lock的值为false的时候,lock的值才会被修改;当lock为true的时候,lock的值会一直保持为true。
- 当线程A拿到了lock为true的值,无论如何修改,lock永远为true,即不会存在lock被修改的情况
- 当线程A拿到了lock为false的值,A自己同时将lock置为了true,并将lock的值存在了自己的临时变量check的里。这样,无论自己如何修改自己内部临时变量check的值,都不会对其他线程产生影响;同理,外部线程无论怎么修改lock(况且此时lock已经为true了,完全修改不了)的值,都不会对check的值产生影响。所以,此时无论怎么判断怎么修改非共享区域check的值,都不会涉及到线程安全问题。
3. 当第三步被打断
当第三步被打断时,即检查完check的值后,线程便中断了。
同样,check只是线程内部的一个临时变量,这个变量都无法被其他线程访问,都不会涉及多线程的问题,当然是线程安全的
所以,只要保证第二步是原子的,就能保证整个操作是线程安全的。那如何保证第二步是原子的呢?
在文章末尾,稍微说点以后的安排
这几个月没更新不是陪劳达去了
原本是打算,把Linux系列全部更完整的,但是写着写着发现,Linux操作系统部分确实涉及好多领域的知识,以我这个半吊子理解来教大家有点误人子弟了,完全回避其他领域的知识吧又讲不明白只会越听越迷糊,所以最后还是放弃Linux完整的更新了。
但是有些东西非常重要,并且涉及的专业性比较强,所以会专门以专题的形式来慢慢讲清楚,就比如这一期的线程部分的讲解
往后就会更新实际项目的开发,目前已经准备好了三个项目——Log日志系统的开发,在线OJ的网络服务开发,卫星导航系统程序的开发,涉及了Linux系统,Linux网络和实际项目的解决,争取在过年之前更完吧,毕竟过完年就准备去参加gamejam了
还有——