Skip to content

并发:Go语言的核心竞争力

Go语言之所以在众多现代编程语言中脱颖而出,其简洁而强大的并发模型功不可没。与传统基于线程和锁的复杂并发编程不同,Go推崇一种更高层次、更易于理解的并发哲学。

"不要通过共享内存来通信;而要通过通信来共享内存。"

这句著名的Go语言格言,是理解其并发模型的钥匙。

本文将以一个"并发工坊"的类比,带你领略Go语言是如何通过goroutineschannels,将复杂的并发任务变得如流水线般清晰、自然。


1. Goroutine:轻量级的工人

在我们的"并发工坊"里,goroutine就是一名名精力充沛的工人

一个goroutine是一个由Go运行时管理的轻量级执行线程。启动一个goroutine非常简单,成本也极低,只需在函数调用前加上 go 关键字即可。你可以轻易地启动成千上万个goroutine。

go
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

go
// 创建一个只能传输 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") 完成。

go
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 同时就绪,它会随机选择一个。

go
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。

go
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

go
// 创建一个容量为2的string类型channel
ch := make(chan string, 2)
  • 向一个带缓冲的channel发送数据时,只有当缓冲区满时才会阻塞。
  • 从一个带缓冲的channel接收数据时,只有当缓冲区空时才会阻塞。

类比: 一个有容量的传送带。生产者可以先把几个物料放在传送带上,而不用等待消费者立即取走。这可以减少goroutine间的等待,在某些场景下提升性能。

go
ch := make(chan int, 2)
ch <- 1
ch <- 2
// ch <- 3 // 此时再发送会阻塞,因为缓冲区满了

fmt.Println(<-ch) // 输出 1
fmt.Println(<-ch) // 输出 2

总结

Go的并发原语虽然简单——只有 go 关键字、chanselect——但它们的组合却异常强大。

  • Goroutines 提供了并发执行的能力。
  • Channels 提供了goroutine之间安全、同步的通信机制。
  • Select 提供了在多个channel操作间进行选择的能力,是构建复杂并发逻辑的基石。

通过拥抱"通过通信来共享内存"的哲学,你可以用一种更安全、更清晰的方式来思考和编写并发程序,这也是Go语言备受青睐的原因之一。