并发编程基础概念

​ 前言:大家好!今天给大家讲一下并发编程相关概念,这些概念与任何语言无关,无论你使用任何语言都可能遇到这些问题。本篇文章会给每个知识点下一个定义,然后通过举例方式让小白也能轻松理解并发。相信你认真看完这篇文章一定会有所收获。

并发和并行

下面我们将看到一个小故事,用来帮助我们理解并发和并行。故事中电视可以理解为CPU资源,小明看动画片可以理解为任务A,爸爸看新闻可以理解为任务B。

并发

定义:并发是指多个任务在逻辑上同时推进,但在单核CPU上是通过时间片轮转交替执行,多核CPU上可能同时执行多个任务。

例子:有天小明放学后,匆匆地跑回家,因为他最喜欢的动画片即将开播。一进门,他就立刻打开电视看了起来。正当剧情达到最高峰突然插播了一条广告,小明只好失望地去做其他事情。这时,爸爸走过来将频道切换到新闻台观看。没过多久,新闻节目也进入了广告时段,小明便趁机换回动画频道继续观看。就这样,父子俩轮流切换频道,既让小明看上了动画片,也让爸爸看完了新闻。

这里模拟单核CPU, 执行过程如下图:

并行

定义:并行表示任务真正意义上的在同一时刻同时运行,需要多核CPU的支持。

例子:这天小明放学回家时特别开心,因为今天是他最喜欢的动画片大结局。然而,当他兴冲冲地想打开电视时,却发现爸爸正在看新闻。小明向爸爸要遥控器,却被爸爸训斥道:"作业写完了吗?没写完就回屋写作业去!"小明只好垂头丧气地回房间写作业。

后来,为了能准时收看动画片,小明开始缠着爸爸再买一台电视机。经过他坚持不懈的软磨硬泡,爸爸终于同意添置了一台新电视。现在,即使动画片和新闻同时播出,小明可以看他的动画片,爸爸也能安心看新闻了。

执行过程如下图:

线程的由来

在探讨线程的起源之前,我们有必要先来看看操作系统的发展历程。

无操作系统

计算机最初的设计,只能接收用户简单的输入,每当输入一个指令,计算机就做出一个操作。程序执行过程中用户去做别的事情时,计算机就会处于等待状态,这样就会白白浪费计算机的计算能力。

批处理操作系统

无操作系统用户既要输入指令又要输入数据,有的时候还特别容易出错。人工输入的速度是远远慢于计算机运算的速度, 那么有没有什么方法可以实现自动化读取执行的操作呢?

为了解决这些问题,批处理系统就诞生了,它可以把多个任务写在磁带上,然后交由计算机去读取并逐个执行这些程序, 计算机就可以不断读取指令来进行操作,这样可以大大缓解人机矛盾的问题。

进程的诞生

批处理系统的诞生的确增加了计算机使用的效率,但是批处理系统的任务只能串行执行,内存、CPU什么的全被一个任务给占着。

假如现在有A和B两个任务,A任务先执行,当A有大量数据输入(即I/O操作,这里需要提一下I/O操作是非常耗时的),而任务B和CPU只能干等着。也就是说一个CPU同时只能一个一个的处理任务,即使有任务处于阻塞状态其他任务也只能等待。有没有什么方法当任务A有数据输入的时候先让任务B来执行呢?

此时进程就诞生了,操作系统会把多个待执行的任务同时加载进计算机,为每个不同任务分配独立的内存空间。当任务A有I/O操作时,系统会暂时保存任务A的运行状态,并让出CPU资源供任务B执行。 如果CPU采用时间片轮转的方式运行任务此时就达到了并发的效果,即使任务A没有I/O操作等时间片用完也会切换为任务B执行。

线程的由来

进程虽然解决了多任务并发问题,但是每个进程只能处理一个任务,当我有多个任务,只能让这些任务在进程中一个一个的执行。 我们有没有方法让这些任务在进程中同时执行?这时,就提出了线程概念。

 这里肯定有人会说我多开几个进程不一样能让这些任务并发执行吗?这样想确实没错,但是会有几个问题。

  • 进程间的通信比较复杂,而线程间的通信比较简单,复杂的通信机制无疑增加我们开发成本。一般我们多个任务的耦合性比较高,需要大量数据交换,我们把这些任务放到一个进程执行,肯定比开多个进程效果要好。
  • 进程间切换开销是大于线程的,进程创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,而线程只需要保存寄存器和栈信息。

进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位。

同步和异步

定义:同步和异步通常是一种形容方法、函数或接口的调用。同步调用一旦开始,调用方就必须等待方法返回结果,才能继续执行后续的行为。异步调用更像是一种消息传递,调用一旦开始,调用方不需要等待方法返回结果,可以继续执行后续操作。 异步方法调用通常会在另一个线程执行,不会影响调用方的线程。如果想要得到异步方法的返回值,异步方法完成后通常会通知调用者。

例子:在主线程中有三个接口调用很耗时,第2和第3个接口需要依赖第1个接口的返回值,2和3接口没有相互依赖的关系,同时主线程需要等待2和3接口调用的返回结果,后面两个接口就可以使用异步调用(这里因为主线程需要等待2和3接口调用结果,所以可以只使用一个异步调用)。调用过程如下图:

临界区

