引言
在学习并发以前,我们习惯用一种简单的顺序叙事方式编程,首先是第一件事,然后是第二件事,第三件 ...... 总之,我们完全掌握着事情的发展。现在,我们来到了陌生的并发世界,你会发现原本值得信赖的事物突然变得不可靠了,比如将一个值设为 5,回头一看又变成 47 了,这就很匪夷所思了。并发就好似使用多故事线并行的叙事方式写的一部小说:第一个间谍在岩石底留下微缩胶片,第二个间谍来取时,胶片已经被第三个间谍拿走了。然而小说并没有交代此处的细节,所以直到故事结尾,我们都没能搞清楚发生了什么?
并行和并发
并行和并发经常被混为一谈,实际上两者是不同的概念:
- 并发是关于正确有效地控制共享资源的访问
- 并行是使用额外的资源来更快地产生结果
并发表示同时完成多个任务,无需等待当前任务完成即可执行其他任务。并发解决了程序因外部控制而无法进一步往下执行的阻塞问题,最常见的例子就是 I/O 操作,任务必须等待数据输入。
并行表示同时在多个位置完成多个任务,将程序分为多部分,在多个处理器上同时处理不同部分以加快程序执行效率。
从定义上看,并发和并行都是“同时完成多个任务”,不过并行增加了跨多个处理器的分布。并发和并行用来解决不同类型的问题,并行可能对于 I/O 密集型问题没有任何增益,因为问题不在于程序的整体执行速度,而在于 I/O 阻塞。而尝试在单个处理器上使用并发来解决计算密集型问题可能也是浪费时间。实际上,两者都是为了能在更短的时间内完成更多的工作,但它们各自实现的方式有所不同,这取决于问题施加的约束。
这两个概念经常被混在一起的原因主要是包括 Java 在内的许多编程语言都使用相同的机制 —— 线程来实现并行和并发。
并发的新定义
并发是一系列专注于减少等待的性能技术
这实际上是一个相当复杂的表述,所以可以将其分解如下:
- 并发技术包含了许多不同的方法来解决这个问题,这使得并发十分具有挑战性。
- 并发的目的在于让你的程序运行得更快,然而它的使用和管理十分棘手,如非必要,不要随意使用。
- 并发的关键是减少等待,因为你只有在等待发生时才能收益。如果你发起一个 I/O 请求后能立即获得结果,没有延迟,那么就无需改进。如果程序的某个部分被迫等待,才有必要使用并发来加快速度。
想象这么一个场景,你必须在一栋大楼找某样东西,而这个东西被巧妙地隐藏在大楼中一千万个房间中的一间。显然,如果你一个人挨家挨户地找,恐怕一辈子也找不完。
假设你有某种超能力,可以像火影忍者一样变出影分身,那好,你变出一千万个自己,每个人搜索一个房间,这样效率自然奇高。但我们回到现实,把一千万个分身换成一千万个处理器核心,显然这就不可能办到了。
我们再增加一个条件:每个分身到达一扇门时,必须敲门等待,直到有人开门。如果每个分身都有一个处理器核心,那没有问题,等就是了。但事实上,我们的机器可能只有八个处理器核心,却有一千万个分身,那好吧,只能每个分身轮流用了。同时,我们不希望处理器因为某个分身刚好在等待应答时被锁住而闲置下来,处理器应该能切换到别的分身上继续执行任务,必要的时候再回来。这就是所谓的减少等待。
并发为速度而生
上面的场景可以简单理解为:处理器从等待(阻塞)的任务切换到准备运行的任务,就大程度利用空闲下来的运算资源。但切换任务是有成本的(创建和切换上下文的开销),也许这会导致你的程序比原来还要慢也说不定。减少上下文切换的方法有无锁并发编程、CAS 算法、使用最少线程和使用协程:
- 无锁并发编程:当多线程竞争锁时,会引起上下文切换,所以我们可以避免使用锁,如将数据 ID 按 Hash 算法分段,不同线程处理不同段的数据
- CAS 算法:Java 提供的 Atomic 包使用 CAS 算法来更新数据,其实现不需要加锁
- 使用最少线程:避免创建不必要的线程
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
并发执行的任务有时会互相干扰,比如一个制作蛋糕的工厂,每个工人各司其职,最后蛋糕完成了,一个工人准备吧蛋糕放进盒子里,突然另一个工人冲了过来,也把蛋糕放进盒子里,啪的一声,两个蛋糕撞到一起摔坏了。这就是常见的共享内存问题,即所谓的竞争状态,其结果取决于哪个工人先把蛋糕放进盒子。这个问题通常使用锁机制解决,比如一个工人先把盒子抱住,以免别人冲过来把蛋糕砸坏。
并发是如此的危险,所以在决定使用它之前要仔细思考,不要随便跳进并发的火坑。如果有别的方法可以让你的程序运行得更快,就请执行该操作,只有在没有其他选择的情况下才可以选择并发。
但并发还是有它的价值所在的。随着我们提高时钟能力的耗尽,速度的提升将以更多处理器核心的形式而非更快的芯片,为了使程序运行得更快,你必须学会利用额外的处理器。另外,并发也能提高单处理器上运行程序的性能,前提是解决等待所带来的收益要高于上下文切换的开销。
并发会带来各种成本,包括复杂性成本,但可以被程序设计、资源平衡和用户便利性方面的改进所抵消。通常,并发性使你能设计出更低耦合的代码,但与其同时,你必须特别留意使用了并发操作的那一部分。