在学习并发编程的前提下,我们应该先了解一下并发编程中的一些概念
并发和并行
并发和并行是两个非常容易混淆的概念。它们都可以表示两个或多个任务一起执行,但是偏重点有点不同。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。然而并行的偏重点在于”同时执行”。
并行
指在同一时刻,有多条指令在多个处理器上同时执行。就好像两个人各拿一把铁锨在挖坑,一小时后,每人一个大坑。所以无论从微观还是从宏观来看,二者都是一起执行的。
简单来讲,就是各个线程独立运行,互不影响。
并发
指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。这就好像两个人用同一把铁锨,轮流挖坑,一小时后,两个人各挖一个小一点的坑,要想挖两个大一点得坑,一定会用两个小时。
简单来讲,各个线程共用一个资源交替运行,会有切换但是看起来就像同时发生一样。
并发这么好用的话我们为什么不一直使用并发呢?
1、并发编程模型相对复杂
2、写的不太对的程序有可能导致并发错误
并发编程的目的是为了让程序运行得更快,但是,并不是启动更多的线程就能让程序最 大限度地并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,会面临非常多的挑战,比如上下文切换的问题、死锁的问题,以及受限于硬件和软件的资源限制问题
当某一个数据正在计算的时候,不要让IO闲着,把下一个需要计算的数据拿上来,当现在这个数据计算完了,就可以立马运算下一个数据。接近实时的交互行为使用户的体验更好,这就是并发的优势,减少CPU等待数据的时间。
并发并不能解决资源受限的问题,会由于线程的上下文切换导致更慢了。
进程、线程、协程
进程
- 进程是程序一次动态执行的过程,是程序运行的基本单位。
- 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。
- 进程占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、页表、文件句柄等)比较大,但相对比较稳定安全。协程切换和协程切换
线程
- 线程又叫做轻量级进程,是CPU调度的最小单位。
- 线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。
- 多个线程共享所属进程的资源,同时线程也拥有自己的专属资源。
- 线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
协程
- 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。
- 一个线程可以拥有多个协程,协程不是被操作系统内核所管理,而完全是由程序所控制。
- 与其让操作系统调度,不如我自己来,这就是协程。
协程不是进程也不是线程,而是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行。所以说,协程与进程、线程相比并不是一个维度的概念。
一个程序可以包含多个协程,可以对比于一个进程包含多个线程,因而下面我们来比较协程和线程。我们知道多个线程相对独立,有自己的上下文,切换受系统控制;而协程也相对独立,有自己的上下文,但是其切换由自己控制,由当前协程切换到其他协程由当前协程来控制。
一个线程的多个协程的运行是串行的。如果是多核CPU,多个进程或一个进程内的多个线程是可以并行运行的,但是一个线程内协程却绝对是串行的,无论CPU有多少个核。毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内可以运行多个函数,但这些函数都是串行运行的。当一个协程运行时,其它协程必须挂起。
协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。
上下文切换
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现 这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切 换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。这样讲可能不太具体,所以我们举个例子:
-
A存数据1000,B存2000,对A和B进行一万次加法;假如单线程来执行,就需要读一次A,做一万次加法,再写回A,读一次B,做一万次加法,再写回B,整个过程花费20004秒。
-
假如多线程并发执行,只有一个核,读A,A线程执行100次,写回A,切换B,计算100次,写回B,切换A,这样下来花费20400秒。
这就像我们同时读两本书,当我们在读一本英文书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。
那么我们应该做些什么来减少上下文切换呢?
如何减少上下文切换
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
CAS算法
CAS的全称是compare and swap(比较并且交换);
CAS是一种能让线程不需要通过阻塞就能够避免多线程安全问题的一种算法,也是乐观锁的技术。它可以不使用锁而保证多线程安全。所以cas也是一种无锁算法。
CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
其实CAS算法实现过程比较简单,就是维护了3个变量:当前内存值V、旧的预期值A、即将更新的值B。通过while循环不断获取当前内存中的数值V,如果V等于A,就把V赋值为B;整个比较并交换的操作是原子操作。
比如说我们有原始数据100,然后A线程是给数据+10,B线程是给数据+20,此时A、B同时去拿这个数据,+10的操作先完成,返回给原始数据,然后+20的返回数据的时候发现数据已经变成110而不是100了,所以此时就会报异常,然后再拿到110的数据再+20最终得到130为正确数据。
死锁
锁是个非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但同时它也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可 用。让我们先来看一段代码,这段代码会引起死锁,使线程t1和线程t2互相等待对方释放锁。
public class DeadLockDemo {
private static String A = "A";
private static String B = "B";
public static void main(String[] args) {
new DeadLockDemo().deadLock();
}
private void deadLock() {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (A) {
try { Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override public void run() {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
}
});
t1.start();
t2.start();
}
}
形成死锁的必要条件
- 互斥条件:进程对所分配到的资源不允许其他进程进行访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源
- 请求和保持条件:进程获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程占有,此事请求阻塞,但又对自己获得的资源保持不放
- 不可剥夺条件:是指进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放
- 循环等待条件:是指进程发生死锁后,必然存在一个进程–资源之间的环形链
处理死锁的几种基本思想
- 预防死锁:通过设置一些限制条件,去破坏产生死锁必要条件的一个或多个来预防死锁
- 避免死锁:在资源分配过程中,使用某种方法避免系统进入不安全的状态,从而避免发生死锁
- 检测死锁:允许死锁的发生,但是通过系统的检测之后,采取一些措施,将死锁清除掉
- 解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来
避免死锁的几个常见方法
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况