Go并发编程系列01:你好,并发

开始并发的学习

最近对Go语言的并发编程有一些新认识和学习,所以想记录下开了这个系列文章

Go语言并发哲学

Do not communicate by sharing memory, instead, share memory by communicating!

不要通过共享内存来通信, 而是通过通信来共享内存!

Go语言最早从UNIX系统的B语言和C语言演化而来,其中的并发特性是从Newsqueak、Alef和Limbo等语言演化而来。

其中Newsqueak是Go之父Rob Pike于989年设计的语言,Alef则是Phil Winterbottom于1993年对C语言扩展了并发特性的语言,Limbo也是Rob Pike参与设计的支持并发的语言。

由此可见,Rob Pike在并发编程语言领域已经积累了几十年的设计经验,Go语言正是站在这些前辈的基础上涅槃重生的。


Go的并发简单又难掌握

func main() {
    go println("你好, 并发!")
}

没错 函数前加入 go 关键字即可,当然上面的代码你运行是看不到任何东西的。

上面的程序需要有中彩票特大奖的运气才能有机会执行成功。大部分普通用户将无法看到输出信息!

对于上面的代码有一种很不负责的方法教你调用time.sleep 或 runtime.Gosched 假装解决这个问题:

func main() {
    go println("你好, 并发!")
    time.Sleep(time.Second) // or runtime.Gosched() 让出CPU时间
}

没有人能够回答为何刚好需要休眠1秒钟就看似能工作了,其实这只是他们尝试后得到的一个经验值。

这种方法可以举个反例就可以推翻他的“不稳定性”:

func main() {
    go println("你好, 并发!")
    time.Sleep(time.Second)
}

func println(s string) {
    time.Sleep(time.Second*2)
    print(s+"\n")
}

上面的反例中,我们在不改变println函数输出的前提下重写了实现了改函数,在输出之前先休眠了2秒钟(一定要大于前面的1秒钟)。另一种反面的证明是假设,输出的字符串足够大,输出的设备足够慢,println很可能需要1万年才能完成工作。因为main函数作为println的使用者,不能也无法要求println函数在几个时钟周期内完成任务(毕竟Go语言无法做到实时编程),因此当println函数执行的时间稍微出现波动时就将影响上述代码的正确性!

Go语言并发编程的学习一般要经过2个阶段:

  • 第一阶段是这个并发程序终于可以产生正确的输出了;
  • 第二个阶段是这个并发程序不会产生错误的输出!

并发问题的解决方案

前面代码运行有一定的随机性,无法保证并发程序的正确运行。导致可能产生错误结果的原因有2个:

  • #### 第一个是go启动Goroutine时无法保证新线程马上运行(这里留个坑我后面会填坑的);
  • #### 第二个是main函数代表的主Goroutine退出将直接退出进程(语言本身的特性)。

阻止 main 函数退出的方式有很多,就要思路就是让主线程阻塞:

Golang中阻塞的情况包括:

  1. sync.WaitGroup

一直等待直到WaitGroup等于0。

package main

import "sync"

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    
    go func() {
        // 内部没有调用 wg.done() 
    }()
    wg.Wait()
}
  1. 空select

如果一个select{}是一个没有任何case事件的的select,它会一直阻塞下去,只是在程序退出前会报错 死锁问题。

func main() {
    runtime.GOMAXPROCS(1)
    go println("你好, 并发!")
    select {}
}
  1. 死循环for{}

这样的方式虽然可以阻塞,但会100%占用一个cpu。不建议使用。

func main() {
    runtime.GOMAXPROCS(1)
    go println("你好, 并发!")
    for {}
}
  1. sync.Mutex 锁

用一个已经锁了的锁,再锁一次就会一直阻塞,这个也不建议使用,但是需要了解,避免这样的坑。

func main() {
    var m sync.Mutex
    m.Lock()
    go println("你好, 并发!")
    m.Lock()
}
  1. os.Signal

os.Signal系统信号量,在golang中是个channel,它在收到特定的消息之前一直阻塞。

func main() {
    sig := make(chan os.Signal, 2)
    signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
    go println("你好, 并发!")
    <-sig
}
  1. 标准输入os.Stdin

会一直等待控制台输入之前会阻塞。

func main() {
    go println("你好, 并发!")
    fmt.Scanln()
}
  1. 空channel或者nil channel
func main() {
    c := make(chan struct{})
    go println("你好, 并发!")
    <-c
}

package main

func main() {
    var c chan struct{} //nil channel
    go println("你好, 并发!")
    <-c
}

理解的解决方案是:main 函数在 println 完成输出任务前不退出,但是在 println 完成任务后可以正确退出。

改进代码如下:

func main() {
    done := make(chan bool)
    go func() {
        println("你好, 并发!")
        done <- true
    }()

    <-done
}

main函数在退出前需要从done管道取一个消息,后台任务在将消息放入done管道前必须先完成自己的输出任务。

因此,main函数成功取到消息时,后台的输出任务确定已经完成了,main函数也就可以放心退出了。