文章目录

  • 概念
  • 构建go协程
  • 使用普通函数构建goroutine
  • 使用匿名函数构建
  • go并发与并行
  • go并发
  • go并行
  • go并发通信
  • go三种并发通信方式
  • 1. 使用共享变量+锁
  • 2. channel通道机制(无缓冲通道举例)
  • 创建通道
  • 将数据放入通道
  • 接收通道中的数据
  • 特性:
  • 接收方式
  • 举例:阻塞接收
  • 举例:循环接收
  • 通道类型
  • 有缓冲通道
  • 单向通道
  • 3. 互斥锁、读写锁
  • 不加锁
  • 互斥锁
  • 读写锁
  • 读锁实例
  • 读写锁实例


概念

Go 语言的并发通过 goroutine 特性完成。goroutine 类似于线程,但是可以根据需要创建多个 goroutine 并发工作。
Go 语言还提供 channel 在多个 goroutine 间进行通信
goroutine 是一种非常轻量级的实现,可在单个进程里执行成千上万的并发任务,它是Go语言并发设计的核心。

构建go协程

使用普通函数构建goroutine

package main
import (
	"fmt"
	"time"
)
//函数定义:构建一个无限循环打印
func running() {
	var times int
	// 构建一个无限循环
	for {
		times++
		fmt.Println("tick", times)
		// 延时1秒
		time.Sleep(time.Second)
	}
}
func main() {
	// 使用go关键字,开启一个协程,并发执行程序
	go running()
	// 接受命令行输入, 不做任何事情
	var input string
	fmt.Scanln(&input) //这里会等待用户输入,当用户输入后,main函数执行结束
	//所有 goroutine 在 main() 函数结束时会一同结束。
}

使用匿名函数构建

package main
import (
	"fmt"
	"time"
)
func main() {
	//使用匿名函数,不需要加函数名字
	go func() {
		var times int
		for {
			times++
			fmt.Println("tick", times)
			time.Sleep(time.Second)
		}
	}() //直接调用
	var input string
	fmt.Scanln(&input)
}

go并发与并行

go并发

Go语言实现多核多线程并发运行是非常方便的,下面举个例子:

package main
import (
	"fmt"
)
func main() {
	//开启五个goroutine
	for i := 0; i < 5; i++ {
		go AsyncFunc(i)
	}
	var input string
	fmt.Scanln(&input) //这里会等待用户输入,当用户输入后,main函数执行结束
}
//函数加和
func AsyncFunc(index int) {
	sum := 0
	for i := 0; i < 10000000000; i++ {
		sum += 1
	}
	fmt.Printf("线程%d, sum为:%d\n", index, sum)
}

上面例子中会创建五个goroutine然后并发执行,顺序是不一定的。

go并行

官方给出的答案是,这是当前版本的 Go 编译器还不能很智能地去发现和利用多核的优势。虽然我们确实创建了多个 goroutine,并且从运行状态看这些 goroutine 也都在并行运行,但实际上所有这些 goroutine 都运行在同一个 CPU 核心上。
我们可以使用设置环境变量来控制使用多少个核心runtime.GOMAXPROCS(16) 这里我们说明程序可以使用计算机的16个核心,但是具体使用多少,要看操作系统分配

package main
import (
"fmt"
"runtime"
)
func main() {
cpuNum := runtime.NumCPU() //获得当前设备的cpu核心数
fmt.Println("cpu核心数:", cpuNum)
runtime.GOMAXPROCS(cpuNum) //设置需要用到的cpu数量
}

go并发通信

go三种并发通信方式

  1. 使用共享变量(通常和锁结合使用)
  2. 通道(消息队列),channel(go中主要方式)
  3. 锁:互斥锁,读写锁

在工程上,有两种最常见的并发通信模型:共享数据和消息。

1. 使用共享变量+锁

package main
import (
	"fmt"
	"runtime"
	"sync"
)
//共享变量
var counter int = 0
//协程函数:加锁
func Count(lock *sync.Mutex) {
	lock.Lock()
	counter++
	fmt.Println(counter)
	lock.Unlock()
}
func main() {
	//创建锁对象
	lock := &sync.Mutex{}
	//创建十个协程并执行
	for i := 0; i < 10; i++ {
		go Count(lock)
	}
	//在 main 函数中,使用 for 循环来不断检查 counter 的值(同样需要加锁)。当其值达到 10 时,说明所有 goroutine 都执行完毕了,这时主函数返回,程序退出
	for {
		lock.Lock()
		c := counter
		lock.Unlock()
		runtime.Gosched() //让goroutin协程暂停
		if c >= 10 {
			break
		}
	}
}

但是我们发现,实现一个如此简单的功能,需要很冗长的代码。想象一下,在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支,那将是一场噩梦。这噩梦就是众多 C/C++ 开发者正在经历的,其实 Java 和 C# 开发者也好不到哪里去。

2. channel通道机制(无缓冲通道举例)

