golang实习/秋招自学笔记

文章目录


我的github :luminescenceJ
ref :

  1. mao888/golang-guide: 「Golang学习+面试指南」一份涵盖大部分 Golang程序员所需要掌握的核心知识。准备 Golang面试,首选 GolangGuide!
  2. Golang中GC回收机制三色标记与混合写屏障_哔哩哔哩_bilibili
  3. Go 面试宝典
  4. 后端 - go 读写锁实现原理解读 - 个人文章 - SegmentFault 思否
  • 注意:如果图片显示不出来,可以到github上看置顶仓库

Golang

面向对象

面向对象编程(OOP)的三大核心特性

1.封装 :将数据(属性)和行为(方法)绑定在一个类中,并对外隐藏内部实现细节,仅通过公共接口与外界交互。

2.继承(Inheritance):子类继承父类的属性和方法,并可以扩展或重写这些行为,实现代码复用和分层抽象。

3.多态(Polymorphism):同一操作在不同对象上表现出不同行为,通常通过继承(方法重写)或接口实现

golang实现继承

type Animal struct {
    Name string
}

func (a *Animal) Eat() {
    fmt.Printf("%v is eating", a.Name)
    fmt.Println()
}

type Cat struct {
    *Animal
}

cat := &Cat{
    Animal: &Animal{
        Name: "cat",
    },
}
cat.Eat() // cat is eating
  • 如果一个 struct 嵌套了另一个匿名结构体,那么这个结构可以直接访问匿名结构体的属性和方法,从而实现继承。
  • 如果一个 struct 嵌套了另一个有名的结构体,那么这个模式叫做组合。
  • 如果一个 struct 嵌套了多个匿名结构体,那么这个结构可以直接访问多个匿名结构体的属性和方法,从而实现多重继承。

go实现多态

Go 语言并没有设计诸如虚函数、纯虚函数、继承、多重继承等概念,但它通过接口却非常优雅地支持了面向对象的特性。
多态是一种运行期的行为,它有以下几个特点:

  1. 一种类型具有多种类型的能力
  2. 允许不同的对象对同一消息做出灵活的反应
  3. 以一种通用的方式对待个使用的对象
  4. 非动态语言必须通过继承和接口的方式来实现
(1) 定义接口
type Shape interface {
    Area() float64
    Perimeter() float64
}
(2) 定义具体类型并实现接口
// 矩形类型
type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

// 圆形类型
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}
(3) 多态调用
func PrintShapeDetails(s Shape) {
    fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}

func main() {
    rect := Rectangle{Width: 5, Height: 3}
    circle := Circle{Radius: 4}

    PrintShapeDetails(rect)   // 输出: Area: 15.00, Perimeter: 16.00
    PrintShapeDetails(circle) // 输出: Area: 50.27, Perimeter: 25.13
}

官方包

Context

type Context interface {
	// 当 context 被取消或者到了 deadline,返回一个被关闭的 channel
	Done() <-chan struct{}

	// 在 channel Done 关闭后,返回 context 取消原因
	Err() error

	// 返回 context 是否会被取消以及自动取消时间(即 deadline)
	Deadline() (deadline time.Time, ok bool)

	// 获取 key 对应的 value
	Value(key interface{}) interface{}
}

Context 是一个接口,定义了 4 个方法,它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。此外,Context是并发安全的。

Done() 返回一个 channel,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。注意,这是一个只读的channel。 我们又知道,读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值。换句话说,这是一个 receive-only 的 channel。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值(零值)后,就可以做一些收尾工作,尽快退出。

Err() 返回一个错误,表示 channel 被关闭的原因。例如是被取消,还是超时。

Deadline() 返回 context 的截止时间,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然,也可以用这个 deadline 来设置一个 I/O 操作的超时时间。

Value() 获取之前设置的 key 对应的 value。但是父协程读不到子协程的值。

context在go中一般可以用来做什么?

在 Go 语言中,context 包提供了一种管理多个 goroutine 之间的截止时间取消信号请求范围数据的方法。以下是 context 常见的用途:

取消信号:

  • context 可以用来向多个 goroutine 传递取消信号。当一个 goroutine 需要取消其他 goroutine 时,可以调用 contextCancelFunc

  • 例如,在处理 HTTP 请求时,如果客户端关闭了连接,可以使用 context 取消所有相关的后台操作。

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    // 执行一些操作
    // 在需要取消操作时调用 cancel
    cancel()
}()

select {
case <-ctx.Done():
    fmt.Println("操作取消")
case result := <-someOperation():
    fmt.Println("操作结果:", result)
}

截止时间/超时控制:

  • context 可以设置一个截止时间或超时。当超过这个时间或超时发生时,context 会自动取消操作。

  • 例如,在数据库查询或网络请求时,可以使用 context 设置一个超时时间,以防止长时间的等待。

  • ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    select {
    case <-ctx.Done():
        if ctx.Err() == context.DeadlineExceeded {
            fmt.Println("操作超时")
        }
    case result := <-someOperation():
        fmt.Println("操作结果:", result)
    }
    

传递请求范围的数据:

  • context 可以在多个 goroutine 之间传递请求范围的数据,例如请求的唯一 ID、用户认证信息等。

  • 例如,在处理 HTTP 请求时,可以将请求的元数据存储在 context 中,并在各个处理函数之间传递这些数据。

  • ctx := context.WithValue(context.Background(), "requestID", "12345")
    
    go func(ctx context.Context) {
        requestID := ctx.Value("requestID").(string)
        fmt.Println("处理请求ID:", requestID)
    }(ctx)
    

sort

sort包的底层实现是多种排序的算法,例如快排,插入等等。调用时并不公开,也无需定义用那种算法。
sort包内部会根据实际情况,自动选择最高效的排序算法。

使用的时候仅仅需要三要素:序列长度,比较方式,交换方式:Len,Less,Swap

//定义序列类型
type TestStringList []string
//定义排序三要素
func (t TestStringList) Len() int { ... }
func (t TestStringList) Less(i,j int) bool { ... }
func (t TestStringList) Swap(i,j int)  { ... }

//进行排序
sort.Sort(TestStringList)
//根据定义的排序规则取反
sort.Sort(sort.Reverse(TestStringList))

//元素个数
func (t TestStringList) Len() int {
 return len(t)
}
//比较结果
func (t TestStringList) Less(i, j int) bool {
 return t[i] < t[j]
}
//交换方式
func (t TestStringList) Swap(i, j int) {
 t[i], t[j] = t[j], t[i]
}

stringList := TestStringList{
     "1-php",
     "2-java",
     "3-golang",
     "4-c",
     "5-cpp",
     "6-python",
 }
sort.Sort(stringList)
//[1-php 2-java 3-golang 4-c 5-cpp 6-python]

Slice同样也可以进行排序,仅仅需要一个回调函数确定排序方式

 sort.Slice(arr,func(i,j int){
     return arr[i]<arr[j]
 })

strconv

