Go语言进阶学习笔记 | 青训营

131 阅读9分钟

在学习了前面的课程之后,了解过Golang的基本语法,必然要来聊聊他实现的原理。本篇文章主要围绕工程实践角度,讲授在企业项目实际开发过程中的所遇的难题,重点讲解 Go 语言的进阶之路。

  在学习了前面的课程之后,了解过Golang的基本语法,必然要来聊聊他实现的原理

  肯定很多人想问Go语言运行为何会如此之快?

  这是因为GO语言实现了并发性能极高的一个调度模型,通过高效的调度,可以最大限度的利用计算资源。充分发挥多核优势,高效运行。

  所以本篇文章将主要从并发编程的角度来了解 Go 语言高性能的本质,为何能运行的如此之快

  这里就必须提一嘴,并发​ 和 ​并行​ 的区别

  ​并发​是指多个线程在同一个CPU​上运行,主要是通过时间片的切换看起来像多个程序在同时运行,只不过CPU时间片的切换十分迅速,我们感知不到。

  ​并行​是指多个线程在多个CPU​上运行,多个线程在同一时间同时运行而不是时间片的切换。

  ‍

  并发和并行都是多任务处理的方式,并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。而并行则是同时做很多事情。

  可能这说的还不太形象,实际操作系统中都是多个进程同时运行,但二者“同时”的含义不同

  并发的"同时"是经过上下文快速切换,使得看上去多个进程同时都在运行的现象——多线程程序在一个核上运行

  并行的"同时"是同一时刻可以多个进程在运行(处于running)——多线程程序在多个核的CPU上运行

  ‍

  在 Go 语言中,它使用的是“协程(goroutine)模型”,和传统基于 OS 线程和进程实现不同,Go 语言的并发是基于用户态的并发,这种并发方式就变得非常轻量,能够轻松运行几万并发逻辑。Go 可以充分发挥多核优势,高效运行

Goroutine

  Go语言的协程——Goroutine,协程也叫轻量级线程

  Goroutine是Go语言特有的名词。区别于进程Process,线程Thread,协程Coroutine

  • 线程 VS 协程

    协程:用户态,轻量级线程,栈KB级别

    线程:内核态,线程跑多个协程,栈MB级别

    一个线程中可以同时执行多个协程, Go能支持高并发的原因也由此考虑

  Goroutine 是与其他函数或方法同时运行的函数或方法。Goroutines可以被认为是轻量级的线程。 与线程相比,创建Goroutine的成本很小,它就是一段代码,一个函数入口。以及在堆上为其分配 的一个堆栈(初始大小为4K,会随着程序的执行自动增长删除)。因此它非常廉价,Go应用程序 可以并发运行数千个Goroutines。

  Go语言中开启协程相对简单,在调用函数前 func()​ 加上 go​ 关键字即可,如以下代码

package main

import (
	"fmt"
	"time"
)

func screen(i int) {
	println("It is " + fmt.Sprint(i) + ".")
}

func main() {
	for i := 0; i < 10; i++ {
		go screen(i)
	}

	time.Sleep(time.Second)//保证子协程结束时主线程不退出,等待协程执行结束
}

  此程序代码的执行结果是

It is 9.
It is 7.
It is 2.
It is 8.
It is 1.
It is 4.
It is 5.
It is 3.

  我们可以发现,结果并不是按照顺序,我们会在接下来的 并发安全锁中介绍。

CSP

  CSP 是 Communicating Sequential Process 的简称,叫做通信顺序进程,是一个很强大的并发数据模型。

  这是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。相对于Actor模型,CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。

  严格来说,CSP 是一门形式语言(类似于 ℷ calculus),用于描述并发系统中的互动模式,也因此成为一众面向并发的编程语言的理论源头,并衍生出了 Occam/Limbo/Golang…

  在通信双方抽象出中间层,数据的流转由中间层来控制,通信双方只负责数据的发送和接收,从而实现了数据的共享,这就是所谓的通过通信来共享内存。 Channel 就是按这个模型来实现的。

  ​image-20230726220147-t0tosld.png

Go 语言提倡通过通信共享内存而不是通过共享内存而实现通信

  传统的线程方式(Java,C++和Python程序)共享内存时会涉及到多个线程同时访问修改数据的情况,使得共享数据结构受锁保护, 线程将争夺这些锁以访问数据,加锁会让并行变为串行,CPU也忙于线程抢锁,会影响程序的性能

  而 Goroutine 和 Channel 提供了一个优雅的 以及构建并发软件的独特方法

Channel

  通过通信共享内存,我们必须要提到另一个名字 Channel (通道)

  在Golang中,Channel 是一种类型,它可以用来在协程之间传递数据。

  通过操作符 <-​ 来指定通道的方向 如果没有指定方向,那么Channel就是双向的,既可以接收数据,也可以发送数据。

