Go -- 并发编程

math/rand包 crypto/rand包生随机数

  • 该部分为补充内容,不涉及并发

1、math/rand 包

  • math/rand 包实现了伪随机数生成器

1.1、主要方法

(1)func Seed(seed int64)

设置随机种子,不设置则默认Seed(1)

(2)func Int() int

返回一个非负的伪随机int值

(3)func Int31() int32

返回一个int32类型的非负的31位伪随机数

(4)func Int63() int64

返回一个int64类型的非负的63位伪随机数

(5)func Intn(n int) int

返回一个取值范围在[0,n)的伪随机int值,如果n<=0会panic

(6)func Int31n(n int32) int32

返回一个取值范围在[0,n)的伪随机int32值,如果n<=0会panic

(7)func Int63n(n int64) int64

返回一个取值范围在[0, n)的伪随机int64值,如果n<=0会panic

(8)func Float32() float32

返回一个取值范围在[0.0, 1.0)的伪随机float32值

(9)func Float64() float64

返回一个取值范围在[0.0, 1.0)的伪随机float64值

(10)func Perm(n int) []int

返回一个有n个元素的,[0,n)范围内整数的伪随机排列的切片

1.2、示例

package main
 
import (
    "fmt"
    "math/rand"
    "time"
)
 
func main() {
    rand.Seed(time.Now().Unix()) //若不设置随机种子,每次运行结果都一样
    fmt.Println(rand.Intn(10))
    fmt.Println(rand.Float64())
    //设置随机种子
    rand.Seed(time.Now().UnixNano())
    fmt.Println(rand.Intn(10))
    fmt.Println(rand.Float64())
    //随机数切片
    fmt.Println(rand.Perm(5))
}

1.3、rand.Seed(time.Now().UnixNano())作用:

  • 获取随机数,不加随机种子,每次遍历获取都是重复的一些随机数据
  • rand.Seed(time.Now().UnixNano()) 设置随机数种子,加上这行代码,可以保证每次随机都是随机的
package main

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

func main() {
	rand.Seed(time.Now().Unix()) // 当我们将代码编译后,执行可执行文件,产生的随机数不变,因此需要加入这段种子代码
	for i := 0; i <= 5; i++ {
		r1 := rand.Int()    // 随机生成int64的数
		r2 := rand.Intn(10) // 指定范围,[0~max)的随机数
		fmt.Println(r1, r2)
	}
}

2、crypto/rand 包

  • crypto/rand 包实现了用于加解密的更安全的随机数生成器

2.1、主要方法

(1)func Int(rand io.Reader, max *big.Int) (n *big.Int, err error)

返回一个在[0, max)区间服从均匀分布的随机值,如果max<=0则会panic

(2)func Prime(rand io.Reader, bits int) (p *big.Int, err error)

返回一个具有指定字位数(二进制的位数)的数字,该数字具有很高可能性是质数。如果从rand读取时出错,或者bits<2会返回错误

(3)func Read(b []byte) (n int, err error)

本函数是一个使用io.ReadFull调用Reader.Read的辅助性函数。当且仅当err == nil时,返回值n == len(b)

2.2、代码示例

package main
 
import (
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "math/big"
)
 
func main() {
    //1、Int
    n, err := rand.Int(rand.Reader, big.NewInt(128))
    if err == nil {
        fmt.Println("rand.Int:", n, n.BitLen())
    }
    //2、Prime
    p, err := rand.Prime(rand.Reader, 5)
    if err == nil {
        fmt.Println("rand.Prime:", p)
    }
    //3、Read
    b := make([]byte, 32)
    m, err := rand.Read(b)
    if err == nil {
        fmt.Println("rand.Read:", b[:m])
        fmt.Println("rand.Read:", base64.URLEncoding.EncodeToString(b))
    }
}

输出结果

rand.Int: 62 6
rand.Prime: 29
rand.Read: [94 77 69 89 129 128 135 213 37 53 73 101 0 33 186 128 123 230 56 129 32 168 95 61 61 128 176 133 26 168 52 56]
rand.Read: Xk1FWYGAh9UlNUllACG6gHvmOIEgqF89PYCwhRqoNDg=

