第28节 高质量编程简介及编码规范

❤️💕💕Go语言高级篇章,在此之前建议您先了解基础和进阶篇。Myblog:http://nsddd.topopen in new window

Go语言基础篇open in new window

Go语言100篇进阶open in new window


[TOC]

准备

  1. 在课程开始之前,克隆网址一open in new window到本地,保证能够编译运行
  2. 尝试使用 test 命令,编写并运行简单测试网址二open in new window
  3. 尝试使用 -bench 参数,对编写的函数进行性能测试网址三open in new window

高质量代码

编写的代码达到正确的、可靠的、简介清晰的目标可称之为高质量代码。

  • 边界条件(错误调用处理)
  • 异常情况处理
  • 易读易维护

image-20230117153307968

注释

  • 注释解释代码的作用
  • 注释解释代码如何做的
  • 注释解释代码实现的原因
  • 注释解释代码在什么情况下会出错

好的代码有很多注释,坏的代码需要很多注释。

命名规范

规范:

  1. 包名应该是小写字母,不能包含下划线或者短横线。
  2. 变量名和函数名应该是小写字母,如果名字由多个单词组成,则每个单词之间应该使用驼峰式命名。
  3. 类型名和接口名应该是大写字母,如果名字由多个单词组成,则每个单词之间应该使用驼峰式命名。
  4. 常量名应该全部大写,如果名字由多个单词组成,则每个单词之间应该使用下划线连接。
  5. 变量名和函数名不能以下划线或短横线开头。
  6. 不能使用与Go语言关键字相同的名字。
  7. 注意缩略词全大写,位于开头的话可以全小写(HTTP、XML)

image-20230117154947011

函数命名规范

在 Go 语言中,函数命名规范如下:

  1. 函数名应该是小写字母,如果名字由多个单词组成,则每个单词之间应该使用驼峰式命名,首字母小写。
  2. 函数名不能以下划线或短横线开头。
  3. 不能使用与Go语言关键字相同的名字。
  4. 函数名不能缩写,应该使用全称。
  5. 函数名应该能够清楚地描述函数的功能。

image-20230117155415454

包的规范

在 Go 语言中,包名命名规范如下:

  1. 包名应该是小写字母,不能包含下划线或者短横线。
  2. 包名应该能够清楚地描述该包的功能。
  3. 包名应该尽量短,并且避免使用缩写。
  4. 包名应该尽量避免和其他包名相同。
  5. 包名应该是全局唯一的, 不能和其他任何已经发布的包或者其他项目重名。
  6. 包名不能与Go语言关键字相同。
  7. 包名不能以下划线或短横线开头。

注意:Go语言默认支持在线安装包,所以包名必须是全局唯一的, 建议使用公司或者组织名称作为包名的前缀。

image-20230117155500912

错误和异常处理

image-20230117160247843

errors.New 函数是 Go 语言标准库中定义的一个函数,它用于创建一个新的错误。该函数接受一个字符串作为参数,该字符串描述了错误的原因。这个函数会返回一个实现了 error 接口的对象,可以在程序中使用这个对象来报告错误。

package main

import (
    "fmt"
    "errors"
)

func main() {
    _, err := divide(10, 0)
    if err != nil {
        fmt.Println(err)
    }
}

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

🚀 编译结果如下:

cannot divide by zero

在上面的程序中,divide函数检查分母是否为0,如果是,则使用errors.New函数创建了一个新的错误,描述了错误原因为“cannot divide by zero”.在main函数中,使用if语句判断返回值是否为错误,如果是,则输出错误信息.

记住,errors.New 不会添加额外的调试信息到错误中,如果需要添加更多的信息,可以使用 fmt.Errorf 函数.

image-20230117160650790

fmt.Errorf 函数是 Go 语言标准库中定义的一个函数,它用于格式化错误信息并创建一个新的错误。该函数接受一个格式字符串和一些参数作为参数,格式字符串描述了错误的原因,参数用于替换格式字符串中的占位符。这个函数会返回一个实现了 error 接口的对象,可以在程序中使用这个对象来报告错误。

package main

import (
    "fmt"
)

func main() {
    _, err := divide(10, 0)
    if err != nil {
        fmt.Println(err)
    }
}

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("cannot divide %d by %d", a, b)
    }
    return
}

🚀 编译结果如下:

PS D:\文档\my\go-pprof-practice> go run .\test.go
cannot divide 10 by 0

性能优化

如何评估:

  • 性能表现需要实际数据说明
  • Go语言提供了支持基准性能测试benchmark工具
go test -bench=. -benchmen

image-20230117163103118

程序在初始化的时候指定大小,可以看到执行时间是之前的 1/3 大小。

