Golang 入门系列(十五)如何理解go的并发?

  • 2019 年 10 月 3 日
  • 筆記

前面已经讲过很多Golang系列知识,感兴趣的可以看看以前的文章,https://www.cnblogs.com/zhangweizhong/category/1275863.html

接下来要说的是golang的并发,其实之前简单介绍过协程(goroutine)和管道(channel) 等基础内容,只是比较简单,只讲了基本的语法。今天就详细说说golang的并发编程。

 

一、并发和并行

Go是并发语言,而不是并行语言。所以我们在讨论,我们首先必须了解什么是并发,以及它与并行性有什么不同。

 

什么是并发

并发就是一段时间内处理许多事情。

比如,一个人在晨跑。在晨跑时,他的鞋带松了。现在这个人停止跑步,系鞋带,然后又开始跑步。这是一个典型的并发。这个人能够同时处理跑步和系鞋带,这是一个人能够同时处理很多事情。

 

什么是并行

并行就是同一时刻做很多事情。这听起来可能与并发类似,但实际上是不同的。

再比如,这个人正在慢跑,并且使用他的手机听音乐。在这种情况下,一个人一边慢跑一边听音乐,那就是他同时在做很多事情。这就是所谓的并行。

 

并发不是并行。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如GPU中对图像处理都会有大量的并行运算。为更好的编写并发程序,从设计之初Go语言就注重如何在编程语言层级上设计一个简洁安全高效的抽象模型,让程序员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些繁琐的操作分散精力。
 

上图能清楚的说明了并发和并行的区别。
 

二、协程(Goroutines)

go中使用Goroutines来实现并发。Goroutines是与其他函数或方法同时运行的函数或方法。Goroutines可以被认为是轻量级的线程。与线程相比,创建Goroutine的成本很小。因此,Go应用程序可以并发运行数千个Goroutines。

Goroutines在线程上的优势。

  1. 与线程相比,Goroutines非常便宜。它们只是堆栈大小的几个kb,堆栈可以根据应用程序的需要增长和收缩,而在线程的情况下,堆栈大小必须指定并且是固定的

  2. Goroutines被多路复用到较少的OS线程。在一个程序中可能只有一个线程与数千个Goroutines。如果线程中的任何Goroutine都表示等待用户输入,则会创建另一个OS线程,剩下的Goroutines被转移到新的OS线程。所有这些都由运行时进行处理,我们作为程序员从这些复杂的细节中抽象出来,并得到了一个与并发工作相关的干净的API。

  3. 当使用Goroutines访问共享内存时,通过设计的通道可以防止竞态条件发生。通道可以被认为是Goroutines通信的管道。

 

如何使用Goroutines

在函数或方法调用前面加上关键字go,您将会同时运行一个新的Goroutine。

实例代码:

package main    import (      "fmt"      "time"  )    func hello() {      fmt.Println("Hello world goroutine")  }  func main() {      go hello()      time.Sleep(1 * time.Second)      fmt.Println("main function")  }
运行结果: Hello world goroutine main function

 

如何启动多个Goroutines

示例代码:

package main    import (      "fmt"      "time"  )    func numbers() {      for i := 1; i <= 5; i++ {          time.Sleep(250 * time.Millisecond)          fmt.Printf("%d ", i)      }  }  func alphabets() {      for i := 'a'; i <= 'e'; i++ {          time.Sleep(400 * time.Millisecond)          fmt.Printf("%c ", i)      }  }  func main() {      go numbers()      go alphabets()      time.Sleep(3000 * time.Millisecond)      fmt.Println("main terminated")  }  运行结果:    1 a 2 3 b 4 c 5 d e main terminated

 

Goroutine切换

下面通过素数计算的例子来说明goland是如何通过切换不同的goroutine实现并发的。

package main

import (
"fmt"
"runtime"
"sync"
)

var wg sync.WaitGroup

func main() {

runtime.GOMAXPROCS(1)

wg.Add(2)
go printPrime("A")
go printPrime("B")

fmt.Println("Wait for finish")
wg.Wait()
fmt.Println("Program End")
}

func printPrime(prefix string) {
defer wg.Done()

  nextNum:
for i := 2; i < 6000; i++ {
for j := 2; j < i; j++ {
if i%j == 0 {
continue nextNum
}
}
fmt.Printf("%s:%dn", prefix, i)
}
fmt.Printf("complete %sn", prefix)
}

运行结果:
Wait for finish  B:2  B:3  B:5  B:7  B:11  ...  B:457  B:461  B:463  B:467  A:2  A:3  A:5  A:7  ...  A:5981  A:5987  complete A  B:5939  B:5953  B:5981  B:5987  complete B  Program End