Go语言中的并发编程

1、并发与并行

  • 了解并发和并行

    • 并发:同一时间段内执行多个任务(你在用微信和两个女朋友聊天)。
    • 并行:同一时刻执行多个任务(你和你朋友都在用微信和女朋友聊天)。
  • Go语言的并发通过goroutine实现。

    • goroutine类似于线程,属于用户态的线程,我们可以根据需要创建成千上万个goroutine并发工作。
    • goroutine是由Go语言的运行时(runtime)调度完成,而线程是由操作系统调度完成。
  • Go语言还提供channel在多个goroutine间进行通信。

    • goroutine和channel是 Go 语言秉承的 CSP(Communicating Sequential Process)并发模式的重要实现基础。

2、goroutine

  • 在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?

  • Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

  • 在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

2.1、使用goroutine

  • Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。

  • 一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

2.2、启动单个goroutine

  • 启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。

如下示例:输出结果只有main,原因是main()函数执行结束了,由main()函数启动的gorouting也都结束了

func hello() {
	fmt.Println("hello world")
}

// 程序启动,其实也是创建一个主gorouting去执行
func main() {
	go hello() // 调用函数时,在前面加上go关键字,就可以开启一个gorouting。(开启一个gorouting去单独运行函数)
	fmt.Println("main") // 只输出main,原因是这行代码执行太快,main输出后函数执行结束。
}

执行结果是

main
  • 我们在主函数后面加上sleep,延迟结束时间,如下
func hello() {
	fmt.Println("hello world")
}

// 程序启动,其实也是创建一个主gorouting去执行
func main() {
	go hello() // 调用函数时,在前面加上go关键字,就可以开启一个gorouting。(开启一个gorouting去单独运行函数)
	fmt.Println("main")
	time.Sleep(1)
}

输出结果就为

main
hello world

2.3、sync.WaitGroup 同步gorouting

  • 在Go语言中实现并发就是这样简单,我们还可以启动多个goroutine。
  • sync.WaitGroup可以实现与gorouting的同步,是其运行完后主gorouting再结束
    • sync.WaitGroup的Add()方法,每启动一个gorouting,计数就会+1
    • sync.WaitGroup的Down()方法,每结束一个gorouting,计数就会-1
    • sync.WaitGroup的Wait()方法,等待所有的gorouting都执行结束

示例:这里使用了sync.WaitGroup来实现goroutine的同步(比sleep更好)

var wg sync.WaitGroup

func hello(i int) {
	defer wg.Done() // goroutine结束就登记-1
	fmt.Println("Hello Goroutine!", i)
}
func main() {

	for i := 0; i < 10; i++ {
		wg.Add(1) // 启动一个goroutine就登记+1
		go hello(i)
	}
	wg.Wait() // 等待wg的计数器减为零
}

输出结果

Hello Goroutine! 0
Hello Goroutine! 9
Hello Goroutine! 4
Hello Goroutine! 1
Hello Goroutine! 2
Hello Goroutine! 3
Hello Goroutine! 6
Hello Goroutine! 5
Hello Goroutine! 7
Hello Goroutine! 8

// 多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine的调度是随机的。

3、goroutine与线程

3.1、可增长的栈

  • OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

3.2、goroutine调度

  • GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

    • G很好理解,就是个goroutine,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。

    • P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。

    • M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;是真正干活的

    • P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。

    • P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

  • 单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。

    • 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。
    • 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

参考地址:https://www.cnblogs.com/sunsky303/p/9705727.html

3.3、GOMAXPROCS 最大使用的线程数设置

  • Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
  • Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
  • Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数

示例:设置最大线程数为1,意味着只有一个线程工作,所以下面示例中的两个gorouting间是有序的,一个个执行

var wg sync.WaitGroup

func f1() {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		fmt.Printf("A: %d\n", i)
	}
}

