Go基础:Go语言中 Goroutine 和 Channel 的声明与使用

一、并发编程概述

一般我们所写的代码都按照顺序执行,也就是上一句代码执行完,才会执行下一句,这样的代码逻辑简单,也符合我们的阅读习惯。但这样是不够的,因为计算机很强大,如果只让它干完一件事情再干另外一件事情就太浪费了。比如一款音乐软件,使用它听音乐的时候还想让它下载歌曲,同一时刻做了两件事,在编程中,这就是并发,并发可以让你编写的程序在同一时刻做多几件事情。

Go语言采用的是 CSP(Communicating Sequential Processes,通信顺序进程) 并发模型,核心思想是通过通信来共享内存,而不是通过共享内存来通信。 Go 语言中并发编程的两大核心:GoroutineChannel。它们是 Go 语言"天生并发"设计理念的基石。

  • Goroutine:Go语言的轻量级线程,由Go运行时管理,启动成本低,适合高并发场景。
  • Channel:用于goroutine之间的通信,支持同步和异步操作,避免数据竞争问题。

1.1 进程

在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄。

1.2 线程

线程是进程的执行空间,一个进程可以有多个线程,线程被操作系统调度执行,比如下载一个文件,发送一个消息等。这种多个线程被操作系统同时调度执行的情况,就是多线程的并发。

一个程序启动,就会有对应的进程被创建,同时进程也会启动一个线程,这个线程叫作主线程。如果主线程结束,那么整个程序就退出了。有了主线程,就可以从主线里启动很多其他线程,也就有了多线程的并发。

1.3 协程

Go 语言中没有线程的概念,只有协程,也称为 goroutine。相比线程来说,协程更加轻量,一个程序可以随意启动成千上万个 goroutine。

goroutine 被 Go runtime 所调度,这一点和线程不一样。也就是说,Go 语言的并发是由 Go 自己所调度的,自己决定同时执行多少个 goroutine,什么时候执行哪几个。这些对于我们开发者来说完全透明,只需要在编码的时候告诉 Go 语言要启动几个 goroutine,至于如何调度执行,我们不用关心。

1.4 Goroutine 与 Channel 的协同工作

特性 Goroutine Channel
角色 执行单元,是并发任务的载体。 通信机制,是 Goroutine 之间连接的管道。
目的 实现任务的并行执行 实现 Goroutine 间的安全通信同步
如何创建 go function() ch := make(chan T)
核心操作 函数执行 ch <- value (发送), <-ch (接收), close(ch) (关闭)

关系Goroutine 是生产者和消费者,Channel 是传送带。 多个 Goroutine 可以向同一个 Channel 发送数据,也可以从同一个 Channel 接收数据,从而构建出复杂的并发流程。

1.5 并发编程建议

  1. 避免数据竞争 : 使用channel或sync.Mutex保护共享数据。
  2. 合理使用Goroutine : 避免启动过多goroutine,可通过sync.Pool复用资源。
  3. 优雅退出 : 使用context.Context管理goroutine的生命周期。

二、 协程Goroutine:轻量级线程

Goroutine 是Go语言中并发执行的基本单位,可以把它想象成一个非常轻量级的"线程",由 Go 运行时管理,而不是操作系统。与操作系统线程相比,Goroutine 的创建成本极低,初始栈大小只有几KB,并且可以按需增长。一个 Go 程序可以轻松地创建成千上万甚至上百万个 Goroutine。Goroutine具有以下特点:

  • 轻量级:启动一个goroutine仅需几KB的栈空间,且栈空间可动态增长。
  • 高效调度:由Go运行时调度,而非操作系统内核线程,支持大规模并发。

2.1 Goroutine 的声明与启动

启动一个 Goroutine 非常简单,只需要在函数调用前加上关键字 go 即可。
语法:

go 复制代码
go function_name(arguments)

AI写代码go
运行
1

或者

go 复制代码
go func(parameters) { ... }(arguments) // 启动一个匿名函数作为 Goroutine

AI写代码go
运行
1

核心特点:

  • 非阻塞go 关键字不会等待函数执行完毕,它会立即返回,程序继续向下执行。主 Goroutine(main 函数)不会等待其他 Goroutine 执行结束。
  • 调度 :Goroutine 的调度由 Go 运行时在用户态完成,它会在多个操作系统线程上多路复用(Multiplex)Goroutine,实现高效的并发。

