Go 并发

目录

概念

  • 并发是同一时间段执行多个任务,(你同时和两个女生聊天)
  • 并行是同一时刻执行多个任务,(你和你朋友在和女生聊天)

Go 语言的并发是通过 goroutine 实现的,goroutine 类似于线程,属于用户态的线程(程序员自己编写的) ,我们可以根据需要创建成千上万的 goroutine 并发工作,goroutine 是由 Go 语言的运行时 runtime 调度实现的,而线程是由操作系统调度完成。

Go 语言还提供 channel 在多个 goroutine 间通信。

goroutien的规则

  • Go 语言使用 goroutine 非常简单,只需要在调用函数的时候,前边加个 go 就可以了

  • 一个 goroutine 必定对应一个函数,可以创建多个 goroutine 去执行相同的函数,比如下面例子

func f1(i int) {
	fmt.Println(i)
}

func main() {
	for i := 0; i < 100000; i++ {
		go f1(i)
	}
	fmt.Println("main")
}

go语言的闭包问题

闭包就是在函数内部,引用外部的变量了,因为外边循环的快,里面循环的慢,所以导致了这种结果。

func main() {
	for i := 0; i < 10; i++ {
		go func() {
			fmt.Print(i, "\t") // 这个里面的东西,是作用域外边获得的,闭包
		}()
	}
	fmt.Println("main")
	time.Sleep(time.Second)
}
// 输出
3       7       5       7       3       7       3       main

解决闭包问题

想解决闭包问题用很简单,不要让函数读外边的值,而是让函数直接传值

func main() {
	for i := 0; i < 10; i++ {
		go func(i int) {
			fmt.Print(i, "\t") // 这个里面的东西,是作用域外边获得的,闭包
		}(i)
	}
	fmt.Println("main")
	time.Sleep(time.Second)
}

// 输出如下,顺序是乱的,因为是异步操作,但是数据并不会重复
main
4       1       0       2       6       3       5       7       8       9

为什么要给随机数添加种子?

  • 如果不添加种子,每次编译好的代码都是相同的,所以运行出来的随机数也是相同的
  • 因为使用时间戳的毫秒数肯定不一样,所以可以拿他当种子
import (
	"fmt"
	"math/rand"
)

func main() {
	for i := 0; i < 5; i++ {
		r1 := rand.Int()
		r2 := rand.Intn(9) // 可以指定最大值
		fmt.Println(r1, r2)
	}
}

// 输出
5577006791947779410 6
6129484611666145821 2
3916589616287113937 6
605394647632969758 8
894385949183117216 6

加上 种子 以后,打印:

func main() {
	rand.Seed(time.Now().UnixNano()) // 用那秒速
	for i := 0; i < 5; i++ {
		r1 := rand.Int()
		r2 := rand.Intn(9) // 可以指定最大值
		fmt.Println(r1, r2)
	}
}

goroutine 什么时候结束?

下面两种情况下,会导致 goroutine 结束

  1. goroutine 对应的函数结束了,goroutine 就结束了
  2. main 函数结束了,由 main 函数创建的那些 goroutine 就都结束了

下面改掉之前用 sleep 的说法,用高级点的方法 wg WaitGroup

wg WaitGroup

wg 只有三种方法,而且这三种方法,用的时候要一起用

  • Add
  • Done
  • Wait

下面是个例子

func f1(i int) {
	defer wg.Done() //开头就用,而且一定要在 defer 的后面
	fmt.Print(i, " ")
}

var wg sync.WaitGroup

func main() {
	for i := 0; i < 10; i++ {
		wg.Add(1) //在 goroutine 执行之前加这个
		go f1(i)
	}
	wg.Wait() //所有的 goroutine 后面执行这个
}
// 输出
// 2 5 1 4 7 9 6 8 3 0

goroutine调度

4.1 可增长的栈

