Go语言工程实践之并发编程 | 青训营

115 阅读11分钟

串行,并发与并行

  • 串行

    串行指任务按照顺序依次执行,每个任务需要等待前一个任务完成后才能开始执行。在串行模式下,任务是单线程执行的,只有一个任务在运行,其他任务需要等待,也即任务的执行是按线性顺序进行的,因此任务的执行时间相对较长,适用于较为简单的任务。

    值得注意的是,串行并非都是单核的,是否单核取决于计算机的硬件或者是操作系统。在多核处理器或多台计算机上,也可以通过将串行任务分割为多个子任务,并利用多个处理器核心或计算机来并行执行这些子任务。这样虽然整体上还是串行执行,但可以利用多核资源提高整体性能。

  • 并发

    并发指多个任务在逻辑上同时进行,通过任务切换的方式交替执行。在并发模式下,任务之间可以相互切换执行,每个任务都有可能被暂停和恢复。并发可以提高系统的吞吐量,但并不一定加速任务的处理速度。

    需要注意,并发可以借由多线程、进程或协程来实现,在单线程中也有异步编程、回调机制、事件驱动的方式来实现并发。在多线程编程中,即便是单核也可以通过并发来营造出多个任务同时执行的假象,就像一个人即使并不能同时干很多件事,但可以一会儿干这件,一会儿干另一件,当然这有时并不如先做完一件事再做另一件事来得效率高。

  • 并行

    指多个任务真正意义上的同时执行,互不干扰。在并行模式下,多个任务通过同时利用多个处理器核心或多台计算机来实现并行执行,每个任务都可以独立地执行。并行可以充分利用硬件资源,加速任务的处理速度。

    需要注意,多核只是实现并行的一种方式,除此之外,在分布式系统中,并行还可以通过将任务分发到多台计算机上并行执行,每台计算机独立地执行部分任务来实现;一些硬件设备,如图形处理器(GPU),也可以用于并行计算。GPU 拥有大量的并行处理单元,在某些情况下可以通过 GPU 并行计算来加速任务的执行。

Go 语言在语言层面提供了丰富的并发编程机制,特别是通过 Goroutine 和 Channel 的概念,可以轻松实现并发。Goroutine 是 Go 语言中的轻量级线程,可以并发执行函数或方法,而不需要像传统线程那样消耗过多的系统资源。通过 Goroutine,可以方便地实现任务的并发处理,提高程序的效率。

另外,Go 语言的 Channel 是一种用于多个 Goroutine 之间进行通信和同步的机制。通过 Channel,不同的 Goroutine 可以安全地发送和接收数据,以实现协调和共享状态。这种通信机制使得编写并发程序变得简单和可靠。

与此同时,Go 语言也支持并行计算。通过使用 Go 语言中的并发原语,例如并发执行的 Goroutine 和同步的 Channel,可以将任务分解为独立的子任务,并发地执行它们。如果运行环境支持多核处理器或多台计算机,这些子任务可以在不同的处理器核心或计算机上并行执行,从而充分利用硬件资源来加速任务处理速度。

进程、线程与协程

  • 进程:进程是操作系统进行资源分配和调度的基本单位。每个进程都有独立的内存空间和系统资源,包括程序代码、变量、文件句柄等。不同进程之间的通信需要通过进程间通信(IPC)机制,如管道、套接字等。进程之间相互独立,一个进程的崩溃不会影响其他进程的执行。

  • 线程:线程是操作系统能够进行运算调度的最小单位。一个进程可以包含多个线程,这些线程共享同一进程的资源,如内存空间、打开的文件等。线程之间可以通过共享内存进行通信。多线程可以实现并发执行,提高程序的性能和响应速度。

  • 协程:协程是一种用户级的轻量级线程,也被称为纤程。与操作系统线程不同,协程由用户程序控制,而非操作系统。协程可以在一个线程内实现多个协作任务的切换,避免线程上下文切换的开销。协程之间的切换由程序员主动触发,通常使用特定的关键字或函数来实现切换。协程适用于需要高度协作和交互、频繁切换任务的场景。

