昨天晚上找了很久的歌单,找到了去年很国风、很有周杰伦味道的曲风的歌。不过这个歌词的名称第一次是真的难ping,简单记录下~

《纟纟 - 黄雨勳》

斜阳暮色如虹 隔巷窗前对坐

你抽絲绣凤 我抚絃唱咏

心事缱绻情脉脉

青阶烟雨濛濛 无意湿了衣袖

你脚步匆匆 我撑伞相送

眼波流转化作绕指柔

一眼倾心 我以琴声赠你

相思偶遇 惹你双颊微晕

三笑缘定 共度春光夏艳秋霜冬雨

念你如昔 深情不枉半生寻觅

一拜天地 我以红霞许你

杯酒相敬 在你耳边轻语

三世有幸 余生如絲如絃不弃不离

绕指柔情 细数年华的诗意

细描黛眉妆红 盈盈起舞婆娑

你发絲如风 我含笑轻拥

浅留余香化作绕指柔

一眼倾心 我以琴声赠你

相思偶遇 惹你双颊微晕

三笑缘定 共度春光夏艳秋霜冬雨

念你如昔 深情不枉半生寻觅

一拜天地 我以红霞许你

杯酒相敬 在你耳边轻语

三世有幸 余生如絲如絃不弃不离

绕指柔情 细数年华的诗意~

Go基础

与其他语言相比,使用 Go 有什么好处?

  • 与其他作为学术实验开始的语言不同,Go 代码的设计是务实的。每个功能和语法决策都旨在让程序员的生活更轻松。

  • Golang 针对并发进行了优化,并且在规模上运行良好。

  • 由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。
  • 自动垃圾收集明显比 Java 或 Python 更有效,因为它与程序同时执行。

Golang 使用什么数据类型?

1. Method(方法)

方法是一种特殊的函数,它与某个类型关联。

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

import "fmt"

type Rectangle struct {
width, height float64
}

func (r Rectangle) Area() float64 {
return r.width * r.height
}

func main() {
r := Rectangle{width: 10, height: 5}
fmt.Println("Area:", r.Area()) // 输出: Area: 50
}

2.Bool(布尔)

布尔类型表示真(true)或假(false)。

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

func main() {
isValid := true
if isValid {
fmt.Println("Valid") // 输出: Valid
}
}

3. String(字符串)

字符串类型表示文本数据。

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
name := "Alice"
fmt.Println("Hello,", name) // 输出: Hello, Alice
}

4. Array(数组)

数组是具有固定长度的相同类型元素的序列。

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
numbers := [3]int{1, 2, 3}
fmt.Println(numbers) // 输出: [1 2 3]
}

5. Slice(切片)

切片是动态数组,可以动态增长和收缩。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
numbers := []int{1, 2, 3}
numbers = append(numbers, 4)
fmt.Println(numbers) // 输出: [1 2 3 4]
}

6. Struct(结构体)

结构体是用户定义的复合类型,可以包含不同类型的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

type Person struct {
Name string
Age int
}

func main() {
p := Person{Name: "Bob", Age: 30}
fmt.Println(p) // 输出: {Bob 30}
}

7. Pointer(指针)

指针存储变量的内存地址。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
var x int = 10
var p *int = &x
fmt.Println("Value of x:", *p) // 输出: Value of x: 10
}

8. Function(函数)

函数是一段可重用的代码块。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func add(a, b int) int {
return a + b
}

func main() {
result := add(3, 4)
fmt.Println("Result:", result) // 输出: Result: 7
}

9. Interface(接口)

接口定义了一组方法签名,任何实现这些方法的类型都实现了该接口。

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

import "fmt"

type Shape interface {
Area() float64
}

type Circle struct {
radius float64
}

func (c Circle) Area() float64 {
return 3.14 * c.radius * c.radius
}

func main() {
var s Shape = Circle{radius: 5}
fmt.Println("Area:", s.Area()) // 输出: Area: 78.5
}

10. Map(映射)

映射是一种无序的键值对集合。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
ages := map[string]int{
"Alice": 25,
"Bob": 30,
}
ages["Charlie"] = 35
fmt.Println(ages) // 输出: map[Alice:25 Bob:30 Charlie:35]
}

11. Channel(通道)

通道用于在 goroutine 之间传递数据。

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

import (
"fmt"
"time"
)

func worker(ch chan int) {
ch <- 42
}

func main() {
ch := make(chan int)
go worker(ch)
value := <-ch
fmt.Println("Received:", value) // 输出: Received: 42
}

Go 程序中的包是什么?

包 (pkg) 是 Go 工作区中包含 Go 源文件或其他包的目录。源文件中的每个函数、变量和类型都存储在链接包中。每个 Go 源文件都属于一个包,该包在文件顶部使用以下命令声明:

package < packagename >

您可以使用以下方法导入和导出包以重用导出的函数或类型:

import < packagename >

Golang 的标准包是 fmt,其中包含格式化和打印功能,如 Println().

如何停止一个 Goroutine?

1. 通过通道(Channel)进行通信和控制

使用通道(Channel)是 Go 语言中常见的并发模式,通过向通道发送信号来通知 Goroutine 停止工作。

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
package main

import (
"fmt"
"time"
)

func worker(stopChan chan bool) {
for {
select {
case <-stopChan:
fmt.Println("Goroutine stopping...")
return
default:
fmt.Println("Working...")
time.Sleep(500 * time.Millisecond)
}
}
}

func main() {
stopChan := make(chan bool)

go worker(stopChan)

time.Sleep(2 * time.Second)

stopChan <- true // 向通道发送信号,通知 Goroutine 停止工作

time.Sleep(1 * time.Second) // 等待 Goroutine 退出
}

在这个例子中,worker 函数中的 Goroutine 会持续输出 “Working…”,直到从 stopChan 通道中接收到 true 信号,之后它会打印 “Goroutine stopping…” 并退出。

2. 使用上下文(context 包)进行控制

Go 语言中的 context

Go 语言提供了一个标准库包 context,用于在 goroutine 之间传递截止时间、取消信号和其他请求范围的值。context 包的主要类型是 Context,它定义了以下主要功能:

  • 取消信号:一个 Context 可以被取消,取消信号会传播到所有从该 Context 派生的子 Context
  • 截止时间:可以设置一个截止时间,到达该时间后,Context 会自动被取消。
  • 超时:可以设置一个超时时间,超过该时间后,Context 会自动被取消。
  • 传递值:可以在 Context 中存储和传递请求范围的值。

Go 的 context 包提供了一种更为优雅的方式来控制 Goroutine,可以用于超时控制、取消操作等。

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
package main

import (
"context"
"fmt"
"time"
)

func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Goroutine stopping...")
return
default:
fmt.Println("Working...")
time.Sleep(500 * time.Millisecond)
}
}
}

