Go语言进阶 | 青训营笔记

66 阅读8分钟

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

一、语言进阶

1.1 并发和并行

对于并发来说,就是go语言本身自己实现调度,对于并行来说,是和运行的电脑的物理处理器的核数有关的,多核就可以并行并发,单核只能并发了。 从概念上,并发和并行是不同的: 并行:指让不同的代码片段同时在不同的物理处理器上执行。 并发:指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了,其宗旨:“使用较少资源做更多事情”。 列个队列,一个处理器处理,那是并发。 两个队列,两个处理器处理,那是并行。

并发:

image.png 并行:

image.png

Go可以充分发挥多核优势,高效运行,Go就是为此而生的。

1.2 Goroutine

线程:

线程是指进程内的一个执行单元,也是进程内的可调度实体。线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

协程:

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。从技术的角度来说,“协程就是你可以暂停执行的函数”。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

协程与线程的区别:

  1. 一个线程可以多个协程,一个进程也可以单独拥有多个协程。
  1. 线程进程都是同步机制,而协程则是异步。
  1. 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。
  2. 线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
  3. 协程并不是取代线程, 而且抽象于线程之上, 线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行, 线程是协程的资源, 但协程不会直接使用线程, 协程直接利用的是执行器(Interceptor), 执行器可以关联任意线程或线程池, 可以使当前线程, UI线程, 或新建新程。
  4. 线程是协程的资源。协程通过Interceptor来间接使用线程这个资源。

image.png

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

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

goroutine的优点

1、创建与销毁的开销小

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

2、切换开销小

这是goroutine于线程的主要区别,也是golang能够实现高并发的主要原因。

线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存/恢复所有的寄存器信息,比如16个通用寄存器,PC(Program Counter),SP(Stack Pointer),段寄存器等等。

而goroutine的调度是协同式的,没有时间片的概念,由Golang完成,它不会直接地与操作系统内核打交道。当goroutine进行切换的时候,之后很少量的寄存器需要保存和恢复(PC和SP)。因此gouroutine的切换效率更高。

总的来说,操作系统的一个线程下可以并发执行上千个goroutine,每个goroutine所占用的资源和切换开销都很小,因此,goroutine是golang适合高并发场景的重要原因。

生成一个goroutine的方法十分简单,直接使用go关键字即可

1.3 CSP

image.png

提倡通过通信共享内存而不是通过共享内存而实现通信。

而在java和c++中是采用多线程开发,是通过多线程来实现共享内存。

CSP(communicating sequential processes)并发模型:Go语言特有且推荐使用的。

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

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

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

1.4 channel

Go语言中的通道(channel)是一种特殊的类型。 在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。

image.png

channel的使用方法:

声明之后,传数据用channel <- data,取数据用<-channel。channel分为无缓冲和有缓冲,无缓冲会同步阻塞,即每次生产消息都会阻塞到消费者将消息消费;有缓冲的不会立刻阻塞。

特点:

(1)一旦初始化容量,就不会改变了。

(2)当写满时,不可以写,取空时,不可以取。

(3)发送将持续阻塞直到数据被接收 把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示。

(4)接收将持续阻塞直到发送方发送数据。 如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。

(5)每次接收一个元素。 通道一次只能接收一个数据元素。

无缓存通道:

相当于同步通道,从无缓存的 channel 中读取消息会阻塞,直到有 goroutine 向该 channel 中发送消息;同理,向无缓存的 channel 中发送消息也会阻塞,直到有 goroutine 从 channel 中读取消息。

有缓存通道:

当缓存未满时,向 channel 中发送消息时不会阻塞,当缓存满时,发送操作将被阻塞,直到有其他 goroutine 从中读取消息

课程案例

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

1.5 WaitGroup 实现并发等待

WaitGroup 类实现的功能是:等待一系列协程并发地执行完毕。如果不等待所有协程执行完毕,可能会导致一些线程安全问题。sync.WaitGroup 包含 3 个方法:

image.png

课程案例:

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