协程只是一个特殊的函数,一个线程里可以有多个协程,线程是内核态的,栈为 MB 级别,而协程可以看成是轻量级的线程,同时是用户态的,栈为 KB 级别。另外,多个协程在一个线程内是串行执行的,没法运用 CPU 的多核能力。

Goroutine

接下来我们通过下面这段代码来简单阐述一下 Go 语言中并发的效果:

package main

import (
	"fmt"
	"time"
)

func hello(i int) {
	println("hello goroutine : " + fmt.Sprint(i))
}

func main() {
	for i := 0; i < 5; i++ {
		go func(j int) {
			hello(j)
		}(i)
	}
	time.Sleep(time.Second)
}

以上代码的运行结果如下:

image.png

0 ~ 4 的数按随机顺序输出,这说明不同 Goroutine 的完成时间是不确定的,先调用的未必就会先执行完毕,这也就是并发执行的一大特点——执行顺序不确定,其完全取决于调度器的调度策略。另外,协程实现的执行模式为异步执行,它允许程序在执行某个操作时无需等待该操作完成,而是继续执行后续的操作。

不过现在可能还是有个疑问:为什么在主函数体的最后要加上一个 time.Sleep(time.Second) 呢?这就要涉及到并发编程中主 Goroutine 和子 Goroutine 的关系了。

在 Go 语言的并发编程中,main 函数本身就是一个主 Goroutine,虽然在主函数里创建了很多子 Goroutine,但是两者之间并不是包含式的执行关系(主 Goroutine 最先开始,最后结束),而是并列执行关系,也就是说主 Goroutine 并不会等子 Goroutine 执行完毕后再执行,而是自己执行下去,这样的话很可能子 Goroutine 还没执行完主程序已经跑完了,输出就被强制中断了。所以要在最后加一个延时函数,让主程序等一下,不过这种等待方式并不是最为理想的,因为等待时间是固定的,并不知道等待多久是合适的,因此使用创建 sync.WaitGroup 对象等同步机制或者是通道对子 Goroutine 是否执行完毕作出判断再决定主程序是否继续执行会更好。

CSP

CSP(Communicating Sequential Processes)是一种并发计算模型,描述了一组通过通信进行交互的并发进程,这些进程通过发送和接收消息来进行通信。

CSP 模型中的进程是顺序执行的,每个进程按照自己的节奏进行操作,通过在固定的通道上发送和接收消息来进行通信。进程之间通过共享通道进行信息交换,而不是共享内存。通过严格控制进程间的通信,CSP 模型可以避免竞态条件和其他并发问题。

CSP模型具有以下几个关键概念:

  1. 进程(Process):CSP 模型由一组独立运行的进程组成,每个进程都有自己的代码和内部状态,按照顺序执行。
  2. 通道(Channel):通道是进程之间进行通信的媒介。进程可以通过往通道发送消息或从通道接收消息来进行通信。通道可以有容量限制,也可以是无缓冲的。
  3. 操作(Operation):发送和接收消息是进程与通道进行交互的基本操作。发送操作将消息发送到通道,而接收操作将从通道接收消息。这些操作可能会阻塞,直到满足某些条件。
  4. 选择(Select):进程可以通过选择语句从多个可用的通道中选择一个进行操作。选择语句会根据通道的可用性来决定执行哪个操作。
  5. 并发(Concurrency):CSP 模型中的进程是并发执行的,每个进程独立运行,它们之间通过通信进行交互。

CSP 模型被广泛应用于并发系统的设计和分析。它提供了一种结构化的方式来描述并发问题,并可以通过形式化验证方法来验证并发系统的正确性。CSP 模型对于解决竞态条件、死锁、活锁等并发问题非常有帮助,并促进了并发编程的安全性和可靠性。