func main() {
ctx, cancel := context.WithCancel(context.Background())

go worker(ctx)

time.Sleep(2 * time.Second)

cancel() // 取消上下文,通知 Goroutine 停止工作

time.Sleep(1 * time.Second) // 等待 Goroutine 退出
}

在这个例子中,worker Goroutine 会持续输出 “Working…”,直到 cancel() 被调用,ctx.Done() 通道被关闭,Goroutine 会收到信号并退出。

3. 通过关闭通道(Channel)

除了通过通道发送信号,还可以通过关闭通道来通知 Goroutine 停止工作。

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
package main

import (
"fmt"
"time"
)

func worker(stopChan chan struct{}) {
for {
select {
case <-stopChan:
fmt.Println("Goroutine stopping...")
return
default:
fmt.Println("Working...")
time.Sleep(500 * time.Millisecond)
}
}
}

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

go worker(stopChan)

time.Sleep(2 * time.Second)

close(stopChan) // 关闭通道,通知 Goroutine 停止工作

time.Sleep(1 * time.Second) // 等待 Goroutine 退出
}

在这个例子中,worker Goroutine 会持续输出 “Working…”,直到 stopChan 通道被关闭,Goroutine 会收到信号并退出。

如何在运行时检查变量类型?

在 Go 语言中,类型开关(Type Switch)是一种在运行时检查变量类型的机制。它允许你根据变量的实际类型来执行不同的代码块。类型开关通常与接口类型一起使用,因为接口类型可以持有任何实现了该接口的具体类型的值。

类型开关的基本语法如下:

1
2
3
4
5
6
7
8
9
switch v := variable.(type) {
case type1:
// 当 variable 的类型是 type1 时执行的代码
case type2:
// 当 variable 的类型是 type2 时执行的代码
// 可以有更多的 case
default:
// 当没有任何一个 case 匹配时执行的代码
}

这里,variable.(type) 是一个特殊的类型断言,它返回变量的类型。v 是一个新的变量,它的类型是 variable 的实际类型。

下面是一个具体的例子,展示了如何使用类型开关来检查变量的类型:

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

import (
"fmt"
)

func main() {
var x interface{} = "Hello, World!"

switch v := x.(type) {
case int:
fmt.Printf("x is an int, value: %d\n", v)
case float64:
fmt.Printf("x is a float64, value: %f\n", v)
case string:
fmt.Printf("x is a string, value: %s\n", v)
default:
fmt.Printf("x is of an unknown type\n")
}
}

补充:

var x interface{} = "Hello, World!" 这种写法是 Go 语言中声明一个变量并将其初始化为一个空接口类型interface{})的值。

在 Go 语言中,interface{} 类型表示一个空接口,它没有任何方法。**由于 Go 的接口是隐式实现的,任何类型都自动满足空接口的要求,**因此 interface{} 类型可以持有任何类型的值。

这种写法在需要处理未知类型或动态类型的情况下非常有用。

在这个例子中,变量 x 被声明为一个空接口类型 interface{},它可以持有任何类型的值。类型开关 switch v := x.(type) 用于检查 x 的实际类型,并根据类型执行相应的代码块。

运行这段代码,输出将会是:

1
x is a string, value: Hello, World!

因为 x 被赋值为一个字符串 "Hello, World!",所以类型开关匹配到了 case string 分支。

类型开关是 Go 语言中一种非常强大和灵活的机制,它允许你在运行时根据变量的实际类型来执行不同的逻辑。

Go 语言当中 Channel(通道)有什么特点,需要注意什么?

如果给一个 nil 的 channel 发送数据,会造成永远阻塞。

如果从一个 nil 的 channel 中接收数据,也会造成永久阻塞。

给一个已经关闭的 channel 发送数据, 会引起 panic

从一个已经关闭的 channel 接收数据, 如果缓冲区中为空,则返回一个零值。

无缓冲的 channel 是同步的,而有缓冲的 channel 是非同步的

Golang修养之路口诀

Go 语言中 cap 函数可以作用于哪些内容?

在 Go 语言中,cap 函数用于返回数组、切片或通道的容量。容量是指在重新分配内存之前,该类型可以容纳的元素数量。以下是 cap 函数可以作用于的不同类型的详细说明和示例:

1. 数组(Array)

数组的容量是固定的,等于数组的长度。

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
arr := [5]int{1, 2, 3, 4, 5}
fmt.Println("Array capacity:", cap(arr)) // 输出: Array capacity: 5
}

2. 切片(Slice)

切片的容量是从切片的起始元素到底层数组的最后一个元素的元素数量。切片的长度可能小于或等于其容量。

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
slice := []int{1, 2, 3, 4, 5}
fmt.Println("Slice capacity:", cap(slice)) // 输出: Slice capacity: 5

// 创建一个长度为 3,容量为 5 的切片
slice2 := make([]int, 3, 5)
fmt.Println("Slice2 capacity:", cap(slice2)) // 输出: Slice2 capacity: 5
}

3. 通道(Channel)

通道的容量是指在阻塞之前可以缓冲的元素数量。无缓冲通道的容量为 0,而有缓冲通道的容量等于其缓冲区的大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
// 无缓冲通道
ch1 := make(chan int)
fmt.Println("Unbuffered channel capacity:", cap(ch1)) // 输出: Unbuffered channel capacity: 0

// 有缓冲通道,缓冲区大小为 3
ch2 := make(chan int, 3)
fmt.Println("Buffered channel capacity:", cap(ch2)) // 输出: Buffered channel capacity: 3
}

总结来说,cap 函数在 Go 语言中用于获取数组、切片和通道的容量。对于数组,容量等于其长度;对于切片,容量是从切片的起始元素到底层数组的最后一个元素的元素数量;对于通道,容量是通道缓冲区的大小,无缓冲通道的容量为 0。

Go Convey 介绍

以下是一个简单的示例,展示了如何使用 Go Convey 编写测试用例:

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
package main

import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)

// 要测试的函数
func Add(a, b int) int {
return a + b
}

// 测试用例
func TestAdd(t *testing.T) {
Convey("测试 Add 函数", t, func() {
Convey("当 a 和 b 都是正数时", func() {
So(Add(2, 3), ShouldEqual, 5)
})
Convey("当 a 和 b 都是负数时", func() {
So(Add(-2, -3), ShouldEqual, -5)
})
Convey("当 a 和 b 一正一负时", func() {
So(Add(2, -3), ShouldEqual, -1)
})
})
}

运行测试

要运行测试,可以使用以下命令:

1
goconvey

这将启动 Go Convey 的 Web 界面,并在浏览器中打开。你可以在这个界面中查看测试结果,并且当源代码发生变化时,测试会自动重新运行。

Go 当中同步锁有什么特点?