2.2 Goroutine 的使用案例

示例 1:基本使用

go 复制代码
package main
import (
	"fmt"
	"time"
)
func sayHello() {
	fmt.Println("Hello, Goroutine!")
}
func main() {
	go sayHello() // 启动一个goroutine
	time.Sleep(1 * time.Second) // 等待goroutine执行完成
	fmt.Println("Main function")
}

AI写代码go
运行
12345678910111213

解析

  • 使用go关键字启动一个goroutine。
  • time.Sleep用于等待goroutine执行完成,实际开发中建议使用sync.WaitGroupchannel来同步。

示例 2:基本使用

go 复制代码
package main
import (
	"fmt"
	"time"
)
func say(s string) {
	for i := 0; i < 5; i++ {
		time.Sleep(100 * time.Millisecond) // 模拟耗时操作
		fmt.Println(s)
	}
}
func main() {
	// 启动一个新的 Goroutine 来执行 say 函数
	go say("world")
	// main 函数所在的 Goroutine 继续执行 say 函数
	say("hello")
	// 程序运行到这里可能会直接结束,因为 main Goroutine 不会等待 "world" Goroutine
	// 在这个例子中,由于 "hello" 和 "world" 的循环次数和耗时相同,它们会交替执行。
	// 如果我们把 main 中的 say 循环次数减少,就可能看不到 "world" 的全部输出。
}

AI写代码go
运行
1234567891011121314151617181920

可能的输出:

复制代码
hello
world
hello
world
hello
world
hello
world
hello

AI写代码
123456789

注意main 函数本身也是一个 Goroutine,我们称之为主 Goroutine。当 main 函数执行完毕时,整个程序会立即退出,无论其他 Goroutine 是否已经执行完毕。这是初学者最常遇到的问题。