共享内存是指多个并发进程或线程可以同时访问和修改的一块内存区域。多个进程或线程可以通过读取和写入共享内存来进行通信和数据交换。为了保证共享内存的安全访问,需要使用临界区机制。临界区是指一段代码或代码片段,在同一时间只能有一个进程或线程执行。在进入临界区之前,进程或线程需要获取相应的同步锁或互斥锁,以确保在临界区内的代码不会被其他进程或线程中断。这样可以防止并发访问共享内存引发竞态条件。至于 CSP 模型为什么提倡通信而非共享内存,是因为共享内存在数据并发访问时可能会存在一系列问题:

  • 竞态条件:指多个进程同时访问共享内存时,由于执行顺序的不确定性,导致结果的正确性取决于进程的执行时序。这可能导致数据不一致或者不可预测的结果。

  • 死锁:使用共享内存时,需要采取额外的同步机制来确保并发访问的正确性。这可能包括使用互斥锁、读写锁等,以保证在任意时刻只有一个进程能够访问共享内存。然而,过多的锁操作会导致性能下降,并且容易出现死锁的问题。

相比之下,通过通信进行共享,即每个进程通过发送和接收消息进行通信,可以避免上述问题。通过发送和接收消息,进程之间的交互可以更加明确和可控,避免了对共享内存的直接访问。这样就不需要担心竞态条件和数据一致性的问题,同时也减少了对锁等同步机制的需求。

此外,通过通信进行共享还具有良好的封装性和模块化特性,不同进程之间的通信更加清晰可见。这使得系统更易于理解、调试和维护。

Go 语言作为一门并发编程语言,在设计上采用了 CSP 模型的一些核心概念,并提供了相应的语言特性来支持并发编程。

在Go语言中,主要的CSP模型概念有:

  1. Goroutine:Goroutine 是 Go 语言中的轻量级线程,它可以独立执行,并发地与其他 Goroutine 同时运行。每个 Goroutine 都是由 Go 运行时系统所调度,它们之间通过通信进行交互。
  2. Channel:Channel 是 Goroutine 之间进行通信的机制。Channel 提供了一种类型安全、同步的方式,用于在 Goroutine 之间传递数据。通过 Channel,Goroutine 可以发送和接收消息,实现了明确的通信接口。
  3. Select 语句:Select 是 Go 语言中的一种控制结构,用于选择多个通信操作中的一个进行处理。通过 Select 语句,可以监听多个 Channel 的发送和接收操作,并根据情况进行相应的处理,从而实现非阻塞的并发控制。

Go语言的并发模型基于CSP模型,通过 Goroutine 和 Channel 的组合,使得并发编程变得简单和可控。开发者可以使用 Goroutine 来并发执行任务,并通过 Channel 进行通信和同步,避免了共享状态的使用和直接操作共享内存的风险。

Channel

关于 Go 语言的通道,之前在学 Go 的基础语法时已经了解一些了,是一种用于在 Goroutine 之间进行通信和同步的特殊类型,可以使用 make 函数创建通道并指定通道中元素的类型和通道缓冲区的容量大小,通道的用法主要有以下几种:

  1. Channel 的发送和接收: 使用 <- 运算符可以在 Goroutine 中发送数据到 Channel,以及从 Channel 中接收数据。发送操作和接收操作都是阻塞操作,即当没有对应的发送或接收操作时, goroutine 会被阻塞等待。

    • 发送数据到Channel:可以使用ch <- data将数据data发送到Channel ch中。

    • 从Channel接收数据:可以使用data := <- ch从Channel ch中接收数据,并将其赋值给变量data

  2. Channel 的同步: Channel 还可用于在不同的 Goroutine 之间进行同步操作。通过 Channel 的阻塞特性,可以实现等待其他 Goroutine 完成某个操作的效果。

    • 没有接收者时的发送阻塞:当 Channel 中没有接收者时,发送操作将被阻塞,直到有接收者为止。

    • 接收者等待发送阻塞:当 Channel 中没有数据可用时,接收操作将被阻塞,直到有数据发送为止。

  3. 单向 Channel: 可以通过类型限定来创建单向 Channel,即只能用于发送或只能用于接收。例如, ch := make(chan<- int) 表示只能发送 int 类型数据的 Channel,ch := make(<-chan int) 表示只能接收 int 类型数据的 Channel。

  4. 关闭 Channel: 通过调用内置的 close 函数可以关闭一个 Channel。关闭后的 Channel 不能再发送数据,但仍然可以从中接收已有的数据。

    • 接收已关闭 Channel 的返回值:在关闭的 Channel 上进行接收时,如果 Channel 中没有数据可用,接收操作将立即返回一个零值和一个布尔标志,标识 Channel 是否已关闭。