func ManyGo() {
	var wg sync.WaitGroup
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func(j int) {
			defer wg.Done()
			hello(j)
		}(i)
	}
	wg.Wait()
}

二、依赖管理

2.1依赖管理的演进

image.png

2.2 GOPATH

GOPATH是Go语言中使用的一个环境变量,使用绝对路径提供项目的工作目录。工作目录是一个工程开发的相对参考目录。

GOPATH适合处理大量Go语言源码、多个包组合而成的复杂工程。

弊端:

image.png A 和 B 依赖于某一个package的不同版本

它的弊端是无限实现pacjage的多版本的控制

2.3 GOVENDOR

govendor只是用来管理项目的依赖包,如果GOPATH中本身没有项目的依赖包,则需要通过go get先下载到GOPATH中,再通过govendor add +external拷贝到vendor目录中。

x项目目录下增加vendor文件,所有依赖包副本方在vendor下,他的依赖寻址方式是vendor=>GOPATH。通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题。

弊端:

image.png

无法控制依赖的版本。

更新项目有可能出现依赖冲突,导致编译出错。

总的来说vendor依赖的是项目的源码,不是版本

2.4 Go Module

通过go.mod文件管理依赖包版本

通过go get/go mod 指令工具管理依赖包

终极目标:定义版本规则和管理项目依赖关系

依赖管理三要素:

image.png

类似于java中的maven

三、测试

测试产生的意义就是防止产品发布后产生问题影响到用户体验以及资金的损失。

image.png

3.1 单元测试

规则:

  • 所有测试文件以_test.go结尾
  • func TestXxx(*testing.T)
  • 初始化逻辑放到TestMain中

如下图:

image.png

测试:

通过设置测试函数对函数功能进行测试,将输出和预期结果进行对比,如果不正确则设置对应的错误级别和错误信息。

3.2 代码覆盖率

测试覆盖率是通过执行某包的测试用例来确认代码被执行的程度的术语。

func JudgePassLine(score int16) bool {
	if score >= 60 {
		return true
	}
	return false
}
func TestJudgePassLineTrue(t *testing.T) {
	isPass := JudgePassLine(70)
	assert.Equal(t, true, isPass)
}

func TestJudgePassLineFail(t *testing.T) {
	isPass := JudgePassLine(50)
	assert.Equal(t, false, isPass)
}

如果只做成绩为70的测试,代码的覆盖率只有2/3,因为return false这句话没有执行。

加上了成绩为50的测试,代码的覆盖率就达到了百分之百。

单元测试覆盖率小结:

  • 一般覆盖率要保证50%-60%,较高的覆盖率可以到达80%以上
  • 测试分支相互独立、全面覆盖。
  • 测试单元粒度足够小,函数单一职责。

依赖:

image.png

3.3 Mock

Mock是单元测试中常见的一种技术,就是在测试过程中,对于一些不容易构造或者获取的对象,创建一个Mock对象来模拟对象的行为,从而把测试与测试边界以外的对象隔离开。

优点:

  • 团队并行工作
  • 测试驱动开发 TDD (Test-Driven Development)
  • 测试覆盖率
  • 隔离系统

缺点:

  • Mock不是万能的,使用Mock也存在着风险,需要根据项目实际情况和具体需要来确定是否选用Mock。
  • 测试过程中如果大量使用Mock,mock测试的场景失去了真实性,可能会导致在后续的系统性测试时才发现bug,使得缺陷发现的较晚,可能会造成后续修复成本更大

3.4 基准测试

基准测试主要用来测试CPU和内存的效率问题,来评估被测代码的性能。测试人员可以根据这些性能指标的反馈,来优化我们的代码,进而提高性能问题。

四、项目实战

项目需求描述

展示话题和回帖列表,仅仅实现一个本地web服务,话题和回帖数据用文件存储。

个人所感

web项目跟java的web项目的程序架构类似,很像java中的MVC思想,后端数据库做数据存储,逻辑层交给server去做,controller来接收和发送请求。

个人对于Go的语法还不算熟悉,跟各位大佬们还有太大的差距,我得加把劲了。

引用

该文章部分内容来自于视频和网页