语言进阶

并发 VS 并行

正如前面曾提到的,Go 的特点有高性能,高并发,因此 Go 语言可以充分发挥多核优势,高效运行

在一个单线程的程序中,同一时刻只有一个用户的需求被处理,就好似在超市中购物,单收银台收银员只能服务当时正在交钱的顾客,后面的顾客只能等待,想象一下,如果同时有多个用户正在使用该程序,剩下的人就必须等待第一个人结束使用,这在很多程序中简直是不可忍受的。

为了解决这种问题,许多语言使用了线程(thread)来实现多个请求同时处理,这也好似超市中有多个收银台同时处理顾客的需求,一定程度上优化了顾客的体验。

但是,正如多个收银台需要雇佣多个收银员一样,开辟多个线程消耗的资源是显而易见的,即使是单个线程栈甚至也会带来MB级别的内存占用,因此为了节省资源,有时也会采用另外一种方式来解决问题,那种方式就是“并发”,并发的本质仍是第一种逐个处理请求的方式,只不过处理的方式有所不同,给人一种在同时处理多个请求的感觉,不过并发也经常与并行配合使用来处理用户需求。

这种并发的处理办法看似和线程相似,但实际上仍不同,我们称之为协程, Go 语言一次性可以创建上万条协程,这也是Go语言作为高并发语言的原因所在。

Goroutine

使用 Goroutine 创建协程的方法非常简单,只需要在你的函数前面加上一个 go 即可完成协程的添加

以下是课程中提到的一个例子

package main
 
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)
}

func main() {
	HelloGoRoutine()
}

上面的代码定义了两个函数,helloHelloGoRoutine ,前者负责打印字符串,后者负责控制字符串打印的次数,再通过 time.Sleep 使得协程休眠1s,输出结果如下

hello goroutine : 4
hello goroutine : 2
hello goroutine : 1
hello goroutine : 3
hello goroutine : 0

你会发现这些字符串有多个线程在无规律打印,再次运行代码,你会得到不同的结果,是因为函数正在并行打印输出。

CSP

通过上面的代码,我们可以引出一个新的话题——协程之间的通信。 Go 语言提倡通过通信来共享内存,而不是通过共享内存实现通信。而前者的方法,则涉及到一个重要的概念——通道(Channel),后者则是利用一个缓冲区来进行数据的交换,但其在一定的程度上会影响程序的性能,因此对比两种通信方式 Go 提倡使用通信来共享内存。

Channel

想要创建一个 Channel 来共享内存实现通信,同样需要使用 make 关键字

格式为

ch := make(chan 元素类型,缓冲大小)

接下来简单解析一下这三个参数所代表的含义

chan顾名思义,就是channel的缩写,告诉 Go 语言你创建的是一个通道

而元素类型,则规定了你这个通道中所传输的值的类型

最后的一个缓冲大小是可选的,添加缓冲大小的通道就叫做有缓冲通道,不添加缓冲大小的就是无缓冲通道,那么缓冲大小的作用是什么呢?

无缓冲的通道意味着,源头发送信息之后接收端必须进行接收,否则就会使通道堵塞,就像奶茶吸管里卡了一颗珍珠,没人把它吸出来,它就永远在那里。

而有缓冲的通道可以理解为在奶茶杯里吸珍珠,有着一个由你定义的空间存放源头发送的信息,当然,奶茶杯的大小也不是无限的,珍珠太多仍会导致堵塞,这就是超出缓冲大小的后果,以下面的通道为例

ch := make(chan int, 2)

它的缓冲大小是2,也就是说,它可以存储源头发送的两份数据,当源头向他发送第三份数据,且前两份数据占据了缓冲区时,通道仍然会被阻塞

接下来使用一段代码帮助理解通道在实际开发中的作用

func CalSquare() {
    src := make(chan int)
    dest := make(chan int, 3)
    go func() {
        defer close(src)
        for i := 0; i < 10; i++ {
            src <- i
        }
    }()
    go func() {
        defer close(dest)
        for i := range src {
            dest <- i * i
        }
    }()
    for i := range dest {
        println(i)
    }
}