当一个 Goroutine(协程)获得了 Mutex 后,其他 Goroutine(协程)就只能乖乖的等待,除非该 Goroutine 释放了该 Mutex。RWMutex 在读锁占用的情况下,会阻止写,但不阻止读 RWMutex。 在写锁占用情况下,会阻止任何其他Goroutine(无论读和写)进来,整个锁相当于由该 Goroutine 独占同步锁的作用是保证资源在使用时的独有性,不会因为并发而导致数据错乱,保证系统的稳定性。

在 Go 语言中,为了在并发编程中安全地访问共享数据,Go 提供了一些同步原语,其中最重要的就是互斥锁(Mutex)**和**读写锁(RWMutex)。这些同步锁的主要作用是确保并发访问共享资源时的独占性,防止数据竞争和数据不一致的情况发生,从而保证程序的正确性和系统的稳定性。

1. Mutex(互斥锁)

Mutex 是 Go 中最基础的一种同步锁,位于 sync 包中。它的作用是确保在多 Goroutine 并发访问共享资源时,同一时刻只能有一个 Goroutine 获得锁并访问资源,其他 Goroutine 必须等待锁被释放后才能继续执行。

特点:

  • 独占性:当一个 Goroutine 获得了 Mutex 锁后,其他尝试获取该锁的 Goroutine 会被阻塞,直到这个锁被释放。
  • 简单易用:Mutex 提供了最基本的互斥功能,适用于需要完全互斥访问的场景。

常用方法:

  • Lock():获取锁。当锁已经被其他 Goroutine 占用时,调用 Lock() 的 Goroutine 会被阻塞,直到锁被释放。
  • Unlock():释放锁。调用 Unlock() 会让其他等待该锁的 Goroutine 有机会获得锁并继续执行。

使用示例:

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
package main

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

var (
counter int
mutex sync.Mutex
)

func increment() {
for i := 0; i < 1000; i++ {
mutex.Lock() // 获取锁
counter++ // 访问共享资源
mutex.Unlock() // 释放锁
}
}

func main() {
var wg sync.WaitGroup

wg.Add(2)
go func() {
defer wg.Done()
increment()
}()
go func() {
defer wg.Done()
increment()
}()

wg.Wait()
fmt.Println("Final Counter:", counter)
}

在这个例子中,两个 Goroutine 并发地对 counter 进行递增操作。通过使用 Mutex 锁,确保同一时刻只有一个 Goroutine 能够访问和修改 counter,从而避免数据竞争。

2. RWMutex(读写锁)

RWMutex 是 Go 中的读写锁,位于 sync 包中。相比于简单的 Mutex,RWMutex 提供了更高级的锁定机制,分别支持读锁和写锁。它允许多个 Goroutine 同时读取共享资源(只要没有写操作),但在写操作时,必须独占锁,其他的读写操作都会被阻塞。

特点:

  • 多读单写:在读锁占用的情况下,可以允许多个 Goroutine 同时获取读锁进行读操作,但写操作会被阻塞。
  • 写锁独占:在写锁占用的情况下,会阻止任何其他 Goroutine(无论读和写)进来,整个锁相当于被独占。
  • 性能优化:在读多写少的场景下,RWMutex 可以显著提高并发性能,因为它允许多个读操作并行进行。

常用方法:

  • RLock():获取读锁。如果已经有写锁占用,调用 RLock() 的 Goroutine 会被阻塞,直到写锁被释放。
  • RUnlock():释放读锁。
  • Lock():获取写锁。如果已经有其他读锁或写锁占用,调用 Lock() 的 Goroutine 会被阻塞,直到所有其他锁被释放。
  • Unlock():释放写锁。

使用示例:

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
package main

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

var (
rwMutex sync.RWMutex
value int
)

func readValue(id int) {
rwMutex.RLock() // 获取读锁
fmt.Printf("Goroutine %d: Reading value %d\n", id, value)
time.Sleep(1 * time.Second)
rwMutex.RUnlock() // 释放读锁
}

func writeValue(newValue int) {
rwMutex.Lock() // 获取写锁
fmt.Printf("Writing value %d\n", newValue)
value = newValue
time.Sleep(1 * time.Second)
rwMutex.Unlock() // 释放写锁
}

func main() {
var wg sync.WaitGroup

// 启动多个读操作
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
readValue(id)
}(i)
}

// 启动写操作
wg.Add(1)
go func() {
defer wg.Done()
writeValue(42)
}()

wg.Wait()
}

在这个例子中,多个 Goroutine 并发读取 value,同时只有一个 Goroutine 可以写入 value。读操作可以并行执行,但写操作会独占锁,确保数据一致性。

3. 同步锁的作用

同步锁的主要作用是在并发编程中保护共享资源,避免数据竞争和不一致的情况,确保系统的稳定性。以下是同步锁的具体作用和场景:

  • 数据一致性:确保在并发环境下,多个 Goroutine 对共享资源的访问是安全的,避免出现数据竞争和数据损坏的情况。
  • 独占资源:通过 Mutex 或 RWMutex,确保同一时刻只有一个 Goroutine 能够修改共享资源(或者在读多写少情况下,允许多个 Goroutine 读取资源)。
  • 并发安全:在多 Goroutine 访问共享数据的场景下,使用锁可以防止因并发访问而导致的数据错乱,确保程序的正确性。

Go 语言当中 new 和make的作用是什么

在 Go 语言中,newmake 是两个用于内存分配的内建函数,它们的作用和使用场景有所不同。理解它们之间的区别对于编写高效、正确的 Go 程序至关重要。

1. new 函数

new 是一个内建函数,用于分配内存。它的主要作用是分配一块内存并将其初始化为零值,然后返回指向这块内存的指针。

特点

  • 返回指针new 返回的是一个指向分配内存的类型的指针。
  • 零值初始化new 分配的内存会被初始化为该类型的零值。例如,new(int) 会返回一个指向值为 0int 类型的指针。

使用场景

new 主要用于分配值类型的内存,如 intstructarray 等,并返回指向该内存的指针。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func main() {
// 使用 new 分配 int 类型的内存
p := new(int)
fmt.Println("p:", p) // 输出:p: 0xc000014088(指针地址)
fmt.Println("*p:", *p) // 输出:*p: 0(默认的零值)

// 修改指针指向的值
*p = 42
fmt.Println("新的 *p:", *p) // 输出:新的 *p: 42
}

在这个例子中:

  • new(int) 分配了一块 int 类型的内存,并返回指向该内存的指针 p
  • *p 表示解引用指针 p,获取其指向的值,初始值为 0。后来通过 *p = 42 修改了该值。

2. make 函数

make 也是一个内建函数,但它与 new 不同,主要用于创建并初始化以下三种类型:slice(切片)map(映射)channel(通道)make 返回的是一个被初始化的(而非指针)引用类型。

特点

  • 返回引用类型make 返回的结果是一个初始化后的引用类型(即 slicemapchannel),而不是指针。
  • 初始化内部数据结构make 不仅分配内存,还初始化内部数据结构。例如,make 分配的 slice 会分配底层数组,并返回指向该数组的切片。

