Willson Chen

Stay Hungry, Stay Foolish.

Golang 基础(三)

Golang 基础(三)

并发

背景知识

  • 串行、并行与并发的区别:
    • 串行是指多个任务按照时间先后由一个处理器逐个执行。
    • 并行是指多个任务在同一时间由多个处理器同时执行。
    • 并发是指多个任务在宏观上并行执行,但是在微观上只是分成很多个微小指令串行或并行执行。
  • 进程:操作系统分配系统资源(cpu 时间片、内存)的最小单位。
  • 线程:操作系统调度的最小单位,同进程内的不同线程除了拥有独立的栈外,其他资源都共享。
  • 协程:轻量级线程,不依赖操作系统调度,开销小(线程栈8M,协程栈2k 动态增长)。
  • Go 并发:通过协程 goroutine 实现并发,通过通道 channel 实现同步。

协程

  goroutine 是一种语言级的协程,是 go 并发调度的单元。特点如下:

  • 协程调度由运行时负责,编码时不需要关注其调度。
  • 切换调度不依赖系统内核线程,开销小,数量不受限。
  • 可以用 go 加函数调用创建协程。
  • 可以用 go 加匿名函数创建协程,会创建闭包,函数内可以访问外部变量。
  • 程序启动时会创建主协程,调用main(),当main()结束时,其他协程停止运行。
  • 协程的运行是没有严格先后顺序的,由运行时调度。
func doJob(name string) string {
 fmt.Println("doJob:", name)
 return name + "_result"
}

func main() {
 go doJob("job1") //用 go 调用函数启动协程

 x := "nothing"
 go func() {
  x = doJob("job2") //通过闭包修改外部环境变量 x
 }()

 fmt.Println("x:", x)        //x: nothing,协程创建了但未运行
 time.Sleep(3 * time.Second) //简单等待协程运行,更科学的做法是用线程同步机制
 fmt.Println("x:", x)        //x: job2_result,协程已运行
}

通道

  channel 是用于协程间传递指定类型数据的通道,是一种队列,可以实现协程并发同步。使用要点:

  • 声明通道时需指定数据类型,方式如 var ch chan string。
  • 通道是引用类型,赋值或传参时仅仅是复制引用。
  • 初始化通道:
    • 用 make() 初始化,可以同时指定缓冲区容量,未指定时表示没有缓冲区。
    • 如果未初始化,通道为 nil,长度为0,容量为0。
    • 用 len() 获取长度,表示缓冲区数据的实际数量。
    • 用 cap() 获取容量,容量在初始化后就不会变化。
    • 如果未初始化,对通道发送或接收不会 panic,但会阻塞等待其他协程初始化。
  • 关闭通道:
    • 用 close() 关闭通道。
    • 通道关闭后再关闭将 panic。
    • 关闭通道的原则:只允许发送端关闭通道,接收端不需要。有多个发送端时也不要关闭通道。
  • 向通道发送数据:
    • 用 ch <- x 方式发送数据。
    • 对于没有缓冲区的通道,发送时如果没有被接收将阻塞等待。
    • 对于有缓冲区的通道,只有缓冲区满了发送端才阻塞等待。
    • 对于已关闭的通道,再发送会 panic。
  • 从通道接收数据:
    • 用 x := <-ch 方式接收数据,x 为数据。
    • 用 x,ok := <-ch 方式接收数据,如果通道关闭则 ok 为 false。
    • 如果接收端接收不到数据,会阻塞等待。
    • 对于已关闭的通道,仍然可以接收数据,接收完剩余数据后不阻塞。
  • 遍历通道
    • 支持 for range 遍历,如 for x := range ch {}
    • 如果没有数据,遍历会阻塞等待。
    • 如果通道关闭,遍历完数据后会退出。
  • 只读/只写通道
    • 通常只是作为函数参数或返回值,借助编译器限制对某个通道的只读或只写。
    • 函数参数为只读/只写通道时,调用方可以传递正常通道。
    • 可以关闭只写通道,不能关闭只读通道。
  • select case 作用于通道
    • 如果通道为nil,会忽略。
    • 如果通道已关闭,则每次都会命中,如果只有一个case则会死循环。