image-20230117163132940

这个和底层数据结构相关的。

🤔 可以看到当容量不够的时候,底层是先有一次扩容 (✖2)操作,然后再添加,消耗时间~

image-20230117163410113

大内存未释放

切片是引用的一段空间,所以原底层再内存中的引用得不到释放。

在 Go 语言中,使用 copy 函数可以避免重新切片 (re-slice) 造成的性能问题。

重新切片是指对一个已经存在的切片进行切片操作,这样会导致新的内存分配,从而降低程序的性能。

当我们重新切片一个已经存在的切片时,如果新切片的长度或容量超过了原切片的长度或容量,则需要重新分配内存来容纳新切片。这样会导致新的内存分配,从而降低程序的性能。

需要注意的是,当重新切片的长度和容量小于等于原切片的长度和容量时,则不会进行重新分配内存的操作。

而使用copy函数复制数据可以避免这个问题,因为它只是将数据从一个切片复制到另一个切片,不会导致内存的重新分配.

使用 copy 函数可以避免这个问题。 copy 函数接受两个参数,分别是目标切片和源切片,它会将源切片中的数据复制到目标切片中,而不是新分配内存。

image-20230117163610901

package main

import (
    "fmt"
)

func main() {
    // 定义一个切片
    s := []int{1, 2, 3, 4, 5}

    // 使用重新切片操作,复制s的前3个元素到s1
    s1 := s[:3]

    //使用copy函数,复制s的前3个元素到s2
    s2 := make([]int, 3)
    copy(s2, s[:3])

    fmt.Println(s1)
    fmt.Println(s2)
}

使用 copy 函数复制数据可以节省内存的分配和垃圾回收的时间,提高性能。

注意,copy 函数只能复制切片的数据,不能复制切片的底层数组。如果需要复制底层数组,可以使用其他方法如 append 函数来实现。

map优化建议

slice 类似:

image-20230117164516197

分配内存建议:

image-20230117164529754

字符串处理,优化

不同拼接字符串的方法对应的性能有差异:

image-20230117165018556

image-20230117165024292

Go语言中常见的拼接字符串的方式有以下几种:

使用 + 运算符:

s1 := "Hello"
s2 := "World"
s3 := s1 + " " + s2
fmt.Println(s3) // "Hello World"

使用 fmt.Sprintf 函数:

Copy codes1 := "Hello"
s2 := "World"
s3 := fmt.Sprintf("%s %s", s1, s2)
fmt.Println(s3) // "Hello World"

使用 strings.Join 函数:

s := []string{"Hello", "World"}
s3 := strings.Join(s, " ")
fmt.Println(s3) // "Hello World"

使用 bytes.Buffer:

var buffer bytes.Buffer
buffer.WriteString("Hello")
buffer.WriteString(" ")
buffer.WriteString("World")
s3 := buffer.String()
fmt.Println(s3) // "Hello World"

在结合你的具体需求来选择合适的方式来拼接字符串。

在拼接字符串数量较多时,使用 fmt.Sprintf, strings.Join 以及 bytes.Buffer 是更优秀的选择。 而在拼接字符串数量较少时,使用 + 运算符是比较简单直接的选择。

需要注意的是,在使用 + 运算符拼接字符串时,如果字符串数量较多,会产生大量的临时对象,导致频繁的内存分配和垃圾回收,影响性能。而使用 fmt.Sprintf, strings.Join 以及 bytes.Buffer 不会有这个问题。

另外,在处理大量数据时,使用 bytes.Buffer 效率更高,因为它是基于字节的,而不是基于字符的。

总之,在选择字符串拼接的方式时,要考虑到字符串的数量、性能和可读性等因素。

image-20230117165239305

但是bytes.Buffer和strings.builder底层区别也影响了它们的性能:

  1. bytes.Buffer是基于字节的,内部使用一个动态数组来存储字节。它提供了一些方法来操作字节,如 Write, WriteString, WriteByte 等。
  2. strings.Builder 是基于字符的,内部使用一个动态数组来存储字符。它提供了一些方法来操作字符,如 Write, WriteString, WriteByte 等。
  3. 在性能上,bytes.Buffer 和 strings.Builder 的性能差异不大,但是在处理大量字节数据时,bytes.Buffer 会更快一些,因为它是基于字节的。 而strings.Builder 更适合处理字符数据。
  4. 在选择使用bytes.Buffer 和 strings.Builder 时,要根据你的具体需求,如果你需要处理大量字节数据,使用 bytes.Buffer 更加合适。如果处理的是字符串,使用strings.Builder 更加合适。
  5. 需要注意的是,在Go 1.13版本之后,strings.Builder 已经成为了标准库中的标准字符串拼接方式。

