Go语言进阶笔记

目录

课程地址:Go语言从入门到实践

基础部分

学习这个课程主要是对之前学习的查漏补缺,快速地过一遍。

课程中老师提到的一些概念

他们会大量的使用共享内存的方式,来进行并发控制,而忽略了 Go 语言本身内置的 CSP 的并发机制。

什么是CSP?

其实 Java 程序员在编写程序的时候,在方法调用间,传递数组参数时,通常是直接传递数组,导致大量的内存复制。与Java不同,Go的数组参数是通过值复制来进行传递的。

另外,Java程序员也总是喜欢创建一个只有接口定义的包,用于处理依赖关系,而其实这在 Go 中是大可不必的,因为Go中接口的实现与接口的定义是没有依赖关系的。

软件开发的新挑战

  1. 多核硬件架构

  2. 超大规模分布式计算集群

  3. Web 模式导致的前所未有的开发规模和更新速度

开发环境构建

GOPATH

  1. 在1.8版本前必须设置这个环境变量
  2. 1.8版本后(含1.8)如果没有设置使用默认值 在Unix 上默认为 $HOME/go,在 Windows 上默认为 %USERPROFILE%/go 在Mac上GOPATH可以通过修改 ~/.bash_profile 来设置

返回程序退出时的状态

Go语言中,main()函数是不支持返回值的。可以使用os.Exit() 指定程序退出状态。

1
os.Exit(-1) // 指定程序退出状态

变量

变量声明和初始化

值类型:基本数据类型(如整数、浮点数、布尔值、字符串)、数组和结构体。 指针类型:切片、映射(map)、通道(channel)、指针、接口等。 如要对一个变量赋值,这个变量必须有对应分配好的内存这样才可以对这块内存操作,完成赋值的目的。 在声明时,如果是值类型,Go语言会帮我们分配内存,可以直接赋值使用。但如果是指针类型,我们就必须手动地申请开辟内存。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func main() {
    var str string // 这里只声明了str变量,但由于string是值类型,所以系统会帮我们分配内存,所以下面可以直接赋值使用,而不用手动的开辟内存
    str = "hello"
    fmt.Println(str)

    var slice01 []int
    fmt.Println(len(slice01), cap(slice01)) // 0 0
    // 没有为slice01分配内存,直接使用是会报错的
    //slice01[0] = 1 // panic: runtime error: index out of range [0] with length 0
    //fmt.Println(slice01)

    // new()函数:根据传入的类型,申请一块内存,然后返回指向这块内存的指针。指针指向的数据就是该类型的零值。
    var slice02 = new([]int)
    fmt.Println(len(*slice02), cap(*slice02)) // 0 0
    fmt.Println(*slice02)                     // []

    // make函数只用于slice、chan和map 这三种内置类型的创建和初始化,因为这三种类型的结构比较复杂
    var slice03 = make([]int, 1)
    fmt.Println(slice03) // [0]
}

new用于基本数据类型和结构体类型的创建,返回一个指向新分配内存的指针,指针指向的数据就是该类型的零值。

make用于引用数据类型(如切片、映射、通道)的创建,返回一个已初始化的引用类型。

变量赋值

与其他主要编程语言的差异

  • 赋值可以进行自动类型推断
  • 在一个赋值语句中可以对多个变量进行同时赋值

可以使用a, b = b, a来交换两个变量的值。

常量定义

快速设置连续值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 连续的递增操作
const (
	Sunday    = iota + 1 // 1
	Monday               // 2
	Tuesday              // 3
	Wednesday            // ...
	Thursday
	Friday
	Saturday
)

// 连续的位移操作
const (
	Open    = 1 << iota // 1
	Close               // 2
	Pending             // 4
)

2023.10.05

类型转化

与其他主要编程语言的差异

  1. Go 语言不允许隐式类型转换

  2. 别名和原有类型也不能进行隐式类型转换

类型的预定义值

  1. math.MaxInt64

  2. math.MaxFloat64

  3. math.MaxUint32

指针类型

与其他主要编程语言的差异

  1. 不支持指针运算

在支持指针运算的语言中,可以通过指针的自增来获取连续的数组值,但是在go语言中是不支持指针的运算的。如:

1
2
3
4
a := 10
aPtr := &a
//aPtr = aPtr + 1 // invalid operation: aPtr + 1 (mismatched types *int and untyped int)
fmt.Println(a, aPtr)
  1. string 是值类型,其默认的初始化值为空字符串,而不是 nil
1
2
3
4
5
6
var str string
if str == "" {
    fmt.Println("str==\"\"")  // 输出:str==""
} else {
    fmt.Println("str!=\"\"")
}

空结构体

(2023.10.11补)

在Go语言中,空结构体(empty struct)是一种特殊的数据结构,它不包含任何字段。它通常被用来实现某些特定的编程模式和技巧,有以下几个主要用途:

  1. 占位符:空结构体可以用作占位符,占据内存空间但不包含任何有用的数据。这在某些情况下可以用于构建数据结构,例如在通道的元素中使用空结构体来表示事件的发生。

  2. 同步锁:空结构体通常用于创建互斥锁或信号量。通过通道和空结构体的结合,你可以实现简单而高效的同步机制。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    var mu sync.Mutex
    var wg sync.WaitGroup
    ch := make(chan struct{})
    
    // 锁定
    mu.Lock()
    
    // 解锁
    mu.Unlock()
    
    // 等待完成
    wg.Wait()
    
    // 发信号
    close(ch)
  3. 实现集合:空结构体可以用来创建一个集合,它表示存在或者不在集合中的状态,而不需要存储任何额外的信息。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    set := make(map[string]struct{})
    
    // 添加元素
    set["item1"] = struct{}{}
    
    // 检查元素是否存在
    if _, exists := set["item1"]; exists {
        fmt.Println("元素存在")
    }
  4. 简化接口:在某些情况下,接口的实现可能不需要存储额外的数据,只需实现接口的方法。此时,可以使用空结构体来实现这些接口,以减少内存开销。

总之,空结构体在Go语言中用于一些特定的编程技巧和模式,通常与通道、互斥锁、集合和接口实现相关。通过它们,可以更加灵活地构建高效的数据结构和同步机制,同时减少内存开销。

运算符

算术运算符

Go 语言没有前置的 ++,–。如:++a--a是不被允许的。

用==比较数组

  • 相同维数且含有相同个数元素的数组才可以比较

    如果维度不同,则编译直接报错。如下:

    1
    2
    3
    4
    
    a := [2]int{1, 2}
    b := [3]int{1, 2, 3}
    isEqual := a == b  // invalid operation: a == b (mismatched types [2]int and [3]int)
    fmt.Println(isEqual)
  • 每个元素都相同的才相等

位运算符

与其他主要编程语言的差异

&^按位置零

1
2
3
4
1 &^ 0 --- 1
1 &^ 1 --- 0
0 &^ 1 --- 0
0 &^ 0 --- 0

如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

const (
    Readable = 1 << iota
    Writable
    Executable
)

func main() {
    a := 7 // 0111
    fmt.Println(a&Readable == Readable,
       a&Writable == Writable,
       a&Executable == Executable) // true true true

    // 现在使用 &^ 按位清零
    a = a &^ Readable // 将最低位 置0
    a = a &^ Writable // 将倒数第二位 置0
    fmt.Println(a&Readable == Readable,
       a&Writable == Writable,
       a&Executable == Executable) // false false true
}

函数是一等公民

与其他主要编程语言的差异

  1. 可以有多个返回值

  2. 所有参数都是值传递。slice,map,channel 会有传引用的错觉

  3. 函数可以作为变量的值

  4. 函数可以作为参数和返回值

函数式编程

image-20231005111114225

面向对象

实例创建及初始化

1
2
3
4
5
6
e := Employee{"0", "Bob", 20}
e1 := Employee{Name:"Mike", Age: 30}
e2 := new(Employee)  //注意这里返回的引用/指针,相当于 e := &Employee{}
e2.Id ="2"  //与其他主要编程语言的差异: 通过实例的指针访问成员不需要使用->
e2.Age = 22
e2.Name ="Rose"

行为(方法)定义

与其他主要编程语言的差异

1
2
3
4
5
type Employee struct {
	Id string
	Name string
	Age int
}
1
2
3
4
5
6
7
8
9
//第一种定义方式在实例对应方法被调用时,实例的成员会进行值复制
func (e Employee) String() string {
    return fmt.Sprintf("ID:%s-Name:%s-Age:%d", e.Id, e.Name, e.Age)
}

//通常情况下为了避免内存拷贝我们使用第二种定义方式
func (e *Employee) String() string {
	return fmt.Sprintf("ID:%s/Name:%s/Age:%d", e.Id, e.Name, e.Age)
}

Go 接口

与其他主要编程语言的差异

  1. 接口为非入侵性,实现不依赖于接口定义

  2. 所以接口的定义可以包含在接口使用者包内

Go的错误机制

与其他主要编程语言的差异

  1. 没有异常机制

  2. error 类型实现了 error 接口

    1
    2
    3
    
    type error interface {
        Error() string
    }
  3. 可以通过errors.New 来快速创建错误实例errors.New("n must be in the range [0,10]")

panic VS OS.Exit

os.Exit 退出时不会调用 defer 指定的函数 os.Exit 退出时不输出当前调用栈信息

init 方法

在main 被执行前,所有依赖的 package的init 方法都会被执行。 不同包的 init 函数按照包导入的依赖关系决定执行顺序。 每个包可以有多个 init 函数。 包的每个源文件也可以有多个 init 函数,这点比较特殊。

package

  1. 通过 go get 来获取远程依赖
  • go get -u 强制从网络更新远程依赖
  1. 注意代码在 GitHub 上的组织形式,以适应 go get
  • 直接以代码路径开始,不要有 src

示例: https://github.com/easierway/concurrent_map

Thead vs.Groutine

  1. 创建时默认的 stack 的大小
  • JDK5 以后 Java Thread stack 默认为1M

  • Groutine 的 Stack 初始化大小为2K

  1. 和KSE (Kernel Space Entity) 的对应关系
  • Java Thread 是1:1
  • Groutine 是M:N
image-20231005163108859 image-20231005163240091

结构体嵌套

非匿名嵌套

这种方式就是在结构体中嵌套另外一个结构体的变量。相当于新的结构体在原结构体的基础上进行扩展。 但这和java中的继承是完全不同的。 以这里为例,Gog1结构体中嵌套了Pet1结构体的变量。在Gog1的实例对象访问Pet1结构体的SpeakTo()方法时,必须使用Dog1.p.SpeakTo()来进行调用,而不能直接使用Dog1.SpeakTo()。 如果希望新的结构体中有和原结构体相同的方法,则需要重新实现相关的方法。但本质上来说,这Pet1SpeakTo()方法和Dog1SpeakTo()方法并没有任何的关系,也不存在类似于Java中的重写的功能。它们本质上就是两个彼此独立的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type Pet1 struct {}

func (p *Pet1) Speak() {
    fmt.Printf("...")
}

func (p *Pet1) SpeakTo(host string) {
    p.Speak()
    fmt.Println(" ", host)
}

type Dog1 struct {
    p *Pet1
}

// Speak 结构体Dog1重新实现Speak方法
func (d *Dog1) Speak() {
    d.p.Speak()
}

func (d *Dog1) SpeakTo(host string) {
    d.p.SpeakTo(host)
}
func main() {
    dog1 := &Dog1{}
    dog1.p.SpeakTo("Pet1") // 调用Pet1结构体的SpeakTo()方法
    dog1.SpeakTo("Gog1")   // 调用Dog1结构体的SpeakTo()方法
}

匿名嵌套

匿名嵌套的方式,可以使新的结构体Dog直接拥有原结构体Pet的所有属性和方法。Dog的对象dog可以直接访问Pet的方法。 分下面两种情况:

  1. 如果Dog没有SpeakTo()方法:Dog.SpeakTo(),会直接调用结构体Pet的这个方法。

  2. 如果DogSpeakTo()方法:Dog.SpeakTo(),会直接调用结构体Dog自身的这个方法。(和Java中重写的功能相同)

这样就好像在Go语言中实现了Java中的继承,但其实还是和Java是不相同的。 如下面的例子中,Dog结构体重写了Pet结构体的Speak()方法,现在Dog结构体的对象dog来调用SpeakTo()方法,在此方法中,又调用了Speak()方法。 现在,结构体DogPet中都有Speak()方法,那么程序会调用哪一个Speak()方法呢?这里就体现出JavaGo的区别了。 Java:由于Dog继承自Pet,而Dog中又实现了Speak()方法,所以在SpeakTo()方法中调用Speak()方法时,会调用子类Dog中的此方法。 Go:由于Go语言没有继承,只有结构体的嵌套,现在虽然是结构体Dog的对象dog来调用SpeakTo()方法,但由于结构体Dog没有此方法,所以会去结构体Pet中调用SpeakTo()方法,在此方法中调用Speak()方法时,自然也是调用的结构体Pet中的Speak()方法,而不是结构体Dog中的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type Pet struct {}

func (p *Pet) Speak() {
    fmt.Printf("...")
}

func (p *Pet) SpeakTo(host string) {
    p.Speak()
    fmt.Println(" ", host)
}

type Dog struct {
    Pet
}

// Speak 结构体Dog重新实现Speak方法
func (d *Dog) Speak() {
    fmt.Printf("wang...")
}

//  func (d *Dog) SpeakTo(host string) {
//     d.Speak()
//     fmt.Println(" ", host)
//  }
func main() {
    //var Dog *Dog = new(Dog)
    //var p = (*Pet)(Dog) // cannot convert Dog (variable of type *Dog) to type *Pet
    //p.SpeakTo("Pet")

    dog := new(Dog)
    dog.SpeakTo("Dog") // ...  Dog(调用的是Pet中的SpeakTo()方法)
}

