【魅力golang】之-玩转协程
1、背景知识
- 程序是存储在磁盘上的静态指令集,只有加载到内存并由 CPU 执行时,才能产生动态行为。
- 程序运行需要有内存分配、代码执行、I/O 交互等多个资源的协调。
- 进程是程序在操作系统中运行的实例,具有独立的内存空间。
- 每个进程包含一个主线程,可以通过进程间通信(IPC)与其他进程交互。
- 特点:
- 独立性强,不同进程互不干扰。
- 创建与切换成本高(需要操作系统分配资源)。
1.1 并行与并发
- 并发是指多个任务在同一时间段内交替执行。任务并不一定同时运行,而是通过合理调度,让每个任务都能在某段时间运行。并发的本质是逻辑上的同时性。
- 并行是指同时执行多个任务。多个任务在同一时间点上运行,通常需要多核或多处理器支持。并行的本质是物理上的同时性。
下图对比并发与并行的主要区别与特征
特性 | 并行 | 并发 |
核心定义 | 多任务同时运行 | 多任务交替运行 |
硬件依赖 | 需要多核处理器支持 | 单核环境也可实现 |
实现方式 | 任务独立并同时执行 | 任务交替共享时间片 |
适用场景 | 计算密集型任务 | I/O 密集型任务 |
开发难度 | 高(任务分解与同步复杂) | 较低(调度器负责切换) |
1.2 线程的概念
- 线程是进程中的一个独立执行单元,多个线程共享同一进程的内存和资源。
- 一个进程可以包含多个线程(称为多线程),各线程并行或并发执行。
- 特点:
- 线程间通信更快捷,资源共享方便。
- 线程切换比进程轻量,但仍有开销(如保存上下文)。
1.3 协程的概念
- 协程(Coroutine)又称为微线程,是一种比线程更轻量的执行单元。可以直接与操作系统的线程对应,但是创建和调度goroutine的代价远远低于操作系统线程。
- 区别于线程:协程由应用程序级别调度,而不是由操作系统管理。
- 协程切换:只涉及少量的栈操作,没有上下文切换的开销。
1.4 协程对比线程的优点
- 轻量性:一个线程通常需要 1 MB 的栈,而协程只需要几 KB 的栈,支持大规模并发。
- 低开销:协程切换在用户态完成,无需系统调用,开销小。
- 更强的控制力:开发者可以通过代码控制协程的启动、暂停和恢复。
- 天然的非阻塞:Go 语言的协程和 runtime 内置的调度器让阻塞操作(如 I/O)不会阻塞其他协程。
2、Golang 中的协程
2.1 协程的定义
在 Golang 中,协程被称为 Goroutine,是由 Go runtime 管理的并发任务。通过 go 关键字创建协程。Golang天生支持并发编程,通过goroutine,可以轻松创建并发程序。Goroutine的创建和销毁成本非常低,通常只占用几KB的内存,这使得并发编程更加安全和高效。
示例:创建协程
package main
import (
"fmt"
"time"
)
func printMessage(message string) {
for i := 0; i < 5; i++ {
fmt.Println(message, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go printMessage("Hello from Goroutine")
printMessage("Hello from Main")
}
- go printMessage("Hello from Goroutine") 启动一个协程。
- 主线程和协程并发执行。
输出:
Hello from Main 0
Hello from Goroutine 0
Hello from Goroutine 1
Hello from Main 1
Hello from Main 2
Hello from Goroutine 2
Hello from Goroutine 3
Hello from Main 3
Hello from Main 4
Hello from Goroutine 4
2.2 Golang 协程的原理与设计思路
协程模型:Go 的协程基于 M:N 模型,一个操作系统线程可以管理多个协程。Go runtime 中包含调度器,负责将协程分配到多个线程上运行。
调度器:调度器分为三个部分:M(操作系统线程)、P(处理器资源)和 G(Goroutine)。调度器通过 Work Stealing 等算法高效调度 Goroutine。
非阻塞运行:I/O 操作、网络请求等会触发协程的让步,避免阻塞其他任务。
2.3 协程与主线程的关系
- 主线程是程序启动时的默认线程。
- 如果主线程退出,所有协程也会终止。
示例:主线程等待协程完成
package main
import (
"fmt"
"time"
)
func printMessage(message string) {
for i := 0; i < 5; i++ {
fmt.Println(message, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go printMessage("Goroutine")
time.Sleep(1 * time.Second) // 主线程等待 1 秒
fmt.Println("Main thread finished")
}
与2.1中的例子不同的是,这里主线程 sleep了1秒,等待协程,协程每次循环只sleep了100个毫秒,即0.1秒,于是,输出变成了:
Goroutine 0
Goroutine 1
Goroutine 2
Goroutine 3
Goroutine 4
Main thread finished
2.4 多协程与同步
WaitGroup
sync.WaitGroup 是 Go 中常用的同步原语,用于等待一组协程完成。
示例:使用 WaitGroup 同步协程
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 协程完成时通知 WaitGroup
fmt.Printf("Worker %d starting\n", id)
// 模拟任务
for i := 0; i < 3; i++ {
fmt.Printf("Worker %d doing task %d\n", id, i)
}
fmt.Printf("Worker %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 增加计数
go worker(i, &wg)
}
wg.Wait() // 阻塞,直到计数归零
fmt.Println("All workers finished")
}
输出:
Worker 3 starting
Worker 3 doing task 0
Worker 3 doing task 1
Worker 3 doing task 2
Worker 3 done
Worker 1 starting
Worker 1 doing task 0
Worker 1 doing task 1
Worker 1 doing task 2
Worker 1 done
Worker 2 starting
Worker 2 doing task 0
Worker 2 doing task 1
Worker 2 doing task 2
Worker 2 done
All workers finished
互斥锁
sync.Mutex 用于保护共享资源的并发访问。
示例:使用互斥锁
package main
import (
"fmt"
"sync"
)
var (
counter int // 共享资源 计数器
mutex sync.Mutex // 定义互斥锁
)
func increment(wg *sync.WaitGroup) {
defer wg.Done() // 减少计数器
mutex.Lock() // 加锁
counter++ // 修改共享资源
mutex.Unlock() // 解锁
}
func main() {
var wg sync.WaitGroup // 创建一个WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1) // 添加10个任务
go increment(&wg)// 开启10个goroutine
}
wg.Wait() // 等待所有任务完成
fmt.Println("Final Counter:", counter)
}
输出:Final Counter: 10
读写锁
sync.RWMutex 提供读写分离的锁机制。
示例:使用读写锁
package main
import (
"fmt"
"sync"
"time"
)
var (
data int // 定义共享数据
rwMux sync.RWMutex // 定义读写锁
)
func read(wg *sync.WaitGroup) {
defer wg.Done() // 减少计数器
rwMux.RLock() // 读锁
fmt.Println("Reading data:", data) // 打印数据
time.Sleep(100 * time.Millisecond) // 模拟读操作
rwMux.RUnlock() // 释放读锁
}
func write(wg *sync.WaitGroup) {
defer wg.Done() // 减少计数器
rwMux.Lock() // 写锁
data++ // 更新数据
fmt.Println("Writing data:", data) // 打印数据
time.Sleep(100 * time.Millisecond) // 模拟写操作
rwMux.Unlock() // 释放写锁
}
func main() {
var wg sync.WaitGroup // 定义等待组
for i := 0; i < 3; i++ {
wg.Add(1) // 增加计数器
go read(&wg) // 启动一个goroutine执行读取操作
wg.Add(1) // 增加计数器
go write(&wg) // 启动一个goroutine执行写入操作
}
wg.Wait() // 等待所有goroutine完成
}
输出:
Reading data: 0
Writing data: 1
Reading data: 1
Reading data: 1
Writing data: 2
Writing data: 3
3、协程的应用场景
高并发任务:如 Web 服务器处理多个请求,使用 Goroutine 处理 HTTP 请求。
并行计算:分解任务到多个协程并行处理,提高计算效率。
I/O 密集型任务:如文件操作、数据库查询,协程让 I/O 操作非阻塞。
定时任务:使用协程运行周期性任务。
4、注意事项
资源泄漏:
- 确保协程退出,避免资源耗尽。
- 使用 context 提供超时或取消机制。
共享资源:
- 对共享资源使用互斥锁或其他同步原语保护。
死锁风险:
- 小心锁的使用顺序,避免多个协程互相等待。
协程数量控制:
- 避免大量协程导致内存耗尽。
Golang 的协程通过轻量、高效的实现,为开发者提供了强大的并发处理能力。结合同步原语(如 WaitGroup 和 Mutex)和 Go runtime 的调度能力,协程在处理高并发和复杂任务时表现出色。合理使用协程是 Go 并发编程的核心技能。下期风云再给大家详细介绍golang中的通道channel,解决协程之间的通讯,敬请期待。