本文首发于 PRSDigg(顶呱呱) © 2022 阿坦
转载请注明出处
原创不易,请多转发点赞
众所周知 Go 语言是高并发开发利器,它可以使多核(多线程)CPU 的性能得到充分利用。
那么,Go 具体是怎么实现并发的呢?这篇教程试图用最简洁的文字和代码给你讲清楚。
goroutine
首先,需要引入一个概念:goroutine.
线程这个概念,可能知道的人会更多一些。我想借助这个概念来帮助你搞明白 goroutine.
当我们在进行一项任务,比如打开一个 word 文档,可以同时在它里面进行打字、拼写检查、打印等。同时干这几件事,就是在同时运行多项子任务。这每一个子任务——打字、拼字检查——它们就分别是一个线程。
在 Go 语言里面,也类似,在执行一段 Go 语言代码的时候,可以同时开启几项不同的工作,让它们交替进行(并发)或者同时进行(并行)。每一个并发(并行)的工作,就是一个 goroutine. 我听到有些老师把它叫做 Go 程,我觉得很贴切易懂。所以我想,不如接下来我们也把它叫做「Go 程」吧。
那么,具体在代码里是怎么实现的呢?
代码实现
在 Go 语言里,一段代码一般至少要有 3 个部件:包名、引入的包、以及 main() 函数。
我们先来看一个简单的 Go 代码:
package main
import "fmt"
func main() {
fmt.Println("Hello")
fmt.Println("World")
fmt.Println("Say something.")
}
整个 main() 函数的任务是输出三个字符串 "Hello", "World", "Say something". 来看看运行的结果:

现在,假设我们想把输出 "Hello" 与 输出 "World" 这两个语句变成「Go 程」,我们只需要在它们前面加上关键字 go.
看下面这段代码,这样就算是为 main() 函数添加了两个「Go 程」:
package main
import "fmt"
func main() {
go fmt.Println("Hello")
go fmt.Println("World")
fmt.Println("Say something.")
}
这意味着说,输出 "Hello" 这句语句,和输出 "World" 这句语句会同时启动,说不好哪一句会先输出在屏幕上。
来,运行一下看看:

诶,奇怪!"Hello", "World" 都不再输出了。发生了什么?
原来 main() 函数也是一个「Go 程」。现在输出 "Hello", "World" 的两个函数已经不再是「main Go 程」的一部分,而是独立出去的两个「Go 程」。
于是,fmt.Println("Say something")不会再等它们,「main Go 程」还没等里面的两个「Go 程」执行完毕,就已经提前结束了。
再于是,在「main go 程」里面的两个「Go 程」再也没有机会执行了。
「Go 程」不能有返回值
正是由于上述的这个特性,Go 语言规定「Go 程」不能有返回值。因为有可能在 main() 函数中已经要用这个返回值来,但是返回它的那个「Go 程」还没执行完毕,这就产生了问题。
那么,「Go 程」是不是就没办法传值了呢?
非也。Go 语言可以定义 channel. 下面就具体来说说这个 channel
channel
先来看看定义 channel 的语法:
var+chennel名 +chan关键字 + 这个channel将要保存的值的类型
// 定义 channel
var myChannel chan int
像这样,就定义好了一个保存整型值的 channel, 它的名字是 myChannel.
和切片、map 一样,定义好之后还不能直接使用,要先 make:
myChannel = make(chan int)
接下来,这个 myChannel 就可以用来传值了。
具体怎样传值给 channel 呢?用一个 < 加上一个小横线 -, 把值指向具体的 channel. 就像这样:
myChannel <- 3.14
那又怎么从 channel 中把值拿出来呢?用同样的符号 <-,把 channel 放到右边:
<-myChannel
实操一下
package main
import "fmt"
func HelloWorld(channel chan string) {
channel <- "Hello"
channel <- "World"
}
func main() {
var myChannel chan string
myChannel = make(chan string)
go HelloWorld(myChannel)
fmt.Println(<-myChannel)
fmt.Println(<-myChannel)
fmt.Println("Say something.")
}
输出结果:

把上面的代码复制到 Go Play 亲自去运行感受一下,学习效果会更佳。
如果对 Go 的语法有基础了解的话,建议按照你对代码工作逻辑的猜想,在此基础上设法删删改改,去验证看看你的猜想是否正确。
channel 的阻塞机制
channel 会通过阻塞来保障它的储值和取值不发生混乱。
同一个 channel,在接收一个值后就会阻塞。直到这个 channel 中的值被取出——即 <-channel 被执行——才会解除阻塞,继续接收下一个值;
取值后同样会阻塞。直到这个 channel 再次接受新的值,才会解除阻塞,之后才会可以对这个 channel 进行再一次取值。
package main
import (
"fmt"
"time"
)
func HelloWorld(channel chan string) {
channel <- "Hello"
for i := 0; i < 5; i++ {
fmt.Println("channel sleeping")
time.Sleep(time.Second)
}
fmt.Println("channel wakes up!")
channel <- "World"
}
func main() {
myChannel := make(chan string)
go HelloWorld(myChannel)
fmt.Println(<-myChannel)
fmt.Println(<-myChannel)
fmt.Println("Say something.")
}
上面的代码中 HelloWorld() 函数做了一点小改动,在 channel 接收了值 "Hello" 后,会暂停 5 秒,再接着执行接收 "World" 值命令。

从结果中可以看到,我们延缓了 channel 第二次接收值,在 main() 函数中并没有任何延缓执行的命令。但是 main() 函数中的第二次 channel 取值命令被阻塞了。等到 5 秒过去 channel 第二次接收值完成之后,main() 函数中的阻塞才得以解除,于是新接收到的值 "World" 才被第二句 fmt.Println(<-myChannel) 输出出来。
小结
Go 的并发实现简单朴素,两个简单的东西 goroutine, channel 搞定。像 JavaScript 等类似的语言,往往要花哨和复杂一些。