字符串和int类型相互转换
  • string转成int:

    int, err := strconv.Atoi(string)

  • string转成int64:

    int64, err := strconv.ParseInt(string, 10, 64)

  • int转成string:
    string := strconv.Itoa(int)

  • int64转成string:
    `string := strconv.FormatInt(int64,10)

container

heap实现优先级队列

package main

import (
    "container/heap"
    "fmt"
)
// heap实现了五个接口 分别是
// sort.Interface中的
//  ->Len() int
//  ->Less(i, j int) bool
//  ->Swap(i, j int)
// Push(x any) // add x as element Len()
// Pop() any
// 实现了这五个方法才能使用go提供的heap
type Heap []int
func (h Heap) Len() int           { return len(h) }
func (h Heap) Less(i, j int) bool { return h[i] < h[j] }
func (h Heap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *Heap) Pop() interface{} {
    // 弹出最后一个元素
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[0 : n-1]
    return x
}
func (h *Heap) Push(x interface{}) {
    *h = append(*h, x.(int))
}

func main() {
    h := &Heap{}
    heap.Init(h)
    fmt.Println(*h)
    heap.Push(h, 8)
    heap.Push(h, 4)
    heap.Push(h, 4)
    heap.Push(h, 9)
    heap.Push(h, 10)
    heap.Push(h, 3)
    fmt.Println(*h)
    for len(*h) > 0 {
       fmt.Printf("%d ", heap.Pop(h))
    }
    // 3 4 4 8 9 10
}

heap提供的方法不多,具体如下:

h := &Heap{3, 8, 6}  // 创建IntHeap类型的原始数据
func Init(h Interface)  // 对heap进行初始化,生成小根堆(或大根堆)
func Push(h Interface, x interface{})  // 往堆里面插入内容
func Pop(h Interface) interface{}  // 从堆顶pop出内容
func Remove(h Interface, i int) interface{}  // 从指定位置删除数据,并返回删除的数据
func Fix(h Interface, i int)  // 从i位置数据发生改变后,对堆再平衡,优先级队列使用到了该方法

保留关键字

golang 有 25 个保留的关键字,这些关键字不能用作程序标识符。

类型关键字介绍
声明const func import package type var这些关键字用于声明代码中的各种元素
复合类型chan interface map struct这些关键字用于声明一些特殊的复合类型
流程控制break case continue default else fallthrough for goto if range return select switch这些关键字用于控制程序运行流程
功能修饰defer go用于修饰特殊的 function

make和new的区别

  • new() 用于创建任意类型的变量,而 make() 仅用于创建引用类型的变量,包括 slice、map、chan 的数据。

    ptr := new(T) //可以看作下面写法的语法糖

    var t T

    ptr := &t

  • new() 返回的是指针,而 make() 返回的是初始化后的值。

for range 的变量地址会发生变化么?

for idx,value := range arr{}

在这个遍历中,idxvalue 在循环的过程中始终对应同一个内存地址,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给idxvalue 。由于有这个特性,for 循环里面如果开协程,不要直接把idx 或者value的地址传给协程。

尽量创建临时变量使用值拷贝,进行传递。否则可能导致底层数据被修改。

defer 的使用和原理

每个 defer 语句都对应一个 _defer 实例,多个实例使用指针连接起来形成一个单连表,保存在 Goroutined 的链表数据结构中,每次插入 _defer 实例,均插入到链表的头部,函数结束再从头部取出,从而形成后进先出(LIFO)的效果。

defer返回的时机?

defer、return、返回值三者的执行逻辑应该是:return最先执行,return负责将结果写入返回值中;接着defer开始执行一些收尾工作;最后函数携带当前返回值(可能和最初的返回值不相同)退出。

此外,panic前的defer仍然会在抛出错误之前运行,而os.Exit(0)则不会执行defer

  • defer声明函数时,参数会立刻解析
func test() { //无返回值函数
    var a int = 1
    defer fmt.Println("1、a =", a)                    //方法
    defer func(v int) { fmt.Println("2、a =", v) }(a) //有参函数
    defer func() { fmt.Println("3、a =", a) }()       //无参函数
    a++
}
// 3、a = 2
// 2、a = 1 因为传入的参数是a=1,传入的是值拷贝,即使a的值被修改,参数的值不改变
// 1、a = 1 
  • 无名返回值和有名返回值
func a() int { // 无名返回值
	var i int
	defer func() {
		i++
		fmt.Println("defer2:", i) 
	}()
	defer func() {
		i++
		fmt.Println("defer1:", i) 
	}()
	return i
}
func main() {
	fmt.Println("return:", a()) 
}
// defer1: 1
// defer2: 2
// return: 0
func b() (i int) { // 有名返回值
	defer func() {
		i++
		fmt.Println("defer2:", i)
	}()
	defer func() {
		i++
		fmt.Println("defer1:", i)
	}()
	return i 
}
func main() {
	fmt.Println("return:", b()) 
}
// defer1: 1
// defer2: 2
// return: 2

对于a(),由于defer的先进后出特性,defer1先于defer2。关于返回的顺序,首先return会产生一个临时变量 res ,再把 i 的值赋给res (这一步是不可见的) , 然后执行defer函数链,最后将res的值进行返回,因此res的值在defer之前就确定了是0,而执行完defer之后 i 变成了2。

对于b(),返回值的变量已经被显示指定了,所以会直接把 变量 i 的值进行返回,最终的返回值就是2

  • defer 和 range的示例
type Test struct {
	name string
}
func (t *Test) pp() {
	fmt.Println(t.name)
}
func main() {
	ts := []Test{{"a"}, {"b"}, {"c"}}
	for _, t := range ts {
		defer t.pp()
	}
}
// c
// c
// c

range遍历t得到的变量t共享一块内存,因此三次循环得到的是同一个地址,最后一次调用是c,因此接下来执行的那些defer语句中用到的t.name的值均为”c“。

func pp(t Test) {
	fmt.Println(t.name)
}
func main() {
	ts := []Test{{"a"}, {"b"}, {"c"}}
	for _, t := range ts {
		defer pp(t)
	}
}
//c
//b
//a

参数实时解析,每次defer的时候参数被实时传入了。

闭包是什么?

闭包是一个可以捕获其外部作用域(环境)变量的函数。它使得函数能够“记住”它创建时的外部状态,并在调用时继续访问和操作这些变量。这些被捕获的变量会随着闭包一起存在,直到闭包被销毁。闭包会保持对这些变量的引用,因此这些变量不会被销毁,直到闭包本身被销毁。

func main() {
    // 创建一个闭包
    counter := createCounter()
    // 调用闭包,修改内部变量
    fmt.Println(counter()) // 输出: 1
    fmt.Println(counter()) // 输出: 2
    fmt.Println(counter()) // 输出: 3
}
// 返回一个闭包
func createCounter() func() int {
    count := 0
    // 这个匿名函数会捕获 `count` 变量
    return func() int {
        count++
        return count
    }
}

通过闭包可以实现只能用函数制定的方式操作变量,比如add()等

  • 闭包是一个函数,它不仅包括函数的代码,还包含对外部作用域中变量的引用。

  • 它允许函数“记住”并操作创建它时的状态,直到闭包本身被销毁。

  • 闭包广泛应用于数据封装、回调函数、延迟执行等场景。

select 和 IO多路复用

select复用协程读写

最全Go select底层原理,一文学透高频用法-腾讯云开发者社区-腾讯云

第一,Go select语句采用的多路复用思想,本质上是为了达到通过一个协程同时处理多个IO请求(Channel读写事件)。

第二,select的基本用法是:通过多个case监听多个Channel的读写操作,任何一个case可以执行则选择该case执行,否则执行default。如果没有default,且所有的case均不能执行,则当前的goroutine阻塞。

第三,编译器会对select有不同的case的情况进行优化以提高性能。首先,编译器对select没有case、有单case和单case+default的情况进行单独处理。这些处理或者直接调用运行时函数,或者直接转成对channel的操作,或者以非阻塞的方式访问channel,多种灵活的处理方式能够提高性能,尤其是避免对channel的加锁。

第四,对最常出现的select有多case的情况,会调用 runtime.selectgo() 函数来获取执行 case 的索引,并生成 if 语句执行该case的代码。

第五,selectgo函数的执行分为四个步骤

首先,随机生成一个遍历case的轮询顺序 pollorder 并根据 channel 地址生成加锁顺序 lockorder,随机顺序能够避免channel饥饿,保证公平性,加锁顺序能够避免死锁;

然后,根据 pollorder 的顺序查找 scases 是否有可以立即收发的channel,如果有则获取case索引进行处理;

再次,如果pollorder顺序上没有可以直接处理的case,则将当前 goroutine 加入各 case 的 channel 对应的收发队列上并等待其他 goroutine 的唤醒;

最后,当调度器唤醒当前 goroutine 时,会再次按照 lockorder 遍历所有的case,从中查找需要被处理的case索引进行读写处理,同时从所有case的发送接收队列中移除掉当前goroutine。

  1. 随机轮询:编译器会将所有 case 生成一个随机顺序的列表(pollorder),避免某些 channel 长期饥饿;
  2. 加锁顺序:对涉及的 channel 按地址排序加锁(lockorder),防止死锁;
  3. 阻塞与唤醒:如果没有就绪的 case,会将当前 Goroutine 加入所有 channel 的等待队列,挂起直到某个 channel 就绪,之后再次遍历确认就绪的 case。

select 的特性
1)select 操作至少要有一个 case 语句,出现读写 nil 的 channel 该分支会忽略,在 nil 的 channel 上操作则会报错。
2)select 仅支持管道,而且是单协程操作。
3)每个 case 语句仅能处理一个管道,要么读要么写。
4)如果多个通道都准备好,那么 select 语句会随机选择一个通道执行。如果所有通道都没有准备好,那么执行 default 块中的代码。
5)存在 default 语句,select 将不会阻塞,但是存在 default 会影响性能。

linux下的IO模型
  • 阻塞IO : 线程发起I/O操作后挂起,直到数据就绪并完成拷贝。在这期间程序不能做其它的事情。并发能力差,资源消耗大。
  • 非阻塞IO:I/O操作立即返回状态,需轮询检查数据是否就绪。轮询CPU资源浪费
  • IO多路复用:通过单一线程监控多个文件描述符,利用select/poll/epoll等待就绪事件。高并发下资源利用率高,epoll性能最优。
  • 信号驱动IO:信号驱动IO是利用信号机制,让内核告知应用程序文件描述符的相关事件。
  • 异步IO: 和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。内核完成数据拷贝后通知应用

阻塞 vs 非阻塞 : 看线程等待结果,或者是不等待直接返回并且通过轮询进行监听

同步 vs 异步 :同步是调用者自己处理结果,异步是通过回调进行调用。

三种IO多路复用模型

select:将已连接的 Socket 都放到一个文件描述符集合,然后将文件描述符集合拷贝到内核里,让内核通过遍历来检查是否有网络事件产生,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。通过fd_set位数组管理fd,默认最大1024个。

poll:提供了更优质的编程接口,但是本质和 select 模型相同。使用pollfd结构数组,用链表突破文件描述符的个数限制,但每次调用仍然需要遍历文件描述符。

epoll:维护了一棵红黑树来跟踪所有待检测的文件描述字,红黑树的使用减少了内核和用户空间大量的数据拷贝和内存分配,大大提高了性能。同时,epoll 维护了一个链表来记录就绪事件,内核在每个文件有事件发生时将自己登记到这个就绪事件列表中,通过内核自身的文件 file-eventpoll 之间的回调和唤醒机制,减少了对内核描述字的遍历。

先用epoll_create 创建一个 epoll对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。

  • epoll 采用事件驱动模型,不再需要遍历所有 fd(维护了一个「链表」来记录就绪事件),而是只有有事件发生的 fd 才会通知进程只返回有事件的 fd,无需遍历所有 fd。
  • 内核使用一个红黑树管理 fd,不需要项select、poll一样传入整个socket集合。减少了内核和用户空间大量的数据拷贝和内存分配。

level-triggered edge-triggered 分别为水平触发和边沿触发。

level-triggered 表示只要有IO操作可以进行,比如某个文件描述符有数据可读,每次调用epoll_wait都会返回以通知程序可以进行IO操作。

edge-triggered 是指在文件描述符状态发生变化时,调用epoll_wait才会返回,如果第一次没有全部读完该文件描述符的数据而且没有新数据写入,再次调用epoll_wait都不会有通知给到程序,因为文件描述符的状态没有变化。

epoll的相对优点

特性select & pollepoll
时间复杂度O(n):每次调用需遍历所有 fd 检查状态O(1):仅关注活跃 fd,通过事件回调触发
fd 数量限制selectFD_SETSIZE(默认 1024)支持数十万 fd(仅受内存限制)
内核/用户空间交互每次调用需拷贝完整 fd 集合到内核通过 epoll_ctl 注册 fd,仅传递活跃事件
触发模式仅支持水平触发(LT)支持水平触发(LT)和边缘触发(ET)

缺点是:仅适用于 Linux 系统。且需处理 ET/LT 模式差异:ET 模式需确保读取完所有数据(否则可能丢失事件)。

Go Modules

发布于 Go1.11,“淘汰”现有的 GOPATH 的使用模式。

go path 模式

go env查看当前GOPATH 变量的结果,进入目录可以发现

go
├── bin
├── pkg
└── src
├── github.com
├── golang.org
├── google.golang.org
├── gopkg.in
....

GOPATH目录下一共包含了三个子目录,分别是:

  • bin:存储所编译生成的二进制文件。
  • pkg:存储预编译的目标文件,以加快程序的后续编译速度。
  • src:存储所有.go文件或源代码。在编写 Go 应用程序,程序包和库时,一般会以$GOPATH/src/github.com/foo/bar的路径进行存放。

因此在使用 GOPATH 模式下,我们需要将应用代码存放在固定的$GOPATH/src目录下,并且如果执行go get来拉取外部依赖会自动下载并安装到$GOPATH目录下。

弊端:无版本控制,无法同步第三方库版本

go env -w GO111MODULE=on打开go modules模式,

  • auto:只要项目包含了 go.mod 文件的话启用 Go modules,目前在 Go1.11 至 Go1.14 中仍然是默认值。
  • on:启用 Go modules,推荐设置,将会是未来版本中的默认值。
  • off:禁用 Go modules,不推荐设置。

go mod 命令

go mod init生成 go.mod 文件
go mod download下载 go.mod 文件中指明的所有依赖
go mod tidy整理现有的依赖
go mod graph查看现有的依赖结构
go mod edit编辑 go.mod 文件
go mod vendor导出项目所有的依赖到vendor目录
go mod verify校验一个模块是否被篡改过
go mod why查看为什么需要依赖某模块

go.mod

module github.com/aceld/modules_test //#用于定义当前项目的模块路径
go 1.14 //#标识当前Go版本.即初始化版本
require github.com/aceld/zinx v0.0.0-20200221135252-8a8954e75100 // indirect //# 当前项目依赖的一个特定的必须版本.indirect: 示该模块为间接依赖,也就是在当前应用程序中的 import 语句中,并没有发现这个模块的明确引用,有可能是你先手动 go get 拉取下来的,也有可能是你所依赖的模块所依赖的.

go.sum

在第一次拉取模块依赖后,会发现多出了一个 go.sum 文件,其详细罗列了当前项目直接或间接依赖的所有模块版本,并写明了那些模块版本的 SHA-256 哈希值以备 Go 在今后的操作中保证项目所依赖的那些模块版本不会被篡改。

go init()函数

一个包下可以有多个 init 函数,每个文件也可以有多个 init 函数。多个 init 函数按照它们的文件名顺序逐个初始化。应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到 main 包。不管包被导入多少次,包内的 init 函数只会执行一次。(先入先出:队列)

应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到 main 包。但包级别变量的初始化先于包内 init 函数的执行。

import _ "net/http/pprof

golang对没有使用的导入包会编译报错,但是有时我们只想调用该包的init函数,不使用包导出的变量或者方法,这时就采用上面的导入方案。

数据类型

整数 int

Golang 中的整数类型:

类型
8int8 uint8
16int16 uint16
32int32 uint32
64int64 uint64
32 或 64 (基于系统架构)int uint
  • rune : 等价于 int32 , 用于存储 Unicodeutf-8 字符
  • byte :等价于 uint8 , 用于存储 ASCII 字符

uint类型溢出 :超过最大存储值,如uint8最大是255

func main() {
	var a uint8 = 255
	var b uint8 = 1
	fmt.Println(a + b) // 0
	var c byte = 255
	fmt.Println(a == c) // true
}

Rune类型:golang 中的字符串底层实现是通过byte数组的,中文字符在unicode下占2个字节,在utf-8编码下占3个字节,而 golang 默认编码正好是utf-8,英文字符占一个1字节。

常见的进制前缀表示
前缀进制示例说明
0x十六进制 (Hex)0x1F (31 十进制)x 大小写均可,表示 16 进制。
0八进制 (Octal)075 (61 十进制)不推荐使用,部分语言已弃用。
0b二进制 (Binary)0b1011 (11 十进制)表示 2 进制,b 大小写均可。
0o八进制 (Octal)0o71 (57 十进制)更清晰的八进制表示 (Python)。
无前缀十进制 (Decimal)42默认数值形式 (10 进制)。

\0 开头,后跟 1-3 位八进制数。如\101 表示 A

\u 前缀表示,4 位十六进制数,表示 BMP 范围字符(范围:0000FFFF)。

fmt.Println("\u4F60") // 输出:你

\U 总是跟 8 位十六进制数,表示完整 Unicode 范围,包括超出 BMP 的字符。

var ch int = '\u0041'
var ch2 int = '\u03B2'
var ch3 int = '\U00101234'
fmt.Printf("%d - %d - %d\n", ch, ch2, ch3) // integer 65 - 946 - 1053236
fmt.Printf("%c - %c - %c\n", ch, ch2, ch3) // character A - β - r
fmt.Printf("%X - %X - %X\n", ch, ch2, ch3) // UTF-8 bytes 41 - 3B2 - 101234
fmt.Printf("%U - %U - %U", ch, ch2, ch3) // UTF-8 code point U+0041 - U+03B2 - U+101234

浮点数 float

浮点数就是包含小数点的数字

类型
32float32
64float64

复数 complex

复数包含虚数和实数,实数为浮点数

类型
32 位浮点数 + 虚数complex64
64 位浮点数 + 虚数complex128
// 初始化一个复数
var complexData complex64 = complex(5, 3) // 等于: 5 + 3i

// 另一种初始化方式
complexData2 := 5 + 3i

常量 const

const中的枚举

iota 可以被用作枚举值:

const (
	a = iota
	b = iota
	c = iota
)

第一个 iota 等于 0,每当 iota 在新的一行被使用时,它的值都会自动加 1,并且没有赋值的常量默认会应用上一行的赋值表达式:

// 赋值一个常量时,之后没赋值的常量都会应用上一行的赋值表达式
const (
	a = iota  // a = 0
	b         // b = 1
	c         // c = 2
	d = 5     // d = 5   
	e         // e = 5
)

// 赋值两个常量,iota 只会增长一次,而不会因为使用了两次就增长两次
const (
	Apple, Banana = iota + 1, iota + 2 // Apple=1 Banana=2
	Cherimoya, Durian                  // Cherimoya=2 Durian=3
	Elderberry, Fig                    // Elderberry=3, Fig=4
)

// 使用 iota 结合 位运算 表示资源状态的使用案例
const (
	Open = 1 << iota  // 0001
	Close             // 0010
	Pending           // 0100
)

const (
	_           = iota             // 使用 _ 忽略不需要的 iota
	KB = 1 << (10 * iota)          // 1 << (10*1)
	MB                             // 1 << (10*2)
	GB                             // 1 << (10*3)
	TB                             // 1 << (10*4)
	PB                             // 1 << (10*5)
	EB                             // 1 << (10*6)
	ZB                             // 1 << (10*7)
	YB                             // 1 << (10*8)
)

iota 也可以用在表达式中,如:iota + 50。在每遇到一个新的常量块或单个常量声明时, iota 都会重置为 0( 简单地讲,每遇到一次 const 关键字,iota 就重置为 0 )。

字符串 string

在 Go 语言中,字符串(string) 是不可变的,拼接字符串事实上是创建了一个新的字符串对象。如果代码中存在大量的字符串拼接,对性能会产生严重的影响。

常见的字符串拼接方式包括:+ , fmt.Sprintf, strings.Builder, bytes.Buffer, []byte

从基准测试的结果来看,使用 +fmt.Sprintf 的效率是最低的,和其余的方式相比,性能相差约 1000 倍,而且消耗了超过 1000 倍的内存。当然 fmt.Sprintf 通常是用来格式化字符串的,一般不会用来拼接字符串。

strings.Builderbytes.Buffer[]byte 的性能差距不大,而且消耗的内存也十分接近,性能最好且消耗内存最小的是 preByteConcat,这种方式预分配了内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,因此性能最好,且内存消耗最小。

综合易用性和性能,一般推荐使用 strings.Builder 来拼接字符串。

背后的原理

strings.Builder+ 性能和内存消耗差距如此巨大,是因为两者的内存分配方式不一样。

字符串在 Go 语言中是不可变类型,占用内存大小是固定的,当使用 + 拼接 2 个字符串时,生成一个新的字符串,那么就需要开辟一段新的空间,新空间的大小是原来两个字符串的大小之和。拼接第三个字符串时,再开辟一段新空间,新空间大小是三个字符串大小之和,以此类推。假设一个字符串大小为 10 byte,拼接 1w 次,需要申请的内存大小为:

10 + 2 * 10 + 3 * 10 + ... + 10000 * 10 byte = 500 MB 

strings.Builderbytes.Buffer,包括切片 []byte 的内存是以倍数申请的。例如,初始大小为 0,当第一次写入大小为 10 byte 的字符串时,则会申请大小为 16 byte 的内存(恰好大于 10 byte 的 2 的指数),第二次写入 10 byte 时,内存不够,则申请 32 byte 的内存,第三次写入内存足够,则不申请新的,以此类推。

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randomString(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = letterBytes[rand.Intn(len(letterBytes))]
	}
	return string(b)
}

func plusConcat(n int, str string) string {
	s := ""
	for i := 0; i < n; i++ {
		s += str
	}
	return s
}
func sprintfConcat(n int, str string) string {
	s := ""
	for i := 0; i < n; i++ {
		s = fmt.Sprintf("%s%s", s, str)
	}
	return s
}
func builderConcat(n int, str string) string {
	var builder strings.Builder
	for i := 0; i < n; i++ {
		builder.WriteString(str)
	}
	return builder.String()
}
func bufferConcat(n int, s string) string {
	buf := new(bytes.Buffer)
	for i := 0; i < n; i++ {
		buf.WriteString(s)
	}
	return buf.String()
}
func byteConcat(n int, str string) string {
	buf := make([]byte, 0)
	for i := 0; i < n; i++ {
		buf = append(buf, str...)
	}
	return string(buf)
}
func preByteConcat(n int, str string) string { // 容量已知
	buf := make([]byte, 0, n*len(str))
	for i := 0; i < n; i++ {
		buf = append(buf, str...)
	}
	return string(buf)
}
func benchmark(b *testing.B, f func(int, string) string) {
    // 每个 benchmark 用例中,生成了一个长度为 10 的字符串,并拼接 1w 次
	var str = randomString(10)
	for i := 0; i < b.N; i++ {
		f(10000, str)
	}
}
func BenchmarkPlusConcat(b *testing.B)    { benchmark(b, plusConcat) }
func BenchmarkSprintfConcat(b *testing.B) { benchmark(b, sprintfConcat) }
func BenchmarkBuilderConcat(b *testing.B) { benchmark(b, builderConcat) }
func BenchmarkBufferConcat(b *testing.B)  { benchmark(b, bufferConcat) }
func BenchmarkByteConcat(b *testing.B)    { benchmark(b, byteConcat) }
func BenchmarkPreByteConcat(b *testing.B) { benchmark(b, preByteConcat) }

数组和切片

同:存储相同类型数据的数据结构,通过下标访问,有容量和长度。

异:

  • slice长度可变,且容量会自动扩容

  • 数组是值类型,切片是引用类型。每个切片都引用了一个底层数组,切片本身不能存储任何数据,都是这底层数组存储数据,所以修改切片的时候修改的是底层数组中的数据。切片一旦扩容,指向一个新的底层数组,内存地址也就随之改变

slice的扩容原则:当切片比较小时(容量小于 1024),则采用较大的扩容倍速进行扩容(新的扩容会是原来的 2 倍),避免频繁扩容,从而减少内存分配的次数和数据拷贝的代价。当切片较大的时(原来的 slice 的容量大于或者等于 1024),采用较小的扩容倍速(新的扩容将扩大大于或者等于原来 1.25 倍),主要避免空间浪费,网上其实很多总结的是 1.25 倍,那是在不考虑内存对齐的情况下,实际上还要考虑内存对齐,扩容是大于或者等于 1.25 倍。

注意:什么时候传入函数的slice会被修改?

slice在函数没出现扩容时,slice指向的数组和函数外的数组同一个地址,此时会被修改。

而一旦出现扩容,函数内的slice指向新地址。而函数外的 slice 指向的还是原来的 slice,不会修改。

哈希表 map

map 是一种键值映射表,通过 key 获取对应的 value

// 声明 map
var m map[string]int

// 使用 make 初始化 map
m = make(map[string]int)

// 设置值
m["path"] = 66

// 输出值
fmt.Println(m["path"])
底层数据结构

https://www.topgoer.cn/docs/gozhuanjia/gozhuanjiamap

// map的基础数据结构
type hmap struct {
	count     int	 // map存储的元素对计数,len()函数返回此值,所以map的len()时间复杂度是O(1)
	flags     uint8  // 记录几个特殊的位标记,如当前是否有别的线程正在写map、当前是否为相同大小的增长(扩容/缩容?)
	B         uint8  // hash桶buckets的数量为2^B个
	noverflow uint16 // 溢出的桶的数量的近似值
	hash0     uint32 // hash种子

	buckets    unsafe.Pointer // 指向2^B个桶组成的数组的指针,数据存在这里
	oldbuckets unsafe.Pointer // 指向扩容前的旧buckets数组,只在map增长时有效
	nevacuate  uintptr        // 计数器,标示扩容后搬迁的进度

	extra *mapextra // 保存溢出桶的链表和未使用的溢出桶数组的首地址
}

// hash桶结构
type bmap struct {
    tophash  [8]uint8   // 哈希值高8位,用于快速匹配
    keys     [8]keytype // 存储8个键
    values   [8]valuetype // 存储8个值
    overflow *bmap      // 溢出桶指针
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • Go 中的 map 是基于哈希表实现的(C++的map是红黑树实现,而C++ 11新增的unordered_map则与go的map类似,都是hash实现),数据结构为hash数组 + 桶 + 溢出的桶链表

  • 查找和插入的原理:key的hash值(低阶位)与桶数量相与,得到key所在的hash桶,再用key的高8位与桶中的tophash[i]对比,相同则进一步对比key值,key值相等则找到。

    如冲突哈希,则继续从下个overflow的bucket中查找。

    如果查找不到,也不会返回空值,而是返回相应类型的0值。

  • 扩容和缩容:每次扩容hash表增大1倍,hash表只增不减。支持有限缩容,delete操作只置删除标志位,释放溢出桶的空间依靠触发缩容来实现。

    扩容触发条件

    1. 负载因子 > 6.5时,也即平均每个bucket存储的键值对达到6.5个(增量扩容)
    2. overflow数量 > 2^15时,也即overflow数量超过32768时(等量扩容)

    增量扩容:当负载因子过大时,新建一张哈希表,这张新表的 bucket 数量(桶个数)是旧表的 2 倍,每个 bucket 本身的结构没变,然后逐步把旧表里的 key-value 搬过去。考虑到如果map存储了数以亿计的key-value,Go采用逐步搬迁策略,即每次访问map时都会触发一次搬迁,以桶为单位迁移,每次最多搬迁两个 bucket(一个是当前访问key所在的bucket,然后再多迁移一个bucket),及其关联的溢出桶,一旦所有的键值对都已经重新散列到新的哈希表中,Go Map 就会将原来的bucket释放掉,将新的bucket作为 Map 的内部存储结构。

    等量扩容:所谓等量扩容,实际上并不是扩大容量,buckets数量不变,重新做一遍类似增量扩容的搬迁动作,把松散的键值对重新排列一次,以使bucket的使用率更高,进而保证更快的存取。创建一张bucket数量相同的哈希表进行操作。 等量扩容的核心是 数据迁移,但并非一次性完成,而是逐步进行。

    1. 分配新桶数组
      新桶数组的大小与旧桶相同,但桶的布局更加紧凑(减少溢出桶)。

    2. 渐进式迁移:

      • 每次对 map 进行 写入操作(插入、删除) 时,会触发迁移当前操作的旧桶及其关联溢出桶到新桶。
      • 迁移完成后,旧桶中的数据会被 标记为迁移完成,但不会被立即覆盖或删除。
    3. 访问逻辑

      • 读操作
        • 先查新buckets,若未找到且旧buckets未完全迁移,则查旧buckets。
        • 读操作不触发迁移,仅根据迁移进度判断数据位置。
      • 写操作
        • 若键在旧buckets中且未迁移,触发该bucket的迁移后再写入新buckets。
        • 写入新buckets后,旧bucket中的数据可能仍存在,但逐渐被取代。
  • hash冲突的解决:go使用链地址法来解决键冲突。冲突的数据会被添加到同一个哈希桶对应的链表中。

    其他方式:

    • 开放地址法(去寻找一个新的空闲的哈希地址)

      如线性探测法:h(x)=(Hash(x)+i)mod ,hash值+1取模

    • 再哈希法:同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。

    • 建立公共溢出区:将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中。

  • 删除元素:调用delete函数时,将对应槽位的tophash标记为empty(nil),并不立即释放内存(软删除)。删除操作可能触发扩容或迁移,间接回收空间。

扩容总结:

问题回答
是新建 bucket 还是新建整张哈希表?✅ 是新建整张哈希表,bucket 个数是原来的 2 倍
每个 bucket 大小会变吗?❌ 不会变,每个 bucket 固定结构,最多容纳 8 个 key-value
如何搬迁?每次 map 访问时,搬迁当前 bucket 和下一个 bucket,每次最多搬 2 个,直到搬完为止
搬完之后原来的 bucket 会怎样?会被 GC 回收,buckets 指向新表,oldbuckets 置空
map的并发性

Go语言中的map类型并不是并发安全的。这意味着,如果有多个goroutine尝试同时读写同一个map,可能会导致竞态条件和数据不一致,触发 fatal error: concurrent map writes

如何保证并发安全?

  • 使用互斥锁(sync.Mutex):在读写map的操作前后加锁,确保同一时间只有一个goroutine可以访问map。
  • 使用读写互斥锁(sync.RWMutex):如果读操作远多于写操作,可以使用读写锁来提高性能。读写锁允许多个goroutine同时读取map,但在写入时需要独占访问。
  • 使用并发安全的map(sync.Map):从Go 1.9版本开始,标准库中的sync包提供了sync.Map类型,这是一个专为并发环境设计的map。它提供了一系列方法来安全地在多个goroutine之间共享数据。

sync.Map 通过读写分离和延迟写入在读多写少的场景下提供更高的并发性能,而使用全局锁的 map 在读写频繁时性能较低, 需要处理更多的并发控制细节。

sync.Map 适用于读多写少的场景,而使用全局锁的 map 适用于读写操作较均衡或者对性能要求不高的场景。

sync.Map 是 Go 语言标准库中提供的并发安全的 Map 类型,它适用于读多写少的场景。以下是 sync.Map 的一些关键原理:

  1. 读写分离sync.Map 通过读写分离来提升性能。它内部维护了两种数据结构:一个只读的只读字典 (read),一个读写字典 (dirty)。读操作优先访问只读字典,只有在只读字典中找不到数据时才会访问读写字典。
  2. 延迟写入:写操作并不立即更新只读字典(read),而是更新读写字典 (dirty)。只有在读操作发现只读字典的数据过时(即 misses 计数器超过阈值)时,才会将读写字典中的数据同步到只读字典。这种策略减少了写操作对读操作的影响。
  3. 原子操作:读操作大部分是无锁的,因为它们主要访问只读的 read map,并通过原子操作 (atomic.Value) 来保护读操作;写操作会加锁(使用 sync.Mutex)保护写操作,以确保对 dirty map 的并发安全 ,确保高并发环境下的安全性。
  4. 条目淘汰:当一个条目被删除时,它只从读写字典中删除。只有在下一次数据同步时,该条目才会从只读字典中删除。
map的特性
map的key限制

在Go语言中,map的key可以是任何可以比较的类型。这包括所有的基本类型,如整数、浮点数、字符串和布尔值,以及结构体和数组,只要它们没有被定义为包含不可比较的类型(切片、map和函数类型是不可比较的)。

map遍历的无序性

在Go语言中,map的循环(遍历)是无序的。这意味着当你遍历map时,每次遍历的顺序可能都不同。Go语言的map是基于哈希表的,因此元素的存储顺序是不确定的,并且可能会随着元素的添加、删除等操作而改变。

map删除key后的内存释放

在 Golang 中,当删除一个 map 的键(使用 delete() 函数)后,键值对会从 map 中移除,但其占用的内存是否立即释放取决于具体场景:

  • 删除后,键值对的内存会从 map 结构中移除,但底层分配的内存并不会立刻被释放,而是可能保留以供后续使用。
  • 如果 map 不再使用,整个 map 的内存会在垃圾回收(GC)时被释放。

Golang 的 map 底层使用桶(buckets)来存储键值对。当某些键被删除后,底层内存不会立即被释放,但空闲空间会被保留以供后续使用。如果整个 map 变量不再被引用,Golang 的垃圾回收器(GC)会释放与该 map 相关的所有内存。注:map的底层hash表会分配在堆上,但是指向map的指针会根据逃逸分析决定位置

**nil map VS 空 map **
var a map[int]int //nil
var b = map[int]int{} //空map

nil map不占用实际的内存空间来存储键值对,因为它没有底层的哈希表结构。不占用实际的内存空间来存储键值对,因为它没有底层的哈希表结构。空map分配了底层的哈希表结构,但表中没有存储任何键值对。

对增删查操作的影响

  • nil map
    • 添加操作:向nil map中添加键值对将导致运行时panic,因为nil map没有底层的哈希表来存储数据。
    • 删除操作:无效
    • 查找操作:从nil map中查找键值对不会引发panic,但会返回对应类型的零值,表示未找到键值对。
  • 空map
    • 添加操作:向空map中添加键值对是安全的,键值对会被添加到map中。
    • 删除操作:从空map中删除键值对是一个空操作,不会引发panic,因为map中原本就没有该键值对。
    • 查找操作:从空map中查找不存在的键值对也会返回对应类型的零值,表示未找到键值对。
redis hash 区别

一、底层实现对比

维度Go mapRedis Hash
数据结构哈希表(数组 + 桶 + 溢出桶)哈希表(数组 + 链表,或 ziplist 紧凑结构)
冲突解决链地址法(溢出桶链接)链地址法(链表链接冲突键值对)
内存布局桶内存储键值对数组(每个桶最多8个键值对)哈希表节点存储键值对,通过指针链接
扩容触发条件负载因子 > 6.5 或溢出桶过多负载因子 > 1(默认)或特定配置阈值
渐进式迁移策略写操作触发迁移(每次迁移一个桶)定时任务或操作触发迁移(每次迁移部分条目)
并发安全非并发安全(需配合 sync.Map 或锁)单线程模型,天然并发安全

二、扩容过程中的数据访问差异

1. Go map 的扩容访问流程

  • 读操作

    • 优先访问新桶(newbuckets)。
    • 若未找到且旧桶(oldbuckets)未完全迁移,则回查旧桶。
    • 不触发迁移,仅根据迁移进度判断数据位置。
  • 写操作

    • 若目标键在旧桶中且未迁移,则触发迁移该旧桶到新桶。
    • 写入操作直接作用于新桶。
    • 迁移原子性:单个桶迁移完成后,旧桶数据不再修改。
  • 示例

    m := make(map[int]string)
    m[1] = "a" // 触发扩容后,写操作迁移旧桶
    v := m[1]  // 读操作可能同时查新旧桶
    

2. Redis Hash 的扩容访问流程

  • 读/写操作

    • 在进行哈希表查询(例如 GET 操作)时,Redis 会先检查 旧哈希表(ht[0])。
    • 如果在 旧哈希表 中找到了匹配的元素,Redis 会返回该元素。
    • 如果元素不在旧哈希表中,Redis 会继续查找 新哈希表(ht[1]),即使在进行 rehash 的过程中,新哈希表中的元素还在逐步迁移。
  • 定时迁移

    • Redis 后台任务(rehash)逐步迁移数据,每次迁移固定数量的桶。
    • 操作触发迁移时,每次迁移一个键值对。
  • 示例

    HSET myhash key1 value1  # 触发扩容时,写操作可能触发迁移
    HGET myhash key1         # 读操作可能触发迁移
    

三、核心差异总结

场景Go mapRedis Hash
迁移触发者写操作触发迁移(被动)定时任务或读写操作触发(主动+被动)
迁移粒度按桶迁移(一次一个桶)按键值对迁移(单次操作迁移部分或全部)
数据一致性迁移过程中新旧桶共存,访问透明迁移过程中新旧表共存,访问可能触发迁移
并发处理无锁读,写操作需加锁单线程模型,无并发问题

四、设计哲学对比

  • Go map
    • 目标:高性能、低延迟的进程内数据结构。
    • 优化点:通过桶+溢出桶减少内存碎片,写操作分摊迁移开销。
  • Redis Hash
    • 目标:支持高并发、持久化的分布式数据结构。
    • 优化点:紧凑存储(ziplist)节省内存,单线程模型简化迁移逻辑。

五、实际应用场景

  • Go map:适合高频读写的内存缓存、临时状态管理(如 HTTP 请求上下文)。
  • Redis Hash:适合分布式缓存、配置存储、对象属性聚合(如用户画像)。
如何实现set?

Go中是不提供Set类型的,Set是一个集合,其本质就是一个List,只是List里的元素不能重复。
Go提供了map类型,但是我们知道,map类型的key是不能重复的,因此,我们可以利用这一点,来实现一个set。那value呢?value我们可以用一个常量来代替,比如一个空结构体,实际上空结构体不占任何内存,使用空结构体,能够帮我们节省内存空间,提高性能

mapSet := make(map[string]struct{}, 0) //value为空
mapSet["Foo"] = struct{}{}
_, exists := mapSet["Foo"] 
fmt.Println(exists)// true
bitMap 位图

bitset 中每个数子用一个 bit 即能表示,对于一个 int8 的数字,我们可以用它表示 8 个数字,能帮助我们大大节省数据的存储空间。
bitset 最常见的应用有 bitmap 和 flag,即位图和标志位。这里,我们先尝试用它表示一些操作的标志位。比如某个场景,我们需要三个 flag 分别表示权限1、权限2和权限3,而且几个权限可以共存。我们可以分别用三个常量 F1、F2、F3 表示位 Mask。

type Bits uint8

const (
    F0 Bits = 1 << iota
    F1
    F2
)

func Set(b, flag Bits) Bits    { return b | flag }
func Clear(b, flag Bits) Bits  { return b &^ flag }
func Toggle(b, flag Bits) Bits { return b ^ flag }
func Has(b, flag Bits) bool    { return b&flag != 0 }

func main() {
    var b Bits
    b = Set(b, F0)
    b = Toggle(b, F2)
    for i, flag := range []Bits{F0, F1, F2} {
        fmt.Println(i, Has(b, flag))
    }
}

例子中,我们本来需要三个数才能表示这三个标志,但现在通过一个 uint8 就可以。bitset 的一些操作,如设置 Set、清除 Clear、切换 Toggle、检查 Has 通过位运算就可以实现,而且非常高效。

要注意的是,bitset 和前面的 set 的区别,bitset 的成员只能是 int 整型,没有 set 灵活。平时的使用场景也比较少,主要用在对效率和存储空间要求较高的场景。

Channel机制

底层结构

简单介绍:channel 的数据结构包含 qccount 当前队列中剩余元素个数,dataqsiz 环形队列长度,即可以存放的元素个数,buf 环形队列指针,elemsize 每个元素的大小,closed 标识关闭状态,elemtype 元素类型,sendx 队列下表,指示元素写入时存放到队列中的位置,recv 队列下表,指示元素从队列的该位置读出。recvq 等待读消息的 goroutine 队列,sendq 等待写消息的 goroutine 队列,lock 互斥锁,chan 不允许并发读写。
无缓冲和有缓冲区别: 管道没有缓冲区,从管道读数据会阻塞,直到有协程向管道中写入数据。同样,向管道写入数据也会阻塞,直到有协程从管道读取数据。管道有缓冲区但缓冲区没有数据,从管道读取数据也会阻塞,直到协程写入数据,如果管道满了,写数据也会阻塞,直到协程从缓冲区读取数据。
channel 的一些特点 1)、读写值 nil 管道会永久阻塞 2)、关闭的管道读数据仍然可以读数据 3)、往关闭的管道写数据会 panic 4)、关闭为 nil 的管道 panic 5)、关闭已经关闭的管道 panic
向 channel 写数据的流程: 如果等待接收队列 recvq 不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从 recvq 取出 G,并把数据写入,最后把该 G 唤醒,结束发送过程; 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程; 如果缓冲区中没有空余位置,将待发送数据写入 G,将当前 G 加入 sendq,进入睡眠,等待被读 goroutine 唤醒;
向 channel 读数据的流程: 如果等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G,把 G 中数据读出,最后把 G 唤醒,结束读取过程; 如果等待发送队列 sendq 不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程; 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;将当前 goroutine 加入 recvq,进入睡眠,等待被写 goroutine 唤醒;
使用场景: 消息传递、消息过滤,信号广播,事件订阅与广播,请求、响应转发,任务分发,结果汇总,并发控制,限流,同步与异步

type hchan struct {
    //channel分为无缓冲和有缓冲两种。
    //对于有缓冲的channel存储数据,借助的是如下循环数组的结构
    qcount   uint           // 循环数组中的元素数量
    dataqsiz uint           // 循环数组的长度
    buf      unsafe.Pointer // 指向底层循环数组的指针
    elemsize uint16 		//能够收发元素的大小
    
    closed   uint32   //channel是否关闭的标志
    elemtype *_type   //channel中的元素类型
    
    //有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
    //当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
    sendx    uint   // 已发送元素在循环数组中的索引
    recvx    uint   // 已接收元素在循环数组中的索引
    
    //当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
    //当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
    recvq    waitq  // 等待接收的 goroutine 队列
    sendq    waitq  // 等待发送的 goroutine 队列
    
    lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

总结hchan结构体的主要组成部分有四个:

  • 用来保存goroutine之间传递数据的循环链表。====> buf。
  • 用来记录此循环链表当前发送或接收数据的下标值。====> sendx和recvx。
  • 用于保存向该chan发送和从改chan接收数据的goroutine的队列。====> sendq 和 recvq
  • 保证channel写入和读取数据时线程安全的锁。 ====> lock

对于无缓存的Channel,底层结构如下,waitq 是sudog的一个双向链表,而sudog 实际上是对 goroutine 的一个封装:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

type waitq struct {
    first *sudog
    last  *sudog
}

**而对于有缓冲的channel底层数据结构是发送接收者列表的队列,以及一个循环数组和对应的发送数据的下标和接收数据的下标。**例如,创建一个容量为 6 的,元素为 int 型的 channel 数据结构如下 :elemsize(8)表示int占8个字节。在64位操作系统中,int类型占用8个字节(64位),其取值范围与int64相同,在32位操作系统中,int类型占用4个字节(32位),其取值范围与int32相同。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

并发安全

channel 是否线程安全?
  1. Golang的Channel,发送一个数据到Channel 和 从Channel接收一个数据 都是 原子性的。
  2. 而且Go的设计思想就是:不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。
  3. 也就是说,设计Channel的主要目的就是在多任务间传递数据的,这当然是安全的

锁的使用

  • 保护共享状态hchan 中的 qcountsendxrecvx 等字段的读写需要加锁。
  • 协调发送/接收操作:确保同一时间只有一个 goroutine 修改 Channel 状态。

Channel 底层有锁hchan.lock 保护元数据和等待队列,但锁的粒度极细。

读写流程和问题

向一个channel中写数据简单过程如下:

  1. 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
  2. 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
  3. 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;

从一个channel读数据简单过程如下:

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
  2. 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
  4. 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;

关闭流程

关闭 channel 时,recvq 中等待接收数据的 Goroutine 会被唤醒,这些 Goroutine 会接收到 channel 的零值,并继续执行。

对于有缓冲的 channelsendq 中的 Goroutine 会被唤醒,但由于 channel 已关闭,它们会尝试发送数据时会发生 panic

关闭后的 channel 不再接受写入操作,尝试向已关闭的 channel 发送数据会导致运行时 panic

  • 给一个 nil channel 发送数据,造成永远阻塞
  • 从一个 nil channel 接收数据,造成永远阻塞
  • 给一个已经关闭的 channel 发送数据,引起 panic
  • 从一个已经关闭的 channel 接收数据,如果缓冲区中为空,则返回一个零值(如果非空会返回缓存区的数据,如果没有对象引用channel时会被gc)
  • 无缓冲的channel是同步的,而有缓冲的channel是非同步的
  • 关闭一个nil channel 或者是 已经关闭的close的channel都会panic

内存泄漏

泄漏的原因是 goroutine 操作 channel 后,处于发送或接收阻塞状态,而 channel 处于满或空的状态,一直得不到改变。同时,垃圾回收器也不会回收此类资源,进而导致 gouroutine 会一直处于等待队列中,不见天日。

另外,程序运行过程中,对于一个 channel,如果没有任何 goroutine 引用了,gc 会对其进行回收操作,不会引起内存泄漏。

外层的协程能捕获子协程的panic吗

  • 在Go语言中,协程是相互独立的执行单元。当一个子协程发生panic时,它会在自己的执行栈中进行异常处理流程。通常情况下,外层协程无法直接捕获子协程的panic
  • 这是因为panicrecover的机制是基于当前协程的执行栈。recover函数只能在defer语句中使用,并且只能恢复当前协程中发生的panic。例如,主协程中的recover函数不能捕获子协程中抛出的panic
  • 从执行流程角度看,每个协程就像是一个独立的小世界,当子协程出现panic,它会在自己的世界里按照panic的处理规则(如逆序执行defer函数)进行处理,这个过程不会自动和外层协程交互,使得外层协程不能简单地捕获子协程的panic

panic都会被捕获吗?哪些panic不会捕获

  • 不是所有的panic都会被捕获。如果没有在合适的位置使用deferrecover来处理panic,那么当panic发生时,程序会沿着函数调用栈向上回溯,直到找到可以处理它的recover调用或者程序直接崩溃。例如,在一个没有任何defer - recover机制的简单函数中发生了panic,并且这个函数没有被其他可以捕获panic的函数调用,那么这个panic就不会被捕获,程序会直接退出。
  • 而在有deferrecover的函数或者协程中,当发生panic时,recover可以捕获到panic的值,并且可以在这个函数内部进行处理,阻止panic继续向上传播导致程序崩溃。

Goroutine原理

协程与线程、进程

进程:是应用程序的启动实例,每个进程都有独立的内存空间,不同的进程通过进程间的通信方式来通信。由操作系统内核调度。是资源分配的最小单位

线程:从属于进程,每个进程至少包含一个线程,线程是 CPU 调度的最小单位,拥有独立的栈和寄存器,多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信。由操作系统内核调度

协程:为轻量级线程,与线程相比,协程不受操作系统的调度,协程的调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。共享线程的资源,由用户程序控制调度

特性进程线程协程(Goroutine)
本质资源分配单位执行单位(进程内)用户态轻量级执行单元
内存隔离性完全隔离(独立地址空间)共享进程内存(需同步)共享进程内存(通过 Channel 通信)
创建开销高(MB 级内存,ms 级时间)中(MB 级栈,μs 级时间)极低(KB 级栈,ns 级时间)
切换开销高(内核态切换)中(内核态切换)极低(用户态切换,无系统调用)
调度主体操作系统操作系统Go 运行时(用户态调度器)
并发数量上限数百数千百万级(如 100 万+)
通信方式IPC(管道、Socket 等)共享内存(需锁/原子操作)Channel(线程安全通信)
阻塞影响仅阻塞自身进程阻塞所属进程的所有线程仅阻塞当前协程,线程可复用
典型应用场景隔离性任务(如 Docker 容器)CPU 密集型计算(如矩阵运算)高并发 I/O 服务(如 Web 服务器)
数据竞争风险无(天然隔离)高(需手动同步)无(通过 Channel 避免共享)

GMP模型

G 代表着 goroutine:用户级协程,包含执行代码和上下文。

P (Processor)代表着上下文处理器:管理Goroutine队列并与M绑定,每个P对应一个G的本地队列

M(Machine) 代表 thread 线程:内核线程,由操作系统调度。

在 GPM 模型,有一个全局队列(Global Que ue):存放等待运行的 G,还有一个 P 的本地队列:也是存放等待运行的 G,但数量有限,不超过 256 个。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

GMP调度流程:
  1. 创建 Goroutine:
    • 当通过 go func() 创建新的 Goroutine 时,G 会首先被加入到与当前 P 关联的本地队列中。
    • 如果 P 的本地队列已满(超过 256 个 G),会将P本地队列中的一半G打乱顺序移入全局队列。
  2. 调度与执行:
    • 每个 M 与一个 P 绑定,M 从 P 的本地队列中获取一个 G 来执行。
    • 如果 P 的本地队列为空,M 会尝试从全局队列或其他 P 的本地队列中偷取(work stealing)任务执行。
  3. 系统调用与阻塞:
    • 当 G 执行过程中发生阻塞或系统调用,**M 会被阻塞。**这时,P 会解绑当前的 M,并尝试寻找或创建新的 M 来继续执行其他 G。
    • 阻塞结束后,原来的 M 会尝试重新绑定一个 P 继续执行。
G,M,P个数问题

G的数量在理论上是没有上限的,只要系统的内存足够,就可以创建大量的goroutine,但实际上它们会受到系统可用内存的限制。每个goroutine都需要分配一定的栈空间,而且goroutine之间共享的数据结构(如全局变量、通道等)也会占用内存。

P的数量由GOMAXPROCS决定,通常设置为逻辑CPU数的两倍,这是为了提高调度的并行性和效率。每个P都可以绑定到一个M上执行goroutine,而设置更多的P可以使得在某些M阻塞时,其他M仍然可以执行P上的goroutine,从而减少等待时间。

M的数量是动态变化的,Go运行时根据需要创建和销毁M。当一个M上的所有goroutine都阻塞时,该M可能会被销毁,而当有goroutine等待执行但没有可用的M时,会创建新的M。M的最大数量(默认通常是10000)。

关键机制

work stealing(工作量窃取) 机制:当本地队列为空时,会优先从全局队列里进行窃取,若全局G为空,之后会从其它的P队列里窃取一半的G,放入到本地P队列里。
hand off (移交)机制:协程G发生阻塞时,他所在的本地队列和绑定的P会被切换到空闲/新的M上,原来的M和阻塞的G会睡眠或者销毁。G阻塞结束后尝试获取空闲的P执行,获取不到P就会加入全局G中延迟等待。

抢占式调度:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,Goroutine执行超过10ms就会强制让出CPU,防止其他协程饿死。这就是goroutine不同于coroutine的一个地方。

在编译阶段,Go 在函数调用点插入了一个“抢占检查点”(类似中断)。只要当前 Goroutine 有机会调用函数,就会检查 G.preempt 标志位。如果标志为 true,就中断执行,把控制权交还调度器。

Go 在后台启动了一个系统监控线程 sysmon,它会定期检查某个 goroutine 占用 CPU 时间太久。如果占用太久,调度器会设置 g.preempt = true ,向这个 goroutine 对应的 M 发信号(软中断)。下一次 G 执行函数调用或检查点时,发现标志位。让出 CPU,进入调度器,挂起,切换其他 G

GMP的生命周期

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在启动第一个G之后M0就和其他的M一样了。

G0是每次启动一个M都会第一个创建的goroutine ,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。

我们来跟踪一段代码

package main 
import "fmt" 
func main() {
    fmt.Println("Hello world") 
}

接下来我们来针对上面的代码对调度器里面的结构做一个分析。
也会经历如上图所示的过程:

  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。
  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
  3. 示例代码中的main函数是main.main,runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。
  4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
  5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境
  6. M运行G
  7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

GMP介绍模板

Go 语言的 GMP 调度模型 是 Go 并发的核心,P 负责调度,M 负责执行,G 是具体任务。当 M 被阻塞时,P 会解绑 M,继续调度其他 Goroutine,避免 CPU 资源浪费。同时 Go 运行时通过 工作窃取、sysmon 监控、Goroutine 复用、10ms 饥饿检测 来提升并发效率。最终,它实现了比传统线程池更轻量、更高效的并发调度机制。

1.什么是 GMP 模型?(概念层面)

Go 运行时使用 GMP 调度模型 来高效管理 Goroutine,它是 Go 语言的核心并发调度机制,主要由三部分组成:⏩一句话总结: G 是任务,P 是调度器,M 是执行器。

  • G(Goroutine):Go 语言中的轻量级线程,用户态调度,不直接绑定 OS 线程。
  • M(Machine):操作系统的 内核线程,用来真正执行 Goroutine。
  • P(Processor):逻辑处理器,负责 Goroutine 调度,P 绑定 M,P 持有 G,M 需要 P 才能执行 G。

2.GMP 之间如何交互?(调度流程)

M 需要 P 才能执行 Goroutine,每个 P 维护一个 本地 run queue

M 会从 Prun queueG,如果本地 run queue 为空:

  1. 先去 全局 run queue 取任务。
  2. 如果仍然没有任务,尝试 从其他 PGoroutine(Work Stealing)。

如果 M 被阻塞(如 syscall),P 会解绑 M,然后绑定新的 M 继续执行 G

⏩一句话总结: P 负责调度,M 负责执行,G 在 P 的任务队列中被调度,阻塞时会切换。

3.关键优化机制?(性能层面)

Goroutine 复用,避免频繁创建线程

  • M 不是无限增加的,Go 运行时会复用 M,减少线程创建销毁的开销。

Work Stealing(工作窃取)

  • 如果 P 没有 Goroutine 了,会从其他 P 窃取 G 来执行,提高 CPU 利用率。

sysmon 监控线程

  • Go 运行时有 sysmon 线程,负责:
    • 监控阻塞的 M,避免长时间 M 占用 P
    • 定期检查 G 是否被长时间占用。

10ms 饥饿检测

  • 如果 M 长时间(10ms)无法获取 P,Go 运行时会创建新的 M 以提高吞吐量

一句话总结: GMP 通过 Goroutine 复用、工作窃取、sysmon 监控、10ms 饥饿检测等优化,提高并发性能。

  1. 深入问题

  2. 如果 Goroutine 阻塞了,P 会怎么处理?

    • P 不会一直等待,它会解绑 M,尝试执行其他 Goroutine,或从全局队列窃取任务。
  3. syscall(系统调用)会影响 GMP 调度吗?

    • 会的。如果 M 进入 syscall,P 会解绑这个 M,转交给新的 M 继续执行 G,防止 CPU 资源浪费。
  4. Goroutine 是如何调度的?

    • 优先使用 P 的本地 run queue,其次是全局 run queue,最后是窃取其他 P 的任务
  5. P 和 GOMAXPROCS 有什么关系?

    • P 的数量受 runtime.GOMAXPROCS() 控制,决定可同时运行的 Goroutine 数量

原子操作

原子操作就是不可中断的操作,外界是看不到原子操作的中间状态,要么看到原子操作已经完成,要么看到原子操作已经结束。在某个值的原子操作执行的过程中,CPU 绝对不会再去执行其他针对该值的操作,那么其他操作也是原子操作。

Go 提供 sync/atomic 包来实现 无锁的原子操作,适用于高性能、轻量级的并发控制。

import "sync/atomic"

// 增加操作
atomic.AddInt32(&num, 1)

// 原子读取
atomic.LoadInt32(&num)

// 原子写入
atomic.StoreInt32(&num, 10)

// 原子比较并交换
atomic.CompareAndSwapInt32(&num, oldVal, newVal)

如果不适用原子操作,可能造成竞态条件。这是因为i++不是原子操作,分为new_i = i + 1和i= new_i两步组成,在不同的协程中执行速度可能不一样,导致两个协程读到了同样的i,产生了覆盖加操作,导致最终结果偏小。如下

var counter int32 = 0
func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            atomic.AddInt32(&counter, 1)
            // count ++ //输出 990
            wg.Done()
        }()
    }
    wg.Wait()
    fmt.Println("计数器最终值:", counter) // 1000
}

CAS

CAS(Compare-And-Swap) 是一种 无锁(Lock-Free) 的原子操作,广泛用于并发编程中实现线程安全的共享资源操作。它通过硬件指令(如 x86 的 CMPXCHG)保证操作的原子性,避免了传统锁(如互斥锁)带来的上下文切换和阻塞开销。

操作语义

func CompareAndSwap(ptr *T, old T, new T) bool {
    if *ptr == old {
        *ptr = new
        return true
    }
    return false
}
  • 检查内存地址ptr的当前值是否等于old:
    • 若相等,将 ptr 的值设置为 new,并返回 true
    • 否则,不修改内存,返回 false
  • 原子性:整个操作由 CPU 指令直接保证,中间不会被其他线程打断。

应用场景

原子计数器

var counter int32 = 0

func increment() {
 for {
     old := atomic.LoadInt32(&counter)
     new := old + 1
     if atomic.CompareAndSwapInt32(&counter, old, new) {
         break
     }
 }
}
  • 通过循环重试(乐观锁)实现无锁的计数器递增。

乐观锁:CAS + 自选锁

  • 数据库事务、版本控制等场景中,通过 CAS 检查版本号避免冲突。

优点和缺点:

无死锁风险,避免线程阻塞和上下文切换,适合低竞争场景(如少量并发写)

但自循环时间长,开销大。只能保证一个共享变量的原子操作。存在ABA问题

ABA问题

现象:某线程读取内存值为 A,其他线程将值改为 B 后又改回 A,导致 CAS 误判未发生修改。

解决方案:

版本号/标记:每次修改操作递增一个版本号或附加标记,CAS 同时检查值和版本号。

type VersionedValue struct {
 value int
 version uint64
}

var v atomic.Value // 存储 VersionedValue

func update(newValue int) {
 for {
     old := v.Load().(VersionedValue)
     if old.value != newValue {
         new := VersionedValue{value: newValue, version: old.version + 1}
         if atomic.CompareAndSwapPointer(&v, old, new) {
             break
         }
     }
 }
}

互斥锁

互斥锁是一种最基本的锁,它的作用是保证在同一时刻只有一个 goroutine 可以访问共享资源。在 Go 语言中,互斥锁由 sync 包提供,使用 syns.Mutex 结构体来表示。

Go语言的sync.Mutex悲观锁的实现。

悲观锁:当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量

import "sync"

var mu sync.Mutex

func criticalSection() {
    mu.Lock()   // 加锁
    // 临界区
    mu.Unlock() // 解锁
}

适合保护共享资源的并发读写,比如 map、全局变量等。

sync.Mutex 是一种互斥锁(mutual exclusion)

内部使用了 CAS(Compare-And-Swap) + 自旋锁 + 阻塞队列 组合实现。

Lock 时如果锁已被占用,则当前 Goroutine 会阻塞

使用的是 Go runtime 提供的调度器来挂起和唤醒 Goroutine,避免用户层的 busy-wait。

底层实现
type Mutex struct {
    state int32  // 锁状态:0表示未锁定,1表示已锁定
    sema  uint32 // 信号量,用于协调 Goroutine 的阻塞和唤醒
}

内部状态变化大致流程:

  1. 调用 Lock() 时,CAS 尝试将 state 从 0 改为 1。
  2. 如果失败,说明已被锁,会进入队列排队(自旋几次后挂起)。
  3. 解锁时调用 Unlock(),会将 state 设置为 0,并唤醒排队的协程。

同一个 Goroutine 不可以重复 Lock 而不 Unlock,否则会死锁。也就是golang的锁是不可重入的

Mutex 的几种模式

🟩 正常模式(Normal mode)

  • 默认工作模式。
  • 加锁请求是FIFO公平队列
  • 如果锁被频繁释放又被新的 goroutine 抢占,可能导致等待队列中的老 goroutine 饿死
  • 性能高,但公平性差。

🟨 饥饿模式(Starvation mode)

  • 当某个 goroutine 等待锁超过 1ms(在老版本中),系统会把 Mutex 转为饥饿模式。
  • 饥饿模式下,锁会严格交给队头的 goroutine,新来的都得排队。
  • 保证公平性,防止 goroutine 被饿死,但性能略低
  • 只要系统发现锁竞争不激烈(后面没人排队了),就会回退到正常模式

Go 的 Mutex 默认是非公平锁(谁先抢到谁执行),这样可以提高吞吐。但为了防止“饿死”,当锁被频繁抢占时会让等待者优先执行(抢占调度也会帮助)。

1)正常模式

  1. 当前的mutex只有一个goruntine来获取,那么没有竞争,直接返回。
  2. 新的goruntine进来,如果当前mutex已经被获取了,则该goruntine进入一个先入先出的waiter队列,在mutex被释放后,waiter按照先进先出的方式获取锁。该goruntine会处于自旋状态(不挂起,继续占有cpu)
  3. 新的goruntine进来,mutex处于空闲状态,将参与竞争。新来的 goroutine 有先天的优势,它们正在 CPU 中运行,可能它们的数量还不少,所以,在高并发情况下,被唤醒的 waiter 可能比较悲剧地获取不到锁,这时,它会被插入到队列的前面。如果 waiter 获取不到锁的时间超过阈值 1 毫秒,那么,这个 Mutex 就进入到了饥饿模式。

2)饥饿模式
在饥饿模式下,Mutex 的拥有者将直接把锁交给队列最前面的 waiter。新来的 goroutine 不会尝试获取锁,即使看起来锁没有被持有,它也不会去抢,也不会 spin(自旋),它会乖乖地加入到等待队列的尾部。 如果拥有 Mutex 的 waiter 发现下面两种情况的其中之一,它就会把这个 Mutex 转换成正常模式:

  1. 此 waiter 已经是队列中的最后一个 waiter 了,没有其它的等待锁的 goroutine 了;
  2. 此 waiter 的等待时间小于 1 毫秒。
特点

互斥性:在任何时刻,只有一个 goroutine 可以持有sync.Mutex的锁。如果多个 goroutine 尝试同时获取锁,那么除了第一个成功获取锁的 goroutine 之外,其他 goroutine 将被阻塞,直到锁被释放。

非重入性:如果一个 goroutine 已经持有了 sync.Mutex 的锁,那么它不能再次请求这个锁,这会导致死锁(抛出panic)。

读写锁

后端 - go 读写锁实现原理解读 - 个人文章 - SegmentFault 思否

允许多个读操作同时进行,但写操作会完全互斥。这意味着在任何时刻,可以有多个 goroutine 同时读取某个资源,但写入资源时,必须保证没有其他 goroutine 在读取或写入该资源。

适用于读多写少的场景,可以显著提高程序的并发性能。例如,在缓存系统、配置管理系统等场景中,读操作远多于写操作,使用sync.RWMutex 可以在保证数据一致性的同时,提高读操作的并发性。使用方法与普通的锁基本相同,唯一的区别在于读操作的加锁、释放锁用的是RLock方法和RUnlock方法

底层原理

在看源码之前我们不妨先思考一下,如果自己实现,需要怎么设计这个数据结构来满足上面那三个要求,然后再参看源码会有更多理解。
首先,为了满足第二点和第三点要求,肯定需要一个互斥锁:

type RWMutex struct{
    w Mutex // held if there are pending writers
    ...
}

这个互斥锁是在写操作时使用的:

func (rw *RWMutex) Lock(){
    ...
    rw.w.Lock()
    ...
}

func (rw *RWMutex) Unlock(){
    ...
    rw.w.Unlock()
    ...
}

而读操作之间是不互斥的,因此读操作的RLock()过程并不获取这个互斥锁。但读写之间是互斥的,那么RLock()如果不获取互斥锁又怎么能阻塞住写操作呢?go语言的实现是这样的:
通过一个int32变量记录当前正在读的goroutine数:

type RWMutex struct{
    w           Mutex // held if there are pending writers
    readerCount int32 // number of pending readers
    ...
}

每次调用Rlock方法时将readerCount加1,对应地,每次调用RUnlock方法时将readerCount减1:

func (rw *RWMutex) RLock() {
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
        // 如果readerCount小于0则通过同步原语阻塞住,否则将readerCount加1后即返回
        runtime_SemacquireMutex(&rw.readerSem, false, 0)
    }
}

func (rw *RWMutex) RUnlock() {
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
    // 如果readerCount减1后小于0,则调用rUnlockSlow方法,将这个方法剥离出来是为了RUnlock可以内联,这样能进一步提升读操作时的取锁性能
        rw.rUnlockSlow(r)
    }
}

既然每次RLock时都会将readerCount增加,那判断它是否小于0有什么意义呢?这就需要和写操作的取锁过程Lock()参看:

// 总结一下Lock的流程:1. 阻塞新来的写操作;2. 阻塞新来的读操作;3. 等待之前的读操作完成;
func (rw *RWMutex) Lock() {
    // 通过rw.w.Lock阻塞其它写操作
    rw.w.Lock()
    // 将readerCount减去一个最大数(2的30次方,RWMutex能支持的最大同时读操作数),这样readerCount将变成一个小于0的很小的数,
    // 后续再调RLock方法时将会因为readerCount<0而阻塞住,这样也就阻塞住了新来的读请求
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
    // 等待之前的读操作完成
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
}

写操作获取锁时通过将readerCount改成一个很小的数保证新来的读操作会因为readerCount<0而阻塞住;那之前未完成的读操作怎么处理呢?很简单,只要跟踪写操作Lock之前未完成的reader数就行了,这里通过一个int32变量readerWait来做这件事情:

type RWMutex struct{
    w           Mutex // held if there are pending writers
    readerCount int32 // number of pending readers
    readerWait  int32 // number of departing readers
    ...
}

每次写操作Lock时会将当前readerCount数量记在readerWait里。
回想一下,当写操作Lock后readerCount会小于0,这时reader unlock时会执行rUnlockSlow方法,现在可以来看它的实现过程了:

func (rw *RWMutex) rUnlockSlow(r int32) {
    if r+1 == 0 || r+1 == -rwmutexMaxReaders {
        throw("sync: RUnlock of unlocked RWMutex")
    }
    // 每个reader完成读操作后将readerWait减小1
    if atomic.AddInt32(&rw.readerWait, -1) == 0 {
        // 当readerWait为0时代表writer等待的所有reader都已经完成了,可以唤醒writer了
        runtime_Semrelease(&rw.writerSem, false, 1)
    }
}

最后再看写操作的释放锁过程:

func (rw *RWMutex) Unlock() {
    // 将readerCount置回原来的值,这样reader又可以进入了
    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
    if r >= rwmutexMaxReaders {
        throw("sync: Unlock of unlocked RWMutex")
    }
    // 唤醒那些等待的reader
    for i := 0; i < int(r); i++ {
        runtime_Semrelease(&rw.readerSem, false, 0)
    }
    // 释放互斥锁,这样新的writer可以获得锁
    rw.w.Unlock()
}

将上面这些过程梳理一下:

  1. 如果没有writer请求进来,则每个reader开始后只是将readerCount增1,完成后将readerCount减1,整个过程不阻塞,这样就做到“并发读操作之间不互斥”;
  2. 当有writer请求进来时首先通过互斥锁阻塞住新来的writer,做到“并发写操作之间互斥”;
  3. 然后将readerCount改成一个很小的值,从而阻塞住新来的reader;
  4. 记录writer进来之前未完成的reader数量,等待它们都完成后再唤醒writer;这样就做到了“并发读操作和写操作互斥”;
  5. writer结束后将readerCount置回原来的值,保证新的reader不会被阻塞,然后唤醒之前等待的reader,再将互斥锁释放,使后续writer不会被阻塞。

这就是go语言中读写锁的核心源码(简洁起见,这里将竞态部分的代码省略,TODO:竞态分析原理分析),相信看到这你已经对读写锁的实现原理了然于胸了,如果你感兴趣,不妨一起继续思考这几个问题。

思考

writer lock时在判断是否有未完成的reader时为什么使用r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0

回想一下Lock方法:

func (rw *RWMutex) Lock() {
    rw.w.Lock()
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
        runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
}

为了判断是否还有未完成的reader,直接判断 r!= 0不就行了吗,为什么还需要判断atomic.AddInt32(&rw.readerWait, r)!=0
这是因为上面第三行和第四行的代码并不是原子的,这就意味着中间很有可能插进其它goroutine执行,假如某个时刻执行完第三行代码,r=1,也就是此时还有一个reader,但执行第四行之前先执行了该reader的goroutine,并且reader完成RUnlock操作,此时如果只判断r!=0就会错误地阻塞住,因为这时候已经没有未完成的reader了。而reader在执行RUnlock的时候会将readerWait减1,所以readerWait+r就代表未完成的reader数。
那么只判断atomic.AddInt32(&rw.readerWait, r)!=0不就可以吗?理论上应该是可以的,先判断r!=0应该是一种短路操作:如果r==0那就不用执行atomic.AddInt32了(注意r==0时readerWait也等于0)。

Benchmark

最后让我们通过Benchmark看看读写锁的性能提升有多少:

func Read() {
   loc.Lock()
   defer loc.Unlock()
   _, _ = fmt.Fprint(ioutil.Discard, idx)
   time.Sleep(1000 * time.Nanosecond)
}

func ReadRW() {
   rwLoc.RLock()
   defer rwLoc.RUnlock()
   _, _ = fmt.Fprint(ioutil.Discard, idx)
   time.Sleep(1000 * time.Nanosecond)
}

func Write() {
   loc.Lock()
   defer loc.Unlock()
   idx = 3
   time.Sleep(1000 * time.Nanosecond)
}

func WriteRW() {
   rwLoc.Lock()
   defer rwLoc.Unlock()
   idx = 3
   time.Sleep(1000 * time.Nanosecond)
}

func BenchmarkLock(b *testing.B) {
   b.RunParallel(func(pb *testing.PB) {
      foo := 0
      for pb.Next() {
         foo++
         if foo % writeRatio == 0 {
            Write()
         } else {
            Read()
         }
      }
   })
}

func BenchmarkRWLock(b *testing.B) {
   b.RunParallel(func(pb *testing.PB) {
      foo := 0
      for pb.Next() {
         foo++
         if foo % writeRatio == 0 {
            WriteRW()
         } else {
            ReadRW()
         }
      }
   })
}

这里使用了go语言内置的Benchmark功能,执行go test -bench='Benchmark.*Lock' -run=none mutex_test.go即可触发benchmark运行,-run=none是为了跳过单测。
结果如下:

cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkLock
BenchmarkLock-12            235062          5025 ns/op
BenchmarkRWLock
BenchmarkRWLock-12          320209          3834 ns/op

可以看出使用读写锁后耗时降低了24%左右。
上面writeRatio用于控制读、写的频率比例,即读:写=3,随着这个比例越高耗时降低的比例也越大,这里作个简单比较:

writeRatio31020501001000
耗时降低24%71.3%83.7%90.9%93.5%95.7%

可以看出当读的比例越高时,使用读写锁获得的性能提升比例越高。

自旋锁

自旋锁是指当一个线程(在 Go 中是 Goroutine)在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待(自旋),不断判断锁是否已经被释放,而不是进入睡眠状态。

使用atomic实现

import (
    "sync/atomic"
    "time"
)

type Spinlock struct {
    locked int32
}

func (s *Spinlock) Lock() {
    for !atomic.CompareAndSwapInt32(&s.locked, 0, 1) {
        // 这里可以添加一些退避策略,比如随机等待一段时间,以避免过多的CPU占用
        // time.Sleep(time.Nanosecond) // 注意:实际使用中可能不需要或想要这样的退避
    }
}

func (s *Spinlock) Unlock() {
    atomic.StoreInt32(&s.locked, 0)
}

func main() {
    var lock Spinlock

    // 示例:使用自旋锁
    go func() {
        lock.Lock()
        // 执行一些操作...
        lock.Unlock()
    }()

    // 在另一个goroutine中尝试获取锁
    go func() {
        lock.Lock()
        // 执行一些操作...
        lock.Unlock()
    }()

    // 等待足够的时间以确保goroutines完成
    time.Sleep(time.Second)
}

在这个例子中,

  1. Spinlock 结构体有一个 int32 类型的字段 locked,用于表示锁的状态。
  2. Lock 方法使用 atomic.CompareAndSwapInt32 原子操作来尝试将 locked 从0(未锁定)更改为1(已锁定)。如果锁已经被另一个goroutine持有(即 locked 为1),则 CompareAndSwapInt32 会返回 false,并且循环会继续。
  3. Unlock 方法使用 atomic.StoreInt32 原子操作将 locked 设置回0,表示锁已被释放。

需要注意的是,在实际应用中,自旋锁可能会导致CPU资源的过度占用,特别是在锁被长时间持有或存在大量竞争的情况下。因此,在使用自旋锁之前,应该仔细考虑其适用性和潜在的性能影响。在许多情况下,使用互斥锁(sync.Mutex)或其他更高级的同步机制可能是更好的选择。

goroutine 的自旋占用资源如何解决

Goroutine 的自旋占用资源问题主要涉及到 Goroutine 在等待锁或其他资源时的一种行为模式,即自旋锁(spinlock)。自旋锁是指当一个线程(在 Go 中是 Goroutine)在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待(自旋),不断判断锁是否已经被释放,而不是进入睡眠状态。这种行为在某些情况下可能会导致资源的过度占用,特别是当锁持有时间较长或者自旋的 Goroutine 数量较多时。

针对 Goroutine 的自旋占用资源问题,可以从以下几个方面进行解决或优化:

  1. 减少自旋锁的使用
    评估必要性:首先评估是否真的需要使用自旋锁。在许多情况下,互斥锁(mutex)已经足够满足需求,因为互斥锁在资源被占用时会让调用者进入睡眠状态,从而减少对 CPU 的占用。
    优化锁的设计:考虑使用更高级的同步机制,如读写锁(rwmutex),它允许多个读操作同时进行,而写操作则是互斥的。这可以显著减少锁的竞争,从而降低自旋的需求。
  2. 优化自旋锁的实现
    设置自旋次数限制:在自旋锁的实现中加入自旋次数的限制,当自旋达到一定次数后,如果仍未获取到锁,则让 Goroutine 进入睡眠状态。这样可以避免长时间的无效自旋,浪费 CPU 资源。
    利用 Go 的调度器特性:Go 的调度器在检测到 Goroutine 长时间占用 CPU 而没有进展时,会主动进行抢占式调度,将 Goroutine 暂停并让出 CPU。这可以在一定程度上缓解自旋锁带来的资源占用问题。
  3. 监控和调整系统资源
    监控系统性能:通过工具(如 pprof、statsviz 等)监控 Go 程序的运行时性能,包括 CPU 使用率、内存占用等指标。这有助于及时发现和解决资源占用过高的问题。
    调整 Goroutine 数量:根据系统的负载情况动态调整 Goroutine 的数量。例如,在高并发场景下适当增加 Goroutine 的数量以提高处理能力,但在负载降低时及时减少 Goroutine 的数量以避免资源浪费。
  4. 利用 Go 的并发特性
    充分利用多核 CPU:通过设置 runtime.GOMAXPROCS 来指定 Go 运行时使用的逻辑处理器数量,使其尽可能接近或等于物理 CPU 核心数,从而充分利用多核 CPU 的并行处理能力。
    使用 Channel 进行通信:Go 鼓励使用 Channel 进行 Goroutine 之间的通信和同步,而不是直接使用锁。Channel 可以有效地避免死锁和竞态条件,并且减少了锁的使用,从而降低了资源占用的风险。
    综上所述,解决 Goroutine 的自旋占用资源问题需要从多个方面入手,包括减少自旋锁的使用、优化自旋锁的实现、监控和调整系统资源以及充分利用 Go 的并发特性等。通过这些措施的综合应用,可以有效地降低 Goroutine 在自旋过程中对系统资源的占用。

内存模型

逃逸分析

在编译原理中,分析指针动态范围的方法称之为逃逸分析。通俗来讲,当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸。简单来说,编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上。

编译器会根据变量是否被外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;
  2. 如果函数外部存在引用,则必定放到堆中;

逃逸分析这种“骚操作”把变量合理地分配到它该去的地方。即使你是用new申请到的内存,如果我发现你竟然在退出函数后没有用了,那么就把你丢到栈上,毕竟栈上的内存分配比堆上快很多;反之,即使你表面上只是一个普通的变量,但是经过逃逸分析后发现在退出函数之后还有其他地方在引用,那我就把你分配到堆上。

如果变量都分配到堆上,堆不像栈可以自动清理。它会引起Go频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销(占用CPU容量的25%)

堆和栈相比,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片。栈内存分配则会非常快。栈分配内存只需要两个CPU指令:“PUSH”和“RELEASE”,分配和释放;而堆分配内存首先需要去找到一块大小合适的内存块,之后要通过垃圾回收才能释放。

Channel 分配在栈上还是堆上?哪些对象分配在堆上,哪些对象分配在栈上?

Channel 被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以 golang 直接将其分配在堆上
准确地说,你并不需要知道。Golang 中的变量只要被引用就一直会存活,存储在堆上还是栈上由内部实现决定而和具体的语法没有关系。
知道变量的存储位置确实和效率编程有关系。如果可能,Golang 编译器会将函数的局部变量分配到函数栈帧(stack frame)上。然而,如果编译器不能确保变量在函数 return 之后不再被引用,编译器就会将变量分配到堆上。而且,如果一个局部变量非常大,那么它也应该被分配到堆上而不是栈上。
当前情况下,如果一个变量被取地址,那么它就有可能被分配到堆上,然而,还要对这些变量做逃逸分析,如果函数 return 之后,变量不再被引用,则将其分配到栈上。

介绍一下大对象小对象,为什么小对象多了会造成 gc 压力?

小于等于 32k 的对象就是小对象 (小于16k 是tiny),其它都是大对象。一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。通常小对象过多会导致 GC 三色法消耗过多的 CPU。优化思路是,减少对象分配。
小对象:如果申请小对象时,发现当前内存空间不存在空闲跨度时,将会需要调用 nextFree 方法获取新的可用的对象,可能会触发 GC 行为。
大对象:如果申请大于 32k 以上的大对象时,可能会触发 GC 行为。

内存泄漏

go 中的内存泄漏一般都是 goroutine 泄漏,就是 goroutine 没有被关闭,或者没有添加超时控制,让 goroutine 一只处于阻塞状态,不能被 GC。

常见情况

1)如果 goroutine 在执行时被阻塞而无法退出,就会导致 goroutine 的内存泄漏,一个 goroutine 的最低栈大小为 2KB,在高并发的场景下,对内存的消耗也是非常恐怖的。
2)互斥锁未释放或者造成死锁会造成内存泄漏
3)time.Ticker 是每隔指定的时间就会向通道内写数据。作为循环触发器,必须调用 stop 方法才会停止,从而被 GC 掉,否则会一直占用内存空间。
4)切片引发临时性的内存泄漏。

var str0 = "12345678901234567890"
str1 := str0[:10]

5)函数数组传参引发内存泄漏

如果我们在函数传参的时候用到了数组传参,且这个数组够大(我们假设数组大小为 100 万,64 位机上消耗的内存约为 800w 字节,即 8MB 内存),或者该函数短时间内被调用 N 次,那么可想而知,会消耗大量内存,对性能产生极大的影响,如果短时间内分配大量内存,而又来不及 GC,那么就会产生临时性的内存泄漏,对于高并发场景相当可怕。

6)在闭包中引用包外的值

7)在 slice 或 map 中存储指针

8)在 interface 类型上调用方法

interface 为什么会“逃逸”?

因为 interface 内部是一个 指针 data unsafe.Pointer,如果你把一个栈上的变量赋值给 interface,Go 编译器必须让它逃逸到堆上,以防 interface 在栈失效后还引用它。

func foo() interface{} {
x := 10
return x  // 变量 x 会逃逸到堆
}

排查方式:
一般通过 pprof 是 Go 的性能分析工具,在程序运行过程中,可以记录程序的运行信息,可以是 CPU 使用情况、内存使用情况、goroutine 运行情况等,当需要性能调优或者定位 Bug 时候,这些记录的信息是相当重要。

判定规则

指针逃逸:函数返回局部变量的指针 return &x

动态作用域: 变量被闭包或者外部函数应用

动态大小:变量容量在编译时无法确定 make([]int,n),其中n是变量

接口类型转换:变量被赋值给interface{}类型,如fmt.Println(x)中的参数

1)本该分配到栈上的变量,跑到了堆上,这就导致了内存逃逸。

2)栈是高地址到低地址,栈上的变量,函数结束后变量会跟着回收掉,不会有额外性能的开销。

3)变量从栈逃逸到堆上,如果要回收掉,需要进行 gc,那么 gc 一定会带来额外的性能开销。编程语言不断优化 gc 算法,主要目的都是为了减少 gc 带来的额外性能开销,变量一旦逃逸会导致性能开销变大。

值拷贝与引用传递

Go 的函数参数传递所有都是值传递。但当参数是引用类型(比如slice、map、channel、指针等)时,副本和原始数据会共享底层的数据结构,因此修改副本的内容会影响原始数据。

值传递:指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数

引用传递:指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数

因为 Go 里面的 map,slice,chan 是引用类型。变量区分值类型和引用类型。

所谓值类型:变量和变量的值存在同一个位置,当值类型的变量作为参数传递给函数时,传递的是该值的副本,因此函数内对值的修改不会影响原始数据。

所谓引用类型:变量和变量的值是不同的位置,变量的值存储的是对值的引用。但并不是 map,slice,chan 的所有的变量在函数内都能被修改,不同数据类型的底层存储结构和实现可能不太一样,情况也就不一样。

需要注意的是slice 的特殊情况,slice 是一种引用类型,底层是对一个数组的引用。通过 slice 可以修改底层数组的内容,但 slice 本身的结构(如长度和容量)可能会被修改。

func modifySlice(s []int) {
 s[0] = 100          // 修改底层数组的内容
 s = append(s, 4)    // append 会使 slice 可能指向新的底层数组
 fmt.Println(s)       // [100 2 3 4]
}
func main() {
 a := []int{1, 2, 3}
 modifySlice(a)
 fmt.Println(a)       // [100 2 3],原始 slice 没有改变,因为 append 后底层数组被修改
}

slice 的修改是否影响原始数据取决于是否进行了 append 操作,以及是否改变了底层数组的引用。

map也存在特殊情况,map 是一种引用类型,指向哈希表。在函数中修改 map 的内容是可以生效的,因为 map 是通过引用传递的。

func modifyMap(m map[string]int) {
 m["a"] = 100  // 修改 map 中的值,直接影响原始 map
 m = make(map[string]int) // 重新赋值,不会影响原始 map
 m["b"] = 200
 fmt.Println(m) // map[b:200]
}
func main() {
 m := map[string]int{"a": 1, "b": 2}
 modifyMap(m)
 fmt.Println(m) // map[a:100 b:2],原始 map 受到了修改
}

重新赋值 map 变量:如果你在函数内给 map 变量赋一个新的值(即重新指向一个新的 map),这种改变并不会影响到原始的 map 变量,因为 Go 是值传递,map 的引用也会被复制。同理在channel也一样,函数内赋值不会改变原channel

  • 当调用 m = make(...) 时,创建了一个 新的 map 对象,并将函数内部的局部变量 m 指向这个新对象。
  • 此时,外部 map 变量仍然指向原来的内存地址,而函数内部的 m 已指向新的地址,后续操作仅影响新 map。

GC原理

垃圾回收策略

引用计数

引用计数算法是一种最简单的垃圾回收算法,它的基本思想是:给对象中添加一个引用计数字段,每当有一个地方引用它时,计数加 1;当引用失效时,计数减 1;当计数为 0 时,表示对象不再被使用,可以被回收。

  • 优点:

    • 不需要从根节点遍历,相对容易查找;

    • 每个对象始终知道自己的被引用次数,一旦引用计数为 0,就会立即将自身连接到空闲链表上,等待回收;

    • 最大限度地减少程序暂停时间,在 mutator 更新引用计数时,就会触发垃圾回收,不需要等到内存耗尽时才触发,因此不会出现程序暂停时间过长的情况

  • 缺点:不能很好地处理循环引用,而且实时维护引用计数,也有一定的代价。

  • 代表语言:Python、PHP、Swift

标记-清除

从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收。

  • 优点:可以解决循环引用问题,不需要额外的空间存储计数器
  • 缺点:需要STW,即要暂时停掉程序运行。在清除阶段会产生大量的碎片,导致内存碎片化,可能会导致程序运行分配对象时找不到连续的内存空间而再次触发垃圾回收。执行效率不稳定
  • 代表语言:Golang(其采用三色标记法)
节点复制

节点复制也是基于追踪的算法。其将整个堆等分为两个半区(semi-space),一个包含现有数据,另一个包含已被废弃的数据。节点复制式垃圾收集从切换(flip)两个半区的角色开始,然后收集器在老的半区,也就是 Fromspace 中遍历存活的数据结构,在第一次访问某个单元时把它复制到新半区,也就是 Tospace 中去。 在 Fromspace 中所有存活单元都被访问过之后,收集器在 Tospace 中建立一个存活数据结构的副本,用户程序可以重新开始运行了。

  • 优点:
    • 所有存活的数据结构都缩并地排列在 Tospace 的底部,这样就不会存在内存碎片的问题
    • 获取新内存可以简单地通过递增自由空间指针来实现。
  • 缺点:内存得不到充分利用,总有一半的内存空间处于浪费状态。
分代收集

按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不同的回收算法和回收频率。

  • 优点:回收性能好
  • 缺点:算法复杂
  • 代表语言: JAVA

Go的GC演变

Go 的 GC 回收有三次演进过程,Go V1.3 之前普通标记清除(mark and sweep)方法,整体过程需要启动 STW,效率极低。GoV1.5 三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要 STW),效率普通。GoV1.8 三色标记法,混合写屏障机制:栈空间不启动(全部标记成黑色),堆空间启用写屏障,整个过程不要 STW,效率高。

Go 1.3 及之前 : Stop-the-World (STW): 在这些早期版本中,垃圾回收是全局停止的,即在进行垃圾回收时,所有应用程序的 goroutine 都会暂停。这种方式导致较长的停顿时间,对性能有显著影响。

Go 1.5

在 Go 1.5 中,三色标记法的实现需要两次全局 STW

  1. 第一次 STW:扫描所有根对象(全局变量、栈等),标记为灰色。
  2. 并发标记阶段:并发地遍历对象图(灰色对象变黑,引用对象变灰)。
  3. 第二次 STW:重新扫描栈(Rescan Stacks),确保在并发标记期间栈上的引用变化被正确处理。

Go 1.8

Hybrid Barrier: 引入混合屏障机制,结合了写屏障和标记终止,进一步减少了 STW 时间。

非阻塞回收: 清除过程变为完全并发,减少了垃圾回收对应用程序的影响。

三色标记法

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

目的

  1. 主要是利用Tracing GC(Tracing GC 是垃圾回收的一个大类,另外一个大类是引用计数) 做增量式垃圾回收,降低最大暂停时间
  2. 原生Tracing GC只有黑色和白色,没有中间的状态,这就要求GC扫描过程必须一次性完成,得到最后的黑色和白色对象。在前面增量式GC中介绍到了,这种方式会存在较大的暂停时间。
  3. 三色标记增加了中间状态灰色,增量式GC运行过程中,应用线程的运行可能改变了对象引用树,只要让黑色对象直接引用白色对象,GC就可以增量式的运行,减少停顿时间。

什么是三色标记?

  1. 黑色 Black:表示对象是可达的,即使用中的对象,黑色是已经被扫描的对象。
  2. 灰色 Gary:表示被黑色对象直接引用的对象,但还没对它进行扫描。
  3. 白色 White:白色是对象的初始颜色,如果扫描完成后,对象依然还是白色的,说明此对象是垃圾对象。

黑色不能指向白色对象。即黑色可以指向灰色,灰色可以指向白色。

流程

详细总结: Golang GC、三色标记、混合写屏障机制 - Code2020 - 博客园

  • 起初所有对象都是白色
  • 从根出发扫描所有可达对象,标记为灰色,放入待处理队列。
  • 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色
  • 重复 3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收

解决并发修改问题的关键

在并发标记过程中,用户程序(Mutator)可能修改对象引用关系,导致两类问题:

  • 多标(浮动垃圾):存活对象被错误标记为可回收
  • 漏标(对象丢失):本应存活的对象未被标记

Go通过写屏障(Write Barrier) 解决这些问题:

  • 传统插入写屏障:当黑色对象引用白色对象时,将白色对象标记为灰色。缺点:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活(插入屏障不在栈上使用)

  • 传统删除写屏障:被删除的对象如果是白色/灰色,则会被标记为灰色。缺点:回收精度低,一个对象即使被删除了最后一个引用它的指针依然可以存活过本轮GC,在下一轮GC中才删除。

  • 混合写屏障(Go 1.8+):

    **结合两者优点,栈对象(新创建的对象)直接标记为黑色,堆对象动态处理。(只有堆栈两种内存)**栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。

    1. GC开始将栈上的对象全部扫描并标记被引用的为黑色(之后不再进行第二次重复扫描,无需STW)
    2. GC期间,任何在栈上创建的新对象,均为黑色。
    3. 堆上被删除引用的对象标记为灰色。 如a.child = b中,原来的child值
    4. 堆上被添加引用的对象标记为灰色。

触发时机

分为系统触发和主动触发。
1)gcTriggerHeap:当所分配的堆大小达到阈值(由控制器计算的触发堆的大小)时,将会触发。
2)gcTriggerTime:当距离上一个 GC 周期的时间超过一定时间时,将会触发。时间周期以runtime.forcegcperiod 变量为准,默认 2 分钟。
3)gcTriggerCycle:如果没有开启 GC,则启动 GC。
4)手动触发的 runtime.GC() 方法。

GC介绍模板

Go 的垃圾回收采用并发三色标记-清除算法。首先通过根对象(如 Goroutine 栈、全局变量)标记所有可达对象为黑色,不可达的白色对象会被回收。整个过程大部分阶段与用户代码并发执行,依赖写屏障技术保证标记正确性,并通过混合屏障大幅减少 STW 停顿时间,最终实现高效且低延迟的内存管理。

“混合屏障是 Go 在并发标记阶段确保正确性的关键机制。它结合了插入屏障和删除屏障的规则:

  1. 插入屏障:当新指针写入对象时,若目标对象未被标记,则标记为待扫描;
  2. 删除屏障:当旧指针被覆盖时,若旧指向的对象未被标记,则标记为待扫描。
    这种设计避免了传统方案中需要 STW 重新扫描栈的问题,使得 Go 的 GC 在保证安全的同时,能将暂停时间控制在微秒级。”

核心机制采用 三色标记清除(Tri-color Mark-Sweep)+ 并发 GC

GC 过程:

  1. 标记阶段(Mark)
    • 遍历所有可达对象(根对象及其引用),标记存活对象。
    • 三色标记法:
      • 白色(未访问):默认所有对象。
      • 灰色(待处理):被引用但未处理。
      • 黑色(已处理):已经扫描的对象。
    • 逐步将对象从 白色 → 灰色 → 黑色,最终未变黑的对象会被回收。
  2. 清除阶段(Sweep)
    • 释放未被标记(仍是白色)的对象,回收内存。

并发 GC:GC 过程中,应用线程可以继续运行,避免长时间 STW(Stop The World)。

增量 GC:GC 以小步方式进行,避免一次性暂停整个程序。

写屏障(Write Barrier):防止 GC 过程中对象引用丢失。

优化机制:Go 通过 STW 优化、GOGC 调节、对象分代管理、sync.Pool 复用对象,提升 GC 效率。

STW(Stop The World)优化

  • STW 只在 GC 的起始和结束 发生,Go 避免长时间全局暂停,提升并发性能。

GOGC(GC 触发阈值)

  • GOGC 环境变量控制 GC 触发频率:
    • GOGC=100(默认):表示堆增长 100% 时触发 GC。
    • GOGC=200:减少 GC 频率,提高吞吐量。
    • GOGC=50:增加 GC 频率,降低内存占用但影响性能。

对象分代管理

  • Go 并没有真正的分代 GC,但采用 “大对象分离”
    • 小对象分配在 小对象池(mcache),生命周期短,GC 代价低。
    • 大对象进入 heap(堆),减少 GC 影响。

内存复用

  • sync.Pool:缓存短生命周期对象,减少 GC 负担。

反射原理

反射(Reflection)是 Go 语言中一种强大的机制,允许程序在 运行时 动态检查和操作变量类型、值、方法等元信息。它通过 reflect 包实现,核心原理基于接口(Interface)的底层类型系统。

反射将接口变量转换成反射对象 Type 和 Value;反射可以通过反射对象 Value 还原成原先的接口变量;反射可以用来修改一个变量的值,前提是这个值可以被修改;

eface和iface

Go 的反射基于接口的底层结构 ifaceeface

  • iface:带有方法的接口(interface{} 的底层)。
  • eface:空接口(interface{} 的底层)
// 非空接口(iface)
type iface struct {
    tab  *itab          // 类型和方法表
    data unsafe.Pointer  // 实际值的指针
}

// 空接口(eface)
type eface struct {
    _type *_type      // 类型信息
    data  unsafe.Pointer  // 实际数据的地址
}

type itab struct {
    inter *interfacetype // 接口类型
    _type *_type         // 具体类型
    fun   [ ]func        // 方法表(虚表)
}

iface 内部维护两个指针,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的实体类型。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。
再来仔细看一下 itab 结构体:_type 字段描述了实体的类型,包括内存对齐方式,大小等;inter 字段则描述了接口的类型。fun 字段放置和接口方法对应的具体数据类型的方法地址,实现接口调用方法的动态分派,一般在每次给接口赋值发生转换时会更新此表,或者直接拿缓存的 itab。
这里只会列出实体类型和接口相关的方法,实体类型的其他方法并不会出现在这里。
另外,你可能会觉得奇怪,为什么 fun 数组的大小为 1,要是接口定义了多个方法可怎么办?实际上,这里存储的是第一个方法的函数指针,如果有更多的方法,在它之后的内存空间里继续存储。从汇编角度来看,通过增加地址就能获取到这些函数指针,没什么影响。顺便提一句,这些方法是按照函数名称的字典序进行排列的。
再看一下 interfacetype 类型,它描述的是接口的类型:

type interfacetype struct {
    typ     _type
    pkgpath name
    mhdr    []imethod
}

可以看到,它包装了 _type 类型,_type 实际上是描述 Go 语言中各种数据类型的结构体。我们注意到,这里还包含一个 mhdr 字段,表示接口所定义的函数列表, pkgpath 记录定义了接口的包名。
这里通过一张图来看下 iface 结构体的全貌:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

接着来看一下 eface 的源码:

type eface struct {
    _type *_type
    data  unsafe.Pointer
}

相比 iface,eface 就比较简单了。只维护了一个 _type 字段,表示空接口所承载的具体的实体类型。data 描述了具体的值。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

接口类型和 nil 作比较

接口值的零值是指动态类型动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil

type Coder interface {
	code()
}

type Gopher struct {
	name string
}

func (g Gopher) code() {
	fmt.Printf("%s is coding\n", g.name)
}

func main() {
	var c Coder
	fmt.Println(c == nil)
	fmt.Printf("c: %T, %v\n", c, c)

	var g *Gopher
	fmt.Println(g == nil)

	c = g
	fmt.Println(c == nil)
	fmt.Printf("c: %T, %v\n", c, c)
}

// true
// c: <nil>, <nil>
// true
// false
// c: *main.Gopher, <nil>

一开始,c 的 动态类型和动态值都为 nilg 也为 nil,当把 g 赋值给 c 后,c 的动态类型变成了 *main.Gopher,仅管 c 的动态值仍为 nil,但是当 cnil 作比较的时候,结果就是 false 了。

类型 T 只有接受者是 T 的方法;而类型 *T 拥有接受者是 T*T 的方法。语法上 T 能直接调 *T 的方法仅仅是 Go 的语法糖

反射的三大法则

  1. 从接口到反射对象
    通过 reflect.TypeOf()reflect.ValueOf() 获取变量的反射对象。

    var x float64 = 3.14
    t := reflect.TypeOf(x)   // t 是 reflect.Type,表示 float64
    v := reflect.ValueOf(x)  // v 是 reflect.Value,包含 3.14
    
  2. 从反射对象到接口
    通过 Value.Interface() 将反射对象还原为接口。

    original := v.Interface().(float64) // 类型断言获取原值
    
  3. 修改反射对象的值
    必须传递指针,并通过 Elem() 获取指针指向的值。

    var x float64 = 3.14
    v := reflect.ValueOf(&x).Elem()
    v.SetFloat(6.28) // 修改 x 的值为 6.28
    

反射的应用

gorm ,json, yaml ,gRPC, protobuf ,gin.Bind()都是通过反射来实现的

1. 动态类型检查与操作

func printTypeAndValue(v interface{}) {
    t := reflect.TypeOf(v)
    val := reflect.ValueOf(v)
    fmt.Printf("Type: %s, Value: %v\n", t.Name(), val)
}

printTypeAndValue(42)        // Type: int, Value: 42
printTypeAndValue("hello")    // Type: string, Value: hello

2. 结构体字段遍历

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func printFields(s interface{}) {
    val := reflect.ValueOf(s).Elem() // .Elem()用于获取指针指向的值或接口的动态值。
    fmt.Println(val) // {Alice 30}
    typ := val.Type() //获取反射对象的类型信息,返回一个reflect.Type对象。main.User
    // typ.Name() // User
	// typ.Name() // struct
    
    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)
        tag := typ.Field(i).Tag.Get("json")
        fmt.Printf("Field: %s, Tag: %s, Value: %v\n", typ.Field(i).Name, tag, field)
    }
}

u := User{Name: "Alice", Age: 30}
printFields(&u)
// 输出:
// Field: Name, Tag: name, Value: Alice
// Field: Age, Tag: age, Value: 30
  • reflect.ValueOf(s) → 将变量转换为反射对象。
  • .Elem() → 解引用指针/接口,获取底层值。
  • .Type() → 提取反射对象的类型元信息。

3.动态调用方法

type Calculator struct{}

func (c *Calculator) Add(a, b int) int {
    return a + b
}

func callMethod(obj interface{}, methodName string, args ...interface{}) []reflect.Value {
    val := reflect.ValueOf(obj)
    method := val.MethodByName(methodName)
    in := make([]reflect.Value, len(args))
    for i, arg := range args {
        in[i] = reflect.ValueOf(arg)
    }
    return method.Call(in)
}

calc := &Calculator{}
result := callMethod(calc, "Add", 3, 4)
fmt.Println(result[0].Int()) // 输出 7

为未知类型提供统一操作接口。比如web框架中,根据 HTTP 请求路径动态调用控制器方法。

fmt包中的反射

fmt.Println 函数的参数是 interface。对于内置类型,函数内部会用穷举法,得出它的真实类型,然后转换为字符串打印。而对于自定义类型,首先确定该类型是否实现了 String() 方法,如果实现了,则直接打印输出 String() 方法的结果;否则,会通过反射来遍历对象的成员进行打印。

三种断言:

type Student struct {
	Name string
	Age int
}
var i interface{} = new(Student)
s := i.(Student) //非安全断言  断言失败抛出panic

s, ok := i.(Student) // 安全断言
if ok {
	fmt.Println(s)
}

func judge(v interface{}) { //使用swich case判断接口类型
	fmt.Printf("%p %v\n", &v, v)

	switch v := v.(type) {
	case nil:
		fmt.Printf("%p %v\n", &v, v)
		fmt.Printf("nil type[%T] %v\n", v, v)

	case Student:
		fmt.Printf("%p %v\n", &v, v)
		fmt.Printf("Student type[%T] %v\n", v, v)

	case *Student:
		fmt.Printf("%p %v\n", &v, v)
		fmt.Printf("*Student type[%T] %v\n", v, v)

	default:
		fmt.Printf("%p %v\n", &v, v)
		fmt.Printf("unknow\n")
	}
}

反射的影响

  • 反射操作比直接代码调用慢约 10-100 倍,需避免高频使用。

  • 类型不匹配会 panic:需用 Kind() 提前检查类型。

    if v.Kind() != reflect.Int {
     return errors.New("expected int")
    }
    
  • 修改不可寻址的值会 panic:确保使用 CanSet()

    if v.CanSet() {
     v.SetInt(42)
    }
    
  • 过度使用反射会降低代码可维护性,优先考虑接口或代码生成(如 protobuf)。

高并发问题

缓存

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

缓存雪崩

当某一个时刻出现大规模的缓存失效的情况,那么就会导致大量的请求直接打在数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。

造成缓存雪崩的关键在于在同一时间大规模的key失效。两种可能导致,第一种可能是Redis宕机,第二种可能是采用了相同的过期时间。

解决方式:

在原有的失效时间上加上一个随机值,比如1-5分钟随机。这样就避免了因为采用相同的过期时间导致的缓存雪崩。

使用熔断机制。当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

提高数据库的容灾能力,可以使用分库分表,读写分离的策略。

为了防止Redis宕机导致缓存雪崩的问题,可以搭建Redis集群,提高Redis的容灾性。

缓存击穿

其实跟缓存雪崩有点类似,缓存雪崩是大规模的key失效,而缓存击穿是一个热点的Key,有大并发集中对其进行访问,突然间这个Key失效了,导致大并发全部打在数据库上,导致数据库压力剧增。这种现象就叫做缓存击穿。

从两个方面解决,第一是否可以考虑热点key不设置过期时间,第二是否可以考虑降低打在数据库上的请求数量。

解决方式:

如果业务允许的话,对于热点的key可以设置永不过期的key

**使用互斥锁。**如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,防止数据库打死。当然这样会导致系统的性能变差。

缓存穿透

首先做参数校验,比如你的合法id是15xxxxxx,以15开头的。如果用户传入了16开头的id,比如:16232323,则参数校验失败,直接把相关请求拦截掉。这样可以过滤掉一部分恶意伪造的用户id。

我们使用Redis大部分情况都是通过Key查询对应的值,假如发送的请求传进来的key是不存在Redis中的,那么就查不到缓存,查不到缓存就会去数据库查询。假如有大量这样的请求,这些请求像“穿透”了缓存一样直接打在数据库上,这种现象就叫做缓存穿透。

关键在于在Redis查不到key值,这和缓存击穿有根本的区别,区别在于缓存穿透的情况是传进来的key在Redis中是不存在的。假如有黑客传进大量的不存在的key,那么大量的请求打在数据库上是很致命的问题,所以在日常开发中要对参数做好校验,一些非法的参数,不可能存在的key就直接返回错误提示,要对调用方保持这种“不信任”的心态。

解决方式:

把无效的Key存进Redis中。如果Redis查不到数据,数据库也查不到,我们把这个Key值保存进Redis,设置value=“null”,当下次再通过这个Key查询时就不需要再查询数据库。这种处理方式肯定是有问题的,假如传进来的这个不存在的Key值每次都是随机的,那存进Redis也没有意义。

使用布隆过滤器。布隆过滤器的作用是某个 key 不存在,那么就一定不存在,它说某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回。

并发控制

go哪些内置类型是并发安全的
  • 原子类型(如sync/atomic包中的类型)是并发安全的,例如atomic.Value可以在多个协程中安全地存储和读取任意类型的值。
  • sync.Mutexsync.RWMutex本身也是并发安全的,用于实现互斥和读写锁的功能。
  • sync.Once用于保证某个操作只执行一次,是并发安全的。
  • sync.WaitGroup用于协程的同步,在多个协程中正确使用时是并发安全的,它可以用来等待一组协程完成。
  • sync.Cond用于条件变量,是并发安全的,可用于协程之间的同步等待某个条件满足。
  • sync.Map是一个并发安全的map类型。
Go 中主协程如何等待其余协程退出?

答:Go 的 sync.WaitGroup 是等待一组协程结束,sync.WaitGroup 只有 3 个方法,Add()是添加计数,Done()减去一个计数,Wait()阻塞直到所有的任务完成。Go 里面还能通过有缓冲的 channel 实现其阻塞等待一组协程结束,这个不能保证一组 goroutine 按照顺序执行,可以并发执行协程。Go 里面能通过无缓冲的 channel 实现其阻塞等待一组协程结束,这个能保证一组 goroutine 按照顺序执行,但是不能并发执行。

怎么控制并发数?

有缓冲通道
根据通道中没有数据时读取操作陷入阻塞和通道已满时继续写入操作陷入阻塞的特性,正好实现控制并发数量。

func main() {
    count := 10                     // 最大支持并发
    sum := 100                      // 任务总数
    wg := sync.WaitGroup{}          //控制主协程等待所有子协程执行完之后再退出。
    c := make(chan struct{}, count) // 控制任务并发的chan
    defer close(c)
    for i := 0; i < sum; i++ {
        wg.Add(1)
        c <- struct{}{} // 作用类似于waitgroup.Add(1)
        go func(j int) {
            defer wg.Done()
            fmt.Println(j)
            <-c // 执行完毕,释放资源
        }(i)
    }
    wg.Wait()
}

三方库实现的协程池

import (
    "github.com/Jeffail/tunny"
    "log"
    "time"
)
func main() {
    pool := tunny.NewFunc(10, func(i interface{}) interface{} {
        log.Println(i)
        time.Sleep(time.Second)
        return nil
    })
    defer pool.Close()
    for i := 0; i < 500; i++ {
        go pool.Process(i)
    }
    time.Sleep(time.Second * 4)
}
golang实现多并发请求

go语言中其实有两种方法进行协程之间的通信。一个是共享内存、一个是消息传递
共享内存(互斥锁)

//基本的GET请求
package main
 
import (
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
    "sync"
    "runtime"
)
 
// 计数器
var counter int = 0
 
func httpget(lock *sync.Mutex){
    lock.Lock()
    counter++
    resp, err := http.Get("http://localhost:8000/rest/api/user")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
    fmt.Println(resp.StatusCode)
    if resp.StatusCode == 200 {
        fmt.Println("ok")
    }
    lock.Unlock()
}
 
func main() {
    start := time.Now()
    lock := &sync.Mutex{}
    for i := 0; i < 800; i++ {
        go httpget(lock)
    }
    for  {
        lock.Lock()
        c := counter
        lock.Unlock()
        runtime.Gosched()
        if c >= 800 {
            break
        }
    }
    end := time.Now()
    consume := end.Sub(start).Seconds()
    fmt.Println("程序执行耗时(s):", consume)
}

问题
我们可以看到共享内存的方式是可以做到并发,但是我们需要利用共享变量来进行协程的通信,也就需要使用互斥锁来确保数据安全性,导致代码啰嗦,复杂话,不易维护。我们后续使用go的消息传递方式避免这些问题。

//基本的GET请求
package main
 
import (
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)
// HTTP get请求
func httpget(ch chan int){
    resp, err := http.Get("http://localhost:8000/rest/api/user")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    fmt.Println(string(body))
    fmt.Println(resp.StatusCode)
    if resp.StatusCode == 200 {
        fmt.Println("ok")
    }
    ch <- 1
}
// 主方法
func main() {
    start := time.Now()
    // 注意设置缓冲区大小要和开启协程的个人相等
    chs := make([]chan int, 2000)
    for i := 0; i < 2000; i++ {
        chs[i] = make(chan int)
        go httpget(chs[i])
    }
    for _, ch := range chs {
        <- ch
    }
    end := time.Now()
    consume := end.Sub(start).Seconds()
    fmt.Println("程序执行耗时(s):", consume)
}

通过go语言的管道channel来实现并发请求,能够解决如何避免传统共享内存实现并发的很多问题而且效率会高于共享内存的方法。

sync.Pool

Go语言中的sync.Pool是一种用于缓存和复用临时对象的同步池化工具,旨在减少内存分配频率和垃圾回收(GC)压力,从而提升高并发场景下的程序性能。

基本概念

sync.Pool 是一个可以存储任意类型的临时对象的集合。当你需要一个新的对象时,可以先从 sync.Pool 中尝试获取;如果 sync.Pool 中有可用的对象,则直接返回该对象;如果没有,则需要自行创建。使用完对象后,可以将其放回 sync.Pool 中,以供后续再次使用。

核心机制与设计原理
1.两级缓存结构
sync.Pool采用本地缓存(per-P池)和全局缓存(victim池)的两级结构:

  • 本地缓存:每个逻辑处理器(P)维护一个私有对象池(poolLocal),通过减少锁竞争优化并发性能。
  • victim池:当发生GC时,本地缓存的对象会被转移到victim池;若下一轮GC时仍未使用,则彻底清除。这种设计允许对象在两次GC周期内被复用,降低瞬时分配压力。

2.对象生命周期管理

  • 自动回收:池中对象可能在任意GC周期被清除,因此仅适合存储临时、无状态对象。
  • 按需创建:通过New字段指定对象的构造函数,当池为空时自动调用以生成新对象。

3.并发安全与性能优化

  • 无锁设计:本地缓存通过CPU缓存行对齐(pad字段)避免伪共享(False Sharing)
  • 双链表结构:全局缓存使用poolChain双链表管理对象,支持多协程高效存取

主要特点

  1. 减少内存分配和垃圾回收(GC)压力:通过复用已经分配的对象,sync.Pool 可以显著减少内存分配的次数,从而减轻 GC 的压力,提高程序的性能。
  2. 并发安全sync.Pool是 Goroutine 并发安全的,多个 Goroutine 可以同时从 sync.Pool 中获取和放回对象,而无需额外的同步措施。
  3. 自动清理:Go 的垃圾回收器在每次垃圾回收时,都会清除 sync.Pool 中的所有对象。因此,你不能假设一个对象被放入 sync.Pool 后就会一直存在。

使用场景

  • 对象实例创建开销较大的场景,如数据库连接、大型数据结构等。
  • 需要频繁创建和销毁临时对象的场景,如 HTTP 处理函数中频繁创建和销毁的请求上下文对象。

sync.Pool是Go语言中优化高并发程序性能的利器,尤其适合管理短生命周期、高频创建的临时对象。其核心价值在于通过两级缓存结构和GC协作机制,减少内存分配压力并提升CPU缓存利用率。使用时需注意对象状态重置、避免大对象缓存及理解GC行为。结合业务场景合理使用,可显著提升程序吞吐量和响应速度

// 全局对象池定义 以[]byte 类型的池为例
var bufferPool = sync.Pool{
    New: func() interface{} { return new(bytes.Buffer) },
}

func ProcessRequest(data []byte) {
    buf := bufferPool.Get().(*bytes.Buffer) // 获取对象
    defer bufferPool.Put(buf)               // 放回池中
    buf.Reset()                             // 重置状态
    buf.Write(data)
    // 处理逻辑...
}

注意事项

  1. 对象状态未知:从 sync.Pool中获取的对象的状态是未知的。因此,在使用对象之前,你应该将其重置到适当的初始状态。
  2. 自动清理:由于 Go 的垃圾回收器会清理 sync.Pool 中的对象,因此你不能依赖 sync.Pool来长期存储对象。
  3. 不适合所有场景sync.Pool 并不适合所有需要对象池的场景。特别是对于那些需要精确控制对象生命周期的场景,你可能需要实现自定义的对象池。

sync.Pool是Go语言中优化高并发程序性能的利器,尤其适合管理短生命周期、高频创建的临时对象。其核心价值在于通过两级缓存结构和GC协作机制,减少内存分配压力并提升CPU缓存利用率。使用时需注意对象状态重置、避免大对象缓存及理解GC行为。结合业务场景合理使用,可显著提升程序吞吐量和响应速度

协程池设计

在 Go 语言中,可以通过 Channel 的通信机制替代锁(如 sync.Mutex)来管理共享资源,从而减少锁竞争,提高并发性能。以下是一个具体示例,展示如何用 Channel 实现无锁的并发任务处理:

假设我们需要实现一个高性能的任务处理器,多个生产者协程提交任务,多个消费者协程处理任务。传统锁方案可能因锁竞争导致性能瓶颈,改用 Channel 后可以无锁化处理。

传统方案
package main

import (
	"sync"
)

type Task struct {
	ID int
}

type TaskQueue struct {
	mu    sync.Mutex
	tasks []Task
}

func (q *TaskQueue) Enqueue(task Task) {
	q.mu.Lock()
	defer q.mu.Unlock()
	q.tasks = append(q.tasks, task)
}

func (q *TaskQueue) Dequeue() (Task, bool) {
	q.mu.Lock()
	defer q.mu.Unlock()
	if len(q.tasks) == 0 {
		return Task{}, false
	}
	task := q.tasks[0]
	q.tasks = q.tasks[1:]
	return task, true
}

func main() {
	queue := &TaskQueue{}
	var wg sync.WaitGroup

	// 生产者
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			queue.Enqueue(Task{ID: id})
		}(i)
	}

	// 消费者
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for {
				task, ok := queue.Dequeue()
				if !ok {
					return
				}
				// 处理任务
			}
		}()
	}

	wg.Wait()
}

问题:频繁的锁操作(EnqueueDequeue)会导致高并发下的性能下降。

Channel 方案
package main

import (
	"sync"
)

type Task struct {
	ID int
}

func main() {
	const numProducers = 10
	const numConsumers = 5

	// 使用带缓冲的 Channel 作为任务队列
	taskChan := make(chan Task, 100) // 缓冲区大小根据需求调整
	var wg sync.WaitGroup

	// 生产者协程
	for i := 0; i < numProducers; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			taskChan <- Task{ID: id} // 无锁发送任务
		}(i)
	}

	// 消费者协程
	for i := 0; i < numConsumers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for task := range taskChan { // 无锁接收任务
				// 处理任务
				_ = task.ID
			}
		}()
	}

	// 等待所有生产者完成并关闭 Channel
	go func() {
		wg.Wait()
		close(taskChan) // 安全关闭 Channel
	}()

	wg.Wait()
}
优化方向锁方案Channel 方案
并发控制依赖 sync.Mutex 保护共享队列通过 Channel 的阻塞机制自动同步
竞态条件需手动管理锁天然无竞态(Channel 操作原子性)
性能瓶颈锁竞争限制吞吐量缓冲区缓解生产-消费速度差异
代码复杂度需处理锁的获取/释放代码简洁,逻辑清晰

Channel 方案的优势

  1. 无锁化
    • Channel 的发送和接收操作是线程安全的,无需额外锁。
    • 生产者和消费者通过 Channel 直接通信,避免共享内存的竞争。
  2. 流量控制
    • 带缓冲的 Channel 可以平滑生产者和消费者的速度差异。
    • 示例中 make(chan Task, 100) 设置缓冲区大小为 100,防止瞬时高峰阻塞。
  3. 优雅关闭
    • 通过 close(taskChan) 通知消费者退出,避免资源泄漏。
    • 消费者使用 for range 自动检测 Channel 关闭。
  4. 可扩展性
    • 动态调整生产者和消费者数量,无需修改核心逻辑。

注意事项

  1. 缓冲区大小
    • 缓冲区过小可能导致生产者阻塞,过大可能占用过多内存。
    • 根据实际场景调整(如 make(chan Task, 1024))。
  2. Channel 关闭
    • 必须由生产者明确关闭 Channel,否则消费者可能永久阻塞。
    • 使用 sync.WaitGroup 确保所有生产者完成后关闭 Channel。
  3. 处理剩余任务
    • 关闭 Channel 后,消费者仍会处理完缓冲区中的所有任务。

高级技巧

1. 多级 Channel 分流

// 根据任务类型分发到不同 Channel
var (
	highPriorityChan = make(chan Task, 100)
	lowPriorityChan  = make(chan Task, 100)
)

// 消费者优先处理高优先级任务
go func() {
	for {
		select {
		case task := <-highPriorityChan:
			processHighPriority(task)
		case task := <-lowPriorityChan:
			processLowPriority(task)
		}
	}
}()

2. 超时控制

select {
case taskChan <- task: // 正常发送
case <-time.After(1 * time.Second): // 超时处理
	log.Println("任务提交超时")
}

第三方库

Gin

答案:Gin 是基于 net/http 封装的高性能框架,核心是使用压缩路由树进行高效路由匹配,Context 对象贯穿请求生命周期,支持中间件链式处理。同时通过 sync.Pool 对 Context 做了对象复用,极大降低了内存开销,提高了并发性能。

  1. 路由树结构(Radix Tree)
    Gin 使用压缩前缀树(Radix Tree)作为路由匹配结构,能够高效地处理静态路由、参数路由(如 /user/:id)和通配符路由(如 /files/*filepath)。每个路由节点保存路径片段、处理方法等信息,匹配时按照前缀逐步查找。
  2. Context 对象
    请求在进入框架后会生成一个 gin.Context,封装了 Request、Response、参数、状态码、上下文数据等。通过它可以实现链式调用、中间件传值、控制流程等功能。
  3. 中间件机制
    Gin 使用洋葱模型实现中间件:每个请求会按照注册顺序依次执行中间件,支持在任意中间件中提前终止处理流程(通过 c.Abort()),并支持恢复错误(Recovery())和日志打印(Logger())等内置中间件。
  4. 内存复用和性能优化
    Gin 通过对象池(sync.Pool)重用 Context 对象,减少频繁创建结构体带来的 GC 压力,从而大幅提升性能。此外,Gin 避免了反射处理路由和中间件,所有内容都是显式调用,运行时开销更小。
  5. 兼容 net/http
    Gin 本质是 http.Handler 接口的实现,因此可以无缝接入标准库及第三方中间件,同时也支持 HTTPS、静态文件服务、表单绑定、JSON 渲染等功能。

Gin 是一个高效的 Go 语言 web 框架,主要特点是高性能、简洁和易扩展。它基于 Radix Tree(基数树)实现路由匹配,提供了快速的路径匹配和高效的路由处理。Gin 支持灵活的 中间件机制,允许我们在请求处理过程中的多个阶段插入功能,比如日志、认证、权限校验等。每个 HTTP 请求都会生成一个 Context 对象来存储请求和响应的相关信息。Gin 的中间件和处理函数通过 c.Next()c.Abort() 控制执行流程,从而实现高效的请求处理。框架内部还使用了连接池来优化 Context 对象的管理,避免了频繁的内存分配,从而提升了性能。

注意:调用abor()之后,不会执行接下来的函数,但会继续执行上层c.Next()之后的部分,如果希望直接结束可以使用return

Gin 中的 Bind 方法通过根据请求的 Content-Type 自动选择解析方式(如 JSON、表单数据、查询参数等),然后使用 Go 的反射机制将请求数据绑定到结构体字段上。它通过读取结构体字段的标签来确定如何绑定数据,并在绑定失败时返回错误,简化了请求数据的处理过程。

Gin 中间件的核心机制是将多个中间件函数按顺序压入一个切片(链表)中,然后依次执行,并通过 c.Next() 控制是否继续调用下一个中间件。

核心设计
  • 路由引擎:基于 httprouter,使用 Radix 树实现高效路由匹配,支持参数和通配符。

  • 中间件链:通过 c.Next() 控制处理流程,支持预处理(如鉴权)和后处理(如日志)。

    Gin 中间件的核心机制是将多个中间件函数按顺序压入一个切片(链表)中,然后依次执行,并通过 c.Next() 控制是否继续调用下一个中间件。

    func LoggerMiddleware(c *gin.Context) {
        start := time.Now()
        c.Next() // 执行后续中间件和处理函数
        latency := time.Since(start)
        log.Printf("Path: %s, Cost: %v", c.Request.URL.Path, latency)
    }
    // 中间件的执行顺序是按照注册的顺序执行的,在中间件中使用 c.Next() 方法,会先执行c.Next() 前面的,然后将控制权传递给下一个中间件或处理器,最后按照相反顺序执行中间件c.Next() 后面的代码。
    
  • Context 池化:复用 gin.Context 对象减少内存分配,提升性能。

  • 绑定与渲染:内置 JSON/XML 数据绑定和模板渲染,简化开发。

为什么是高性能?

“Gin框架主要通过Go语言的协程机制来处理并发请求。当有新的请求到达时,Gin会为每个请求启动一个独立的协程进行处理,由于协程是轻量级的,创建和销毁开销小,能在同一个操作系统线程上并发执行多个协程,实现高效的并发处理。

在并发处理中,Gin框架的上下文是每个请求独立的,但如果涉及全局变量或共享资源,需要使用Go语言的同步机制(如互斥锁)来保证线程安全。此外,Gin的中间件机制也有助于并发处理,中间件可以在多个请求的协程中独立执行,完成一些通用的操作,如日志记录、权限验证等。

  • gin 将 Engine 作为 http.Handler 的实现类进行注入,从而融入 Golang net/http 标准库的框架之内
  • gin 中基于 handler 链的方式实现中间件和处理函数的协调使用
  • gin 中基于压缩前缀树的方式作为路由树的数据结构,对应于 9 种 http 方法共有 9 棵树
  • gin 中基于 gin.Context 作为一次 http 请求贯穿整条 handler chain 的核心数据结构
  • gin.Context 是一种会被频繁创建销毁的资源对象,因此使用对象池 sync.Pool 进行缓存复用

GORM

GORM 是基于反射实现的 ORM 框架,通过结构体与数据库表的自动映射实现模型绑定,底层通过链式调用构建 SQL,同时支持事务、钩子函数、预加载、数据库方言等功能,帮助我们更优雅高效地进行数据库操作。

GORM 是一个基于 Go 的 ORM 库,它通过结构体和数据库表之间的映射简化了数据库操作。底层原理是通过反射机制读取结构体中的字段,并根据字段标签生成相应的 SQL 查询语句。它支持多种数据库操作,如增、删、改、查,并且支持事务、关联查询等高级功能。GORM 会自动管理数据库连接池,并提供模型和表的自动迁移功能,减少了开发者手动编写 SQL 的复杂度。

基于反射实现模型与数据库的映射
GORM 通过反射(reflect)解析结构体字段,提取字段名、类型、标签(如 gorm:"column:id")等元信息,自动映射到数据库表字段,实现对象和数据表之间的自动绑定。

链式调用构建 SQL 语句
GORM 提供链式 API(如 .Where(), .Select(), .Joins()),每一步都构建 SQL 语句的不同部分,最后调用 .Find().Create().Delete() 等执行时统一生成最终 SQL,并通过数据库驱动发送执行。

钩子函数机制(Hooks)
GORM 提供如 BeforeCreateAfterUpdate 等钩子接口,用户可以自定义结构体方法,在增删改操作前后自动执行逻辑,实现行为注入。

事务与预加载机制
内置事务控制(.Transaction())、预加载(.Preload())、延迟加载等机制,封装了常用的数据库访问模式,提升开发效率。

数据库方言支持(Dialector)
GORM 通过 Dialector 抽象不同数据库语法差异,如 MySQL、PostgreSQL、SQLite 等,内部统一构建 SQL,再由对应 Dialector 处理方言差异。

Web相关

单点登录(SSO, Single Sign-On)

单点登录是一种允许用户在多个系统或服务间使用同一套凭证登录的机制。在单点登录的场景下,退出操作需要同时让所有已登录的系统和服务都失效,而不仅仅是本地服务。

通知 SSO 服务:

  • 通知 SSO 服务注销当前用户的会话。

  • 通常这涉及调用 SSO 服务的 API,例如:

    go复制代码err := ssoClient.Logout(userID)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, gin.H{
            "code": 500,
            "msg":  "Failed to logout from SSO",
        })
        return
    }
    

销毁本地会话:

  • 清理本地的 Token 或用户会话信息,例如:
    • 删除 Token。
    • 从 Redis 或数据库中清除会话数据。

通知其他系统注销:

  • 如果用户已登录到其他子系统,需要通过回调或通知机制让这些系统也注销当前用户。
  • 例如,SSO 服务可能会发送回调请求到每个子系统,子系统接收到通知后处理用户退出。

返回全局注销结果:

  • 如果所有子系统的注销操作都成功,向用户返回成功响应。
  • 如果某些子系统的注销失败,可以记录日志并返回部分成功的状态。

性能监测

linux下的排查

有没有遇到过cpu不高但是内存高的场景?怎么排查的

在实际开发中,遇到 CPU 使用率不高但内存占用很高的情况并不少见。这种现象通常表明程序中存在内存泄漏、内存占用过大、或者内存管理不当的问题。下面是一个排查的步骤:

检查内存占用情况

  • 工具:top**, htop, **ps使用这些系统工具查看内存占用较高的进程,确认是否是你的 Go 程序导致的内存消耗。

    查询所有监听端口 netstat -tulnp 或 ss -tulnp
    查询某端口占用 lsof -i :8080
    CPU 使用情况 top 或 htop
    进程 CPU 排序 ps aux --sort=-%cpu
    进程内存排序 ps aux --sort=-%mem
    内存使用情况 free -h
    磁盘使用情况 df -h
    查询进程 ps aux
    监控磁盘 I/O iostat -x 1 5

  • pmap使用 pmap <PID> 查看进程的内存分布,确定是哪个内存段占用最大(如 heap、stack)

未完待续

关于golang的部分基本上已经结束,但是想找到开发岗的工作至少还得学数据库,缓存,分布式,消息队列等等很多中间件。后续如果有时间会继续上传一些整理和自写的笔记。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值