func doJob(name string, ch chan string) { //通道作为参数引用传递
 fmt.Println("doJob:", name)
 ch <- name + "_result" //将处理结果发送到通道
 close(ch)              //关闭通道
}

func main() {

 var ch chan string //仅声明不初始化
 // fmt.Println(<-ch)  //不初始化也可以接收,但会阻塞等待,这里会导致死锁异常。

 fmt.Println(len(ch), cap(ch), ch == nil) //0 0 true

 ch = make(chan string)                   //初始化,没有缓冲区
 fmt.Println(len(ch), cap(ch), ch == nil) //0 0 false

 go doJob("job1", ch)          //启动协程
 fmt.Println(len(ch), cap(ch)) //0 0,对于没有缓冲区的通道,长度任何时候都是 0
 data, ok := <-ch              //接收到数据则 ok 为 true
 fmt.Println(data, ok)         //job1_result true
 data, ok = <-ch               //再接收会阻塞,直到通道关闭
 fmt.Println(data == "", ok)   //true false

 ch2 := make(chan string, 2) //声明且初始化,缓冲区容量 
 go func() {
  for _, a := range "abcd" {
   ch2 <- string(a)
   fmt.Printf("w(%c,%d) ", a, len(ch2)) //写入完成后输出
  }
  time.Sleep(3 * time.Second) //等待3秒
  close(ch2) //关闭通道
 }()

 for x := range ch2 { //遍历通道,没有数据会阻塞,等通道关闭后退出遍历
  fmt.Printf("r(%s,%d) ", x, len(ch2))
 }
 // w(a,0) w(b,1) w(c,2) r(a,2) r(b,2) r(c,1) r(d,0) w(d,0)
}
func ReadOnly(ch <-chan int) {
 for x := range ch {
  print(x, " ")
 }
}

func WriteOnly(ch chan<- int) {
 for i := 0; i < 10; i++ {
  ch <- i
 }
 close(ch)
}

func main() {
 ch := make(chan int)
 go WriteOnly(ch)
 ReadOnly(ch)
}

并发控制

  • 如何控制并发数
    • 利用有缓冲区的通道进行控制
    • 缓冲区满了写入阻塞。
    • 处理前写入,处理后读取。
    • 缓冲区容量即为并发数。
    • 使用协程池,如第三方实现ants

错误处理

区分错误与异常

  • 在 Go 语言中,错误与异常是不同概念,用不同方式处理。
  • 错误是指可能出现问题的地方出现了问题,是在意料之中的,是业务的一部分。
  • 异常是指不应该出现问题的地方出现了问题,是意料之外的,与业务无关。
  • 错误通过 error 接口机制来处理,异常通过 panic 机制处理。

error

  • Go 提供了内建的错误接口 error,仅包含一个Error()字符串的方法。
  • 任何实现了error接口的类型都可以作为错误使用.
  • 可以断言底层结构类型,并通过底层类型的字段或方法获取更多错误信息。
  • 可以通过errors包的New()函数或fmt包的Errorf()函数创建简单的自定义错误。
  • 可以通过github.com/pkg/errors包进行错误处理,在标准errors包基础上增加堆栈跟踪功能。
  • 使用错误注意事项:
    • 没有失败时不使用 error。
    • 当失败原因只有一个时,返回布尔值而不是 error。
    • error 应放在返回值的最后。
    • 错误最好统一定义和管理,避免散落到代码各处。
    • 错误应包含足够的信息,必要时使用自定义结构,或增加堆栈信息。
