Go语言快速上手 (6)- Goroutine|青训营

104 阅读8分钟

Goroutine

利用并行线程的操作,进程/线程的数量越多,切换成本越大,也就越浪费

一个线程分为用户空间与内核空间,将其分为线程(thread)与协程(co-routine),两者绑定。协程中又设计成N:1的关系,即一个协程调度器来调度N个协程。但是空间分布比还是1:1,协程的创建、删除和切换的代价都由CPU完成,消耗还是有点大。

最后将内核空间与用户空间做成M:N关系,也就是说内核空间中,不再是单一线程与单一协程绑定,而是多线程与协程调度器绑定,再联系到多个线程

协程co-routine在go中就改成了Goroutine,内存只有几kb,可以灵活调度。

老Go中的调度器有缺陷:1.创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争;2.M转移G会造成延迟和额外的系统负载;3.系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

于是在新的Go中,利用了一个叫做GMP的东西,利用Process来更好的处理协调器以及应用线程。同时也有work stealing机制,从全局偷取,当M1的P在进行处理G1时,M2的P会利用这个机制从全局中偷G2放入本地序列中。所以Go是多进程

在抢占过程中,co-routine是老的协程主动释放CPU,而goroutine则是最多10ms,立刻会被新的goroutine抢占CPU

创建goroutine方法

package main
​
import (
  "fmt"
  "time"
)
​
//从goroutine
func newTask() {
  i := 0
  for {
    i++
    fmt.Printf("new Goroutine : i = %d\n", i)
    time.Sleep(1 * time.Second)
  }
}
​
//主goroutine    如果主goroutine要结束,从goroutine要帮忙结束
func main() {
  //创建一个go程,去执行newTask()流程
  go newTask()
​
  i := 0
  for {
    i++
    fmt.Printf("main Goroutine : i = %d\n", i)
    time.Sleep(1 * time.Second)
  }
}

可以看出这里的异步发生属性:main Goroutine : i = 14;new Goroutine : i = 14;new Goroutine : i = 15;main Goroutine : i = 15;main Goroutine : i = 16;new Goroutine : i = 16;new Goroutine : i = 17;main Goroutine : i = 17

如果主goroutine要结束,从goroutine要帮忙结束,所以我们将main()中的循环关掉变成:

func main() {
  go newTask()
  fmt.Println("main goroutine exit")
}

这样的话打完"main goroutine exit"这行字就会结束,因为主goroutine要结束了。

或者直接在main()中构建方法:将进程分为有参数版和无参数版

package main
​
import (
  "fmt"
  //"runtime" //退出进程使用
  "time"
)
​
func main() {
  //无参版:用go创建承载一个形参为空,返回值为空的一个函数
  go func() {   //一个func()就为一个进程(function)
    defer fmt.Println("A.defer")
​
    //return  //用return可以退出函数,但是只是退出当前函数,如果此函数为子函数则不能整个退出
    
    func() {
      defer fmt.Println("B.defer")
      //runtime.Goexit()  //可以退出所有父与子函数到下个函数
      fmt.Println("B")
    }()   //这里没加小括号的话,只是函数定义,需要加小括号才能直接调用
​
    fmt.Println("A")
  }()
​
  //有参版:
  go func(a int, b int) bool {
    fmt.Println("a =",a ,"b =", b)
    return true
  }(10, 20)
  //为了让main不结束,需要加入一个死循环
  for {
    time.Sleep(1 * time.Second)
  }
}

使用go func()来开启进程,也就是go程。在go中进程是异步进行的,在进程中可以使用return来退出当前进程,利用runtime.Goexit()则是退出所有进程。

Channal

但是在go程中,我们会发现goroutine的问题,就是协程的返回值不能通过return返回给主协程。于是乎就需要channel来进行go程中间的通信。

channal相当于是在两个goroutine中间作为通信的中介

通过通信来共享内存

channal的定义:

make(chan Type)   //等价于make(chan Type, 0)
make(chan Type, capacity)

channal的使用:

channal <- value    //发送value到channel
<-channel           //接收并将其丢弃
x := <-channel      //从channel中接收数据,并赋值给x
x, ok := <-channel  //功能同上,同时检查通道是否已关闭或者是否为空

channal的实际应用:

package main
​
import "fmt"func main() {
  //定义一个channel
  c := make(chan int)
​
  go func() {
    defer fmt.Println("goroutine结束")
​
    fmt.Println("goroutine 正在运行...")
​
    c <- 666  //将666 发送给c   是带阻塞的,必须要等到c把数据传过去
  }()
​
  num := <-c  //从c中接收数据,并赋值给num 是带阻塞的,必须要等到c把数据传过来
​
  fmt.Println("num =", num)
  fmt.Println("main goroutine 结束...")
}

这里要注意:可以看到有父进程与子进程,两者是异步的,但是channel是有阻塞作用的。对于上面的程序也就是说,如果当子进程先一步到达c <- 666,那就会自动发送阻塞,需要等待父进程中num := <-c处理好才能真正结束,所以直到num取到了c的数据,defer才会打印"goroutine结束"。父进程先也是同理,如果先进行到了num,那也得先发生阻塞,必须等待c把数取到才能进行num取c的值。所以子进程中的defer fmt.Println("goroutine结束")永远会在num := <-c这行结束后运行。