func f2() {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		fmt.Printf("B: %d\n", i)
	}
}
func main() {
	// 设置最大线程数为1,若不设置则默认开启系统的最大线程数
	runtime.GOMAXPROCS(1)
	// 设置计数值为2,开启两个gorouting
	wg.Add(2)
	go f1()
	go f2()

	wg.Wait()
}

输出结果

B: 0
B: 1
B: 2
B: 3
B: 4
A: 0
A: 1
A: 2
A: 3
A: 4

示例2:设置线程数为2以上,可以发现多个gorouting并发执行,并发数是2个gorouting
输出结果如下

B: 0
B: 1
A: 0
B: 2
A: 1
B: 3
A: 2
B: 4
A: 3
A: 4
  • Go语言中的操作系统线程和goroutine的关系:
    • 一个操作系统线程对应用户态多个goroutine。
    • go程序可以同时使用多个操作系统线程。
    • goroutine和OS线程是多对多的关系,即m:n(把m个gorouting分配给n个os操作系统线程去执行)

4、channel

  • 单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。

  • 虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

  • Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。

  • 如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

  • Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

4.1、channel类型

  • channel是一种类型,一种引用类型,需要指定通道中的元素类型。
  • 声明通道类型的格式如下:
    • var 变量 chan 元素类型

示例:

var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道

4.2、创建channel

1、注意点:

  • 通道是引用类型,是不可以直接使用的,直接声明是通道类型的空值nil。
  • 声明的通道后需要使用make函数初始化之后才能使用。
var ch chan int
fmt.Println(ch) // <nil>

2、创建channel的格式如下:

  • channel的缓冲大小是可选的。
make(chan 元素类型, [缓冲大小])
  • 示例:
ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)
func main() {
	ch1 := make(chan int)
	fmt.Println(ch1)  // 0xc00005c060
}

缓冲区的作用:有了缓冲区,数据可以发送到缓冲区中,等待接收者接收。无缓冲区的,就需要一收一发了。

4.3、channel操作

  • 通道有发送(send)、接收(receive)和关闭(close)三种操作。
  • 发送和接收都使用<-符号。

示例:

# 首先定义一个通道
ch := make(chan int)

# 发送,将值发送到定义的通道中
ch <- 10 // 把10发送到ch中

# 接收:从通道中接收值
x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果

# 关闭通道,我们通过调用内置的close函数来关闭通道。
close(ch)

  • 补充:

    • 关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。
    • 通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。
  • 关闭后的通道有以下特点:
    1、对一个关闭的通道再发送值就会导致panic。
    2、对一个关闭的通道进行接收会一直获取值直到通道为空。
    3、对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
    4、关闭一个已经关闭的通道会导致panic。

4.4、无缓冲的通道

无缓冲的通道又称为阻塞的通道。

如下示例:这段代码能够通过编译,但是执行的时候会出现错误:

func main() {
	ch := make(chan int)
	ch <- 10
	fmt.Println("发送成功")
}

报错如下

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        .../src/github.com/Q1mi/studygo/day06/channel02/main.go:8 +0x54
  • deadlock报错的原因是:
    • 因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。因此上面代码中的ch <- 10发送值到通道却没有接收,形成了一个死锁。
  • 解决办法:启用一个gorouting去接收值,示例如下
func recv(c chan int) {
	ret := <-c
	fmt.Println("接收成功", ret)
}
func main() {
	ch := make(chan int)
	go recv(ch) // 启用goroutine从通道接收值
	ch <- 10
	fmt.Println("发送成功")
}

补充:

  • 无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

  • 使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。

4.5、有缓冲的通道

  • 解决上面问题的方法还有一种就是使用有缓冲区的通道。我们可以在使用make函数初始化通道的时候为其指定通道的容量。

  • 只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。

  • 我们可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,虽然我们很少会这么做。

示例:

func main() {
	ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
	ch <- 10
	fmt.Println("发送成功")
}
# 同样,如果容量为1,只能暂存一个数据,若这个数据没有被接收而且有发送了一个数据到通道,就会报错

