Skip to content

并发编程基础

进程

进程(Process)的概念是操作系统中的一个核心概念,指的是正在执行的程序的一个实例。它是系统资源分配和调度的基本单位。简单来说,进程可以看作是程序运行时的一个动态实体,包含了程序代码、数据以及运行时的状态信息。

线程

线程(Thread)的概念是操作系统中比进程更轻量级的执行单元。它是进程内部的一个执行流,通常被称为“轻量级进程”。线程是CPU调度和执行的基本单位,而进程则是资源分配的基本单位。简单来说,线程是进程中的一个子任务,多个线程可以共享同一个进程的资源。

协程

协程(Coroutine)的概念是一种比线程更轻量级的并发执行单元。它不是由操作系统直接管理,而是由程序或运行时环境(如编程语言的库或框架)控制的。协程的核心思想是“协作式多任务”,即协程在执行过程中可以主动让出控制权,交给其他协程,而不是像线程那样依赖操作系统的抢占式调度。

串行/并行/并发

串行/并行/并发

串行:一个时间段内多个任务执行时,任务之间是顺序执行的,一个任务结束后才能开始下一个任务。

并行:一个时间段内每个线程分配给独立的核心,多个任务在同一时刻同时执行,这通常需要多个独立的执行单元(例如,多个 CPU 核心或多个处理器)。

并发:一个时间段内,多个任务都在向前推进,但它们不一定在同一时刻真正地同时执行。在单核 CPU 的情况下,通常是通过时间片轮转的方式,让不同的任务快速地交替执行,从而给人一种“同时”执行的错觉。在多核 CPU 的情况下,并发也可以包含并行的成分。

goroutine

什么是goroutine

goroutine 是 Go 语言(Golang)中特有的轻量级线程概念,用于实现高并发编程。它本质上是一种协程(Coroutine),但由 Go 运行时(runtime)管理,结合了协程的轻量级特性和线程的多核并行能力。goroutine 是 Go 语言并发模型的核心,配合通道(channel)实现了“以通信共享内存”的设计哲学。

使用 goroutine

使用 go 关键字启动: 要启动一个新的 goroutine,只需要在函数调用前加上 go 关键字。

示例:

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,示例代码如下:

go
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 中共享且可能被并发读写的资源,通常包括:

  1. 共享变量:如结构体字段、全局变量、切片、映射(map)等。
  2. 共享对象:如文件句柄、数据库连接、缓存等。
  3. 内存中的状态:如计数器、配置数据等。

特性:

  • 并发访问:临界资源会被多个 goroutine 同时访问(读或写)。
  • 需要同步:若不加保护(如锁或 channel),并发写或读写可能导致未定义行为。
  • 竞争条件:未经同步的访问可能引发数据损坏或逻辑错误。

示例:

go
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个售票窗口同时出售。

示例:

go
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 // 票已经卖完
		}
	}
}

输出结果:

text
售票窗口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票已经卖完了...

从输出结果可以看到卖出的票数为负数,这就是临界资源的安全问题。

解决方法

[方法一] 上锁

go
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()
	}
}

输出结果:

text
售票窗口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

go
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 // 票已经卖完
		}
	}
}

输出结果:

text
售票窗口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)

通道的声明

声明通道类型的语法格式和声明变量类型的语法是一样的。

go
// 声明通道
var 通道名称 chan 数据类型
// 创建通道
通道名称 := make(chan 数据类型 [, 缓冲区大小])

示例:

go
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 支持三种基本操作:发送、接收和关闭。

  • 接收数据
go
data, ok := <-channel
  • 发送数据
go
channel <- data
  • 关闭通道
go
close(channel)

示例:

go
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 在使用的时候,有以下几个注意点:

  1. channel 是用于 goroutine 之间传递消息的
  2. 每个 channel 都有相关联的数据类型,nil chan 是不能使用,类似于nil map,不能直接存储键值对
  3. 使用通道传递数据:<-
    • chan <- data:向通道中写数据
    • data <- chan:从通道中读数据
  4. 阻塞
    • 发送数据:chan <- data 是阻塞的,直到另一个 goroutine 读取数据来解除阻塞
    • 读取数据:data <- chan 也是阻塞的,直到另一个 goroutine 写出数据解除阻塞
    • 可以通过在 make 的时候设置缓冲区来避免阻塞
  5. channel 本身就是同步的,意味着同一时间,只能有一个 goroutine 来操作 channel
  6. channel 是 goroutine 之间的连接,所以通道的发送和接收必须处在不同的 goroutine 中

缓冲通道

一个通道发送数据和接受数据,默认是阻塞的。当一个数据被发送到通道时,在发送的 goroutine 中等待,直到另一个 goroutine 从通道中接收数据。相对的,当一个数据被从通道中接收时,接收的 goroutine 中等待,直到另一个 goroutine 发送数据到通道中。

这些通道的特性是帮助 goroutines 有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。

go
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)
}

输出结果:

text
准备接收数据
发送数据到 channel
收到数据: Hello

可以给通道设置缓冲区来避阻塞。

go
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)
}

输出结果:

text
主 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)无限期地等待对方持有的资源,导致所有相关进程都无法继续执行。简单来说,就像两个人要在一条很窄的路上相向而行,两人都坚持要对方先让路,结果谁也动不了,卡在那里了。

示例:

go
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("数据发送完成...")
}

输出结果:

text
正在尝试接收数据...
主 goroutine 向通道发送数据...
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()

通道的关闭

发送者可以通过关闭 channel,来通知接收方不会有更多的数据被发送到 channel 上。

go
close(channel)

接收者可以在接收来自通道的数据时使用额外的变量来检查 channel 是否已经关闭。

go
// data:表示从通道中接收到的数据
// ok:是bool类型,表示通道是否已经关闭
data, ok := <-channel

根据 MIT 许可证发布