使用场景:

make 主要用于创建 slicemapchannel 这三种复合数据类型。它不仅分配内存,还初始化它们的内部结构,使得它们可以立即使用。

示例

1. 创建切片(slice)

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
// 使用 make 创建一个长度为 5 的 int 类型的切片
s := make([]int, 5)
fmt.Println("slice s:", s) // 输出:slice s: [0 0 0 0 0]

// 修改切片中的值
s[0] = 42
fmt.Println("新的 slice s:", s) // 输出:新的 slice s: [42 0 0 0 0]
}

在这个例子中,make([]int, 5) 创建了一个长度为 5 的 int 类型的切片,并且切片中的元素都被初始化为 0

2. 创建映射(map)

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
// 使用 make 创建一个 string 到 int 的映射
m := make(map[string]int)
fmt.Println("map m:", m) // 输出:map m: map[]

// 添加键值对
m["key1"] = 100
fmt.Println("新的 map m:", m) // 输出:新的 map m: map[key1:100]
}

在这个例子中,make(map[string]int) 创建了一个空的 map,并使用键值对进行赋值。

3. 创建通道(channel)

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

import "fmt"

func main() {
// 使用 make 创建一个无缓冲的通道
ch := make(chan int)

// 启动一个新的 Goroutine 发送数据
go func() {
ch <- 42
}()

// 从通道接收数据
value := <-ch
fmt.Println("接收到的值:", value) // 输出:接收到的值: 42
}

在这个例子中,make(chan int) 创建了一个 int 类型的无缓冲通道,并用于 Goroutine 之间的通信。

3. newmake 的区别

  • 用途不同
    • new 用于分配类型的内存,并返回指向该类型的指针。
    • make 用于创建和初始化 slicemapchannel,并返回它们的引用。
  • 返回值不同
    • new 返回的是指针类型,即 *T,指向新分配的类型 T 的内存。
    • make 返回的是初始化后的 slicemapchannel,即 T 本身,而不是指针。
  • 适用场景
    • 使用 new 当你需要分配内存并获取指向该内存的指针时,适用于值类型(如 intstruct 等)。
    • 使用 make 当你需要创建和初始化 slicemapchannel 时。

4. 总结

  • new:用于分配内存,返回指针,适用于值类型。分配的内存被初始化为零值。
  • make:用于创建和初始化 slicemapchannel,返回的是这些数据结构本身,而不是指针。

Go 语言当中数组和切片的区别是什么?

1. 数组(Array)

数组 是一种具有固定长度且元素类型相同的集合。数组的长度在定义时就固定了,不能在运行时动态改变。

特点:

  • 固定长度:数组的长度在编译时确定,不能动态改变。
  • 值类型:数组是值类型,意味着在赋值或传递数组时,会复制整个数组的所有元素。
  • 连续内存:数组中的元素在内存中是连续存储的。

声明和初始化:

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

import "fmt"

func main() {
// 声明长度为 3 的 int 类型数组,并初始化
var arr1 [3]int
fmt.Println("arr1:", arr1) // 输出:arr1: [0 0 0]

// 声明并初始化数组
arr2 := [3]int{1, 2, 3}
fmt.Println("arr2:", arr2) // 输出:arr2: [1 2 3]

// 省略长度,根据初始化的元素个数自动推断数组长度
arr3 := [...]int{4, 5, 6}
fmt.Println("arr3:", arr3) // 输出:arr3: [4 5 6]go
}

数组的使用:

  • 访问元素:通过索引访问数组元素,索引从 0 开始。
1
2
arr := [3]int{10, 20, 30}
fmt.Println("第一个元素:", arr[0]) // 输出:第一个元素: 10
  • 修改元素
1
2
arr[1] = 25
fmt.Println("修改后的 arr:", arr) // 输出:修改后的 arr: [10 25 30]
  • 数组是值类型:将一个数组赋值给另一个数组时,实际上是复制了整个数组。
1
2
3
4
5
arrA := [3]int{1, 2, 3}
arrB := arrA
arrB[0] = 100
fmt.Println("arrA:", arrA) // 输出:arrA: [1 2 3]
fmt.Println("arrB:", arrB) // 输出:arrB: [100 2 3]

在这个例子中,对 arrB 的修改不会影响 arrA,因为 arrAarrB 是两个独立的数组。

2. 切片(Slice)

切片 是 Go 中最常用的集合类型。切片是基于数组的一个抽象,它提供了更灵活和强大的功能。切片是动态的,可以在运行时改变长度。

特点:

  • 动态长度:切片的长度可以动态变化。
  • 引用类型:切片是引用类型,它指向一个底层数组。切片的赋值和传递只是引用的复制。
  • 三部分结构:切片包含三个部分:指向底层数组的指针、切片的长度和切片的容量。

声明和初始化

  • 由数组生成切片
1
2
3
arr := [5]int{10, 20, 30, 40, 50}
slice := arr[1:4] // 生成一个切片,包含 arr 的第 1 到第 3 个元素
fmt.Println("slice:", slice) // 输出:slice: [20 30 40]
  • 直接声明切片
1
2
slice := []int{100, 200, 300}
fmt.Println("slice:", slice) // 输出:slice: [100 200 300]
  • 使用 make 函数创建切片
1
2
3
4
slice := make([]int, 3, 5) // 创建一个长度为 3,容量为 5 的切片
fmt.Println("slice:", slice) // 输出:slice: [0 0 0]
fmt.Println("长度:", len(slice)) // 输出:长度: 3
fmt.Println("容量:", cap(slice)) // 输出:容量: 5
  • 访问和修改元素:与数组类似,可以通过索引访问和修改切片中的元素。
1
2
3
4
slice := []int{1, 2, 3}
fmt.Println("第一个元素:", slice[0]) // 输出:第一个元素: 1
slice[1] = 20
fmt.Println("修改后的 slice:", slice) // 输出:修改后的 slice: [1 20 3]
  • 动态扩展:切片可以通过内置函数 append 动态扩展。
1
2
3
slice := []int{1, 2, 3}
slice = append(slice, 4, 5)
fmt.Println("扩展后的 slice:", slice) // 输出:扩展后的 slice: [1 2 3 4 5]
  • 切片是引用类型:对切片的修改会影响所有引用该切片的变量。
1
2
3
4
5
sliceA := []int{10, 20, 30}
sliceB := sliceA
sliceB[0] = 100
fmt.Println("sliceA:", sliceA) // 输出:sliceA: [100 20 30]
fmt.Println("sliceB:", sliceB) // 输出:sliceB: [100 20 30]

在这个例子中,sliceAsliceB 引用的是同一个底层数组,因此对 sliceB 的修改会影响 sliceA

  1. 数组和切片的区别总结

