Golang有趣的并发

Posted 大后端开发

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang有趣的并发相关的知识,希望对你有一定的参考价值。

并发

什么是并发

说到并发,离不开的另一个词是并行。“并发”是指用一些彼此独立的执行模块构建程序;而“并行”则是指通过将计算任务在多个处理器上同时执行以提高效率。《七周七并发模型》是这样描述他们的:

Concurrency is about dealing with lots of things at once.
Parallelism is about doing lots of things at once.

大概是说,”并发”是同时处理(dealing)很多事情,而”并行”是同时做(doing)很多事情。读者可能还不是很理解,一图胜千言。

并发是两个队伍交替使用咖啡机,并行是两个队伍同时使用两台咖啡机。换成专业术语来解释就是,并发的实质是一个物理CPU(也可以多个物理CPU) 在若干道程序(或线程)之间多路复用(本质上同一时刻只执行一个程序或线程),而并行,是两个或两个以上事件(或线程)在同一时刻发生。

并发的好处

前面一小节我们大概知道了什么事并发,但是我们还不理解并发有哪些好处。举个例子来讲,比如说一个程序员在家里,要做以下事情:1.撸代码(大概要1个小时),2.烧开水(大概10分钟))。如果是顺序做这些事情的话,就是先撸一个小时代码,撸完之后再去烧开水,总共需要花费70分钟。而如果是并发的话,则是这样子的,先撸代码,撸了一段时间之后(小于1小时),去烧开水,然后再回来撸代码,等代码撸完了的时候,开水也烧好了,所以花费的总时间是小于70分钟的。总共消耗的时候变少了,这就是并发的好处。

goroutine

在golang中实现并发主要靠的是两个:goroutine和channel。本小节主要讲述goroutine。在Golang中,每一个并发的执行单元叫作一个goroutine。我们并不深究goroutine到底是什么,读者暂且简单的将goroutine理解为线程就可以了,至于这两者的区别在以后的博文中再详细讲。创建一个goroutine也很简单,
调用函数的时候在其前面加上go关键字就表示创建了一个goroutine。创建了一个goroutine之后,不用等待这个函数执行完毕就可以执行接下来的代码,等效于跳过了这个函数。当这个函数执行完毕时,这个goroutine也随之退出。

    f()    //主函数要等待f函数执行结束才能继续往下执行,哪怕f要花费很长时间
    go f() //主函数无需等待f函数执行完毕就可以往下执行

还是举前面撸代码和烧开水的例子,这里把顺序执行和并发执行的代码都放出来,读者可以做个对比。

顺序执行:

package main

import (
    "fmt"
    "time"
)

func code() {
    fmt.Println("I am coding")
    time.Sleep(60 * time.Minute)
    fmt.Println("coding done.")
}

func boil() {
    fmt.Println("I am boiling")
    time.Sleep(10 * time.Minute)
    fmt.Println("boiling done.")
}

func main() {
    now := time.Now().Unix()

    code()

    boil()

    fmt.Println(time.Now().Unix() - now)
}

因为是顺序执行的,所以烧开水要等到撸代码完成之后才能开始做,因此总时长要70分钟。

并发执行:

package main

import (
    "fmt"
    "time"
)

func code() {
    fmt.Println("I am coding")
    time.Sleep(60 * time.Minute)
    fmt.Println("coding done.")
}

func boil() {
    fmt.Println("I am boiling")
    time.Sleep(10 * time.Minute)
    fmt.Println("boiling done.")
}

func main() {
    now := time.Now().Unix()

    go code()

    go boil()

    time.Sleep(60 * time.Minute)    //等待子goroutine执行完毕

    fmt.Println(time.Now().Unix() - now)
}

我们在调用code函数和boil函数的时候分别在其前面加上了go关键字,表示新创建了两个goroutine来做撸代码和烧开水的工作,因此总共有三个goroutine(main函数也是一个goroutine,读者可以将其理解为主goroutine或main goroutine),注意在main函数中调用了一个time.Sleep函数,这是为了保证main goroutine不过早退出,如果main goroutine退出,那么所有的子goroutine也会被打断,不管子goroutine有没有执行完。这里总共只花了60分钟,相比顺序执行确实是变快了。

channel

在go语言世界中有一句名言:

勿以共享实现通信,应以通信实现共享

我们已经知道,用goroutine可以实现并发。那么在并发体之间,如何实现通信呢?这就需要channel了。在一个goroutine中可以发送一个channel到另一个goroutine。channel是引用类型,意味着将channel作为函数参数传递时(只是拷贝了一个引用),调用者和被调用者将引用同一个channel对象,在任何一方将channel改变也会影响到另一方。channel是类型相关的,一个channel只能传递一种类型的值,具体传递哪种类型是在初始化时指定的。我们一般通过make进行channel的初始化。

ci := make(chan int)

ci这个channel只能传递int类型的值。那么哪些情况下goroutine之间需要通信呢?最常见的情况是两个goroutine之间需要同步,也就是说一个goroutine执行完之后才能开始执行另外一个goroutine。或者一个goroutine需要依赖另外一个goroutine的结果。知道了这个之后,我们就可以使用channel来修改刚才烧开水和撸代码的那一段代码,我们终于可以不用在main函数中执行time.Sleep来等待子进程执行结束了。

