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

53 阅读2分钟

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

0、前情提要

昨天课程最后的实战主要是教写代码,做出来的笔记不太多,所以先搁置一边。今天的任务有点困难,有点抽象,花了很多力气。

1、并发和并行

作为一个几个月前刚学完操作系统的人来说,被这些东西支配的恐惧还历历在目233333

对于一个单线程环境,单个程序只能同时执行一个操作,其他操作必须等待该程序完成当前操作后,才能执行。但是随着计算机功能的增加,以及计算需求的增加,这种单线程的设置将难以满足需求。

于是提出了并行与并发的概念。

  • 并发是指一个处理器同时处理多个任务
    并行是指多个处理器或者是多核的处理器同时处理多个不同的任务
    并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。
    来个比喻:并发是一个人同时吃三个馒头,而并行是三个人同时吃三个馒头。

  • 图示 屏幕截图 2023-01-16 234713.png 其中Go使用的就是并行。

2、 协程Goroutine

  • 协程:用户态,通过Go调度器调度;轻量级线程,栈KB级别;不经过操作系统的用户态和内核态切换;
  • 线程:内核态,通过内核调度;线程跑多个协程,栈MB级别;

Go语言一次可以创建上万级别的协程。

  • 代码示例:
    package main
    ​
    import (
        "fmt"
        "time"
    )
    ​
    func say(s string) {
        for i := 0; i < 5; i++ {
            time.Sleep(100 * time.Millisecond)
            fmt.Println(s)
        }
    }
    ​
    func main() {
        go say("world")
        say("hello")
        time.Sleep(time.Second)
    }
    

最终的打印结果是hello和world随意地交替打印。

上述代码定义了一个say函数,每隔 100 毫秒就打印一次传入的字符串,重复 5 次;main函数调用了两次say函数,分别传入不同的参数"hello"和"world";最后,调用time.Sleep()函数令当前协程(也就是main函数的主协程)休眠 1 秒。

此外第一个say()函数前使用了go关键字说明该函数将在一个新的 Goroutine 协程中运行。

  • 但是并发并不是真正的并行,在注释掉say()函数中的休眠函数后,程序将依次先输出5个"world"再输出5个"hello",打印结果和单线程一样,因为并发只是一种错觉,程序并没有真正的并行运行,只有在一段程序有空闲时间的时候,另一端程序才有机会抢过执行权,执行自己。当注释掉休眠函数后,协程内程序并没有空闲,其他的程序就不能执行。
  • 同时注释掉main()和say()中的休眠函数以后,输出结果只有5个"hello",因为主协程是一个特殊的协程,如果主协程执行完毕,那么其他子协程也会停止,直接退出。注释掉了time.Sleep(time.Second)后,在say("hello")执行完毕后,程序便会直接退出,不会再等待接着执行的 say("world")。

因此,当我们有多个子协程执行时,应该等待这些协程全部执行完毕后,再结束主协程。因为无法获知其他协程的执行时间,所以要想做到这一点,绝不是用time.Sleep()这样的函数。实际上 Go 语言标准库为我们提供了更好的解决方案,那就是WaitGroup。

3、WaitGroup

正常情况下,新激活的goroutine的结束过程是不可控制的,唯一可以保证终止goroutine的行为是main goroutine的终止。也就是说,我们并不知道哪个goroutine什么时候结束。

但很多情况下,我们正需要知道goroutine是否完成。这需要借助sync包的WaitGroup来实现。

WatiGroup是sync包中的一个struct类型,用来收集需要等待执行完成的goroutine。

它有3个方法:

  • Add():每次激活想要被等待完成的goroutine之前,先调用Add(),用来设置或添加要等待完成的goroutine数量

    • 例如Add(2)或者两次调用Add(1)都会设置等待计数器的值为2,表示要等待2个goroutine完成
  • Done():每次需要等待的goroutine在真正完成之前,应该调用该方法来人为表示goroutine完成了,该方法会对等待计数器减1

  • Wait():在等待计数器减为0之前,Wait()会一直阻塞当前的goroutine。

    也就是说,Add()用来增加要等待的goroutine的数量,Done()用来表示goroutine已经完成了,减少一次计数器,Wait()用来等待所有需要等待的goroutine完成。

示例

package main

import (  
   "fmt"
   "sync"
   "time"
)

func process(i int, wg *sync.WaitGroup) {  
   fmt.Println("started Goroutine ", i)
   time.Sleep(2 * time.Second)
   fmt.Printf("Goroutine %d ended\n", i)
   wg.Done()
}

func main() {  
   no := 3
   var wg sync.WaitGroup
   for i := 0; i < no; i++ {
       wg.Add(1)
       go process(i, &wg)
   }
   wg.Wait()
   fmt.Println("All go routines finished executing")
}

上面激活了3个goroutine,每次激活goroutine之前,都先调用Add()方法增加一个需要等待的goroutine计数。每个goroutine都运行process()函数,这个函数在执行完成时需要调用Done()方法来表示goroutine的结束。激活3个goroutine后,main goroutine会执行到Wait(),由于每个激活的goroutine运行的process()都需要睡眠2秒,所以main goroutine在Wait()这里会阻塞一段时间(大约2秒),当所有goroutine都完成后,等待计数器减为0,Wait()将不再阻塞,于是main goroutine得以执行后面的Println()。