//内建error接口
type error interface {
  Error() string
}
//断言底层结构类型,并获取更多错误信息。
func main() {
 f, err := os.Open("/test.txt")
 if err, ok := err.(*os.PathError); ok {
  fmt.Println("File at path", err.Path, "failed to open")
  return
 }
 fmt.Println(f.Name(), "opened successfully")
}

package main

import (
 "fmt"
 "github.com/pkg/errors"
)

// 自定义error
type BizError struct {
 Code    int32  //错误编码
 Message string //错误信息
 Cause   error  //内部错误
}

// 实现error接口
func (err *BizError) Error() string {
 return fmt.Sprintf("%s", err)
}

// 实现fmt.Formatter接口
func (err *BizError) Format(s fmt.State, verb rune) {
 switch verb {
 case 'v':
  if s.Flag('+') {
   fmt.Fprintf(s, "code:%d, message:%s", err.Code, err.Message)
   if err.Cause != nil {
    fmt.Fprintf(s, ", cause:%+v", err.Cause)
   }
   return
  }
  fallthrough
 case 's':
  fmt.Fprint(s, err.Message)
 }
}

func WrapBizError(code int32, cause error) error {
 return &BizError{
  Code:    code,
  Message: mapBizErrorMessage[code],
  Cause:   cause,
 }
}

const ERROR_LOGIN_FAIL = 1000
const ERROR_LOGIN_FAIL_MSG = "login fail"

var mapBizErrorMessage = map[int32]string{
 ERROR_LOGIN_FAIL: ERROR_LOGIN_FAIL_MSG,
}

func login(user string, pwd string) error {
 //github.com/pkg/errors包的Errorf()创建的错误包含堆栈信息
 return WrapBizError(ERROR_LOGIN_FAIL, errors.Errorf("user '%s' not found", user))
}

func main() {
 err := login("wills", "123456")
 if err != nil {
  fmt.Printf("%+v", err)
 }
}


panic

  • panic 机制类似于其他语言的 try{} catch{} finally{}。
  • 通过内建函数 panic() 抛出异常,参数可以是任何类型的变量。
  • 在 defer 中通过内建函数 recover() 捕获异常。
  • recover() 返回值是 panic() 抛出的数据。
  • 如果 panic 没有被捕获,会向上一层函数抛出,直到当前协程的起点,然后终止其他所有协程(包括主协程)。
  • 使用 panic 注意事项:
    • 在开发阶段,panic() 中断程序以便尽快发现并修复缺陷。
    • 在部署阶段,需要选择一个合适的上游进行 recover() 避免程序退出。
    • 对于入参不应该有问题的函数,使用 panic,比如 regexp.MustCompile() 的实现。
    • 对于不应该出现的分支,使用 panic。
func divide(a, b int) int {
 if b == 0 {
  panic("除数不能为零")
 }
 return a / b
}

func main() {
 defer func() {
  if err := recover(); err != nil {
   fmt.Println("panic:", err)
   debug.PrintStack() //堆栈信息
  }
 }()

 result := divide(10, 0)
 fmt.Println(result)
}

依赖管理

  Go 通过包(package)和模块(module)进行代码的模块化组织和管理。

  • 包(pakage)是同一目录中一起编译的文件集合。
  • 包的类型:
    • 标准库包:由 Go 官方提供的包。
    • 第三方包:由第三方提供的包,如 github.com 发布的包。
    • 内部包:项目内部的包。
  • 包的定义:
    • 同个目录下源码文件的非注释第一行,用 package 定义包名。
    • 包名一般与目录名相同,如不同,在 import 时需要指定包名。
    • 包名不能包含“-”符号。
    • 包内可以定义多个 init()函数,在导入时会被执行。
    • main 包是程序入口包,必须定义一个 main 函数作为入口函数,编译后会生成可执行文件。
  • 包的使用:
    • 通过 import 关键字引入包。
    • 对于标准库包,直接使用包名。
    • 对于第三方包,需使用”模块名/包名“。
    • 对于内部包,如果启用了GO111MODULE,则需要使用”模块名/包名“。
    • 对于内部包,如果未启用GO111MODULE,则需要使用包的路径,如”./pkg/mypkg“。
    • 在代码中通过包名前缀引用外部包的函数、类型、变量、常量,只有首字母大写的标识符才能引用。
    • import 时可以指定包别名,引用时用别名前缀。
    • 只导入不使用的包,只执行其 init()函数,可使用“_”忽略。