总结

  • 非匿名嵌套的方式,程序不会去找嵌套结构体中的方法,如果需要访问,需要程序员手动指定。

  • 匿名嵌套的方式,程序会先找结构体自身是否有指定调用的方法,如果没有,再去找其嵌套的结构体中是否有对应的方法。如果嵌套结构体中的方法中又调用了其他方法,则程序也只会在嵌套的结构体中查找是否有该方法,而不会退出嵌套结构体进行查找。(只支持向内查找,不支持向外查找

并发编程

介绍

Go 语言在语言级别内置了强大的并发支持,使得并发编程变得相对容易和高效。Go 语言的并发编程特性主要基于 goroutine 和 channel。以下是 Go 语言中的并发编程的核心概念和技术:

  1. Goroutine
    • Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时系统管理。
    • 你可以通过使用关键字 go 来创建一个新的 goroutine,它会在一个独立的执行线程中运行。
    • Goroutine 之间的切换由 Go 运行时自动处理,而不需要显式的线程管理。
1
2
3
4
5
6
7
8
func main() {
    go doSomething() // 创建并启动一个新的 goroutine
    // 主 goroutine 继续执行其他操作
}

func doSomething() {
    // 在新的 goroutine 中执行的任务
}
  1. Channel
    • Channel 是 Go 语言用于在 goroutine 之间进行通信和同步的机制。
    • 通过 channel,你可以发送和接收数据,以协调多个 goroutine 之间的工作。
    • 使用 make 函数创建 channel,可以指定 channel 的类型。
1
2
3
4
5
6
7
ch := make(chan int) // 创建一个整数类型的 channel

go func() {
    ch <- 42 // 将数据发送到 channel
}()

value := <-ch // 从 channel 接收数据
  1. 同步和等待
    • 使用 sync 包中的工具,如 sync.WaitGroup 来等待 goroutine 完成。
    • sync.Mutexsync.RWMutex 用于实现互斥锁,保护共享数据。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import (
    "sync"
)

func main() {
    var wg sync.WaitGroup

    // 启动多个 goroutine
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 在这里执行一些工作
        }(i)
    }

    // 等待所有 goroutine 完成
    wg.Wait()
}
  1. Select 语句
    • select 语句用于在多个 channel 操作中进行选择,使得你可以实现非阻塞的通信和超时等功能。
    • select 语句允许你在多个通信操作之间选择一个可用的操作执行。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
select {
case msg1 := <-ch1:
    // 处理 ch1 中的消息
case msg2 := <-ch2:
    // 处理 ch2 中的消息
case ch3 <- data:
    // 发送数据到 ch3
default:
    // 当没有通信操作可用时执行的默认操作
}
  1. 原子操作
    • Go 语言提供了 sync/atomic 包,用于执行原子操作,以确保对共享变量的操作是线程安全的。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import (
    "sync/atomic"
)

var counter int32

func increment() {
    atomic.AddInt32(&counter, 1)
}

func main() {
    // 启动多个 goroutine 并并发增加计数器
}

Go 语言的并发模型通过 goroutine 和 channel 简化了并发编程,使得编写安全且高效的并发代码变得容易。然而,正确地管理并发程序仍然需要小心谨慎,以避免竞态条件和死锁等问题。

提前退出Goroutine

我们可以使用runtime.Goexit()来退出当前的Goroutine。

  • return:返回当前函数。
  • os.Exit(-1):退出进程。
  • runtime.Goexit():退出当前Goroutine。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func main() {
    go func() {
       func() {
          fmt.Println("这是子Goroutine内部的函数!")
          runtime.Goexit() // 当前协程直接结束,后面的输出语句不会执行
       }()
       fmt.Println("子Goroutine结束!")
    }()

    fmt.Println("这是主Goroutine!")
    time.Sleep(time.Second)
    fmt.Println("OVER!")
}

单例模式

使用once.Do()控制函数只执行一次。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Singleton struct {}

var singleInstance *Singleton
var once sync.Once

func GetSingletonObj() *Singleton {
    once.Do(func() { // 只执行一次
       fmt.Println("Create Obj")
       singleInstance = new(Singleton)
    })
    return singleInstance
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
       wg.Add(1)
       go func() {
          obj := GetSingletonObj()
          fmt.Printf("%x\n", unsafe.Pointer(obj)) // 可以看到,这里打印的地址值是相同的,说明只创建了一个实例对象。
          wg.Done()
       }()
    }
    wg.Wait()
}

任意一个任务完成

只要任意一个协程完成了任务,就直接返回,其他协程不再继续运行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var numOfRunner = 10

func main() {
    fmt.Println("Number of Goroutine:", runtime.NumGoroutine()) // 查看当前运行的协程数量
    r := FirstResponse()
    fmt.Println(r)
    fmt.Println("Number of Goroutine:", runtime.NumGoroutine()) // 改成带缓冲的管道之后,多个协程被阻塞的问题被解决。
}

func runTask(id int) string {
    time.Sleep(10 * time.Millisecond)
    return fmt.Sprintf("This result is from %d", id)
}

func FirstResponse() string {
    ch := make(chan string, numOfRunner) // 3.解决办法:使用带缓冲的管道,其容量等于numOfRunner,这样就不会造成其他协程的阻塞。
    for i := 0; i < numOfRunner; i++ {
       go func(i int) {
          ret := runTask(i)
          ch <- ret // 2.但这样会造成一个问题,就是其余协程被阻塞在此,导致内存泄漏。
       }(i)
    }
    return <-ch // 1.只要ch中能够读出数据,就不会被阻塞在此,会直接返回。
}

sync.WaitGroup

介绍

sync.WaitGroup 是 Go 语言标准库中的一个同步原语,用于等待一组 Go 协程完成其任务。它通常用于确保在主协程退出之前,所有其他协程都已经完成了它们的工作。下面是 sync.WaitGroup 的详细介绍和使用方法:

sync.WaitGroup 结构体

sync.WaitGroup 是一个结构体,它包含一个整数计数器和一些方法来管理该计数器。主要的方法有:

  1. Add(delta int):增加计数器的值,可以为正数或负数。通常在启动一个新的 Go 协程时调用,以表示有一个新的任务需要等待完成。

  2. Done():减少计数器的值,通常在协程完成其任务时调用,以表示一个任务已经完成。

  3. Wait():等待计数器的值变为零。如果计数器不为零,它将阻塞调用协程,直到计数器归零为止。

原理

image-20231007135735183

使用示例

下面是一个使用 sync.WaitGroup 的示例,说明了如何等待一组协程完成任务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 在协程结束时减少计数器的值

    fmt.Printf("Worker %d is working\n", id)
}

func main() {
    var wg sync.WaitGroup

    numWorkers := 5

    for i := 1; i <= numWorkers; i++ {
        wg.Add(1) // 增加计数器的值,表示有一个新的任务
        go worker(i, &wg)
    }

    wg.Wait() // 等待所有协程完成任务

    fmt.Println("All workers have finished.")
}

在这个示例中,我们创建了一个 sync.WaitGroup 对象 wg,并启动了5个协程来执行 worker 函数。每个协程在工作完成后调用 wg.Done() 来减少计数器的值。最后,我们调用 wg.Wait() 来等待所有协程完成任务,一旦计数器的值变为零,程序将继续执行,输出 “All workers have finished."。

这个示例展示了如何使用 sync.WaitGroup 来等待一组协程完成任务,确保所有工作都已经完成再继续执行后续的代码。这在并发编程中非常有用,特别是在需要等待一组协程完成某项工作时。

互斥锁

互斥锁(Mutex)是 Go 语言标准库中的一种同步原语,用于控制多个协程对共享资源的访问,确保在同一时刻只有一个协程可以访问共享资源,从而避免竞态条件(Race Condition)。下面是互斥锁的详细介绍和使用方法:

互斥锁的结构体

Go 语言中的互斥锁通过 sync 包中的 Mutex 结构体来实现。主要的方法有:

  1. Lock():用于获取锁。如果锁已经被其他协程获取,那么调用 Lock 的协程将被阻塞,直到锁被释放为止。

  2. Unlock():用于释放锁。一旦一个协程完成了对共享资源的访问,应该调用 Unlock 来释放锁,以便其他协程可以获取锁并继续访问共享资源。

使用示例

下面是一个简单的示例,演示了如何在 Go 中使用互斥锁来保护共享资源:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var (
    counter int
    mutex   sync.Mutex // 创建一个互斥锁
)

func increment() {
    mutex.Lock()   // 获取锁
    defer mutex.Unlock() // 在函数退出时释放锁

    counter++
}

func main() {
    numIterations := 1000
    var wg sync.WaitGroup

    for i := 0; i < numIterations; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }

    wg.Wait() // 等待所有协程完成

    fmt.Println("Counter:", counter)
}

在这个示例中,我们创建了一个名为 counter 的共享变量,并使用 sync.Mutex 创建了一个互斥锁 mutex。在 increment 函数中,我们首先通过调用 mutex.Lock() 来获取锁,然后在函数退出时使用 defer mutex.Unlock() 来释放锁。这确保了每次只有一个协程可以递增 counter 变量,避免了竞态条件。

main 函数中,我们启动了多个协程来并发地调用 increment 函数,递增 counter 变量的值。最后,我们等待所有协程完成(使用 sync.WaitGroup),然后输出最终的计数值。

互斥锁是在多协程并发访问共享资源时的一种重要工具,可以确保数据的安全性,防止竞态条件,但要注意,滥用互斥锁可能会导致性能问题,因为只有一个协程可以同时访问被保护的资源。因此,在设计并发程序时,需要权衡锁的使用,确保在必要的时候使用锁,但也不要过度使用。

读写锁

Go 语言中的读写锁(RWMutex)是一种同步原语,它允许多个协程同时读取共享资源,但只允许一个协程写入共享资源。这在某些场景下可以提高并发性能,因为多个协程可以同时读取数据而不需要互斥锁,但当有协程需要写入数据时,需要互斥锁来保护写操作。下面是读写锁的详细介绍和使用方法:

读写锁的结构体

Go 语言中的读写锁通过 sync 包中的 RWMutex 结构体来实现。主要的方法有:

  1. RLock():用于获取读锁。多个协程可以同时获取读锁,只要没有协程持有写锁。如果有协程持有写锁,那么获取读锁的协程将被阻塞,直到写锁被释放。

  2. RUnlock():用于释放读锁。一旦一个协程完成了对共享资源的读取,应该调用 RUnlock 来释放读锁。

  3. Lock():用于获取写锁。当有协程持有读锁或写锁时,获取写锁的协程将被阻塞,直到所有读锁被释放。写锁是排他的,只有一个协程可以获取写锁。

  4. Unlock():用于释放写锁。一旦一个协程完成了对共享资源的写入,应该调用 Unlock 来释放写锁。

使用示例

下面是一个示例,演示了如何在 Go 中使用读写锁来保护共享资源:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    data    map[string]int
    rwMutex sync.RWMutex // 创建一个读写锁
)

func init() {
    data = make(map[string]int)
}

func readData(key string) int {
    rwMutex.RLock() // 获取读锁
    defer rwMutex.RUnlock() // 在函数退出时释放读锁

    return data[key]
}

func writeData(key string, value int) {
    rwMutex.Lock() // 获取写锁
    defer rwMutex.Unlock() // 在函数退出时释放写锁

    data[key] = value
}

func main() {
    numReaders := 5
    numWriters := 2
    var wg sync.WaitGroup

    for i := 0; i < numReaders; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            key := "foo"
            value := readData(key)
            fmt.Printf("Reader: Key=%s, Value=%d\n", key, value)
        }()
    }

    for i := 0; i < numWriters; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            key := "foo"
            value := 42
            writeData(key, value)
            fmt.Printf("Writer: Key=%s, Value=%d\n", key, value)
        }()
    }

    wg.Wait() // 等待所有协程完成
}

在这个示例中,我们创建了一个共享的 data map,然后使用 sync.RWMutex 创建了一个读写锁 rwMutexreadData 函数用于读取共享数据,它首先获取读锁,然后在函数退出时释放读锁。writeData 函数用于写入共享数据,它获取写锁,然后在函数退出时释放写锁。

main 函数中,我们启动了多个读取协程和写入协程,它们可以并发地读取和写入共享数据。由于读操作使用了读锁,多个读取协程可以同时进行,而写操作使用了写锁,只有一个写入协程可以进行。这样可以提高并发性能,同时确保数据的一致性和安全性。

读写锁适用于读多写少的场景,可以有效地降低读操作的竞争,提高程序的性能。但需要小心使用,不要滥用读写锁,否则可能引入复杂性和潜在的问题。

定时任务

只执行一次

使用time.After()time.NewTimer()来实现定时任务。

可以使用timer.Stop()提前中止定时任务,使用timer.Reset()重置定时任务。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
var order = stopOrReset()

func main() {
    //timing() // 两种定时任务的实现
    // 如果提前完成定时要处理的业务, 或者需要调整时间 怎么办?
    // 定时器的中止与重置: timer.Stop() timer.Reset()
    timingWithStopOrReset() // 中止或重置timer
}

func timing() {
    // 方式1:time.After()
    fmt.Println(time.Now().Format("2006-01-02 15:05:05"))
    rTime := <-time.After(time.Second * 3) // time.After()返回一个只读的管道,在阻塞指定的时间后,函数内部向管道中写入一个Time类型的数据,让程序可以继续往下执行。
    // time.After()的返回值是阻塞完成之后的时间值
    fmt.Println("rTime:", rTime.Format("2006-01-02 15:05:05")) // rTime==time.Now()
    fmt.Println(time.Now().Format("2006-01-02 15:05:05"))

    // time.After()内部使用的是:NewTimer(d).C
    // 方式2:time.NewTimer()
    fmt.Println(time.Now().Format("2006-01-02 15:05:05"))
    timer := time.NewTimer(time.Second * 3)
    t := <-timer.C
    fmt.Println(t.Format("2006-01-02 15:05:05"))
}

func timingWithStopOrReset() {
    timer := time.NewTimer(time.Second * 3)
    fmt.Println(time.Now())
    if order == "stop" { // 如果满足条件,则提前终止
       fmt.Println("提前中止...")
       timer.Stop() // 提前中止定时  如果停止了timer,还去拿,就会报错:fatal error: all goroutines are asleep - deadlock!
    } else if order == "reset" {
       fmt.Println("定时重置...")
       timer.Reset(time.Second * 5) // 定时重置为5s
       t := <-timer.C
       fmt.Println(t)
    } else {
       t := <-timer.C
       fmt.Println("时间结束,任务终止...")
       fmt.Println(t)
    }
}

