稀土掘金课程:后端入门 - Go 语言原理与实践,第3节课:Go 语言进阶与依赖管理
1 语言进阶
Golang为什么快?
- 区分并发与并行
- Go可以充分发挥多核优势,高效运行。Golang为并发而生。
1.1 Goroutine
线程:内核态,一个线程能并发的跑多个协程,栈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建议协程间通信而共享内存,而不是反过来。
通道Channel:
如图1.2左,通道连接着不同goroutine,如同一个队列(先入先出)。
图1.2右是通过共享内存而实现通信,这种情况下必须对临界区加锁,保证互斥访问,可能会发生data race,影响性能。
1.3 Channel
channel是一种引用类型,需要通过make创建。
make(chan 元素类型,[缓冲大小])
// 无缓冲通道(同步通道)
make(chan int)
// 有缓冲通道
make(chan int,2)
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通道实现协程A和B之间的通信,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的用法:
可以把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()
}
这样,我们就优雅且安全地实现了主线程阻塞。