示例 3:等待 Goroutine 结束(使用 sync.WaitGroup

为了确保 main 函数能等待所有 Goroutine 执行完成,我们需要使用同步机制。sync.WaitGroup 是最常用的工具之一。

go 复制代码
package main
import (
	"fmt"
	"sync"
	"time"
)
// 使用 WaitGroup 来等待一组 Goroutine 完成
var wg sync.WaitGroup
func worker(id int) {
	// 在函数退出时,通知 WaitGroup 这个 Goroutine 已经完成
	defer wg.Done()
	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(time.Second) // 模拟工作
	fmt.Printf("Worker %d done\n", id)
}
func main() {
	// 启动 3 个 worker Goroutine
	for i := 1; i <= 3; i++ {
		// 启动 Goroutine 前,增加 WaitGroup 的计数器
		wg.Add(1)
		go worker(i)
	}
	// 等待所有 Goroutine 完成(即 WaitGroup 计数器归零)
	wg.Wait()
	fmt.Println("All workers finished.")
}

AI写代码go
运行
1234567891011121314151617181920212223242526

输出:

bash 复制代码
Worker 3 starting
Worker 1 starting
Worker 2 starting
(等待约1秒后...)
Worker 2 done
Worker 1 done
Worker 3 done
All workers finished.

AI写代码
12345678

sync.WaitGroup 的三个方法:

  • Add(int):增加计数器,表示需要等待的 Goroutine 数量。
  • Done():减少计数器,通常在 Goroutine 的 defer 语句中调用。
  • Wait():阻塞,直到计数器归零。

三、 Channel:管道

如果说 Goroutine 是 Go 并发的"执行体",那么 Channel 就是它们之间的"通信管道"。Channel 允许一个 Goroutine 向另一个 Goroutine 发送类型化的值,从而实现安全的数据交换。Channel是Go语言中用于goroutine间通信的管道,分为以下两种:

  • 无缓冲Channel:发送和接收操作会阻塞,直到另一方准备好。
  • 有缓冲Channel:发送操作在缓冲区未满时不会阻塞,接收操作在缓冲区非空时不会阻塞。

Go 的并发哲学是: "不要通过共享内存来通信,而要通过通信来共享内存。" Channel 就是这一哲学的完美体现。其实就是提倡通过 channel 发送接收消息的方式进行数据传递,而不是通过修改同一个变量。所以在数据流动、传递的场景中要优先使用 channel,它是并发安全的,性能也不错。

channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁来保证并发的安全

3.1 Channel 的声明与创建

语法:

go 复制代码
var ch chan T // 声明一个元素类型为 T 的 Channel
ch = make(chan T) // 创建一个无缓冲 Channel
ch = make(chan T, capacity) // 创建一个缓冲 Channel,容量为 capacity

AI写代码go
运行
123
  • T 是 Channel 可以传递的数据类型,例如 int, string, struct 等。
  • make 是创建 Channel 的唯一方式。只声明不创建的 Channel 值为 nil,操作它会引发 panic。

3.2 Channel 的操作

Channel 有三个核心操作:发送、接收和关闭。

  • 发送ch <- value

    • value 发送到 Channel ch 中。
    • 如果 Channel 是无缓冲的,发送操作会阻塞,直到有另一个 Goroutine 从该 Channel 接收数据。
    • 如果 Channel 是缓冲的且未满,发送操作会成功并将数据放入缓冲区;如果缓冲区已满,发送操作会阻塞,直到有空间可用。
  • 接收value := <-ch

    • 从 Channel ch 中接收一个数据,并将其赋值给 value
    • 如果 Channel 是无缓冲的,接收操作会阻塞,直到有另一个 Goroutine 向该 Channel 发送数据。
    • 如果 Channel 是缓冲的且有数据,接收操作会立即成功;如果缓冲区为空,接收操作会阻塞,直到有数据可读。
    • value, ok := <-ch:这是一种更安全的接收方式。如果 Channel 已被关闭且没有数据了,ok 会返回 false,可以用来判断 Channel 是否已关闭。
  • 关闭close(ch)

    • 关闭一个 Channel。只有发送者才应该关闭 Channel,而不是接收者。
    • 向一个已关闭的 Channel 发送数据会引发 panic。
    • 从一个已关闭的 Channel 接收数据,会立即返回该 Channel 缓冲区中剩余的数据(如果是缓冲 Channel)。当数据全部接收完毕后,后续的接收操作会立即返回该元素类型的零值,而不会阻塞。

3.3 Channel 的使用示例

示例 1:无缓冲 Channel

go 复制代码
package main
import "fmt"
func main() {
	ch := make(chan int) // 创建无缓冲channel
	go func() {
		ch <- 42 // 发送数据到channel
	}()
	value := <-ch // 从channel接收数据
	fmt.Println("Received:", value)
}

AI写代码go
运行
12345678910

解析

  • 发送操作ch <- 42会阻塞,直到有goroutine接收数据。
  • 接收操作<-ch会阻塞,直到有数据发送到channel。

示例 2:无缓冲 Channel

无缓冲 Channel 也称为同步 Channel,因为发送和接收操作会同步进行,双方必须同时准备好才能完成数据传递。

go 复制代码
package main
import (
	"fmt"
	"sync"
)
var wg sync.WaitGroup
func main() {
	wg.Add(1)
	
	// 创建一个无缓冲的 string 类型 Channel
	ch := make(chan string)
	// 启动一个 Goroutine 作为接收者
	go func() {
		defer wg.Done()
		// 从 ch 接收数据,这个操作会阻塞,直到 main Goroutine 发送数据
		msg := <-ch
		fmt.Println("Received:", msg)
	}()
	// main Goroutine 作为发送者
	fmt.Println("Sending a message...")
	ch <- "Hello, Channel!" // 这个操作会阻塞,直到接收者准备好
	wg.Wait()
	fmt.Println("Message sent and received.")
}

AI写代码go
运行
123456789101112131415161718192021222324

输出:

erlang 复制代码
Sending a message...
Received: Hello, Channel!
Message sent and received.

AI写代码
123

执行流程:

  1. main Goroutine 启动接收者 Goroutine。
  2. 接收者 Goroutine 执行到 msg := <-ch,因为 ch 为空,所以它阻塞
  3. main Goroutine 继续执行,打印 "Sending a message..."。
  4. main Goroutine 执行 ch <- "Hello, Channel!"。此时接收者已经准备好,所以数据被传递过去,发送操作完成,不再阻塞。
  5. 接收者 Goroutine 被唤醒,接收到数据,打印 "Received: Hello, Channel!"。
  6. 接收者 Goroutine 调用 wg.Done()
  7. main Goroutine 在 wg.Wait() 处不再阻塞,继续执行,打印 "Message sent and received."。

示例 3:缓冲 Channel

go 复制代码
package main
import "fmt"
func main() {
	ch := make(chan int, 2) // 创建缓冲大小为2的channel
	ch <- 1
	ch <- 2 // 缓冲区未满,不会阻塞
	fmt.Println("Sent values to channel")
	value := <-ch
	fmt.Println("Received:", value)
}

AI写代码go
运行
12345678910

解析

  • 缓冲区大小为2,发送操作不会阻塞,直到缓冲区满。
  • 接收操作会从缓冲区中取出数据。

示例 4:缓冲 Channel

缓冲 Channel 是一个异步 Channel,它有一个固定大小的"队列"。发送操作只有在队列满时才会阻塞,接收操作只有在队列空时才会阻塞。

go 复制代码
package main
import (
	"fmt"
	"time"
)
func main() {
	// 创建一个缓冲容量为 2 的 Channel
	ch := make(chan string, 2)
	// 因为缓冲区未满,这两个发送操作都不会阻塞
	ch <- "buffered"
	fmt.Println("Sent 'buffered'")
	ch <- "channel"
	fmt.Println("Sent 'channel'")
	// 启动一个 Goroutine 来接收数据
	go func() {
		time.Sleep(1 * time.Second) // 模拟处理延迟
		msg1 := <-ch
		fmt.Println("Received:", msg1)
	}()
	// 这个发送操作会阻塞,因为缓冲区已满,需要等待接收者取走一个数据
	fmt.Println("Trying to send 'hello'...")
	ch <- "hello" // 这里会阻塞约1秒
	fmt.Println("Sent 'hello'")
	// 接收剩余数据
	msg2 := <-ch
	fmt.Println("Received:", msg2)
}

AI写代码go
运行
123456789101112131415161718192021222324252627

输出:

vbnet 复制代码
Sent 'buffered'
Sent 'channel'
Trying to send 'hello'...
(等待约1秒后...)
Received: buffered
Sent 'hello'
Received: channel

AI写代码
1234567

示例 5:使用 for...range 遍历 Channel 和 close

当 Channel 被关闭后,for...range 循环会自动终止,这是处理多个数据流的常用模式。

go 复制代码
package main
import (
	"fmt"
	"sync"
)
var wg sync.WaitGroup
func worker(jobs <-chan int, results chan<- int) {
	defer wg.Done()
	for j := range jobs { // 循环会一直从 jobs 接收,直到 jobs 被关闭
		fmt.Printf("Processing job %d\n", j)
		// 模拟耗时工作
		// time.Sleep(time.Second)
		results <- j * 2 // 将结果发送到 results Channel
	}
}
func main() {
	const numJobs = 5
	jobs := make(chan int, numJobs)
	results := make(chan int, numJobs)
	// 启动 3 个 worker Goroutine
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go worker(jobs, results)
	}
	// 发送 5 个任务到 jobs Channel
	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}
	close(jobs) // 关闭 jobs Channel,告诉所有 worker 没有新任务了
	// 等待所有 worker 完成
	wg.Wait()
	// 接收所有结果
	// 由于我们不知道 results 何时关闭,这里用 for 循环接收固定次数
	fmt.Println("Results:")
	for i := 1; i <= numJobs; i++ {
		fmt.Println(<-results)
	}
}