还有一点需要特别注意的是process()中使用指针类型的*sync.WaitGroup作为参数,这里不能使用值类型的sync.WaitGroup作为参数,因为这意味着每个goroutine都拷贝一份wg,每个goroutine都使用自己的wg。这显然是不合理的,这3个goroutine应该共享一个wg,才能知道这3个goroutine都完成了。实际上,如果使用值类型的参数,main goroutine将会永久阻塞而导致产生死锁。

4、Channel

  • 无缓冲区:接收和发送端同步
  • 有缓冲区:缓冲区未满,发送不阻塞;缓冲区未空,接收不阻塞。

例:由于消费者和生产者的消费或者生产速度不匹配,可以用带缓冲的channel减少阻塞,提高二者执行效率

5、CSP

QQ图片20230117155508.jpg 显然通过共享内存实现通信涉及临界区的访问问题(死锁、饥饿等)

6、Go依赖管理

GOPATH——不能实现package的多版本控制

最初,Go 直接将依赖库源码扔进 GOPATH 的 src 文件夹以作为项目依赖。

GOPATH 是一个环境变量,指向一个目录,作为项目的编译产出目录和依赖目录。这是一个公共环境变量,也就意味着,所有项目都依赖于同一个 GOPATH,这就会导致这样的问题:如果项目 A 依赖于依赖库 Lib 的版本 1,而项目 B 依赖于同一个依赖库的版本 2,由于 GOPATH 并没有任何版本管理措施,就会导致编译出错。

Go Vendor——无法控制依赖的版本

于是,Go 引入了 Go Vendor,通过在项目目录下新建 vendor 文件夹,并存放依赖库文件副本的方式,使得不同项目可以依赖不同的依赖库版本,解决了版本冲突的问题。

值得一提的是,如果无法在 vendor 文件夹中找到项目所需的依赖文件,那么 Go 会尝试回到 GOPATH 查找。

Go Vendor 的引入看似解决了版本问题,但是实际上依然造成了问题:如果项目 A 引入了 项目 B 和项目 C 作为依赖库,而后两者又共同依赖了项目 D 的不同版本,那么由于 B,C,D 作为项目 A 的依赖依然被同时存在同一个 vendor 文件夹中,依旧导致了依赖冲突。

Go Module——定义了版本规则和依赖关系

最后用了和 JavaScript(NodeJS)的 npm 类似的包管理方案 —— 这就是 Go Module。

Go Module 通过项目路径中的 go.mod 文件(类似于 npm 的 pakcage.json 声明所需依赖的名称和版本范围),然后,通过 go.sum 文件记录项目实际使用的依赖和版本(类似于 npm 的 package-lock.json)。

Go 为我们提供了 go get 和 go mod 两条指令来方便的添加和移除项目中的依赖。

三要素

  • 配置文件 go.mod

    • 一般是 [module_path][version]形式 version一般有两种${MAJOR}.${MINOR}.${PATCH}或是vX.Y.Z-yyyymmddhhmmss-comit
  • 中心仓库 goproxy

    • 可以通过指定 GOPROXY 环境变量的方式指定 Proxy 服务器,其值为一个由逗号分隔的网址列表,例如 "https://proxy1.cn, https://proxy2.cn, direct"。当需要拉取依赖时,Go 便会按顺序从依赖服务器拉取代码,如果找不到指定的依赖,那么就前往下一个依赖服务器拉取,直到前往源站(即direct)拉取代码。
  • 本地工具go get

    • go get 是一个命令行指令,可用于添加和移除依赖,在项目目录执行它以为项目配置依赖: 基本语法:go get example.org/pkg???? 其中,example.org/pkg 是所需依赖的仓库地址,????可以取以下值:
      • pdate,缺省值(不填写时使用的值),使用最新版本
      • @none,删除项目中的此依赖
      • @v1.1.2,使用此版本的依赖
      • @23dfdd5,使用特定的 commit
      • @master,使用指定分支的最新 commit 得一提的是,在过去,go get 指令还可用于安装二进制可执行程序,但是在 Go 1.17 后,使用 go get 指令安装可执行包的操作已被弃用,取而代之的是 go install 指令。阅读 Deprecation of 'go get' for installing executables - The Go Programming Language 以获得更多信息。

go mod 指令

go mod 是一个命令行指令,可用于初始化项目和管理依赖,在项目目录执行它以为项目配置依赖:

  • go mod init,初始化项目,这将创建 go.mod 文件,类似于 npm 的 npm init
  • go mod download,下载模块到本地缓存
  • go mod tidy,添加需要的依赖,删除不需要的依赖(有点类似于 apt autoremove

7、单元测试

单元测试

Go 内置单元测试支持。所有以 _test.go 结尾的代码会被 Go 识别为单元测试文件。

一个单元测试函数的函数名应当以 Test 开头,并包含 *testing.T 形参。

可通过 func TestMain(m *testing.M) 函数对测试数据进行初始化,并调用 m.Run() 运行单元测试。

基准测试

测试cpu损耗之类的。

函数名称func BenchmarkXxxx(b *testing.b)

8、参考链接:

  1. 并行的区别
  2. 从Java角度实践Go工程
  3. WaitGroup用法说明