OS栈(操作系统线程)一般都有固定的栈内存(通常是2M),而一个goroutine 在其生命周期开始的时候,只有很小的栈(通常2kb),goroutine 的栈不是固定的,他会按需增大或者缩小,goroutine 的栈的大小,可以限制到 1GB,虽然极少会用到这么大,所以在 go 中,一次创建十万的 goroutine 也是可以的。

goroutien 调度

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

  • 使用 runtime.GOMAXPROCS(1) 命令断定核数

  • 如果不配置,那么默认是跑满

  • defer wg.Done() 应该加derfer,保证在最后调用

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func f1() {
	defer wg.Done() //应当是 derfer 后调用
	for i := 0; i < 10; i++ {
		fmt.Println("A", i)
	}
}

func f2() {
	defer wg.Done() //  应当使用derfer 后调用
	for i := 0; i < 10; i++ {
		fmt.Println("B", i)
	}
}

var wg sync.WaitGroup

func main() {
	fmt.Println("打印CPU 的核心数")
	fmt.Println(runtime.NumCPU())
	
	runtime.GOMAXPROCS(1) // 单核使用,不填的话,始终占满真个线程
	wg.Add(2)
	go f1()
	go f2()
	wg.Wait()
}

channel

channel的定义, chan int 才是一个完整的定义!

  • 单纯的将函数并发执行是没有意义的,函数与函数之间,只有交换数据, 才能体现并发函数的意义

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

  • GO语言的并发模式是 CSP,提倡通过通信实现共享内存,而不是通过共享内存而实现通信

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

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

下面一个不指定缓冲通道数的小例子

var b chan int
var wg sync.WaitGroup

func f1() {
	defer wg.Done()
	c := <-b
	fmt.Println("f1函数", c)
}

func main() {
	fmt.Println(b)
	b = make(chan int) // 不指定缓冲通道数
	wg.Add(1)
	go f1() // 应该先取值,然后再放值
	b <- 10
	fmt.Println("函数已经完毕")
	wg.Wait()
}

// 输出
<nil>
f1函数 10
函数已经完毕

使用指定缓存区大小

这样会出现死锁的情况,因为指定了缓存区只能存一个,现在你硬往里面存两个。

var b chan int

func main() {
	b = make(chan int, 1)
	fmt.Println(b)
	b <- 10
	b <- 20
	y := <-b
	fmt.Println(y)
}

// 输出错误
// fatal error: all goroutines are asleep - deadlock!

关闭通道

关闭通道往往通过内置的 close 函数,关于通道,需要注意的是,通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在操作文件之后,关闭文件是必须的,但是关闭通道不是必须的。

close(b)

一个小例子,但是不知道 bug 在什么地方,以后再改吧。

/*
启动一个 goroutine ,生成100 个数,发送到 ch1
启动一个 goroutien,从ch1 中取值,计算其平方,然后放到 ch2 中
在 main 中,从 ch2 中取值
*/

var wg sync.WaitGroup

var a chan int
var b chan int

func f1(ch1 chan int) {
	defer wg.Done()
	for i := 0; i < 100; i++ {
		ch1 <- i
	}
}

func f2(ch1, ch2 chan int) {
	defer wg.Done()
	for x := range ch1 {
		ch2 <- x * x
	}
}

func main() {
	a = make(chan int)
	b = make(chan int)
	wg.Add(2)
	go f1(a)
	go f2(a, b)
	wg.Wait()
	for ret := range b {
		fmt.Println(ret)
	}
}

单通道限制

单通道一般用在函数的参数里面,限定参数只能读或者只能写。

func f2(ch1 <-chan int, ch2 chan<- int) {
	defer wg.Done()
	for x := range ch1 {
		ch2 <- x * x
	}
}

通道的总结:

Go 并发
阻塞就是一个goroutine在等着。死锁是所有的groutine 都在等。

select