有无缓冲channel的区别

1.无缓冲channel:必须要完成接棒这一步,一步都不能少,所以必须发送阻塞等待取值

程序与上面的例子一样

2.有缓冲channel:如果缓存空间内有物品,可以先取,不需要发送阻塞,但是如果取完了值也得发生阻塞等待放值。

package main
​
import (
  "fmt"
  "time"
)
​
func main() {
  c := make(chan int, 3)  //带有缓冲的channel
​
  fmt.Println("len(c) =", len(c), ",cap(c) =", cap(c))
​
  go func() {
    defer fmt.Println("子go程结束")
​
    for i := 0; i < 4; i++ {
      c <- i
      fmt.Println("子go程正在运行,发送的元素为:", i,", len(c)=", len(c), ",cap(c)=", cap(c))
    }
  }()
​
  time.Sleep(2 * time.Second)
  
  for i := 0; i < 4; i++ {
    num := <-c  //从c中接收数据,并赋值给num
    fmt.Println("num =", num)
  }
  fmt.Println("main 结束")
}

结果:

len(c) = 0 ,cap(c) = 3
子go程正在运行,发送的元素为: 0 , len(c)= 1 ,cap(c)= 3
子go程正在运行,发送的元素为: 1 , len(c)= 2 ,cap(c)= 3
子go程正在运行,发送的元素为: 2 , len(c)= 3 ,cap(c)= 3
num = 0
子go程正在运行,发送的元素为: 3 , len(c)= 3 ,cap(c)= 3
num = 1
num = 2
num = 3
main 结束

可以看出容量不够的时候会等待取出,取出完有空间了又继续放入,可以看出缓冲够的时候没有阻塞,但是缓冲不够的时候会进行阻塞。

关闭Channel

1.注意事项:

(1)channel不像文件一样需要经常去关闭,只有当你确实没有任何发送数据了,或者你想显示的结束range循环之类的,才去关闭channel;

(2)关闭channel后,无法向channel再发送数据(引发panic错误后导致接收立即返回零值);

(3)关闭channel后,可以继续从channel接收数据;

(4)对于nil channel,无论收发都会被阻塞。

package main
​
import (
  "fmt"
)
​
func main() {
  c := make(chan int)
​
  go func() {
    for i := 0; i < 5; i++ {
      c <- i
    }
​
    //close可以关闭一个channel
    close(c)
  }()
​
  for {
    //ok如果为true表示channel没有关闭,如果为false表示channel已经关闭
    if data, ok := <-c; ok {
      fmt.Println(data)
    }else{
      break
    }
  }
​
  fmt.Println("Main Finished..")
}

在程序中可以体现出第三点,如果没有close(c)则会出现死锁问题,因为前面传入数据已经结束了,不会再传了。而让下面可以停止的操作则是关闭channel,这样一样可以读取,同时也不会继续要求上面的c再继续往里存东西。

nil channel则是不用make来初始化channel,这样是不行的,会导致出现nil channel然后收发都会被阻塞。

2.利用range来迭代不断操作channel(不需要再来判断ok,直接range自动读取里面的个数,但是close channel还是要的)

将上面的父级for循环换成如下代码

  for data := range c {
    fmt.Println(data)
  }
​
  fmt.Println("Main Finished..")

3.利用select来判断多路channel的监控状态功能

package main
​
import "fmt"func fibonacii(c, quit chan int) {
  x, y := 1, 1for {
    select {
    case c <- x:
      //如果c可写,则该case就会进来
      x = y
      y = x + y
    case <-quit:
      fmt.Println("quit")
      return
    }
  }
}
​
func main() {
  c := make(chan int)
  quit := make(chan int)
  //sub go
  go func() {
    for i := 0; i < 6; i++ {
      fmt.Println(<-c)
    }
​
    quit <- 0
  }()
  // main go
  fibonacii(c, quit)
}
并发安全Lock

因为有通信来共享内存的机制,同时Go又有保存通过共享内存来实现通信的机制,所以这样就会存在多个Goroutine同时操作一块内存的情况,为保证数据的一致性,需要使用互斥锁进行并发控制。

同时在对文件、代码段、配置文件等数据进行访问时,需要进行排他访问,以保证数据的完整性。

var (
  x int64
  lock sync.Mutex
)
​
func addWithLock() {
  for i := 0; i < 2000; i++ {
    lock.Lock()
    x += 1
    lock.Unlock()
  }
}
​
func addwithoutLock() {
  for i := 0; i < 2000; i++ {
    x += 1
  }
}
​
func Add() {
  x = 0
  for i := 0; i < 5, i++ {
    go addWithoutLock()
  }
  time.Sleep(time.Second)
  println("WithoutLock",x)
  x = 0
  for i := 0; i < 5, i++ {
    go addWithLock()
  }
  time.Sleep(time.Second)
  println("WithLock",x)
}

最后输出结果为不带锁发送计算错误,带锁则是正确的值,所以为了anti并发问题,对临界区进行控制,要加入锁。