并发编程基础
进程
进程(Process)的概念是操作系统中的一个核心概念,指的是正在执行的程序的一个实例。它是系统资源分配和调度的基本单位。简单来说,进程可以看作是程序运行时的一个动态实体,包含了程序代码、数据以及运行时的状态信息。
线程
线程(Thread)的概念是操作系统中比进程更轻量级的执行单元。它是进程内部的一个执行流,通常被称为“轻量级进程”。线程是CPU调度和执行的基本单位,而进程则是资源分配的基本单位。简单来说,线程是进程中的一个子任务,多个线程可以共享同一个进程的资源。
协程
协程(Coroutine)的概念是一种比线程更轻量级的并发执行单元。它不是由操作系统直接管理,而是由程序或运行时环境(如编程语言的库或框架)控制的。协程的核心思想是“协作式多任务”,即协程在执行过程中可以主动让出控制权,交给其他协程,而不是像线程那样依赖操作系统的抢占式调度。
串行/并行/并发
串行:一个时间段内多个任务执行时,任务之间是顺序执行的,一个任务结束后才能开始下一个任务。
并行:一个时间段内每个线程分配给独立的核心,多个任务在同一时刻同时执行,这通常需要多个独立的执行单元(例如,多个 CPU 核心或多个处理器)。
并发:一个时间段内,多个任务都在向前推进,但它们不一定在同一时刻真正地同时执行。在单核 CPU 的情况下,通常是通过时间片轮转的方式,让不同的任务快速地交替执行,从而给人一种“同时”执行的错觉。在多核 CPU 的情况下,并发也可以包含并行的成分。
goroutine
什么是goroutine
goroutine
是 Go 语言(Golang)中特有的轻量级线程概念,用于实现高并发编程。它本质上是一种协程(Coroutine),但由 Go 运行时(runtime)管理,结合了协程的轻量级特性和线程的多核并行能力。goroutine
是 Go 语言并发模型的核心,配合通道(channel)实现了“以通信共享内存”的设计哲学。
使用 goroutine
使用 go
关键字启动: 要启动一个新的 goroutine
,只需要在函数调用前加上 go
关键字。
示例:
package main
import (
"fmt"
)
func main() {
fmt.Println("Main Start")
go sayHello() // 启动一个goroutine
//time.Sleep(1 * time.Second) // 休眠1秒等待 goroutine 执行
fmt.Println("Main Over")
}
func sayHello() {
fmt.Println("Hello, World!")
}
启动多个 goroutine
下面使用一个简单示例来说明如何启动多个 goroutine,示例代码如下:
package main
import (
"fmt"
"time"
)
func main() {
/*
示例:启动多个goroutine
一个goroutine打印数字1-5
另一个goroutine打印字母a-e
*/
fmt.Println("main start")
go numbers() // 启动一个goroutine
go alphabets() // 启动另一个goroutine
time.Sleep(3000 * time.Millisecond) // 睡眠3秒让main函数延迟结束
fmt.Println("main over")
}
// alphabets 打印字母a-e
func alphabets() {
for i := 'a'; i <= 'e'; i++ {
time.Sleep(400 * time.Millisecond)
fmt.Printf("数字:%c\n", i)
}
}
// numbers 打印数字1-5
func numbers() {
for i := 1; i <= 5; i++ {
time.Sleep(250 * time.Millisecond)
fmt.Printf("字母:%d\n", i)
}
}
临界资源安全问题
临界资源
在 Go(Golang)中,临界资源(Critical Resource)是指在并发编程中多个 goroutine 可能同时访问的共享资源,如果不对其访问进行同步控制,可能会导致数据不一致、竞争条件(race condition)或程序错误。临界资源的定义与一般并发编程一致,但结合 Go 的并发模型(goroutine 和 channel),可以进一步说明如下:
临界资源是任何在多个 goroutine
中共享且可能被并发读写的资源,通常包括:
- 共享变量:如结构体字段、全局变量、切片、映射(map)等。
- 共享对象:如文件句柄、数据库连接、缓存等。
- 内存中的状态:如计数器、配置数据等。
特性:
- 并发访问:临界资源会被多个
goroutine
同时访问(读或写)。 - 需要同步:若不加保护(如锁或 channel),并发写或读写可能导致未定义行为。
- 竞争条件:未经同步的访问可能引发数据损坏或逻辑错误。
示例:
package main
import (
"fmt"
"sync"
)
var counter int // 全局变量 counter 是临界资源
func main() {
/*
临界资源:是指在并发执行的多个 goroutine 中,同一时刻只能被一个 goroutine 访问和操作的共享资源。
*/
var wg sync.WaitGroup
for i := 1; i <= 10; i++ {
wg.Add(1) // 计数器+1
go increment(&wg)
}
wg.Wait() // 阻塞等待,直到计数器为0
fmt.Println("counter:", counter)
}
func increment(wg *sync.WaitGroup) {
defer wg.Done() // 等待 goroutine 完成,计数器-1
for i := 0; i < 1000; i++ {
counter++ // 并发访问 counter,可能导致竞争条件
}
}
安全问题
下面使用一个简单的卖票案例来说明临界资源安全问题,一共有10张票,4个售票窗口同时出售。
示例:
package main
import (
"fmt"
"math/rand"
"time"
)
var ticket = 10 // 定义票全局变量,这个票就是临界资源
func main() {
// 这4个窗口表示4个goroutine,它们会操作同一个全局变量
go sellTicket("售票窗口1")
go sellTicket("售票窗口2")
go sellTicket("售票窗口3")
go sellTicket("售票窗口4")
// 主 goroutine(main)睡眠4秒确保其他goroutine可以够时间运行完成
time.Sleep(4 * time.Second)
}
// sellTicket 函数模拟售票
func sellTicket(window string) {
rand.NewSource(time.Now().UnixNano())
for {
if ticket > 0 {
// 模拟售票时间
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
fmt.Printf("%s正在售出:%d号票\n", window, ticket)
// 减票
ticket--
} else {
fmt.Printf("%s票已经卖完了...\n", window)
break // 票已经卖完
}
}
}
输出结果:
售票窗口2正在售出:10号票
售票窗口4正在售出:9号票
售票窗口1正在售出:8号票
售票窗口1正在售出:7号票
售票窗口2正在售出:6号票
售票窗口3正在售出:5号票
售票窗口2正在售出:4号票
售票窗口4正在售出:3号票
售票窗口3正在售出:2号票
售票窗口3正在售出:1号票
售票窗口3票已经卖完了...
售票窗口1正在售出:0号票
售票窗口1票已经卖完了...
售票窗口2正在售出:-1号票
售票窗口2票已经卖完了...
售票窗口4正在售出:-2号票
售票窗口4票已经卖完了...
从输出结果可以看到卖出的票数为负数,这就是临界资源的安全问题。
解决方法
[方法一] 上锁
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
var ticket = 10 // 定义票全局变量,这个票就是临界资源
var mutex sync.Mutex // 互斥锁
var wg sync.WaitGroup // 计数器
func main() {
// 这4个窗口表示4个goroutine,它们会操作同一个全局变量
wg.Add(4) // 这里4个goroutine,计数器的值就是4
go sellTicket("售票窗口1")
go sellTicket("售票窗口2")
go sellTicket("售票窗口3")
go sellTicket("售票窗口4")
// 主 goroutine(main)睡眠4秒确保其他goroutine可以够时间运行完成
//time.Sleep(4 * time.Second)
wg.Wait() // 阻塞主goroutine,等待计数器为0才继续执行后续操作
}
// sellTicket 函数模拟售票
func sellTicket(window string) {
rand.NewSource(time.Now().UnixNano())
defer wg.Done() // 等函数执行完成,计数器-1操作
for {
// 上锁
mutex.Lock()
if ticket > 0 {
// 模拟售票时间
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
fmt.Printf("%s正在售出:%d号票\n", window, ticket)
// 减票
ticket--
} else {
mutex.Unlock() // 即使没有卖出票也要释放锁,否则其他等待锁的 goroutine 将会一直阻塞,导致死锁
fmt.Printf("%s票已经卖完了...\n", window)
break // 票已经卖完
}
// 释放锁
mutex.Unlock()
}
}
输出结果:
售票窗口4正在售出:10号票
售票窗口4正在售出:9号票
售票窗口2正在售出:8号票
售票窗口3正在售出:7号票
售票窗口1正在售出:6号票
售票窗口4正在售出:5号票
售票窗口2正在售出:4号票
售票窗口3正在售出:3号票
售票窗口1正在售出:2号票
售票窗口4正在售出:1号票
售票窗口1票已经卖完了...
售票窗口4票已经卖完了...
售票窗口2票已经卖完了...
售票窗口3票已经卖完了...
[方法二] channel
package main
import (
"fmt"
"math/rand"
"time"
)
var ticket = 10 // 定义票全局变量,这个票就是临界资源
func main() {
// 创建一个无缓冲的channel
ticketChan := make(chan int)
// 这4个窗口表示4个goroutine,它们会操作同一个全局变量
go sellTicket("售票窗口1", ticketChan)
go sellTicket("售票窗口2", ticketChan)
go sellTicket("售票窗口3", ticketChan)
go sellTicket("售票窗口4", ticketChan)
// 将票放入channel
for i := ticket; i > 0; i-- {
ticketChan <- i
}
// 主 goroutine(main)睡眠4秒确保其他goroutine可以够时间运行完成
time.Sleep(4 * time.Second)
}
// sellTicket 函数模拟售票
func sellTicket(window string, ticketChan chan int) {
rand.NewSource(time.Now().UnixNano())
for {
//
ticket := <-ticketChan
if ticket > 0 {
// 模拟售票时间
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
fmt.Printf("%s正在售出:%d号票\n", window, ticket)
// 减票
ticket--
} else {
fmt.Printf("%s票已经卖完了...\n", window)
break // 票已经卖完
}
}
}
输出结果:
售票窗口4正在售出:9号票
售票窗口1正在售出:10号票
售票窗口2正在售出:7号票
售票窗口3正在售出:8号票
售票窗口3正在售出:3号票
售票窗口3正在售出:2号票
售票窗口2正在售出:4号票
售票窗口1正在售出:5号票
售票窗口3正在售出:1号票
售票窗口4正在售出:6号票
channel 通道
通道的定义
通道是什么?通道可以被认为是 goroutine 之间通信的管道,类似于管道中的水从一端到另一端的流动,数据可以从一端发送到另一端,通过通道接收。
每个通道都有与其相关的类型。该类型是通道允许传输的数据类型。(通道的零值为 nil。nil 通道没有任何用处,因此通道必须使用类似于 map 和切片的方法来定义,也就是 make)
通道的声明
声明通道类型的语法格式和声明变量类型的语法是一样的。
// 声明通道
var 通道名称 chan 数据类型
// 创建通道
通道名称 := make(chan 数据类型 [, 缓冲区大小])
示例:
package main
import "fmt"
func main() {
// 声明一个channel类型
var a chan int
fmt.Printf("%T, %v\n", a, a)
// 判断通道是否为空
if a == nil {
fmt.Println("channel通道是nil的,不能直接使用,需要先创建通道")
a = make(chan int) // 通过make函数创建通道
fmt.Println(a)
}
// 调用test1函数,传入channel类型
test1(a)
}
func test1(ch chan int) {
fmt.Printf("%T, %v\n", ch, ch) // 打印传入进来的通道类型参数,这里输出的是地址值
}
通道的基本操作
channel 支持三种基本操作:发送、接收和关闭。
- 接收数据
data, ok := <-channel
- 发送数据
channel <- data
- 关闭通道
close(channel)
示例:
package main
import (
"fmt"
"time"
)
func main() {
// 创建通道
ch1 := make(chan int)
// 子goroutine发送数据
go func() {
fmt.Println("子goroutine开始执行...")
ch1 <- 66 // 子goroutine向通道发送数据
}()
time.Sleep(2 * time.Second) // 主goroutine睡眠2秒
// 主goroutine从通道接收数据
data, ok := <-ch1
if ok {
fmt.Println("成功从通道接收到数据...")
fmt.Println("data:", data)
} else {
fmt.Println("通道已经关闭")
}
fmt.Println("main...over...")
}
使用通道的注意点
channel 在使用的时候,有以下几个注意点:
- channel 是用于 goroutine 之间传递消息的
- 每个 channel 都有相关联的数据类型,nil chan 是不能使用,类似于nil map,不能直接存储键值对
- 使用通道传递数据:<-
- chan <- data:向通道中写数据
- data <- chan:从通道中读数据
- 阻塞
- 发送数据:chan <- data 是阻塞的,直到另一个 goroutine 读取数据来解除阻塞
- 读取数据:data <- chan 也是阻塞的,直到另一个 goroutine 写出数据解除阻塞
- 可以通过在 make 的时候设置缓冲区来避免阻塞
- channel 本身就是同步的,意味着同一时间,只能有一个 goroutine 来操作 channel
- channel 是 goroutine 之间的连接,所以通道的发送和接收必须处在不同的 goroutine 中
缓冲通道
一个通道发送数据和接受数据,默认是阻塞的。当一个数据被发送到通道时,在发送的 goroutine 中等待,直到另一个 goroutine 从通道中接收数据。相对的,当一个数据被从通道中接收时,接收的 goroutine 中等待,直到另一个 goroutine 发送数据到通道中。
这些通道的特性是帮助 goroutines 有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string) // 无缓冲 channel
go func() {
time.Sleep(2 * time.Second) // 模拟延迟
fmt.Println("发送数据到 channel")
ch <- "Hello" // 发送数据,阻塞直到有接收者
}()
fmt.Println("准备接收数据")
data := <-ch // 接收数据,阻塞直到有发送者
fmt.Println("收到数据:", data)
}
输出结果:
准备接收数据
发送数据到 channel
收到数据: Hello
可以给通道设置缓冲区来避阻塞。
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个有缓冲的通道
ch1 := make(chan int, 5)
// 子 goroutine 接收数据
go func() {
time.Sleep(1 * time.Second)
for i := 0; i < 10; i++ {
data := <-ch1 // 接收数据
fmt.Println("子 goroutine 接收到的数据:", data)
}
fmt.Println("子 goroutine 结束...")
}()
// 主 goroutine 发送数据
for i := 0; i < 10; i++ {
fmt.Println("主 goroutine 发送数据, i:", i)
ch1 <- i
}
fmt.Println("主 goroutine 结束...")
// 主 goroutine 睡眠2秒,等待子 goroutine 执行完成
time.Sleep(2 * time.Second)
}
输出结果:
主 goroutine 发送数据, i: 0
主 goroutine 发送数据, i: 1
主 goroutine 发送数据, i: 2
主 goroutine 发送数据, i: 3
主 goroutine 发送数据, i: 4
主 goroutine 发送数据, i: 5
子 goroutine 接收到的数据: 0
子 goroutine 接收到的数据: 1
子 goroutine 接收到的数据: 2
子 goroutine 接收到的数据: 3
子 goroutine 接收到的数据: 4
子 goroutine 接收到的数据: 5
主 goroutine 发送数据, i: 6
主 goroutine 发送数据, i: 7
主 goroutine 发送数据, i: 8
主 goroutine 发送数据, i: 9
主 goroutine 结束...
子 goroutine 接收到的数据: 6
子 goroutine 接收到的数据: 7
子 goroutine 接收到的数据: 8
子 goroutine 接收到的数据: 9
子 goroutine 结束...
通道死锁
死锁是一个计算机科学中的通用概念,不仅仅局限于 Go 语言。它描述了一种状态,其中两个或多个进程(或线程、goroutine)无限期地等待对方持有的资源,导致所有相关进程都无法继续执行。简单来说,就像两个人要在一条很窄的路上相向而行,两人都坚持要对方先让路,结果谁也动不了,卡在那里了。
示例:
package main
import (
"fmt"
)
func main() {
// 创建一个无缓冲的channel
ch1 := make(chan int)
// 子 goroutine 在 channel 读取数据
go func() {
fmt.Println("正在尝试接收数据...")
// 子 goroutine 并没有接收数据
// 会出现:fatal error: all goroutines are asleep - deadlock!
}()
// 主 goroutine 向 channel 中发送数据
fmt.Println("主 goroutine 向通道发送数据...")
ch1 <- 66
fmt.Println("数据发送完成...")
}
输出结果:
正在尝试接收数据...
主 goroutine 向通道发送数据...
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
通道的关闭
发送者可以通过关闭 channel,来通知接收方不会有更多的数据被发送到 channel 上。
close(channel)
接收者可以在接收来自通道的数据时使用额外的变量来检查 channel 是否已经关闭。
// data:表示从通道中接收到的数据
// ok:是bool类型,表示通道是否已经关闭
data, ok := <-channel