Go语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方式来解决。Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。Go语言提倡使用通信的方法代替共享内存。
Go语言提供的消息通信机制被称为 channel。
通道的特性:
Go语言中的通道(channel)是一种特殊的类型。在任何时候同时只能有一个 goroutine 访问通道进行发送和获取数据
通道其实是一个消息队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。不同的goroutine从里面放数据和取数据

创建通道

ch1 := make(chan int)                 // 创建一个整型类型的通道
ch2 := make(chan interface{})         // 创建一个空接口类型的通道, 可以存放任意格式
type Equip struct{ /* 一些字段 */ }
ch2 := make(chan *Equip)             // 创建Equip指针类型的通道, 可以存放*Equip

将数据放入通道

把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞

// 创建一个空接口通道
ch := make(chan interface{})
// 将0放入通道中
ch <- 0
// 将hello字符串放入通道中
ch <- "hello"

接收通道中的数据

注意数据放入通道后要有相应的接收代码,如果只把数据放入通道而没有接收,则会运行期间报错。也就是说所有 goroutine 中的 channel 并没有形成发送和接收对应的代码。

特性:
  1. 通道的收发操作在不同的两个 goroutine 间进行。
    由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。
  2. 接收将持续阻塞直到发送方发送数据。如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。
  3. 每次接收一个元素。通道一次只能接收一个数据元素。
接收方式
  1. 阻塞:data := <-ch
  2. 非阻塞:data, ok := <-ch
  3. 阻塞接收任意数据忽略返回值 <-ch
  4. 使用for range通道循环接收for data := range ch { }
举例:阻塞接收

阻塞接收

package main
import (
	"fmt"
)
func main() {
	// 构建一个通道
	ch := make(chan int)
	// 开启一个并发匿名函数,并直接调用
	go func() {
		fmt.Println("start goroutine")
		// 将数据0放入通道中,通过通道通知main的goroutine
		ch <- 0
		fmt.Println("exit goroutine")
	}()
	fmt.Println("wait goroutine")
	// 阻塞接收,等待通道中的数据。等待匿名goroutine
	<-ch
	fmt.Println("all done")
}
package main
import (
	"fmt"
)
func printer(c chan int) {
	// 开始无限循环等待数据
	for {
		// 从channel中取一个数据
		data := <-c
		// 如果该数据为0则跳出循环
		if data == 0 {
			break
		}
		// 打印数据
		fmt.Println(data)
	}
	// 数据放入channel,通知main已经结束循环(我搞定了!)
	c <- 0
}
func main() {
	// 创建一个channel
	c := make(chan int)
	//执行goroutine,传入channel
	go printer(c)

	//main函数将数据1-10以此放入channel
	for i := 1; i <= 10; i++ {
		c <- i
	}
	// 最后放一个0进入channel
	c <- 0
	// 等待printer结束(搞定喊我!)
	<-c
}
举例:循环接收
package main

import (
	"fmt"
)
func main() {
	// 构建一个通道
	ch := make(chan int)
	// 开启一个并发匿名goroutine
	go func() {
		// 从3循环到0
		for i := 3; i >= 0; i-- {
			// 将数据放入通道,3,2,1,0,每次通道中放入一个数后,当没有接收的时候会阻塞,等待其他协程从通道取数据
			ch <- i
		}
	}()
	// main函数遍历接收通道数据,每次接收到一个数之后,就会阻塞,等待下一次通道中还有数据
	for data := range ch {
		// 打印通道数据
		fmt.Println(data)
		// 当遇到数据0时, 退出循环接收
		if data == 0 {
			break
		}
	}
}

通道类型

  1. 无缓冲通道(上面例子中都是无缓冲通道):必须有发送有接收,不然就报错。并且有阻塞,发送一个数据后必须有接收数据接收,才能再次向通道中存入下一个数据。
  2. 有缓冲通道(可以存放多个数据)
  3. 单向通道
有缓冲通道

和创建无缓冲通道一样,但是需要指明通道大小参数
其实无缓冲通道可以看成缓冲大小为0的有缓冲通道。
所以有缓冲通道的阻塞条件为:

  1. 带缓冲通道被填满时,尝试再次发送数据时发生阻塞。
  2. 带缓冲通道为空时,尝试接收数据时发生阻塞。
package main
import "fmt"
func main() {
	// 创建一个3个元素缓冲大小的整型通道
	ch := make(chan int, 3)
	// 查看当前通道的大小
	fmt.Println(len(ch)) //0
	// 发送3个整型元素到通道
	ch <- 1
	ch <- 2
	ch <- 3
	// 查看当前通道的大小
	fmt.Println(len(ch))//3
}
package main
import (
"fmt"
)
func main() {
	// 构建一个缓冲区为3的通道
	ch := make(chan int, 3)
	// 开启一个并发匿名goroutine
	go func() {
		// 从3循环到0
		for i := 3; i >= 0; i-- {
			// 将数据放入通道,3,2,1,0先放入通道,当通道满了就阻塞
			ch <- i
		}
	}()
	// 从通道中取数据,当没有数据就阻塞
	for data := range ch {
		// 打印通道数据
		fmt.Println(data)
		// 当遇到数据0时, 退出循环接收
		if data == 0 {
			break
		}
	}
}