1. 长度

  • 数组:长度固定,声明时就确定,不能在运行时改变。
  • 切片:长度可动态变化,可以在运行时使用 append 等操作扩展切片。

2. 类型

  • 数组:是值类型,赋值或传递时会复制整个数组。
  • 切片:是引用类型,赋值或传递时只是复制引用(指针、长度、容量)。

3. 内存管理

  • 数组:在内存中是连续存储的,整个数组都占用一块内存。
  • 切片:引用底层数组的一部分,切片的容量可以超过其长度,可以通过 append 操作动态扩展,可能会触发底层数组的重新分配。

4. 灵活性

  • 数组:在 Go 中使用较少,通常用于固定大小的集合。
  • 切片:使用非常广泛,提供了灵活的内存布局和动态扩展能力,适用于大多数场景。

4. 举例说明区别

示例 1:数组的固定长度

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
arr := [3]int{1, 2, 3}
// arr = append(arr, 4) // 错误:不能对数组使用 append 动态扩展
fmt.Println("数组 arr:", arr)
}

示例 2:切片的动态扩展

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
slice := []int{1, 2, 3}
slice = append(slice, 4) // 切片可以动态扩展
fmt.Println("切片 slice:", slice) // 输出:切片 slice: [1 2 3 4]
}

示例 3:数组的值传递

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func modifyArray(arr [3]int) {
arr[0] = 100
}

func main() {
arr := [3]int{1, 2, 3}
modifyArray(arr)
fmt.Println("数组 arr:", arr) // 输出:数组 arr: [1 2 3]
}

在这个例子中,对数组的修改不会影响原数组,因为数组是值类型,传递时会复制整个数组。

示例 4:切片的引用传递

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func modifySlice(slice []int) {
slice[0] = 100
}

func main() {
slice := []int{1, 2, 3}
modifySlice(slice)
fmt.Println("切片 slice:", slice) // 输出:切片 slice: [100 2 3]
}

在这个例子中,对切片的修改会影响原切片,因为切片是引用类型,传递时只是复制了引用。

5. 总结

  • 数组:固定长度、值类型、内存连续、适用于大小固定的数据集合。
  • 切片:动态长度、引用类型、灵活强大、适用于大多数动态数据集合。

defer 的执行顺序是什么?

defer 的作用是:

你只需要在调用普通函数或方法前加上关键字 defer,就完成了 defer 所需要的语法。当 defer 语句被执行时,跟在 defer 后面的函数会被延迟执行。直到包含该 defer 语句的函数执行完毕时,defer 后的函数才会被执行,不论包含defer 语句的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。

defer 的常用场景:

  • defer 语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、加锁、释放锁。

  • 通过 defer 机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资源被释放。

  • 释放资源的 defer 应该直接跟在请求资源的语句后

Golang 的参数传递、引用类型

非引用类型示例

假设我们有一个简单的 struct 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Person struct {
Name string
Age int
}

func modifyPerson(p Person) {
p.Name = "Alice"
p.Age = 30
}

func main() {
p := Person{Name: "Bob", Age: 25}
modifyPerson(p)
fmt.Println(p) // 输出: {Bob 25}
}

在这个例子中,modifyPerson 函数接收的是 Person 类型的一个副本。因此,对副本的修改不会影响到原始的 Person 对象。

引用类型示例

指针

假设我们有一个指向 Person 类型的指针:

1
2
3
4
5
6
7
8
9
10
func modifyPersonPtr(p *Person) {
p.Name = "Alice"
p.Age = 30
}

func main() {
p := &Person{Name: "Bob", Age: 25}
modifyPersonPtr(p)
fmt.Println(*p) // 输出: {Alice 30}
}

在这个例子中,modifyPersonPtr 函数接收的是一个指向 Person 类型的指针的副本。但这个副本仍然指向原始的 Person 对象,因此对指针所指向的对象的修改会影响到原始对象。

map

假设我们有一个 map 类型:

1
2
3
4
5
6
7
8
9
10
func modifyMap(m map[string]int) {
m["one"] = 1
m["two"] = 2
}

func main() {
m := make(map[string]int)
modifyMap(m)
fmt.Println(m) // 输出: map[one:1 two:2]
}

在这个例子中,modifyMap 函数接收的是 map 类型的一个副本。但这个副本实际上是一个指向底层哈希表的指针,因此对 map 的修改会影响到原始的 map 对象。

slice

假设我们有一个 slice 类型:

1
2
3
4
5
6
7
8
9
10
func modifySlice(s []int) {
s[0] = 100
s = append(s, 4)
}

func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // 输出: [100 2 3]
}

在这个例子中,modifySlice 函数接收的是 slice 类型的一个副本。这个副本包含了指向底层数组的指针、长度和容量。因此,对 slice 的元素的修改会影响到原始的 slice 对象。但需要注意的是,append 操作可能会导致底层数组的重新分配,因此在这种情况下,原始 slice 不会受到影响。

channel

假设我们有一个 channel 类型:

1
2
3
4
5
6
7
8
9
10
11
func sendData(ch chan int) {
ch <- 1
ch <- 2
}

func main() {
ch := make(chan int)
go sendData(ch)
fmt.Println(<-ch) // 输出: 1
fmt.Println(<-ch) // 输出: 2
}

在这个例子中,sendData 函数接收的是 channel 类型的一个副本。但这个副本实际上是一个指向底层通信机制的指针,因此可以通过这个副本进行通信操作。

new 和 make 函数

new

new 函数为类型分配零值内存,并返回一个指向该内存的指针:

1
2
3
4
5
func main() {
p := new(Person)
fmt.Println(p) // 输出: &{ 0}
fmt.Println(*p) // 输出: { 0}
}

在这个例子中,new(Person) 返回一个指向 Person 类型零值内存的指针。

make

make 函数专门用于 slicemapchannel 类型的初始化:

1
2
3
4
5
6
7
8
9
10
func main() {
s := make([]int, 3)
fmt.Println(s) // 输出: [0 0 0]

m := make(map[string]int)
fmt.Println(m) // 输出: map[]

ch := make(chan int)
fmt.Println(ch) // 输出: 0x... (channel 的地址)
}

在这个例子中,make 函数分别用于初始化 slicemapchannel 类型,并返回初始化后的对象(而非指针)。

**Golang Map 底层实现 **

Golang 中 map 的底层实现是一个散列表,因此实现 map 的过程实际上就是实现 散表的过程。在这个散列表中,主要出现的结构体有两个,一个叫 hmap(a header for a go map),一个叫 bmap(a bucket for a Go map,通常叫其 bucket)。

Golang Map 如何扩容

  1. 双倍扩容:扩容采取了一种称为“渐进式”的方式,原有的 key 并不会一次性搬迁完毕,每次最多只会搬迁 2 个 bucket。
  2. **等量扩容:**重新排列,极端情况下,重新排列也解决不了,map 存储就会蜕变成链表,性能大大降低,此时哈希因子 hash0 的设置,可以降低此类极端场景的发生。