空结构体 占位符

在 Go 语言中,空结构体 (empty struct) 可以被用作占位符。空结构体是一种特殊的结构体,它没有任何字段,也就是说它没有任何内存占用。

这种用途常用在 channel 上, 因为空结构体占用内存最小,可以高效地在 channel 上传递信号。

💡简单的一个案例如下:

package main

import "fmt"

func main() {
	done := make(chan struct{})

	go func() {
		fmt.Println("Doing some work...")
		close(done)
	}()
	fmt.Println("Waiting for work to be done...")
	<-done
	fmt.Println("Work done!")
}

🚀 编译结果如下:

PS D:\文档\my\go-pprof-practice> go run .\test.go
Waiting for work to be done...
Doing some work...
Work done!

📜 对上面的解释:

在这个例子中,我们使用了一个空结构体类型的 channel 来传递信号,当 goroutine 完成了工作之后就会向 channel 中发送一个空结构体,主 goroutine 通过读取 channel 中的数据来判断工作是否完成。

需要注意的是,由于空结构体不占用任何内存,所以它在 channel 中传递的是一个空消息,不能在 channel 中传递数据。

空结构体还有其它用途,例如作为占位符在 map 中使用、作为锁的实现等。

image-20230117165629098

作为占位符在 map 中使用

作为占位符在 map 中使用,用来检查一个键是否存在:

m := make(map[string]struct{})
m["key1"] = struct{}{}

_, ok := m["key1"]
fmt.Println(ok) // true

_, ok = m["key2"]
fmt.Println(ok) // false

作为锁的实现,用来实现互斥操作

var lock struct{}

func myFunc() {
    fmt.Println("Acquiring lock...")
    lock = struct{}{}
    fmt.Println("Lock acquired.")
    // do some work
    fmt.Println("Releasing lock...")
    lock = struct{}{}
    fmt.Println("Lock released.")
}

作为占位符在结构体中使用

用来减少结构体的内存占用。

💡简单的一个案例如下:

package main

import (
	"fmt"
	"unsafe"
)

type myStruct struct {
	name string
	data []byte
	_    struct{}
}

type myStruct2 struct {
	name string
	data []byte
}

func main() {
	s := myStruct{
		name: "example",
		data: []byte{1, 2, 3, 4, 5},
	}
	fmt.Println(s)
	fmt.Printf("Size of myStruct: %d bytes\n\n", unsafe.Sizeof(s))

	s2 := myStruct2{
		name: "example2",
		data: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
	}
	fmt.Println(s2)
	fmt.Printf("Size of myStruct2: %d bytes\n", unsafe.Sizeof(s2))
}

🚀 编译结果如下:

{example [1 2 3 4 5] {}}
Size of myStruct: 48 bytes

{example2 [1 2 3 4 5 6 7 8 9 10]}
Size of myStruct2: 40 bytes

这里使用了空结构体作为占位符,减少了结构体的内存占用。

还有就是它可以用来标记某种类型, 例如:

type MyType struct{}

func (t MyType)DoSomething(){
    // do something
}

var _ MyInterface = MyType{}

这样就可以避免编译错误。

总之, 空结构体作为占位符是一种高效的方式,在很多场景下都可以使用。

atomic包

Go语言的atomic包提供了一组原子操作函数,这些函数可以在并发环境中安全地更新基本数据类型,例如整型和浮点数。这些函数在底层使用了CPU的原子指令,可以保证在多线程环境中更新操作的原子性。

atomic包中提供了以下常用函数:

  • AddInt32、AddInt64等:对指定的整型变量执行加法操作。
  • CompareAndSwapInt32、CompareAndSwapInt64等:如果当前变量的值等于预期值,则将其设置为新值。
  • LoadInt32、LoadInt64等:读取指定变量的值。
  • StoreInt32、StoreInt64等:将指定变量设置为指定值。

请注意,atomic包中的函数仅适用于基本数据类型,如果需要对复杂数据类型进行原子操作,可以使用sync/atomic包中的函数。

实例:

package main

import (
    "fmt"
    "sync/atomic"
)

func main() {
    var i int32 = 0
    atomic.AddInt32(&i, 1)
    fmt.Println(i) // 1
}

性能对比

image-20230117171344626

使用actomic包

image-20230117171535814

actomic保持线程安全

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var counter int64
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&counter, 1)
        }()
    }

    wg.Wait()
    fmt.Println(counter)
}

🚀 编译结果如下:

100