chan T          // 可以接收和发送类型为 T 的数据
chan<- float64  // 只可以用来发送 float64 类型的数据
<-chan int      // 只可以用来接收 int 类型的数据

  基本操作

  1. 发送操作 ​ch <- x
  2. 接收操作 ​x := <- ch
  3. 关闭操作 ​close(ch)

  Channel 分为俩种类型

  • 无缓冲通道

    make(chan int)

    ch := make(chan int) //无缓冲通道
    
  • 有缓冲通道

    make(chan int,100)

    使用make​初始化Channel,还可以设置容量:

    //make(chan 元素类型,[缓冲大小])
    ch := make(chan int, 2) //有缓冲通道
    

    容量 (capacity) 代表Channel容纳的最多的元素的数量,代表Channel的缓存的大小。
    如果没有设置容量,或者容量设置为0, 说明Channel没有缓存,即为只有sender和receiver都准备好了后它们的通讯(communication)才会发生(Blocking)。如果设置了缓存,就有可能不发生阻塞, 只有buffer满了后 send才会阻塞, 而只有缓存空了后receive才会阻塞。一个nil channel不会通信。

    如果缓冲区已经满了,发送操作会被阻塞,直到有其他协程从channel中取走了数据;如果缓冲区已经空了,接收操作会被阻塞,直到有其他协程向channel中发送了数据。缓冲区的大小可以在创建channel时指定。

我认为理解为快递包裹更有助于吃透这个概念,无缓冲通道内只能存放一个快件,只有当有人取走后无缓存通道才能再次存进快件。而有缓冲通道相当于菜鸟驿站,缓冲大小就是驿站能容纳的最大包裹数,只要不超过这个值,大家都可以存取快件。——生产消费模型

image-20230726221515-owp9rpk.png

默认情况下,发送和接收会一直阻塞着,直到另一方准备好。以下方式可以用来在gororutine中进行同步,而不必使用显示的锁或者条件变量。

import "fmt"
func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // send sum to c
}
func main() {
    s := []int{7, 2, 8, -9, 4, 0}
    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c // receive from c
    fmt.Println(x, y, x+y)
}

并发安全 & LOCK

  同JAVA一样,Go语言也要通过加锁的机制保证在多线程环境下的并发安全。

  Lock锁实现并发安全的方式可以保证多个线程之间的互斥访问,从而保证了数据的一致性和完整性

  加锁与不加锁的执行对比,不同电脑执行WithoutLock可能不同 如下代码若不加锁,可能输出的结果与预期不符

var x int64
var wg sync.WaitGroup
 
func add() {
    for i := 0; i < 5000; i++ {
        x = x + 1
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
}

  这时便需要引入锁,来保证并发安全

  • 锁有俩种

    1. 互斥锁:并发编程中对共享资源进行访问的控制手段,由标准库中的Mutex结构体类型,sync.Mutex​ 类型只有两个公开的指针方法,Lock 和Unlock 可以通过go build -race main.go 编译后运行, 查看共享资源的竞争互斥锁本质是当一个goroutine访问的时候,其他的 goroutine 都不能访问,程序由原来的并行执行变成了串行执行
    2. 读写锁:读写锁可以让多个读操作并发,同时读取,但是对于写操作是完全互斥的。当一个 goroutine 进行写操作的时候,其他的 goroutine 不能读也不能写

  通过引入互斥锁来保证执行程序后获得的结果与所要实现的输出(功能)一致,在Go中sync​包提供了锁机制。

  通过加速意图在于实现多个协程在同一时间只能由获取到锁的协程来运行,其他协程需等待锁的释放才能继续进行。

  ​Sync​包同步提供基本的同步原语,如互斥锁。 除了Once和WaitGroup类型之外,大多数类型都是供低级库例程使用的。 通过Channel和沟通可以更好地完成更高级别的同步。并且此包中的值在使用过后不要拷贝。Sync​包中主要有:Locker​, Cond​, Map​, Mutex​, Once​, Pool​ , RWMutex​, WaitGroup

WaitGroup

  ​WaitGroup​ 是Go语言标准库中的一个结构体,它提供了一种简单的机制,用于同步多个协程的执行。适用于需要并发执行多个任务并等待它们全部完成后才能继续执行后续操作的场景。

  用于同步协程 sync package - sync - Go Packages

  如同上文提到 Sleep​ 方法一样 WaitGroup的原理如下

Add (delta int) //计数器+delta
Done() //计数器 -1
Wait() //阻塞直到计数器为0

  不难看出, waitGroup​ 一共包含3个方法,分别是 Add​,Done​,Wait

  image-20230726225405-b2y691j.png

  计数器实现开启协程调用Add​方法+1计数,通过 Done​ 方法代表一个协程结束,使得执行结束计数器-1,主协程阻塞 Wait​ 等待直到计数器为0 主协程退出

采用Sleep方法 ​快速****打印 hello goroutine

func hello(i int){
	println("hello goroutine :" fmt.Sprint(i))
}

func HelloGoRoutine(){
	fori:=0;i<5;i+{
	go func(j int){
		hello(j)
	}(i)
	time.Sleep(time.Second)
}

采用 WaitGroup方法 ​快速****打印 hello goroutine

 func ManyGoWait(){ //上文部分代码省略
	var wg sync.WaitGroup
	wg.Add(5)
	for i:=0;i<5;i++{
		go func(j int)
			defer wg.Done()//完成调用Done方法
			hello(j)
		}(i)
	}
	wg.Wait()
}

  前者方法的缺点是采用 Sleep​ 方法,不确定子协程的具体结束时间,不能精确的控制。这里在前面的Goroutine中也有出现

  后者方法可以更加精准的控制每个协程结束的时间,无需写死时间

  其中 sync.WaitGroup​ 用于等待一组goroutine执行完成。 Add()​ 方法用于添加将要执行的goroutine数量。当goroutine执行完成时,调用 Done()​ 方法。Wait()​ 方法会阻塞,直到所有goroutine执行完成。

  ‍