package main

import (
    "fmt"
    "time"
)

func code(ci chan int) {
    fmt.Println("I am coding")
    time.Sleep(60 * time.Minute)
    fmt.Println("coding done.")
    ci <- 1
}

func boil() {
    fmt.Println("I am boiling")
    time.Sleep(10 * time.Minute)
    fmt.Println("boiling done.")
}

func main() {
    now := time.Now().Unix()
    ci := make(chan int)

    go code(ci)

    go boil()

    <- ci

    fmt.Println(time.Now().Unix() - now)
}

前面说过,一个goroutine可以发送一个channel到另一个goroutine,所以对于channel来说会对应发送操作和接收操作。发送和接收都使用<-运算符。

ch <- x //发送一个值到channel
x = <-ch //从channel里面接收值,并赋值给x变量
<-ch    //从channel里面接收值,但抛弃掉接收的值

在本例中,main函数中的<- ci表示从channel里面接收值,如果接收不到的话会阻塞。那么什么时候可以接收到呢?当code这个goroutine执行完毕之后,会将1赋值给ci这个channel,这个时候main函数就可以从ci这个channel里面接收到值了,才可以继续往下运行。

无缓冲channel

前文说到创建一个channel对象的方法

ch = make(chan int)

make其实还有第二个参数,该参数是一个可选的整形参数,默认值是0.如果不提供第二个参数或者提供0,那么创建的就是一个无缓存的channel;如果第二个参数提供一个大于0的整数,那么创建的就是一个有缓存的channel。

ch = make(chan int) //无缓存channel
ch = make(chan int, 0) //无缓存channel
ch = make(chan int, 3) //有缓存channel

这里引用go语言圣经里面的一句话:

一个基于无缓存Channels的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的Channels上执行接收操作,当发送的值通过Channels成功传输之后,两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作。

我们可以利用无缓存channel的阻塞特性来保证两个goroutine之间的同步,即另一个goroutine要等到前一个goroutine执行完毕才继续执行。还是看前面那段撸代码和烧开水的代码,在main函数中有一行<-ci,表示从ci这个channel里面取值,如果取不到值的话是会阻塞在这里的,那么什么时候能够取到值呢?当code函数执行完的时候(也即code这个goroutine执行完毕的时候),我们使用ci<-1将1写入到了ci这个channel。换句话说就是main goroutine一直在等待code goroutine执行完毕,这就是同步。

有缓存channel

带缓存的Channel内部持有一个元素队列,队列的容量就是初始化时指定的第二个参数。队列我们大学都学过,从队尾插入元素,从队头取出元素。用内置函数cap可以取出带缓存channel的容量,用len可以去除带缓存channel的元素个数。

ch = make(chan int, 3) //初始化一个带缓存的channel对象
cap(ch)                    //打印3
len(ch)                    //打印0,因为我们未发送任何数据到ch

这里要注意的是,如果队列为满的情况下(即len(ch)等于cap(ch)),这时候向队列尾部插入元素将导致发送操作的阻塞,直到另一个goroutine执行了接收操作(释放了队列空间);如果队列为空的情况下(即len(ch)等于0),则这时候另一个goroutine执行接收操作将阻塞,直到另一个goroutine执行一个发送操作;如果队列不空也不满(即len(ch)大于0但是小于cap(ch)),这时候执行发送操作或接收操作都不会引起阻塞。

package main  

import (  
    "fmt"
    "time"
)

func main() {
    chs := make(chan int, 3)

    go func(chs chan int) {
        fmt.Println("I am cooking...")
        time.Sleep(5 * time.Second)
        fmt.Println("cook done...")
        chs <- 1
    }(chs)

    go func(chs chan int) {
        fmt.Println("I am singing...")
        time.Sleep(10 * time.Second)
        fmt.Println("sing done...")
        chs <- 1
    }(chs)

    go func(chs chan int) {
        fmt.Println("I am coding...")
        time.Sleep(15 * time.Second)
        fmt.Println("code done...")
        chs <- 1
    }(chs)

    fmt.Println("main goroutine is waiting for the first thing...")

    <- chs

    fmt.Println("first thing is done.")
    fmt.Println("main goroutine is waiting for the second thing...")

    <- chs

    fmt.Println("second thing is done.")
    fmt.Println("main goroutine is waiting for the third thing...")

    <- chs

    fmt.Println("third thing is done.")
}

相信大家明白了上述原理之后,对这段代码应该也能理解。这里贴出执行结果,读者可以对照一下。

以上是关于Golang有趣的并发的主要内容,如果未能解决你的问题,请参考以下文章

Golang有趣的并发

如何在golang中顺序处理并发请求? [复制]

goroutine简介

有趣的 C++ 代码片段,有啥解释吗? [复制]

python [代码片段]一些有趣的代码#sort

php 有趣的代码片段在某些时候可能会有用。