AI写代码go
运行
1234567891011121314151617181920212223242526272829303132333435363738

可能的输出:

makefile 复制代码
Processing job 1
Processing job 2
Processing job 3
Processing job 4
Processing job 5
Results:
2
4
6
8
10

AI写代码
1234567891011

在这个经典的"工作池"模式中:

  1. main Goroutine 创建任务和结果 Channel。
  2. 启动多个 worker Goroutine,它们都在 for j := range jobs 处等待任务。
  3. main Goroutine 将所有任务发送到 jobs Channel,然后立即关闭它。
  4. 关闭 jobs 后,worker 们仍然可以从其中接收剩余的任务。当所有任务都被处理完毕后,for...range 循环会自动结束,worker Goroutine 随之退出。
  5. main Goroutine 等待所有 worker 完成后,再从 results Channel 中收集所有结果。

四、综合案例

4.1 多个Goroutine协同工作

以下案例展示了5个goroutine生成随机数并通过channel传递,另一个goroutine实时接收并求和:

go 复制代码
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// 生成随机数的函数
// 注意:WaitGroup 应该以指针方式传递,否则会得到一个拷贝
func generateNumbers(ch chan int, wg *sync.WaitGroup) {
	defer wg.Done() // 生成完成后,通知WaitGroup
	for i := 0; i < 5; i++ {
		num := rand.Intn(100)
		ch <- num
		fmt.Printf("Generated: %d\n", num)
		time.Sleep(time.Millisecond * 500)
	}
}