另外,Channel 也具有两种类型,一种是有缓冲通道的,一种是没有缓冲通道的。两者具有以下的区别:

  1. 无缓冲通道:

    • 特点:

      • 发送操作和接收操作是同步的。当发送操作完成后,发送者会被阻塞,直到另一个 Goroutine 进行接收操作。
      • 收操作必须在发送操作之前完成。如果没有接收者,发送操作将无法执行,发送者也会被阻塞。
    • 应用场景:

      • 适用于两个 Goroutine 之间的直接通信和同步。无缓冲通道可以确保数据的同步传输,即发送和接收操作在不同 Goroutine 中按照特定顺序进行。
  2. 有缓冲通道:

    • 特点:

      • 通道具有一个缓冲区,可以存储一定数量的元素。缓冲通道允许发送操作在缓冲区未满时立即完成,而无需等待接收操作。
      • 当缓冲区已满时,发送操作将被阻塞,直到有接收者从缓冲区中取出数据。
      • 当缓冲区为空时,接收操作将被阻塞,直到有发送者将数据放入缓冲区。
    • 应用场景:

      • 适用于解耦发送和接收操作的时间或处理速度不一致的情况,其中一个 Goroutine 可以在缓冲区已满的情况下继续工作。

接下来通过一个例子来更加深刻地理解 Channel 的实际应用:

package main

import "fmt"

func main() {
	src := make(chan int)
	dest := make(chan int, 3)
	go func() {
		defer close(src)
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	go func() {
		defer close(dest)
		for i := range src {
			dest <- i * i
		}
	}()
	for i := range dest {
		fmt.println(i)
	}
}

在以上代码中,首先创建了两个 int 元素,区别在于一个通道有缓冲,一个没有。接着创建两个协程,第一个协程是向无缓冲通道内传入整数,第二个协程则是接收无缓冲通道的数据并在计算平方后由缓冲通道接收,最后将缓冲通道内的数据全部输出,得到的是0 ~ 9的平方。

需要注意的是,在第二个协程中 for i := range src { 也是在接收通道的数据,同样满足通道的阻塞原则(其实就是数据可以由 -> 传入通道,通道中的数据也可以通过赋值传给数据)。

继续分析,如果 src 通道的关闭先于第二个协程中循环的结束,会对最终值的输出有影响吗?答案是不会的。因为通道关闭虽然会令该通道无法接收数据,但是依旧可以对该通道内的数据进行读取。如果直接对通道内的数据进行读取(比如这种形式 i := <-src),那么其实每一次读取会返回两个值,一个是本次读取的数据,另一个是本次读取是否成功的标志,如果通道内没有数据可供读取,则返回该通道内数据类型的零值以及 false (读取失败的标志)。

但是如果使用 range 读取通道数据的话,只能返回通道值,且通道内没有数据循环会自动终止。

上述代码的结果其实跟缓冲不缓冲关系确实不大。但是我还发现了一个有意思的现象:如果把最后一个循环的 dest 改成 src,会发现输出结果只能是 0 ~ 9 中的一部分,因为不同协程读取一个通道的数据是存在竞争的,通道内的数据只能被读取一次。

具体来说,当多个协程同时尝试从通道中读取数据时,Go 语言的调度器会选择其中一个协程进行读取。被选中的协程会成功读取通道中的一个数据元素,并且将该数据从通道中移除。其他协程会继续等待下一次调度机会,以便再次尝试读取通道中的数据。

这意味着对于每个协程而言,它们读取到的数据是互不重复的,并且无法保证每个协程都能读取到完整的通道内数据。哪个协程先读取到数据是不确定的,取决于调度器的调度策略和协程之间的竞争情况。

并发安全锁和等待组

接下来先对并发安全锁的概念及原理有一个大致的了解:

并发安全的锁是一种用于控制并发访问共享资源的机制,以确保在多个并发执行的线程或协程中,同一时间只有一个线程或协程可以访问该共享资源。

在并发编程中,多个线程或协程同时访问共享资源可能导致竞态条件,从而引发数据不一致或错误。为了避免这种情况,我们可以使用锁来保护共享资源。

锁提供两个基本操作:上锁和解锁。在访问共享资源之前,线程或协程必须尝试获取锁(上锁),如果锁已被其他线程或协程持有,则当前线程或协程将被阻塞,并等待其他线程或协程释放锁(解锁)。一旦获得锁,线程或协程可以自由地访问共享资源,完成操作后再释放锁。

锁的使用确保了对共享资源的互斥访问,即同一时间只有一个线程或协程能够访问共享资源,提供了并发安全性。这样可以防止数据竞争、保证数据的一致性和完整性,从而确保程序的正确性。

常见的锁机制有互斥锁和读写锁。互斥锁适用于对共享资源的独占式访问,而读写锁允许多个线程或协程同时读取共享资源,但只允许单个线程或协程进行写入操作。

Go 语言标准库中的 sync 包用于提供同步和并发编程的基本工具。它包含了多种用于协调并发操作的类型和函数。见如下案例代码(来自青训营的课堂):

package concurrence

import (
	"sync"
	"time"
)

var (
	x    int64
	lock sync.Mutex
)

func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x += 1
		lock.Unlock()
	}
}
func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("WithoutLock:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)
}