详细说明:

map它的底层是哈希表,那哈希表通常我们需要考虑3个方面,哈希函数、冲突处理、扩容方式。

冲突处理这块,golang中的map采用的是拉链法,不过这有个细节就是,这个map的链表的节点是一个桶,一个桶你可以理解为一个8个元素的数组,这样做的好处就是,减少了频繁分配链表节点时的开销,我理解的话,这是一种时间和空间上的折中

扩容方式这块,是采用渐进式扩容,渐进式扩容就可以把时间均摊到每次请求上,不会有那种常规扩容的卡顿的情况。然后前面提到桶,golang也提出等量扩容的方式,主要是在稀疏的情况下做,减少检索和内存的开销

介绍一下 Channel

Go 语言中,不要通过共享内存来通信,而要通过通信来实现内存共享。Go 的CSP(Communicating Sequential Process)并发模型,中文可以叫做通信顺序进程,是通过 goroutine 和 channel 来实现的。channel 收发遵循先进先出 FIFO 的原则。分为有缓冲区和无缓冲区,channel中包括 buffer、sendx 和 recvx 收发的位置(ring buffer 记录实现)、sendq、recv。当 channel 因为缓冲区不足而阻塞了队列,则使用双向链表存储。

用C++简单实现

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <iostream>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>

template <typename T>
class Channel {
public:
explicit Channel(size_t bufferSize) : bufferSize_(bufferSize) {}

void Send(const T& value) {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return queue_.size() < bufferSize_ || closed_; });
if (closed_) {
throw std::runtime_error("Channel is closed");
}
queue_.push(value);
lock.unlock();
cv_.notify_one();
}

std::optional<T> Receive() {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return !queue_.empty() || closed_; });
if (queue_.empty() && closed_) {
return std::nullopt;
}
T value = queue_.front();
queue_.pop();
lock.unlock();
cv_.notify_one();
return value;
}

void Close() {
std::unique_lock<std::mutex> lock(mutex_);
closed_ = true;
lock.unlock();
cv_.notify_all();
}

private:
std::queue<T> queue_;
const size_t bufferSize_;
bool closed_ = false;
std::mutex mutex_;
std::condition_variable cv_;
};

int main() {
Channel<int> channel(2);

std::thread sender([&channel]() {
for (int i = 0; i < 5; ++i) {
channel.Send(i);
std::cout << "Sent: " << i << std::endl;
}
channel.Close();
});

std::thread receiver([&channel]() {
while (true) {
auto value = channel.Receive();
if (value.has_value()) {
std::cout << "Received: " << value.value() << std::endl;
} else {
break;
}
}
});

sender.join();
receiver.join();

return 0;
}

Slice

学习目标

Go八股整理与解析

  1. 介绍思路:概念、核心操作、细节

    • Slice 的基本概念、核心的使用操作,以及其中涉及的细节内容。
  2. 数组:当前长度、数组容量

    • Slice 本质上是对底层数组的引用,它有自己的当前长度(len)和容量(cap)。长度是 Slice 中实际包含的元素数量,而容量则是从 Slice 起始位置到底层数组末尾的元素数量。
  3. 扩容与缩容

    • 扩容:当 Slice 容量不足时,扩容机制会触发,通常按照一定的规则:
      • 小于两倍容量时,扩容为两倍。【如果当前容量小于 1024,则扩容为当前容量的 两倍。】
      • 大于两倍容量时,扩容为 1.25 倍。【如果当前容量大于 1024,则扩容为当前容量的 1.25 倍。】
    • 缩容:如果 Slice 被缩小到小于 1/4 的容量,容量会缩小为一半,且小切片的容量保持不变。
  4. 头部的值与引用类型的区别

    • 与其他引用类型不同,Slice 的头部保存的是值(即 Slice 结构体本身),而不是指针。这意味着它的头部信息与其他引用类型有些不同。
  5. 引用类型的差异

    • 其他引用类型(如指针)的引用是头部的指针,而 Slice 是头部的值。这表明 Slice 在内部结构上与其他引用类型有一些差异。
    • 举例1
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package main

    import "fmt"

    func modifySlice(s []int) {
    s[0] = 100
    }

    func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s) // 输出: [100 2 3]
    }
    • 举例2
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    package main

    import "fmt"

    func modifySliceHeader(s []int) {
    s = append(s, 4)
    fmt.Println("Inside modifySliceHeader:", s) // 输出: Inside modifySliceHeader: [1 2 3 4]
    }

    func main() {
    s := []int{1, 2, 3}
    modifySliceHeader(s)
    fmt.Println("After modifySliceHeader:", s) // 输出: After modifySliceHeader: [1 2 3]
    }

快速使用

1.切片的底层结构

在 Go 语言中,切片本质上是一个结构体,包含以下三个字段:

  1. 指针(ptr:指向底层数组的指针。
  2. 长度(len:切片中的元素个数。
  3. 容量(cap:切片从起始位置到底层数组末尾的元素总数。

在 Go 的运行时包(runtime)中,slice 结构体的定义大致如下:

1
2
3
4
5
type slice struct {
array unsafe.Pointer // 指向底层数组
len int // 切片的长度
cap int // 切片的容量
}

解释:

  • arrayunsafe.Pointer 类型,指向底层数组的起始地址。unsafe.Pointer 是一种通用指针类型,可以指向任意类型的数据。
  • len:切片的当前长度,即可以访问的元素个数。
  • cap:切片的容量,从切片的起始位置到底层数组的末尾。

2.切片的创建

切片可以通过多种方式创建,例如通过数组生成、直接创建以及使用 make 函数。这里重点介绍通过 make 函数创建切片时的底层实现。

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

func main() {
s := make([]int, 5, 10)
fmt.Println("slice s:", s) // slice s: [0 0 0 0 0]
fmt.Println("len(s):", len(s)) // len(s): 5
fmt.Println("cap(s):", cap(s)) // cap(s): 10
}

底层实现分析

当你执行 make([]int, 5, 10) 时,Go 会分配一个长度为 10 的底层数组,并返回一个长度为 5、容量为 10 的切片。对应的底层代码(位于 runtime/slice.go)大致如下:

1
2
3
4
5
6
7
8
func makeSlice(et *_type, len, cap int) slice {
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len > cap {
panic(errorString("makeSlice: cap out of range"))
}
p := mallocgc(mem, et, true) // 分配底层数组
return slice{p, len, cap} // 返回切片
}

关键点

  • mallocgc:这个函数用于分配内存,即为底层数组分配所需的内存空间(mem)。
  • slice{p, len, cap}:创建并返回一个 slice 结构体,其中 p 指向分配的底层数组,len 是切片的长度,cap 是切片的容量。

3.切片的扩容

当使用 append 向切片添加元素时,如果切片的容量不足,Go 会自动扩展切片的容量。扩展的容量通常是当前容量的两倍,以减少未来的扩展次数。

示例

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
s := make([]int, 5, 5)
fmt.Println("初始 slice:", s, len(s), cap(s)) // 初始 slice: [0 0 0 0 0] 5 5

s = append(s, 1)
fmt.Println("扩容后 slice:", s, len(s), cap(s)) // 扩容后 slice: [0 0 0 0 0 1] 6 10
}

底层实现分析

切片扩容的核心逻辑在 Go 的运行时中(runtime/slice.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
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
newcap = cap
}
}
}