func main() {
	CalSquare()
}

首先我们定义了 srcdest 两个通道,前者是无缓冲通道,后者是有缓冲通道

4~9行是第一个协程,它负责循环0~9,并将结果传输给 src

10~15行是第二个协程,它使用 range 来遍历 src 中的数据,然后将其平方后传输给 dest

src 便是两个协程之间进行数据传输的通道

从这两步不难看出,将变量值传输到通道和将通道值传输给变量的方法分别是

ch <- i
i := <- ch

同理,16~18行使用 range 来遍历 dest 中的数据,最后进行输出

0
1
4
9
16
25
36
49
64
81

可能有朋友会疑惑,为什么这两个协程中都存在着一个 defer closeclose 相信大家知道,当一个通道调用完毕时,应该使用 close 来关闭它,那么 defer 的作用是什么呢?

这里简单的说一下 defer 在这段代码中的作用,即声明一个延迟函数,也就是说,只有在这个协程内其他所有内容执行完之后,才会执行 close (就该这样,总不能你还没完成值的传输就直接给你把通道关了)

实际上 defer 还有很多特点,感兴趣的读者可以自己查阅相关资料,笔者在此不做详解

并发安全 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)
}
func main() {
	Add()
}

上面这段代码是对变量进行2000次+1操作,五个协程同时执行

但你会发现,它们的输出并不一致,这是为什么呢?

WithoutLock: 7491
WithLock: 10000

实际上我们可以看到,在 WithLock 函数中,我们调用了一个 lock.Lock() 方法,该方法可以锁定一块内存资源用于接下来的运算,直到使用 lock.Unlock() 方法将锁定的内存资源再度释放

而在不加锁的 WithoutLock 函数中,它输出了一个未知的值,这便是并发的安全问题,于是我们需要恰当的使用并发锁来锁定临界区保证并发的安全,因为并发安全的错误的发生是有几率的,错误点较为难以定位,因此在实际开发的过程中,应当避免对共享内存做一些非并发安全的读写操作。

WaitGroup

采用 WaitGroup 对我们之前的代码进行优化

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()
}

WaitGroup 的调用实际上分为三步:首先调用 Add 方法,在计数器中添加指定值,然后通过调用 Done 方法使计数器中的值-1,直到计数器的值为0时,前面被 Wait 方法阻塞的进程就会得到释放,继续执行接下来的代码或是直接结束运行。

所以,上述代码使得计数器的初始值+5,随后阻塞主协程,当余下的5个子协程调用 Done 方法后,主协程便会被释放,代码得以继续向下并结束运行。

依赖管理

为什么需要依赖管理

在实际开发的过程中,一个重要的概念就是依赖管理。依赖指各种开发包,在项目开发的过程中,我们要利用已经封装好的,经过验证的开发组件或工具来提高自己的开发效率。

简单的单体函数只需要原生的SDK,但实际工程相对复杂,开发者不可能完全基于标准库搭建,一系列的依赖都会通过SDK的方式引入,这样对依赖包的管理就显得尤为重要

依赖管理的演进过程

Go 的依赖管理主要经历了三个阶段,主要是GOPATHGoVendor,和目前广泛应用的Go Module

GOPATH

GOPATH 是 Go 语言支持的一个环境变量,value 是 Go 项目的工作区,在 GOPATH 下的目录有以下结构,分别是 src:存放 Go 项目的源码;pkg:存放编译的中间产物,加快编译速度;bin:存放 Go 项目编译生成的二进制文件

但是GOPATH有着一个较为严重的弊端,那便是 pkg 的版本问题,在 GOPATH 的管理模式下,如果多个项目依赖同一个库,则依赖库是同一份代码,所以不同的项目不能依赖同一个库的不同版本,这很显然不能满足我们的项目依赖需求,为了解决这个问题,Go Vendor 横空出世。(实际上Python也有库的版本问题)

Go Vendor