4.6 从通道中循环取值

  • 当向通道中发送完数据时,我们可以通过close函数来关闭通道。
  • 当通道被关闭时,再往该通道发送值会引发panic,从该通道取值的操作会先取完通道中的值,再然后取到的值一直都是对应类型的零值。
  • 那如何判断一个通道是否被关闭了呢?有两中方法:
    • 方法一:用两个变量接收通道发出的值,当通道关闭时,如下示例中的ok就为false
      • 使用这种方式,取值结束后需要手动关闭通道
      • 利用这种方式,当我们取值的通道已经没有值时,接收值的变量会返回对应的值的类型的空值,而ok变量会返回false
    • 方法二:使用for range遍历通道,当通道被关闭的时候就会退出for range。这个方法比较常用

示例:

// channel 练习
func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	// 开启goroutine将0~100的数发送到ch1中
	go func() {
		for i := 0; i < 10; i++ {
			ch1 <- i
		}
		close(ch1)
	}()
	// 开启goroutine从ch1中接收值,并将该值的平方发送到ch2中
	go func() {
		for {
			i, ok := <-ch1 // 通道关闭后再取值ok=false
			if !ok {
				break
			}
			ch2 <- i * i
		}
		close(ch2)
	}()
	// 在主goroutine中从ch2中接收值打印
	for i := range ch2 { // 通道关闭后会退出for range循环
		fmt.Println(i)
	}
}

输出结果

0
1
4
9
16
25
36
49
64
81

示例讲解:for range 和 for i,ok := <-chan的使用区别

/*
在下面示例中,f2不能用range来遍历通道中的值,因为若使用range,f1和f2同时执行,此时f2中的通道ch2没有获取到f1中的值后就直接关闭ch2通道,导致ch1通道中的值没有接收者而报错
  - 解决办法: 使用ok的方式来遍历接收ch1通道中的值,值全部发送到ch1后就会关闭ch1通道,ok就会返回false并退出循环,与此同时ch1通道中的值都已经传递给ch2了,退出循环后ch2通道也会关闭。
  - f1和f2同时执行,通道ch1中每传递一个值就会被ch2接收,可以不用定义缓存大小,但是ch2是在ch1的值全部被接收后,才被for range遍历,因此需要缓存
*/
func f1(ch1 chan int) {
	defer wg.Done()
	for i := 0; i < 10; i++ {
		ch1 <- i
	}
	close(ch1)
}

func f2(ch1, ch2 chan int) {
	defer wg.Done()
	for {
		i, ok := <-ch1
		if !ok {
			break
		}
		ch2 <- i * i
	}
	// close(ch2)
	once.Do(func() { close(ch2) }) // 利用sync.Once中的Do()方法,确保括号中的函数只执行一次,即我们多次调用f2函数时close(ch2)只执行一次
}
func main() {

	wg.Add(3)
	go f1(ch1)
	go f2(ch1, ch2)
	go f2(ch1, ch2)
	wg.Wait()
	for x := range ch2 {
		fmt.Println(x)
	}
}

4.7、单向通道

  • 有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。
  • Go语言中提供了单向通道来处理这种情况。
    • 单向通道多用于函数的参数 (目的是确保函数中传入的通道只能进行一种操作)
    • chan<- int是一个只写单向通道(只能对其写入int类型值),可以对其执行发送操作但是不能执行接收操作;
    • <-chan int是一个只读单向通道(只能从其读取int类型值),可以对其执行接收操作但是不能执行发送操作。
      在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的
  • 在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的

示例:

// 只写的单向通道类型 chan<- int
func counter(out chan<- int) {
	for i := 0; i < 10; i++ {
		out <- i
	}
	close(out)
}
// 只写的单向通道类型 chan<- int
func squarer(out chan<- int, in <-chan int) {
	for i := range in {
		out <- i * i
	}
	close(out)
}

// 只读的单向通道类型 <-chan int
func printer(in <-chan int) {
	for i := range in {
		fmt.Println(i)
	}
}

func main() {
	var out = make(chan int)
	var in = make(chan int)
	go counter(out)  // 开一个协程将值写入到out通道中
	go squarer(in, out) // 再开一个协程将out通道中的值计算后写入到in通道中
	printer(in) // 使用range来读取in通道中的值
}

4.8、通道总结

channel常见的异常总结,如下图
在这里插入图片描述
关闭已经关闭的channel也会引发panic。

5、worker pool(goroutine池)

  • 在工作中我们通常会使用可以指定启动的goroutine数量–worker pool模式,控制goroutine的数量,防止goroutine泄漏和暴涨。
func worker(id int, jobs <-chan int, results chan<- int) {
	for j := range jobs {
		fmt.Printf("worker:%d start job:%d\n", id, j)
		time.Sleep(time.Second)
		fmt.Printf("worker:%d end job:%d\n", id, j)
		results <- j * 2
	}
}

func main() {
	jobs := make(chan int, 10)
	results := make(chan int, 10)
	// 开启3个goroutine,也就是开启3个工作者
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}
	// 5个任务,传入5个值到通道中
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)
	// 输出结果
	for a := 1; a <= 5; a++ {
		<-results  // 接受result的值,但是值不用
	}
}

输出结果:有三个worker来执行工作

worker:3 start job:1
worker:1 start job:2
worker:2 start job:3
worker:1 end job:2
worker:1 start job:4
worker:3 end job:1
worker:3 start job:5
worker:2 end job:3
worker:3 end job:5
worker:1 end job:4

6、select多路复用

  • 在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现,如下:
for{
    // 尝试从ch1接收值,不OK就继续往下
    data, ok := <-ch1
    // 尝试从ch2接收值
    data, ok := <-ch2
    …
}
# 这种方式没有做到随机从某个通道中取值的效果,依旧是一次顺序向下执行的
  • 上面这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了select关键字,可以同时响应多个通道的操作。
    • select的使用类似于switch语句,它有一系列case分支和一个默认的分支。
    • 每个case会对应一个通道的通信(接收或发送)过程。
    • select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。
    • 具体格式如下:
select{
    case <-ch1:
        ...
    case data := <-ch2:
        ...
    case ch3<-data:
        ...
    default:
        默认操作
}

示例:使用select,随机从下面的两个case中匹配执行

  • 使用select,随机从下面的两个case中匹配执行
    • 第一次循环,随机匹配两个case,第一个case的ch1中没有值,执行第二个case,将0发送到通道。
    • 第二次循环时,由于通道的缓冲区只有1,只能放一个值,因此无法匹配第二个case继续发送值,执行第一个取值的case。
    • 第三次·····第n次,依次类推
func main() {
	fmt.Println()

	var ch1 = make(chan int, 1)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch1:
			fmt.Println(x)
		case ch1 <- i:
		}
	}
}

输出结果

0
2
4
6
8
  • 使用select语句能提高代码的可读性。
    • 可处理一个或多个channel的发送/接收操作。
    • 如果多个case同时满足,select会随机选择一个。
    • 对于没有case的select{}会一直等待,可用于阻塞main函数。
  • 使用场景:莫一时刻,随机的从一个通道中取值

7、并发安全和锁

  • 有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。
  • 类比现实生活中的例子有十字路口被各个方向的的汽车竞争;还有火车上的卫生间被车厢里的人竞争。

示例:下面代码中我们开启了两个goroutine去累加变量x的值,这两个goroutine在访问和修改x变量的时候就会存在数据竞争,导致最后的结果与期待的不符。(在windows上执行有问题,看不出效果,用mac才行)

var x int64
var wg sync.WaitGroup

func add() {
	for i := 0; i < 5000; i++ {
		x = x + 1
	}
	wg.Done()
}
func main() {
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x)
}

7.1、互斥锁

  • 互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。
  • 使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。
var x int64
var wg sync.WaitGroup
var lock sync.Mutex   // 定义一个互斥锁

func add() {
	for i := 0; i < 5000; i++ {
		lock.Lock() // 加锁
		x = x + 1
		lock.Unlock() // 解锁
	}
	wg.Done()
}
func main() {
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x)
}