定义:用来形容公共资源或者共享数据,可以被多个线程使用。但是每一次只能由一个线程进入临界区使用共享资源,一旦临界区资源被占用,其他线程想要使用这个资源就必须等待。

例子:比如打印机,每次只能有一个线程使用,其他线程想要使用该打印机就必须等待第一个线程完成打印任务。

阻塞和非阻塞

阻塞和非阻塞通常来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其它所有需要这个资源的线程就必须在这个临界区外等待。等待会导致线程挂起,这种情况就是阻塞。 如果占用资源的线程一直不释放资源,其它所有阻塞在这个临界区上的线程都不能工作。

非阻塞与阻塞相反,它指的是线程与线程之间互不影响,所有线程都会不断向前执行。

死锁、饥饿和活锁

死锁

定义:死锁是指两个或两个以上的进程/线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。 此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称/线程为死锁进程/线程。

例子:比如有两个线程A和B,另外有两个共享资源a和b,每个共享资源在同一时刻只能有一个线程使用。

  • 当线程A占用资源a,线程B占用资源b;
  • 线程A等待共享资源b释放才能继续执行,线程B等待共享资源a释放才能继续执行;
  • 线程想要释放占用资源需要先获得,每个线程等待对方对方线程释放资源,从而形成一个环路;

如下图:

饥饿

定义:饥饿是指某一个或者多个线程因为种种原因无法获得所需的资源,导致一直无法执行。比如优先级太低,而优先级高的线程不断抢占资源,导致优先级低的无法正常执行。

例子:在自然界中,母鸟给雏鸟喂食的时候很容易出现这种情况。由于雏鸟很多,食物有限,雏鸟之间的食物竞争可能非常厉害,经常抢不到食物的雏鸟有可能会被饿死。

活锁

定义:活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行。当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行。

例子:比如在转弯时,迎面突然来了一个人,于是,你很礼貌的靠左走,避让对方。同时,对方也非常礼貌的靠右走,希望避让你。结果,你们又撞上了。于是,你们都意识到了问题,希望尽快避让对方,你立即向右走,同时他向左边走,结果又撞上了!

线程不安全的原因

原子性

定义:原子性指的是一个操作是不可中断的,要么全部执行完成,要么都不执行。

例子:

在业务上,比如某用户购买了1件商品,这时需要对库存减1操作。首先要把这个操作分解为多步:

  • 查询数据库;
  • 对库存数量减1;
  • 把结果写回数据库;

查询库存还剩10件商品,然后减1变为9件商品,最后写回数据库。乍一看好像没问题,但是如果有两个用户同时购买该商品,他们查询库存结果查到的都等于10件商品,然后他们同时对商品减1算出的结果都是9,最后写回结果发现商品只扣了1件。事实上商品被用户购买了两次, 如下图:

此时减库存这个操作应该被设计成原子性的就没有上述问题,比如加锁和事务机制,这样就能保证每次只有一个线程对库存进行修改。如下图:

在程序中定义一个整数类型变量a = 1。比如对a+=1操作,在多线程环境下也会出现上述减库存的问题,在cpu中有很多指令,每个指令是有原子性的,但是a+=1操作在计算机中是被分解为多条指令:

  • 取数据a = 1;
  • 加1操作;
  • 写回;

可以看到这里操作和上面减库存非常相似,这里就不作过多赘述。如果想要线程安全的操作,可以使用CAS操作或者加锁。

可见性

定义:一个线程对共享变量的修改,其他线程能够立即看到。

栗子:如果在CPU1和CPU2上各运行了一个线程,它们共享变量t,由于编译器优化或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,然后将其缓存在cache中或者寄存器里。这时如果CPU2上的某个线程修改了变量t的实际值,那么CPU1上的线程可能无法知道这个改动,依然会读取cache中或者寄存器里的数据原始值。

有序性

定义:由于指令重排序问题,代码执行顺序未必就是编写代码时候的顺序。

为什么要使用指令重排序?CPU出于性能的考虑,如何高效利用高速缓存(使用空间局部性和时间局部性原理提高缓存命中率),如何高效利用流水线技术。 说白了就是我们写的代码执行的性能不高,重排序后能更高效的执行。指令重排序可以保证单线程语义一致,但是没有义务保证多线程的语义也一致,因为最开始指令重排序并没有考虑多线程情况。

例子:如何高效利用流水线,在CPU中一条指令的执行可以分为取指、译码、执行、访存和写回这五步, 这些操作在CPU中都是独立的模块(我们可以下想象生活中的流水线,比如一条流水线上有5道工序,每条指令可以想象成每个产品需要顺序的经过这5道工序加工)LW表示取数据到寄存器中,ADD表示把寄存器数据相加(SUB表示寄存器数据相减),SW表示把寄存器中的数据保存存储器中。如图展示了a = b + c和d = e - f在没有使用指令重排序执行过程。可以看到在程序执行过程中有很多断层红叉,ADD执行前需要等待R2的数据准备好,所以会等待一个时钟周期,这样就会触发连锁反应后面的执行都会跳过一个时钟周期。 减法同理也会这样。执行过程如下图:

如果使用指令重排序就会避免这样的问题,如下图,可以看到我们只是改变一下执行的顺序就可以节省两个时钟周期,而且在重排序之后不会影响执行的结果。 执行过程如下图:

参考文献

进程与线程的区别 《Java高并发程序设计实战》

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP