并发:Go语言的核心竞争力
Go语言之所以在众多现代编程语言中脱颖而出,其简洁而强大的并发模型功不可没。与传统基于线程和锁的复杂并发编程不同,Go推崇一种更高层次、更易于理解的并发哲学。
"不要通过共享内存来通信;而要通过通信来共享内存。"
这句著名的Go语言格言,是理解其并发模型的钥匙。
本文将以一个"并发工坊"的类比,带你领略Go语言是如何通过goroutines和channels,将复杂的并发任务变得如流水线般清晰、自然。
1. Goroutine:轻量级的工人
在我们的"并发工坊"里,goroutine就是一名名精力充沛的工人。
一个goroutine是一个由Go运行时管理的轻量级执行线程。启动一个goroutine非常简单,成本也极低,只需在函数调用前加上 go
关键字即可。你可以轻易地启动成千上万个goroutine。
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// 启动一个新的goroutine,就像雇佣一个新工人去执行 say("World")
go say("World")
// main函数自身也是一个goroutine
say("Hello")
}
当你运行这段代码,你会看到 "Hello" 和 "World" 的输出是交错的。这说明两个 say
函数是并发执行的。main goroutine并没有等待 go say("World")
执行完毕。
2. Channel:工坊的传送带
如果工人们(goroutines)之间不能交流,那他们就无法协作完成复杂的任务。在Go的工坊里,channel就是连接工人们的传送带。
Channel是一个带类型的管道,你可以用它在goroutine之间安全地发送和接收数据,而无需担心显式的锁或竞态条件。
创建和使用Channel
// 创建一个只能传输 string 类型数据的channel
messages := make(chan string)
// 发送 (Send): 将数据放到传送带上
messages <- "ping"
// 接收 (Receive): 从传送带上取走数据
msg := <-messages
默认情况下,发送和接收操作都是阻塞的。
- 当一个goroutine向channel发送数据时,它会阻塞,直到另一个goroutine从该channel接收数据。
- 当一个goroutine从channel接收数据时,它也会阻塞,直到另一个goroutine向该channel发送数据。
这种阻塞特性是Go并发中一个核心的同步机制。它保证了信息的交接。
示例:使用Channel来同步
让我们修复一下上一个例子。我们希望 main
goroutine能够等待 say("World")
完成。
package main
import "fmt"
func sayHello(done chan bool) {
fmt.Println("Hello, World!")
// 工作完成,向传送带上放一个信号
done <- true
}
func main() {
// 创建一个布尔类型的channel
done := make(chan bool)
// 启动一个工人,并把传送带交给他
go sayHello(done)
// main goroutine在这里等待,直到从传送带上取到信号
<-done
fmt.Println("Main goroutine is done.")
}
3. Select:多路传送带的调度员
有时候,一个工人可能需要同时关注多条传送带,并处理最先到来的那一个物料。select
语句就是这个调度员。
select
会阻塞,直到它的某一个 case
分支可以执行。如果有多个 case
同时就绪,它会随机选择一个。
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string)
c2 := make(chan string)
// 工人1,2秒后在c1上传送数据
go func() {
time.Sleep(2 * time.Second)
c1 <- "one"
}()
// 工人2,1秒后在c2上传送数据
go func() {
time.Sleep(1 * time.Second)
c2 <- "two"
}()
// main goroutine作为调度员,等待两条传送带
// 这里会等待两次,因为我们循环两次
for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println("received", msg1)
case msg2 := <-c2:
fmt.Println("received", msg2)
}
}
}
你会看到,程序先打印 "received two",大约1秒后,再打印 "received one"。总耗时约为2秒,而不是3秒。
select
的强大之处:实现超时
select
最常见的用途之一是处理超时。我们可以让一个 case
等待我们的业务逻辑channel,另一个 case
等待一个 time.After
返回的超时channel。
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string, 1)
go func() {
// 模拟一个耗时2秒的操作
time.Sleep(2 * time.Second)
c1 <- "result 1"
}()
// 我们只愿意等待1秒
select {
case res := <-c1:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("timeout 1")
}
}
这个程序会打印 "timeout 1",因为它等待了1秒,而 c1
的结果需要2秒才能到达。这是构建健壮网络服务的关键模式。
4. 缓冲Channel
我们上面使用的都是无缓冲channel。Go还允许你创建带缓冲的channel。
// 创建一个容量为2的string类型channel
ch := make(chan string, 2)
- 向一个带缓冲的channel发送数据时,只有当缓冲区满时才会阻塞。
- 从一个带缓冲的channel接收数据时,只有当缓冲区空时才会阻塞。
类比: 一个有容量的传送带。生产者可以先把几个物料放在传送带上,而不用等待消费者立即取走。这可以减少goroutine间的等待,在某些场景下提升性能。
ch := make(chan int, 2)
ch <- 1
ch <- 2
// ch <- 3 // 此时再发送会阻塞,因为缓冲区满了
fmt.Println(<-ch) // 输出 1
fmt.Println(<-ch) // 输出 2
总结
Go的并发原语虽然简单——只有 go
关键字、chan
和 select
——但它们的组合却异常强大。
- Goroutines 提供了并发执行的能力。
- Channels 提供了goroutine之间安全、同步的通信机制。
- Select 提供了在多个channel操作间进行选择的能力,是构建复杂并发逻辑的基石。
通过拥抱"通过通信来共享内存"的哲学,你可以用一种更安全、更清晰的方式来思考和编写并发程序,这也是Go语言备受青睐的原因之一。