7.2、读写互斥锁

  • 互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。

  • 读写锁分为两种:读锁和写锁。

    • 当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,
    • 如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。
    • 即当一个gorouting获取的是读锁后,其他gorouting依旧可以读;但如果一个gorouting获取的是一个写锁,那么其他的gorouting急不可读也不可写(防止用户读取时正在更新写入,导致读取的不是最新的)
var (
	x      int64
	wg     sync.WaitGroup
	lock   sync.Mutex
	rwlock sync.RWMutex // 定义读写锁
)

func write() {
	// lock.Lock()   // 加互斥锁
	rwlock.Lock() // 加写锁
	x = x + 1
	time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
	rwlock.Unlock()                   // 解写锁
	// lock.Unlock()                     // 解互斥锁
	wg.Done()
}

func read() {
	// lock.Lock()                  // 加互斥锁
	rwlock.RLock()               // 加读锁
	time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
	rwlock.RUnlock()             // 解读锁
	// lock.Unlock()                // 解互斥锁
	wg.Done()
}

func main() {

	start := time.Now()
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go write()
	}

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go read()
	}

	wg.Wait()
	end := time.Now()
	fmt.Println(end.Sub(start))
}

7.3、sync.WaitGroup

  • 在代码中生硬的使用time.Sleep肯定是不合适的,Go语言中可以使用sync.WaitGroup来实现并发任务的同步。 sync.WaitGroup
  • sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。
    • 例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。
方法名功能
(wg * WaitGroup) Add(delta int)计数器+delta
(wg *WaitGroup) Done()计数器-1
(wg *WaitGroup) Wait()阻塞直到计数器变为0

示例

var wg sync.WaitGroup

func hello() {
	defer wg.Done()
	fmt.Println("Hello Goroutine!")
}
func main() {
	wg.Add(1)
	go hello() // 启动另外一个goroutine去执行hello函数
	fmt.Println("main goroutine done!")
	wg.Wait()
}

需要注意sync.WaitGroup是一个结构体,传递的时候要传递指针。

7.4、sync.Once

  • 在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。

  • Go语言中的sync包中提供了一个针对只执行一次场景的解决方案:sync.Once

  • sync.Once只有一个Do方法,并且Do()方法只能接收一个没有参数没有返回值的函数,其签名如下:
    func (o *Once) Do(f func()) {} (备注:如果要执行的函数f需要传递参数就需要搭配闭包来使用。)

1、加载配置文件的示例:

  • 延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序的启动耗时,而且有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。示例如下:
var icons map[string]image.Image

func loadIcons() {
	icons = map[string]image.Image{
		"left":  loadIcon("left.png"),
		"up":    loadIcon("up.png"),
		"right": loadIcon("right.png"),
		"down":  loadIcon("down.png"),
	}
}

// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
	if icons == nil {
		loadIcons()
	}
	return icons[name]
}
  • 多个goroutine并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons函数可能会被重排为以下结果:
func loadIcons() {
	icons = make(map[string]image.Image)
	icons["left"] = loadIcon("left.png")
	icons["up"] = loadIcon("up.png")
	icons["right"] = loadIcon("right.png")
	icons["down"] = loadIcon("down.png")
}
  • 在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做又会引发性能问题。

  • 使用sync.Once改造的示例代码如下:

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
	icons = map[string]image.Image{
		"left":  loadIcon("left.png"),
		"up":    loadIcon("up.png"),
		"right": loadIcon("right.png"),
		"down":  loadIcon("down.png"),
	}
}

// Icon 是并发安全的
func Icon(name string) image.Image {
	loadIconsOnce.Do(loadIcons)
	return icons[name]
}

2、并发安全的单例模式
下面是借助sync.Once实现的并发安全的单例模式:

package singleton

import (
    "sync"
)

type singleton struct {}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

7.5、sync.Map

  • Go语言中内置的map不是并发安全的。(并发操作map就会出错)

看下面示例:当我并发操作map时,如果并发量在20个gorouting以内,一般不会出现问题,但是如果超出了20就会出错

var m = make(map[string]int)

