当前位置: 首页 > 科技观察

你的线程很可能会出现:安全性、活性、性能问题

时间:2023-03-20 20:08:05 科技观察

在并发编程中需要注意的问题有很多。幸运的是,前人已经为我们总结了它们。主要有三个方面,即:安全性问题、活动性问题、性能问题。下面我就这些问题一一介绍。安全问题相信大家一定听过这样的描述:这个方法不是线程安全的,这个类不是线程安全的等等。那么什么是线程安全呢?其实本质上就是正确性,正确性的意思就是程序按照我们的预期执行,所以不要让我们感到意外。在上一篇《深入底层探究并发编程Bug罪魁祸首——可见性、原子性、有序性 》中,我们看到了很多奇怪的bug,出乎我们的意料,并没有达到我们预期的效果。那么我们如何编写线程安全的程序呢?在上一篇文章中,我们介绍了并发bug的三个主要来源:原子性问题、可见性问题和顺序问题。也就是说,理论上,线程安全的程序必须避免原子性、可见性和顺序问题。是不是说所有的代码都需要仔细分析,看是否存在这三个问题?当然不是,其实只有一种情况需要它:有共享数据,数据会变。通俗地说就是有多个线程同时读取。写入相同的数据。那么如果能够做到数据不共享或者数据的状态不发生变化,那么线程的安全性就可以得到保证。基于这个理论的技术方案有很多,比如线程本地存储(ThreadLocalStorage,TLS)、不变模式等,后面会详细介绍相关技术方案是如何用Java语言实现的。然而在现实生活中,变化的数据必须共享,这样的应用场景还是很多的。当多个线程同时访问同一个数据时,至少有一个线程会写这个数据,如果不采取保护措施,就会导致并发bug。对此还有一个专业术语,叫做数据竞赛(DataRace)。比如上一篇文章中有一个add10K()方法。当多个线程调用它时,会发生数据竞争,如下图。publicclassTest{privatelongcount=0;voidadd10K(){intidx=0;while(idx++<10000){count+=1;}}}那是访问数据的地方吗?我们可以通过加一把锁来保护它来解决所有的并发问题现在?显然不是那么简单。例如,对于上面的例子,我们稍加修改,增加两个由synchronized修饰的get()和set()方法。在add10K()方法中,通过get()和set()方法访问值变量。修改后的代码如下。对于修改后的代码,我们在所有访问共享变量值的地方都加了互斥锁,此时不存在数据竞争。但显然修改后的add10K()方法不是线程安全的。publicclassTest{privatelongcount=0;synchronizedlongget(){returncount;5}synchronizedvoidset(longv){count=v;}voidadd10K(){intidx=0;while(idx++<10000){set(get()+1)}}}假设count=0,当两个线程同时执行get()方法时,get()方法会返回相同的值0,两个线程执行get()+1操作,结果为1,然后两个线程将返回的Result1写入内存。你期待的是2结果是1。这种问题有一个正式的名字叫RaceCondition。所谓竞争条件,就是程序的执行结果取决于线程执行的先后顺序。比如上面的例子,如果两个线程同时执行,则结果为1;如果两个线程前后执行,则结果为2。在并发环境中,线程的执行顺序是不确定的。如果程序出现了racecondition问题,说明程序执行的结果是不确定的,执行结果是不确定的,是一个大bug。下面结合一个例子来说明racecondition,也就是上一篇文章中提到的transfer操作。转账操作有一个判断条件——转账金额不能大于账户余额,但是在并发环境下,如果不加控制,当多个线程同时对一个账户进行转账操作时,可能会出现多余的转出问题。假设A账户余额为200,线程1和线程2都会从A账户转150。下面的代码中,有可能线程1和线程2会同时执行到第6行,这样线程1和线程2都会发现转账150的提现金额小于账户余额200,所以会发生超额转账。classAccount{privateintbalance;/*transfer*/voidtransfer(Accounttarget,intamt){if(this.balance>amt){this.balance-=amt;target.balance+=amt;}}}所以你也可以这样理解race健康)状况。在并发场景下,程序的执行依赖于某个状态变量,类似如下:if(状态变量满足执行条件){执行操作}当线程发现状态变量满足执行条件时,开始执行操作;但是当本线程在执行操作时,其他线程同时修改了状态变量,导致状态变量不满足执行条件。当然,在很多场景下,这个条件并不明确。例如,在前面的addOne示例中,复合操作set(get()+1)实际上隐式依赖于get()的结果。面对数据竞争和竞态条件,如何保证线程的安全?其实这两类问题都可以使用互斥的技术方案,实现互斥的方案有很多。CPU提供了相关的互斥指令,操作系统和编程语言也会提供相关的API。从逻辑的角度,我们可以将它们统称为:锁。在前面的章节中,我们也简单介绍了如何使用锁。相信大家心里已经有很多了,这里就不再赘述了。大家可以结合之前的文章温故知新。Livenessproblem所谓的livenessproblem是指一个操作不能被执行。我们常见的“死锁”就是一个典型的活性问题。当然,除了死锁之外,还有另外两种情况,即“活锁”和“饥饿”。通过前面的学习你已经知道,发生“死锁”后,线程之间会互相等待,会一直等下去。技术表现是线程被永久“阻塞”。但是有时候即使线程没有被阻塞,仍然会出现无法继续执行的情况。这就是所谓的“活锁”。它可以与现实世界中的示例进行比较。路人甲从左边出去,路人乙从右边进来。为了不相撞,两人互相让路。路人甲向右侧让路,路人乙也向左侧让路。人们再次发生冲突。这种情况基本上谦虚几次就可以解决,因为人是可以沟通的。但如果这种情况发生在编程世界中,可能会无休止地“谦虚”,成为不阻塞但仍然无法执行的“活锁”。“活锁”的解决方案非常简单。当你谦虚的时候,试着等待一个随机的时间。比如上面的例子,当路人A发现左边有人,他并没有立即切换到右边,而是等待一个随机的时间再切换到右边;同样,路人B也没有立即切换路线,而是在等待。随机时间再次切换。由于路人A和路人B的等待时间是随机的,所以同时碰撞后再次碰撞的概率很低。“等待一个随机时间”的方案虽然很简单,但是非常有效,在Raft等著名的分布式共识算法中也有使用。如何理解“饥饿”?所谓“饥饿”,是指一个线程因为无法访问到需要的资源而无法继续执行的情况。“不患匮乏,患不均”。如果线程优先级“参差不齐”,当CPU繁忙时,优先级低的线程被执行的机会很小,可能会出现线程“饥饿”;持有锁的线程,如果执行时间过长,也会造成“饥饿”问题。解决“饥饿”问题的方法很简单。解决方案有三种:一是保证资源充足,二是公平分配资源,三是避免持有锁的线程长时间执行。这三种方案中,方案一和方案三的适用场景都比较有限,因为在很多场景下,无法解决资源稀缺问题,很难缩短持锁线程的执行时间。相反,选项2的适用场景相对较多。那么如何公平分配资源呢?在并发编程中,主要使用公平锁。所谓公平锁,就是一种先到先得的方案。线程有序等待??,等待队列前面的线程会先拿到资源。性能问题使用“锁”要非常小心,但如果太小心,也可能会出现“性能问题”。过度使用“锁”可能会导致序列化的范围过大,以至于无法发挥多线程的优势,而我们之所以使用多线程来搞并发程序,就是为了提高性能。所以我们要尽量减少序列化,那么序列化对性能有什么影响呢?假设序列化百分比为5%,与单核单线程相比,使用多核多线程可以加快多少速度?有一个阿姆达尔(Amdahl)定律代表了处理器在并行运算后提高效率的能力。它可以解决这个问题。具体公式如下:公式中的n可以理解为CPU核心数,p可以理解为并行百分比,那么(1-p)就是串行百分比,也就是我们假设的5%。我们假设CPU核心数(也就是n)是无穷大,那么加速比S的极限就是20。换句话说,如果我们的串口率为5%,那么不管我们使用什么技术,我们都可以最多只能将性能提高20倍。所以在使用锁的时候,一定要注意对性能的影响。那么如何避免锁带来的性能问题呢?这个问题很复杂。JavaSDK并发包中之所以有这么多东西,很大一部分原因是为了提高特定领域的性能。但是从程序层面来说,我们可以通过这种方式来解决这个问题。首先,既然使用锁会带来性能问题,那么最好的解决方案自然是使用无锁算法和数据结构。这方面的相关技术有很多,比如线程本地存储(ThreadLocalStorage,TLS)、写时复制(Copy-on-write)、乐观锁等;Java并发包中的原子类也是一个无锁数据结构;Disruptor是一个无锁的内存队列,性能非常好。。。第二,减少锁的持有时间。互斥锁本质上是将并行程序序列化,所以要提高并行度,就必须减少持有锁的时间。这个方案还有很多具体的实现技术,比如使用细粒度的锁。一个典型的例子就是Java并发包中的ConcurrentHashMap,它使用了所谓的分段锁技术(我们稍后会详细介绍这项技术);也可以使用Read-writelock,即读时无锁,只有写时互斥。性能指标有很多,我认为三个非常重要:吞吐量、延迟和并发性。吞吐量:指单位时间内可以处理的请求数。吞吐量越高,性能越好。延迟:指从发出请求到收到响应的时间。延迟越低,性能越好。并发性:指可以同时处理的请求数。一般来说,随着并发量的增加,延迟也会增加。因此,延迟指标一般以并发量为准。比如并发为1000时,延迟为50毫秒。总结并发编程是一个复杂的技术领域。微观上涉及原子性、可见性和顺序性问题,宏观上表现为安全性、活性和性能问题。我们在设计并发程序的时候,主要是从宏观的角度出发,也就是要关注它的安全性、活跃性和性能。在安全方面,要注意数据竞争和竞争条件。在活性方面,需要注意死锁、活锁、饥饿等问题。在性能方面,虽然我们介绍了两种方案,但还是需要具体问题具体分析。针对特定场景选择合适的数据结构和算法。