我之前学习的是JAVA,在我开始学习Go之前,我就曾听说Go的并发,据说这是Go最大的一个优势。但是我之前学习JAVA的时候并没有着重学习并发,所以对这些东西并不是很了解,看字节内部课的时候很多东西感觉了解不深,特此写一篇总结。
Go的并发概念
我们都知道Go要开启并发很容易,即使用go关键字。但是这个关键词本质上是做什么呢?Goland的并发,本质上是函数之间相互独立运行的能力。 所以用Go关键字,本质上为我们开启了一个协程。
协程
协程又是什么呢?比如电脑上开了一个vscode,这可以看作一个线程。而其中又包括若干协程。所以我们可以比较简单地认为协程是比线程更轻量级的资源单位。
go task()
最简单样例
func task(s string) {
for i := 0; i < 5; i++ {
fmt.Println(s)
time.Sleep(time.Millisecond * 100)
}
}
func main() {
go task("goland")
go task("JAVA")
time.Sleep(time.Second * 2)
fmt.Println("main end ...")
}
这个程序会交替输出goland和JAVA两种字符串,这和我们平常的程序不同。正常程序顺序执行,一定是输出完goland才会输出JAVA,开启协程后这两个函数就变得平等了,会竞争运行。虽然是简单的样例,但是已经引出了两个问题:
- 如果去掉main函数里的休眠函数,那么程序只能输出main end(或者只输出很少的字符串),这是因为主线程结束,所有协程也就跟着结束了,所以无法正常输出。
- 如果去掉task里输出一个字符串后的休眠函数,那么很大概率会缺失一些字符串。这是因为如果没有间隔的话,输出JAVA和输出goland竞争太过密集,会使得程序出现一些错误。
通道channel
协程之间设计为无法直接通信。不同的goroutine需要使用通道通信。所以它的名字实际上非常的形象。通道的类型可以是各种内置类型或是自定义数据类型。可以创建两种通道,一种是有缓存的,用于异步通信,一种是无缓存的用于同步通信。 这里有一个点需要注意,Go的chan设计为同一时刻只能由一个goroutine访问,这样的设计避免了数据冲突。并且在传送阶段是自己加锁的,所以不需要再自己写休眠。
var values = make(chan int)
func send() {
rand.Seed(time.Now().UnixNano())
value := rand.Intn(10)
fmt.Printf("send : %v\n", value)
values <- value
}
func main() {
defer close(values)
go send()
fmt.Println("wait send .. ")
value := <-values
fmt.Printf("receive : %v\n", value)
fmt.Println("end ... ")
}
WaitGroup
waitgroup主要用来解决协程间同步的问题,回忆最开始的例子,有一个重要的问题就是开启协程解决问题的时候,如果主协程结束,那么子协程也会被迫中止从而无法完成所需任务。那么如何解决这个问题?
- 使用休眠。就是在main函数强制写一个休眠几秒,然而这种方式想想就觉得不靠谱,因为实际开发你显然难以估计子协程运行时间,你这个休眠时间难以把握。
- 用channel,利用channel强制阻塞。然而这样显得大材小用,且创建channel的内存开销不可小视。
- 也就是用waitGroup。它可以很简单地做到协程间同步地问题。 waitGroup可以看作一个计数器,有三个方法,Add(), Done(), wait() Add(x)也就是把计数器加x,Done本质就是Add(-1),wait即阻塞线程,直到计数器为0才往下放行。
var wg sync.WaitGroup
func show(i int) {
defer wg.Done()
fmt.Println(i)
}
func main() {
for i := 0; i < 10; i++ {
wg.Add(1)
go show(i)
}
wg.Wait()
fmt.Println("end ...")
}
Runtime包
Runtime包包含了一些管理协程地函数
- runtime.Gosched 让出Cpu时间片,重新安排时间
func show(msg string) {
for i := 0; i < 2; i++ {
fmt.Println(msg)
}
}
func main() {
go show("java")
for i := 0; i < 2; i++ {
runtime.Gosched()
fmt.Println("goland")
}
}
每次主协程执行任务都会让出时间片转到子协程,所以保证先输出完java再输出go。 2. runtime.Goexit() 退出协程。
func show() {
for i := 0; i < 10; i++ {
fmt.Println(i)
if i >= 5 {
runtime.Goexit()
}
}
}
func main() {
go show()
time.Sleep(time.Second)
}
如果不加Goexit,那么就会输出0到9,加入后输出到5,就会被该函数结束协程。
- GOMAXPROCS设置运行最大核心数,默认为最大CPU数量。
func a() {
for i := 0; i < 10; i++ {
fmt.Printf("a : %v\n", i)
time.Sleep(time.Millisecond * 100)
}
}
func b() {
for i := 0; i < 10; i++ {
fmt.Printf("b : %v\n", i)
time.Sleep(time.Millisecond * 100)
}
}
func main() {
fmt.Printf("Cpu: %v\n", runtime.NumCPU())
runtime.GOMAXPROCS(1)
go a()
go b()
time.Sleep(time.Second)
}
runtime.NumCPU可以查看CPU数量。本例设置最大运行核心为1,这样保证两个协程输出交替运行。
- mutex互斥锁。不同的协程可能会对同一个内存单元操作,这可能会出现错误。 例如一个协程对某个数字+1,另一个协程对相同数字-1,那么可能最后运算结果就会出现问题(不过这个例子很多机器复现不了,现在机器运算速度太快了) 这时候可以在协程开始地时候加锁,结束再取消锁,这样就保证某个协程访问数据单元的时候不会被另一个协程破坏.
var i = 100
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
defer wg.Done()
lock.Lock()
i++
lock.Unlock()
}
func sub() {
defer wg.Done()
lock.Lock()
i--
lock.Unlock()
}
func main() {
for i := 0; i < 100; i++ {
wg.Add(1)
add()
wg.Add(1)
sub()
}
wg.Wait()
fmt.Printf("i : %v\n", i)
}
- 遍历channel 两种方式,普通for和范围for
var c = make(chan int)
func main() {
go func() {
for i := 0; i < 2; i++ {
c <- i
}
close(c)
}()
//for i := 0; i < 10; i++ {
// v, ok := <-c
// if ok {
// fmt.Printf("i : %v\n", v)
// } else {
// break
// }
//}
for v := range c {
fmt.Printf("i : %v\n", v)
}
}
注意,一个管道ch如果已经被取光了元素,那么再请求取元素就会造成死锁报错。但是如果该管道已经被关闭的状态下再去申请,那么就不会报错,会返回默认值。所以好的做法是每次填完ch的元素就要把他关闭避免后续造成死锁。