func stopOrReset() string {
    var str string
    _, err := fmt.Scanln(&str)
    if err != nil {
       panic(err)
    }
    return str
}

循环定时任务

采用time.NewTicker()实现循环定时任务。 可以使用time.NewTimer(),每次执行完之后,手动timer.Reset()新的定时就可以了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var (
    wg      sync.WaitGroup
    taskNum = 5
)

func main() {
    newTickerMethod()
    newTimerMethod() // 效果同newTickerMethod
}

func newTickerMethod() {
    ticker := time.NewTicker(time.Second) // 使用time.NewTicker()的方式,每隔1s执行一次定时任务
    count := 0
    wg.Add(1)
    go func() {
       defer wg.Done()
       defer ticker.Stop()
       for {
          count++
          t := <-ticker.C
          fmt.Println(t)
          if count >= taskNum { // 控制定时任务执行的次数
             return
          }
       }
    }()
    wg.Wait()
    fmt.Println("程序结束!")
}

func newTimerMethod() {
    timer := time.NewTimer(time.Second) // 使用time.NewTimer()的方式
    count := 0
    wg.Add(1)
    go func() {
       defer wg.Done()
       defer timer.Stop()
       for {
          count++
          t := <-timer.C
          fmt.Println(t)
          timer.Reset(time.Second) // 重置定时时间
          if count >= taskNum {
             return
          }
       }
    }()
    wg.Wait()
    fmt.Println("程序结束!")
}

time.After 和 time.Sleep的区别

time.Aftertime.Sleep 都涉及到 Go 语言中的时间控制,但它们在用途和行为上有一些关键的区别:

  1. time.After

    • time.After 是一个返回 chan time.Time 的函数,它会在指定的时间间隔之后向通道发送一个时间值。

    • 通常用于在指定的时间间隔后执行某个操作,例如超时控制、定时任务触发等。

    • time.After 不会阻塞当前协程,而是立即返回一个通道,然后你可以通过 select 语句等待通道的值到达,从而触发相关操作。

    • 例如,使用 selecttime.After 来实现一个简单的超时控制:

      1
      2
      3
      4
      5
      
      select {
      case <-time.After(5 * time.Second):
          // 在5秒后执行此代码
          fmt.Println("超时")
      }
  2. time.Sleep

    • time.Sleep 是一个函数,它会导致当前协程休眠指定的时间间隔,让协程暂停执行。

    • 主要用于在协程内部进行等待或暂停,例如执行一些周期性的任务或简单的延迟操作。

    • time.Sleep 会阻塞当前协程的执行,直到指定的时间间隔过去后再继续执行后续代码。

    • 例如,使用 time.Sleep 来实现一个协程内的延迟操作:

      1
      2
      
      time.Sleep(2 * time.Second)
      fmt.Println("2秒后执行此代码")

总结来说,time.After 主要用于在协程之间触发操作,而 time.Sleep 主要用于在协程内部进行休眠或延迟。它们的选择取决于你的具体需求,例如是否需要等待通道触发、是否需要协程内部休眠等。

time.Sleep() 本身是不能中止的,因为它是用于让当前协程休眠指定的时间间隔,而不提供直接的方法来取消休眠。而time.NewTimer()可以使用time.Stop()来中止定时任务。

通道

2023.10.06

介绍

通道(Channel)是Go语言中一种重要的并发编程工具,用于在不同goroutine之间进行通信和同步。通道可以帮助解决多个goroutine之间的数据竞态(Data Race)问题,从而实现安全的并发操作。

channel内部使用了互斥锁来保证并发的安全。

以下是Go语言中通道的详细介绍:

  1. 通道的创建: 你可以使用内置的make函数来创建通道,指定通道的类型。通道可以是带缓冲的或非带缓冲的。带缓冲的通道允许在通道满之前存储一定数量的元素,而非带缓冲的通道则只能在发送和接收操作匹配时才能进行通信。

    1
    2
    
    // 创建一个带缓冲的通道,可以存储3个整数
    ch := make(chan int, 3)
  2. 通道的发送和接收: 使用通道的箭头操作符<-进行数据的发送和接收。发送操作将数据发送到通道,接收操作从通道中接收数据。通道的发送和接收操作是阻塞的,直到另一端准备好。

    1
    2
    3
    4
    5
    
    // 发送数据到通道
    ch <- 42
    
    // 从通道中接收数据
    value := <-ch
  3. 通道的关闭: 通道可以被关闭,以通知接收者不再有数据需要接收。关闭后的通道仍然可以被接收,但不再可以发送数据。关闭通道是一种好的实践,可以避免死锁。

    1
    
    close(ch)
  4. 通道的遍历: 你可以使用range关键字来遍历通道。当通道被关闭时,range会自动退出。

    1
    2
    3
    
    for value := range ch {
        // 处理通道中的数据
    }
  5. 通道的选择: select语句允许你在多个通道操作之间进行选择,以便非阻塞地进行通信。它可以用于处理多个通道的并发操作。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    select {
    case msg1 := <-ch1:
        // 处理从ch1接收到的数据
    case msg2 := <-ch2:
        // 处理从ch2接收到的数据
    case ch3 <- data:
        // 将数据发送到ch3
    default:
        // 如果没有通道准备好,执行默认操作
    }
  6. 通道的同步: 通道还可以用于同步goroutine。通过通道,你可以等待其他goroutine完成某个任务,或者通知其他goroutine开始执行某个任务。

  7. 通道的使用场景: 通道通常用于多个goroutine之间的数据传递和协调,例如生产者-消费者问题、工作池等。

  8. 避免死锁: 在使用通道时要特别小心死锁情况,即一些goroutine在等待某个事件发生,但该事件无法发生。要避免死锁,确保在发送或接收数据时有足够的通信机制。

通道是Go语言并发编程中非常强大的工具,可以安全且有效地处理并发操作。但也需要小心使用,以避免潜在的问题,如死锁或资源竞争。在编写并发代码时,始终遵循最佳实践以确保程序的正确性和性能。

无缓冲通道和带缓冲通道

在Go语言中,通道可以分为无缓冲通道和带缓冲通道,它们有以下主要区别:

  1. 容量:

    • 无缓冲通道(Unbuffered Channel):无缓冲通道没有存储元素的缓冲区,发送操作和接收操作必须同时准备好,否则它们会阻塞,直到双方都准备好。

      下面的代码会造成死锁,因为发送方和接收方无法同时准备好。

      1
      2
      3
      
      intChan := make(chan int)
      intChan <- 13
      <-intChan
    • 带缓冲通道(Buffered Channel):带缓冲通道有一个存储元素的缓冲区,可以存储一定数量的元素。发送操作只要缓冲区未满就不会阻塞,接收操作只要缓冲区不为空就不会阻塞。

  2. 阻塞特性:

    • 无缓冲通道:发送和接收操作都是阻塞的,只有当发送者和接收者都准备好时,数据才会被传递。
    • 带缓冲通道:发送操作只有在缓冲区满时才会阻塞,接收操作只有在缓冲区为空时才会阻塞。
  3. 同步 vs 异步:

    • 无缓冲通道是用于同步goroutine的工具,它强制发送者和接收者在同一时刻准备好,以确保数据的安全传递。这种同步机制使得无缓冲通道适用于实现互斥锁和条件变量等同步原语。
    • 带缓冲通道允许异步操作,发送者和接收者可以在不同时刻准备好。这使得带缓冲通道适用于解耦发送者和接收者的速度,以提高程序的并发性能。
  4. 长时间阻塞:

    • 无缓冲通道可能会导致较长时间的阻塞,因为发送者和接收者必须在同一时刻准备好。
    • 带缓冲通道可以减少某些情况下的长时间阻塞,因为发送者可以向缓冲区发送数据而不等待接收方准备好。
  5. 选择语句:

    • 带缓冲通道与select语句结合使用时可以用于非阻塞地进行通信和同步,而无缓冲通道通常需要直接的发送和接收操作以进行同步。

选择使用无缓冲通道还是带缓冲通道取决于具体的应用场景。通常情况下,无缓冲通道用于强制同步操作,确保数据的安全传递,而带缓冲通道用于提高并发性能和解耦操作的速度。在并发编程中,根据需求选择适当的通道类型非常重要。

通道阻塞

通道在Go语言中会发生阻塞的条件取决于通道的类型和操作,主要有以下几种情况:

  1. 无缓冲通道的阻塞:

    • 发送操作阻塞:如果一个goroutine试图向一个无缓冲通道发送数据,但没有其他goroutine准备好从通道接收数据,发送操作会阻塞,直到有接收者准备好。
    • 接收操作阻塞:如果一个goroutine试图从一个无缓冲通道接收数据,但没有其他goroutine准备好向通道发送数据,接收操作会阻塞,直到有发送者准备好。

    无缓冲通道的阻塞是用来强制发送者和接收者在同一时刻准备好,以确保数据的安全传递。

  2. 带缓冲通道的阻塞:

    • 发送操作阻塞:如果一个goroutine试图向一个已满的带缓冲通道发送数据,发送操作会阻塞,直到有其他goroutine从通道接收数据来释放缓冲空间。
    • 接收操作阻塞:如果一个goroutine试图从一个空的带缓冲通道接收数据,接收操作会阻塞,直到有其他goroutine向通道发送数据。

    带缓冲通道的阻塞是由通道的缓冲区状态决定的。

  3. select语句中的阻塞:

    • 当使用select语句时,如果所有通道操作都不能立即执行,select语句会阻塞,等待其中一个通道操作可以执行。
    • 如果多个通道操作可以立即执行,select会随机选择一个执行。如果有default子句,而其他通道操作都无法立即执行,那么将执行default子句,或者如果没有default子句,则select会阻塞。
  4. 关闭通道的影响:

    • 关闭通道后,可以无阻塞地从通道接收数据。接收操作将从通道中读取所有已经发送的数据,然后返回通道类型的零值。
    • 如果你试图向一个已经关闭的通道发送数据,会导致panic
  5. 被置为nil的管道:

    当管道为nil时,对其读写操作都会阻塞,但不会导致panic

总的来说,通道阻塞的主要条件涉及发送和接收操作,以及通道的状态(有缓冲或无缓冲)。在并发编程中,要特别小心处理阻塞情况,以避免死锁和其他并发问题。使用select语句可以帮助你在多个通道操作之间进行选择,以实现非阻塞的通信和同步。

内存泄漏

当通道读写次数不对等时,会出现内存泄漏的问题。如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func main() {
    intChan := make(chan int, 5)
    go func() {
       for i := 0; i < 30; i++ { // 写入30次,但只读了20次,管道容量为5,导致该协程一直被阻塞,造成内存泄漏。
          intChan <- i
          fmt.Println("写入:", i)
       }
       close(intChan) // 写完之后,关闭管道
    }()

    go func() {
       for i := 0; i < 20; i++ {
          fmt.Println("等待读...")
          data := <-intChan
          fmt.Println("<---读出:", data)
       }
    }()
    time.Sleep(time.Second * 4)
}

for-range读取通道

使用for-range的方式可以防止读写次数不一致的问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func main() {
    intChan := make(chan int, 5)
    go func() {
       for i := 0; i < 30; i++ {
          intChan <- i
          fmt.Println("写入:", i)
       }
       close(intChan) // 写完之后,关闭管道
    }()

    go func() {
       for v := range intChan {
          fmt.Println("<---读出:", v)
       }
    }()
    time.Sleep(time.Second * 4)
}

判断通道是否关闭

如果对于未关闭的空管道进行读取,则会被一直阻塞。

如果对已经关闭的空管道进行读取,会返回零值和是否成功的标识(和判断mapkey是否存在相同,这种方式被称为ok-idom模式)

单向通道

单向通道(One-way Channel)是一种通道,限制了通道的发送或接收操作的方向,使其只能用于发送或接收数据,而不能同时用于发送和接收。单向通道通常用于在函数参数中传递通道,以强制函数的行为。

在Go语言中,你可以将一个双向通道转换为单向通道,但不能将一个单向通道转换回双向通道。单向通道有两种类型:只能发送的通道和只能接收的通道。

以下是单向通道的示例和说明:

  1. 只能发送的通道(Send-Only Channel):

    • 定义方式:chan<- T,其中 T 是通道中元素的类型。
    • 只能用于发送数据到通道,不能用于接收数据。
    • 示例:
    1
    2
    3
    4
    5
    6
    
    func sendData(ch chan<- int) {
        ch <- 42
    }
    
    ch := make(chan int)
    go sendData(ch)
  2. 只能接收的通道(Receive-Only Channel):

    • 定义方式:<-chan T,其中 T 是通道中元素的类型。
    • 只能用于从通道接收数据,不能用于发送数据。
    • 示例:
    1
    2
    3
    4
    5
    6
    
    func processData(ch <-chan int) {
        data := <-ch
    }
    
    ch := make(chan int)
    go processData(ch)

单向通道的主要优势在于它们可以用于强制函数的行为。例如,如果你希望一个函数只能向通道发送数据而不能从通道接收数据,可以将该通道声明为只能发送的通道类型,从而确保函数不会意外地从通道接收数据。

需要注意的是,一旦将通道转换为单向通道类型,就只能在该类型的方向上执行操作,不再能够进行双向的发送和接收操作。这种限制有助于编写更安全和可维护的代码。

双向通道的参数可以传递给单向通道,但是不能反过来。

select

介绍

select 是Go语言中用于处理多个通道操作的控制结构。它允许在多个通道之间进行非阻塞的选择,等待其中一个通道准备好并执行相应的操作。select 语句通常用于协调并发操作,处理I/O多路复用以及实现超时等场景。