func get(key string) int {
	return m[key]
}

func set(key string, value int) {
	m[key] = value
}

func main() {
	wg := sync.WaitGroup{}
	// for i := 0; i < 20; i++ {
	for i := 0; i < 21; i++ {  // 如果此时我们开启的是21个gorouting,就会报错
		wg.Add(1)
		go func(n int) {
			key := strconv.Itoa(n)
			set(key, n)
			fmt.Printf("k=:%v,v:=%v\n", key, get(key))
			wg.Done()
		}(i)
	}
	wg.Wait()
}

如下报错:

goroutine 25 [semacquire]:
internal/poll.runtime_Semacquire(0x9)
        E:/GolangPATH/src/runtime/sema.go:61 +0x25
internal/poll.(*fdMutex).rwlock(0xc00007a280, 0xf4)
        E:/GolangPATH/src/internal/poll/fd_mutex.go:154 +0xd2
internal/poll.(*FD).writeLock(...)
        E:/GolangPATH/src/internal/poll/fd_mutex.go:239
internal/poll.(*FD).Write(0xc00007a280, {0xc00008e140, 0xc, 0x10})
        E:/GolangPATH/src/internal/poll/fd_windows.go:598 +0x6c
os.(*File).write(...)
        E:/GolangPATH/src/os/file_posix.go:49
os.(*File).Write(0xc000006018, {0xc00008e140, 0xc, 0xc000135f98})
        E:/GolangPATH/src/os/file.go:176 +0x65
fmt.Fprintf({0x137ee0, 0xc000006018}, {0x117b7d, 0xc}, {0xc000135f98, 0x2, 0x2})
        E:/GolangPATH/src/fmt/print.go:205 +0x9b
fmt.Printf(...)
        E:/GolangPATH/src/fmt/print.go:213
main.main.func1(0x12)
        E:/GoProject/src/gitee.com/LTP/test/main.go:27 +0x11b
created by main.main
        E:/GoProject/src/gitee.com/LTP/test/main.go:24 +0x38
  • 上面的代码开启少量几个goroutine的时候可能没什么问题,当并发多了之后执行上面的代码就会报 fatal error: concurrent map writes错误。

  • 像这种场景下就需要为map加锁来保证并发的安全性了

    • Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。
    • 开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。
    • 同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。

使用sync.Map{}的示例如下:如果需要对map进行并发操作,就可以用sync.Map{}

// 定义一个sync包内的安全版的map(和内置的map有些区别)
var m = sync.Map{}

func main() {
	wg := sync.WaitGroup{}
	for i := 0; i < 25; i++ {
		wg.Add(1)
		go func(value int) {
			key := strconv.Itoa(value)
			// sync.Map{}的Store()方法,用于存储键值对,参数1为新增的键,参数2为对应的值。(对应内置map的 map[key] = value)
			m.Store(key, value)
			// sync.Map{}的Load()方法,用于取出对应key的值。(对应着内置map的 value = map[key])
			v, ok := m.Load(key)
			if !ok {
				fmt.Println("map取值失败")
				return
			}
			fmt.Printf("k=:%v,v:=%v\n", key, v)
			wg.Done()
		}(i)
	}
	wg.Wait()
}

8、原子操作

  • 在上面的代码中的我们通过锁操作来实现同步。
    • 而锁机制的底层是基于原子操作的,其一般直接通过CPU指令实现。
    • Go语言中原子操作由内置的标准库sync/atomic提供。

8.1、atomic包

方法解释
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
读取操作
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
写入操作
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)
修改操作
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
比较并交换操作

示例:如下示例中,使用了安全锁,对变量x进行了+1的操作。

  • 原子操作atomic包中就直接提供了一个对int64数字的加法操作的方法
var lock sync.Mutex
var x int64
var wg sync.WaitGroup

func f() {
	lock.Lock()
	x++
	wg.Done()
	lock.Unlock()
}
func main() {
	wg.Add(1000)
	for i := 0; i < 1000; i++ {
		go f()
	}
	wg.Wait()
	fmt.Println(x)
}