问题:为什么Go语言对通道要限制长度而不提供无限长度的通道?
我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。

单向通道
ch := make(chan int)
// 声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch

上面的例子中,chSendOnly 只能写入数据,如果尝试读取数据
另一种创建方式

ch := make(<-chan int)
var chReadOnly <-chan int = ch
<-chReadOnly

3. 互斥锁、读写锁

sync 包提供了两种锁类型:互斥锁sync.Mutex 和 读写锁sync.RWMutex。

不加锁

package main
import (
	"fmt"
)
func main() {
	var count = 0
	//var wg sync.WaitGroup //声明互斥锁变量
	n := 10
	//wg.Add(n)
	//开启n个协程对count进行累加
	for i := 0; i < n; i++ {
		go func() {
			//defer wg.Done()
			//1万叠加
			for j := 0; j < 1000; j++ {
				count++
			}
		}()
	}
	var input string
	fmt.Scanln(&input)
	fmt.Println(count)
}

我们想要的共享变量累加结果应该是10000.但是实际上每次运行到不了10000,因为多个线程在累加时出现了对count的竞争,也就是当一个线程做累加时,同时另一个线程也拿到count做累加。导致结果错误

互斥锁

Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。

package main

import (
	"fmt"
	"sync"
)

func main() {
	var count = 0
	var mu sync.Mutex //声明互斥锁变量
	n := 10

	//开启n个协程对count进行累加
	for i := 0; i < n; i++ {
		go func() {
			//1万叠加
			for j := 0; j < 1000; j++ {
				//对共享变量进行加锁然后操作
				mu.Lock()
				count++
				mu.Unlock()
			}
		}()
	}
	var input string
	fmt.Scanln(&input)
	fmt.Println(count)
}

用上面加锁之后,结果就正确了

读写锁

RWMutex 相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁(调用 RLock() 方法;而写锁(调用 Lock() 方法)会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。从 RWMutex 的实现看,RWMutex 类型其实组合了 Mutex。
Mutex在大量并发的情况下,会造成锁等待,对性能的影响比较大。
读写锁主要遵循以下规则 :

  1. 读写锁的读锁可以重入,在已经有读锁的情况下,可以任意加读锁。读加锁,可读
  2. 在读锁没有全部解锁的情况下,写操作会阻塞直到所有读锁解锁。读加锁,不可写
  3. 写锁定的情况下,其他协程的读写都会被阻塞,直到写锁解锁。写加锁,不可读写

写锁:Lock/Unlock
读锁:RLock/RUnlock

读锁实例
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var m sync.RWMutex
	//开启三个协程
	go read(&m, 1)
	go read(&m, 2)
	go read(&m, 3)

	time.Sleep(2 * time.Second)
}

func read(m *sync.RWMutex, i int) {
	fmt.Println(i, "reader start")
	//给reading加读锁,并且停一秒
	m.RLock()
	fmt.Println(i, "reading")
	time.Sleep(1 * time.Second)
	m.RUnlock()//解开读锁

	fmt.Println(i, "reader over")
}
/*
1 reader start
1 reading
2 reader start
3 reader start
3 reading
2 reading
2 reader over
1 reader over
3 reader over
 */

可以看到,在一个协程拿读锁的时候,其他协程也可以进来读。

读写锁实例
package main

import (
	"fmt"
	"sync"
	"time"
)

var count = 0

func main() {
	var m sync.RWMutex
	//开启三个写线程
	for i := 1; i <= 3; i++ {
		go write(&m, i)
	}
	//开启三个读协程
	for i := 1; i <= 3; i++ {
		go read(&m, i)
	}

	time.Sleep(1 * time.Second)
	fmt.Println("final count:", count)
}
//读开始的时候,对count上读锁
func read(m *sync.RWMutex, i int) {
	fmt.Println(i, "reader start")
	m.RLock()
	fmt.Println(i, "reading count:", count)
	time.Sleep(1 * time.Millisecond)
	m.RUnlock()

	fmt.Println(i, "reader over")
}
//写开始的时候,将count上写锁
func write(m *sync.RWMutex, i int) {
	fmt.Println(i, "writer start")
	m.Lock()
	count++
	fmt.Println(i, "writing count", count)
	time.Sleep(1 * time.Millisecond)
	m.Unlock()

	fmt.Println(i, "writer over")
}
/*
3 reader start
3 reading count: 0
2 reader start
2 reading count: 0
3 writer start  //3写锁之前只有2和3读锁上锁了,在此处阻塞等待
2 writer start
1 writer start

1 reader start
3 reader over
2 reader over

3 writing count 1 //此时23读锁释放,3写锁可以进入
3 writer over
1 reading count: 1 //3写锁结束,1读锁才可以进入,读
1 reader over
2 writing count 2
2 writer over
1 writing count 3
1 writer over
final count: 3
 */

解析:1.读线程上读锁的时候,写线程要写该共享变量会阻塞等待。等到所有读线程执行完了,读锁释放。写锁才可以获得。
2. 当有一个线程上写锁的时候,必须等到写完之后,其他线程才进去写。