以素数生成器为例阐述Go语言的并发机制

并发Concurrency机制可以说是Go语言最重要的特性之一,Go语言的命名就来源于创建并发操作的关键字Go。理解并运用好Go语言的并发就显得十分关键了。本文结合素数生成器的具体实例,从通过通信共享内存的角度初探Go语言的并发机制。

通过沟通共享内存

“Do not communicate by sharing memory; instead, share memory by communicating.”

上面这句话可以说是Go语言并发模型的中心思想了,翻译过来就是:不要通过共享内存进行通信,要通过通信来共享内存。

这句话该怎样理解呢?

  • 并发编程的关键点在于,如何让并发的程序间进行安全有效的信息共享;

  • 在其他语言中,一种主流的并发编程的模型是,对多个线程公用的数据进行显式的加锁,通过对同一份公用数据的读写(share memory)进行线程间的沟通(communicate)。这种方式就是”communicate by sharing memory”。这种方式一个显著的缺点就是加解锁很容易出错,并且从语法的角度也更显得复杂;

  • 而在Go语言中,我们可以这样理解并发:

    • 假设我们现在编写了一个跑在单CPU上的单线程程序A,那么在这个程序里就不需要涉及同步原语(比如.加锁)。然后我们再编写一个跑在单CPU上的单线程程序B,那么在这个程序里也不需要涉及同步原语(比如.加锁)。现在让程序A和B之间进行通信,当然通信的时候也不需要同步原语(比如.加锁),同时A和B能够获取到对方的信息。Unix pipelines就是完美适用这种模型的例子。虽然具体实现上有差别,不过Go语言的并发可以看做是Unix pipelines的一种类型安全的实现。

    • 对应到Go语言并发的模型就是,两个在同一个地址空间的Goroutine通过Channel类型的变量实现信息的共享,也就是share memory by communicating。

    • 这种模型显著的好处是,隐藏了操作系统os的线程的创建和管理,不需要进行显示的加解锁操作,大大简化了并发程序编写的程序。其实从心理层面来说,也会让使用者不再”害怕”去编写并发程序。

Goroutines 和 Channels

从上面我们能够了解到实现Go语言并发的两大因素就是Go程(Goroutines)和通道(Channels)类型的变量。下面主要介绍一下这两个关键点有哪些特殊的地方。

Goroutines

之所以叫Goroutines是因为当前现存的描述并发的术语: 线程(threads),协程(coroutines),进程(processes)等等并不能准确描述Go语言并发处理的程序。一个Go程(Goroutines)就是一个可以和处于相同地址空间的Go程并发执行的函数(function)。

Go程是非常轻量廉价的,开启一个Go程最初只需要2KB左右的栈(stacks)空间,后面会根据该Go程的使用情况的需要,申请或者释放堆存储空间(heap storage)。Go程(Goroutines)是对多个操作系统线程的多路复用,由Go语言runtime的内部调度器进行调度。内部调度器负责创建和管理操作系统的线程,以供Go程使用,因此对操作者隐藏了这一部分的复杂性。

Go程(Goroutines) vs 线程(threads):

  1. Go程可以说是Go语言层面的概念,线程是操作系统(OS)级的概念;

  2. Go程是由Go语言内部调度器进行调度,线程是由操作系统进行操控;

  3. Go程开销非常小,初始只需要2KB的栈空间,而线程的初始开销则是MB级别的;

Channels

channels类型的变量可以说就是为了进行Go程间的通信而存在的,并且这种通信是并发安全的,同一时间只有一个Go程能对channel变量进行操作。就像channels的含义”通道”,channels类型的变量就是Go程间的信息通道。这也就能很好解释Go语言并发的中心思想–通过通信来共享内存,channels类型的变量就是安全的通信通道。

从素数生成器看Go语言的并发

素数:又称质数,定义为在大于1的自然数中,除了1和它本身以外不再有其他因数的数称为质数。

一种验证素数的方法:一个数A, 如果小于它的素数全不能被该数A整除,则该数A为素数 (关于该方法正确性的证明,不在本文讨论范围内)

如何去实现一个素数生成器呢?咱们可以这样想:

  1. 首先我们需要一个整数的数据来源,最小素数是2,那么我们需要一个从2开始递增的整数输出源;

  2. 最小的素数是2,那么根据上面的验证素数的方法,下一个素数就是不能被2整除的第一个整数,也就是3;再下一个素数也就是不能被2和3整除的第一个整数,也就是5;依次类推

  3. 也就是说,我们让步骤1中产生的数据源依次通过已找到的全部素数,完全不能被现有素数整除的第一个整数,就是下一个素数。

具体到Go语言的代码实现如下:

源代码链接

The Go Playground在线运行版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"fmt"
"time"
)

//素数生成器

//生成用于筛选的数据源 发送2,3,4,...到通道变量ch
func Generate(ch chan<- int) {

//2是最小的素数,从2开始递增
for i := 2; ; i++ {
//发送i到通道ch
ch <- i
}
}

func Filter(prime int, in <-chan int, out chan<- int) {

for {
//从in通道获取数据
i := <-in
if i%prime != 0 {
//如果i不能被当前的素数prime整除,
//那么就发送给通道out继续进行检验
out <- i
}
//如果i能够被prime整除,说明不为素数,不做处理
}
}

func main() {

//初始通道
ch := make(chan int)

//开辟一个Go程 用于生成数据源
go Generate(ch)

for {

//最新找到的一个素数 这里输出来
prime := <-ch
fmt.Println(prime)

ch1 := make(chan int)
//这里对理解程序很关键 每次循环都产生一个新的Go程
//这个Go程与当前的素数prime绑定,用于监测数据源是否
//可以被这个素数整除。要注意的是,因为Filter内有一个
//无限循环,因此这个Go程一旦产生就会不停运行下去
go Filter(prime, ch, ch1)
ch = ch1

//为了更好的观察数据输出 每次循环暂停一秒
time.Sleep(time.Second * 1)
}
}

解析这个素数生成器:

  1. Generate函数产生用于筛选的数据源,从最小的素数2开始递增,将产生的数据通过通道变量ch传递出去;

  2. Filter函数起到过滤器的作用,prime是和这个Filter函数绑定的素数,用于判断从in的channel传入的数据是否能被prime整除,如果能整除,说明不是素数;如果不能整除,说明通过了这个过滤器的筛选,通过out的channel继续传递给下一个过滤器;

  3. 理解这个素数生成器的关键就是,在main函数的for无限循环中,每次执行go Filter(prime, ch, ch1),就会生成一个上面第2点所说的过滤器,一直运行于后台,接收从上一个过滤器Filter传过来的数据,判断之后将有效的数据传给下一个过滤器。接收的数据溯源到开始处,就是Generate函数产生的数据源,传给和素数2绑定的第一个过滤器,之后依次传递,依次通过目前已有的全部过滤器,如果全部通过了,则说明这个数就是下一个素数。每次for循环里进行到prime := <-ch的时候,这时候ch都是当前的最后一个通道,通过这个通道输出素数

  4. 这个程序很好的展示了,Goroutines之间通过channel类型变量通信来共享内存(被传递的数)的过程,从而演示了Go语言的并发编程。数据在Goroutine之间通过channel依次传递的过程,和Unix pipelines完美匹配,所以说虽然具体实现上有差别(具体实现后面文章会讲到),不过Go语言的并发可以看做是Unix pipelines的一种类型安全的实现。

本文以素数生成器为例,初探了Go语言的并发机制,重点在于”通过通信来共享内存”的中心思想的阐述。当然,Go语言的并发机制还有很多可讨论学习的方面,在后面的文章中会详细探讨。