通过以上的输出结果,可以看出两个Goroutine是在一个处理器上通过切换goroutine实现并发执行。

 

三、通道(channels)

通道可以被认为是Goroutines通信的管道。类似于管道中的水从一端到另一端的流动,数据可以从一端发送到另一端,通过通道接收。

 

声明通道

每个通道都有与其相关的类型。该类型是通道允许传输的数据类型。(通道的零值为nil。nil通道没有任何用处,因此通道必须使用类似于地图和切片的方法来定义。)

示例代码:

package main    import "fmt"    func main() {      var a chan int      if a == nil {          fmt.Println("channel a is nil, going to define it")          a = make(chan int)          fmt.Printf("Type of a is %T", a)      }  }  运行结果:    channel a is nil, going to define it  Type of a is chan int

也可以简短的声明:

a := make(chan int)

发送和接收

发送和接收的语法:

data := <- a   // read from channel a  a <- data      // write to channel a

在通道上箭头的方向指定数据是发送还是接收。

 

一个通道发送和接收数据,默认是阻塞的。当一个数据被发送到通道时,在发送语句中被阻塞,直到另一个Goroutine从该通道读取数据。类似地,当从通道读取数据时,读取被阻塞,直到一个Goroutine将数据写入该通道。

这些通道的特性是帮助Goroutines有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。

示例代码:

package main    import (      "fmt"      "time"  )    func hello(done chan bool) {      fmt.Println("hello go routine is going to sleep")      time.Sleep(4 * time.Second)      fmt.Println("hello go routine awake and going to write to done")      done <- true  }  func main() {      done := make(chan bool)      fmt.Println("Main going to call hello go goroutine")      go hello(done)      <-done      fmt.Println("Main received data")  }

运行结果:

 Main going to call hello go goroutine
 hello go routine is going to sleep
 hello go routine awake and going to write to done
 Main received data

 

定向通道

之前我们学习的通道都是双向通道,我们可以通过这些通道接收或者发送数据。我们也可以创建单向通道,这些通道只能发送或者接收数据。

创建仅能发送数据的通道,示例代码:

package main    import "fmt"    func sendData(sendch chan<- int) {      sendch <- 10  }    func main() {      sendch := make(chan<- int)      go sendData(sendch)      fmt.Println(<-sendch)  }

报错:

 # command-line-arguments
 .main.go:12:14: invalid operation: <-sendch (receive from send-only type chan<- int)

 

示例代码:

package main    import "fmt"    func sendData(sendch chan<- int) {      sendch <- 10  }    func main() {      chnl := make(chan int)      go sendData(chnl)      fmt.Println(<-chnl)  }

运行结果:  10

 

死锁

为什么会死锁?非缓冲信道上如果发生了流入无流出,或者流出无流入,也就导致了死锁。或者这样理解 Go启动的所有goroutine里的非缓冲信道一定要一个线里存数据,一个线里取数据,要成对才行 。

示例代码:

package main

func main() {
c, quit := make(chan int), make(chan int)

go func() {
c <- 1 // c通道的数据没有被其他goroutine读取走,堵塞当前goroutine
quit <- 0 // quit始终没有办法写入数据
}()

<-quit // quit 等待数据的写
}
  报错:  fatal error: all goroutines are asleep - deadlock!    goroutine 1 [chan send]:  main.main()      /tmp/sandbox249677995/main.go:11 +0x80

 

关闭通道

关闭通道只是关闭了向通道写入数据,但可以从通道读取。

package main    import (      "fmt"  )    var ch chan int = make(chan int, 3)    func main() {      ch <- 1      ch <- 2      ch <- 3        close(ch)        for v := range ch {          fmt.Println(v)      }  }

 

四、缓冲通道

之前学习的所有通道基本上都没有缓冲。发送和接收到一个未缓冲的通道是阻塞的。

可以用缓冲区创建一个通道。发送到一个缓冲通道只有在缓冲区满时才被阻塞。类似地,从缓冲通道接收的信息只有在缓冲区为空时才会被阻塞。

可以通过将额外的容量参数传递给make函数来创建缓冲通道,该函数指定缓冲区的大小。

语法:

ch := make(chan type, capacity)  

上述语法的容量应该大于0,以便通道具有缓冲区。默认情况下,无缓冲通道的容量为0,因此在之前创建通道时省略了容量参数。

示例代码:

func main() {      done := make(chan int, 1) // 带缓存的管道        go func(){          fmt.Println("你好, 世界")          done <- 1      }()        <-done  }

 

五、最后

以上,就把golang并发编程相关的内容介绍完了,希望能对大家有所帮助。