谈到锁,离不开多线程,或者进程间的通讯。
还记得上次跟你撕逼内存模型的那我的吗,他又来了,而且向你甩出了一堆问题:
咱们知道,在操做系统中,互相协做的进程之间可能共享一些彼此都能读写的公共存储区,假设两个进程都须要改写这个公共的存储区那么就会产生竞争关系了。
下面举个例子
假设两个进程a和b共享一个脱机目录,脱机目录中有许多槽位,free记录了下一个空的槽位,进程能够往下一个空槽位中写入内容。
进程a准备往下一个空槽位写入内容"test",进程b准备往下一个空槽位写入内容“good”。
咱们来分析下极端状况:
能够发现,因为发生了时钟中断,两个进程都往槽位3写入了内容,进程b的内容被进程a的内容覆盖掉了。
像这种因为两个或者多个进程读写某些共享数据,最后结果取决于进程运行的精确时序,称为竞态条件
。
为了不这种竞态条件
的出现,就须要找出存在这种竞态条件
的程序片断,经过互斥
的手段来阻止多个进程同时读写共享的数据。
对共享内存进行访问的程序片断称为临界区
。
为了实现互斥而选择适当的原语是任何操做系统的主要涉及内容之一。
仍是以上面的例子来讲明,为了不竞态条件
的产生,咱们须要把获取空槽位和往槽位写内容的程序片断做为一个临界区,任何不一样的进程,不能够在同一个时刻进入这个临界区:
如上图,进程b试图在a离开临界区以前进入临界区,会进入不了,致使阻塞,一般表现的行为为:
为了实现这种临界区的互斥,须要进程之间可以像对话同样,确认是否能够进入临界区执行代码,这种对话即进程通讯
。
所谓忙等待,指的是进程本身一直在循环判断是否能够获取到锁了,这种循环也称为自旋
。
屏蔽中断
和锁变量
的介绍,依次引出忙等待的相关互斥手段方法。
以下图,在进程进入临界区以前,调用local_irq_disable
宏来屏蔽中断,在进程离开临界区以后,调用local_irq_disable
宏来使能中断。
CPU只有发生时钟中断或其余中断才会进行进程切换,也就是说,屏蔽中断后,CPU不会切换到其余进程。
另外,这个屏蔽中断是用户进程触发的,若是用户进程长时间没有离开临界区,那就意味着中断一直启用不了,最终致使整个系统的终止。
因而可知,在这个多核CPU普及的时代,屏蔽中断并非实现互斥的良好手段。
上面一种硬件的解决方案,既然硬件解决不了,那么咱们尝试经过软件层面的解决方案去实现。
可是因为对Lock的check和set是分为两步,并不是原子性的,那么可能会出现以下状况:
也就是说在进程a把Lock设置为1以前,b就进行check和set操做了,也获取到了Lock=0,致使两个进程同时进入了临界区。
这种非原子性的检查并设置锁操做仍是会存在竞态条件,并不能做为互斥的解决方案。
接下来咱们升级一下程序,为了不这种竞态条件
,咱们让进程间严格轮换的方式去争抢使用Lock的机会。
所谓严格轮换法,就是指定一个标识位turn
,当turn=0的时候让进程a进入临界区,当turn=1的时候,让进程b进入临界区。
1// 进程a 2while(TRUE){ 3 while(turn != 0); /* 循环测试turn,看其值什么时候变为0 */ 4 critical_region(); /* 进入临界区 */ 5 turn = 1; /* 让给下一个进程处理 */ 6 noncritical_region(); /* 离开临界区 */ 7} 8// 进程b 9while(TRUE){10 while(turn != 1); /* 循环测试turn,看其值什么时候变为1 */11 critical_region(); /* 进入临界区 */12 turn = 0; /* 让给下一个进程处理 */13 noncritical_region(); /* 离开临界区 */14}复制代码
这种方法可能致使在循环中不停的测试turn,这称为
忙等待
,比较浪费CPU,只有有理由认为等待时间是很是短的情形下,才使用忙等待
,用于忙等待的锁,称为自旋锁(spin lock)。
假设如今进程a在临界区里面,而且执行了turn=1,准备把临界区轮换给进程b,可是这个时候进程b正在处理其余事情,那么这个临界区就一直被进程b阻塞了。
也就是说,我唱完一首歌,把麦给了你,轮到你唱,这个时候你拿着麦去上厕所了。
你上厕所居然影响到了我唱歌,就是所谓的临界区外运行的进程阻塞了其余想进入临界区的进程。
看来这种解决方案并非一个很好的选择。
接下来咱们经过一种G.L.Peterson
发现的一种互斥算法来实现互斥功能。
既然Linus
说了Talk is cheap. Show me the code.
话很少说,咱们直接上代码:
1#define FALSE 0 2#define TRUE1 3#define N 2 /* 进程数量 */ 4 5int turn; /* 如今轮到谁? */ 6int interested[N]; /* 全部值初始化为0 (FALSE) */ 7 8void enter_region(int process){ /* 进程是0或1 */ 9 int other; /* 其余进程号 */10 other = 1 - process; /* 另外一方进程 */11 interested[process] = TRUE; /* 表示所感兴趣 */12 turn = process;13 while(turn == process && interested[other] == TRUE);/* 空循环 */14}1516void leave_region(int process){17 interested[process] = FALSE; /* 表示离开临界区 */18}复制代码
算法关键代码是while循环,若是并发执行,当进程0调用完enter_region
以后,变量值以下:
1interested[0] = TRUE2turn=0复制代码
进程1调用完enter_region
后,给turn赋值=1,覆盖了进程0的赋值:
1interested[0] = TRUE2interested[1] = TRUE3turn=1复制代码
而后进程1发现turn == process
成立,而且interested[other] == TRUE
,而后卡在这里自旋等待,直到另外一个进程离开了临界区。
能够看到,这个算法只适用于两个进程间的互斥处理,更多进程就没办法了。
接下来,咱们经过一种硬件指令的方式,帮助咱们更好的实现互斥。
基于硬件指令通常是基于冲突检测的乐观并发策略:
乐观并发策略须要硬件指令集的发展才能进行,须要硬件指令实现:
这类指令有:
测试并设置锁 Test and Set Lock (TSL)
获取并增长 Fetch-and-Increment
交换 Swap
比较并交换 Compare-and-Swap (CAS)
加载连接/条件存储 Load-linked / Store-Conditional LL/SC
测试并设置锁 Test and Set Lock (TSL),指令格式以下:
TSL RX, LOCK
做用是将一个内存字lock读到寄存器RX,而后将lock设置为一个非0值。
执行原理:
执行TSL指令的CPU会锁住内存总线,禁止其余CPU在这个指令结束以前访问内存。
为了使用TSL指令,须要使用一个共享变量lock来协调多内存的访问。
1enter_region:2 TSL REGISTER,LOCK | 复制锁到寄存器并将锁设为13 CMP REGISTER,#0 | 锁是0吗?4 JNE enter_region | 若不是0,说明锁已被设置,因此循环5 RET | 返回调用者,进入临界区67leave_region:8 MOVE LOCK,#0 | 在锁中存入 09 RET | 返回调用者复制代码
若是TSL原子操做没有成功,则从新跳转到enter_region
方法循环执行。
Peterson
算法有点相似,不过TSL能够支持任意多个进程的并发执行。
IA64 和 X86 使用cmpxchg指令完成CAS功能。
cas 内存位置 旧预期值 新值
CAS存在ABA问题,可使用版本号进行控制,保证其正确性。
JDK中的CAS,相关类:
Unsafe
里面的compareAndSwapInt()
以及compareAndSwapLong()
等几个方法包装提供。只有启动类加载器加载的class才能访问他,或者经过反射获取。
硬件指令既能够实现忙等互斥,也能够实现进程挂起阻塞,关键看具体的实现代码,这里使用了JNE指令进行跳转循环等待,后面咱们会介绍用TSL指令实现进程挂起阻塞的互斥量。
以上的解法都是能够实现互斥的,可是存在忙等,致使浪费CPU时间的问题,若是同步资源锁定时间很短,那么这个等待仍是值得的,可是若是锁占用时间过长,那么自旋就会浪费CPU资源了。
优先级反转问题
:
以下图,进程H优先级较高,进程L先进入了临界区,而后H变到就绪状态,准备运行,如今H开始忙等待。
为了避免浪费CPU资源,咱们可使用进程间通讯的原语sleep
和wakeup
,sleep
形成调用者阻塞,直到其余进程唤醒它。
以下图,生产者往队列里面生产消息,消费者从队列里面取消息进行消费:
当消息队列满的时候,生产者准备进行睡眠,但还没睡着:
消费者消费了一条消息以后,他认为生产者正在睡觉,准备通知生产者也起床干活生产消息了:
但是这个时候,生产者实际上都还没真正睡着,因此:
wakeup信号丢失以后,生产者才真正的睡着了,这个时候消费者殊不知道生产者睡着了,因而一直在消费消息,知道消息消费完了,消费者本身也睡觉了。
生产者消费者完整代码以下:
1#define N 100 /* 缓冲区中的槽数量 */ 2int count = 0; /* 缓冲区中的数据项数目 */ 3 4// 生产者 5void producer(void){ 6 int item; 7 8 while(TRUE){ /* 无限循环 */ 9 item = produce_item() /* 产生下一新数据项 */10 if(count == N) sleep(); /* 若是缓冲区满了,就进入休眠状态 */11 insert_item(item); /* 将新数据放入缓冲区中 */12 count = count + 1; /* 缓冲区数据项计数器+1 */13 if(count == 1) wakeup(consumer); /* 缓冲区不为空则唤醒消费 */14 }15}1617// 消费者18void consumer(void){19 int item;2021 while(TRUE){ /* 无限循环 */22 if(count == 0) sleep(); /* 若是缓冲区是空的,则进入休眠 */23 item = remove_item(); /* 从缓冲区中取出一个数据项 */24 count = count - 1 /* 将缓冲区的数据项计数器-1 */25 if(count == N - 1) wakeup(producer); /* 缓冲区不满,则唤醒生产者? */26 consumer_item(item); /* 打印数据项 */27 }28}复制代码
怎么解决这种进程之间不一样步,致使的死锁问题呢,接下来咱们就经过信号量来实现。
经过使用一个整型变量来累计唤醒次数,以供以后使用。
down:
up:
检查数值、修改变量值以及可能发生的睡眠或者唤起操做是原子性的。
信号量原理:
检查数值、修改变量值以及可能发生的休眠或者唤起操做是原子性的,一般将up和down做为系统调用来实现;
当执行如下操做时,操做系统暂时屏蔽所有中断:
检查信号量、更新、可能发生的休眠或者唤醒,这些操做须要不多的指令,所以中断不会形成影响; 若是是多核CPU,信号量同时会被保护起来,经过使用TSL或者XCHG指令确保同一个时刻只有一个CPU对信号量进行操做。
使用信号量解决进程同步问题代码以下:
1#define N 100 /* 缓冲区中的槽数目 */ 2typedef int semaphore; /* 信号量是一种特殊的整型数据 */ 3semaphore mutex = 1; /* 控制对临界区的访问 */ 4semaphore empty = N; /* 计数缓冲区的空槽数目 */ 5semaphore full = 0; /* 计数缓冲区的满槽数目 */ 6 7void producer(void){ 8 9 int item; 1011 while(TRUE){ /* TRUE是常量1 */12 item = producer_item(); /* 产生放在缓冲区中的一些数据 */13 down(&empty); /* 将空槽数目-1 */14 down(&mutex); /* 进入临界区 */15 insert_item(item); /* 将新数据放入缓冲区中 */16 up(&mutex); /* 离开临界区 */17 up(&full); /* 将满槽数目+1 */18 }19}2021void consumer(void){2223 int item;2425 while(TRUE){ /* 无限循环 */26 down(&full); /* 将满槽数目-1 */27 down(&mutex); /* 进入临界区 */28 item = remove_item(); /* 从缓冲区取出数据项 */29 up(&mutex); /* 离开临界区 */30 up(&empty); /* 将空槽数目+1 */31 consume_item(item); /* 处理数据项 */32 }33}复制代码
如上,每一个进程在进入关键区域以前执行down操做,在离开关键区域以后执行up操做,这样就能够确保互斥了。
mutex
:
full
和empty
:
若是仅仅是须要互斥,而不是计数能力,可使用信号量的简单版本:
mutex_lock
:
mutex_unlock
:
互斥量能够经过TSL或者XCHG指令实现,下面是用户线程包的mutex_lock
和mutex_unlock
的代码:
1mutex_lock: 2 TSL REGISTER,MUTEX | 将互斥信号量复制到寄存器,而且将互斥信号量置为1 3 CMP REGISTER,#0 | 互斥信号量是0吗? 4 JZE ok | 若是互斥信号量为0,它被解锁,因此返回 5 CALL thread_yield | 互斥信号量忙;调度其余线程 6 JMP mutex_lock | 稍后再试 7ok: RET | 返回调用者,进入临界区 8 9mutex_unlock:10 MOVE MUTEX,#0 | 将mutex置为 011 RET | 返回调用者复制代码
以上代码和
enter_region
的区别?
enter_region
失败的时候会始终重试,而这里会调度其余进程进行执行,这样早晚拥有锁的进程会进入运行并释放锁;在用户线程中,
enter_region
经过忙等待试图获取锁,将永远循环下去,绝对不会获得所,由于其余线程不能获得运行进行释放锁。没有时钟中止运行时间过长的线程。 线程库没法像进程那样经过时钟中断强制线程让出CPU。
在单核系统中若是一个线程霸占了CPU,那么该进程中的其余线程就没法执行了。
因为thread_yield
仅仅是一个用户空间的进程调度,因此它运行很是快捷。
mutex_lock
和mutex_unlock
都不须要任何内核调用,从而实现了在用户空间中的同步,这个过程仅仅须要少许的同步。
有些线程包也会提供mutex_trylock
,尝试获取锁或者失败,让调用方本身决定是等待下去仍是使用个替代方法。
为了可以编写更加准确无误的程序,因而出现了管程(monitor)的概念:
管程
是程序、变量和数据结构等组成的集合,构成一个特殊模块或者软件包,进程能够调用管程中的程序,可是不能在管程以外声明的过程当中直接访问管程内的数据结构。
注意:
1monitor example 2 integer i; 3 condition c; 4 5 procedure producer(); 6 . 7 end; 8 9 procedure consumer();10 .11 end;12end monitor;复制代码
管程中任意时刻只能有一个活跃的进程,从而实现了互斥。
管程的互斥由编译器负责决定。
互斥量
和二进制信号量
。
Java中的synchronized关键字正是基于管程实现的,咱们后面会具体介绍。
经过临界区的自动互斥,管程比信号量更容易保证并行编程的正确性。
不管是经过硬件指令,仍是信号量阻塞,或者管程,都是设计用来解决一个或者多个CPU上的互斥问题的。
消息传递
。
这个是专门给进程组而不是进程间实现同步的。
有写程序中划分了若干阶段,而且规定,除非全部进程都准备就绪着手下一个阶段,不然任何进程都不能进入下一个阶段,能够在每一个阶段结尾安装屏障来实现这种行为,当一个进程达到屏障的时候,就会被屏障阻拦,直到全部进程都达到该屏障为止。
第2节内容主要提炼于《现代操做系统》一书,并添加了一些辅助理解的图片,给枯燥的描述添加一点趣味儿。
线程通讯是创建在线程模型之上的,咱们首先来说一下Java的线程模型。
注意:
上面说的互斥量实现,是 用户线程包
中的实现,因此不须要内核调用。而Java中的互斥量会有所不一样,仍是须要进行系统调用,由用户态切换到内核态。 JVM规范未指定Java线程须要用哪一种线程模型。
常见线程实现方式有如下几种:
使用内核线程实现
内核线程(KLT
, Kernel-Level Thread)是直接由操做系统内核支持的线程,内核完成线程切换,内核经过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
各类线程操做,如建立、析构及同步,都须要进行系统调用
,须要在用户态
和内核态
中来回切换。
程序经过内核线程的高级接口:
LWP
, Light Weight Process)操做内核线程。
每一个轻量级进程
都须要有一个内核线程
的支持,所以轻量级进程要消耗必定的内核资源如内核线程的栈空间,因此一个系统可以支持的轻量级进程的数量是有限的。
内核线程至关于内核的分身,这样能够内核同时处理多件事情,支持多线程的内核叫多线程内核。
使用用户线程实现
咱们上面将的互斥量的实现,即便基于用户线程的。
缺点:
使用用户线程+轻量级进程混合实现
Java线程的实现
JVM规范并无限定Java线程须要那种模型,对于Windows和Linux版本使用的是1:1的线程,映射到轻量级进程中。
经过使用synchronized
,能够实现任意大语句块的原子性单位,使咱们可以解决volatile没法实现的read-modify-write
问题。
Java中的synchronized
关键字也是经过管程实现的,保证了操做单原子性和可见性。
管程通用作法是经过互斥量实现的,这会致使线程挂起阻塞,这种传统的锁称为重量级锁
。
轻量级锁
和偏向锁
的概念。
对象头中记录了对象的锁类型,咱们再来回顾一下对象的内存布局:
咱们先来介绍下轻量级锁和偏向锁。
之因此叫轻量级锁
,是与互斥量致使线程挂起阻塞这种重量级锁
对比的叫法,没错,我就是比互斥重量级锁轻巧多了。
从未锁定到轻量级锁定的过程仍是有点繁琐的,涉及复制Mark Word
,CAS
指定锁记录,指定失败的状况下可能还须要膨胀为重量级锁
;
CAS
替换Mark Word
,替换失败则说明有其余线程在等待获取锁,这个时候在释放锁的同时须要唤起其余线程。
Mark Word
的变化:
所谓偏向锁,就是在数据无竞争的状况下,消除同步原语,进一步提升运行性能。
轻量级锁
在无竞争状况下使用CAS消除同步使用的互斥量,偏向锁
在无竞争的状况把整个同步都消除了,更加轻量级。
为何叫偏向锁
,觉得偏爱呀,总是偏袒第一个获取到他的线程。
若是开启了偏向锁(JDK1.8默认是开启的),那么当锁对象第一次被线程获取的时候,虚拟机就会尝试设置为偏向锁模式:
一旦有其余线程竞争,那么偏向模式就结束了。
为了提升synchronized的性能,HotSpot虚拟机团队在JDK 1.6版本花费了大量精力进行锁优化,包括:
自旋锁
:
-XX:PreBlockSpin
;
自适应自旋锁
:
锁消除
:
即时编译器
干的活,通常经过逃逸分析
的数据支持进行锁消除
,通常程序员都不会直接在单线程代码中显示的使用锁,可是有时候虽然只有一行代码:
str = "a" + "b" + "."
可是在JDK5以前底层是翻译为了StringBuffer的append()操做,该方法是包含synchronized
锁的,因此这种状况及时编译器仍是会进行锁消除。
锁粗化
:
固然,上面说起的锁升级,也是锁优化的一种手段。
对于同一个锁,若是一个线程成功进入了临界区,那么该线程在持有锁的同时,能够反复进入该锁。
每退出一个synchronized方法块,计数器就-1,直到0的时候就释放锁。
为何说它是一把悲观锁呢,由于假设有一个线程获取到了锁,那么其余尝试获取锁的线程只能等待,因而悲观的去睡觉了,等到别人叫醒以后才从新去竞争获取锁。
咱们在各类文章书籍里面可能会看到对锁的各类分类,都是什么意思呢?
乐观锁老是很乐观的认为不会有太多人会抢占锁,因此通常不会先进行加锁,等到出了问题以后再处理。
对于乐观锁,可能若是发现的确出现了问题,通常会经过自旋,或者直接放弃等的方式进行处理。
锁以乐观锁适用于并发写入少,大部分是读的场景。
悲观锁就是很悲观的认为会有不少人想占用这个锁,悲观锁为了保证本身能够拿到锁,一上来就尝试锁定,若是锁不住,那就放弃了,直接睡觉去了,也就是线程挂起,等到下次有人叫他起床的时候,才会从新参与到锁的竞争中来。
对于竞争比较激烈,临界区消耗比较多的时间的场景,比较适合悲观锁。
这个概念,详细看完上文的你应该比较了解了,就是获取锁失败以后,循环重试。
这个概念,详细看完上文的你应该比较了解了,就是获取锁失败以后,挂起线程。
又称读锁,既然共享了,那么就不能随便删除和修改了。
跟共享锁不同,排他锁就是一旦获取到了他以后,其余线程就不再能获取到了。
这个比较容易理解,咱们在上面讲synchronized的时候已经介绍了。
可重入锁在反复屡次使用同一个锁的场景下,避免了死锁的发生。
公平锁,就是彻底按照请求顺序来分配的锁,保证了对全部线程公平。
跟公平锁不同,是不彻底按照请求顺序来处理的。
Java并发包中的
ReentrantLock
锁就提供了非公平锁和公平锁的实现。
为何要有公平锁呢?
可是轮到一个号以后,假如那个号的人恰好去外面买东西了,若是你们要继续等它回来办理,就会很花时间,因而索性让人去抢柜台窗口,先抢到的人就先办理,若是连续两次都抢位失败了,那么咱们就把这我的放入排队队列,等到抢到窗口的人办理完了业务,再轮流叫唤他们,这就是非公平锁。
可是若是窗口的那我的办理业务的时间好久,忽然叫一大波人冲上来抢窗口,是抢不到的呀,也就是说对于业务执行时间很长的场景,非公平锁其实效率并不高。
很明显,公平锁吞吐量小,但能够保证每一个线程在等一段时间总有机会执行;
而非公平锁吞吐量更大,可是可能有些线程会长时间得不到执行。
能够响应线程中断的锁,如ReentrantLock.lockInterruptibly()。
不能够响应线程中断的锁,如ReentrantLock.lock()。
其实Java并发包中针对不一样的使用场景,也提供了不少的锁,咱们能够直接拿来用。
好了,咱们今天就讲到这里了,可以看到这里的朋友们是真的很热爱技术,想对大家说:
本文为arthinking基于相关技术资料和官方文档撰写而成,确保内容的准确性,若是你发现了有何错漏之处,烦请高抬贵手帮忙指正,万分感激。
你们能够关注个人博客:
itzhai.com
获取更多文章,我将持续更新后端相关技术,涉及JVM、Java基础、架构设计、网络编程、数据结构、数据库、算法、并发编程、分布式系统等相关内容。
若是您以为读完本文有所收获的话,能够关注个人帐号,或者点个赞。
关注个人公众号,及时获取最新的文章。
《现代操做系统》
《深刻理解Java虚拟机:
Java并发编程—细说J.U.C下Lock的分类及特色详解(结合案例和源码)
本文做者:
arthinking 博客连接:
https://www.itzhai.com/cpj/process-synchronization-and-lock.html 版权声明:
BY-NC-SA
许可协议:创做不易,如需转载,请务必附加上博客连接,谢谢!