并发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):
Go程可以说是Go语言层面的概念,线程是操作系统(OS)级的概念;
Go程是由Go语言内部调度器进行调度,线程是由操作系统进行操控;
Go程开销非常小,初始只需要2KB的栈空间,而线程的初始开销则是MB级别的;
Channels
channels类型的变量可以说就是为了进行Go程间的通信而存在的,并且这种通信是并发安全的,同一时间只有一个Go程能对channel变量进行操作。就像channels的含义”通道”,channels类型的变量就是Go程间的信息通道。这也就能很好解释Go语言并发的中心思想–通过通信来共享内存,channels类型的变量就是安全的通信通道。
从素数生成器看Go语言的并发
素数:又称质数,定义为在大于1的自然数中,除了1和它本身以外不再有其他因数的数称为质数。
一种验证素数的方法:一个数A, 如果小于它的素数全不能被该数A整除,则该数A为素数 (关于该方法正确性的证明,不在本文讨论范围内)
如何去实现一个素数生成器呢?咱们可以这样想:
首先我们需要一个整数的数据来源,最小素数是2,那么我们需要一个从2开始递增的整数输出源;
最小的素数是2,那么根据上面的验证素数的方法,下一个素数就是不能被2整除的第一个整数,也就是3;再下一个素数也就是不能被2和3整除的第一个整数,也就是5;依次类推
也就是说,我们让步骤1中产生的数据源依次通过已找到的全部素数,完全不能被现有素数整除的第一个整数,就是下一个素数。
具体到Go语言的代码实现如下:
1 | package main |
解析这个素数生成器:
Generate函数产生用于筛选的数据源,从最小的素数2开始递增,将产生的数据通过通道变量ch传递出去;
Filter函数起到过滤器的作用,prime是和这个Filter函数绑定的素数,用于判断从in的channel传入的数据是否能被prime整除,如果能整除,说明不是素数;如果不能整除,说明通过了这个过滤器的筛选,通过out的channel继续传递给下一个过滤器;
理解这个素数生成器的关键就是,在main函数的for无限循环中,每次执行go Filter(prime, ch, ch1),就会生成一个上面第2点所说的过滤器,一直运行于后台,接收从上一个过滤器Filter传过来的数据,判断之后将有效的数据传给下一个过滤器。接收的数据溯源到开始处,就是Generate函数产生的数据源,传给和素数2绑定的第一个过滤器,之后依次传递,依次通过目前已有的全部过滤器,如果全部通过了,则说明这个数就是下一个素数。每次for循环里进行到prime := <-ch的时候,这时候ch都是当前的最后一个通道,通过这个通道输出素数;
这个程序很好的展示了,Goroutines之间通过channel类型变量通信来共享内存(被传递的数)的过程,从而演示了Go语言的并发编程。数据在Goroutine之间通过channel依次传递的过程,和Unix pipelines完美匹配,所以说虽然具体实现上有差别(具体实现后面文章会讲到),不过Go语言的并发可以看做是Unix pipelines的一种类型安全的实现。
本文以素数生成器为例,初探了Go语言的并发机制,重点在于”通过通信来共享内存”的中心思想的阐述。当然,Go语言的并发机制还有很多可讨论学习的方面,在后面的文章中会详细探讨。