likes
comments
collection
share

Go并发编程 | 并发概述引言 并发编程是一种编程范式,无论是自己构建一个 Web 后台程序,理解多任务操作系统,甚至是

作者站长头像
站长
· 阅读数 18

引言

并发编程是一种编程范式,无论是自己构建一个 Web 后台程序,理解多任务操作系统,甚至是使用 cuda 进行并行计算,都需要理解并发编程的思想作为基础。本篇基于近期阅读的并发编程书籍总结。

并发与并行

并发(concurrency)与并行(parallelism)是经常被使用的两个概念,由于汉语相似的字面意思(分布式中的一致与共识也是同理),经常会给初学者带来困扰,我对两者区别的理解是:

  • 并发关注结构,并行关注执行

并发是指系统或应用程序中多个任务的执行时间重叠。由于人类和计算机的行动频率差距过大,在人类感知中时间的不可划分单位可能是眨一次眼睛,但一个 3GHz 的处理器可能已经执行了2亿次简单循环。

在并发模型中,一个处理器在同一时间内切换执行多个任务,任一时刻处理器只执行一个任务的一部分,但在人类视角中这些任务是同时进行的。可以说并发并不关心是否真正同时执行了这些任务,也不关心是在单核还是多核上执行,而更关注任务管理和资源共享的效率,以及用户视角的体验。所以说并发编程是一种提高资源利用率的方式(如从软件层面处理内存墙问题),并发关注程序的结构。

基于种种的并发编程要实现的目标,并发编程演化出了很多编程范式,从最早的多进程,多线程再到协程。Go语言的并发模型就是基于协程和CSP构建的。

而并行指的是同时利用多个计算资源(多核CPU或其他异构硬件)来执行多个任务或一个任务的多个部分。并行实际上是在同一时刻进行多个操作,因此并行可以显著地提升计算速度。

因为关乎具体的执行,并行基于硬件设备的层次可以划分不同的级别,如指令级并行,数据级并行,线程级并行和任务级并行等。

并发编程的挑战

由于人类与计算机时间不可划分单位差距过于巨大,因此编写正确的并发代码是困难的,计算机很有可能出现一些反人类常识的情况。在实际中,有些挑战来自于并发编程本身,也有些来自于为防止并发 BUG 而出现的人为失误,有些甚至只能在小概率情况下才能触发。

竞争条件

竞争条件是最常见的一种并发问题,多个操作需要按照顺序执行,在代码中体现为先启动要先执行操作的线程,再启动后执行操作的线程,但是经过编译的优化和操作系统的调度后,很可能是后面的线程先执行完毕。因为执行顺序的改变导致程序的结果与预期不一致的问题,被称为竞争条件。如不使用任何同步机制按序打印AB时,很可能打印出的是乱序。

其中,涉及到更改数据导致数据不一致的竞争条件被称为数据竞争,如最常见的更新丢失。

原子性

原子性指一个操作在它运行的环境中是不可划分的,如数据库事务的原子性。原子性的保证依赖于它执行的环境,这个环境可以被称作上下文,某个操作在某个上下文中是原子的,但在另一个上下文中可能就不是。如数据库的事务提供了在事务这个上下文中的原子性,但是相应的操作在汇编的层级中可能有上千条,在汇编的上下文中显然不是原子的。

在考虑一个操作时,首先要定义上下文,然后再考虑这个操作是否是原子的。最简单的关于原子性的例子是自增操作i++,从编程语言的语法分析上下文中,这是一个通过编译的最基本的语句是原子的;但是在机器环境的上下文中,需要至少完成 loadaddstore 三步操作而不是原子的,这也是并发自增代码往往达不到预期的结果的原因。

死锁

之前的问题的成因在于并发自身,而接下来的死锁、活锁与饥饿三种问题则是为了处理以上问题错误使用并发原语导致的。

为了处理数据竞争问题,引入了包括锁在内的多种并发原语,例如互斥锁保证了同一时刻只有一个线程可以持有资源。死锁即是多个线程中的每一个都在等待其他进程释放资源,而这些其他进程又同时在等待它释放资源,形成一个无法打破的循环,导致所有相关线程无法继续执行。

出现死锁的四个必要条件如下: 满足死锁的四个条件

  • 互斥
  • 持有并等待
  • 非抢占
  • 循环等待

活锁

活锁类似于死锁,两者都涉及到多个线程无法继续执行。与死锁的静止等待不同,活锁是线程在没有实际进展的情况下不断地改变状态。

活锁一个常见的成因是两个线程试图在没有协调的情况下防止死锁,主动放出以及持有的资源。

饥饿

饥饿指某个线程因为无法获得必要的资源而无法继续执行。饥饿通常发生在系统资源被不断地占据或分配给其他进程,使得一个或多个进程长时间等待。

饥饿的成因可能是不公平的资源分配或优先级倒置,但是错误的编程也有可能导致,如一个线程使用细粒度的锁而另一个使用粗粒度,结果可能是使用粗粒度锁的线程占用了绝大多数的时间片。

Go语言并发概述

CSP模型

在关于并发与并行的讨论中提到并发关注结构,我们对所写的并发代码是否真的并行执行并不知情,这取决于程序之下的抽象层:并发原语、程序运行时、操作系统、操作系统运行的平台(hypevisor、容器、虚拟机)和 CPU。这些抽象层提供了区分并发和并行的根本。在进行并发编程时,需要根据利用到不同的抽象层进行不同的设计,不同的设计就是不同的并发模型。

如以进程作为并发编程的上下文时,因为操作系统的虚拟化,可以减少一些并发问题,但也会降低效率。随着对于抽象层的进一步深入,更细粒度的并发模型可以优化资源的使用效率,也更需要编写正确的并发逻辑。

不同的语言都有一系列的抽象层,Go语言遵循了少即是多的设计理念,提供了 CSP 作为并发模型。

CSP的全称为Communicating Sequential Processes(顺序通信进程),可以概括为通过通信未共享内存,而不是通过共享内存来通信

Go 使用 goroutine 和 channel 来实现 CSP。goroutine可以看作是轻量级线程,而channel则用于在goroutine之间安全地传递消息。这种方式的优点有:

  • 自然的问题建模 Go使开发者能够更自然地按问题的实际逻辑来进行编程,而非过分关注并行处理的技术细节。

  • 优化和性能提升 Goroutines非常轻量,创建和运行成本低,使得在Go中创建数千甚至数万个goroutine成为可能。 Go语言的运行时环境能够进行优化,如调整goroutine的调度策略,以适应并行技术的发展,从而提高程序的性能。这些优化是透明的,不需要开发者干预。

  • 更细粒度的并发控制 Go允许在更细的粒度级别处理并发,这有助于构建动态可扩展的系统,符合Amdahl法则。在处理例如网络请求时,每个请求都可以独立地由一个goroutine处理,而不是依赖于固定的线程池。

Go并发哲学

CSP 是Go语言设计的重要组成部分。然而Go语言还支持通过内存访问同步和遵循该技术的原语来编写并发代码的传统方式。 sync与其他包中的结构体提供了执行锁和使用资源池的方法。

有一个决策树可以判断使用哪种同步原语

Go并发编程 | 并发概述引言 并发编程是一种编程范式,无论是自己构建一个 Web 后台程序,理解多任务操作系统,甚至是

Go的并发哲学可以总结为:追求简洁,尽量使用 channel,并且认为 goroutine 的使用是没有成本的。

转载自:https://juejin.cn/post/7369884289713061939
评论
请登录