/* 示例为未启用GO111MODULE的情况,项目文件结构如下:
.
|-- main.go
`-- mypkg
    `-- mypkg.go
*/

//mypkg/mypkg.go
package mypkg
import "fmt"
func init() {
 fmt.Print("init ")
}
func MyFunc() {
 fmt.Println("MyFunc ")
}

//main.go
package main
import p "./mypkg"
func main() {
 p.MyFunc() 
}
//运行输出:init MyFunc 

模块

  • 模块化管理:
    • 模块化管理是 go1.11版本起引入的特性。
    • 将项目代码模块化,可以更好地组织依赖和版本控制。
    • 通过环境变量GO111MODULE 控制是否启用,值 on/off/auto,auto 表示根据目录情况决定是否启用,go1.16之后默认为 on。
  • 模块:
    • 模块是一个项目代码库,是版本控制的单元,是包的集合。
    • 模块根目录下包含 go.mod 和 go.sum 文件.
    • 模块版本号是通过 git tag 来实现的。
  • go.mod 文件:
    • 包含模块的元数据,如模块名、依赖的模块及版本等。
    • 可以用 go mod 命令进行管理。
    • go mod init,把当前目录初始化为新模块,会创建go.mod文件。
    • go mod download,下载 go.mod 下所有依赖的模块。
    • go mod tidy,整理依赖关系,添加丢失,移除无用。
    • go mod vendor,将依赖复制到vendor目录,在编译时无需下载。
    • go mod graph,查看模块依赖图。
    • go mod edit -fmt,格式化 go.mod文件。
    • go mod edit [email protected],添加一个依赖。
    • go mod edit -droprequire=xxx,删除一个依赖。
    • go mod edit [email protected][email protected],替换一个依赖,如开发期替换成本地路径。
    • go mod edit -exclude=xxx,添加一个排除依赖。
    • go mod edit -dropexclude=xxx,删除一个排除依赖。
    • go get xxx,可以下载 xxx 模块的最新版本,并更新当前目录的 go.mod文件。
  • go.sum 文件:
    • 自动生成,包含依赖模块的校验和,用于防篡改。
    • 每行由模块名、版本、哈希算法、哈希值组成。
    • 构建时会计算本地依赖包的哈希值与 sum 文件是否一致,不一致则构建失败。
//示例 go.mod 文件,仅包含一个依赖
module code.oa.com/mymod //模块名,需包含代码库地址
go 1.20 //指明本模块代码所需的最低 go 版本,仅起标识作用
require github.com/pkg/errors v0.9.1 //依赖的模块名及版本
//示例 go.sum 文件,仅包含一个依赖
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

依赖版本

  • 模块的版本号为仓库的 tag 号,需遵循以下规范:
    • 格式:v主版本号.次版本号.修订号-预发布号
    • 主版本号:递增表示出现不兼容的 API变化。
    • 次版本号:递增表示增加新的功能,但不影响 API 兼容性。
    • 修订号:递增表示修复 bug,也不影响 API 兼容性。
    • 预发布号:用于标识非稳定版本,如 alpha(内测)、beta(公测)、rc(发布候选)等。
    • v0主版本:可以在生产环境使用,但不保证稳定性和向后兼容性。
  • go get 时会获取仓库的最新 tag 作为模块版本。
  • 如果仓库没有 tag,会生成伪版本号,格式:v0.0.0-最新commit时间-commit哈希