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哈希