Vendor 是当前项目中的一个目录,其中存放了当前项目依赖的副本。在 Vendor 机制下,如果当前项目存在 Vendor 目录,会优先使用该目录下的依赖,如果依赖不存在,才会在 GOPATH 中寻找,但是 Vendor 并不能很好地解决依赖包的版本变动问题以及一个项目依赖同一个包的不同版本问题,归根结底是因为 Vendor 不能很清晰的标识依赖的版本概念,为了解决这个问题, Go Module 出现了。

Go Module

Go Module 是 Go 语言官方推出的依赖管理系统,解决了前面两种方式存在的无法依赖同一个库的多个版本等等问题,在 Go 1.16之后默认开启,我们可以通过 go.mod 文件管理依赖包版本,也可以通过 go get/go mod 指令工具来管理依赖包。

依赖管理三要素

依赖配置

以 go.mod 为例

module example/project/app //依赖管理基本单元
 
go 1.16 //原生库
 
require (
    example/lib1 v1.0.2 //单元依赖
    example/lib2 v1.0.0 // indirect
    example/lib3 v0.1.0-20190725025543-5a5fe074e612
    example/lib4 0.0.0-20180306012644-bacd9c7efldd // indirect
    example/lib5/v3 v3.0.2
    example/lib6 v3.2.0+incompatible
)

首先模块路径用来标识一个模块,module example/project/app 标识了依赖管理基本单元,如果是 Github前缀则表示可以从 Github 仓库找到该模块,依赖包的源代码由 Github 托管,如果项目的子包想被单独引用,就需要通过单独的 init go.mod 文件进行管理

接下来的 go 1.16 代表着依赖的原生SDK版本

最下面的 require 代表单元依赖,每个依赖都用模块路径加版本来唯一标识,解决了无法依赖同一个库的多个版本的问题

GOPATHGo Vendor 都是源码副本方式依赖,没有版本规则的概念,而 go.mod 为了放方便管理则定义了版本规则,分为语义化版本和基于 commit 的伪版本,两种表示版本的方式各有特点,笔者在这里不做赘述

读者可能会发现,在上面的 go.mod 文件中有着 indirect 的后缀,其表示 go.mod 对应的当前模块,没有直接导入该依赖模块的包,也就是非直接依赖(间接依赖)。例如项目 A 引入了 B 依赖,B 依赖依赖于依赖 C,那么依赖 C 就是 项目 A 的间接依赖)

有的依赖可能会在版本末尾添加 +incompatible 标识,这是因为 Go 1.11 才实验性添加了 Go Module,为了兼容这一部分非语义化版本才额外添加标识。

依赖分发

想要用 Go Module 安装依赖,大部分会选择使用 Github 等各种代码托管平台,这样的话,对于 go.mod 中定义的依赖,则可以直接从对应的仓库中下载,完成依赖的分发。

但开发者的数量众多,极大的增加了第三方代码托管平台的压力,同时直接用版本管理仓库下载依赖还存在着多个问题,包括但不仅限于构建失败,作者删库跑路等等

GOPROXY 便是解决这些问题的方案,它是一个服务站点,缓存源站中的软件内容,缓存的软件版本不会改变,并且在源站删除软件之后仍然可以使用,我们可以通过指定 GOPROXY 环境变量的方式指定 Proxy 服务器,当需要拉取依赖时,Go 便会按照顺序从配置的依赖服务器下载依赖,如果所需的依赖不存在,那么就前往下一个依赖服务器下载,直到前往源站(使用 direct 表示)直接下载依赖,并缓存在 Proxy 站点中。

工具

go get

go get 是 Go Module 的一个工具,作用是添加和移除依赖:

基本语法:go get example.org/pkg*

example.org/pkg 是所需依赖的仓库地址,* 可以取以下值:

  1. @update,默认
  2. @none,删除依赖
  3. @v1.1.2,使用tag版本,语义版本的依赖
  4. @23dfdd5,使用特定的 commit
  5. @master,使用指定分支的最新 commit
go mod

go mod 是 Go Module 的一个工具,作用是初始化项目和管理依赖:

基本语法: go mod ** 可以取以下值:

  1. init,初始化,创建 go.mod 文件
  2. download,下载模块到本地缓存
  3. tidy,添加需要的依赖,删除不需要的依赖

非自律型摸鱼AI