以下是 select 的详细介绍和使用方法:

  1. 基本语法

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    select {
    case <-channel1:
        // 处理 channel1 的操作
    case data := <-channel2:
        // 处理 channel2 的操作,同时将数据存储在变量 data 中
    case channel3 <- value:
        // 向 channel3 写入数据 value
    default:
        // 如果没有通道准备好,则执行 default 分支(可选)
    }
    • select 语句包含多个 case 分支,每个分支对应一个通道操作或默认操作。
    • 如果其中一个通道准备好了(可读或可写),则相应的分支将被执行。
    • 如果没有通道准备好,且存在 default 分支,那么 default 分支将被执行。
  2. 用途

    • select 常用于处理多个通道的非阻塞操作,例如等待多个goroutine完成工作或选择最快响应的通道。
    • 可用于实现超时机制,等待某个通道操作一定时间,然后执行其他操作。
    • 在实现I/O多路复用时,可以使用 select 监听多个网络或文件描述符上的事件。
    • select 还用于避免goroutine泄漏或死锁,可以通过通道的关闭来通知其他goroutine停止运行。
  3. 示例

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    func main() {
        ch1 := make(chan int)
        ch2 := make(chan int)
    
        go func() {
            time.Sleep(1 * time.Second)
            ch1 <- 42
        }()
    
        go func() {
            time.Sleep(2 * time.Second)
            ch2 <- 99
        }()
    
        select {
        case value := <-ch1:
            fmt.Println("Received from ch1:", value)
        case value := <-ch2:
            fmt.Println("Received from ch2:", value)
        case <-time.After(3 * time.Second):
            fmt.Println("Timeout")
        }
    }

    在这个示例中,select 等待 ch1ch2 准备好,同时还设置了一个3秒的超时。一旦其中一个通道准备好,相应的 case 分支将被执行。如果3秒内没有任何通道准备好,那么超时分支将被执行。

总之,select 语句是Go语言中强大的并发控制工具,它允许在多个通道之间选择,并且可以用于解决各种并发编程的问题。通过合理使用 select,可以实现高效、安全的并发操作。

随机性

在Go语言中,select 语句用于处理通道(Channel)的多路选择,通常用于等待多个通道中的其中一个完成。在 select 语句中,如果多个通道都准备好了(可以进行读取或写入操作),那么Go语言会随机选择一个可用的通道来执行操作。

这种随机性是由Go运行时调度器实现的,它会在多个可用通道之间进行随机选择,以确保公平性和避免死锁。这也意味着程序员无法精确地控制 select 语句的选择顺序,它是不确定的。

以下是一个简单的示例,演示了 select 的随机性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(1 * time.Second)
		ch1 <- "Channel 1"
	}()

	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- "Channel 2"
	}()

	select {
	case msg1 := <-ch1:
		fmt.Println(msg1)
	case msg2 := <-ch2:
		fmt.Println(msg2)
	}
}

在这个示例中,有两个goroutine分别向两个通道 ch1ch2 发送消息。然后,select 语句随机选择一个可用通道,并打印相应的消息。由于通道的操作时间不确定,所以你可能会看到不同的输出顺序,这取决于哪个通道先准备好。

总之,select 语句的随机性是Go语言中的一种特性,它确保了在多个通道之间进行公平的选择,但程序员不能控制选择的顺序。这种不确定性可以帮助避免死锁和提高并发性能。

select阻塞

select{}是一个没有任何caseselect,会一直阻塞导致死锁。

1
2
3
func main() {
    select {}
}

判断通道是否阻塞

1
2
3
4
5
6
var ch chan int = make(chan int,5)
select {
    case ch<- data:
    	fmt.Println("add syccess")
    default:  // channel满了
}

select timeout模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main(){
    result := make(chan string)
	go func() {
    	//模拟网络访问
    	time.Sleep(8* time.Second)
		result <-"服务端结果"
	}()
    select {
    case V := <-result:
		fmt.Println(v)
	case <-time.After(5* time.Second):
        fmt.Println("网络访问超时了")
    }
}

对象池

使用带缓冲的管道实现对象池。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
type ReusableObj struct {}

type ObjPool struct {
    bufChan chan *ReusableObj
}

func NewObjPool(numOfObj int) *ObjPool {
    objPool := ObjPool{}
    objPool.bufChan = make(chan *ReusableObj, numOfObj)
    for i := 0; i < numOfObj; i++ {
       objPool.bufChan <- &ReusableObj{}
    }
    return &objPool
}

func (p *ObjPool) GetObj(timeout time.Duration) (*ReusableObj, error) {
    select {
    case ret := <-p.bufChan:
       return ret, nil
    case <-time.After(timeout): // 超时控制
       return nil, errors.New("time out")
    }
}

func (p *ObjPool) ReleaseObj(obj *ReusableObj) error {
    select {
    case p.bufChan <- obj:
       return nil
    default:
       return errors.New("overflow")
    }
}

func main() {
    pools := NewObjPool(10)

    for i := 0; i < 10; i++ { // 在不放回的情况下,如果取的数量超过了连接池中连接的数量,会报超时的错误。
       if v, err := pools.GetObj(time.Second); err != nil {
          panic(err)
       } else {
          fmt.Println("v:", unsafe.Pointer(v))

          // 对连接进行的操作....

          if err := pools.ReleaseObj(v); err != nil { // 操作完成后,将连接放回连接池
             panic(err)
          }
       }
    }
    fmt.Println("Done")
}

在使用时不是一定会提高程序的性能,因为需要考虑带缓冲的管道带来的锁的机制,为了保证线程安全的同步机制对性能的影响。

sync包

介绍