var overflow bool
var lenmem, capmem uintptr
lenmem = uintptr(old.len) * et.size
capmem = uintptr(newcap) * et.size
newarray := mallocgc(capmem, et, true)
if lenmem > 0 && !writeBarrier.enabled {
memmove(newarray, old.array, lenmem)
} else {
typedmemmove(et, newarray, old.array)
}
return slice{newarray, old.len, newcap}
}

关键点

  • newcap:计算新的容量。通常情况下,容量会倍增(newcap = old.cap * 2);如果切片长度小于 1024,直接倍增;如果长度超过 1024,则每次增量为当前容量的 1/4。
  • mallocgc:为新容量分配内存。
  • memmove/typedmemmove:将旧切片的数据复制到新分配的内存中。
  • 返回新的 slice 结构:返回一个新的切片结构,指向扩展后的底层数组。
  1. 切片的内存共享

切片可以共享底层数组的内存,这意味着对一个切片的修改可能会影响到从同一个底层数组生成的其他切片。

示例

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

import "fmt"

func main() {
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:4]
s2 := arr[2:5]

fmt.Println("s1:", s1) // 输出:s1: [2 3 4]
fmt.Println("s2:", s2) // 输出:s2: [3 4 5]

s1[1] = 100
fmt.Println("修改后的 s1:", s1) // 输出:修改后的 s1: [2 100 4]
fmt.Println("s2:", s2) // 输出:s2: [3 100 5]
}

内存共享的实现

由于 s1s2 共享同一个底层数组,修改 s1[1] 也会影响到 s2,因为它们都指向同一个底层数组中的相同位置。

  1. 总结
  • 切片的本质:切片是一个包含指针、长度和容量的结构体,指向一个底层数组。
  • 动态扩容:当切片容量不足时,Go 会自动扩展切片的容量,通常是当前容量的两倍。
  • 内存共享:多个切片可以共享同一个底层数组,因此对一个切片的修改可能会影响到其他切片。
  • 引用类型:切片是引用类型,赋值和传递切片时仅复制指针、长度和容量,而不会复制底层数组。

调度模型

GMP 调度模型是什么?

Go 语言的 GMP 调度模型是 Go 运行时(runtime)中用于管理 Goroutine 调度的核心机制。

GMP 分别代表 Goroutine、Machine(线程)和 Processor(处理器)。

GMP 调度模型的组成部分

1. Goroutine (G)

  • 轻量级线程:Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理。
  • 用户级调度:Goroutine 由 Go 运行时调度器在用户空间进行调度,而不是由操作系统内核调度。
  • 创建和管理:Goroutine 的创建和管理开销很小,可以轻松创建成千上万个 Goroutine。

2. Machine (M)

  • 操作系统线程:M 代表操作系统线程,由操作系统内核调度。
  • 绑定 Goroutine:M 负责执行 Goroutine,一个 M 可以绑定一个或多个 Goroutine。
  • 系统调用:当 Goroutine 进行系统调用时,M 可能会被阻塞,此时调度器会进行手动交接(Hand Off),将 Goroutine 从当前 M 解绑,并绑定到另一个空闲的 M 上。

3. Processor §

  • 逻辑处理器:P 代表逻辑处理器,是 Go 运行时调度器中的一个抽象概念。
  • 调度 Goroutine:P 负责调度 Goroutine 到 M 上执行。每个 P 都有一个本地队列,用于存储待执行的 Goroutine。
  • 数量可配置:P 的数量可以通过 GOMAXPROCS 环境变量或 runtime.GOMAXPROCS 函数进行配置,通常设置为 CPU 核心数。

【P 是 GMP 调度模型中的处理器,它是调度器的一部分。每个 P 都绑定到一个 M 上,并负责调度该 M 上的 Goroutine。】

GMP模型如何调度

  1. 当一个Goroutine被创建时,调度器会将其放入全局的可运行Goroutine队列中。

  2. 当一个M空闲时,它会从全局队列中获取一个Goroutine,并将其绑定到自己的P上。

  3. M会执行绑定的P上的Goroutine,直到Goroutine完成或发生阻塞。

  4. 如果一个Goroutine发生阻塞,P会将其从M上解绑,并将其放入相应的等待队列中。

  • 系统调用:当Goroutine执行系统调用(如I/O操作)时,它会被阻塞,此时P会将其从M上解绑,并将其放入系统调用等待队列中,然后按照 手动交接(hand off) 机制调度。(锁竞争、channel操作相同调度机制)
  • 时间片用完:当一个Goroutine的时间片用完时,调度器会将其暂停,并将其放回到本地队列中等待下一次调度。然后,调度器按照 抢占式调度(preemptive scheduling) 选择另一个就绪的Goroutine来执行。
  1. 当一个M执行完当前的Goroutine后
  • 当一个 M(Machine,即操作系统线程)执行完当前的 Goroutine 后,调度器会从全局运行队列中选择一个可运行的 Goroutine,并将其分配给该 M 来执行。
  • 当全局运行队列中没有可运行的 Goroutine 时,会从其他 M 的本地运行队列中窃取 Goroutine以保持工作的平衡。
  • 当从其他 M没有窃取到Goroutine,会再次尝试从全局运行队列中取Goroutine
  1. 当一个M长时间没有执行Goroutine时,调度器会将其标记为闲置,并将其销毁以释放系统资源。.

image-20240915155112210

什么是 Goroutine 的抢占式调度?它是如何实现的?

Goroutine 的抢占式调度是指在 Go 语言中,多个 Goroutine 之间会自动进行抢占式调度,而不是依赖于显式的调度点。

  • 当一个 Goroutine 主动调用了一个可能会发生阻塞的操作,如通道的读写操作、系统调用等,运行时系统会在这个点进行抢占。
  • 当一个 Goroutine 的时间片用尽,即执行时间超过了一定的阈值,运行时系统会中断该 Goroutine 的执行,切换到其他可运行的 Goroutine 上继续执行。
  • 当一个 Goroutine 主动调用了 runtime.Gosched() 函数,它会主动让出当前 Goroutine 的执行权,让其他 Goroutine 有机会执行。

手动交接(Hand Off)机制