func ManyGoWait() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			defer wg.Done()
			hello(j)
		}(i)
	}
	wg.Wait()
}

在以上代码中,sync 包被导入并使用了其中的 MutexWaitGroup 类型。下面是这两个类型的简要说明:

  1. Mutexsync.Mutex 是互斥锁的一种实现,它用于保护共享资源在并发访问时的一致性。通过调用 Lock()Unlock() 方法,可以在某段代码执行期间对互斥锁进行上锁和解锁。当一个 goroutine 获得了互斥锁的所有权后,其他 goroutine 就无法同时获取该锁,直到该 goroutine 释放了锁。这样,互斥锁可以用来确保对共享资源的安全访问。

  2. WaitGroupsync.WaitGroup 是一个等待组,用于等待一组 goroutine 执行完毕。它维护了一个计数器,初始值为 0。通过调用 Add() 方法增加计数器的值,在每个 goroutine 的末尾调用 Done() 方法减少计数器的值。主程序可以调用 Wait() 方法来阻塞,直到计数器的值变为 0,表示所有 goroutine 都已经执行完毕。这个机制可以用来等待并发任务完成后再继续执行主程序的逻辑。

接着就是对四个函数进行分析,其中 addWithLock()addWithoutLock 均代表对 x 递增 2000 次,而区别在于前者递增时上锁,递增后解锁,后者则是正常递增。

在函数 Add() 中,先将 x 初始化为 0,接着通过循环创建 5 个不上锁的递增子协程,然后让主协程停顿 1 秒,目的在于等待 5 个子协程执行完毕,再输出 x 的值,再然后重复上述操作,只是创建的协程变成了上锁的;在函数 ManyGoWait() 中,先是创建了一个等待组变量 wg,接着将该变量的计数值调为 5,然后创建 5 个子协程,每执行完一个计数值减 1,最后用 wg.Wait() 等待所有子协程执行完毕。

最后再来看 Add()ManyGoWait() 函数的运行结果:

image.png

image.png

需要注意,在未加锁的条件下,由于竞态条件的存在,多个 goroutine 可能会同时读取和修改 x,很难说会不会出现并发冲突,没有同步加上执行顺序不确定导致结果也不确定,此时 x 的值既可能等于 10000,也可能小于 10000。