Golag内置并发功能的特性。通过把标识符go放置在将要执行的函数前,被执行的代码可以在相同的地址空间内启动独立的并发线程。在Golang中,称为goroutine。这里我想特别强调下并发并不意味着并行。Goroutines是创建并发体系结构的手段,可以在硬件允许的情况下并行执行。并发并不意味着并行
我们先试试goroutine的示例代码:
func main() {
// Start a goroutine and execute println concurrently
go println("goroutine message")
println("main function message")
}
复制代码
这段程序将会打印main function message,但是可能打印goroutine function message。在main方法中衍生goroutine协程,但是在main方法中并没有等待goroutine方法执行完,此时程序就只会打印main function message,反之会打印出goroutine message。
你想必会想golang一定会有解决这种不确定性的方案,这就是我将要介绍的关于Golang通道的知识点。
通道的基础知识
通道可以将并发执行的程序同步,并通过特定值类型实现并发进程相互通信。通道有以下几个组成:通过通道传递的数据类型、通道的缓存容量、标识数据方向的标识符<-。开发者可以通过内置函数make为通道分配内存。
i := make(chan int)
s := make(chan string, 3)
r := make(<-chan bool) // can only read from
w := make(chan<- []os.FileInfo) // can only write to
复制代码
通道是基础的变量,使用场景与其它基础类型一样。诸如:结构元素、函数变量、函数返回值、甚至可以作为其它通道的类型:
c := make(chan <- chan bool)
func readFromChannel(input <-chan string)
func getChannel() chan bool {
b := make(chan bool)
return b
}
复制代码
可以通过标识符<-向通道中读写数据,如何创建通道并对它做些基础操作,现在让我们回到本文的第一个例子程序中,探究通道可以帮助我们事先什么功能?
package main
import (
"fmt"
)
func main() {
done := make(chan bool)
go func() {
fmt.Println("Goroutine message")
done <- true
}()
fmt.Println("Main message")
<-done
}
复制代码
这段程序将会将程序中所有message信息都打印出来,并不存在其它可能性。为什么会这样?done通道没有任何缓冲(由于我们没有为通道定义容量)。所有没有缓冲的通道都将会阻塞程序的执行,除非通道的发送和接受数据的功能都已经为通信做好准备,这也是无缓冲通道也被称为同步的原因。在我们的示例代码main函数中,消费done通道的数据一直会阻塞main函数的执行,直至goroutine向main通道中写入数据。因此程序只有在成功读取done通道的数据后才会结束。
对于有缓冲的通道:当缓存不为空时,所有通过通道读数据的操作都不会阻塞程序的执行。当缓存没有满时,所有向通道中写数据的操作都不会阻塞程序的执行。这样的通道可以称为异步。下面的代码将展示同步通道与异步通道的差异:
package main
import (
"fmt"
"time"
)
func main() {
message := make(chan string)
count := 3
go func () {
for i := 1; i <= count; i ++ {
fmt.Println("send message")
message <- fmt.Sprintf("message %d", i)
}
}()
time.Sleep(time.Second * 3)
for i := 1; i <= count; i ++ {
fmt.Println(<-message)
}
}
复制代码
上面的例子中message通道是同步的,程序的输出如下:
send message
// wait for 3 seconds
message 1
send message
send message
message 2
message 3
复制代码
如你所见,当第一次向通道中写入数据后,其它的写入数据操作都将被阻塞,直到3秒后,通道中的数据被消费。
如果我们使用的是有缓存的通道, 例如向下面那样创建message通道: message := make(chan tring, 2)
这时,程序将是下面的输出:
send message
send message
send message
// wait for 3 seconds
message 1
message 2
message 3
复制代码
通过上面的输出我们可以看到,所有缓存通道的写操作并没有因为通道中的数据没有被消费而阻塞。通过更改通道的容量,开发者可以控制系统吞吐量。
死锁
现在我们先看下面这段向通道中读写数据的示例代码片段:
package main
import (
"fmt"
)
func main() {
c := make(chan int)
c <- 42
val := <-c
fmt.Println(val)
}
复制代码
执行上面的代码,将会得到下面的输出结果:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/fullpathtofile/channelsio.go:5 +0x54
exit status 2
复制代码
上面的输出结果是典型的死锁信息。这是由于两个goroutines相互等待对方,以至于任何一方都不能完成执行操作。Golang可以在运行时发现错误,然后将错误信息打印出来。因此这个错误是由于通道的通信被阻塞所导致的。
代码可以在单线程中按照顺序一行行的执行,只有当接收方顺利读取通道中的数据后,(c <- 42)才可以以同步的方式向通道中写入数据。因此开发者要在向通道写数据的代码后,添加向通道中消费数据的功能代码。
为了使程序顺利运行,开发者可以参考下面的修改:
package main
import (
"fmt"
)
func main() {
c := make(chan int)
go func() {
c <- 42
}()
val := <-c
fmt.Println(val)
}
复制代码
Range channels and closing(非常抱歉,真心不知道给如何优雅的翻译)
在上面的了例子中,实现多次通过通道读写数据的代码如下:
for i := 1; i <= count; i ++ {
fmt.Println(<-message)
}
复制代码
由于通道被消费的数据个数不能多于写入通道的数据个数,为了顺利通过通道读数据而且不产生死锁,开发者必须准确知道已经向通道中写入多少条数据。
在Golang中,通过range表达式的语法可以实现对数组、字符串、切片、maps和通道的遍历。对于通道而言,在通道关闭时会触发遍历通道的代码。可以参照下面的代码(下面的代码并不能正常工作):
package main
import (
"fmt"
)
func main() {
message := make(chan string)
count := 3
go func() {
for i := 1; i <= count; i ++ {
messager <- fmt.Sptintf("message %d", i)
}
}()
for msg := range message {
fmt.Println(msg)
}
}
复制代码
非常抱歉上面的程序并不能正常工作。我在上文也已经强调了,当通道被关闭时,range表达式才会被触发。因此为了程序能够正常工作,开发者必须在程序中调用close函数。这样gotroutine的代码将会是下面的样子:
go func() {
for i := 1; i <= count; i ++ {
message <- fmt.Sprintf("message %d", i)
}
close(message)
}()
复制代码
关闭通道的函数还具有一个额外的特性 - 当调用关闭通道的函数后,然后向空通道中读取数据或是在同一个线程中读数据,此时并不会阻塞程序。
package main
import (
"fmt"
)
func main() {
done := make(chan bool)
close(done)
fmt.Println(<- done) // false
fmt.Println(<- done) // false
}
复制代码
这个特性也可以使用在同步化goroutines的代码中。现在让我们在回顾下同步goroutine的代码:
func main() {
done := make(chan bool)
go func() {
println("goroutine message")
// We are only interested in the fact of sending itself,
// but not in data being sent.
done <- true
}()
println("main function message")
<-done
}
复制代码
上面done通道只是为了使得程序可以同步执行,并不需要通过通道传递数据。因此我们可以将上面的代码作如下修改:
func main() {
// Data is irrelevant
done := make(chan struct{})
go func() {
println("goroutine message")
// Just send a signal "I'm done"
close(done)
}()
println("main function message")
<-done
}
复制代码
当开发者关闭了goroutine的通道后,并不会阻塞向通道读数据的操作,因此main函数可以继续执行。
多通道和select
在真实的开发需求中,开发者往往需要面对多个goroutine和通道。独立运行的模块越多,越是需要高效率的同步。让我们来看一个更加复杂的例子:
func getMessagesChannel(msg string, delay time.Duration) <-chan string {
c := make(chan string)
go func() {
for i := 1; i <= 3; i++ {
c <- fmt.Sprintf("%s %d", msg, i)
// Wait before sending next message
time.Sleep(time.Millisecond * delay)
}
}()
return c
}
func main() {
c1 := getMessagesChannel("first", 300)
c2 := getMessagesChannel("second", 150)
c3 := getMessagesChannel("third", 10)
for i := 1; i <= 3; i++ {
println(<-c1)
println(<-c2)
println(<-c3)
}
}
复制代码
上面的代码中,通过创建通道、衍生spawns的goroutine函数和休眠固定的时间后返回通道。我们可以明显发现c3通道的时间间隔是最短的,因此期望先打印出c3通道中消息。然而程序的输出却是下面的内容:
first 1
second 1
third 1
first 2
second 2
third 2
first 3
second 3
third 3
复制代码
显然易见,程序的输出是正确的。按顺序的调用getMessagesChannel函数会衍生出相应goroutine和新的无缓存通道,因此只有当无缓存通道中数据被消费后,相应goroutine的代码才会继续向通道中写入数据。如果我们期望程序的运行结果是那个goroutine更快的向通道写入数据,将会被优先消费。
在Golang中,select关键词可以用来在通信中处理多通道的消息传递。这很像其它语言的switch语法,但是select使用场景只能用来向通道中读写数据。因此为了高效的处理多通道问题,可以参照下面的代码:
for i := 1; i <= 9; i++ {
select {
case msg := <-c1:
println(msg)
case msg := <-c2:
println(msg)
case msg := <-c3:
println(msg)
}
}
复制代码
请注意数字9:由于每个通道都会3次向通道中写入数据,因此需要9次循环使用select。 现在我们将会得到期望的输出结果,不同的读写操作间也不会相互阻塞。输出是:
first 1
second 1
third 1 // this channel does not wait for others
third 2
third 3
second 2
first 2
second 3
first 3
复制代码
结论
通道是Golang中非常强大也很有趣的功能。但是如果想高效的使用这个语法特性,必须理解它的实现原理。在这篇文章中我试图向读者介绍关于使用通道的最基本知识点。如果你想进行深入的研究,可以参考下面的连接:
- Concurrency is not parallelism - early mentioned talk from Rob Pike
- Go Concurrency Patterns
- Advanced Go Concurrency Patterns