Golang并发入门笔记

94 阅读4分钟

稀土掘金课程:后端入门 - Go 语言原理与实践,第3节课:Go 语言进阶与依赖管理

1 语言进阶

Golang为什么快?

  • 区分并发与并行
  • Go可以充分发挥多核优势,高效运行。Golang为并发而生。

1.1 Goroutine

图1 goroutine和thread 线程:内核态,一个线程能并发的跑多个协程,栈MB级别 协程:用户态,轻量级线程,栈KB级别 举例,看下这段代码

func hello(i int) {
	println("hello" + fmt.Sprint(i))
}

func HelloGoRoutine() {
	for i := 0; i < 10; i++ {
		go func(j int) {
			hello(j)
		}(i)
	}
	time.Sleep(time.Second)
}
/**
hello2
hello0
hello4
hello3
hello5
hello7
hello6
hello1
hello8
**/

这段代码里第7行,for循环内部每次调用一个匿名函数来打印当前的循环编号i,使用关键字go来启动goroutine并发执行,可以看到打印结果是乱序的。 使用第11行阻塞主线程的原因:保证子协程运行完之前,主线程不退出。(以后有更优雅的方式)我去掉这行代码之后,发现一行都打印不出来。

1.2 CSP(Communicating Sequential Processes)

协程间通信 Golang建议协程间通信而共享内存,而不是反过来。 图1.2 通过通信而共享内存、通过共享内存而通信 通道Channel: 如图1.2左,通道连接着不同goroutine,如同一个队列(先入先出)。 图1.2右是通过共享内存而实现通信,这种情况下必须对临界区加锁,保证互斥访问,可能会发生data race,影响性能。

1.3 Channel

channel是一种引用类型,需要通过make创建。

make(chan 元素类型,[缓冲大小])
// 无缓冲通道(同步通道)
make(chan int)
// 有缓冲通道
make(chan int,2)

图1.3 有缓冲和无缓冲channel的区别

func calSquare() {
	// 创建同步通道
    src := make(chan int)
    // 创建缓冲容量为3的通道
	dest := make(chan int, 3)
	go func() {
		defer close(src)
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	go func() {
		defer close(dest)
		for i := range src {
			dest <- i * i
		}
	}()
	for i := range dest {
		println(i)
	}
}
/**
输出结果:
0
1
4
9
16
25
36
49
64
81
**/

src通道实现协程AB之间的通信,dest通道实现协程B和主线程之间的通信。 为什么dest要用带缓冲的通道?因为这里考虑的情景是,消费者的消费速度是比生产速度要慢的,这种情况下就要使用有缓冲的通道。(如果不用带缓冲的通道,那么当消费者的速度慢于生产速度时,生产者将会阻塞,从而降低性能,所以到底通道要不要带缓存,取决于这个通道连接两端的协程的处理速度) 另外,别忘了defer来延迟关闭通道资源。 我们知道并发可能会导致goroutine执行顺序发生改变,但是使用通道能够goroutine的同步关系。

1.4 并发安全Lock

通道实现了【通过通信而共享内存】,但是Go也支持【通过共享内存而通信】。

var (
	x    int64
	lock sync.Mutex
)

func addWithLock() {
	for i := 0; i < 2000; i++ {
		lock.Lock()
		x = x + 1
		lock.Unlock()
	}
}

func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x = x + 1
	}
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("WithoutLock:", x)
	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)
}

这里使用两种方式来实现加法,一种是用互斥锁的,一种不用。 如果不用互斥锁,那5个协程之间会产生data race,从而导致输出结果不为10000(并发安全问题),当然,由于操作系统的异步性,你也不知道它会输出多少,所以到底会不会出错,出错给出的值是多少,这些都是概率问题,概率问题会为debug带来很大麻烦。 而加锁之后,通过设置临界区,能保证数据同步。

1.5 WaitGroup

在前面的两个例子当中,无论是协程还是锁,都使用了sleep来强制阻塞主进程,让其等到子协程全部执行结束再退出。 但是这是不优雅的,甚至,是可能会出错的。 因为你无法确保所有子协程都在1秒钟内结束,所以设置一个固定的阻塞时间是不能确保没有问题的。 Go语言中,能够使用waitGroup实现并发任务的同步,waitGroup的用法: image.png 可以把waitGroup理解成一个计数器:

  • 开启一个协程就+1
  • 结束一个协程就-1
  • 直到计数器为0之前,主线程都会阻塞等待
func ManyGoWait() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(i int) {
			println(i)
			wg.Done()
		}(i)
	}
	wg.Wait()
}

这样,我们就优雅且安全地实现了主线程阻塞。