这个例子中,我们创建了一个int64类型的变量counter,并在100个goroutine中并发地执行AddInt64函数对其进行自增操作。由于 AddInt64函数是原子的,所以不会发生竞态条件。在所有goroutine执行完毕后,counter的值应该为100,并且不会受到其它线程的影响。

性能优化分析工具

优化建议

Go语言性能调优有以下几点建议:

  1. 使用 Go 的内置性能分析工具,如 pprof,可以帮助诊断程序中的性能瓶颈。
  2. 避免使用高开销的操作,例如过多的内存分配和字符串连接。
  3. 使用 Go 协程,可以提高程序的并发性能。
  4. 使用 Go 的编译器优化,例如禁用内联(-gcflags=-l)和优化级别(-O)。
  5. 使用 Go 的原生并发类型,如 channel,而不是使用第三方库。
  6. 使用 Go 的垃圾回收器,可以减少内存分配和回收的开销。
  7. 尽量避免使用反射。
  8. 可以使用 cgo调用C语言代码来提升性能

优化原则:

  1. 优化最热点的代码: 通过性能分析工具(例如 pprof)来识别程序中的性能瓶颈并优化。
  2. 尽量减少不必要的操作: 比如内存分配、字符串连接等高开销操作。
  3. 使用合适的数据结构和算法: 例如使用 hash 表代替暴力查找,使用优化后的算法等。
  4. 使用并发和并行技术: 例如使用 Go 协程、channel 来提高程序的并发性能。
  5. 尽量避免使用反射: 使用反射会带来性能上的代价。
  6. 使用编译器优化: 例如禁用内联、使用优化级别等。
  7. 利用硬件加速: 例如使用 GPU 加速等。
  8. 优先使用高效的第三方库: 使用高效的第三方库可以减少代码量并提高性能。
  9. 经常进行性能测试和监控: 性能优化不是一次性的,需要经常进行性能测试和监控,及时发现并解决性能问题。

性能分析工具 pprof

pprof是 Go 语言的性能分析工具,它可以帮助诊断程序中的性能瓶颈。

使用方法:

  1. 在程序中导入 "net/http/pprof" 包
  2. 启动 pprof HTTP 服务器,通常是在 main 函数中调用 go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
  3. 使用浏览器或命令行工具访问 pprof 的 HTTP 服务器,例如: http://localhost:6060/debug/pprof/
  4. 通过 pprof 提供的不同的 web页面 或 命令行工具 查看性能统计信息

pprof 支持多种类型的性能分析,包括 CPU profile、memory profile、block profile、goroutine profile、threadcreate profile、heap profile等.

image-20230118153922625

pprof项目实战

将最开始的项目 clone 到本地,在这个项目中,写了一些造成性能问题的代码炸弹。

编译并保持链接:

go run main.go

浏览器查看指标:

  • http://localhost:6060/debug/pprof/

pprof命令排查:

保持连接,另开终端:

go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"

topN — 查看占用资源最多的函数:

image-20230118162256355

📜 对上面的解释:

  • flat = cum,函数中没有调用其他的函数
  • flat = 0,函数中只有其他函数的调用

list命令

(pprof) list Eat
Total: 2.77s
ROUTINE ======================== github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat in D:\文档\my\go-pprof-practice\animal\felidae\tiger\tiger.go
     2.73s      2.74s (flat, cum) 98.92% of Total
         .          .     19:}
         .          .     20:
         .          .     21:func (t *Tiger) Eat() {
         .          .     22:   log.Println(t.Name(), "eat")
         .          .     23:   loop := 10000000000
     2.73s      2.74s     24:   for i := 0; i < loop; i++ {
         .          .     25:           // do nothing
         .          .     26:   }
         .          .     27:}
         .          .     28:
         .          .     29:func (t *Tiger) Drink() {
(pprof)

问题出在第 24 L

调用关系可视化

image-20230118163659804

解决后,发现协程数下降:

image-20230118163915913

性能调优实战

pprof是性能调试工具,可以生成类似火焰图、堆栈图,内存分析图等。

整个分析的过程分为两步:1. 导出数据,2. 分析数据。

使用

allocs:查看过去所有内存分配的样本。

block:查看导致阻塞同步的堆栈跟踪。

cmdline: 当前程序的命令行的完整调用路径。

goroutine:查看当前所有运行的 goroutines 堆栈跟踪。

heap:查看活动对象的内存分配情况。

mutex:查看导致互斥锁的竞争持有者的堆栈跟踪。

profile: 默认进行 30s 的 CPU Profiling,得到一个分析用的 profile 文件。

threadcreate:查看创建新 OS 线程的堆栈跟踪。

trace:mp.weixin.qq.com/s/I9xSMxy32…open in new window

过程和原理

image-20230118164157274

END 链接