golang并发编程|青训营笔记

174 阅读5分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

并发编程

并发VS并行

并发:多线程程序在一个核的cpu上运行
并行:多线程程序在多个核的cpu上运行

简而言之,并发就是实现了宏观上的并行,举个例子:我们人眼观察到的灯是一直亮的,但是灯泡其实是以一种人眼不可察觉的频率在闪烁。并发同样如此,微观上不同的线程分时占用CPU,时间一到就切换成另一个线程占用CPU,给每个线程分配的时间叫做时间片。时间片用完,线程下处理机切换另一个线程上处理机,因为时间片很短。所以宏观意义上看好像同一时间很多线程都在工作。

golang用并行实现并发 现在CPU一般是双核16个处理机,你不妨打开你电脑上的任务管理器看看,通常处理机的占用率比较低,CPU的占用率低下,计算机的性能没有充分被利用,这是不高效的。可能会出现这种情况,运行一个APP虽然很慢但是你会发现你的CPU的占用率依旧会很低,你就很奇怪?为什么不分配更多的处理机去执行这个APP。这是因为操作系统调度的单位是线程,线程比起进程来说要小很多但是他也是以MB为单位的系统资源。运行一个APP可以认为执行一个进程将他分成多个线程,多个线程去抢占一个处理机,能够实现并发但是不能够完全占用处理机资源。

而Golang 的运行时会在逻辑处理器上调度 goroutine 来运行。每个逻辑处理器都与一个操作系统线程绑定。在 Golang 1.5 及以后的版本中,运行时默认会为每个可用的物理处理器分配一个逻辑处理器。一个进程会在多个处理机上去执行,大大提高了CPU的利用率。

协程 Goroutine

image.png CSP 模型,即在通信双方抽象出中间层,数据的流转由中间层来控制,通信双方只负责数据的发送和接收,从而实现了数据的共享,这就是所谓的通过通信来共享内存。通过通信共享内存而不是通过共享内存实现通信。 image.png 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 <-操作符的意义

  1. ch <- v    // 发送值v到Channel ch中  
  2. 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 这条语句在底层其实不止一条语句被拆分成多个机器指令

  1. 先将x从内存中取出来
  2. 将x和1放入加法器 得到的值暂存在寄存器中
  3. 寄存器中的值存入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

image.png

单元测试

image.png

单元测试规则

测试没什么要讲的,可能在测试时下载第三方包可能有报错在cmd中修改环境变量

SET GO111MODULE=on
SET GOPROXY=https://goproxy.cn

这是临时的解决方案如果想要长期解决,自行检索环境变量的配置问题

项目实战 青训营话题页

需求描述
  • 展示话题(标题,文字描述)和回帖列表
  • 暂不考虑前端页面实现,仅仅实现一个本地web服务
  • 话题和回帖数据用文件存储
需求用例

image.png

需求用例

image.png

分层结构

image.png 数据层:数据 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())
Service层

image.png