Go语言进阶-工程实践 | 青训营笔记

111 阅读5分钟

这是我参与【第五届青训营】伴学笔记创作活动的第2天

Go工程实践

Goroutine(轻量级线程)

并发和并行

Go语言是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言。

并发(concurrency):多个任务在同一段时间内运行。

并行(parallellism):多个任务在同一时刻运行。

Go实现了两种并发形式。

  • 多线程共享内存:Java或者C++等语言中的多线程开发。
  • CSP(communicating sequential processes)并发模型:Go语言特有且推荐使用的。

CSP讲究的是“以通信的方式来共享内存”。go也保存着通过共享内存实现通信的机制(通过互斥量对内存进行加锁),但是提倡通过通信共享内存

  • 普通的线程并发模型,就是像Java、C++、或者Python,他们线程间通信都是通过共享内存的方式来进行的。在访问共享数据(例如数组、Map、或者某个结构体或对象)的时候,通过锁来访问,因此,衍生出一种方便操作的数据结构,叫做“线程安全的数据结构”。

  • Go的CSP并发模型,是通过goroutine和channel来实现的。

    • goroutine 是Go语言中并发的执行单位。可以理解为用户空间的线程。
    • channel是Go语言中不同goroutine之间的通信机制,即各个goroutine之间通信的”管道“,有点类似于Linux中的管道。

goroutine介绍

  • 协程:用户态,轻量级线程,栈KB级别
  • 线程:内核态,线程跑多个协程,栈MB级别

Go 程序中使用 go 关键字为一个函数创建一个 goroutine。一个函数可以被创建多个 goroutine,一个 goroutine 必定对应一个函数。所有 goroutine 在 main() 函数结束时会一同结束。

goroutine的优点:

  • 创建与销毁的开销小

    • 线程创建时需要向操作系统申请资源,并且在销毁时将资源归还,因此它的创建和销毁的开销比较大。相比之下,goroutine的创建和销毁是由go语言在运行时自己管理的,因此开销更低。所以一个Golang的程序中可以支持10w级别的Goroutine。每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少(goroutine: 2KB ,线程:8MB)
  • 切换开销小

    • 这是goroutine于线程的主要区别,也是golang能够实现高并发的主要原因。
    • 线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存/恢复所有的寄存器信息。
    • goroutine的调度是协同式的,没有时间片的概念,由Golang完成,它不会直接地与操作系统内核打交道。当goroutine进行切换的时候,之后很少量的寄存器需要保存和恢复(PC和SP)。因此gouroutine的切换效率更高。

goroutine 虽然类似于线程概念,但是从调度性能上没有线程细致,而细致程度取决于 Go 程序的 goroutine 调度器的实现和运行环境。

使用普通函数创建goroutine

go 函数名( 参数列表 )
package concurrence
​
import (
   "fmt"
   "time"
)
​
func hello(i int) {
   println("hello goroutine : " + fmt.Sprint(i))
}
​
func HelloGoRoutine() {
   for i := 0; i < 5; i++ {
      go func(j int) {    // 并发执行程序
         hello(j)
      }(i)
   }
   time.Sleep(time.Second)
}

使用匿名函数创建goroutine

go func( 参数列表 ){
    函数体
}( 调用参数列表 )
  • 参数列表:函数体内的参数变量列表。
  • 函数体:匿名函数的代码。
  • 调用参数列表:启动 goroutine 时,需要向匿名函数传递的调用参数。
package concurrence
​
import (
   "fmt"
   "time"
)
​
func hello(i int) {
   println("hello goroutine : " + fmt.Sprint(i))
}
​
func HelloGoRoutine() {
   for i := 0; i < 5; i++ {
      go func(j int) {
         hello(j)
      }(i)
   }
   time.Sleep(time.Second)
}

如果需要在 goroutine 中返回数据,使用后面介绍的通道(channel)特性,通过通道把数据从 goroutine 中作为返回值传出。

channel

介绍

  • channel本身是一个队列,先进先出
  • 线程安全,不需要加锁
  • 本身是有类型的,string, int 等,如果要存多种类型,则定义成 interface类型
  • channel是引用类型,必须make之后才能使用,一旦 make,它的容量就确定了,不会动态增加!!它和map,slice不一样

定义及使用

make(chan 元素类型, [缓冲大小])

  • 无缓冲通道 make(chan int),只有通道中的元素被消费了,才能继续推送,不然就会阻塞
  • 有缓冲通道 make(chan int, 2)

如果没有指定方向,那么Channel就是双向的,既可以接收数据,也可以发送数据。

它的操作符是箭头 <- 。

ch <- v // 发送值v到Channel ch中

通道的数据接收

  • 阻塞接收数据

    阻塞模式接收数据时,将接收变量作为<-操作符的左值,格式如下:

//执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量。
data := <-ch    // 从Channel ch中接收数据,并将数据赋值给data
  • 非阻塞接收数据

    使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:

data, ok := <-ch  //有问题时,还是会报错deadlock
  • 接收任意数据,忽略接收的数据

    阻塞接收数据后,忽略从通道返回的数据,比如我们希望获得到管道中的第二个元素,则先将第一个元素推出,格式如下:

<-ch

执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。

  • 循环接收

    通道的数据接收可以借用 for range 语句进行多个元素的接收操作

for data := range ch {
}

通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个,即上面例子中的 data。在遍历管道之前要先关闭管道,不然会出现deadlock的错误

package concurrence
​
func CalSquare() {
    src := make(chan int)
    dest := make(chan int, 3)
    // 生产者
    go func() {
        defer close(src)  //在遍历管道之前要先关闭管道
        for i := 0; i < 10; i++ {
            src <- i   //向管道src写入数据
        }
    }()
    // 消费者 -> 生产者
    go func() {
        defer close(dest)
        for i := range src {
            dest <- i * i
        }
    }()
    for i := range dest {
        //复杂操作
        println(i)
    }
}

关闭通道

close(chan)

Lock

当多个 goroutine 同时操作一个变量时,会存在数据竞争,导致最后的结果与期待的不符,解决办法就是加锁。

Go 中的 sync 包 实现了两种锁:Mutex 和 RWMutex,前者为互斥锁,后者为读写锁,基于 Mutex 实现。当我们的场景是写操作为主时,可以使用 Mutex 来加锁、解锁。

package main
​
import (
    "sync"
    "time"
)
​
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 main() {
    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)
}

对遍历执行2000次+1操作,5个协程并发执行加锁与不加锁的执行结果

image-20230116152322905.png

WaitGroup

WatiGroup 能够一直等到所有的 goroutine 执行完成,并且阻塞主线程的执行,直到所有的 goroutine 执行完成。它有 3 个方法:

  • Add():给计数器添加等待 goroutine 的数量。
  • Done():计数器-1
  • Wait():阻塞直到计数器为0
package concurrence
​
import (
   "sync"
   "time"
)
​
func hello(i int) {
    println("hello goroutine : " + fmt.Sprint(i))
}
​
func ManyGoWait() {
   var wg sync.WaitGroup
   wg.Add(5)
   for i := 0; i < 5; i++ {
      go func(j int) {
         defer wg.Done()
         hello(j)
      }(i)
   }
   wg.Wait()
}

总结:

Go语言中goroutine channel是区别与其他语言的一大特点,正是因为对goroutine和channel的使用,可以避免我们用互斥锁来让它们相互协作,使用起来更加便捷。