这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记
并发编程
并发VS并行:
并发:多线程程序在一个核的cpu上运行
并行:多线程程序在多个核的cpu上运行
简而言之,并发就是实现了宏观上的并行,举个例子:我们人眼观察到的灯是一直亮的,但是灯泡其实是以一种人眼不可察觉的频率在闪烁。并发同样如此,微观上不同的线程分时占用CPU,时间一到就切换成另一个线程占用CPU,给每个线程分配的时间叫做时间片。时间片用完,线程下处理机切换另一个线程上处理机,因为时间片很短。所以宏观意义上看好像同一时间很多线程都在工作。
golang用并行实现并发 现在CPU一般是双核16个处理机,你不妨打开你电脑上的任务管理器看看,通常处理机的占用率比较低,CPU的占用率低下,计算机的性能没有充分被利用,这是不高效的。可能会出现这种情况,运行一个APP虽然很慢但是你会发现你的CPU的占用率依旧会很低,你就很奇怪?为什么不分配更多的处理机去执行这个APP。这是因为操作系统调度的单位是线程,线程比起进程来说要小很多但是他也是以MB为单位的系统资源。运行一个APP可以认为执行一个进程将他分成多个线程,多个线程去抢占一个处理机,能够实现并发但是不能够完全占用处理机资源。
而Golang 的运行时会在逻辑处理器上调度 goroutine 来运行。每个逻辑处理器都与一个操作系统线程绑定。在 Golang 1.5 及以后的版本中,运行时默认会为每个可用的物理处理器分配一个逻辑处理器。一个进程会在多个处理机上去执行,大大提高了CPU的利用率。
协程 Goroutine
CSP 模型,即在通信双方抽象出中间层,数据的流转由中间层来控制,通信双方只负责数据的发送和接收,从而实现了数据的共享,这就是所谓的通过通信来共享内存。通过通信共享内存而不是通过共享内存实现通信。
channel
在多并发操作里是属于协程安全的,并且遵循了 FIFO 特性。即先执行读取的 goroutine 会先获取到数据,先发送数据的 goroutine 会先输入数据。
channel
channel的创建
ch := make(chan int)
上面是创建了无缓冲的 channel,一旦有 goroutine 往 channel 发送数据,那么当前的 goroutine 会被阻塞住,直到有其他的 goroutine 消费了 channel 里的数据,才能继续运行。
无缓冲的channel用来实现协程的同步
还有另外一种是有缓冲的 channel,它的创建是这样的:
ch := make(chan int, 2)
第二个参数表示 channel 可缓冲数据的容量。只要当前 channel 里的元素总数不大于这个可缓冲容量,则当前的 goroutine 就不会被阻塞住。
channel的使用
可参照golang 系列:channel 全面解析 - 云+社区 - 腾讯云 (tencent.com)
golang中channel <-操作符的意义
- ch <- v // 发送值v到Channel ch中
- v := <-ch // 从Channel ch中接收数据,并将数据赋值给v
package main
import "fmt"
func CalSquare() {
src := make(chan int) //容量为0的缓冲通道
dest := make(chan int, 3) //容量为3的缓冲通道
//发送数字 子协程A
//生产者生产产品
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i //发送数据到i
}
}()
//计算输入数字的平方 子协程B
//每生产一个产品就把他放在dest缓冲通道
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
//通过src这个channel实现了A B协程之间的通信
//输出最后的平方数 主协程
//消费者
for i := range dest {
//复杂操作
fmt.Println(i)
}
}
func main() {
CalSquare()
}
对比加锁和不加锁
package main
import (
"fmt"
"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 Add() {
x = 0
for i := 0; i < 10; i++ {
go addWithLock()
}
time.Sleep(time.Second)
fmt.Println("WithLock", x)
x = 0
for i := 0; i < 10; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
fmt.Println("WithoutLock", x)
}
func main() {
Add()
}
输出结果
WithLock 20000
WithoutLock 18471
加锁与不加锁输出有区别?
执行x+=i 这条语句在底层其实不止一条语句被拆分成多个机器指令
- 先将x从内存中取出来
- 将x和1放入加法器 得到的值暂存在寄存器中
- 寄存器中的值存入x所在地址中更新x的值 为什么不上锁的输出可能不等于20000例如:协程1 进行到x=1990,x和1放入加法器得到的值1991放入寄存器中 时间片用完切换到协程2 将x从内存中取出来 x=1,x和1放入加法器得到的值2放入寄存器中 时间片用完切换到协程1 执行指令3 寄存器中的值存入x所在地址中更新x的值,这时更新后x的值为2不再是1991.当然也可能为20000因为当操作不复杂在一个时间片内就可以完成一个协程的工作。不存在切换带来的问题。
WaitGroup
上面的代码使用了time.Sleep(time.Second)这是因为在golang中协程当作线程分开执行,也就是主函数的执行和goroutine的执行没有必然的联系,主函数执行完毕后可能goroutine还没执行完,所以我们让主函数等待time.Sleep(time.Second)让协程运行完但是大型的项目协程的运行时间远远大于等待的time.Second。所以有了等待组(WaitGroup)的概念
由来及用法可参考Golang等待组sync.WaitGroup的用法 - 马谦的博客
底层原理可参考Golang WaitGroup 原理深度剖析
依赖管理
GOPATH
GOPATH 是Golang中使用的一个环境变量,它使用绝对路径提供项目的工作目录。 工作目录是一个工程开发的相对参考目录,好比当你要在公司编写一套服务器代码,你的工位所包含的桌面、计算机及椅子就是你的工作区。工作区的概念与工作目录的概念也是类似的。如果不使用工作目录的概念,在多人开发时,每个人有一套自己的目录结构,读取配置文件的位置不统一,输出的二进制运行文件也不统一,这样会导致开发的标准不统一,影响开发效率无法实现package的多版本控制
Go Vendor
参考Go Vendor 使用指南 - 掘金 (juejin.cn)
- 项目目录下增加vendor文件,所有依赖包副本形式放在$PrijectRoot/vendor
- 依赖寻址方式:vendor=>GOPATH 通过每个项目引入一份依赖的副本,解决了多个项目需要同一个package依赖的冲突问题
弊端:
- 无法控制依赖的版本
- 更新项目有可能出现依赖冲突,导致编译出错
Go Module
- 通过go.mod文件管理依赖包版本
- 通过go get/go mod指令工具管理依赖包 定义版本规则和管理项目依赖关系
依赖配置-go.mod
单元测试
单元测试规则
- 所有测试文件以_test.go结尾
- func TestXxx(*testing.T)
- 初始化逻辑放到TestMain中 在终端进行测试时可能出现被测试函数未定义的错误 解决方案请参照解决Go test执行单个测试文件提示未定义问题
测试没什么要讲的,可能在测试时下载第三方包可能有报错在cmd中修改环境变量
SET GO111MODULE=on
SET GOPROXY=https://goproxy.cn
这是临时的解决方案如果想要长期解决,自行检索环境变量的配置问题
项目实战 青训营话题页
需求描述
- 展示话题(标题,文字描述)和回帖列表
- 暂不考虑前端页面实现,仅仅实现一个本地web服务
- 话题和回帖数据用文件存储
需求用例
需求用例
分层结构
数据层:数据 MOdel,外部数据的增删改查
对于该项目,我们没有使用数据库,所有数据存储在本地文件上 数据从File中读取 数据的存储库为文件
逻辑层:业务 Entity,处理核心业务逻辑输出
各层之间是透明的,Service不需要关心数据层的实现逻辑,只需要从Model中拿到数据即可 对Model输出的数据进行打包封装,输出一个Enitity(实体)
视图层:视图 View,处理和外部的交互逻辑
sync.Once
sync.Once 是 Go 标准库提供的使函数只执行一次的实现,常应用于单例模式,例如初始化配置、保持数据库连接等。作用与 init 函数类似,但有区别。
- init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
- sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。
在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:
- 当且仅当第一次访问某个变量时,进行初始化(写);
- 变量初始化过程中,所有读都被阻塞,直到初始化完成;
- 变量仅初始化一次,初始化完成后驻留在内存里。
sync.Once 仅提供了一个方法 Do,参数 f 是对象初始化函数。
func (o *Once) Do(f func())