在某些场景中,我们需要从多个通道接受数据,通道在接收数据的时候,如果没有数据将会发生阻塞,当然你可以使用 if 语句进行判断,但是这样性能就会差很多,此时,你可以使用 Go 内置的 select 关键字,同时响应多个通道的操作,select 语句的使用,类似于 switch 语句,它会有一些列 case 分支和一个默认分支,每个 case 都对应一个通道的通信(接受 或者发送过程)。select 会一直在那里等,直到某个 case 的通信操作完成时,就会执行case 对应的语句

简单点来说,就是 select 执行的语句是随机的,不一定执行哪一句,但是如果某一句不满足的话,他肯定不会执行。

func main() {
	ch := make(chan int, 1)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Println(x) // 从 x 中取值
		case ch <- i: // 后面必须加 :
			fmt.Println("放值")
		}
	}
}
// 输出
放值
0
放值
2
放值
4
放值
6
放值
8

出现上面这种情况,主要是因为通道里面只能放一个值,所以他只能执行第二个 case

如果暂存区的大于1的话,那么输出的值就不确定了,结果如下:

	func main() {
	ch := make(chan int, 10)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Println(x) // 从 x 中取值
		case ch <- i: // 后面必须加 :
			fmt.Println("放值")
		}
	}
}
// 输出
放值
0
放值
放值
放值
放值
2
3
放值
4


通道详解

1. 小例子,一个函数是从通道里读值,一个是从通道里写值

func main() {
	c := make(chan int)
	for i := 0; i < 5; i++ {
		go f1(c, i)
	}
	for i := 0; i < 5; i++ {
		s := <-c
		fmt.Println("从通道里取值", s)
	}
}

// 输出
往通道里传值 4
从通道里取值 4
往通道里传值 2
从通道里取值 2
往通道里传值 1
从通道里取值 1
往通道里传值 3
从通道里取值 3
往通道里传值 0
从通道里取值 0

上面的程序可以用下面这个图来说明一下
Go 并发

select 和time.After的例子

现实中,我们经常用到的一种情况就是,我们打印的时候,不想等太久。如果超时了,就不再等了。

func f1(id int, ch1 chan int) {
	time.Sleep(time.Duration(rand.Intn(4)) * time.Second)
	ch1 <- id // 往通道里放值
}

func main() {
	c := make(chan int)
	timeout := time.After(2 * time.Second) // 两秒
	for i := 0; i < 5; i++ {
		go f1(i,c)
	}
	for i := 0; i < 5; i++ {
		select {
		case b := <-c:
			fmt.Println(b)
		case <-timeout:
			fmt.Println("超时了不打印")
		}
	}
}

// 输出结果
0
3
超时了不打印
4
2

nil 通道的用处

  • 对于包含 select 语句的循环,如果不希望每次循环都等待 select 所涉及的所有通道数,那么可以将某些通道数设为 nil 等到发送值准备就绪之后,再将通道变成一个非nil 的值并执行发送操作。

阻塞和死锁

  • goroutine 在等待通道的发送或接受时,我们就说他被阻塞了
  • 除了goroutine 本身占用少量的内存外,被阻塞的 goroutien 并不会消耗任何其他的资源,goroutien 静静的停在那里,等待导致其阻塞的事情来解除阻塞
  • 当一个或多个goroutien 因为某些无法发生的事情被阻塞死,我们称这种情况为死锁,而出现死锁的程序通常会崩溃或者挂起

自己写一个死锁

因为他要从c中取值,但是c用于不可能有值,所以就被死锁了。

func main() {
	c := make(chan int)
	<-c
}

利用 goroutine 来实现装配线

Go 并发

  • Go 允许在没有值可发送的情况下,通过close 函数关闭通道 ,例如 close(c)
  • 通道被关闭以后,将无法写入任何值,但是可以读,如果尝试写入,就会引发 panic
  • 尝试读取被关闭的通道会获得与通道类型对应的零值
  • 注意:如果循环里读取一个已关闭的通道,并没有检测通道是否关闭,那么该循环就会一直运转下去,消耗大量的 cpu 时间
上一篇:go goroutine 怎样更好的进行错误处理


下一篇:go goroutine 怎样更好的进行错误处理