手动交接(Hand Off)机制是Go语言调度器中的一种策略,用于处理Goroutine因系统调用等原因被阻塞的情况。具体来说:

  1. 解绑与等待:当Goroutine被阻塞时,调度器将其从当前的P(处理器)上解绑,并放入系统调用等待队列。
  2. 继续执行:调度器检查当前P是否有其他可用的Goroutine。如果有,则选择一个Goroutine绑定到当前M(线程)上继续执行。
  3. 寻找空闲M:如果当前P没有其他可用Goroutine,调度器会尝试找到一个空闲的M。如果找到,则将被阻塞的Goroutine与新的M绑定,继续执行。
  4. 创建新M:如果没有空闲M,调度器会创建一个新的M,并与被阻塞的Goroutine绑定。
  5. 重新调度:当Goroutine的阻塞条件满足后,它会被重新调度执行。

垃圾回收机制

待看:Go垃圾回收机制

Go 并发编程基础

Mutex 几种状态

在 Go 语言中,sync.Mutex 是一个常用的同步原语,用于在多个 goroutine 之间提供互斥访问。sync.Mutex 的内部状态可以通过其字段来表示,尽管这些字段并不是公开的,但我们可以通过它们的含义来理解互斥锁的工作原理。

以下是 sync.Mutex 的几种状态及其含义:

  1. mutexLocked:表示互斥锁的锁定状态。当该字段被设置时,表示互斥锁已经被某个 goroutine 锁定,其他 goroutine 尝试锁定时会被阻塞。
  2. mutexWoken:表示从正常模式被唤醒。在正常模式下,等待的 goroutine 会进入休眠状态,当锁被释放时,其中一个休眠的 goroutine 会被唤醒并尝试获取锁。
  3. mutexStarving:表示当前的互斥锁进入饥饿状态。在饥饿模式下,锁的获取会优先分配给等待时间最长的 goroutine,以避免某些 goroutine 长时间无法获取锁。
  4. waitersCount:表示当前互斥锁上等待的 Goroutine 个数。这个字段记录了有多少个 goroutine 正在等待获取锁。
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
package main

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

var (
counter int
mutex sync.Mutex
)

func increment() {
mutex.Lock()
defer mutex.Unlock()
counter++
fmt.Println("Counter:", counter)
}

func main() {
var wg sync.WaitGroup

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

wg.Wait()
fmt.Println("Final Counter:", counter)
}

Mutex 正常模式和饥饿模式

在 Go 语言的 sync 包中,Mutex(互斥锁)提供了一种用于管理对共享资源的并发访问机制。为了在不同场景下提供高效的锁竞争策略,Mutex 设计了两种模式:正常模式(非公平锁)和饥饿模式(公平锁)。下面是对两种模式的详细说明及其区别。

正常模式(非公平锁)

机制

  • 在正常模式下,所有等待获取锁的 goroutine 都按照 FIFO(先进先出) 的顺序等待锁。
  • 当一个 goroutine 释放锁(unlock)时,系统会从等待队列中唤醒下一个 goroutine。但这里有一个关键点:被唤醒的 goroutine 并不会直接拥有锁,而是会和新请求锁的 goroutine 竞争锁。
  • 由于新请求锁的 goroutine 通常正在 CPU 上运行,因此它很可能在竞争中获胜,而被唤醒的 goroutine 则可能再次失败,回到等待队列的前列。

优点

  • 正常模式下,能够提高系统整体的吞吐量,因为锁的竞争更具动态性,新请求的 goroutine 有更高的机会立即获得锁。

缺点

  • 可能导致“长尾问题”,即某些 goroutine 可能会长时间无法获取锁,因为它们总是被新来的 goroutine 抢占。这种不公平的竞争可能导致某些任务被长期延迟。

饥饿模式(公平锁)

机制

  • 饥饿模式旨在解决正常模式下的“长尾问题”,确保 goroutine 能够公平地获取锁。
  • 在饥饿模式下,锁释放(unlock)时,直接将锁交给等待队列中排在第一位的 goroutine(队头)。
  • 新加入等待锁的 goroutine 不会参与锁的竞争,也不会进入自旋状态,而是直接进入等待队列的尾部。这确保了已经等待了一段时间的 goroutine 能够优先获得锁。

优点

  • 饥饿模式下,等待锁的 goroutine 能够更公平地获取锁,避免了某些 goroutine 长时间无法获得锁的情况,从而解决了长尾问题。

缺点

  • 由于严格的队列顺序,可能会降低系统的整体吞吐量,特别是在高并发环境下,因为锁的竞争变得更为严格和僵化。

Cond 是什么

在 Go 语言中,sync.Cond 是一个条件变量(Condition Variable),它通常与一个锁(sync.Mutexsync.RWMutex)一起使用,用于协调多个 goroutine 之间的同步。条件变量允许 goroutine 在某个条件满足之前等待,并在条件满足时被唤醒。

sync.Cond 的主要方法

  1. NewCond(l Locker):创建一个新的条件变量,并关联一个锁。
  2. Wait():使当前 goroutine 进入等待状态,直到被其他 goroutine 唤醒。
  3. Signal():唤醒一个等待的 goroutine(如果有的话)。
  4. Broadcast():唤醒所有等待的 goroutine。

使用场景

sync.Cond 通常用于多个读取者等待共享资源变为可用的情况。如果只有一个读取者和一个写入者,使用一个简单的锁或 channel 就足够了。

示例代码

以下是一个使用 sync.Cond 的示例,展示了多个读取者等待共享资源变为可用的场景:

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
package main

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

var (
sharedResource string
cond *sync.Cond
)

func reader(id int) {
cond.L.Lock()
for sharedResource == "" {
fmt.Printf("Reader %d is waiting...\n", id)
cond.Wait()
}
fmt.Printf("Reader %d read: %s\n", id, sharedResource)
cond.L.Unlock()
}

func writer() {
time.Sleep(2 * time.Second)
cond.L.Lock()
sharedResource = "Hello, World!"
fmt.Println("Writer updated shared resource")
cond.L.Unlock()
cond.Broadcast()
}

func main() {
var wg sync.WaitGroup
cond = sync.NewCond(&sync.Mutex{})

for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
reader(id)
}(i)
}

go writer()

wg.Wait()
fmt.Println("All readers have finished")
}

代码解释

  1. 初始化:使用 sync.NewCond 创建一个条件变量,并关联一个互斥锁。
  2. 读取者:每个读取者(goroutine)在读取共享资源之前,先锁定互斥锁,然后检查资源是否可用。如果资源不可用,调用 cond.Wait() 进入等待状态。
  3. 写入者:写入者更新共享资源,然后调用 cond.Broadcast() 唤醒所有等待的读取者。
  4. 唤醒与读取:被唤醒的读取者继续执行,读取共享资源并输出。

通过使用 sync.Cond,我们可以有效地协调多个 goroutine 之间的同步,确保它们在共享资源可用时能够正确地进行读取操作。

参考:

Golang修养之路

Go八股整理与解析