青训营笔记技术学习总结 关于Go并发的一些思考 | 青训营

54 阅读2分钟

我之前学习的是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,开启协程后这两个函数就变得平等了,会竞争运行。虽然是简单的样例,但是已经引出了两个问题:

  1. 如果去掉main函数里的休眠函数,那么程序只能输出main end(或者只输出很少的字符串),这是因为主线程结束,所有协程也就跟着结束了,所以无法正常输出。
  2. 如果去掉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主要用来解决协程间同步的问题,回忆最开始的例子,有一个重要的问题就是开启协程解决问题的时候,如果主协程结束,那么子协程也会被迫中止从而无法完成所需任务。那么如何解决这个问题?

  1. 使用休眠。就是在main函数强制写一个休眠几秒,然而这种方式想想就觉得不靠谱,因为实际开发你显然难以估计子协程运行时间,你这个休眠时间难以把握。
  2. 用channel,利用channel强制阻塞。然而这样显得大材小用,且创建channel的内存开销不可小视。
  3. 也就是用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包包含了一些管理协程地函数

  1. 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,就会被该函数结束协程。

  1. 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,这样保证两个协程输出交替运行。

  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)  
}
  1. 遍历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的元素就要把他关闭避免后续造成死锁。