// 求和的函数
func sumNumbers(ch chan int, done chan bool) {
	sum := 0
	// for range 循环会在 ch 被关闭且数据被接收完后自动退出
	for num := range ch {
		sum += num
		fmt.Printf("Current Sum: %d\n", sum)
	}
	fmt.Println("Final Sum:", sum)

	// 求和完成后,通过 done channel 通知 main
	done <- true
}

func main() {
	rand.Seed(time.Now().UnixNano())
	ch := make(chan int)

	// 用于等待生产者
	var producerWg sync.WaitGroup
	// 用于通知消费者已完成
	done := make(chan bool)

	// 启动5个生成随机数的goroutine
	producerWg.Add(5)
	for i := 0; i < 5; i++ {
		go generateNumbers(ch, &producerWg)
	}

	// 启动1个求和的goroutine
	go sumNumbers(ch, done)

	// 关键点:启动一个goroutine来等待所有生产者完成,然后关闭channel
	go func() {
		producerWg.Wait() // 等待所有 generateNumbers 完成
		close(ch)         // 生产者全部完成,立即关闭 ch
	}()

	// 主goroutine等待消费者完成
	// <-done 会一直阻塞,直到 sumNumbers 往 done 里写入数据
	<-done
	fmt.Println("All goroutines finished")
}

AI写代码go
运行
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364

解析

  • 使用sync.WaitGroup等待所有goroutine完成。
  • 生成随机数的goroutine将数据发送到channel,求和goroutine从channel接收数据并累加。
  • 使用close(ch)关闭channel,通知接收方数据发送完毕。

这种方式也彻底解决了死锁问题,从而构建一个健壮且易于理解的并发模型。上面代码也遵循了 Go 并发编程的最佳实践:

  • 职责分离: WaitGroup 只负责等待生产者,done channel 只负责通知消费者完成,ch 只负责数据传递。
  • 明确的关闭时机: channel 的关闭者(匿名 Goroutine)不关心消费者,只关心生产者是否全部完成。一旦完成,就立即关闭。
  • 清晰的退出路径: 每个 Goroutine 都有明确的退出条件和方式,避免了循环等待。
相关推荐
kfaino33 分钟前
码农的AI翻身(六)你好,我叫 Parameter
后端·aigc
掘金者阿豪36 分钟前
把业务数据变成共享仪表盘:Metabase可视化与远程访问实践
前端·后端
猪猪拆迁队2 小时前
虚拟工厂仿真引擎的架构设计:让一条产线可编程、可观测、可干预
后端·ai编程
字节跳动数据库2 小时前
文章分享——相似函数处理方法
人工智能·后端·程序员
云技纵横2 小时前
@Transactional 失效的 7 种场景:第 5 种最难排查
后端
用户6757049885022 小时前
你知道 Go 结构体和结构体指针调用的区别吗?一文带你彻底搞懂!
后端·go
程序员cxuan3 小时前
读懂 Claude Code 架构分析系列,第一篇,开始!
人工智能·后端·架构
用户6757049885023 小时前
面试官问“装饰器模式”,这样回答薪资多要 3000!
后端
tntxia3 小时前
Geo Scene域名修改引起的一些问题
后端
用户298698530143 小时前
Java 实现 Word 文档加密与权限解除
java·后端