sync 包是 Go 语言标准库中提供的用于并发编程的包,它包含了一些用于同步和互斥操作的工具,帮助开发者处理并发的问题。以下是 sync 包的主要组件和功能的详细介绍:

  1. 互斥锁(sync.Mutex

    • sync.Mutex 是一个互斥锁类型,用于保护共享资源,以防止多个协程同时访问或修改。

    • 你可以使用 Lock 方法锁定互斥锁,以阻止其他协程访问受保护的资源,使用 Unlock 方法解锁互斥锁,以允许其他协程访问资源。

    • 互斥锁适用于临界区的互斥访问,以确保数据的一致性和可靠性。

  2. 读写锁(sync.RWMutex

    • sync.RWMutex 是一个读写锁类型,它支持多个协程同时读取共享资源,但只允许一个协程写入共享资源。

    • 你可以使用 RLock 方法锁定读写锁以进行读取操作,使用 RUnlock 方法解锁读写锁,以允许其他协程进行读取操作。

    • 使用 Lock 方法锁定读写锁以进行写入操作,使用 Unlock 方法解锁读写锁,以允许其他协程进行读取或写入操作。

  3. 条件变量(sync.Cond

    • sync.Cond 是一个条件变量类型,它允许协程等待某个条件的发生,并在条件满足时被唤醒。

    • Cond 通常与互斥锁一起使用,以确保条件检查和等待的操作是线程安全的。

    • 你可以使用 Wait 方法等待条件的发生,使用 Signal 方法唤醒一个等待的协程,使用 Broadcast 方法唤醒所有等待的协程。

  4. 等待组(sync.WaitGroup

    • sync.WaitGroup 是一个等待组类型,它用于等待一组协程完成它们的任务。

    • 你可以使用 Add 方法增加等待组的计数器,使用 Done 方法减少计数器,使用 Wait 方法等待计数器变为零。

    • 等待组通常用于等待一组协程完成并发操作,然后继续执行其他任务。

  5. 原子操作(sync/atomic 包)

    • sync/atomic 包提供了一组原子操作函数,用于在多个协程之间安全地进行原子操作,例如原子增加、减少、交换等。

    • 原子操作可以用于实现锁、计数器等各种并发数据结构。

  6. 一些其他工具

    • sync 包还提供了一些其他用于同步的工具和函数,例如 Once 用于执行某个函数一次,Map 用于安全地并发访问映射等。

总之,sync 包是 Go 语言标准库中提供的用于并发编程的基本工具集,它帮助开发者处理并发、同步和互斥的问题,确保多个协程之间的正确协作和数据安全。使用这些工具可以更容易地编写并发安全的 Go 代码。

sync.Pool

  • sync.Pool 是用于池化临时对象的类型。它可以用于重用分配的对象,以减少内存分配和垃圾回收的开销。

  • 你可以使用 Get 方法从池中获取对象,使用 Put 方法将对象放回池中。

  • sync.Pool 通常用于提高性能,特别是在需要频繁分配和释放对象的场景中,例如连接池、临时缓冲区等。

  • 示例:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    
    var objectPool = sync.Pool{
        New: func() interface{} {
            return "New Object"
        },
    }
    
    func main() {
        obj := objectPool.Get().(string)
        fmt.Println(obj)
    
        // 将对象放回池中
        objectPool.Put(obj)
    
        // 再次获取对象,应该是之前放回的对象
        obj = objectPool.Get().(string)
        fmt.Println(obj)
    }

sync.Pool对象获取

  • 尝试从私有对象获取
  • 私有对象不存在,尝试从当前 Processor 的共享池获取
  • 如果当前 Processor 共享池也是空的,那么就尝试去其他Processor 的共享池获取
  • 如果所有子池都是空的,最后就用用户指定的 New 函数产生一个新的对象返回

sync.Pool对象的生命周期

  • GC会清除 sync.pool 缓存的对象
  • 对象的缓存有效期为下一次 GC之前

总结

  • 适合于通过复用,降低复杂对象的创建和 GC代价
  • 协程安全,会有锁的开销
  • 生命周期受 GC 影响,不适合于做连接池等,需自己管理生命周期的资源的池化

sync.Cond

sync.Cond 有三个方法

  • Wait,阻塞当前协程
  • Signal,唤醒一个等待时间最长的协程
  • Broadcast,唤醒所有等待的协程

sync.Cond 是 Go 语言标准库中的一个条件变量(condition variable)类型,用于实现更复杂的同步和通信模式。条件变量允许协程在某个条件满足时等待或被唤醒,通常与互斥锁(sync.Mutex)一起使用,以实现协程之间的协作。以下是对 sync.Cond 的详细介绍:

  1. 创建 sync.Cond 对象

    使用 sync.NewCond 函数可以创建一个新的 sync.Cond 对象。通常,它与一个互斥锁一起使用,以确保在操作条件变量之前和之后的临界区是安全的。

    1
    2
    
    mu := sync.Mutex{}
    c := sync.NewCond(&mu)
  2. 等待条件满足

    协程可以使用 c.Wait() 方法来等待某个条件满足。当协程调用 c.Wait() 时,它会进入休眠状态,直到其他协程通过 c.Signal()c.Broadcast() 唤醒它。

    1
    2
    3
    4
    5
    
    c.L.Lock()
    for !condition {
        c.Wait()
    }
    c.L.Unlock()
  3. 唤醒等待的协程

    通过 c.Signal() 方法可以唤醒一个等待的协程,而通过 c.Broadcast() 方法可以唤醒所有等待的协程。这些方法通常在某些条件满足时由其他协程调用。

    1
    2
    3
    4
    
    c.L.Lock()
    condition = true
    c.Broadcast() // 或者使用 c.Signal() 唤醒单个等待的协程
    c.L.Unlock()
  4. 注意事项

    • 在使用 sync.Cond 时,通常需要将条件检查和等待的操作包装在互斥锁的临界区内,以确保条件检查和等待操作的原子性。

    • 在等待之前和之后,必须使用互斥锁锁定和解锁条件变量。

    • sync.Cond 通常用于多个协程之间的同步和通信,以实现更复杂的协作模式,例如生产者-消费者模式。

    • 请小心避免死锁和竞争条件,确保正确使用互斥锁和条件变量来保护共享资源。

sync.Cond 是 Go 语言标准库提供的一个强大工具,用于协程之间的协作和同步。它通常用于处理更复杂的并发问题,例如等待某些条件满足或等待多个协程协作完成某项任务。使用 sync.Cond 需要仔细设计和实现,以确保正确性和性能。

sync.Map

sync.Map 在 Go 1.9 中引入,是 Go 语言标准库中提供的一种用于并发安全的键值存储的数据结构。它在多个协程之间可以安全地进行读写操作,而无需额外的锁操作,因此在某些并发性能要求较高的场景中非常有用。下面是对 sync.Map 的详细介绍:

  1. 并发安全性

    sync.Map 是并发安全的,可以在多个协程之间进行并发读写操作而不会发生竞态条件。这是因为它内部使用了一些特殊的数据结构和锁机制来实现并发安全性。

  2. 无需显式的锁

    与传统的 map 不同,sync.Map 不需要在每个操作之前显式地加锁或解锁。这意味着你可以在不同的协程中读写 sync.Map 而无需担心锁的管理。

  3. 键值对的存储和检索

    你可以使用 Load 方法来检索键对应的值,使用 Store 方法来存储键值对。LoadStore 方法是 sync.Map 中的核心方法。

  4. 删除键值对

    你可以使用 Delete 方法来删除指定键的键值对。

  5. 迭代

    sync.Map 提供了 Range 方法,可以用于迭代键值对。它接受一个回调函数作为参数,在每个键值对上调用回调函数。

  6. 零值

    sync.Map 的零值是一个可以直接使用的空 map。不需要额外的初始化。

  7. 适用场景

    sync.Map 适用于读多写少的场景,因为在写操作时仍然会有一些开销,但读操作可以并发执行而不需要锁。这使得它特别适合于高并发的读操作,例如缓存。

下面是一个简单的示例,演示了如何使用 sync.Map

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "fmt"
    "sync"
)

func main() {
    var m sync.Map

    // 存储键值对
    m.Store("key1", "value1")
    m.Store("key2", "value2")

    // 读取键值对
    if value, ok := m.Load("key1"); ok {
        fmt.Println("key1:", value)
    }

    // 删除键值对
    m.Delete("key2")

    // 遍历键值对
    m.Range(func(key, value interface{}) bool {
        fmt.Printf("Key: %v, Value: %v\n", key, value)
        return true
    })
}

请注意,虽然 sync.Map 在某些情况下非常有用,但在其他情况下,普通的 map 加锁或其他并发数据结构可能更适合你的需求。在选择数据结构时,需要根据具体的并发需求和性能特点来决定。

内置测试框架

单元测试

Go语言提供了一个内置的测试框架,使得编写单元测试变得非常容易。单元测试用于验证你的代码的各个部分是否按照预期工作。以下是进行Go语言单元测试的一般步骤:

  1. 创建测试文件:创建一个以_test.go为后缀的测试文件,例如mycode_test.go。这个文件应该与要测试的代码文件位于同一个目录中。

  2. 导入testing包:在测试文件中导入testing包,这是Go语言的测试框架。

  3. 编写测试函数:在测试文件中编写以Test开头的测试函数,这些函数的签名为func TestXxx(t *testing.T),其中Xxx是你要测试的函数或方法的名称,t *testing.T是测试的上下文对象。

  4. 使用t.Errort.Fail来报告测试失败:在测试函数中,使用t.Errort.Fail来报告测试是否失败。如果测试失败,你可以使用这些函数输出有关失败的信息。

  5. 运行测试:使用go test命令来运行测试。可以在项目的根目录或包含测试文件的目录中运行此命令。Go会自动识别和运行测试文件中的测试函数,并生成测试报告。

以下是一个示例的测试文件和测试函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package mycode

import (
	"testing"
)

func MyFunction(x int, y int) int {
	return x + y
}

func TestMyFunction(t *testing.T) {
	result := MyFunction(2, 3)
	if result != 5 {
		t.Errorf("Expected 5, but got %d", result)
	}
}

要运行这个测试,你可以在终端中使用以下命令:

1
go test

Go会自动运行测试文件中的测试函数,并输出测试结果。如果测试通过,将显示一个通过的消息;如果测试失败,将显示失败的消息和详细的错误信息。

你可以编写多个测试函数来覆盖代码的不同路径和边界条件,确保代码在各种情况下都能正常工作。单元测试对于保持代码的稳定性和可维护性非常重要。如果你的代码库规模较大,还可以考虑使用表格驱动测试来更有效地组织测试用例。

错误类型

Fail,Error:该测试失败,该测试继续,其他测试继续执行。

FailNow,Fatal: 该测试失败,该测试中止,其他测试继续执行。

在Go语言的单元测试中,通常使用testing.T对象的方法来报告和处理错误。testing.T对象提供了以下用于处理错误的方法:

  1. t.Error(args ...interface{}):报告测试失败,但继续执行后续测试。

    例如:

    1
    
    t.Error("This is an error message")
  2. t.Errorf(format string, args ...interface{}):报告测试失败,并提供格式化的错误消息,但继续执行后续测试。

    例如:

    1
    
    t.Errorf("Expected %d, but got %d", expected, actual)
  3. t.Fail():报告测试失败,但继续执行后续测试。

    例如:

    1
    2
    3
    
    if condition {
        t.Fail()
    }
  4. t.FailNow():报告测试失败并立即终止当前测试函数的执行。不会执行后续的测试代码。

    例如:

    1
    2
    3
    
    if condition {
        t.FailNow()
    }
  5. t.Fatal(args ...interface{}):报告测试失败并立即终止当前测试函数的执行,不会执行后续的测试代码。

    例如:

    1
    
    t.Fatal("This is a fatal error")
  6. t.Fatalf(format string, args ...interface{}):报告测试失败并提供格式化的错误消息,然后立即终止当前测试函数的执行,不会执行后续的测试代码。

    例如:

    1
    
    t.Fatalf("Expected %d, but got %d", expected, actual)

这些方法允许你在测试函数中处理错误,报告不符合预期的情况,以及提供详细的错误信息。通常情况下,使用ErrorErrorfFailFailNow 来报告测试失败,并继续执行其他测试用例,而使用 FatalFatalf 来报告测试失败并立即终止测试函数的执行。

请注意,测试函数中的错误报告将被捕获并汇总到测试报告中,以便你可以轻松地查看所有失败的测试用例和错误消息。这有助于快速识别和修复问题。

查看单元测试覆盖率

1
go test --coverprofile main.cover -v ./ 

查看未覆盖的代码

1
go tool cover -html main.cover -o main.html

这个命令会生成一个html文件,打开这个html文件,可以看到为被测试覆盖的代码片段。

基准测试

在Go语言中,你可以使用内置的testing包来进行基准测试(Benchmarking)。基准测试是一种评估代码性能的方法,它可以帮助你测量一段代码在不同输入条件下的性能表现。

下面是如何进行基准测试的一般步骤:

  1. 创建一个以_test.go为文件后缀的测试文件,例如mycode_test.go

  2. 在测试文件中导入testing包,并定义一个以Benchmark开头的函数,例如BenchmarkMyFunction,该函数的参数类型为*testing.B

  3. Benchmark函数中,编写需要测试性能的代码,并使用b.N来指示要运行多少次测试循环,以便得到准确的性能指标。

  4. 使用b.ResetTimer()在每次循环之前重置计时器,以避免初始化代码的计时干扰性能测量。

  5. 运行基准测试时,使用go test -bench=.命令,其中.表示当前目录下的所有测试文件。

以下是一个基准测试的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package mycode

import (
	"testing"
)

func MyFunction(input int) int {
	// 假设这里有一个需要测试性能的函数
	return input * 2
}

func BenchmarkMyFunction(b *testing.B) {
	// 在Benchmark函数中编写需要测试性能的代码
	for i := 0; i < b.N; i++ {
		// 在这里调用需要测试性能的函数
		result := MyFunction(42)
		_ = result // 防止编译器优化未使用的结果
	}
}

要运行这个基准测试,你可以在终端中使用以下命令:

1
go test -bench=.

其中,-bench=.表示运行当前目录下的所有基准测试。运行后,你将看到一些性能测量信息,包括每次迭代的平均时间和迭代次数。这些信息可以帮助你评估代码在不同输入条件下的性能表现。

请注意,基准测试的结果可能受多个因素影响,包括硬件、操作系统和Go运行时的版本。因此,建议在相同的环境中多次运行基准测试,以获得更稳定的结果。基准测试通常用于优化代码性能,因此在性能关键的部分进行测试非常有用。

测试的命令行参数

在Go语言中,testing包支持一系列命令行参数,可以用于控制和配置单元测试的行为。以下是一些常见的测试命令行参数以及它们的描述:

  1. -v--verbose:以详细模式(verbose mode)运行测试,显示每个测试函数的执行结果以及测试函数的名称。这个参数通常用于查看更多关于测试执行的详细信息。

    例如:

    1
    
    go test -v
  2. -run:指定一个正则表达式来选择要运行的测试函数。只有匹配指定正则表达式的测试函数才会被运行。

    例如:

    1
    
    go test -run=TestMyFunction

    这个命令会运行名称中包含 “TestMyFunction” 的测试函数。

  3. -bench:运行基准测试,指定一个正则表达式来选择要运行的基准测试函数。只有匹配指定正则表达式的基准测试函数才会被运行。

    例如:

    1
    
    go test -bench=.

    这个命令会运行当前目录下所有的基准测试函数。

  4. -benchmem:在运行基准测试时,同时报告内存分配的信息。这个参数允许你查看基准测试函数的内存分配情况。

    例如:

    1
    
    go test -bench=YourBenchmarkFunction -benchmem

    这个命令会运行指定的基准测试函数,并报告内存分配信息。

  5. -count:指定要运行的测试次数。默认情况下,测试运行一次,但你可以使用 -count 参数来指定多次运行测试以更好地评估性能。

    例如:

    1
    
    go test -count=10

    这个命令会运行测试函数 10 次。

  6. -cover:运行测试并报告代码覆盖率信息,显示哪些代码行被测试覆盖,哪些没有被覆盖。

    例如:

    1
    
    go test -cover
  7. -coverprofile:将代码覆盖率信息输出到指定文件,以供进一步分析和报告。

    例如:

    1
    
    go test -coverprofile=coverage.out
  8. -covermode:指定代码覆盖模式,可以是 set, count, 或 atomic。默认是 set 模式。

    例如:

    1
    
    go test -covermode=count

这些命令行参数允许你在运行测试时对测试的执行方式进行自定义和控制,以便获取更多关于测试和代码覆盖率的信息。在编写和运行单元测试时,这些参数是非常有用的工具。

easyjson

介绍

easyjson 是一个用于生成高性能 JSON 编解码的 Go 语言库。它通过生成专用的编码和解码函数,来避免了标准库 encoding/json 中的反射,从而提高了 JSON 操作的速度。下面是关于 easyjson 库的详细介绍:

  1. 性能优化easyjson 的主要目标是提供比标准库更高性能的 JSON 编解码。它通过代码生成来创建专用的编码和解码函数,而不是使用反射。这使得 easyjson 在序列化和反序列化 JSON 数据时更快速。

  2. 代码生成easyjson 利用 Go 的代码生成工具,生成专用的编码和解码函数。为了生成这些函数,你需要为你的数据结构编写一些特殊的注释。然后,easyjson 工具会基于这些注释来生成高效的 JSON 编解码代码。

  3. 与标准库的兼容性easyjson 的 API 设计与标准库的 encoding/json 包非常相似,因此可以轻松地替换标准库的 JSON 操作。你可以在不修改原有代码的情况下,将 easyjson 用于现有的项目。

  4. 支持命令行工具easyjson 提供了一个命令行工具,用于自动生成 JSON 编解码代码。你只需运行 easyjson 工具,并指定你的数据结构,它将自动生成相应的代码文件。

  5. 可选性easyjson 是一个可选库,你可以选择性地在需要性能优化的地方使用它。对于某些场景,标准库的 encoding/json 也许足够了,但在需要更高性能的情况下,可以考虑切换到 easyjson

总之,easyjson 是一个用于提高 JSON 编解码性能的库,通过代码生成和专用的函数来实现。如果你的应用需要频繁地进行 JSON 操作,并且性能是关键因素,那么 easyjson 可能是一个不错的选择。你可以通过查看 easyjson 的文档和示例来深入了解如何使用它来提高 JSON 操作的性能。

使用

要使用 easyjson 库来生成高性能的 JSON 编解码代码,你需要按照以下步骤进行设置和使用:

  1. 安装 easyjson 工具: 首先,你需要安装 easyjson 工具。你可以使用以下命令来安装它:

    1
    
    go get -u github.com/mailru/easyjson/...
  2. 为数据结构添加注释: 对于你想要进行 JSON 编解码的数据结构,你需要为它们添加特定的注释。easyjson 工具会根据这些注释生成编解码代码。例如:

    1
    2
    3
    4
    5
    6
    
    //easyjson:json
    type Person struct {
        Name  string `json:"name"`
        Age   int    `json:"age"`
        Email string `json:"email"`
    }

    在上面的示例中,我们为 Person 结构体添加了 //easyjson:json 注释,并为字段添加了 JSON 标签。

  3. 生成代码: 在你的项目目录中,使用 easyjson 工具生成 JSON 编解码代码。运行以下命令:

    1
    
    easyjson -all your_structs.go

    这将生成一个名为 your_structs_easyjson.go 的文件,其中包含了生成的编解码函数。

  4. 使用生成的代码: 一旦生成了代码,你可以像使用标准库的 encoding/json 一样使用 easyjson 生成的编解码函数。例如:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    // 导入生成的 easyjson 文件
    import (
        "your_package/your_structs"
    )
    
    // 编码
    person := your_structs.Person{Name: "Alice", Age: 30, Email: "alice@example.com"}
    encoded, err := person.MarshalJSON()
    if err != nil {
        panic(err)
    }
    fmt.Println(string(encoded))
    
    // 解码
    var decoded your_structs.Person
    err = decoded.UnmarshalJSON(encoded)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Decoded: %+v\n", decoded)

    你需要确保正确导入生成的 easyjson 文件,并且使用生成的编解码函数来处理 JSON 数据。

这样,你就可以使用 easyjson 生成的高性能 JSON 编解码代码来提高 JSON 数据的序列化和反序列化性能。记住,你只需要为需要性能优化的结构添加 //easyjson:json 注释,并运行 easyjson 工具来生成代码。

RESTful API

RESTful API(Representational State Transfer API)是一种基于HTTP协议的Web服务架构风格,它通过URL来表示资源,通过HTTP方法来执行对这些资源的操作。RESTful API 是一种设计和开发Web服务的规范,旨在提供一种简单、可扩展、易于理解和使用的方式来进行网络通信。

以下是 RESTful API 的详细介绍:

  1. 资源(Resource)

    • 在RESTful API中,资源是系统中的实体,可以是物理对象(如用户、订单、产品)或虚拟概念(如会话、权限)。
    • 资源由唯一的标识符(通常是URL)来表示。例如,一个用户资源可以用URL /users/1 来表示,其中 1 是用户的唯一标识符。
  2. HTTP方法

    • RESTful API 使用HTTP方法来执行对资源的操作,主要有以下几种常用的HTTP方法:
      • GET:用于获取资源的信息,通常用于读取操作。
      • POST:用于创建新的资源,通常用于写入操作。
      • PUT:用于更新资源的信息,通常用于替换操作。
      • PATCH:用于部分更新资源的信息。
      • DELETE:用于删除资源。
  3. 状态无关性(Statelessness)

    • RESTful API 是状态无关的,每个请求都包含足够的信息以便服务器能够理解并处理它,而不依赖于之前的请求。这使得API的设计更加简单和可扩展。
  4. 使用HTTP状态码

    • RESTful API 使用HTTP状态码来表示请求的结果,包括成功(例如200 OK)、资源未找到(例如404 Not Found)、权限问题(例如401 Unauthorized)等。
    • HTTP状态码使得客户端能够更好地理解请求的结果,并采取适当的行动。
  5. URL设计

    • RESTful API的URL应该具有清晰的结构,容易理解和记忆。通常,URL的路径部分表示资源的层次结构,而查询参数用于过滤和排序资源。
  6. 数据格式

    • RESTful API通常使用标准的数据格式来表示资源,如JSON或XML。JSON已经成为最常见的选择,因为它轻量、易于解析和生成,并且具有广泛的支持。
  7. 版本控制

    • 为了确保API的兼容性和稳定性,通常需要在API的URL中包含版本信息,例如/v1/users表示API的第一个版本的用户资源。
  8. 认证和授权

    • RESTful API通常需要认证和授权机制,以确保只有授权的用户能够访问某些资源或执行某些操作。常见的认证方式包括基本认证、令牌认证和OAuth。
  9. 安全性

    • RESTful API需要考虑安全性问题,包括防止SQL注入、跨站点脚本(XSS)攻击、跨站点请求伪造(CSRF)等。
  10. 文档和描述

    • 提供清晰的API文档和描述是非常重要的,以便开发者能够理解和正确使用API。通常,Swagger、OpenAPI等工具可用于生成API文档。

总的来说,RESTful API是一种用于构建Web服务的强大而普遍的架构风格,它的设计原则包括资源、HTTP方法、状态无关性、清晰的URL结构等。合理地设计和实现RESTful API可以帮助开发者构建高性能、可扩展和易于维护的Web服务。

染色标记的GC

介绍

Go语言的垃圾回收器(Garbage Collector,GC)使用的是基于 Tri-Color Mark-and-Sweep 算法的垃圾回收策略。其中,“染色标记” 是 Tri-Color 算法的一部分,用于标记对象的不同状态,以确定哪些对象是活动的(reachable)和哪些对象是垃圾(garbage)。

以下是关于 Go 语言中染色标记 GC 的详细介绍:

  1. Tri-Color 状态: Go 语言的 GC 将对象分为三种状态:

    • 白色(White):表示未访问的对象。最初,所有对象都被标记为白色。
    • 灰色(Gray):表示对象已经被访问,但它的引用还没有完全检查。灰色对象通常保存在一个队列中。
    • 黑色(Black):表示对象已经被访问,并且其引用也已经检查完毕。黑色对象是活动的对象。
  2. 标记阶段: Go 的 GC 周期通常分为两个阶段:标记阶段和清理阶段。在标记阶段,GC 遍历整个对象图,从根对象出发,递归地标记所有可达的对象为灰色或黑色,直到没有灰色对象为止。这个过程称为标记。

  3. 染色标记: 在标记阶段,灰色对象被遍历和检查,它的引用也被标记为灰色。这意味着如果一个对象引用了其他对象,那么被引用的对象也会被标记为灰色,等待进一步的检查。这一过程反复执行,直到所有可达对象都被标记为黑色。

  4. 清理阶段: 在标记阶段完成后,Go 的 GC 进入清理阶段。在这个阶段,GC 回收那些未被标记为黑色的对象,也就是被判定为垃圾的对象。这些垃圾对象会被释放,内存得以回收。

  5. 并发标记: Go 1.5 引入了并发标记,允许标记和清理同时进行,从而减少了停顿时间。这种并发标记的方式称为并发标记(Concurrent Marking)。

  6. 自适应调整: Go 的 GC 还采用了自适应调整策略,根据实际应用程序的需求来调整垃圾回收的行为,以尽量减少停顿时间。

Go 语言的染色标记 GC 以其高效的性能和低停顿时间而闻名。它的设计允许在并发应用程序中高效地管理内存,而不会引发大的停顿,使得 Go 成为了一种非常适合构建 Web 服务和大规模应用程序的语言。

GC友好的代码

避免内存分配和复制

  • 复杂对象尽量传递引用
    • 数组的传递
    • 结构体传递
  • 初始化至合适的大小
    • 自动扩容是有代价的
    • 复用内存

打开 GS 日志

只要在程序执行之前加上环境变量 GODEBUG=gctrace=1

如: GODEBUG=gctrace=1 go test -bench=.

GODEBUG=gctrace=1 go run main.go

日志详细信息参考:https://godoc.org/runtime image-20231006200733051

go tool trace

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main]
import (
	"os"
    "runtime/trace"
)

func main() {
    ferr := os.Create("trace.out")
    if err != nil {
        panic(err)
    }
	defer f.Close()
    
	err = trace.Start(f)
    if err != nil {
        panic(err)
    }
    defer trace.Stop()
    // Your program here
}

测试程序输出 trace 信息:go test -trace trace.out 可视化 trace 信息:go tool trace trace.out

Error的判断

自定义error

1
2
3
4
5
6
7
8
type commonError struct{
    errorCode int // 错误码
	errorMsg string // 错误信息
}

func (ce *commonError) Error() string{
    return ce.errorMsg
}

使用自定义error

1
2
3
4
return 0, &commonError{
    errorCode: 1,
    errorMsg: "a或者b不能为负数",
}

error 断言

有了自定义的error,并且携带了更多的错误信息后,就可以使用这些信息了,先把返回的error 接口转换为自定义的错误类型(类型断言)

1
2
3
4
5
6
sum, err := add(-1,2)
if cm,ok:=err.(*commonError);ok{
    fmt.Println("错误代码为:",cm.errorCode,"错误信息为:",cm.errorMsg)
} else {
	fmt.Println(sum)
}

Error Wrapping

嵌套error

1
2
3
e := errors.New("原始错误e")
w := fmt.Errorf("Wrap了一个错误:%w", e)
fmt.Println(w)

errors.Unwrap函数

Go 语言提供了errors.Unwrap 用于获取被嵌套的 error

1
fmt.Println(errors.Unwrap(w))  // ---> 原始错误e

当存在error的嵌套时,使用err==os.ErrExist将无法判断两个error是否相等。那么就需要使用errors.Is 函数。

errors.Is 函数

func Is(err, target error) bool

  • 如果err target 是同一个,那么返回 true
  • 如果err 是一个 wrapping errortarget 也包含在这个嵌套error链中的话,也返回 true

errors.As函数

有了error 嵌套后,error 断言也不能用了。因为你不知道一个error 是否被嵌套,又嵌套了几层。

1
2
3
4
5
6
var cm*commonError
if errors.As(err,&cm) {
    fmt.Println("错误代码为:", cm.errorCode,",错误信息为:", cm.errorMsg)
} else {
    fmt.Println(sum)
}

切片底层

底层表示

在 Go 语言中,切片(slice)是一种引用类型,它是对底层数组的一个动态视图或引用。切片本身并不包含实际的数据,它只包含了以下三个部分的信息:

  1. 指向底层数组的指针(Pointer):切片包含一个指向底层数组的起始位置的指针。这个指针指向切片中的第一个元素。

  2. 长度(Length):切片中的元素数量。

  3. 容量(Capacity):切片的容量是底层数组中从切片的起始位置到底层数组末尾的元素数量。容量表示切片可以包含的最大元素数量。

切片的底层数据结构可以表示为以下形式:

1
2
3
4
5
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}
  • Data 是指向底层数组的指针,通常是一个无符号整数指针(uintptr)。
  • Len 是切片的长度,表示切片中实际包含的元素数量。
  • Cap 是切片的容量,表示底层数组中可以容纳的最大元素数量。

这种底层结构的使用方式使得切片在内存中非常轻量,因为它只是对底层数组的引用,并不需要复制数据。当你对切片进行修改时,实际上是在修改底层数组的相应元素。

切片本质是 SliceHeader,又因函数的参数是值传递所以传递的是 SliceHeader 副本,内存占用非常少。

切片基于指针的封装是它效率高的根本原因,因为可减少内存的占用,以及减少内存复制时的时间消耗。

切片的动态性质使得它们在处理不确定数量的元素时非常有用,因为你可以在运行时动态调整切片的长度和容量,而不必关心底层数组的大小。这使得切片成为 Go 语言中一种强大且常用的数据结构。

扩容机制

在 Go 语言中,切片的扩容(capacity growth)机制是**在切片的底层数组容量不足以容纳新元素时,自动创建一个更大的底层数组,并将原有的元素复制到新数组中。**切片的扩容是 Go 语言的一个重要特性,它允许切片在运行时动态增加容量,以适应需要。以下是切片扩容机制的一些关键点:

  1. 初始容量:当你创建一个切片时,可以指定切片的初始容量。如果不指定初始容量,切片的容量默认为 0。

    1
    
    s := make([]int, 0, 10) // 初始容量为 10
  2. 自动扩容:当你向切片追加元素(使用 append 函数)时,如果切片的长度超过了当前容量,Go 语言会自动扩容切片,将原有的元素复制到一个新的更大的底层数组中。新容量通常会比原容量大,具体扩容策略如下:

    • 如果当前容量小于 1024,新容量会变为原容量的两倍。
    • 如果当前容量大于或等于 1024,新容量会变为原容量的 1.25 倍。

    这个自动扩容机制保证了切片的动态性能和内存使用的合理性。

  3. 扩容的时间复杂度:由于底层数组的复制,切片的扩容操作的时间复杂度是 O(n),其中 n 是切片中的元素数量。这意味着随着切片中元素数量的增加,扩容操作会变得更加耗时。因此,在需要频繁进行元素追加的场景下,可以考虑提前分配足够大的初始容量,以减少扩容次数。

  4. 原地扩容:切片的扩容是在原切片上进行的,这意味着扩容后的切片和原切片共享相同的底层数组。这也是为什么在追加元素后,原切片可能会发生变化的原因。

  5. 使用 copy 函数:如果你希望创建一个与原切片不共享底层数组的新切片,可以使用 copy 函数将原切片的元素复制到新切片中,然后对新切片进行操作。

切片的自动扩容机制使得切片在处理不确定数量的元素时非常灵活。但要注意,由于扩容操作涉及底层数组的复制,频繁的扩容操作可能会引入性能开销。因此,在需要追加大量元素时,最好提前分配足够的初始容量,以减少扩容次数。

字符串相关

与其他主要编程语言的差异

  1. string 是数据类型,不是引用或指针类型

  2. string 是只读的 byte slice,len 函数可以得到它所包含的 byte 数

  3. string的 byte 数组可以存放任何数据

Unicode UTF8

  1. Unicode 是一种字符集 (code point)
  2. UTF8 是unicode 的存储实现(转换为字节序列的规则)
  1. Unicode编码:
    • Unicode是一个字符集,包含了世界上几乎所有的字符、符号和表情。
    • 在Go语言中,字符串是以Unicode字符编码的,这意味着你可以在字符串中包含来自各种语言的字符,包括非拉丁字符、表情符号等。
    • Go语言中的字符串类型是UTF-8编码的,因此字符串可以包含多字节的Unicode字符。
  2. UTF-8编码:
    • UTF-8是一种变长字符编码,用于将Unicode字符表示为字节序列。UTF-8编码采用不同长度的字节序列来表示不同的Unicode字符,通常使用1到4个字节。
    • Go语言中的字符串是以UTF-8编码的,这意味着一个字符可以由一个或多个字节组成。例如,英文字符通常由一个字节表示,而某些非英文字符可能需要多个字节来表示。
    • UTF-8编码具有可变长度的特性,这使得它能够有效地表示各种Unicode字符,同时节省存储空间。

编码与存储

字符 “中”
Unicode 0x4E2D
UTF-8 0xE4B8AD
string / []byte [0xE4, 0xB8, 0xAD]

拼接字符串

常见的几种方式

在Go语言中,有几种常用的字符串拼接方式,包括:

  1. 使用 + 运算符: 你可以使用 + 运算符来连接两个字符串。这是最简单的字符串拼接方式,但在循环中频繁使用它可能会导致性能问题,因为每次拼接都会创建一个新的字符串对象。

    1
    2
    3
    
    str1 := "Hello, "
    str2 := "World!"
    result := str1 + str2
  2. 使用 fmt.Sprintf 函数fmt.Sprintf 函数允许你使用格式化字符串来拼接多个值,然后返回一个新的字符串。

    虽然它是一种方便的方法来构建格式化字符串,但在大规模字符串拼接的情况下,它通常比直接使用 + 运算符、strings.Builderbytes.Buffer 略慢一些。主要的性能差异在于 fmt.Sprintf 在每次调用时都会创建一个新的字符串。

    1
    2
    3
    
    str1 := "Hello"
    str2 := "World"
    result := fmt.Sprintf("%s, %s!", str1, str2)

    这种方式的效率也比较低。

  3. 使用 strings.Join 函数strings.Join 函数用于连接字符串切片,并且性能较好,因为它内部使用了缓冲区。

    1
    2
    
    strs := []string{"Hello", "World"}
    result := strings.Join(strs, ", ")
  4. 使用 bytes.Buffer 缓冲区bytes.Buffer 是一个缓冲区,允许你高效地拼接字符串。它在处理大量字符串拼接时通常比其他方法更快。

    1
    2
    3
    4
    
    var buffer bytes.Buffer
    buffer.WriteString("Hello, ")
    buffer.WriteString("World!")
    result := buffer.String()
  5. 使用 strings.Builder(Go 1.10及更高版本): strings.Builder 是Go 1.10引入的,类似于bytes.Buffer,但专门用于字符串拼接,具有更好的性能。

    1
    2
    3
    4
    
    var builder strings.Builder
    builder.WriteString("Hello, ")
    builder.WriteString("World!")
    result := builder.String()

请根据你的需求和性能要求选择合适的字符串拼接方式。在大多数情况下,strings.Joinbytes.Buffer(或strings.Builder)是较好的选择,特别是在需要频繁拼接大量字符串时。避免在循环中使用 + 运算符,因为它会导致不必要的内存分配和性能损失。

strings.Builder和bytes.Buffer的区别

在Go语言中,strings.Builderbytes.Buffer都用于字符串的拼接,但它们在某些方面有一些区别。下面是它们之间的主要区别以及哪种方式的效率更高:

  1. 用途

    • strings.Builder 是Go 1.10版本引入的,专门用于构建字符串。它提供了一种更安全、更高效的方式来拼接字符串。
    • bytes.Buffer 是一个通用的字节缓冲区,可以用于操作任意字节数据,包括字符串。虽然你也可以用它来拼接字符串,但相对于strings.Builder而言,它更通用。
  2. 性能

    • strings.Builder 被专门优化用于字符串的构建,因此在处理大量字符串拼接时通常比bytes.Buffer更高效。它减少了内存分配的开销,因为它不会处理字节级别的数据。
    • bytes.Buffer 在大多数情况下性能也不错,但在构建大量字符串时可能会略微慢一些,因为它更通用,需要处理字节数据。
  3. 易用性

    • strings.Builder 更符合语义,因为它的名称明确表明了其用途。它仅限于字符串的拼接,因此在代码中更易于理解。
    • bytes.Buffer 更通用,可以处理不同类型的数据,但可能需要更多的类型转换。
  4. 版本要求

    • strings.Builder 是Go 1.10及更高版本引入的。如果你使用较旧版本的Go,则不支持strings.Builder,此时可以使用bytes.Buffer

总的来说,如果你的代码主要涉及字符串的拼接操作,推荐使用strings.Builder,因为它是为这种用途而专门设计的,并且在性能上更出色。但如果你需要处理不同类型的数据,或者使用的Go版本不支持strings.Builder,那么bytes.Buffer仍然是一个可行的选择,它在大多数情况下也表现良好。最终的选择取决于你的特定需求和代码的兼容性要求。

字符串的复制

在对字符串进行复制时,只要不修改新的字符串的值,则它们底层指向的还是同一块内存。

当对新的字符串进行修改时,才会在内存中开辟新的内存,让新变量的uintptr指针指向新的内存区域。

这样在参数传递的时候,就具备了如下两个特点:

  1. 既可以表现为值的拷贝(修改新的变量值时不会导致原变量的修改)

  2. 又节省了内存消耗(其实传递的还是StringHeader结构体(包含Data和Len字段),不管字符串有多长,占用的内存消耗是不变的。只要不修改新的变量值,两个变量所指向的还是同一快内存区域)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func main() {
    s1 := "hello"
    fmt.Printf("s1中Data的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s1)).Data)
    s2 := s1                                                                           	   // 将s1赋值给s2
    fmt.Printf("s2中Data的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data)
    // 上面的结果为:s1中Data的内存地址和s2相同
    // 可以看到,在对字符串进行复制时,只要不修改新的字符串的值,则它们底层指向的还是同一块内存。
    s2 = "world"
    fmt.Printf("s2中Data的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data)
    // 这时候s2中Data的内存地址发生了变化
    // 当对新的字符串进行修改时,才会在内存中开辟新的内存,让s2的uintptr指针指向新的内存区域。
    fmt.Println(s1, s2)

    // 为了验证上面的结论,这里用函数参数传递来进行试验:
    testStr := "hello"
    fmt.Printf("testStr中Data的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&testStr)).Data)  // 88074f
    notChangeStringValue(testStr) // 88074f
    changeStringValue(testStr)    // 880754
}

func notChangeStringValue(s string) {
    // 只要函数中不修改s的值,s和testStr底层指向的就是同一块内存区域
    fmt.Printf("notChangeStringValue s中Data的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
}

func changeStringValue(s string) {
    s = "world"
    // 如果函数中修改了s的值,则会开辟新的内存,让s指向新的内存地址
    fmt.Printf("changeStringValue s中Data的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
}

context

context 包是 Go 语言标准库中用于处理并发操作和协程之间的上下文传递的重要工具。context 用于解决在协程之间传递上下文信息、控制取消操作、设置截止时间等问题。以下是对 context 包的详细介绍:

  1. 背景(Background)

    context.Background() 返回一个空的上下文,通常作为所有其他上下文的父上下文。这是一个无值的上下文,不携带任何信息。

    1
    
    ctx := context.Background()
  2. 取消上下文(WithCancel)

    context.WithCancel(parent) 函数创建一个带有取消功能的上下文。当调用取消函数时,该上下文以及所有派生的上下文都会被取消。

    1
    2
    3
    
    parent := context.Background()
    ctx, cancel := context.WithCancel(parent)
    defer cancel() // 在不再需要上下文时调用取消函数
  3. 超时上下文(WithTimeout 和 WithDeadline)

    • context.WithTimeout(parent, timeout) 创建一个带有超时功能的上下文,指定了超时时间。

    • context.WithDeadline(parent, deadline) 创建一个带有截止时间功能的上下文,指定了截止时间点。

    1
    2
    3
    4
    5
    
    parent := context.Background()
    ctx, cancel := context.WithTimeout(parent, 5*time.Second)
    // 或者
    ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))
    defer cancel()
  4. 值上下文(WithValue)

    context.WithValue(parent, key, value) 可以用于在上下文中传递键值对信息。这对于在协程之间传递请求ID、用户信息等数据非常有用。

    1
    2
    
    parent := context.Background()
    ctx := context.WithValue(parent, "key", "value")
  5. 上下文的传递和派生

    你可以从一个上下文派生出另一个上下文,派生的上下文可以继承父上下文的取消、截止时间和键值对信息。

    1
    2
    
    parent := context.Background()
    ctx, _ := context.WithTimeout(parent, 5*time.Second)
  6. 取消信号和取消函数

    上下文的取消操作通常是通过调用 context.WithCancelcontext.WithTimeoutcontext.WithDeadline 创建的上下文的取消函数来触发的。取消函数会关闭上下文的 Done 通道,通知相关协程取消操作已发生。

    1
    2
    3
    4
    5
    6
    7
    8
    
    ctx, cancel := context.WithCancel(context.Background())
    go func() {
        select {
        case <-ctx.Done():
            // 上下文取消操作已触发
        }
    }()
    cancel() // 手动触发取消操作
  7. 上下文的传递和传播

    上下文可以传递给协程的函数,然后可以使用传递的上下文来监控和控制协程的行为。ctx.Done() 通道可用于监听取消操作。

    1
    2
    3
    4
    5
    6
    
    func someFunction(ctx context.Context) {
        select {
        case <-ctx.Done():
            // 上下文取消操作已触发
        }
    }
  8. 取消链

    多个上下文可以形成一个取消链,当任何一个上下文被取消时,其派生的所有上下文都将被取消。

    1
    2
    
    parent := context.Background()
    ctx, _ := context.WithCancel(parent)
  9. 上下文的使用场景

    context 主要用于以下场景:

    • 在 HTTP 处理中控制请求的生命周期。
    • 在数据库操作中控制查询的超时。
    • 在协程池中控制协程的启动和停止。
    • 在 RPC 调用中传递请求 ID 或跟踪信息。
    • 在测试中模拟上下文以进行单元测试。

总结来说,context 包是 Go 语言中用于管理协程之间的上下文传递和控制取消操作的强大工具。它可以帮助你构建健壮、可控制的并发代码,并管理协程之间的协作和通信。在编写需要并发处理的程序时,了解和正确使用 context 包非常重要。

image-20231007184530157

Context 使用原则

  • Context 不要放在结构体中,要以参数的方式传递
  • Context作为函数的参数时要放在第一位,也就是第一个参数
  • 要使用 context.Background 函数生成根节点的 Context也就是最顶层的 Context
  • Context 传值要传递必须的值,而且要尽可能地少,不要什么都传
  • Context 多协程安全,可以在多个协程中放心使用

判断结构体是否实现了指定的接口

在 Go 语言中,你可以使用类型断言(type assertion)和反射(reflection)来判断某个结构体是否实现了指定的接口。以下是两种常见的方法:

  1. 类型断言

    使用类型断言可以检查一个值是否实现了指定的接口。如果实现了,类型断言返回一个对应的接口值,否则会触发运行时 panic。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    type MyInterface interface {
        SomeMethod()
    }
    
    type MyStruct struct{}
    
    func (s MyStruct) SomeMethod() {
        // 实现接口方法
    }
    
    func main() {
        var myInterface MyInterface
        myInterface = MyStruct{}
    
        // 使用类型断言检查是否实现了接口
        if value, ok := myInterface.(MyInterface); ok {
            myInterface = value
            // myStruct 实现了 MyInterface
        } else {
            // myStruct 没有实现 MyInterface
        }
    }

    使用类型断言可以安全地检查结构体是否实现了指定的接口,并将其转换为接口类型。

  2. 反射

    反射是 Go 语言的一种高级特性,它允许你在运行时检查类型信息。你可以使用反射来判断一个值是否实现了指定的接口。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    import "reflect"
    
    type MyInterface interface {
        SomeMethod()
    }
    
    type MyStruct struct{}
    
    func (s MyStruct) SomeMethod() {
        // 实现接口方法
    }
    
    func main() {
        myStruct := MyStruct{}
        interfaceType := reflect.TypeOf((*MyInterface)(nil)).Elem()
    
        if reflect.TypeOf(myStruct).Implements(interfaceType) {
            // myStruct 实现了 MyInterface
        } else {
            // myStruct 没有实现 MyInterface
        }
    }

    使用反射可以动态地检查类型信息,但需要注意,反射的使用通常比较复杂,性能较差,应该谨慎使用。

一般来说,使用类型断言是一种更常见和更好的方法来判断一个结构体是否实现了指定的接口,因为它更直观且性能较好。反射通常用于处理更复杂的情况,例如根据配置动态加载模块或解析未知类型的数据。

unsafe包

介绍

unsafe 包是 Go 语言标准库中的一个特殊包,它提供了一些用于与底层系统、C 语言代码交互以及执行不安全操作的函数和工具。unsafe 包的使用需要格外小心,因为它允许绕过 Go 语言的类型安全和内存安全机制,可能导致潜在的内存错误和安全漏洞。以下是 unsafe 包的一些主要功能和用法:

  1. unsafe.Pointer 类型

    • unsafe.Pointerunsafe 包中最重要的类型之一。它是一种通用的指针类型,可以用于存储任何类型的指针。使用 unsafe.Pointer 可以执行指针类型之间的不安全转换。

    • 例如,你可以将一个指向 int 类型的指针转换为 float64 类型的指针,并通过该指针访问不同类型的数据。这种操作是不安全的,应该谨慎使用。

  2. uintptr 类型

    • uintptr 是一个整数类型,用于表示指针的整数值。它常用于将指针转换为整数以进行计算,然后再将整数值转回指针。

    • 使用 uintptr 进行指针操作同样需要非常小心,因为它会绕过 Go 语言的垃圾回收和类型检查。

  3. unsafe.Sizeof 函数

    • unsafe.Sizeof(x) 函数返回变量 x 占用的内存字节数。这个函数通常用于计算结构体和数据类型的大小。

    • 例如,可以使用 unsafe.Sizeof 来获取某个结构体的大小。

  4. unsafe.Offsetof 函数

    • unsafe.Offsetof(x) 函数返回结构体 x 中某个字段的偏移量(以字节为单位)。这个偏移量表示该字段相对于结构体的起始位置的偏移。

    • 通常用于计算结构体中字段的内存偏移量。

  5. unsafe.Alignof 函数

    • unsafe.Alignof(x) 函数返回变量 x 的对齐方式。对齐方式是变量存储在内存中的位置要求,通常是字节的倍数。

    • 用于获取变量的对齐方式,以便进行内存分配和布局的计算。

  6. 与 C 语言交互

    • unsafe 包通常用于与 C 语言代码交互,因为 C 语言不受 Go 语言的类型系统和内存管理机制的限制。

    • 通过 unsafe 包,可以将 Go 语言中的数据类型转换为 C 语言中的对应数据类型,并在 Go 语言和 C 语言之间传递数据。

虽然 unsafe 包在某些情况下是必要的,但它涉及到非常危险的操作,容易引入内存错误和安全漏洞。因此,在正常的 Go 代码中,应尽量避免使用 unsafe 包,除非你非常清楚自己在做什么,并且有明确的理由需要执行不安全的操作。在使用 unsafe 包时,应格外小心,并遵循最佳实践以确保代码的可靠性和安全性。

unsafe.Pointer

unsafe.Pointer 函数是 Go 语言中的一个特殊函数,它用于进行指针类型之间的不安全转换。unsafe.Pointer 的作用是将一个指针从一个类型转换为另一个类型,这个操作通常被称为指针类型的强制转换。使用 unsafe.Pointer 可以绕过 Go 语言的类型系统,但潜在地引入了不安全的行为,因此应该小心谨慎使用。

unsafe.Pointer 的主要用途包括以下几个方面:

  1. 转换指针类型:你可以使用 unsafe.Pointer 将一个指向一个类型的指针转换为指向另一个类型的指针,即使这两个类型之间没有直接的关联。

  2. 与 C 语言交互:在与 C 语言代码交互时,可能需要将 Go 语言中的数据结构转换为 C 语言中的对应数据结构,或者反之。unsafe.Pointer 可以用于执行这些类型之间的指针转换。

  3. 绕过类型系统:在某些情况下,你可能需要直接操作内存中的数据而不受 Go 语言的类型检查限制。unsafe.Pointer 允许你绕过类型检查,但要格外小心,以避免潜在的内存安全问题。

虽然 unsafe.Pointer 在某些情况下是必需的,但它是一种强大的工具,应该谨慎使用。不正确的使用 unsafe.Pointer 可能会导致内存访问错误、数据损坏和安全漏洞。因此,只有在必要的情况下,且确保了安全性的前提下,才应该使用 unsafe.Pointer 进行指针类型的转换。在正常的 Go 代码中,应尽量避免使用 unsafe 包。

uintptr

uintptr 是 Go 语言标准库中的一种整数类型,它用于表示指针的整数值。uintptr 的主要作用是执行指针和整数之间的转换,通常用于以下几个场景:

  1. 指针和整数之间的转换

    • uintptr 类型可以用于将指针转换为整数,以及将整数转换为指针。这些转换是不安全的操作,因为它们绕过了 Go 语言的类型安全和内存安全机制。因此,使用 uintptr 进行指针和整数之间的转换需要非常小心。

    • 示例:将指针转换为 uintptr 类型:

      1
      2
      3
      
      var x int
      ptr := &x
      uintptrValue := uintptr(unsafe.Pointer(ptr))
    • 示例:将 uintptr 转换回指针类型:

      1
      2
      3
      
      x := 42
      uintptrValue := uintptr(unsafe.Pointer(&x))
      ptr := (*int)(unsafe.Pointer(uintptrValue))
  2. 在计算中使用 uintptr

    • uintptr 类型通常用于在计算中执行指针的加法、减法和位运算等操作。这可以用于实现一些低级的内存操作,但需要谨慎处理。

    • 示例:在计算中使用 uintptr 进行指针操作:

      1
      2
      3
      4
      
      var arr [3]int
      ptr := &arr[0]
      offset := 2 * unsafe.Sizeof(arr[0]) // 计算偏移量
      newPtr := uintptr(unsafe.Pointer(ptr)) + offset
  3. Go 和 C 语言交互

    • 在与 C 语言代码交互时,可能需要将 Go 语言中的数据类型转换为 C 语言中的对应数据类型,或者反之。uintptr 可以用于进行指针类型的转换。

    • 示例:将 Go 语言指针转换为 C 语言指针:

      1
      2
      3
      4
      5
      6
      
      import "C"
      
      func CallCFunction() {
          cPtr := C.myCFunction((*C.int)(unsafe.Pointer(ptr)))
          // 调用 C 函数并传递 C 语言指针
      }

需要注意的是,uintptr 类型的使用是危险的,因为它绕过了 Go 语言的类型安全和内存安全机制。在正常的 Go 代码中,应尽量避免使用 uintptr,除非你清楚自己在做什么,并且有明确的理由需要执行不安全的操作。使用 uintptr 进行指针和整数之间的转换需要格外小心,以避免内存错误和安全漏洞。在使用 uintptr 时,应遵循最佳实践,确保代码的可靠性和安全性。

举例

string和slice的底层表示方式 unsafe.Pointer和uintptr的介绍

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func main() {
    //1. string的底层表示
    //string的底层表示为StringHeader结构体:(可以通过reflect.StringHeader进行查看)
    //type StringHeader struct {
    // Data uintptr
    // Len  int
    //}
    //指向底层字符数据的指针(Data):这个指针指向一个连续的内存区域,其中存储了字符串的 UTF-8 编码的字符序列。
    //长度(Len):表示字符串中字符的数量。
    //
    //2. slice的底层表示
    //slice的底层表示为SliceHeader结构体:(可以通过reflect.SliceHeader进行查看)
    //type SliceHeader struct {
    // Data uintptr
    // Len  int
    // Cap  int
    //}
    //指向底层数组的指针(Data):这个指针指向切片中的第一个元素。
    //长度(Len):表示切片中的元素数量。
    //容量(Cap):表示切片可以包含的最大元素数量。
    //
    //3. unsafe.Pointer
    //`unsafe.Pointer` 的作用是将一个指针从一个类型转换为另一个类型,这个操作通常被称为指针类型的强制转换。
    a := 10
    fmt.Println(&a)
    //fmt.Println((*float64)(&a)) // 直接强转,报错:cannot convert &a (value of type *int) to type *float64
    fmt.Println((*float64)(unsafe.Pointer(&a))) // 加上unsafe.Pointer之后,就可以进行转换了。

    //4. uintptr
    //`uintptr` 的主要作用是执行指针和整数之间的转换,这样就可以在计算中执行指针的加法、减法和位运算等操作。
    var arr = [3]int{3, 2, 1}
    ptr := &arr[0]
    fmt.Println("ptr:", ptr)        // ptr: 0xc000010108
    offset := unsafe.Sizeof(arr[0]) // 计算偏移量,由于int在64位系统中占用64位(8字节),所以offset为8
    // 指针 ---> 整数:uintptr()
    newPtr := uintptr(unsafe.Pointer(ptr)) + offset
    fmt.Printf("newPtr:%x\n", newPtr) // newPtr:c000010110

    // uintptr ---> 指针:使用unsafe.Pointer()
    fmt.Println(*(*int)(unsafe.Pointer(newPtr))) // 使用unsafe.Pointer,将newPtr转换为*int类型的指针,然后取出指针的值
}

string和[]byte的互转

优化之前:

Go语言通过先分配一个内存再复制内容的方式,实现string和[]byte之间的强制转换。

小提示:可通过查看runtime.stringtoslicebyte 和runtime.slicebytetostring 两函数的源代码了解关于string和[]byte类型互转的具体实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
    s := "你好,世界"
    fmt.Printf("s的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data) // 查看string的Data部分的地址
    b := []byte(s)
    fmt.Printf("b的内存地址:%x\n", (*reflect.SliceHeader)(unsafe.Pointer(&b)).Data)
    s2 := string(b)
    fmt.Printf("s2的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s2)).Data)
    fmt.Println(s, string(b), s2)
    // 从上面的结果可以看出(三个变量的内存地址都不相同), string和[]byte在进行互转时,会重新分配内存。
    // []byte(s)和 string(b) 强制转换会重新拷贝一份字符串字符串过大,内存开销大,对高性能要求的程序来说,就无法满足了,需性能优化。
    // 优化思路:不重新申请内存(让string和[]byte的Data(类型为uintptr)部分指向同一块内存)。
}

优化之后:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
    s := "你好,世界"
    b := []byte(s)
    s2 := string(b)
    fmt.Println(s, string(b), s2)

    // 优化思路:不重新申请内存(让string和[]byte的Data(类型为uintptr)部分指向同一块内存)。

    // []byte ---> string
    fmt.Printf("b的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&b)).Data)
    s3 := *(*string)(unsafe.Pointer(&b)) // s4没有申请新的内存(零拷贝) 字节切片--->string(由于slice的底层表示是兼容string的(都有Data和Len字段),所以可以直接强转)
    fmt.Printf("s2的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s3)).Data)
    // b 和 s3 的Data部分内存地址相同

    // string ---> []byte
    s = "你好,世界"
    fmt.Printf("s的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&s)).Data)
    sh := (*reflect.SliceHeader)(unsafe.Pointer(&s))  // 由于slice的底层比string的底层多了一个Cap字段,所以需要先转为SliceHeader的指针,设置其Cap值,然后再转为[]slice的指针
    sh.Cap = sh.Len
    b1 := *(*[]byte)(unsafe.Pointer(sh))
    fmt.Printf("b1的内存地址:%x\n", (*reflect.StringHeader)(unsafe.Pointer(&b1)).Data)
    // s 和 b1 的Data部分内存地址相同
}

使用strings.Builder

在Go语言中,可以使用strings.Builder来进行string[]byte之间的转换。strings.Builder是一个用于构建字符串的高效方式,可以用于构建string,然后将其转换为[]byte,或者从[]byte构建string

以下是使用strings.Builder进行string[]byte[]bytestring的转换的示例:

  1. string[]byte的转换:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
	"fmt"
	"strings"
)

func main() {
	str := "Hello, World!"

	// 使用 strings.Builder 构建 []byte
	var builder strings.Builder
	builder.WriteString(str)
	byteSlice := []byte(builder.String())

	fmt.Printf("String: %s\n", str)
	fmt.Printf("Byte Slice: %v\n", byteSlice)
}
  1. []bytestring的转换:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
	"fmt"
	"strings"
)

func main() {
	byteSlice := []byte{72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33}

	// 使用 strings.Builder 构建 string
	var builder strings.Builder
	builder.Write(byteSlice)
	str := builder.String()

	fmt.Printf("Byte Slice: %v\n", byteSlice)
	fmt.Printf("String: %s\n", str)
}

在上述示例中,我们使用strings.BuilderWriteString方法将string添加到Builder,然后使用BuilderString方法将其转换为[]bytestring,具体取决于需求。

请注意,如果需要频繁地进行string[]byte之间的转换,使用strings.Builder可以提高性能,因为它避免了不必要的内存分配和拷贝操作。

堆内存和栈内存

介绍

栈内存

由编译器自动分配和释放,开发者无法控制,一般存储函数中的局部变量、参数等,函数创建时,这些内存会被自动创建函数返回时,这些内存会被自动释放。

堆内存

生命周期比栈内存长,如函数返回值还在其他地方使用,那这个值会被编译器自动分配到堆上,不能自动被编译器释放,只能通过垃圾回收器释放,所以栈内存效率会很高。

在Go语言中,内存分为两种主要的存储区域:栈内存(stack memory)和堆内存(heap memory)。这两种内存区域具有不同的特性和用途。

  1. 栈内存(Stack Memory):

    • 栈内存用于存储函数的局部变量和函数调用的上下文信息。
    • 栈内存是有限的,通常较小,其大小在程序启动时就已经确定,并且是固定的。
    • 栈内存的分配和释放非常快速,因为它仅涉及移动栈指针。
    • 栈内存的生命周期与函数的执行周期相关,当函数执行完成时,局部变量会被自动销毁,栈内存被释放。
    • 栈内存通常用于存储基本数据类型、指针以及较小的数据结构。
  2. 堆内存(Heap Memory):

    • 堆内存用于动态分配和管理内存,通常用于存储对象、数据结构等大型或动态分配的数据。
    • 堆内存的大小不受限制,可以根据需要动态增长,但它的分配和释放相对较慢。
    • 堆内存的生命周期通常由程序员手动控制,需要显式地分配和释放内存,否则可能导致内存泄漏。
    • 在Go语言中,内建的newmake函数用于在堆上分配内存,例如new(int)make([]int, 10)
    • 堆内存通常用于存储大型数据结构、切片、映射、结构体等。

在Go语言中,内存管理由垃圾回收器(garbage collector)负责,它会自动识别并回收不再被引用的堆内存,从而避免内存泄漏问题。这让Go程序员可以更专注于代码编写,而不用过多担心手动内存管理的细节。

总结一下,栈内存和堆内存在Go语言中具有不同的特性和用途。栈内存用于存储函数的局部变量和短期数据,生命周期短,分配和释放快。堆内存用于存储大型或动态分配的数据,生命周期由程序员控制,分配和释放相对较慢。 Go的垃圾回收器有助于管理堆内存,减少了手动内存管理的复杂性。

逃逸分析

Go语言的逃逸分析(Escape Analysis)是一种编译器优化技术,用于确定变量的生命周期是否逃逸到堆上或栈上。逃逸分析是Go编译器在编译代码时进行的一项重要工作,它有助于优化内存分配和性能。

逃逸分析的主要目标是确定:

  1. 变量是否逃逸到堆上:如果一个变量在函数执行完毕后仍然可以被访问到(如返回一个指向该变量的指针),则编译器可能会将该变量分配到堆上,以确保其生命周期超出函数的范围。
  2. 变量是否可以分配在栈上:如果变量的生命周期仅限于函数的范围内,而没有逃逸到堆上,编译器可以将其分配在栈上,以避免堆分配和垃圾回收的开销。

逃逸分析对于Go语言的性能和内存效率非常重要,因为它可以:

  • 避免不必要的堆分配:将对象分配到堆上需要额外的内存和垃圾回收开销。通过将变量分配在栈上,可以减少这种开销。
  • 提高内存局部性:在栈上分配的数据通常在内存中是连续的,这有助于提高内存局部性,从而提高访问效率。
  • 降低垃圾回收负担:减少堆分配可以降低垃圾回收的频率和开销。

逃逸分析通常在编译过程中自动执行,无需开发人员手动干预。然而,开发人员可以使用工具来检查代码的逃逸情况,以帮助识别和优化性能瓶颈。

例如,可以使用Go语言的 -gcflags="-m" 编译选项来启用逃逸分析的打印输出,以查看函数中哪些变量逃逸到堆上。这对于优化和调试代码非常有用。

1
go build -gcflags="-m -l" ./ch19/main.go

总之,Go语言的逃逸分析是一项重要的编译器优化技术,有助于提高程序的性能和内存效率,减少不必要的内存分配和垃圾回收开销。它是Go语言内存管理的关键组成部分。

小技巧:从逃逸分析看,指针虽然可减少内存的拷贝但同样会引起逃逸,所以要根据实际情况选择是否使用指针。

优化技巧

  • 尽可能避免逃逸,因为栈内存效率更高还不用GC,如小对象的传参,array要比slice效果好。

  • 如避免不了逃逸,还在堆上分配了内存,那对于频繁内存申请操作要学会重用内存,如使用sync.Pool。

  • 选用合适的算法,达到高性能目的,如空间换时间。

    性能优化的时候,要结合基准测试来验证自己的优化是否有提升。

  • 要尽可能避免使用锁、并发加锁的范围要尽可能小。

  • 使用 StringBuilder做 string和[]byte 之间的转换、defer 嵌套不要太多。

RPC

介绍

RPC(Remote Procedure Call,远程过程调用)是一种用于实现分布式系统中不同计算机之间通信的协议和编程模型。它允许一个计算机上的程序调用另一个计算机上的远程程序,就像调用本地函数一样,而不需要程序员编写底层的网络通信代码。RPC通常用于构建分布式系统和微服务架构中的组件通信。

以下是RPC的一些关键概念和详细介绍:

  1. 远程调用:RPC允许一个计算机上的程序通过网络请求调用另一个计算机上的远程函数或过程。远程调用的结果就像本地函数调用一样,但实际上它在不同的计算机上执行。

  2. 通信协议:RPC系统使用通信协议来序列化(将函数参数打包成二进制数据)和传输数据。常见的通信协议包括HTTP、TCP、gRPC、Apache Thrift等。

  3. 序列化和反序列化:在RPC中,数据需要在不同计算机之间进行传输。序列化是将数据转换为二进制格式以便传输,而反序列化是将接收到的二进制数据还原为数据结构。这是RPC框架的核心部分,确保数据的正确传输和还原。

  4. Stub/Proxy:在客户端和服务端之间,通常会有一个称为Stub(在某些语言中也称为Proxy)的中间层,它用于将本地函数调用转换为远程调用请求,并将响应结果返回给调用方。

  5. IDL(Interface Definition Language):IDL是一种用于定义RPC接口的语言。它定义了可远程调用的函数、参数和返回值的数据类型。不同的RPC框架使用不同的IDL,例如gRPC使用Protocol Buffers(ProtoBuf)作为其IDL。

  6. 服务注册和发现:在分布式系统中,需要一种机制来注册和发现提供RPC服务的计算机。这可以通过服务注册表、DNS、配置中心等方式实现。

  7. 安全性:RPC通信需要考虑安全性,包括身份验证、授权、加密等机制,以保护数据传输的安全性和完整性。

  8. 性能和可伸缩性:RPC框架通常需要考虑性能和可伸缩性,以处理大量的请求并保持系统的高吞吐量。

一些常见的RPC框架包括:

  • gRPC:由Google开发的高性能RPC框架,使用Protocol Buffers作为IDL。
  • Apache Thrift:由Apache基金会开发的跨语言RPC框架,支持多种语言。
  • JSON-RPC:使用JSON作为数据序列化格式的简单RPC协议。
  • XML-RPC:使用XML作为数据序列化格式的RPC协议。

总之,RPC是一种用于实现分布式系统中不同计算机之间通信的协议和编程模型。它简化了远程函数调用的过程,使分布式系统的开发更加方便。RPC框架提供了一些关键的功能,如序列化、IDL、安全性、性能和可伸缩性,以满足分布式系统的需求。

Go 语言的发展前景

随着这几年 Dokcer、K8s 的普及,云原生的概念也越来越火而Go语言恰恰就是为云而生的编程语言。 云原生时代就具备天生的优势:

  • 易于学习、天然的并发、高效的网络支持
  • 跨平台的二进制文件编译等

CNCF (云原生计算基金会) 对云原生的定义是:

  • 应用容器化
  • 面向微服务架构
  • 应用支持容器的编排调度

代表性的 Docker、K8s 及istio 都是采用 Go语言编写所以 Go 语言在云原生中发挥了极大的优势。

在涉及网络通信、对象存储、协议等领域的工作Go 语言所展现出的优势比 Python、C /C++ 更大甚至很多公司在业务层也采用 Go 语言开发微服务,从而提高开发和运行效率

0%