示例2:针对于上面示例进行修改,利用atomic包中的方法对int64数字的加法操作的方法

  • atomic.AddInt64(&x, 1) 参数1是int64类型的变量指针,参数2表示每次递增的数字(这里就是x每次递增1,x++的意思)
var x int64
var wg sync.WaitGroup

func f() {
	defer wg.Done()
	atomic.AddInt64(&x, 1)
}
func main() {
	wg.Add(1000)
	for i := 0; i < 1000; i++ {
		go f()
	}
	wg.Wait()
	fmt.Println(x)  // 1000
}

示例3:比较并交换的用法

func main() {
	// 比较并交换
	var num = int64(100)
	// atomic.CompareAndSwapInt64(),将int64类型变量的指针传入参数1与参数2的值进行比较,如果相等,那么就进行值交换,将参数3的值赋值给变量
	ok := atomic.CompareAndSwapInt64(&num, 100, 10)
	if !ok {
		fmt.Println("变量的值不相等")
		return
	}
	fmt.Println(num)
}

示例4:我们填写一个示例来比较下互斥锁和原子操作的性能。

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

type Counter interface {
	Inc()
	Load() int64
}

// 普通版
type CommonCounter struct {
	counter int64
}

func (c CommonCounter) Inc() {
	c.counter++
}

func (c CommonCounter) Load() int64 {
	return c.counter
}

// 互斥锁版
type MutexCounter struct {
	counter int64
	lock    sync.Mutex
}

func (m *MutexCounter) Inc() {
	m.lock.Lock()
	defer m.lock.Unlock()
	m.counter++
}

func (m *MutexCounter) Load() int64 {
	m.lock.Lock()
	defer m.lock.Unlock()
	return m.counter
}

// 原子操作版
type AtomicCounter struct {
	counter int64
}

func (a *AtomicCounter) Inc() {
	atomic.AddInt64(&a.counter, 1)
}

func (a *AtomicCounter) Load() int64 {
	return atomic.LoadInt64(&a.counter)
}

func test(c Counter) {
	var wg sync.WaitGroup
	start := time.Now()
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			c.Inc()
			wg.Done()
		}()
	}
	wg.Wait()
	end := time.Now()
	fmt.Println(c.Load(), end.Sub(start))
}

func main() {
	c1 := CommonCounter{} // 非并发安全
	test(c1)
	c2 := MutexCounter{} // 使用互斥锁实现并发安全
	test(&c2)
	c3 := AtomicCounter{} // 并发安全且比互斥锁效率更高
	test(&c3)
}
  • atomic包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。

练习

  • 使用gorouting和channel来实现计算int64随机数的求和
    • 1、开启gorouting随机生成int64位的随机数,发送到jobchan
    • 2、开启24个gorouting虫jobchan取出随机数计算其位数的和,将结果发送到resultchan中
    • 3、主gorouting从resultChan取出结果并打印到
package main

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

type job struct {
	value int64
}

type result struct {
	job *job
	sum int64
}

var jobChan = make(chan *job, 100)
var resultChan = make(chan *result, 100)
var wg sync.WaitGroup

func f1(jc chan<- *job) {
	defer wg.Done()
	for {
		num := rand.Int63n(100)
		// fmt.Println(num)
		newJob := &job{
			value: num,
		}
		jc <- newJob
		time.Sleep(500 * time.Millisecond)
	}
}

func f2(ret chan *result, jc <-chan *job) {
	defer wg.Done()
	// 从jobChan中取得随机数,获取随机数的位数进行求和计算
	for {
		sum := int64(0)
		job := <-jc
		n := job.value
		for n > 0 {
			sum += (n % 10)
			n = n / 10
		}
		newret := &result{
			job: job,
			sum: sum,
		}
		ret <- newret
	}
}

func main() {
	wg.Add(1)

	go f1(jobChan)
	// time.Sleep(10 * time.Second)
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go f2(resultChan, jobChan)
	}

	for i := range resultChan {
		fmt.Println(i.job.value, i.sum)
	}
	wg.Wait()
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值