Go并发编程之Go语言概述
1. Go语言从何而来?
关于Go语言的萌芽时期,我们可以追溯至上个世纪。不过,直至2009年,它才真正被披露,并成为开源大家庭中的一员。在2012年,Go语言的创造者们发布了它的1.0版本。大家可能有所耳闻,Go语言出自Google公司。但很多人可能并不清楚,它的创造者们更是名头不小。他们包括Unix操作系统和B语言(C语言的前身)的创造者、UTF-8编码的发明者Ken Thompson,Unix项目的参与者、UTF-8编码的联合创始人和Limbo编程语言(Go语言的前身)的创造者Rob Pike,以及著名的Javascript引擎V8的创造者Robert Griesemer。正因为有了他们的引领,一批又一批的全球顶尖计算机软件人才都相继加入到了Go语言项目中。
Go语言是一门强类型的通用编程语言。它的基础语法与C语言很类似,但同时也对其他的一些优秀编程语言有所借鉴。它的很多设计灵感都来源于Tony Hoare执笔的那篇著名的论文《Communicating Sequential Processes》。
2. Go语言意味着什么?
Go语言意味着更自由、更高效和一站式的编程体验,程序开发效率和运行效率之间的完美融合,以及天生的并发编程支持。
我们下面重点说说Go语言对各种流行的编程范式的支持,以及它对并发编程的强悍支持。
2.1自由的编程方式
使用Go语言就意味着你可以用自己喜欢的方式编程。因为Go语言支持当今所有主流的编程范式。这包括面向对象编程、函数式编程,以及过程式编程。
面向对象编程
面向对象的设计和编程可以使我们更容易的对现实世界进行建模。这样可以编写出让人类更易懂的代码,并且还会大大增强代码的可维护性和可扩展性。Go语言支持面向对象编程。因此,我们的Go语言程序也可以具备上述优势。面向对象编程中的很多重要原则都可以很容易的在Go语言程序中体现出来,比如:“针对接口编程,而不是针对实现编程”、“对扩展开放,对修改关闭”和“多用组合,少用继承”等等。另外,虽然Go语言中并没有继承的概念,但我们依然可以用类型嵌入的方式来模仿继承并达到相同的效果。
函数式编程
函数式编程同样可以让我们更容易的跟进变化。它可以让我们的程序实体的粒度更加小巧,从而使程序更加易变。此外,函数式编程还可以让我们的程序更加健壮,因为函数本身是没有状态的。要知道,对各种状态的维护会使程序更加复杂和脆弱。Go语言对函数式编程提供支持的一个体现就是:它的函数是绝对的一等类型。这是函数式编程的一个重要特征。更具体地说,我们可以把一个函数作为传给其他函数的参数,或成为其他函数返回的结果。这是构建闭包的必要条件。同时,这也意味着,我们可以对程序的可变部分进行更加灵活的控制和管理。
过程式编程
至于过程式编程,我们自不必多说。程序员们应该再熟悉不过了。过程式编程最直接的体现了程序的本质,同时也是简单程序的最常用的编写手法。我们可以用脚本语言写出纯过程化的程序。我之前常常使用Python代码来做这类事情。因为它可以像Shell脚本那样工作,并且总是能够保持简单。我现在的选择当然是用Go语言。不论是语言语法还是程序运行方式,Go语言都非常的脚本化。简约和易用是它的两个显著特点。
2.2便捷的并发编程
毋庸置疑,Go语言对并发编程的支持是天生的、自然的和高效的。Go语言为此专门创造出了一个关键字“go”。使用这个关键字,我们就可以很容易的使一个函数被并发的执行。就像这样:
go func() { fmt.Println("Concurrent execution!") }()
上面的这段代码使用关键字“go”并发的执行了一个匿名函数,虽然这个匿名函数只会在标准输出上打印一句话而已。更确切地说,该匿名函数会在一个单独的Goroutine中(或称Go程)被执行。我们把“go”和跟在它后面的函数以及调用符号“(”和“)”合称为go语句,而把那个匿名函数称为go函数。
如果我们要在不同的Goroutine中运行的函数之间传递数据,那么我们可以使用Channel(也可称其为通道)。这也是Go语言强烈推荐的做法。
我们可以用程序模仿自来水厂的净水设施。这个净水设施会引导水流先后流经三个净水装置,最后产出可饮用的自来水。为了更好理解,我们用过滤数字来代表对水的过滤。
我们构建两个数字过滤装置、一个数字输出装置和三个数字通道。下面是三个数字通道的创建和初始化代码:
numberChan1 := make(chan int64, 3) // 数字通道1。 numberChan2 := make(chan int64, 3) // 数字通道2。 numberChan3 := make(chan int64, 3) // 数字通道3。
Go语言的内建函数make可以用于创建和初始化一个通道。我们在这里定义,通道的元素类型都是int64,而长度都是3。另外,特殊标记“:=”可被用来在函数中声明并初始化变量。这种情况下,我们可以省略掉变量的类型的声明。
这里有一点需要特别说明,运行main函数的Goroutine(也被称为主Goroutine)会一条接一条地执行main函数中的语句,不论这些语句中是否存在以及有多少条go语句。换句话说,主Goroutine并不会等到其他被启用的Goroutine运行结束之后再结束自身的运行。因此,如果我们不采取任何措施的话,很可能在欲执行的go函数得到执行机会之前主Goroutine就已经结束运行了。一旦如此,当前程序的执行也就会宣告结束。这也意味着,那些go函数中的语句根本就不会被执行。
所以,我们在这里需要再声明一个变量,并稍微设置一下。这个变量代表了一个同步工具。对它的合理运用可以避免上述情况的发生。对这个变量的声明和初始化的代码如下:
var waitGroup sync.WaitGroup // 用于等待一组操作执行完毕的同步工具。 waitGroup.Add(3) // 该组操作的数量是3。
标识符sync.WaitGroup代表了一个类型。该类型的声明存在于代码包sync中,类型名为WaitGroup。另外,上面的第二条语句预示着我们将要后面启用三个Goroutine,或者说要并发的执行三个go函数。请记住,我们在这里进行了一个“加3”的操作。
我们下面依次展现这三个go函数,并说明它们的功用。先来看第一个go函数,包含它的go语句如下:
go func() { // 数字过滤装置1。 for n := range numberChan1 { // 不断的从数字通道1中接收数字,直到该通道关闭。 if n%2 == 0 { // 仅当数字可以被2整除,才将其发送到数字通道2. numberChan2 <- n } else { fmt.Printf("Filter %d. [filter 1]\n", n) } } close(numberChan2) // 关闭数字通道2。 waitGroup.Done() // 表示一个操作完成。 }()
这段代码代表了数字装置1的功能。下面是对其中的go函数的解释:
- 函数的第一条语句为for语句。它会不停的从数字通道numberChan1中接收元素值(在这里是数字)并进行处理,直到numberChan1被关闭。
- 只有可以被2整除的数才可以被送往数字通道numberChan2,否则就会被过滤掉。为了方便查看,我们每过滤一个数字都会打印出一句话。
- 符号“<-”被称为接收操作符。在这里,它会把标识符n代表的数字发送给数字通道numberChan2。
- 由于numberChan1通道的关闭会使这里的for语句结束执行。这意味着上游不会再有任何数字“流出”。所以,我们在这条for语句的后面顺势关闭通道numberChan2。这也是为了告诉它的下游,没有更多的数字需要被过滤了。
- 对waitGroup的Done方法的调用表示了数字装置1已经完成了所有工作。该方法会进行相应的“减1”操作。请记住它,我们后面会对此进行说明。
我们完成了数字过滤装置1的编写。数字过滤装置2的功能与此如出一辙。只不过它会过滤掉不能被5整除的数字。大家应该可以仿照上面的代码写出数字过滤装置2的实现代码。注意,数字过滤装置2会试图从数字通道numberChan2中接收数字,并将未被过滤的数字发送给数字通道numberChan3。如此一来,数字过滤装置1和2就经由数字通道2串联起来了。请注意,不要忘记在数字过滤装置2中的for语句后面添加对数字通道numberChan3的关闭操作,以及调用waitGroup变量的Done方法。这非常重要。
现在我们来看数字输出装置。它即由第三条go语句代表。以下是具体代码:
go func() { // 数字输出装置。 for n := range numberChan3 { // 不断的从数字通道3中接收数字,直到该通道关闭。 fmt.Println(n) // 打印数字。 } waitGroup.Done() // 表示一个操作完成。 }()
这个go函数的功能就非常简单了。它只是打印出“通过净化”的数字。不过,waitGroup.Done()语句依然被包含在内。
好了,我们已经编写出了所有的装置,并合理运用了那三个数字通道。有一点值得说明,到相应的数字通道中没有任何数字可取时,for语句的执行会被阻塞。也正因为如此,我们可以把这三条go语句放置在前,并在之后激活这一过滤数字的流程。具体的激活方法是,向数字通道numberChan1发送数字。相关的代码如下:
for i := 0; i < 100; i++ { // 先后向数字通道1传送100个范围在[0,100)的随机数。 numberChan1 <- rand.Int63n(100) } close(numberChan1) // 数字发送完毕,关闭数字通道1。
这段代码的意图很明显。我们先向数字通道numberChan1发送100个随机数,然后关闭numberChan1通道以表示所以需要过滤的数字都发送完毕。放心,对通道的关闭并不会影响到对已存于其中的数字的接收操作。
好了,我们至此实现了一个完整的数字过滤流程。为了能够让这个流程能够被完整的执行,我们还需要在最后加入这样一条语句:
waitGroup.Wait() // 等待前面那组操作(共3个)的完成。
对waitGroup的Wait方法的调用会一直被阻塞,直到前面三个go函数中的三个waitGroup.Done()语句(即那三个“减1操作”)都被执行完毕。“加3”操作使变量waitGroup的状态有所改变,并以此阻塞住了之后的waitGroup.Wait()语句的执行。而后续被并发执行的三个“减1”操作的执行又使变量waitGroup的状态回归初始。这才能让对waitGroup.Wait()语句的执行从阻塞中恢复并完成。这是防止主Goroutine过早的被运行结束的有效手段之一。
下面是一幅可以宏观的展示数字过滤流程的图示。
图1-1 数字过滤流程
我们把上述代码都放入到一个命令源码文件的main函数中。并在文件的开始处添加一条代码包导入语句:
import ( "fmt" "math/rand" "sync" )
大家可以试着使用“go run”命令运行这个命令源码文件,并观察输出结果。
我们配合使用Goroutine和Channel让数字过滤流程实现了全异步化,但却没有增加开发的难度。Channel可以让我们以管道的方式在多个Goroutine之间交换数据。这也恰恰实践和印证了这句话:
Do not communicate by sharing memory; instead, share memory by communicating.
在这之中起到关键作用的Channel有着非常灵活、多样的使用方法。在后续的文章中,我会专门就此进行论述。
虽然我们可以使用其他语言的代码实现这样的异步化,但是它们不会像Go语言这样为此提供语言级别的原生支持。也正是由于这个原因,那些代码会看起来复杂得多。它们不得不使用若干个类库或辅助工具来满足异步化的要求。另一方面,Go语言先进、高效的并发编程模型及其实现系统会使得程序对系统资源的消耗大大减少,并且会在很大程度上提高对这些资源的使用效率。这一优势是很多其他语言望尘莫及的。同样,我会在后面简明扼要的说明Goroutine的运作机理。
3. Go语言的哲学
通过对前面内容的阅读,大家应该能够隐约的感觉到Go语言的关注点,以及它想为软件开发者们带来的启示和新思想。
作为本篇文章的总结,我在下面列出几点最重要的Go语言的哲学:
- Go语言集众多编程范式之所长,并以自己独到的方式将它们融合在一起。程序员们可以用他们喜欢的风格去设计程序。
- 相对于设计规则上的灵活,Go语言有着明确且近乎严格的编码规范。我们可以通过“go fmt”命令来按照官方的规范格式化代码。
- Go语言是强调软件工程的编程语言。它自带了非常丰富的标准命令,涵盖了软件生命周期(开发、测试、部署、维护等等)的各个环节。
- Go语言是云计算时代的编程语言。它关注高并发程序,并旨在开发效率和运行效率上取得平衡。
- Go语言提倡交换数据,而不是共享数据。它的并发原语Goroutine和Channel是其中的两大并发编程利器。同时,Go语言也提供了丰富的同步工具,以供程序员们根据场景选用。然而,后者就不属于语言级别的支持了。