资源竞争问题

当我们有两个线程同时操作同一块儿内存空间时,就容易出现资源竞争问题,这种问题目前有各种各样的出现情况,但在分布式系统中,或者说多线程任务中都是需要处理的

多线程是线程阻塞通过调度器去协调处理的,那么按理说即使是多线程实际上也都是串行操作,为什么也会出现资源竞争呢?

如下图

osnXcQ.png
osnXcQ.png

当进入线程操作时,会先把变量存的值存入寄存器,待到处理完后在重新放回i的所属内存空间,那么当在放回内存时就有可能覆盖掉上一个线程操作的值,也就出现了资源竞争

解决策略

互斥

由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该 被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。

osKHSS.png
osKHSS.png

这种行为也被称为 互斥,通过阻塞完成

同步

另外还有一种Go语言中常用的操作是 同步,当任务一和任务二同时执行时,任务二的某处任务需要等到等到任务一执行完得到的数据才能继续执行任务二,相当于Go中的信道阻塞select,只有取到数据才执行否则就一直阻塞

原子操作与锁

原子操作,类似于事务,原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态

锁的概念与进程资源锁一样,进程中通过信号判断是否可以执行,而在线程中,可以通过代码来处理

声明一个变量存储 0 1 为占用状态,为 0 时进入等待队列,使用while循环等待,直到为1时才可执行,这只是一种最简单的锁

信号量

信号量则是一个类似线程池的概念,通过信号量控制线程数,当一个方法被多个线程调用时,每个线程进入方法时就增加信号量的值,直到一个负荷后阻塞后来的线程进入等待队列,当信号量发生变更时再决定是否要让新的线程进入

而信号量同样可以适用于更丰富的场景,当程序需要同时执行两块儿代码段时,那就需要占两个锁,而进入第一个锁时,另外的程序占用了第二块儿程序,就无法出现同时占两个锁的情况,也有可能造成同时的阻塞,通过信号量则可以处理此类问题,将程序1加入信号量,程序2也加入信号量,当信号量为2时可以执行两个程序,如果线程1占用了一个锁,那么线程2看到自己没有抢到锁且信号量为1则直接进入等待队列,线程1则继续拿第二个锁,程序执行完时释放加回信号量即可

读写问题

「读-读」允许:同一时刻,允许多个读者同时读
「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
「写-写」互斥:没有其他写者时,写者才能写

通过信号量来处理读写问题,所有的操作都存储于队列,当写操作开始时,所有其他任务都终止。即当写操作的信号量为0时才可以进行读操作,读操作信号量不为0时写操作不允许执行,所有任务以队列形式进入,因此,此为公平的读写操作

锁的安全性

deadlock

死锁,一个生产环境上最为可怕的错误之一,他的形成原因大概可以被解释为,程序1和程序2都在等待对方结束才可执行,结果造成了无限期的等待,程序就此卡死,典型的死锁场景

死锁一般长出现于并发线程场景,满足如下条件时会发生

  • 互斥条件,互相等待对方结束后才可继续执行,一次两个或多个线程同时阻塞
  • 持有并等待条件,持有资源一后需要拿到资源二的时候发生,不释放资源一(参照哲学家就餐的问题,可以按照规定顺序拿资源解决,按场景使用信号量解决)
  • 不可剥夺条件,当线程A持有资源时,必须等到线程A结束时线程B才可获取
  • 环路等待条件,两个线程获取的资源形成环形链,死循环造成死锁

在Go语言中可以使用pprof来跟踪堆栈获取死锁行为的信息

死锁解决策略

所有的资源全部按顺序获取,例如强制线程来的时候先拿A资源再拿B资源,当A信号量不足时线程阻塞等待,直到 A,B资源同时被上一个线程拿到后再释放信号量

这么做的目的是为了资源的合理分配,而这种情况也会导致资源的缓慢阻塞问题,要最大化的利用则可以根据场景去制定更好的合理化资源分配方案

自旋锁还是互斥锁

简单来说,互斥锁是通过内核态由CPU调度处理的锁,当程序没有拿到锁的时候会直接释放CPU,进入睡眠等待,也就是上下文切换,等到拿到锁的时候再唤醒

自旋锁是发生于用户态的锁,由用户程序控制,通俗地讲使用while去轮询获取锁,也算是阻塞等待了,这种操作在Go语言中还挺常见的(所以我总是认为Go的设计理念很大部分都是基于用户态的),CPU提供了PAUSE函数去等待获取锁,相对于while他可以有效减少耗电量,自旋锁可以通过CPU去加锁,CPU提供了CAS函数来加锁,也可以用户自定义锁的方式来做

是否有必要读优先或者写优先

字面意思,读优先是,不管什么情况下,读写锁,只要有读操作的线程加入,那么写就要向后排,直到所有的读操作结束写操作才能结束,而写优先则反之

这种情况可以有效地解决一些并发场景,但一昧的单条优先行为,可能会导致 读或者写长期阻塞(饥饿),这并不是开发者愿意看到的,所以使用这种读写优先锁,还是要谨慎

悲观锁和乐观锁

首先,为了安全性稳定性,我们上述的所有操作都属于悲观锁

乐观锁是什么呢?它的工作方式是,先修改完共享资源,再验证这段时间内 有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作,释放寄存器。

反之悲观锁是只要占用上就不能继续操作

但某些场景下,为了效率,有的人也推荐使用乐观锁

例如在线文档,如果使用悲观锁,那么有人进入文档操作时,剩下的人都得等待,直到编写完成后才能查看或写入,使用乐观锁则可以通过数据判断修改内容,达成在线多人文档的需求

我们常用的GitSvn等常见的版本控制工具也都是借鉴了这种想法思路