Golang常见面试题及解答

Golang常见面试题及解答

本文已全面整理核对完毕,可能依旧有一些小的错误,欢迎在留言区指正补充,将及时修改完善!

1 Slice(切片)、数组

参考1:Slice底层实现
参考2:Golang-Slice 内部实现原理解析
扩展的看参考2。

1.1 切片和数组对比

  • Golang中,数组是值类型,赋值和函数传参操作都会复制整个数组数据。在数据量非常大时,每次传参都用数组,那么每次数组都要被复制一遍。这样会消耗掉大量的内存。所以函数传参用改为使用数组的指针。
  • 但是传递数组指针会有一个弊端,万一原数组的指针指向更改了,那么函数里面的指针指向都会跟着更改。
  • 切片的优势也就表现出来了,切片是引用传递,所以它们不需要使用额外的内存并且比使用数组更有效率。用切片传数组参数,既可以达到节约内存的目的,也可以达到合理处理好共享内存的问题。

注意:数组声明的时候,数组长度也属于类型中的一部分,所以两个数组只有 数组长度类型 完全相同时才能比较、赋值。

1.2 切片的数据结构

代码位置:src/runtime/slice.go

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  1. 切片的结构体由3部分构成,Pointer是指向一个数组的指针,len代表当前切片的长度,cap是当前切片的容量。cap总是大于等于len的。
  2. 切片本身并不是动态数组或者数组指针,它的内部实现是通过指针引用底层数组,设置相关的属性,将数据的读写操作限定在指定的区域内。
  3. 切片本身是一个只读对象,修改的是底层数组,而不是切片本身,其工作机制类似于数组指针的一种封装。
  4. 切片是对数组中一个连续片段的引用,所以切片是一个引用类型。

1.3 切片扩容的规则

判断是否需要扩容:当向切片中追加元素时,如果当前元素个数(长度)超过了底层数组的容量,就需要进行扩容。

go 1.18之前的扩容规则,临界值是1024,小于1024的时候,切片先两倍扩容,如果两倍扩容后的容量还是不够,就直接以切片需要的容量作为容量。

  • 如果切片的容量小于1024个元素,扩容的时候就翻倍增加容量。
  • 一旦元素个数超过1024个元素,那么增长因子就变成1.25,就是每次增加原来容量的四分之一。
  • 扩容时会根据新的容量大小创建一个新的底层数组,将原有切片中的元素逐个复制到新的底层数组中,这是一个值拷贝的过程。如果切片中的元素是引用类型,则只复制引用值,并不复制实际对象。然后将原切片指向新的底层数组,并更新切片的长度为追加元素后的长度。
  • 在切片扩容后,原底层数组将不再使用,Go语言会自动回收该底层数组的内存。

go1.18之后,临界值换成了256,小于256的时候,切片先两倍扩容,如果两倍扩容后的容量还是不够,就直接以切片需要的容量作为容量。


大于256公式变为(oldCap+3*256)/4这个公式的值随着oldcap的越来越大,从2一直接近1.25,相对于1.18之前可以更平滑的过渡。

注意

  1. 扩容指的是扩大切片的容量,而不是底层数组的长度。
  2. Golang切片扩容时的值拷贝是深拷贝还是浅拷贝?【回答来自GPT3.5】
  • 在Go语言中,切片的扩容过程涉及值的复制操作。这是浅拷贝的一种情况,因为只复制了切片中的元素本身,而没有复制元素所引用的对象。

  • 具体来说,在切片扩容时,Go语言会创建一个新的底层数组,并将原有切片中的元素逐个复制到新的底层数组中。这里复制的是切片中元素的值,而不是复制元素引用的对象。所以,原切片和新切片会引用不同的底层数组,但它们的元素值可能是相同的,因为是值拷贝。

  • 这也意味着,如果切片中的元素是引用类型(例如,切片、映射或自定义结构体),复制的仅是这些引用值,并没有复制引用指向的实际对象。因此,如果修改原切片或新切片中的引用值指向同一个对象,修改会在两个切片中都反映出来,因为它们共享相同的引用。

切片扩容时的值拷贝示例:

package main

import "fmt"

func main() {
    // 创建原始切片
    slice1 := []int{1, 2, 3}
    // 创建新切片,通过 append 扩容
    slice2 := append(slice1, 4)
    // 修改原切片中的元素
    slice1[0] = 100
    fmt.Println("原始切片:", slice1) // 输出 [100 2 3]
    fmt.Println("新切片:", slice2)   // 输出 [1 2 3 4]
}

注意到,修改原切片slice1中的元素并不会影响新切片slice2,这是因为它们指向了不同的底层数组。但是,如果修改切片中的元素是引用类型,比如修改切片中的切片或映射中的值,那么会影响原切片和新切片,因为它们共享相同的引用。

1.4 使用make初始化切片【推荐方式】

初始化方式:(一般使用make初始化的时候len最好设置为0,避免使用时切片前面的数据出现多余的零值数据。)

make([]T,len);
//与
make([]T,len,cap);

问下列初始化后的输出结果:

make([]int,8);
//与
make([]int,0,8);

这两种都是初始化了一个切片,根据初始化方式可知,主要的区别为是否声明了容量。

make([]int,8); 
//声明的切片长度是8,在未使用前,这个切片已有8个数据,数值均为0,
//因为8个位置的值均为类型的零值,int型的零值是0,所以输出是8个0.
make([]int,0,8);
//声明的切片长度是0,容量是8,在未使用前,
//这个切片中没有任何数据,只是容量是8,所以输出为空【即没有数据】。

1.5 切片的拷贝

浅拷贝:拷贝的是地址,只是复制指向对象的指针。切片是引用类型数据,默认引用类型数据,全部都是浅拷贝,比如:切片,Map等。

分析:

slice2 := slice1
  • slice1slice2指向的都是同一个底层数组,任何一个数组元素被改变,都可能会影响两个切片。
  • 在切片触发扩容操作前,slice1slice2指向的都是相同数组,但在触发扩容操作后,二者指向的就不一定是相同的底层数组了,具体可参考上面的slice扩容规则。

深拷贝:拷贝的是数据本身,会创建一个新对象。

copy(slice2, slice1)

新对象和原对象不共享内存,在新建对象的内存中开辟一个新的内存地址,新对象的值修改不会影响原对象值,既然内存地址不同,释放内存地址时,可以分别释放。

1.6 切片内存泄露

当切片的底层数组很大,但切片所取元素数量很小时,底层数组占据的大部分空间都是被浪费的。

比如切片b的底层数组很大,切片a只引用了切片b很小的一部分,只要切片a还在,切片b底层数组就永远不会被回收,这样就造成了内存泄露!

代码示例

var a []int
 
func test(b []int) {
    a = b[:1] // 和b共用一个底层数组
    return
}

解决方法
不要引用切片b的底层数组,将需要的数据复制到一个新的切片中,这样新切片的底层数组,就和切片b的底层数组无任何关系了。

var a []int

func test(b []int) {
    a = make([]int, 1)
    copy(a, b[:0])
    return
}

1.7 切片并发安全问题

切片不是并发安全的,要并发安全,有两种方法:

  • 加锁
  • channel

面试题:切片和map的数据结构并发安全吗?
答:切片的写入和map的写入一样都是非线程安全的,但是mapsync.Map{},切片只能通过加锁channel方式来实现线程安全的并发写操作。


加锁:适合于对性能要求不高的场景,毕竟锁的粒度太大,这种方式属于通过共享内存来实现通信。

代码示例:

func TestSliceConcurrencySafeByMutex(t *testing.T) {
    var lock sync.Mutex //互斥锁
    a := make([]int, 0)
    var wg sync.WaitGroup
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            lock.Lock()
            defer lock.Unlock()
            a = append(a, i)
        }(i)
    }
    wg.Wait()
    t.Log(len(a)) 
    // equal 10000
}

channel:适合于对性能要求大的场景,channel就是专用于goroutine间通信的,这种方式属于通过通信来实现共享内存,而Go的箴言便是:尽量通过通信来实现内存共享,而不是通过共享内存来实现通信,推荐此方法!

代码示例:

func TestSliceConcurrencySafeByChanel(t *testing.T) {
    buffer := make(chan int)
    a := make([]int, 0)
    // 消费者
    go func() {
        for v := range buffer {
            a = append(a, v)
        }
    }()
    // 生产者
    var wg sync.WaitGroup
    for i := 0; i < 10000; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            buffer <- i
        }(i)
    }
    wg.Wait()
    t.Log(len(a)) 
    // equal 10000
}

1.8 怎么判断两个相同类型的切片是否相等,比如[]string

参考3:Golang比较两个字符串切片是否相等
方式一:reflect.DeepEqual(s1, s2)方法

func equal( s1 []int ,  s2 []int ) bool {
    return reflect.DeepEqual(s1, s2)
}

说明:reflect.DeepEqual()接收的是两个interface{}类型的参数,首先判断两个参数的类型是否相同,然后才会根据类型层层判断。

方式二:循环遍历切片逐个元素进行比较

func equal( s1 []int ,  s2 []int ) bool {
    if len(s1) != len(s2) {
        return false
    }
    for i := 0; i < len(s1); i++ {
        if s1[i] != s2[i] {
            return false
        }
    }
    return true
}

1.9 Golang内置函数append的时间复杂度是什么样?

在Go语言中,append函数用于向切片(slice)追加元素。append的时间复杂度是均摊 O(1) 的,这意味着在大多数情况下,单次append操作的时间复杂度是常数级别的。

append操作的均摊复杂度为O(1),是因为Go在进行append操作时,会使用一种动态扩容的机制。当切片的容量不足以容纳新增元素时,Go会创建一个新的底层数组,并将原始元素复制到新的数组中。这样做的目的是确保在大多数情况下,append的时间复杂度是常数级别的。

具体来说,append操作的均摊时间复杂度为O(1) 意味着执行N次append操作的总时间复杂度为O(N),其中N为元素的总个数。每次append操作的平均时间复杂度是常数级别的,但在某些情况下可能需要进行底层数组的重新分配和复制,导致某次append操作的耗时略高。

需要注意的是,虽然append的均摊时间复杂度是O(1),但在实际编程中,仍然需要注意避免频繁进行append操作,尤其是在循环中,因为每次append都可能触发底层数组的重新分配和复制,影响性能。

2 goroutine(协程)

2.1 Golang为什么会有协程

参考1:Golang协程详解和应用

Golang的协程是为了解决多核CPU利用率问题,Golang语言层面并不支持多进程或多线程,但是协程更好用,它是一种轻量级的并发执行单元,是Golang语言提供的一种特性,使得在同一个程序中可以同时执行多个函数或方法,实现高效的并发编程。协程被称为用户态线程,因为不存在CPU上下文切换问题,所以效率非常高。

2.2 进程、线程、协程

2.2.1 进程、线程、协程之间的区别

参考1:线程和进程的区别
参考2:协程与线程的区别

两两区分:进程与线程、线程与协程。

进程:

  1. 进程是资源分配的最小单位。
  2. 进程有自己的独立地址空间、代码、数据和系统资源。每启动一个进程,系统就会为它分配地址空间等资源,所以创建和销毁进程会产生较大的开销。
  3. 因为进程有自己独立的地址空间和资源,所以多进程程序间的错误不会相互影响,也就是一个进程死掉并不会对另外一个进程造成影响。多线程程序因为共享进程的资源,只要有一个线程死掉,整个进程可能也会死掉。

线程:

  1. 线程程序执行的最小单位(资源调度的最小单位)。
  2. 线程是共享进程中的地址空间和资源,但每个线程有自己的栈空间寄存器,因此CPU切换一个线程的开销远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
  3. 线程之间因为共享同一个进程的资源,所以通信更方便,而进程因为拥有独立的地址空间和资源,所以进程之间的通信需要使用特定的机制,如管道、消息队列、共享内存等。

进程和线程的关系:

  1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程是操作系统可识别的最小执行和调度单位。
  2. 资源分配给进程,同一进程的所有线程共享该进程的所有资源。 同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。
  3. 处理机分给线程,即真正在处理机上运行的是线程。
  4. 线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。

协程:

  1. 内存占用
    创建一个协程的栈内存消耗默认为2KB。创建一个线程则需要消耗MB级别以上的栈内存

  2. 创建和销毁
    (1)线程创建和销毀都会有巨大的消耗,因为线程是由操作系统管理的,创建和销毁线程涉及到内核的调度和资源分配,是内核级的,相对较重量级,通常解决的办法就是线程池。
    (2)协程因为是由 Go 运行时(runtime)使用了自己的调度器管理的,创建和销毁的消耗非常小,是用户级。因此在Go语言中可以轻松创建成千上万个协程而不会导致资源耗尽。这种特性使得Go语言在并发编程中非常高效,可以充分利用多核处理器的能力,实现高性能的并发程序。

  3. 通信
    (1)Go协程之间通过通道(Channel)进行通信,从而实现数据传递和同步操作。
    (2)线程之间的通信通常需要使用操作系统提供的同步原语,如锁、信号量等,较为繁琐。

  4. 切换
    当线程切换时,需要保存各种寄存器,以便将来恢复,而 goroutines 切换只需保存三个寄存器。
    一般而言,线程切换会消耗 1000-1500 纳秒,一个纳秒平均可以执行 12-18 条指令。所以由于线程切换,执行指令的条数会减少 12000-18000。协程的切换约为 200ns,相当于 2400-3600 条指令。因此,协程切换成本比线程要小得多。

2.2.2 线程是共享进程的哪些资源

参考1:线程间到底共享了哪些进程资源

线程的私有信息

  1. 线程运行的本质就是函数运行,函数运行时信息保存在栈帧(栈区存储函数运行时的返回地址(程序计数器)、参数、局部变量、寄存器原始值)中,因此每个线程有自己独立、私有的栈区。
  2. 线程私有的信息 —— 线程上下文 包括所属线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器

线程的共享信息:线程之间共享除线程上下文信息中的所有内容,包括栈区、堆区、代码区、数据区。

代码区:进程中的代码区存储的是编译后的可执行机器指令。而这些机器指令是从可执行文件中加载到内存的。

线程之间共享代码区,意味着任何函数都可以被线程执行。

堆区malloc/new出来的数据就存放在这个区域。

栈区:线程的上下文信息通常是私有的,但它们并没有严格的隔离机制来保护。因此, 若一个线程能拿到来自另一个线程栈帧上的指针,那么该线程就可以改变另一个线程的栈区。

文件:若线程保存有打开的文件信息,则进程打开的文件也可以被所有的线程使用,这也属于线程间的共享资源。

2.2.3 进程中可以没有线程吗

不可以,因为线程是资源调度的最小单位,一个进程至少要有一个线程来作为主线程。

2.2.4 线程之间是共享哪里的数据,堆内存还是栈内存

  1. 堆内存是线程共享的内存区域,主要存放对象实例,这说明一个线程在堆上分配的内存可以被其他线程访问和使用,但需要注意对堆内存的访问进行同步控制,以避免竞态条件和内存访问冲突。
  2. 栈内存是线程独享的内存区域,主要存放各种基本数据类型、对象的引用,一个线程的栈上的数据只能被自己的代码访问,其他线程无法直接访问。

2.3 协程的调度原理

参考1:https://zhuanlan.zhihu.com/p/323271088
只看 二、Goroutine调度器的GMP模型的设计思想 往后的即可。

Golang的协程调度是通过GMP模型实现的。
在这里插入图片描述

  • G:(goroutine)协程;
  • P:(processor)逻辑处理器;
  • M:Go运行时(runtime)中的操作系统线程,也称为Machine。

P(Processor)在Golang中指的是逻辑处理器。每个P负责调度和管理一组协程的执行。逻辑处理器(P)是协程与操作系统线程(M)的中间层,它允许多个协程在一个操作系统线程(M)上进行并发执行,如果线程想运行协程,必须先获取逻辑处理器(P)。

P与处理器核心(物理处理器)是不同的概念。逻辑处理器(P)的数量默认情况下与处理器核心数相同,但Go运行时可以根据系统的负载情况动态地增加或减少逻辑处理器(P)的数量,以适应程序的并发需求。每个逻辑处理器(P)会从全局的运行队列中获取待执行的协程,并将其映射到一个空闲的操作系统线程(M)上执行。

2.3.1 能用最简短的一句话概括GMP的原理吗

面试官的回答:协程只是一个虚拟的概念,是Go语言层面的一个东西。其实就是一段代码,依赖于操作系统来执行的,GMP本质是一个调度的工具,帮我们把程序代码怎么合理的分配到一个线程上的。

2.3.2 GMP模型执行流程

Go中,线程是最终运行协程实体,调度器的功能是把可运行的协程分配到工作线程上。
在这里插入图片描述

  • 全局队列(Global Queue):存放等待运行的协程。
  • 逻辑处理器(P)的本地队列:同全局队列类似,存放的也是等待运行的协程协程,存的数量有限,不超过256个。新建协程时,协程优先加入到逻辑处理器的本地队列,如果队列满了,则会把本地队列中一半的协程移动到全局队列。
  • 逻辑处理器(P)列表:所有的逻辑处理器(P)都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。逻辑处理器(P)的数量默认与处理器核心数相同,但Go运行时可以在运行时动态增加或减少逻辑处理器的数量,以适应程序的并发需求。逻辑处理器的数量决定了并行执行的协程数目,当逻辑处理器的数量较多时,Go语言可以更充分地利用多核处理器。
  • 操作系统线程(M):线程想运行任务就得获取逻辑处理器,从逻辑处理器的本地队列获取协程,逻辑处理器队列为空时,线程也会尝试从全局队列拿一批协程放到逻辑处理器的本地队列,或从其他逻辑处理器的本地队列拿一半放到自己逻辑处理器的本地队列。线程运行协程,协程执行之后,线程会从逻辑处理器获取下一个协程,不断重复下去。

协程调度器和操作系统的调度器是通过线程结合起来的,每个线程都代表了1个内核线程,操作系统的调度器负责把内核线程分配到CPU的核上执行。

2.3.3 逻辑处理器P 和 线程M 的个数问题

逻辑处理器P的数量:默认情况下与物理处理器核心数相同,但Golang运行时可以根据系统的负载情况动态地增加或减少逻辑处理器(P)的数量,以适应程序的并发需求。

线程M的数量:在Golang中,M(Machine)的数量由Golang运行时(runtime)根据系统的负载情况和配置参数进行决定。MGolang语言运行时的操作系统线程,负责管理和执行Goroutine。在运行时,Golang语言会根据以下因素来确定M的数量:

  1. GOMAXPROCS
  2. 系统负载Golang运行时会根据当前系统的负载情况来调整M的数量。如果系统负载较高,可能会增加M的数量,以充分利用多核处理器的性能。相反,如果系统负载较低,可能会减少M的数量,以节省资源。
  3. GOMAXGCTIMEGOMAXGCTIME是一个环境变量,用于控制垃圾回收的时间。Golang运行时会根据垃圾回收的负载情况来调整M的数量。垃圾回收是Golang语言运行时的一个重要机制,它负责回收不再使用的内存。
  4. Golang程序的性能需求:如果Golang程序需要处理大量的并发任务,Golang运行时可能会增加M的数量以满足性能需求。反之,如果程序并发需求较低,Golang运行时可能会减少M的数量以减少资源占用。

总的来说,M的数量是由Golang运行时动态调整的,目的是根据系统负载和性能需求,充分利用多核处理器的性能,实现高效的并发编程。开发者可以通过GOMAXPROCS等环境变量来进行一定的调整,但一般情况下不需要手动管理M的数量,Golang语言运行时会自动处理。

线程M逻辑处理器P的数量没有绝对关系,一个线程M阻塞,逻辑处理器P就会去创建或者切换另一个线程M,所以,即使逻辑处理器P的默认数量是1,也有可能会创建很多个线程M出来。

逻辑处理器P和线程M何时会被创建:

  1. 逻辑处理器P何时创建:在确定了逻辑处理器P的最大数量n后,系统启动时系统会根据这个数量创建n个逻辑处理器P
  2. 线程M何时创建:没有足够的线程M来关联处理器P并运行其中的可运行的Goroutine。比如所有的线程M此时都阻塞住了,而处理器P中还有很多就绪任务,就会去寻找空闲的线程M,而没有空闲的,就会去创建新的线程M

2.3.4 调度器的调度策略

参考:Golang高并发编程技巧:深入理解Goroutines的调度策略
Goroutine的调度策略主要包括三个方面:抢占式调度协作式调度Work Stealing

  1. 抢占式调度Golang的调度器采用的是抢占式调度策略,即任何一个Goroutine的执行都可能被其他Goroutine随时中断。这种调度策略的好处是能够合理分配CPU资源,防止某个Goroutine长时间独占CPU而导致其他Goroutine无法执行。当一个Goroutine被抢占时,调度器会将其状态保存,并切换到其他可执行的Goroutine
  2. 协作式调度:除了抢占式调度,Golang的调度器还采用了协作式调度策略。在协作式调度中,Goroutine会自动放弃CPU的执行权利,而不是一直占用CPU。通过在适当的时机主动让出CPU,在Goroutine之间合理切换,可以提高整个系统的并发性能。
  3. Work StealingWork StealingGolang调度器中的一个非常重要的机制。它的核心思想是让处在空闲状态的线程主动“偷取”其他线程的任务来执行,从而实现线程之间的负载均衡。这种机制能够避免某些线程工作过多,而其他线程一直处于空闲状态的情况,进一步提高并发程序的性能。

2.4 Golang最多能启动多少个协程

参考1:http://t.zoukankan.com/ExMan-p-12091738.html

计算机资源是有限的,所以Goroutine肯定也是有限制的,单纯的Goroutine,一开始每个占用2KB内存,所以这里会受到内存使用量的限制,还有Goroutine是通过系统线程来执行的,Golang默认最大的线程数是10000个。可以通过runtime/debug中的SetMaxThreads函数,设置M的最大数量。但要注意线程和Goroutine不是一一对应关系,理论上内存足够大,而且Goroutine不是计算密集型的话,可以开启无限个Goroutine

2.5 协程之间并发安全如何处理

  1. 加锁:使用互斥锁、读写锁(RWMutex
  2. 使用通道(channel
  3. 避免全局变量
  4. 使用原子操作
  5. 避免死锁

2.6 协程是用户态的还是内核态的

协程是用户态。

2.7 如何从外部停止并退出正在运行的协程(如何优雅的关闭协程)

来自GPT3.5的回答

  1. 使用通道(channel):可以通过在协程中监听一个特定的停止信号通道,当收到停止信号时,协程就可以安全地退出。
func worker(stopCh <-chan struct{}) {
    for {
        select {
        case <-stopCh:
            // 收到停止信号,安全退出
            return
        default:
            // 正常处理任务
        }
    }
}

func main() {
    stopCh := make(chan struct{})
    go worker(stopCh)

    // 停止并退出协程
    // 发送停止信号到通道
    close(stopCh)
    // 或者使用: stopCh <- struct{}{}
}
  1. 使用Context:Go语言的Context包提供了更灵活的方式来管理协程的生命周期。Context可以用于传递取消信号,让协程在收到取消信号后退出。
import (
    "context"
)

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            // 收到取消信号,安全退出
            return
        default:
            // 正常处理任务
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 释放资源

    go worker(ctx)

    // 停止并退出协程
    cancel()
}

在这个例子中,我们使用context.WithCancel创建一个Context,并在main函数中调用cancel函数来发送取消信号给worker协程。

2.8 协程的并发数怎么控制

  1. 使用channel,有缓冲的channel可以设置数量,从而控制并发数目。

步骤:

  • 设定channel长度,循环开始每生成一个goroutine则写入一次channel。
  • channel写满则阻塞。
  • goroutine执行完毕,释放channel。
  • for循环中继续写入channel,保证同时执行的goroutine只有10个。
  1. sync.WaitGroup
    如果在 Golang 应用程序中,需要让主 goroutine 等待多个 goroutine 都运行结束后再退出程序,我们应该怎么实现呢?是的,同样可以使用 Channel 实现,但是,有一个更优雅的实现方式,那就是 WaitGroup,顾名思义,WaitGroup 就是等待一组 goroutine 运行结束。
package main

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

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()

	fmt.Printf("Worker %d started\n", id)
	time.Sleep(1 * time.Second) // 模拟耗时的工作
	fmt.Printf("Worker %d finished\n", id)
}

func main() {
	const numWorkers = 3

	var wg sync.WaitGroup
	wg.Add(numWorkers)

	for w := 1; w <= numWorkers; w++ {
		go worker(w, &wg)
	}

	wg.Wait()

	fmt.Println("All workers have finished. Continue with the main process.")
}

在上面的例子中,我们使用了sync.WaitGroup来控制并发数。首先,我们调用wg.Add(numWorkers)来设置等待组的计数器为numWorkers,表示有numWorkers个协程需要等待。然后,在每个协程的处理函数中,我们使用defer wg.Done()在函数执行完成时通知等待组计数器减1。最后,我们调用wg.Wait()来阻塞主线程,直到所有协程完成。

通过这种方式,我们可以控制并发数为numWorkers,确保所有协程都完成后再继续执行主线程的后续逻辑。这是一种简单而有效的方式来管理并发协程。

2.9 协程占内存多少

每个Goroutine的栈内存大小默认为2KB,这在大多数情况下是足够的。但是,栈内存的大小可以通过Golang的runtime.Stack函数来查看或者通过runtime.GOMAXPROCS函数设置。

除了栈内存,每个Goroutine还会占用少量的额外内存,用于Goroutine的管理和调度。这些开销相对较小,通常在几KB的范围内。

2.10 如何控制协程抛出的异常

defer+recover来捕获并处理异常。

来自GPT3.5的回答:

在Go语言中,协程(Goroutine)的异常不能被其他协程捕获或控制,而是会导致整个程序崩溃。这是Go语言设计的一种原则,即一个Goroutine的异常不应该影响其他的Goroutines,而应该由每个Goroutine自己负责处理异常

当一个Goroutine发生了未被捕获的异常(例如数组越界、空指针等),Go语言运行时会把该异常视为致命错误,并会终止整个程序的运行。这是为了确保程序在出现严重错误时能够快速停止,避免继续运行可能导致更严重问题的状态。

虽然无法直接控制协程抛出的异常,但我们可以在每个Goroutine中使用recover函数来捕获并处理异常。recover函数只能在defer语句中调用,用于捕获panic引发的异常。通过在Goroutine的处理函数中使用defer和recover,可以捕获异常并在出现异常时采取一些处理措施,从而避免整个程序崩溃。

package main

import (
	"fmt"
)

func worker() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovered from:", r)
		}
	}()

	// 模拟发生异常
	var arr [5]int
	fmt.Println(arr[10]) // 会导致数组越界异常
}

func main() {
	go worker()

	// 主协程休眠等待一段时间
	// 这是为了让Goroutine有足够的时间执行
	// 在实际应用中,可能需要采用更好的方式来等待Goroutine的执行
	select {}
}

在上面的例子中,我们在worker协程中使用了defer和recover,用于捕获可能发生的异常。当数组越界异常发生时,recover会捕获该异常,并在控制台打印异常信息,但程序不会崩溃,而是继续执行。

需要注意的是,即使在一个协程中使用了recover捕获了异常,其他的协程仍然不受影响。异常只会影响当前的协程,而不会影响其他的协程。因此,在Golang中,建议每个协程都独立处理可能的异常,确保程序在出现异常时能够优雅地处理错误。

2.11 Golang的协程数取决哪些因素

2.3 Golang最多能启动多少个协程
因素:计算机内存线程数

2.12 哪些场景有使用到协程、channel

并发处理。有缓冲的channel可以控制并发数目,从而实现多线程的并发处理。

2.13 Golang父级协程怎么获取子级协程的错误信息(或其他信息)

答:通过channel,将错误信息放入channel中,父级协程监听该channel就能获取到子级的错误信息了。

2.14 父协程如何监听多个子协程的退出

  1. 可以使用channel,有缓冲的channel,每退出一个协程,在退出前往channel里塞入一条数据。等channel中的数据等于缓冲数量了,就说明子协程都退出了。
  2. 使用sync.WaitGroup等待组。

2.15 保证多个goroutine都同步返回

使用sync.WaitGroup来实现监听多个协程同步返回的情况。

2.16 一个goroutine,你调用了一个sleep,然后它休眠了,这时候这个调度模型会做什么处理?

在Go语言中,调用time.Sleep会使当前的goroutine进入休眠状态,让出CPU的执行权给其他可运行的goroutine。当一个goroutine调用time.Sleep时,它会被放入等待队列,等待指定的时间过去后再被重新放入可运行队列,准备再次执行。

Go语言的调度器采用抢占式调度,即在每个goroutine执行的适当点上,调度器都有机会检查是否有更高优先级的goroutine可以运行。因此,当一个goroutine调用time.Sleep时,它就放弃了CPU的执行权,调度器会在这个时候选择其他可运行的goroutine来执行。

具体的执行流程可以描述如下:

  1. 当goroutine调用time.Sleep时,它将放入等待队列,同时释放CPU的执行权。
  2. 调度器会选择其他可运行的goroutine继续执行。
  3. 在指定的休眠时间过去后,被休眠的goroutine会被重新放入可运行队列。
  4. 调度器会在适当的时候选择这个goroutine继续执行。

这种抢占式调度的机制使得在休眠期间,其他goroutine有机会继续执行,提高了并发程序的效率。需要注意的是,time.Sleep会导致当前goroutine休眠,但不会阻塞整个线程,因此其他goroutine仍然可以在同一个线程上执行。

3 垃圾回收机制

首先要记住的是 Go语言使用的是基于标记-清除(Mark-Sweep)算法改进后的三色标记法来进行内存垃圾回收。

垃圾回收这块整理起来比较繁琐,特别是三色标记法这块,参考和结合的地方较多,所以在具体内容附近加了很多参考的链接,可以复制查找出处。

参考1:浅析 Golang 垃圾回收机制
参考2:Golang 垃圾回收
参考3:Golang 垃圾回收机制详解
参考4:Golang-垃圾回收原理解析
参考5:图解Golang垃圾回收机制!

常见的垃圾回收算法:

  1. 分代收集法
  2. 引用计数法
  3. 标记 — 复制法
  4. 标记 — 清除法
  5. 标记 — 整理法
  6. 三色标记法

3.1 分代收集法

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

  • 对于生命周期短的新生代区域,每次回收仅需要考虑如何保留少量存活对象,因此可以采用标记-复制法完成GC。Golang-垃圾回收原理解析
  • 对于生命周期长的老年代区域,可以通过减少gc的频率来提供效率,同时由于对象存活率高没有额外的空间用于复制,因此一般可以使用标记清除法或标记整理法。Golang-垃圾回收原理解析
    在这里插入图片描述

这样划分,堆就分成了Young和Old两个分区,因此GC也分为新生代GC和老年代GC。Golang-垃圾回收原理解析

对象的分配策略:Golang-垃圾回收原理解析

  • 对象优先在新生代上Eden区域分配
  • 大对象直接进入老年代
  • 新生代中周期较长的对象在s0或s1区每经过一次新生代Gc,就增加一岁,增加到一定阈值的时候,就进入老年代区域。

代表语言: Java
优点: 回收性能好。
缺点: 算法复杂。
浅析 Golang 垃圾回收机制

3.2 引用计数法

引用计数法会为每个对象维护一个计数器,当该对象被其他对象引用时,该引用计数加1,当引用该对象的对象销毁(引用失效)时减1,当引用计数为0后即可回收对象。浅析 Golang 垃圾回收机制

代表语言: Python、PHP、Swift。
优点: 对象回收快,因为引用计数为0则立即回收,不会出现内存耗尽或达到某个阈值时才回收。

缺点:

  1. 无法解决循环引用的问题 Golang-垃圾回收原理解析。(若是A引用了B,B也引用了A,形成循环引用,当A和B的引用计数更新到只剩彼此的相互引用时,引用计数便无法更新到0,也就不能回收对应的内存了Golang 垃圾回收机制详解
  2. 实时维护引用计数也是有损耗的 浅析 Golang 垃圾回收机制
    时间和空间成本高:每个对象需要额外的空间来存储引用计数,在栈上修改引用计数的时间成本高(因为需要额外的原子操作来保证线程安全)。Golang-垃圾回收原理解析
    无法保证耗时:引用计数是一种摊销算法,会将内存的回收分摊到整个程序的运行过程,当销毁一个很大的树形结构时无法保证响应时间。Golang-垃圾回收原理解析

3.3 标记 — 复制法

参考:Golang-垃圾回收原理解析
主要分为标记和复制两个步骤:

  • 标记: 记录需要回收的垃圾对象。
  • 复制: 将内存分为大小相同的两块,当某一块的内存使用完了之后就将使用中的对象挨个复制到另一块内存中,最后将当前内存恢复为未使用状态。
    标记-复制算法

优点:

  • 不用进行大量垃圾对象的扫描:标记-复制算法需要从GC-root对象出发,将可达的对象复制到另外一块内存后直接清理当前这块的内存即可。
  • 解决了内存碎片问题,防止分配大空间对象时提前垃圾回收的问题。

缺点:

  • 复制成本问题:在可达对象占用内存高的时候,复制成本会很高。
  • 内存利用率低:相当于可利用的内存仅有一半。

※3.4 标记 — 清除法

参考:Golang 垃圾回收机制详解

  • 程序中用的到的数据一定是从栈、数据段这些根节点追踪得到的数据,虽然能够追踪的到但不代表后续一定会用得到,但是根节点追踪不到的数据就一定不会被用到,也就一定是垃圾。
  • 要识别存活对象,可以把栈、数据段上的数据对象作为根(root),基于它们进一步追踪,将能追踪到的数据都进行标记,剩下的追踪不到的就是垃圾。

所以 标记 — 清除法 就是从根变量开始遍历所有引用的对象,然后对引用的对象进行标记,将没有被标记的进行回收。浅析 Golang垃圾回收机制

代表语言Golang(三色标记法)
优点:解决了引用计数的缺点。
缺点:需要STW(Stop The World),即暂时停掉程序运行。

算法分两个部分: 标记(mark)清除(sweep)。标记阶段表明所有已使用的引用对象,清除阶段将未使用的对象回收。

具体步骤: 图解Golang垃圾回收机制!

  1. 进行STWStop The World即暂停程序业务逻辑),然后从main函数开始找到不可达的内存占用和可达的内存占用。
  2. 开始标记,程序找出可达内存占用并做标记。
  3. 标记结束清除未标记的内存占用。
  4. 结束STW,让程序继续运行,循环该过程直到main生命周期结束。

3.5 标记 — 整理法

参考:Golang-垃圾回收原理解析

标记出所有可达对象,然后将可达对象移动到空间的另外一段,最后清理掉边界以外的内存。
在这里插入图片描述
优点:

  • 避免了内存碎片化的问题。
  • 适合老年代算法:老年代对象存活率高的情况下,标记整理算法由于不需要复制对象,效率更高。

缺点:

  • 整理的过程复杂:需要多长遍历内存,导致STW时间比标记清除算法高。

※3.6 三色标记法

三色标记法只是为了叙述方便而抽象出来的一种说法,实际上的对象是没有三色之分的 浅析 Golang 垃圾回收机制。前面的标记-x类算法都有一个问题,那就是STW(即gc时暂停整个应用程序),三色标记法是对标记阶段进行改进的算法,目的是在不暂停程序的情况下即可完成对象的可达性分析,垃圾回收线程将所有对象分为三类:Golang-垃圾回收原理解析

  • 白色对象:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时,所有白色对象都是垃圾对象。
  • 灰色对象:正在搜索的对象,但是对象身上还有一个或多个引用没有扫描。
  • 黑色对象:已搜索完成的对象,所有的引用已被扫描完。

优点: 不需要STWGolang-垃圾回收原理解析
缺点: Golang-垃圾回收原理解析

  1. 三色标记法存在并发性问题。
  2. 错误的回收非垃圾对象。
  3. 线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,从而降低系统吞吐量。
  4. 如果产生垃圾速度大于回收速度时,可能会导致程序中垃圾对象越来越多而无法及时收集。
  5. 能会出现野指针(指向没有合法地址的指针),从而造成严重的程序错误。

三色标记算法属于增量式GC算法,回收器首先将所有对象着色成白色,然后从gc root出发,逐步把所有可达的对象变成灰色再到黑色,最终所有的白色对象都是不可达对象。Golang-垃圾回收原理解析

3.6.1 三色标记法具体流程

具体流程图:浅析 Golang 垃圾回收机制
在这里插入图片描述
具体流程文字描述:Golang-垃圾回收原理解析

  1. 初始时默认所有对象都是白色的。
  2. 从gc根对象出发,扫描所有引用到的对象并标记为灰色,放入待处理队列。
  3. 从待处理队列取出一个灰色对象并标记为黑色,将其引用对象标记为灰色,放入待处理队列。
  4. 重复上一步骤,直到灰色对象队列为空。
  5. 此时只剩下白色对象和黑色对象,白色对象就是等待回收的垃圾对象。

3.6.2 这中间会形成几次STW?

Golang语言的垃圾回收器(Garbage Collector,GC)会导致程序的停顿,这种停顿被称为Stop-The-World(STW)。在Golang语言中,有两个主要的停顿事件:一是用于标记对象的停顿,二是用于清理和回收不再使用的对象的停顿。

  1. 标记阶段(Marking Phase):在标记阶段,垃圾回收器会标记程序中所有活动的对象。这个阶段会导致一次STW停顿。在Golang语言中,标记阶段是由GCTime触发的,默认情况下,GCTime100ms,表示每隔100ms就会进行一次标记阶段的垃圾回收。可以通过设置环境变量GOGC来调整GCTime的值。
  2. 清理阶段(Sweeping Phase):在清理阶段,垃圾回收器会清理和回收不再使用的对象。清理阶段会导致一次STW停顿。在清理阶段,回收器会扫描和清理被标记为不再使用的对象,并将它们的内存释放回堆。清理阶段的时间通常较短。

总的来说,垃圾回收的STW停顿主要发生在标记阶段和清理阶段。标记阶段的频率由GCTime控制,而清理阶段在标记阶段之后立即进行。Golang语言的垃圾回收器的设计目标之一是尽量减小STW时间,以提高程序的响应性。因此,Golang的垃圾回收器采用了一些技术手段,如并发标记(Concurrent Marking)和并发清理(Concurrent Sweeping),以减小STW的影响。

3.7 三色标记法的优化

3.7.1 强三色不变式、弱三色不变式

这种方法看似很好,但是将GC和程序会放一起执行,会因为CPU的调度可能会导致被引用的对象会被垃圾回收掉,从而出现错误。图解Golang垃圾回收机制!

分析问题的根源所在,主要是因为程序在运行过程中出现了下面俩种情况:图解Golang垃圾回收机制!

  • 一个白色对象被黑色对象引用。
  • 灰色对象与它之间的可达关系的白色对象遭到破坏。

因此在此基础上拓展出了两种方法,强三色不变式和弱三色不变式。图解Golang垃圾回收机制!

  • 强三色不变式:不允许黑色对象引用白色对象。
  • 弱三色不变式:黑色对象可以引用白色,但是白色对象必须存在其他灰色对象对他的引用,或者他的链路上存在灰色对象。

3.7.2 插入写屏障、删除写屏障(屏障的机制)

参考:图解Golang垃圾回收机制!
为了实现这两种不变式的设计思想,从而引出了屏障机制,即在程序的执行过程中加一个判断机制,满足判断机制则执行回调函数。

屏障机制分为插入屏障删除屏障插入屏障实现的是强三色不变式删除屏障则实现了弱三色不变式。需要注意的是为了保证的运行效率,屏障只对上的内存对象启用,上的内存会在GC结束后启用STW重新扫描。

插入写屏障:对象被引用时触发的机制,当白色对象被黑色对象引用时,白色对象被标记为灰色(栈上对象无插入屏障)。
缺点:如果灰色对象在栈上新创建了一个新对象,由于栈没有屏障机制,所以新对象仍为白色节点会被回收。

删除写屏障:对象被删除时触发的机制。如果灰色对象引用的白色对象被删除时,那么白色对象会被标记为灰色。
缺点:这种做法回收精度较低,一个对象即使被删除仍可以活过这一轮再下一轮被回收。同样也存在对栈的二次扫描影响程序的效率。

3.7.3 混合写屏障

参考:图解Golang垃圾回收机制!
但是插入写屏障删除写屏障在结束时需要STW来重新扫描栈,带来了性能瓶颈,所以Go在1.8引入了混合写屏障的方式实现了弱三色不变式的设计方式,混合写屏障分下面四步。

  1. GC开始时将栈上可达对象全部标记为黑色(不需要二次扫描,无需STW)。
  2. GC期间,任何栈上创建的新对象均为黑色。
  3. 被删除引用的对象标记为灰色。
  4. 被添加引用的对象标记为灰色。

注意:混合写屏障也仅是在堆上启动。

3.7.4 增量式GC、并发式GC

参考:Golang-垃圾回收原理解析
前面提到的传统GC算法都会STW,这存在两个严重的弊端:

  • 对实时性程序来说,很致命。
  • 对多核计算机来说,会造成计算资源的浪费。

三色标记法结合写屏障技术使得GC避免了STW,因此后面的增量式GC和并发式GC都是基于三色标记和写屏障技术的改进。

增量式垃圾回收:可以分摊GC时间,避免程序长时间暂停。
存在的问题:内存屏障技术,需要额外时间开销,并且由于内存屏障技术的保守性,一些垃圾对象不会被回收,会增加一轮gc的总时长。

并发垃圾回收:GC和用户程序并行。
存在的问题:一定程度上利用多核计算机的优势减少了对用户程序的干扰,不过写屏障的额外开销和保守性问题仍然存在,这是不可避免的。

go v1.5至今都是基于三色标记法实现的并发式GC,将长时间的STW分为分割为多段短的STW,GC大部分执行过程都是和用户代码并行的。

3.7.5 辅助GC

参考:Golang 垃圾回收

辅助GC解决的问题是?
当用户分配内存的速度超过gc回收速度时,golang会通过辅助GC暂停用户程序进行gc,避免内存耗尽问题。

辅助GC干了什么?
辅助标记在垃圾回收标记的阶段进行,当用户程序分配内存的时候,先进行指定的扫描任务,即分配了多少内存就要完成多少标记任务。

3.8 垃圾回收触发时机

参考:Golang 垃圾回收

  1. 内存分配量达到阈值:每次内存分配都会判断当前内存是否达到阈值,如果是则触发GC。阈值为当前堆内存达到2倍上一次GC后的内存,2倍为内存增长率,可通过环节变量GOGC调整;
  2. 定时触发:默认2分钟触发一次,这个配置在runtime/proc.go里的forcegcperiod参数;
  3. 手动触发:使用runtime.GC()手动触发;

3.9 垃圾回收机制调优

参考:Golang 垃圾回收机制详解

  1. 尽量将小对象组合成大对象。
  2. 尽量使用小数据类型。
  3. 大量string拼接时使用string.join,而不是+号(go中string只读,每一个针对string的操作都会创建一个新的string)。

3.10 垃圾回收机制做了两次优化,分别是什么

三色标记法、混合写屏障。

3.11 写屏障是如何减少STW时间的

参考1:深入理解屏障技术

Go1.8版本引入了混合写屏障机制,避免了对栈的重新扫描,大大减少了STW的时间。混合写屏障=插入屏障+删除屏障,它是变形的弱三色不变性,结合了两者的优点。

  • 插入写屏障:在标记开始时无需STW,可直接开始,并发进行,但结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
  • 删除写屏障:则需要在GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需STW。

4 channel

channel主要用于协程之间通信,属于内存级别的通信。

4.1 channel的使用场景

参考1:channel的应用场景
应用场景:

  1. select case实现多路通信监听
    当我们要进行多goroutine通信时,则会使用select写法来管理多个channel的通信数据。
  2. 超时处理
select {
    case <-time.After(time.Second):
  1. 定时任务
select {
    case <- time.Tick(time.Second)
  1. 解耦生产者和消费者
    生产者只需要往channel发送数据,而消费者只管从channel中获取数据。
  2. 控制并发数
    可以通过channel来控制并发规模,使用的是有缓冲的channel,比如同时支持5个并发任务:
ch := make(chan int, 5)
for _, url := range urls {
  go func() {
    ch <- 1
    worker(url)
    <- ch
  }
}

4.2 channel的数据结构

channel的底层结构实现是hchan,所在位置:src/runtime/chan.go

type hchan struct {
	qcount   uint           // total data in the queue
	dataqsiz uint           // size of the circular queue
	buf      unsafe.Pointer // points to an array of dataqsiz elements
	elemsize uint16
	closed   uint32
	elemtype *_type // element type
	sendx    uint   // send index
	recvx    uint   // receive index
	recvq    waitq  // list of recv waiters
	sendq    waitq  // list of send waiters

	// lock protects all fields in hchan, as well as several
	// fields in sudogs blocked on this channel.
	//
	// Do not change another G's status while holding this lock
	// (in particular, do not ready a G), as this can deadlock
	// with stack shrinking.
	lock mutex
}

可以看到hchan最后有一个mutex(锁)类型的lock字段,所有的发送和读取之前都要加锁,所以channel是线程安全的。

hchan各字段解读:

  1. qcount:channel中环形队列当前存在的元素总数,len()返回该值。
  2. dataqsiz:环形队列的长度,即缓冲区可以容纳的元素数量,make时指定,cap()返回该值。
  3. buf:是一个指针,指向实际存储数据的缓冲区,缓存区基于环形队列实现,是一个连续的内存区域,用于存储channel中的元素。
  4. elemsize:单个元素的字节大小,用于确定每个元素在缓冲区中占用的空间。
  5. closed:channel关闭标志,用于表示channel是否已经关闭。当channel被关闭时,这个字段的值会被设置为非零。
  6. elemtype:元素的类型信息,包括元素的大小和对齐方式等。
  7. sendx:向channel发送数据时,写入的位置索引。
  8. recvx:从channel读数据时,读取的位置索引。
  9. recvq:buf空时,用于接收数据的goroutine等待队列,存储的是等待从channel接收数据的goroutine。
  10. sendq:buf满时,用于发送数据的goroutine等待队列,存储等待向channel发送数据的goroutine。
  11. lock:互斥锁,所有发送和读取之前都要加锁,保证同一时刻,只允许一个协程操作,所以channel是线程安全的。

channel在进行读写数据时,会根据无缓冲、有缓冲设置进行对应的阻塞唤起动作,它们之间还是有区别的。

总结: 有缓冲channel和无缓冲channel 的读写基本相差不大,只是多了缓冲数据区域的判断而已。

4.3 无缓冲channel的读写

参考1:golang 系列:channel 全面解析

无缓冲的channel(也称为阻塞式channel)是一种用于在协程之间进行同步的通信方式。无缓冲的channel的读写操作具有阻塞特性,这意味着在特定条件下,读写先后顺序不同,处理也会有所不同,所以还得再进一步区分:

4.3.1 无缓冲channel先写再读

在这里,我们暂时认为有 2 个goroutine在使用channel通信,按先写再读的顺序,则具体流程如下:
无缓冲channel先写再读1
可以看到,由于channel是无缓冲的,所以G1暂时被挂在sendq队列里,然后G1调用了gopark休眠了起来。

接着,又有goroutine G2来channel读取数据了:
无缓冲channel先写再读2
此时G2发现sendq等待队列里有goroutine存在,于是直接从G1 copy数据过来,并且会对G1设置goready函数,这样下次调度发生时,G1就可以继续运行,并且会从等待队列里移除掉。

4.3.2 无缓冲channel先读再写

先读再写的流程跟上面一样。【只是流程一样】
无缓冲channel先读再写1
G1暂时被挂在了recvq队列,然后休眠起来。

G2在写数据时,发现recvq队列有goroutine存在,于是直接将数据发送给G1。同时设置G1 goready函数,等待下次调度运行。
无缓冲channel先读再写2

4.4 有缓冲channel的读写

参考1:golang 系列:channel 全面解析

在分析完了无缓冲channel的读写后,我们继续看看有缓冲channel的读写。同样的,我们分为 2种情况。

4.4.1 有缓冲channel先写再读

这一次会优先判断缓冲数据区域是否已满,如果未满,则将数据保存在缓冲数据区域,即环形队列里。如果已满,则和之前的流程是一样的。
有缓冲channel先写再读1
当G2要读取数据时,会优先从缓冲数据区域去读取,并且在读取完后,会检查sendq队列,如果goroutine有等待队列,则会将它上面的data补充到缓冲数据区域,并且也对其设置goready函数。
有缓冲channel先写再读2

4.4.2 有缓冲channel先读再写

此种情况和无缓冲的先读再写是一样流程,此处不再重复说明。

4.5 channel的创建

参考1:golang 系列:channel 全面解析

4.5.1 无缓冲的channel

 ch := make(chan T)

无缓冲的channel是阻塞式的:

  1. 当有发送端往channel中发送数据,但无接收端从channel中取数据时,发送端阻塞。
  2. 当无发送端往channel中发送数据,但有接收端从channel中取数据时,接收端阻塞。

4.5.2 有缓冲的channel

参考1:golang 系列:channel 全面解析

ch := make(chan T, 2)

第二个参数表示channel中可缓冲类型T的数据容量。只要当前channel里的元素总数不大于这个可缓冲容量,则当前的goroutine就不会被阻塞住。

4.5.3 为nil的channel

参考1:golang 系列:channel 全面解析
创建这样一个nil的channel是没有意义,读、写channel都将会被阻塞住。一般为nil的channel主要用在select 上,让select不再从这个channel里读取数据,达到屏蔽case的目的。

 ch1 := make(chan int)
 ch2 := make(chan int)

 go func() {
  if !ok { // 某些原因,设置 ch1 为 nil
   ch1 = nil
  }
 }()

 for {
  select {
  case <-ch1: // 当 ch1 被设置为 nil 后,将不会到达此分支了。
   doSomething1()
  case <-ch2:
   doSomething2()
  }
 }

4.6 关闭channel

参考1:go 从已关闭的channel读取数据

当我们不再使用channel的时候,可以对其进行关闭:

 close(ch)

提示:有缓冲的通道和无缓冲的channel关闭结果都是一样的。

4.6.1 往一个关闭的channel读写会怎样

  1. 当channel被关闭后,如果继续往里面写数据,会引起panic: send on closed channel,然后退出程序。
  2. 读取关闭后的channel,不会产生pannic,还是可以读到数据。关闭后的channel缓冲中如果有数据,读取到缓冲中的数据,channel缓冲中如果没有数据,再继续读取将得到零值,即对应类型的默认值。

4.6.2 如何判断channel是否关闭

判断channel是否关闭可以通过返回状态是false或true来确定,返回false代表已经关闭。

 if v, ok := <-ch; !ok {
  fmt.Println("channel 已关闭,读取不到数据")
 }

4.6.3 重复(多次)关闭channel会怎么样

重复(多次)关闭channel会报panic: close of closed channel(关闭已关闭的channel)。

4.6.4 关闭channel的时候应该怎么关闭,有什么注意事项吗?

关闭通道的注意事项有以下几点:

  1. 关闭后的通道不可再发送值:一旦通道被关闭,就不能再向其发送值。尝试向已关闭的通道发送值将导致panic
  2. 关闭后的通道仍可接收值:已关闭的通道仍然可以接收之前被发送到通道的值,直到通道中的所有值都被接收。尝试从已关闭的空通道接收值将会得到零值,并且不会导致阻塞。
  3. 重复关闭通道会导致panic:尝试关闭已经关闭的通道将导致panic。因此,在关闭通道之前,建议检查通道是否已经关闭。
  4. 关闭通道是一个广播操作:通道的关闭是一个广播操作,所有从该通道接收数据的协程都将在接收到通道关闭的消息后立即结束。这是用于通知接收方不再有值可用的一种机制。
  5. 使用range遍历通道:通过使用range可以方便地遍历通道,当通道被关闭时,range循环将会结束。

4.6.5 关闭channel的时候如果里面的值就是零值,这个该怎么判断是否要关闭?

Golang中,关闭一个通道时,通道中的值会被正常接收,即接收方会收到通道中的零值。因此,当关闭通道时,接收方无法通过接收到的零值来判断是否是因为通道关闭而接收到的。
通常来说,在Golang中关闭通道时,是通过发送一个信号值告知接收方通道已经关闭。这样的信号值可以是某个特定的值,也可以通过额外的信息来传递。以下是一种常见的模式,使用一个额外的布尔类型的通道来表示是否关闭:

ch := make(chan int)
closeSignal := make(chan bool)

go func() {
    // 一些业务逻辑,将结果发送到通道 ch
    result := 42
    ch <- result

    // 关闭通道
    close(ch)

    // 发送关闭信号
    closeSignal <- true
}()

// 接收结果
result := <-ch
fmt.Println(result)

// 等待关闭信号
<-closeSignal
fmt.Println("Channel closed")

在这个例子中,closeSignal是一个用于传递关闭信号的通道。当通道ch关闭时,会先发送结果值,然后再发送关闭信号。接收方先接收结果值,然后再等待关闭信号。这样可以确保接收方在接收到结果值后知道通道已经关闭。

总的来说,通常不依赖通道中的零值来判断通道是否关闭,而是使用额外的机制(如关闭信号通道)来明确地表示通道的关闭状态。这样可以更加清晰和可靠地处理通道的关闭。

4.7 channel的deadlock(死锁)或channel一直阻塞会怎样

参考1:golang 系列:channel 全面解析
不论是有缓冲通道和无缓冲通道,往channel里读写数据时是有可能被阻塞住的,一旦被阻塞,则需要其他的goroutine执行对应的读写操作,才能解除阻塞状态。

如果阻塞状态一直没有被解除,Go可能会报 fatal error: all goroutines are asleep - deadlock! 错误,所以在使用channel时要注意goroutine的一发一取,避免goroutine永久阻塞!

4.8 不要通过共享内存来通信,要通过通信来共享内存

  1. 使用共享内存的话在多线程的场景下为了处理竞态,需要加锁,使用起来比较麻烦。另外使用过多的锁,容易使得程序的代码逻辑艰涩难懂,并且容易使程序死锁,死锁了以后排查问题相当困难,特别是很多锁同时存在的时候。

  2. go语言的channel保证同一个时间只有一个goroutine能够读写channel里的数据,为开发者提供了一种优雅简单的工具,所以go原生的做法就是使用channel来通信,而不是使用共享内存来通信。

4.9 往一个只声明未初始化的channel里写入数据会怎样

参考1:对未初始化的的chan进行读写,会怎么样?为什么?
综合:4.5.3 为nil的channel4.7 channel的deadlock(死锁)或channel一直阻塞会怎样

只声明未初始化的channel说的就是为nil时的情况,它会阻塞读写,如果一直处于阻塞状态会报死锁fatal error: all goroutines are asleep - deadlock!

答:读写未初始化的 chan 都会阻塞。
报 fatal error: all goroutines are asleep - deadlock!

为什么对未初始化的 chan 就会阻塞呢?

  1. 对于写的情况
  • 未初始化的 chan 此时是等于 nil,当它不能阻塞的情况下,直接返回 false,表示写 chan 失败。
  • 当 chan 能阻塞的情况下,则直接阻塞 gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2), 然后调用 throw(s string) 抛出错误,其中 waitReasonChanSendNilChan 就是刚刚提到的报错 “chan send (nil chan)”。
  1. 对于读的情况
  • 未初始化的 chan 此时是等于 nil,当它不能阻塞的情况下,直接返回 false,表示读 chan 失败
  • 当 chan 能阻塞的情况下,则直接阻塞 gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2), 然后调用 throw(s string) 抛出错误,其中 waitReasonChanReceiveNilChan 就是刚刚提到的报错 “chan receive (nil chan)”。

4.10 哪些场景有使用到Goroutine、channel

并发处理。有缓冲的channel可以控制并发数目,从而实现多线程并发处理。

4.11 在select case中如何屏蔽已关闭的channel

首先判断channel是否关闭了,判断是关闭的channel后将这个通道设置为nil,因为设置为nil,这个通道就阻塞住了,select会选择其他没有阻塞的channel来执行,这样达到一个屏蔽的效果。

4.12 有缓冲通道和无缓冲通道的区别

无缓冲的通道实质是通道容量为0,这是它和有缓冲通道的表象区别。实质区别从4.2 channel 的数据结构4.3 无缓冲channel的读写4.4 有缓冲channel的读写

无缓冲的channel可以用来同步通信、超时等。有缓冲的channel可以用来解耦生产者、消费者,并发控制。

4.13 哪些场景下使用channel会导致panic

参考1:https://jishuin.proginn.com/p/763bfbd381cb

  1. 关闭一个 nil 值 channel 会引发 panic。
  2. 关闭一个已关闭的 channel 会引发 panic。
  3. 向一个已关闭的 channel 发送数据。

综合1、2、3可知,在操作为nil或关闭的channel会导致panic。

4.14 channel怎么做到线程安全的

channel底层的结构是hchan,hchan最后有一个mutex(锁)类型的lock字段,所有的发送和读取之前都要加锁,所以channel是线程安全的。

4.15 channel取值的时候,左值既可以一个值,又可以两个值?go是怎么实现的?

是通过Go语言的通道特性和多重返回值的机制来实现的。

5 map

5.1 map的基本操作

package main

import "fmt"

func main() {
	//1、初始化
	m1 := map[string]int{}
	m2 := make(map[string]int, 10)

	//2、插入数据
	m1["AA"] = 10
	m1["BB"] = 20
	m1["CC"] = 30

	m2["AA"] = 10
	m2["BB"] = 20
	m2["CC"] = 30

	//3、访问数据
	fmt.Println("m1 AA=", m1["AA"])
	fmt.Println("m2 BB=", m2["BB"])
	fmt.Println()

	//4、删除
	delete(m1, "AA")
	delete(m2, "BB")

	fmt.Println("m1 AA=", m1["AA"])
	fmt.Println("m2 BB=", m2["BB"])
	fmt.Println()

	//5、遍历
	for key, value := range m1 {
		fmt.Println("m1 Key=", key, ";Value=", value)
	}
	fmt.Println()
	for key, value := range m2 {
		fmt.Println("m2 Key=", key, ";Value=", value)
	}
}

5.1.1 map初始化

未初始化的map的值是nil,使用函数len() 可以获取map中键值对的数目。

  1. 使用字面量初始化,类似于JSON对象的初始化。
	m1 := map[string]int{}
	//或
	person := map[string]string{
		"name": "John",
		"age":  "30",
		"city": "New York",
	}
  1. 使用make初始化,cap是可选字段,用于提前声明了map的初始容量,可以避免频繁的扩容操作,提高性能。
m2 := make(map[string]int, cap)

注意: 可以使用 make(),但不能使用 new() 来构造 map,如果错误的使用 new() 分配了一个引用对象,会获得一个空引用的指针,相当于声明了一个未初始化的变量并且取了它的地址。

5.1.2 map插入数据

map[key] = value

5.1.3 访问数据map中的数据

map[key]

5.1.4 删除map中的数据

delete(map, key)

5.1.5 清空map中所有数据

Go语言中并没有为 map 提供任何清空所有元素的函数、方法,清空map的唯一办法就是重新 make一个新的map,不用担心垃圾回收的效率,Go语言中的并行垃圾回收效率比写一个清空函数要高效的多。

5.1.6 遍历map

	for key, value := range map {
		fmt.Println("map  Key=", key, ";Value=", value)
	}

map创建后实际是返回了hmap结构体,是使用数组+链表的形式实现的,使用拉链法消除hash冲突。

5.2 哈希表的两种实现方式

参考1:Golang源码探究 — map

开放寻址法、拉链法。

5.2.1 开放寻址法

参考1:开放寻址法(有更详细的介绍)
开放寻址法是一种将所有的键值对都存储在一个大数组中的方法。当发生哈希冲突(多个键映射到同一个位置)时,开放寻址法会尝试在数组中的其他位置继续寻找空闲槽位,直到找到一个空槽位或者遍历整个数组。开放寻址法有几种不同的策略,包括线性寻址、二次寻址和双重哈希寻址。

  • 线性寻址:当需要插入元素的位置被占用时,顺序向后寻址,如果到数组最后也没找到一个空闲位置,则从数组开头寻址,直到找到一个空闲位置插入数据。线性寻址的每次寻址步长是1,寻址公式hash(key)+n(n是寻址的次数)。
  • 二次方寻址:就是线性寻址的总步长的二次方,即hash(key)+n^2。
  • 双重哈希寻址:顾名思义就是多次哈希直到找到一个不冲突的哈希值。

5.2.2 拉链法(map使用的方式)

拉链法是一种在哈希表的每个槽位中存储一个链表(或其他数据结构,比如红黑树),用于存储冲突的键值对。当发生哈希冲突时,新的键值对会被添加到对应槽位的链表中。这样,每个槽位可以存储多个键值对,并且链表的操作可以在冲突的情况下更加高效。

拉链法可以扩展到更复杂的数据结构,如平衡二叉搜索树,以提高在冲突时的查找效率。

5.2.3 两种方式的总结

  • 在实际应用中,选择使用哪种哈希表实现方式取决于多种因素,包括哈希函数的选择、负载因子、内存分配等。
  • 开放寻址法:通常在存储空间效率方面更加高效,因为它避免了链表节点的额外开销。然而,当负载因子较高时,开放寻址法的性能可能会下降,因为冲突的频率会增加。
  • 拉链法:通常在处理冲突时更加稳定,并且可以处理负载因子较高的情况,但它可能会导致额外的内存开销。选择适合场景的哈希表实现方式可以在性能和资源使用方面取得平衡。

5.3 map的数据结构

参考1:Golang 中 map 探究
参考2:golang map实现原理浅析
参考3:Golang Map原理(底层结构、查找/新增/删除、扩缩容)

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

map底层的数据结构是由hmap实现的,hmap的结构体是在runtime/map.go

// A header for a Go map.
type hmap struct {
	// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
	// Make sure this stays in sync with the compiler's definition.
	count     int // # live cells == size of map.  Must be first (used by len() builtin)
	flags     uint8
	B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
	noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
	hash0     uint32 // hash seed

	buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
	oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
	nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

	extra *mapextra // optional fields
}

hmap各字段解读:

  1. count:当前map中的键值对数量,调用len(map)返回这个值。
  2. flags:标志位,用于表示map的状态。
  3. B:2^B表示bucket的数量,B表示取hash后多少位来做bucket的分组,再多就要扩容了。
  4. noverflow:溢出桶的个数。
  5. hash0hash seed(hash 种子)一般是一个素数,用于计算哈希值。
  6. buckets:指向bucket数组的指针(存储key val);大小:2^B,如果没有元素存入,这个字段可能为nil。
  7. oldbuckets:在扩容期间,将旧的bucket数组放在这里,新buckets会是oldbuckets的两倍大,用于实现平滑的扩容操作。
  8. nevacuate:即将迁移的旧桶编号,可以作为搬迁进度,小于nevacuate的表示已经搬迁完成。
  9. extra:用于存储额外的信息,如迭代器状态等。

bucket数组里存储的是bmapbmapruntime/map.go中,它的所有字段如下:

// A bucket for a Go map.
type bmap struct {
	// tophash generally contains the top byte of the hash value
	// for each key in this bucket. If tophash[0] < minTopHash,
	// tophash[0] is a bucket evacuation state instead.
	tophash [bucketCnt]uint8
	// Followed by bucketCnt keys and then bucketCnt elems.
	// NOTE: packing all the keys together and then all the elems together makes the
	// code a bit more complicated than alternating key/elem/key/elem/... but it allows
	// us to eliminate padding which would be needed for, e.g., map[int64]int8.
	// Followed by an overflow pointer.
}

但这只是表面,实际上在golang runtime时,编译器会动态为bmap创建一个新结构:

type bmap struct {
	topbits  [8]uint8     //高位哈希值数组
	keys     [8]keytype   // 存储key的数组
	values   [8]valuetype // 存储val的数组
	pad      uintptr      // 内存对齐使用,可能不需要
	overflow uintptr      // bucket的8个key存满了之后,指向当前bucket的溢出桶
}

bmap就是hmap中的的bucket(桶)的底层数据结构,一个桶中可以存放最多8个key/value,map使用hash函数得到hash值决定分配到哪个桶,然后又会根据hash值的高8位来寻找放在桶的哪个位置,具体的map的组成结构如下图所示:
在这里插入图片描述

5.4 map的扩容

参考1:Golang Map 底层实现
参考2:Golang底层实现系列——map的底层实现
参考3:golang笔记——map底层原理
参考4:Golang源码探究 —— map

5.4.1 map为什么需要扩容

Golang源码探究 —— map

  1. 首先就是当可用空间不足时就需要扩容。
  2. 当哈希碰撞比较严重时,很多数据都会落在同一个桶中,那么就会导致越来越多的溢出桶被链接起来。这样的话,查找的时候最坏的情况就是要遍历整个链表,时间复杂度很高,效率很低。而且当删除了很多元素后,可能会导致虽然有很多溢出桶,但是桶中的元素很稀疏。

5.4.2 map扩容的时机

Golang源码探究 —— map
golang笔记——map底层原理

  1. 达到最大的负载因子(源码里定义的阈值是 6.5,也就是平均每个桶中k-v的数量大于6.5)(翻倍扩容)
  2. 溢出桶的数量太多。频繁的对map增删,会导致未被使用的overflowbucket数量过多:(等量扩容)
  • 当B < 15,也就是bucket总数 2^ B小于2^15时,如果overflowbucket数量超过 2^B(未用于存储的bucket数量过多),就会触发扩容;【即bucket数目不大于2 ^ 15,但是使用overflow数目超过 2^B就算是多了。】
  • 当B >= 15,也就是bucket总数2^ B大于等于2^15,如果overflowbucket 数量超过 2^ 15,就会触发扩容。【即bucket数目大于2^ 15,那么使用overflow数目一旦超过2^15就算是多了。】

简述:Golang Map 底层实现
map扩容规则

解释:golang笔记——map底层原理

  • 针对 1:我们知道,每个bucket有8个空位,在没有溢出,且所有的桶都装满了的情况下,负载因子算出来的结果是8。因此当负载因子超过6.5时,表明很多bucket都快要装满了,查找效率和插入效率都变低了。在这个时候进行扩容是有必要的。

  • 针对2:是对第1点的补充。就是说在负载因子比较小的情况下,这时候map的查找和插入效率也很低,而第1点识别不出来这种情况。表面现象就是计算负载子的分子比较小,即map里元素总数少,但是bucket数量多(真实分配的bucket数量多,包括大量的overflow bucket)。

不难想像造成2. 溢出桶的数量太多。这种情况的原因:不停地插入、删除元素。先插入很多元素,导致创建了很多bucket,但是负载因子达不到第 1 点的临界值,未触发扩容来缓解这种情况。之后,删除元素降低元素总数量,再插入很多元素,导致创建很多的overflow bucket,但就是不会触发第1点的规定,你能拿我怎么办?overflow bucket 数量太多,导致 key 会很分散,查找插入效率低得吓人,因此出台第2点规定。这就像是一座空城,房子很多,但是住户很少,都分散了,找起人来很困难。

mapassign中会判断是否要扩容:Golang源码探究 —— map

//触发扩容的时机
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    ...
    
    // If we hit the max load factor or we have too many overflow buckets,
	// and we're not already in the middle of growing, start growing.
    // 如果达到了最大的负载因子或者有太多的溢出桶
    // 或是是已经在扩容中
    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
		hashGrow(t, h)
		goto again // Growing the table invalidates everything, so try again
	}
}

判断负载因子超过 6.5:golang笔记——map底层原理

func overLoadFactor(count int, B uint8) bool {
	return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

判断overflow buckets 太多:golang笔记——map底层原理

func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
	// If the threshold is too low, we do extraneous work.
	// If the threshold is too high, maps that grow and shrink can hold on to lots of unused memory.
	// "too many" means (approximately) as many overflow buckets as regular buckets.
	// See incrnoverflow for more details.
	if B > 15 {
		B = 15
	}
	// The compiler doesn't see here that B < 16; mask B to generate shorter shift code.
	return noverflow >= uint16(1)<<(B&15)
}

5.4.3 map扩容的类型:翻倍扩容、等量扩容

map的两个扩容的时机,都会发生扩容。但是扩容的策略并不相同,毕竟两种条件应对的场景不同。但map扩容采用的都是渐进式,桶被操作(增删改)时才会重新分配。
Golang Map 底层实现
map扩容规则

  1. 翻倍扩容:针对的是 达到最大的负载因子 的情况,扩容后桶的数量为原来的两倍。Golang源码探究 —— map

对于达到最大的负载因子的扩容,它是因为元素太多,而bucket数量太少,解决办法很简单:将B加 1,bucket 最大数量(2^ B)直接变成原来bucket数量的2倍。于是,就有新老bucket了。
注意: 这时候元素都在老bucket里,还没迁移到新的bucket来。而且,新bucket只是最大数量变为原来最大数量(2^ B)的 2 倍(2^B * 2)。golang笔记——map底层原理

  1. 等量扩容:针对的是溢出桶的数量太多的情况,溢出桶太多了,导致查询效率低。扩容时,桶的数量不增加。Golang源码探究 —— map

对于溢出桶的数量太多的扩容,其实元素没那么多,但是overflow bucket数特别多,说明很多bucket都没装满。解决办法就是开辟一个新bucket空间,将老bucket中的元素移动到新bucket,使得同一个bucket 中的key排列地更紧密。这样,原来在overflow bucket中的key可以移动到bucket中来。节省空间,提高bucket利用率,map的查找和插入效率自然就会提升。golang笔记——map底层原理

5.4.4 map扩容的步骤

Golang源码探究 —— map
步骤一:

  1. 创建一组新桶。
  2. oldbuckets指向原有的桶数组。
  3. buckets指向新的桶的数组。
  4. map标记为扩容状态。

步骤二:迁移数据

  1. 将所有的数据从旧桶驱逐到新桶。
  2. 采用渐进式驱逐。
  3. 每次操作一个旧桶时(插入、删除数据),将旧桶数据驱逐到新桶。
  4. 读取时不进行驱逐,只判断读取新桶还是旧桶。

步骤三:所有旧桶驱逐完成后,回收所有旧桶(oldbuckets)。

5.4.5 map为什么采用渐进式扩容

golang笔记——map底层原理
由于map扩容需要将原有的key/value重新搬迁到新的内存地址,如果有大量的key/value需要搬迁,会非常影响性能。因此Go map的扩容采取了一种称为“渐进式”地方式,每次最多只会搬迁2个bucket。

5.4.6 翻倍扩容、等量扩容中Key的变化

翻倍扩容(达到最大的负载因子):【可能会变,也可能不会变】因为新的buckets数量是之前的一倍,所以在迁移时要重新计算 key的哈希,才能决定它到底落在哪个bucket。例如,原来 B = 5,计算出key的哈希后,只用看它的低 5 位,就能决定它落在哪个bucket。扩容后,B变成了 6,因此需要多看一位,它的低 6 位决定key落在哪个bucket。因此,某个key在搬迁前后bucket序号可能和原来相等,也可能是相比原来加上 2^B(原来的 B 值),取决于hash值 第6位bit位是 0 还是 1。golang笔记——map底层原理

等量扩容(溢出桶的数量太多):【可能会变,也可能不会变】从老的buckets搬迁到新的buckets,由于bucktes数量不变,因此可以按序号来搬,比如原来在0号bucktes,到新的地方后,仍然放在0号buckets。【如果迁移后是紧密的按顺序排列,则不变;如果不按顺序排列,会变】golang笔记——map底层原理

5.5 map为什么是无序的

5.5.1 map不扩容的时候for循环取值,为什么每次取到的都是无序

参考1:为什么说Go的Map是无序的?

首先是For ... Range ... 遍历Map的索引的起点是随机的。
其次,往map中存入时就不是按顺序存储的,所以是无序的。


翻倍扩容和等量扩容都可能会发生无序的情况,原因看 5.3.6 翻倍扩容、等量扩容中Key的变化

golang笔记——map底层原理

map在扩容后,会发生key的搬迁,原来落在同一个bucket中的key,搬迁后,有些key就要远走高飞了(bucket序号加上了 2^B)。而遍历的过程,就是按顺序遍历bucket,同时按顺序遍历bucket中的key。搬迁后,key的位置发生了重大的变化,有些 key飞上高枝,有些key则原地不动。这样,遍历map的结果就不可能按原来的顺序了。

当我们在遍历go中的map时,并不是固定地从0号bucket开始遍历,每次都是从一个随机值序号的bucket开始遍历,并且是从这个 bucket的一个随机序号的cell开始遍历。这样,即使你是一个写死的map,仅仅只是遍历它,也不太可能会返回一个固定序列的 key/value对了。

5.6 float类型是否可以作为map的key

golang笔记——map底层原理

从语法上看,是可以的。Go 语言中只要是可比较的类型都可以作为 key。除开 slice,map,functions 这几种类型,其他类型都是 OK 的。具体包括:布尔值、数字、字符串、指针、通道、接口类型、结构体、只包含上述类型的数组。这些类型的共同特征是支持 == 和 != 操作符,k1 == k2 时,可认为 k1 和 k2 是同一个 key。如果是结构体,只有 hash 后的值相等以及字面值相等,才被认为是相同的 key。很多字面值相等的,hash出来的值不一定相等,比如引用。

float 型可以作为 key,但是由于精度的问题,会导致一些诡异的问题,慎用之。

5.7 map可以遍历的同时删除吗

golang笔记——map底层原理

map 并不是一个线程安全的数据结构。多个协程同时读写同时读写一个 map,如果被检测到,会直接 panic。

如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。

如果想要并发安全的读写,可以通过读写锁来解决:sync.RWMutex。

读之前调用 RLock() 函数,读完之后调用 RUnlock() 函数解锁;写之前调用 Lock() 函数,写完之后,调用 Unlock() 解锁。

5.8 可以对map元素取地址吗

golang笔记——map底层原理

无法对 map 的 key 或 value 进行取址,将无法通过编译。

如果通过其他 hack 的方式,例如 unsafe.Pointer 等获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。

5.9 如何比较两个map是否相等

golang笔记——map底层原理

  1. 都为 nil。
  2. 非空、长度相等,指向同一个 map 实体对象。
  3. 相应的 key 指向的 value “深度”相等
    直接将使用 map1 == map2 是错误的。这种写法只能比较 map 是否为 nil。
    因此只能是遍历map 的每个元素,比较元素是否都是深度相等。

5.10 map是线程安全的吗

golang笔记——map底层原理

不安全,只读是线程安全的,主要是不支持并发写操作的,原因是 map 写操作不是并发安全的,当尝试多个 Goroutine 操作同一个 map,会产生报错:fatal error: concurrent map writes。所以map适用于读多写少的场景。

解决办法:要么加锁,要么使用sync包中提供了并发安全的map,也就是sync.Map,其内部实现上已经做了互斥处理。

5.11 map底层是hash,它是如何解决冲突的

golang的map用的是hashmap,是使用数组+链表的形式实现的,使用拉链法消除hash冲突。拉链法见:5.2.2 拉链法(map使用的方式)

5.12 map如何判断是否并发写的

参考1:https://www.jianshu.com/p/1132055d708b

map是检查是否有另外线程修改h.flag来判断,是否有并发问题。

// 在更新map的函数里检查并发写
    if h.flags&hashWriting == 0 {
        throw("concurrent map writes")
    }
    
// 在读map的函数里检查是否有并发写
    if h.flags&hashWriting != 0 {
        throw("concurrent map read and map write")
    }

5.13 map并发读写会panic吗

参考1:http://c.biancheng.net/view/34.html

map 在并发情况下,只读是线程安全的,同时读写是线程不安全的。会报panic:fatal error: concurrent map read and map write,因为Go语言原生的map并不是并发安全的,对它进行并发读写操作的时候,需要加锁。

5.14 map遍历是否有序

参考1:golang对map排序
golang中map元素是随机无序的,所以在对map range遍历的时候也是随机的,如果想按顺序读取map中的值,可以结合切片来实现。

5.15 map怎么变得有序

如果想按顺序读取map中的值,可以结合切片来实现。

5.16 多个协程读写map的panic可以被捕获吗

参考1:https://www.cnblogs.com/wuchangblog/p/16393070.html

不能,每个协程只能捕获到自己的 panic 不能捕获其它协程。

6 sync.Map

sync.Map是并发安全的。底层通过分离读写map和原子指令来实现读的近似无锁,并通过延迟更新的方式来保证读的无锁化。

6.1 sync.Map的基本操作

sync.Map特性:

  1. 无须初始化,直接声明即可使用。
  2. sync.Map不能使用普通map的方法进行读写操作,而是使用sync.Map自己的方法进行操作,Store表示存储,Load表示读取,Delete表示删除。
  3. 使用Range配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值,Range参数中回调函数的返回值在需要继续遍历时,需要返回true,终止遍历时,返回false。

sync.Map的基本操作的完整代码:

package main

import (
	"fmt"
	"sync"
)

func main() {
	//1、初始化
	var sMap sync.Map

	//2、插入数据
	sMap.Store(1,"a")
	sMap.Store("AA",10)
	sMap.Store("BB",20)
	sMap.Store(3,"CC")

	//3、访问数据
	fmt.Println("Load方法")
	//Load:①如果待查找的key存在,则返回key对应的value,true;
	lv1,ok1 := sMap.Load(1)
	fmt.Println(ok1,lv1) //输出结果:true a

	//Load:②如果待查找的key不存在,则返回nil,false
	lv2,ok2 := sMap.Load(2)
	fmt.Println(ok2,lv2) //输出结果:false <nil>

	fmt.Println()
	fmt.Println("LoadOrStore方法")
	//LoadOrStore:①如果待查找的key存在,则返回key对应的value,true;
	losv1,ok1 := sMap.LoadOrStore(1,"aaa")
	fmt.Println(ok1,losv1) //输出结果:true a

	//LoadOrStore:②如果待查找的key不存在,则返回添加的value,false
	losv2,ok2 := sMap.LoadOrStore(2,"bbb")
	fmt.Println(ok2,losv2) //输出结果:false bbb

	fmt.Println()
	fmt.Println("LoadAndDelete方法")
	//LoadAndDelete:①如果待查找的key存在,则返回key对应的value,true,同时删除该key-value;
	ladv1,ok1 := sMap.LoadAndDelete(1)
	fmt.Println(ok1,ladv1) //输出结果:true a

	//LoadAndDelete:②如果待查找的key不存在,则返回nil,false
	ladv2,ok2 := sMap.LoadAndDelete(1)
	fmt.Println(ok2,ladv2) //输出结果:false <nil>

	//4、删除
	fmt.Println()
	fmt.Println("Delete方法")
	sMap.Delete(2)

	fmt.Println()
	fmt.Println("Range方法")
	// 5、遍历所有sync.Map中的键值对
	sMap.Range(func(k, v interface{}) bool {
		fmt.Println("k-v:", k, v)
		return true
	})
}

6.1.1 sync.Map初始化

sync.Map无须初始化,直接声明即可使用。

var sMap sync.Map

6.1.2 sync.Map插入数据

sync.Map插入数据使用自带的Store(key,value)。源码解读 Golang 的 sync.Map 实现原理 有对Store的源码分析。

	sMap.Store(1,"a")
	sMap.Store("AA",10)

注意:Store(key, value interface{})参数都是interface{}类型,所以同一个sync.Map能存储不同类型的数据。源码:

func (m *Map) Store(key, value interface{}) {
}

6.1.3 访问sync.Map中的数据

sync.Map访问有三个方法:Load()、LoadOrStore()、LoadAndDelete()

  1. Load(key interface{}) (value interface{}, ok bool) 源码解读 Golang 的 sync.Map 实现原理 有对 Load 的源码分析。
  • 如果待查找的key存在,则返回key对应的value,true;
	lv1,ok1 := sMap.Load(1)
	fmt.Println(ok1,lv1) //输出结果:true a
  • 如果待查找的key不存在,则返回nil,false;
	lv2,ok2 := sMap.Load(2)
	fmt.Println(ok2,lv2) //输出结果:false <nil>
  1. LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
  • 如果待查找的key存在,则返回key对应的value,true,不会修改原来key对应的value;
	losv1,ok1 := sMap.LoadOrStore(1,"aaa")
	fmt.Println(ok1,losv1) //输出结果:true a
  • 如果待查找的key不存在,则返回添加的value,false;
	losv2,ok2 := sMap.LoadOrStore(2,"bbb")
	fmt.Println(ok2,losv2) //输出结果:false bbb
  1. LoadAndDelete(key interface{}) (value interface{}, loaded bool)
  • 如果待查找的key存在,则返回key对应的value,true,同时删除该key-value;
	ladv1,ok1 := sMap.LoadAndDelete(1)
	fmt.Println(ok1,ladv1) //输出结果:true a
  • 如果待查找的key不存在,则返回nil,false;
	ladv2,ok2 := sMap.LoadAndDelete(1)
	fmt.Println(ok2,ladv2) //输出结果:false <nil>

6.1.4 删除sync.Map中的数据

sync.Map删除用 Delete(key interface{}),查看源码会发现它是调用的LoadAndDelete(key)最终来实现的。源码解读 Golang 的sync.Map实现原理 有对Delete的源码分析。
源码:

func (m *Map) Delete(key interface{}) {
	m.LoadAndDelete(key)
}

6.1.5 清空sync.Map中的数据

同map一样,Go语言也没有为sync.Map提供任何清空所有元素的函数、方法,清空sync.Map的唯一办法就是重新声明一个新的sync.Map。

6.1.6 遍历sync.Map

sync.Map使用Range配合一个回调函数进行遍历操作,通过回调函数返回内部遍历出来的值,Range参数中回调函数的返回值在需要继续迭代遍历时,返回 true,终止迭代遍历时,返回 false。

	sMap.Range(func(k, v interface{}) bool {
		fmt.Println("k-v:", k, v)
		return true
	})

6.2 sync.Map的数据结构

6.2.1 sync.Map底层是如何保证线程安全(实现原理)

sync.Map 的实现原理可概括为:

  1. 通过read和dirty两个字段将读写分离,读取的数据在只读字段read上,写入的数据则存在dirty字段上。
  2. 读取时会先查询read,read中不存在时,再查询dirty,写入时则只写入dirty。
  3. 读取read并不需要加锁,因为read只负责读,而读或写dirty都需要加锁。
  4. 另外有misses字段来统计read被穿透的次数(被穿透指当从Map中读取entry的时候,如果read中不包含这个entry,需要读dirty的情况),超过一定次数则将dirty晋升为read 。(保证读写一致
  5. 延迟删除,删除一个key值时只是打标记,只有在将dirty晋升为read后的时候才清理数据。对于删除数据则直接通过标记来延迟删除。

6.2.2 sync.Map的数据结构

参考1:源码解读 Golang 的 sync.Map 实现原理
参考2:Golang的Map并发性能以及原理分析

sync.Map是在sync/map.go

type Map struct {
	mu Mutex
	read atomic.Pointer[readOnly]
	dirty map[any]*entry
	misses int
}

sync.Map各字段解读:

  1. mu:互斥锁,保护dirty字段,当涉及到dirty数据的操作的时候,需要使用这个锁。
  2. read:只读的数据,实际数据类型为readOnly,也是一个map,因为只读,所以不会有读写冲突。实际上,实际也会更新read的entries,如果entry是未删除的(unexpunged),并不需要加锁。如果entry已经被删除了,需要加锁,以便更新dirty数据。
  3. dirty:dirty中的数据除了包含当前的entries,它也包含最新的entries(包括read中未删除的数据,虽有冗余,但是提升dirty字段为read的时候非常快,不用一个一个的复制,而是直接将这个数据结构作为read字段的一部分),有些数据还可能没有移动到read字段中(即直接将dirty晋升为read)
  • 对于dirty的操作需要加锁,因为对它的操作可能会有读写竞争。
  • 当dirty为空的时候,比如初始化或者刚提升完,下一次的写操作会复制read字段中未删除的数据到这个数据中。
  1. misses:当从Map中读取entry的时候,如果read中不包含这个entry,会尝试从dirty中读取,这个时候会将misses加一,当misses累积到dirty的长度的时候, 就会将dirty晋升为read,避免从dirty中miss太多次。因为操作dirty需要加锁。【保证读写一致

readOnly结构体:

type readOnly struct {
    m  map[interface{}]*entry
    amended bool
}

readOnly各字段解读:
m:内建map,m的value的类型为*entry
amended:用于判断dirty里是否存在read里没有的key,通过该字段决定是否加锁读dirty,如果有则为true。

readOnly.mMap.dirty存储的值类型是*entry,它包含一个指针p,指向用户存储的value值。

entry 数据结构则用于存储sync.Map中值的指针:

type entry struct {
    p unsafe.Pointer  // 等同于 *interface{}
}

当p指针指向expunged这个指针的时候,则表明该元素被删除,但不会立即从map中删除,如果在未删除之前又重新赋值则会重新使用该元素。

entry各字段解读:
p:指向用户存储的value值,p有三种状态。

  • nil: 键值已经被删除,且m.dirty == nil。
  • expunged: 键值已经被删除,但是m.dirty!=nil且m.dirty不存在该键值(expunged 实际是空接口指针)。
  • 除以上情况,则键值对存在,存在于m.read中,如果m.dirty!=nil则也存在于m.dirty。

6.2.3 read map与dirty map的关系

参考1:Golang的Map并发性能以及原理分析
read map与dirty map的关系
从图中可以看出,read mapdirty map中含有相同的一部分entry,我们称作是normal entries,是双方共享的。状态是p的值为nilunexpunged时。

但是read map中含有一部分entry是不属于dirty map的,而这部分entry就是状态为expunged状态的entry。而dirty map中有一部分entry 也是不属于read map的,而这部分其实是来自Store操作形成的(也就是新增的 entry),换句话说就是新增的entry是出现在dirty map中的。

读取数据时首先从m.read中读取,不存在的情况下,并且m.dirty中有新数据,对m.dirty加锁,然后从m.dirty中读取。

6.2.4 read map、dirty map的作用

参考1:Golang的Map并发性能以及原理分析

read map:是用来进行lock free操作的(其实可以读写,但是不能做删除操作,因为一旦做了删除操作,就不是线程安全的了,也就无法 lock free)。
dirty map:是用来在无法进行lock free操作的情况下,需要lock来做一些更新工作的对象。

6.3 sync.Map的缺陷

参考1:Golang的Map并发性能以及原理分析

当需要不停地新增和删除的时候,会导致dirty map不停地更新,甚至在misses过多之后,导致dirty成为nil,并进入重建的过程,所以sync.Map适用于读多写少的场景

6.4 sync.Map与map的区别

是否支持多协程并发安全。

6.5 sync.Map的使用场景

参考1:sync.Map详解

sync.Map 适用于读多写少的场景。对于写多的场景,会导致不断地从dirty map中读取,导致dirty map晋升为read map,这是一个 O(N) 的操作,会进一步降低性能。

7 interface接口

7.1 interface的数据结构

接口的底层实现结构有两个结构体ifaceeface,区别在于iface类型的接口包含方法,而eface则是不包含任何方法的空接口:interface{}。这两个结构体都在runtime/runtime2.go中。(Golang之接口底层分析

7.1.1 接口之iface

参考1:Go interface的底层实现研究(1)

iface结构体,是在runtime/runtime2.go中,它的所有字段如下:

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

iface 各字段解读:

  1. tab :指针类型,指向一个itab实体,它表示接口的类型以及赋给这个接口的实体类型。
  2. data :则指向接口具体的值,一般而言是一个指向堆内存的指针。

itab结构体,是在runtime/runtime2.go中,它的所有字段如下:

type itab struct {
	inter *interfacetype
	_type *_type
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
	fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

itab各字段解读:

  1. inter:接口自身定义的类型信息,用于定位到具体interface类型。
  2. _type:接口实际指向值的类型信息,即实际对象类型,用于定义具体interface类型;
  3. hash_type.hash的拷贝,是类型的哈希值,用于快速查询和判断目标类型和接口中类型是否一致。
  4. fun:动态数组,接口方法实现列表(方法集),即函数地址列表,按字典序排序,如果数组中的内容为空表示_type没有实现inter接口。

itab.interinterface的类型元数据,它里面记录了这个接口类型的描述信息,接口要求的方法列表就记录在interfacetype.mhdr里。

interfacetype结构体,是在runtime/type.go中,它的所有字段如下:

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

interfacetype各字段解读:

  1. typ:接口的信息。
  2. pkgpath:接口的包路径。
  3. mhdr:接口要求的方法列表。

iface结构体详解:
tab._type就是接口的动态类型,也就是被赋给接口类型的那个变量的类型元数据。itab中的_typeiface中的data能简要描述一个变量。_type是这个变量对应的类型,data是这个变量的值。

itab.fun记录的是动态类型实现的那些接口要求的方法的地址,是从方法元数据中拷贝来的,为的是快速定位到方法。如果itab._type对应的类型没有实现这个接口,则itab.fun[0]=0,这在类型断言时会用到。

fun[0]为0时,说明_type并没有实现该接口,当有实现接口时,fun存放了第一个接口方法的地址,其他方法依次往下存放,这里就简单用空间换时间,其实方法都在_type字段中能找到,实际在这记录下,每次调用的时候就不用动态查找了。

7.1.2 接口之eface

参考1:Go interface的底层实现研究(1)

eface 结构体,是在runtime/runtime2.go中,它的所有字段如下:

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

eface 各字段解读:

  1. _type:类型信息。
  2. data:数据信息,指向数据指针。

_type结构体,是在runtime/type.go中,它的所有字段如下:

type _type struct {
	size       uintptr
	ptrdata    uintptr // size of memory prefix holding all pointers
	hash       uint32
	tflag      tflag
	align      uint8
	fieldAlign uint8
	kind       uint8
	// function for comparing objects of this type
	// (ptr to object A, ptr to object B) -> ==?
	equal func(unsafe.Pointer, unsafe.Pointer) bool
	// gcdata stores the GC type data for the garbage collector.
	// If the KindGCProg bit is set in kind, gcdata is a GC program.
	// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
	gcdata    *byte
	str       nameOff
	ptrToThis typeOff
}

_type 各字段解读:

  1. size:类型占用内存大小。
  2. ptrdata:包含所有指针的内存前缀大小。
  3. hash:类型hash。
  4. tflag:标记位,主要用于反射。
  5. align:对齐字节信息。
  6. fieldAlign:当前结构字段的对齐字节数。
  7. kind:基础类型枚举值。
  8. equal:比较两个形参对应对象的类型是否相等。
  9. gcdata:GC类型的数据。
  10. str:类型名称字符串在二进制文件段中的偏移量。
  11. ptrToThis:类型元信息指针在二进制文件段中的偏移量。

重点说明:

  1. kind:这个字段描述的是如何解析基础类型。在Go语言中,基础类型是一个枚举常量,有26个基础类型,如下。枚举值通过kindMask取出特殊标记位。
const (
	kindBool = 1 + iota
	kindInt
	kindInt8
	kindInt16
	kindInt32
	kindInt64
	kindUint
	kindUint8
	kindUint16
	kindUint32
	kindUint64
	kindUintptr
	kindFloat32
	kindFloat64
	kindComplex64
	kindComplex128
	kindArray
	kindChan
	kindFunc
	kindInterface
	kindMap
	kindPtr
	kindSlice
	kindString
	kindStruct
	kindUnsafePointer

	kindDirectIface = 1 << 5
	kindGCProg      = 1 << 6
	kindMask        = (1 << 5) - 1
)
  1. strptrToThis,对应的类型是nameofftypeOff。分表表示nametype针对最终输出文件所在段内的偏移量。在编译的链接步骤中,链接器将各个.o文件中的段合并到输出文件,会进行段合并,有的放入.text段,有的放入.data段,有的放入.bss段。nameofftypeoff就是记录了对应段的偏移量。

7.2 接口的nil判断(interface可以和nil比较吗)

参考1:Go语言接口的nil判断

答:可以比较,因为nil在Go语言中只能被赋值给指针和接口。接口在底层的实现主要考虑eface结构体,它有两个部分:typedata
两种情况:

  1. 显式地将nil赋值给接口时,接口的typedata都将为nil。此时,接口与nil值判断是相等的。
  2. 将一个带有类型的nil赋值给接口时,只有datanil,而type不为nil,此时,接口与nil判断将不相等。

7.3 两个interface可以比较吗

参考1:golang中接口值(interface)的比较
这个问题,接口在底层的实现主要考虑eface 结构体,它有两个部分:typedatainterface可以使用==!=比较。

2个interface 相等有以下 2 种情况:

  1. 两个interface均等于nil(此时V和T都处于unset状态)
  2. 类型T相同,且对应的值V相等。

8 Golang中的Context

8.1 Context 简介

参考1:golang的context

Golanghttp包的Server中,每一个请求都有一个对应的goroutine负责处理,请求处理函数通常会启动额外的goroutine去处理,当一个请求被取消或者超时,所有用来处理该请求的goroutine都应该及时退出,这样系统才能释放这些goroutine占用的资源,就不会有大量的goroutine去占用资源。

注意:go1.6及之前版本请使用golang.org/x/net/context。go1.7及之后已移到标准库context。

8.2 Context 原理

参考1:golang 系列:context 详解
参考2:快速掌握 Golang context 包,简单示例

  1. Context的功能可以看出来,它是用来传递信息的。这种传递并不仅仅是将数据塞给被调用者,它还能进行链式传递,通过保存父子Context关系,不断的迭代遍历来获取数据。

  2. 因为Context可以链式传递,这就使得goroutine之间能够进行链式的信号通知了,从而进而达到自上而下的通知效果。例如通知所有跟当前context有关系的goroutine进行取消处理。

  3. 因为Context的调用是链式的,所以通过WithCancelWithDeadlineWithTimeoutWithValue派生出新的Context。当父Context被取消时,其派生的所有Context都将取消。

  4. 通过context.WithXXX都将返回新的ContextCancelFunc。调用CancelFunc将取消子代,移除父代对子代的引用,并且停止所有定时器。未能调用CancelFunc将泄漏子代,直到父代被取消或定时器触发。go vet工具检查所有流程控制路径上使用CancelFuncs

8.3 使用场景

参考1:https://www.qycn.com/xzx/article/9390.html 本文中的四种使用场景的分析和相关代码同参考1完全相同。

  • RPC调用
  • PipeLine:pipeline模式就是流水线模型。
  • 超时请求
  • HTTP服务器的request互相传递数据

1. RPC调用

在主goroutine上有4个RPC,RPC2/3/4是并行请求的,我们这里希望在RPC2请求失败之后,直接返回错误,并且让RPC3/4停止继续计算。这个时候,就使用的到Context。

代码:

package main

import (
	"context"
	"sync"
	"github.com/pkg/errors"
)

func Rpc(ctx context.Context, url string) error {
	result := make(chan int)
	err := make(chan error)

	go func() {
		// 进行RPC调用,并且返回是否成功,成功通过result传递成功信息,错误通过error传递错误信息
		isSuccess := true
		if isSuccess {
			result <- 1
		} else {
			err <- errors.New("some error happen")
		}
	}()

	select {
	case <- ctx.Done():
		// 其他RPC调用调用失败
		return ctx.Err()
	case e := <- err:
		// 本RPC调用失败,返回错误信息
		return e
	case <- result:
		// 本RPC调用成功,不返回错误信息
		return nil
	}
}


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

	// RPC1调用
	err := Rpc(ctx, "http://rpc_1_url")
	if err != nil {
		return
	}

	wg := sync.WaitGroup{}

	// RPC2调用
	wg.Add(1)
	go func(){
		defer wg.Done()
		err := Rpc(ctx, "http://rpc_2_url")
		if err != nil {
			cancel()
		}
	}()

	// RPC3调用
	wg.Add(1)
	go func(){
		defer wg.Done()
		err := Rpc(ctx, "http://rpc_3_url")
		if err != nil {
			cancel()
		}
	}()

	// RPC4调用
	wg.Add(1)
	go func(){
		defer wg.Done()
		err := Rpc(ctx, "http://rpc_4_url")
		if err != nil {
			cancel()
		}
	}()

	wg.Wait()
}

这里使用了waitGroup来保证main函数在所有RPC调用完成之后才退出。

在Rpc函数中,第一个参数是一个CancelContext,这个Context形象的说,就是一个传话筒,在创建CancelContext的时候,返回了一个听声器(ctx)和话筒(cancel函数)。所有的goroutine都拿着这个听声器(ctx),当主goroutine想要告诉所有goroutine要结束的时候,通过cancel函数把结束的信息告诉给所有的goroutine。当然所有的goroutine都需要内置处理这个听声器结束信号的逻辑(ctx->Done())。我们可以看Rpc函数内部,通过一个select来判断ctx的done和当前的rpc调用哪个先结束。

这个WaitGroup和其中一个RPC调用就通知所有RPC的逻辑,其实有一个包已经帮我们做好了。errorGroup。具体这个errorGroup包的使用可以看这个包的test例子。

有人可能会担心我们这里的cancel()会被多次调用,context包的cancel调用是幂等的。可以放心多次调用。

我们这里不妨品一下,这里的Rpc函数,实际上我们的这个例子里面是一个“阻塞式”的请求,这个请求如果是使用http.Get或者http.Post来实现,实际上Rpc函数的Goroutine结束了,内部的那个实际的http.Get却没有结束。所以,需要理解下,这里的函数最好是“非阻塞”的,比如是http.Do,然后可以通过某种方式进行中断。

比如像这篇文章Cancel http.Request using Context中的这个例子:

func httpRequest(
  ctx context.Context,
  client *http.Client,
  req *http.Request,
  respChan chan []byte,
  errChan chan error
) {
  req = req.WithContext(ctx)
  tr := &http.Transport{}
  client.Transport = tr
  go func() {
    resp, err := client.Do(req)
    if err != nil {
      errChan <- err
    }
    if resp != nil {
      defer resp.Body.Close()
      respData, err := ioutil.ReadAll(resp.Body)
      if err != nil {
        errChan <- err
      }
      respChan <- respData
    } else {
      errChan <- errors.New("HTTP request failed")
    }
  }()
  for {
    select {
    case <-ctx.Done():
      tr.CancelRequest(req)
      errChan <- errors.New("HTTP request cancelled")
      return
    case <-errChan:
      tr.CancelRequest(req)
      return
    }
  }
}

它使用了http.Client.Do,然后接收到ctx.Done的时候,通过调用transport.CancelRequest来进行结束。

我们还可以参考net/dail/DialContext。

换而言之,如果希望实现的包是“可中止/可控制”的,那么在包实现的函数里面,最好是能接收一个Context函数,并且处理了Context.Done。

2. PipeLine
pipeline模式就是流水线模型,流水线上的几个工人,有n个产品,一个一个产品进行组装。其实pipeline模型的实现和Context并无关系,没有context我们也能用chan实现pipeline模型。但是对于整条流水线的控制,则是需要使用上Context的。这篇文章Pipeline Patterns in Go的例子是非常好的说明。这里就大致对这个代码进行下说明。

runSimplePipeline的流水线工人有三个,lineListSource负责将参数一个个分割进行传输,lineParser负责将字符串处理成int64,sink根据具体的值判断这个数据是否可用。他们所有的返回值基本上都有两个chan,一个用于传递数据,一个用于传递错误。(<-chan string, <-chan error)输入基本上也都有两个值,一个是Context,用于传声控制的,一个是(in <-chan)输入产品的。

我们可以看到,这三个工人的具体函数里面,都使用switch处理了case <-ctx.Done()。这个就是生产线上的命令控制。

func lineParser(ctx context.Context, base int, in <-chan string) (
	<-chan int64, <-chan error, error) {
	...
	go func() {
		defer close(out)
		defer close(errc)

		for line := range in {

			n, err := strconv.ParseInt(line, base, 64)
			if err != nil {
				errc <- err
				return
			}

			select {
			case out <- n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out, errc, nil
}

3. 超时请求
我们发送RPC请求的时候,往往希望对这个请求进行一个超时的限制。当一个RPC请求超过10s的请求,自动断开。当然我们使用CancelContext,也能实现这个功能(开启一个新的goroutine,这个goroutine拿着cancel函数,当时间到了,就调用cancel函数)。

鉴于这个需求是非常常见的,context包也实现了这个需求:timerCtx。具体实例化的方法是 WithDeadline 和 WithTimeout。

具体的timerCtx里面的逻辑也就是通过time.AfterFunc来调用ctx.cancel的。

官方的例子:

package main

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

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

    select {
    case <-time.After(1 * time.Second):
        fmt.Println("overslept")
    case <-ctx.Done():
        fmt.Println(ctx.Err()) // prints "context deadline exceeded"
    }
}

在http的客户端里面加上timeout也是一个常见的办法。

uri := "https://httpbin.org/delay/3"
req, err := http.NewRequest("GET", uri, nil)
if err != nil {
	log.Fatalf("http.NewRequest() failed with '%s'\n", err)
}

ctx, _ := context.WithTimeout(context.Background(), time.Millisecond*100)
req = req.WithContext(ctx)

resp, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatalf("http.DefaultClient.Do() failed with:\n'%s'\n", err)
}
defer resp.Body.Close()

在http服务端设置一个timeout如何做呢?

package main

import (
	"net/http"
	"time"
)

func test(w http.ResponseWriter, r *http.Request) {
	time.Sleep(20 * time.Second)
	w.Write([]byte("test"))
}


func main() {
	http.HandleFunc("/", test)
	timeoutHandler := http.TimeoutHandler(http.DefaultServeMux, 5 * time.Second, "timeout")
	http.ListenAndServe(":8080", timeoutHandler)
}

我们看看TimeoutHandler的内部,本质上也是通过context.WithTimeout来做处理。

func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
  ...
		ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)
		defer cancelCtx()
	...
	go func() {
    ...
		h.handler.ServeHTTP(tw, r)
	}()
	select {
    ...
	case <-ctx.Done():
		...
	}
}
  1. HTTP服务器的request互相传递数据

context还提供了valueCtx的数据结构。这个valueCtx最经常使用的场景就是在一个http服务器中,在request中传递一个特定值,比如有一个中间件,做cookie验证,然后把验证后的用户名存放在request中。

我们可以看到,官方的request里面是包含了Context的,并且提供了WithContext的方法进行context的替换。

package main

import (
	"net/http"
	"context"
)

type FooKey string

var UserName = FooKey("user-name")
var UserId = FooKey("user-id")

func foo(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), UserId, "1")
		ctx2 := context.WithValue(ctx, UserName, "yejianfeng")
		next(w, r.WithContext(ctx2))
	}
}

func GetUserName(context context.Context) string {
	if ret, ok := context.Value(UserName).(string); ok {
		return ret
	}
	return ""
}

func GetUserId(context context.Context) string {
	if ret, ok := context.Value(UserId).(string); ok {
		return ret
	}
	return ""
}

func test(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("welcome: "))
	w.Write([]byte(GetUserId(r.Context())))
	w.Write([]byte(" "))
	w.Write([]byte(GetUserName(r.Context())))
}

func main() {
	http.Handle("/", foo(test))
	http.ListenAndServe(":8080", nil)
}

在使用ValueCtx的时候需要注意一点,这里的key不应该设置成为普通的String或者Int类型,为了防止不同的中间件对这个key的覆盖。最好的情况是每个中间件使用一个自定义的key类型,比如这里的FooKey,而且获取Value的逻辑尽量也抽取出来作为一个函数,放在这个middleware的同包中。这样,就会有效避免不同包设置相同的key的冲突问题了。

8.4 Context使用规则

参考1:快速掌握 Golang context 包,简单示例

  1. 不要将Context放入结构体,相反context应该作为第一个参数传入,命名为ctxfunc DoSomething(ctx context.Context,arg Arg)error { // ... use ctx ... }
  2. 即使函数允许,也不要传入nilContext。如果不知道用哪种Context,可以使用context.TODO()
  3. 使用contextValue相关方法只应该用于在程序和接口中传递的和请求相关的元数据,不要用它来传递一些可选的参数。
  4. 相同的Context可以传递给在不同的goroutineContext 是并发安全的
  5. contextDone()方法往往需要配合select { case }使用,以监听退出。
  6. 一旦context执行取消动作,所有派生的context都会触发取消。

8.5 Context的数据结构

参考1:快速掌握 Golang context 包,简单示例
参考2:golang 系列:context 详解
参考3:golang的context

Context是一个接口,是在context/context.go中,它的所有抽象方法如下:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

Context接口中抽象方法解读:
Deadline():返回截止时间和ok。

  • 如果有截止时间的话,到了这个时间点,Context会自动触发Cancel动作,返回对应deadline时间,同时ok为true是表示设置了截止时间;
  • 如果没有设置截止时间,则ok的值为false是表示没有设置截止时间,就要手动调用cancel函数取消Context。

Done():返回一个只读channel(只有在被cancel后才会返回),它的数据类型是struct{},一个空结构体。当times out或者父级Context调用cancel方法后,将会close channel来进行通知,但是不会涉及具体数据传输,根据这个信号,开发者就可以做一些清理动作,比如退出goroutine。多次调用Done方法会返回的是同一个Channel

Err():返回一个错误。如果上面的Done()channel没被close,则errornil;如果channel已被close,则error将会返回close的原因,说明该context为什么被关掉,比如超时手动取消

Value():返回被绑定到Context的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。对于同一个上下文来说,多次调用Value并传入相同的Key会返回相同的结果,该方法仅用于传递跨API和进程间请求域的数据

8.6 Context的具体实现类型

参考1:golang中的context
参考2:golang的context
参考3:golang 系列:context 详解

  1. Background()&TODO()
    Background():是所有派生Context根Context,该Context通常由接收request的第一个goroutine创建。它不能被取消、没有值、也没有过期时间,常作为处理request的顶层context存在。

    TODO():也是返回一个没有值的Context,目前不知道它具体的使用场景,如果我们不知道该传什么类型的Context的时候,可以使用这个。
    Background()TODO()本质上是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的空Context,是直接return默认值,没有具体功能代码。一般的将它们作为Context的根,往下派生。

  2. WithCancel(parent Context) (ctx Context, cancel CancelFunc):用来取消通知用的context
    返回一个继承的ContextCancelFunc取消方法,在父协程contextDone函数被关闭时会关闭自己的Done通道,或者在执行了CancelFunc取消方法之后,会关闭自己的Done通道。这种关闭的通道可以作为一种广播的通知操作,告诉所有context相关的函数停止当前的工作直接返回。通常使用场景用于主协程用于控制子协程的退出,用于一对多处理。

  3. WithDeadline(parent Context, d time.Time) (Context, CancelFunc)timerCtx类型的context,用来超时通知。
    参数是传递一个上下文,等待超时时间,超时后,会返回超时时间,并且会关闭context的Done通道,其他传递的context收到Done关闭的消息的,直接返回即可。同样用户通知消息出来。
    以下三种情况会取消该创建的context:
    1、到达指定时间点;
    2、调用了CancelFunc取消方法;
    3、父节点context关闭。

  4. WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)timerCtx类型的context,用来超时通知。
    WithTimeout()里是直接调用并返回的WithDeadline(),所以它和WithDeadline()功能是一样,只是传递的时间是从当前时间加上超时时间。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}
  1. WithValue(parent Context, key, val interface{}) ContextvalueCtx类型的context,用来传值的context
    传递上下文的信息,将需要传递的信息从一个协程传递到另外协程。

每个context都可以放一个key-value对, 通过WithValue方法可以找key对应的value值,如果没有找到,就从父context中找,直到找到为止。

WithCancelWithDeadlineWithTimeoutWithValue四个方法在创建的时候都会要求传父级context进来,以此达到链式传递信息的目的。

8.7 context并发安全吗

参考1:https://blog.csdn.net/weixin_38664232/article/details/123663759

context本身是线程安全的,所以context携带value也是线程安全的。

context包提供两种创建根context的方式:

  • context.Backgroud()
  • context.TODO()

又提供了四个函数(WithCancelWithDeadlineWithTimeoutWithValue)基于父Context牌生,其中使用WithValue函数派生的context来携带数据,每次调用WithValue函数都会基于当前context派生一个新的子contextWithValue内部主要就是调用valueCtx类:

func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

说明:参数中的parent是当前valueContext的父节点。

valueCtx结构如下:

type valueCtx struct {
	Context
	key, val interface{}
}

valueContext继承父Context,这种是采用匿名接口的继承实现方式,keyval用来存储携带的键值对。

通过上面的代码分析,可以发现:

  1. 添加键值对不是在原来的父Context结构体上直接添加,而是以此context作为父节点,重新创建一个新的valueContext子节点,将键值对添加到子节点上,由此形成一条context链。

  2. 获取键值对的过程也是层层向上调用,直到首次设置key的父节点,如果没有找到首次设置key的父节点,会向上遍历直到根节点,如果根节点找到了key就会返回,否则就会找到最终的根Context(emptyCtx)返回nil。如下图所示:
    在这里插入图片描述
    总结: context添加的键值对是一个链式的,会不断衍生新的context,所以context本身是不可变的,因此是线程安全的。

8.8 context为什么可以实现并发安全?

context包是Go语言中用于在协程之间传递取消信号、截止时间和共享数据的一种机制。context的并发安全性体现在以下几个方面:

  1. 不可变性(Immutability)context的设计中鼓励不可变性,也就是说,一旦创建了context,它的值就不会被改变。这确保了在协程之间传递context时的线程安全性,因为不会有并发修改的情况。
  2. 值的复制:当一个协程创建一个新的context时,它可以基于已有的context创建一个新的实例,并向其中添加或修改一些值。这个过程中,原始的context实例不会受到影响,保证了并发安全。
  3. 不可变的部分:一些context的方法返回一个新的context,而不是修改原始的context。例如,WithValue方法就是返回一个带有新值的新context实例,而不是在原始的context上修改。这样的设计符合不可变性原则,从而确保并发安全。
  4. 取消信号的传递:通过context的取消机制,一个协程可以通知其他协程停止工作。这是通过contextDone通道来实现的。当一个协程调用cancel函数时,Done通道会被关闭,所有基于该context的协程都能感知到取消信号。

总体来说,context的设计强调了不可变性和值的复制,这使得它在并发环境下能够提供一种安全而有效的机制,用于在协程之间传递相关信息,控制取消,以及传递截止时间等。在并发编程中,使用context能够更容易地管理和传递与协程相关的信息,同时避免了共享状态带来的并发安全性问题。

9 select语句

9.1 介绍、使用规则

参考1:go中select语句

select语句是用来监听和channel有关的IO操作的,当IO操作发生时,触发对应的case动作。有了select语句,可以实现main主线程goroutine线程之间的互动。

	//for {
		select {
		case <-ch1 :     // 检测有没有数据可读
			// 一旦成功读取到数据,则进行该case处理语句
		case ch2 <- 1 :  // 检测有没有数据可写
			// 一旦成功向ch2写入数据,则进行该case处理语句
		default:
			// 如果以上都没有符合条件,那么进入default处理流程
		}
	}//

select语句外面可使用for循环来实现不断监听IO的目的。

注意事项:

  1. select语句只能用于channel的IO操作,每个case都必须是一个channel。
  2. 如果不设置default条件,在没有IO操作发生时,select语句就会一直阻塞;
  3. 如果有一个或多个IO操作同时发生时,Go运行时会随机选择一个case执行,但此时将无法保证执行顺序;
  4. 对于case语句,如果存在channel值为nil的读写操作,则该分支将被忽略,可以理解为相当于从select语句中删除了这个case;
  5. 对于既不设置default条件,又一直没有IO操作发生的情况,select语句会引起死锁(fatal error: all goroutines are asleep - deadlock!),如果不希望出现死锁,可以设置一个超时时间的case来解决;
  6. 对于在for中的select语句,不能添加default,否则会引起CPU占用过高的问题;

9.2 如何给select的case设定优先级

参考1:go语言中select实现优先级
9.1 注意事项3中已知无法保证执行顺序的情况。

问题描述:我们有一个函数会持续不间断地从ch1ch2中分别接收任务1任务2,如何确保当ch1ch2同时达到就绪状态时,优先执行任务1,在没有任务1的时候再去执行任务2呢?

实现代码:

func worker2(ch1, ch2 <-chan int, stopCh chan struct{}) {
	for {
		select {
		case <-stopCh:
			return
		case job1 := <-ch1:
			fmt.Println(job1)
		case job2 := <-ch2:
		priority:
			for {
				select {
				case job1 := <-ch1:
					fmt.Println(job1)
				default:
					break priority
				}
			}
			fmt.Println(job2)
		}
	}
}

使用了嵌套的select,还组合使用了for循环和label来解决问题。上面的代码在外层select选中执行job2 := <-ch2时,进入到内层select循环继续尝试执行job1 := <-ch1,当ch1就绪时就会一直执行,否则跳出内层select,继续执行job2

这是两个任务的情况,在任务数可数的情况下可以层层嵌套来实现对多个任务排序,对于有规律的任务可以使用递归的。

9.3 如何判断select的某个通道是关闭的

参考1:https://blog.csdn.net/eddycjy/article/details/122053524
注意:关闭的channel不是nil,所以在select语句中依然可以监听并执行对应的case,只不过在读取关闭后的channel时,读取到的数据是零值,ok是false。要想知道某个通道是否关闭,判断ok是否为false即可。

要想判断某个通道是否关闭,当返回的ok为false时,执行c = nil 将通道置为nil,相当于读一个未初始化的通道,则会一直阻塞。至于为什么读一个未初始化的通道会出现阻塞,可以看我的另一篇 对未初始化的的chan进行读写,会怎么样?为什么? 。select中如果任意某个通道有值可读时,它就会被执行,其他被忽略。则select会跳过这个阻塞case,可以解决不断读已关闭通道的问题。

9.4 如何屏蔽已关闭的channel

参考1:https://blog.csdn.net/eddycjy/article/details/122053524
要想屏蔽某个已经关闭的通道,判断通道的ok是false后,将channel置为nilselect再监听该通道时,相当于监听一个未初始化的通道,则会一直阻塞,select会跳过这个阻塞,从而达到屏蔽的目的。

9.5 select里只有一个已经关闭的channel会怎么样

参考1:https://blog.csdn.net/eddycjy/article/details/122053524
只有一个case的情况下,则会死循环。

关闭的channel不是nil,所以在select语句中依然可以监听并执行对应的case,只不过在读取关闭后的channel时,读取到的数据是零值,ok是false。

9.6 select里只有一个已经关闭的channel,且置为nil,会怎么样

参考1:https://blog.csdn.net/eddycjy/article/details/122053524
答:因为只有一个已经关闭的channel,且已经置为了nil,这时select会先阻塞,最后发生死锁(fatal error: all goroutines are asleep - deadlock!)。

对于既不设置default条件,又一直没有IO操作发生的情况,select语句会引起死锁(fatal error: all goroutines are asleep - deadlock!),如果不希望出现死锁,可以设置一个超时时间的case来解决;

10 defer

defer的作用就是把defer关键字之后的函数执行压入一个栈中延迟执行,多个defer的执行顺序是后进先出LIFO,也就是先执行最后一个defer,最后执行第一个defer。

10.1 使用场景

  1. 打开和关闭文件;
  2. 接收请求和回复请求;
  3. 加锁和解锁等。

在这些操作中,最容易忽略的就是在每个函数退出处正确地释放和关闭资源。

10.2 一个函数中多个defer的执行顺序【defer之间】

参考1:go defer、return的执行顺序
多个defer的执行顺序是后进先出LIFO,也就是先执行最后一个defer,最后执行第一个defer。

10.3 defer、return、返回值 的执行返回值顺序

参考1:go defer、return的执行顺序
参考2:Go语言中defer和return执行顺序解析

return返回值的运行机制:return并非原子操作,共分为赋值、返回值两步操作。

defer、return、返回值三者的执行是:return最先执行,先将结果写入返回值中(即赋值);接着defer开始执行一些收尾工作;最后函数携带当前返回值退出(即返回值)。

  1. 无名返回值(即函数返回值为没有命名的返回值)

如果函数的返回值是无名的(不带命名返回值),则go语言会在执行return的时候会执行一个类似创建一个临时变量作为保存return值的动作,所以defer里面的操作不会影响返回值

package main
 
import (
        "fmt"
)
 
func main() {
        fmt.Println("return:", Demo()) // 打印结果为 return: 0
}
 
func Demo() int {
        var i int
        defer func() {
                i++
                fmt.Println("defer2:", i) // 打印结果为 defer: 2
        }()
        defer func() {
                i++
                fmt.Println("defer1:", i) // 打印结果为 defer: 1
        }()
        return i
}

代码示例,实际上一共执行了3步操作:
1)赋值,因为返回值没有命名,所以return 默认指定了一个返回值(假设为s),首先将i赋值给s,i初始值是0,所以s也是0。
2)后续的defer操作因为是针对i,进行的,所以不会影响s,此后因为s不会更新,所以s不会变还是0。
3)返回值,return s,也就是return 0
相当于:

var i int
s := i
return s
  1. 有名返回值(函数返回值为已经命名的返回值)

有名返回值的函数,由于返回值在函数定义的时候已经将该变量进行定义,在执行return的时候会先执行返回值保存操作,而后续的defer函数会改变这个返回值(虽然defer是在return之后执行的,但是由于使用的函数定义的变量,所以执行defer操作后对该变量的修改会影响到return的值)。

由于返回值已经提前定义了,不会产生临时零值变量,返回值就是提前定义的变量,后续所有的操作也都是基于已经定义的变量,任何对于返回值变量的修改都会影响到返回值本身。

package main
 
import (
        "fmt"
)
 
func main() {
        fmt.Println("return:", Demo2()) // 打印结果为 return: 2
}
 
func Demo2() (i int) {
        defer func() {
                i++
                fmt.Println("defer2:", i) // 打印结果为 defer: 2
        }()
        defer func() {
                i++
                fmt.Println("defer1:", i) // 打印结果为 defer: 1
        }()
        return i // 或者直接 return 效果相同
}

10.4 defer能否修改return的值

可以,在 10.2.2 defer、return、返回值 的执行返回值顺序有名返回值(函数返回值为已经命名的返回值)的讲解中可以知道,可以更改。

有名返回值的函数,由于返回值在函数定义的时候已经将该变量进行定义,在执行return的时候会先执行返回值保存操作,而后续的defer函数会改变这个返回值(虽然defer是在return之后执行的,但是由于使用的函数定义的变量,所以执行defer操作后对该变量的修改会影响到return的值)。

由于返回值已经提前定义了,不会产生临时零值变量,返回值就是提前定义的变量,后续所有的操作也都是基于已经定义的变量,任何对于返回值变量的修改都会影响到返回值本身。

10.5 在循环打开很多个文件,怎么使用defer关闭文件,defer应该写在哪个位置

参考1:循环内部使用defer的正确姿势

重点是理解defer的执行机制,defer是在函数退出的时候才执行的,所以可以将打开关闭、文件等操作单独写到一个函数里,或者是写到匿名函数中。

11 Golang的反射

参考1:golang之反射

11.1 反射基础知识

反射基本介绍:

  1. 反射可以在运行时动态获取变量的各种信息,比如变量的类型(type),类别(kind)
  2. 如果是结构体变量,还可以获取到结构体本身的信息(包括结构体的字段,方法)。
  3. 通过反射,可以修改变量的值,可以调用关联的方法。
  4. 使用反射,需要导入reflect包。

反射重要的函数:

  1. reflect.TypeOf(变量名),获取变量的类型,返回reflect.Type类型;
  2. reflect.ValueOf(变量名),获取变量的值,返回reflect.Value类型,reflect.Value是一个结构体类型。通过reflect.Value,可以获取到关于该变量的很多信息。
  3. 变量、interface{}reflect.Value是可以相互转换的,这点在实际开发中,会经常使用到。
interface{}  ——>  reflect.Value:

rVal := reflect.ValueOf(b)

reflect.Value  ——>  interface{}:

iVal := rVal.Interface()

interface{}  ——>  原来的变量(类型断言):

v := iVal.(Stu)

反射的注意事项:

  1. reflect.Value.kind,获取变量的类别,返回的是一个常量。
  2. Type是类型,kind是类别,可能相同,也可能不相同。
//比如:
var num int = 10   //num的Type是int,Kind也是int
var stu Student    //stu的Type是包名.Student,Kind是struct
  1. 通过反射可以让变量在interface{}Reflect.Value之间相互转换:
变量 <——> interface{} <——> reflect.Value
  1. 使用反射的方式来获取变量的值(并返回对应的类型),要求数据类型匹配,比如x是int,那么就应该使用reflect.Value(x).Int(),而不能使用其它的,否则报painc。
  2. 通过反射来修改变量,注意当使用SetXxx方法来设置需要通过对应的指针类型来完成,这样才能改变传入的变量的值,同时需要使用到reflect.Value.Elem()方法。

11.2 反射如何获取结构体中字段的jsonTag

参考1:Go语言反射(reflection)简述
看参考1的使用反射获取结构体的成员类型部分。

package main

import (
    "fmt"
    "reflect"
)

func main() {
    // 声明一个空结构体
    type cat struct {
        Name string
        // 带有结构体tag的字段
        Type int `json:"type" id:"100"`
    }
    // 创建cat的实例
    ins := cat{Name: "mimi", Type: 1}
    // 获取结构体实例的反射类型对象
    typeOfCat := reflect.TypeOf(ins)
    // 遍历结构体所有成员
    for i := 0; i < typeOfCat.NumField(); i++ {
        // 获取每个成员的结构体字段类型
        fieldType := typeOfCat.Field(i)
        // 输出成员名和tag
        fmt.Printf("name: %v  tag: '%v'\n", fieldType.Name, fieldType.Tag)
    }
    // 通过字段名, 找到字段类型信息
    if catType, ok := typeOfCat.FieldByName("Type"); ok {
        // 从tag中取出需要的tag
        fmt.Println(catType.Tag.Get("json"), catType.Tag.Get("id"))
    }
}

输出结果:

name: Name tag: ‘’
name: Type tag: ‘json:“type” id:“100”’
type 100

11.3 结构体里的变量不加tag能正常转成json里的字段吗

参考1:golang面试题:json包变量不加tag会怎么样?

  1. 若是变量首字母小写,则为private。由于取不到反射信息,所以不能转成json。
  2. 若是变量首字母大写,则为public,可以转为json:
  • json不加tag,能够正常转为json里的字段,json内字段名跟结构体内字段原名一致。
  • json加了tag,从struct转json的时候,json的字段名就是tag里的字段名,原字段名已经没用。

代码:

package main

import (
	"encoding/json"
	"fmt"
)

type JsonTest struct {
	aa string //小写无tag
	bb string `json:"BB"` //小写+tag
	CC string //大写无tag
	DD string `json:"DJson"` //大写+tag
}

func main() {
	jsonTest := JsonTest{aa: "1", bb: "2", CC: "3", DD: "4"}
	fmt.Printf("转为json前jsonTest结构体的内容 = %+v\n", jsonTest)
	jsonInfo, _ := json.Marshal(jsonTest)
	fmt.Printf("转为json后的内容 = %+v\n", string(jsonInfo))
}

11.4 反射操作指针类型

	typ := reflect.TypeOf(data).Elem()  //指针类型需要加 Elem()
	val := reflect.ValueOf(data).Elem() //指针类型需要加 Elem()

11.5 Golang反射的使用场景和限制。

使用场景:

  1. 通用数据处理:反射可以用于处理未知类型的数据,例如JSON解析、数据库操作等。通过反射,可以动态地获取和修改数据的类型和值。
  2. 动态调用方法:反射可以用于动态调用结构体的方法,尤其在需要通过方法名字符串来调用方法时,反射提供了一种实现机制。
  3. 类型检查和类型转换:通过反射,可以在运行时获取变量的类型信息,进行类型检查和类型转换。这对于泛型编程和接口实现时可能很有用。
  4. 代码工具和框架:一些代码生成工具、ORM框架、测试框架等利用反射来提供通用的、可扩展的功能。

限制和注意事项:

  1. 性能影响:反射操作通常比直接的类型断言和静态编码效率低,因为它需要在运行时进行类型检查。在性能敏感的代码中要谨慎使用反射。
  2. 可读性和维护性:反射的代码通常更加复杂和难以理解,可能会降低代码的可读性和维护性。因此,只有在确实需要在运行时获取类型信息或进行动态操作时才应该使用反射。
  3. 类型安全问题:反射可能会导致类型不安全的问题,因为在编译时无法检查反射操作的正确性。这可能导致一些潜在的运行时错误。
  4. 不支持编译时优化:由于反射的特性,Go编译器无法对涉及反射的代码进行很好的优化。这可能会导致一些性能上的损失。

在实际应用中,要慎重使用反射,并确保它真的是解决问题的最佳选择。在大多数情况下,通过静态编码和类型安全的方式实现更为清晰和高效。

12 Golang哪些情况会导致内存泄漏

参考1:golang容易导致内存泄漏的几种情况

12.1 内存泄漏的本质

参考1:内存泄漏

内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

内存泄漏的危害:
长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

12.2 几种情况

  1. 定时器使用不当
  2. select阻塞
  3. channel阻塞
  4. goroutine导致的内存泄漏
  5. slice引起的内存泄漏
  6. 数组的值传递

12.3 定时器使用不当

12.3.1 time.After()的使用

默认的time.After()是会有内存泄露问题的,因为每次time.After(duration x)会产生NewTimer(),在duration x到期之前,新创建的timer不会被垃圾回收,到期之后才会垃圾回收。

随着时间推移,尤其是duration x很大的话,会产生内存泄露的问题,应特别注意。

for true {
	select {
	case <-time.After(time.Minute * 3):
    // do something
  default:
		time.Sleep(time.Duration(1) * time.Second)
	}
}

为了保险起见,使用NewTimer()或者NewTicker()代替的方式主动释放资源。

timer := time.NewTicker(time.Duration(2) * time.Second)
defer timer.Stop()
for true {
	select {
	case <-timer.C:
		// do something
	default:
		time.Sleep(time.Duration(1) * time.Second)
	}
}

12.3.2 time.NewTicker资源未及时释放

在使用time.NewTicker时需要手动调用Stop()方法释放资源,否则将会造成永久性的内存泄漏。

timer := time.NewTicker(time.Duration(2) * time.Second)
// defer timer.Stop()
for true {
	select {
	case <-timer.C:
		// do something
	default:
		time.Sleep(time.Duration(1) * time.Second)
	}
}

12.4 select阻塞

使用select时如果有case没有覆盖完全的情况且没有default分支进行处理,会出现阻塞,最终导致内存泄漏。

12.4.1 导致goroutine阻塞的情况

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    ch3 := make(chan int)
    go Getdata("https://www.baidu.com",ch1)
    go Getdata("https://www.baidu.com",ch2)
    go Getdata("https://www.baidu.com",ch3)
    select{
        case v:=<- ch1:
            fmt.Println(v)
        case v:=<- ch2:
            fmt.Println(v)
    }
}

上面代码中这种情况会阻塞在ch3的消费处导致内存泄漏。

12.4.2 循环空转导致CPU暴涨

func main() {
	fmt.Println("main start")
	msgList := make(chan int, 100)
	go func() {
		for {
			select {
			case <-msgList:
			default:
 
			}
		}
	}()
	
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt, os.Kill)
	s := <-c
	
	fmt.Println("main exit.get signal:", s)
}

上述for循环条件一旦命中default则会出现循环空转的情况,并最终导致CPU暴涨。

12.5 channel阻塞

channel阻塞主要分为写阻塞读阻塞两种情况。

12.5.1 空channel

读写均会堵塞。

func channelTest() {
  	//声明未初始化的channel读写都会阻塞
    var c chan int
  	//向channel中写数据
    go func() {
        c <- 1
        fmt.Println("g1 send succeed")
        time.Sleep(1 * time.Second)
    }()
  	//从channel中读数据
    go func() {
        <-c
        fmt.Println("g2 receive succeed")
        time.Sleep(1 * time.Second)
    }()
    time.Sleep(10 * time.Second)
}

12.5.2 写阻塞

  1. 无缓冲channel的阻塞通常是写操作因为没有读而阻塞。
func channelTest() {
    var c = make(chan int)
  	//10个协程向channel中写数据
    for i := 0; i < 10; i++ {
        go func() {
            <- c
            fmt.Println("g1 receive succeed")
            time.Sleep(1 * time.Second)
        }()
    }
  	//1个协程丛channel读数据
    go func() {
        c <- 1
        fmt.Println("g2 send succeed")
        time.Sleep(1 * time.Second)
    }()
  	//会有写的9个协程阻塞得不到释放
    time.Sleep(10 * time.Second)
}
  1. 有缓冲的channel因为缓冲区满了,写操作阻塞。
func channelTest() {
    var c = make(chan int, 8)
  	//10个协程向channel中写数据
    for i := 0; i < 10; i++ {
        go func() {
            <- c
            fmt.Println("g1 receive succeed")
            time.Sleep(1 * time.Second)
        }()
    }
  	//1个协程丛channel读数据
    go func() {
        c <- 1
        fmt.Println("g2 send succeed")
        time.Sleep(1 * time.Second)
    }()
  	//会有写的几个协程阻塞写不进去
    time.Sleep(10 * time.Second)
}

12.5.3 读阻塞

从channel读数据,但是没有goroutine往进写数据。

func channelTest() {
   var c = make(chan int)
  //1个协程向channel中写数据
  go func() {
    <- c
    fmt.Println("g1 receive succeed")
    time.Sleep(1 * time.Second)
  }()
  //10个协程丛channel读数据
  for i := 0; i < 10; i++ {
    go func() {
        c <- 1
        fmt.Println("g2 send succeed")
        time.Sleep(1 * time.Second)
    }()
  }
  //会有读的9个协程阻塞得不到释放
  time.Sleep(10 * time.Second)
}

12.6 goroutine导致的内存泄漏

12.6.1 申请过多的goroutine

例如在for循环中申请过多的goroutine来不及释放导致内存泄漏。

12.6.2 goroutine阻塞

12.6.2.1 I/O问题

I/O连接未设置超时时间,导致goroutine一直在等待,代码会一直阻塞。

12.6.2.2 互斥锁未释放

goroutine无法获取到锁资源,导致goroutine阻塞。

12.6.2.3 死锁

当程序死锁时其他goroutine也会阻塞。

func mutexTest() {
    m1, m2 := sync.Mutex{}, sync.RWMutex{}
  	//g1得到锁1去获取锁2
    go func() {
        m1.Lock()
        fmt.Println("g1 get m1")
        time.Sleep(1 * time.Second)
        m2.Lock()
        fmt.Println("g1 get m2")
    }()
    //g2得到锁2去获取锁1
    go func() {
        m2.Lock()
        fmt.Println("g2 get m2")
        time.Sleep(1 * time.Second)
        m1.Lock()
        fmt.Println("g2 get m1")
    }()
  	//其余协程获取锁都会失败
    go func() {
        m1.Lock()
        fmt.Println("g3 get m1")
    }()
    time.Sleep(10 * time.Second)
}
12.6.2.4 WaitGroup使用不当

WaitGroup的Add、Done和wait数量不匹配会导致wait一直在等待。

12.7 slice 引起的内存泄漏

当两个slice共享地址,其中一个为全局变量,另一个也无法被GC;

append slice后一直使用,没有进行清理。

var a []int
 
func test(b []int) {
        a = b[:3]
        return
}

12.8 数组的值传递

由于数组时Golang的基本数据类型,每个数组占用不通的内存空间,生命周期互不干扰,很难出现内存泄漏的情况,但是数组作为形参传输时,遵循的时值拷贝,如果函数被多个goroutine调用且数组过大时,则会导致内存使用激增。

//统计nums中target出现的次数
func countTarget(nums [1000000]int, target int) int {
    num := 0
    for i := 0; i < len(nums) && nums[i] == target; i++ {
        num++
    }
    return num
}

因此对于大数组放在形参场景下通常使用切片或者指针进行传递,避免短时间的内存使用激增。

13 Golang的并发实现方式

参考1:https://www.jb51.net/article/243510.htm
一共四种:

  1. goroutine:Golang在语言层面对并发编程进行了支持, 使用go关键字来使用协程。
  2. channel:Golang语言在语言级别提供了对goroutine之间通信的支持,我们可以使用channel在两个或者多个goroutine之间进行信息传递,能过channel传递对像的过程和调用函数时的参数传递行为一样,可以传递普通参数和指针。
  3. select:当我们在实际开发中,我们一般同时处理两个或者多个channel的数据,我们想要完成一个那个channel先来数据,我们先来处理个那channel,避免等待。
  4. 传统的并发控制:sync.Mutex加锁和sync.WaitGroup等待组。

14 Golang里的结构体可以直接使用双等号作比较吗

参考1:Golang = 比较与赋值
参考2:golang中如何比较struct,slice,map是否相等以及几种对比方法的区别

  • 结构体只能比较是否相等,但是不能比较大小。
  • 相同类型的结构体才能够进行比较,结构体是否相同不但与属性类型有关,还与属性顺序相关,sn3 与 sn1 就是不同的结构体;
  • 如果 struct 的所有成员都可以比较,则该 struct 就可以通过 == 或 != 进行比较是否相等,比较时逐个项进行比较,如果每一项都相等,则两个结构体才相等,否则不相等;(像切片、map、函数等是不能比较的)

15 Golang里有Set结构体吗?如果没有怎么设计一个Set结构体

参考1:Golang数据结构实现(二)集合Set

//定义1个set结构体 内部主要是使用了map
type set struct {
	elements map[interface{}]bool
}

16 Golang的runtime

参考1:说说Golang的runtime

runtime包含Go运行时的系统交互的操作,例如控制goruntine的功能。还有debugpprof进行排查问题和运行时性能分析,tracer来抓取异常事件信息,如 goroutine的创建,加锁解锁状态,系统调用进入推出和锁定还有GC相关的事件,堆栈大小的改变以及进程的退出和开始事件等等;race进行竞态关系检查以及CGO的实现。总的来说运行时是调度器和GC。

17 Golang死锁的场景及解决办法

参考1:详解Golang并发操作中常见的死锁情形

  1. 无缓存能力的管道,自己写完自己读。
    会报死锁:fatal error: all goroutines are asleep - deadlock! 解决办法很简单,开辟两条协程,一条协程写,一条协程读。
func main() {
    ch := make(chan int, 0)
​
    ch <- 666
    x := <- ch
    fmt.Println(x)
}
  1. 协程来晚了
func main() {
    ch := make(chan int,0)
    ch <- 666
    go func() {
        <- ch
    }()
}

我们可以看到,这条协程开辟在将数字写入到管道之后,因为没有人读,管道就不能写,然后写入管道的操作就一直阻塞。这时候就有疑惑了,不是开辟了一条协程在读吗?但是那条协程开辟在写入管道之后,如果不能写入管道,就开辟不了协程。

  1. 管道读写时,相互要求对方先读/写
func main() {
    chHusband := make(chan int,0)
    chWife := make(chan int,0)go func() {
        select {
        case <- chHusband:
            chWife<-888
        }
    }()select {
        case <- chWife:
            chHusband <- 888
    }
}
  • 先来看看老婆协程,chWife只要能读出来,也就是老婆有钱,就给老公发个八百八十八的大红包。
  • 再看看老公的协程,一看不得了,咋啦?老公也说只要他有钱就给老婆包个八百八十八的大红包。
  • 两个人都说自己没钱,老公也给老婆发不了红包,老婆也给老公发不了红包,这就是死锁!
  1. 读写锁相互阻塞,形成隐形死锁
func main() {
    var rmw09 sync.RWMutex
    ch := make(chan int,0)go func() {
        rmw09.Lock()
        ch <- 123
        rmw09.Unlock()
    }()go func() {
        rmw09.RLock()
        x := <- ch
        fmt.Println("读到",x)
        rmw09.RUnlock()
    }()for {
        runtime.GC()
    }
}
  • 这两条协程,如果第一条协程先抢到了只写锁,另一条协程就不能抢只读锁了,那么因为另外一条协程没有读,所以第一条协程就写不进。
  • 如果第二条协程先抢到了只读锁,另一条协程就不能抢只写锁了,那么因为另外一条协程没有写,所以第二条协程就读不到。

18 Golang的僵尸进程

参考1:Go Exec 僵尸与孤儿进程

僵尸进程(zombie process)指:完成执行(通过exit系统调用,或运行时发生致命错误或收到终止信号所致),但在操作系统的进程表中仍然存在其进程控制块,处于“终止状态”的进程。

解决&预防:
收割僵尸进程的方法是通过kill命令手工向其父进程发送SIGCHLD信号。如果其父进程仍然拒绝收割僵尸进程,则终止父进程,使得init进程收养僵尸进程。init进程周期执行wait系统调用收割其收养的所有僵尸进程。

19 Golang函数的入参是值传递还是引用传递

参考1:Golang函数参数的值传递和引用传递

值传递和引用传递都有,看入参的类型。

值传递:是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
引用传递:引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数,由于引用类型(slice、map、interface、channel)自身就是指针,所以这些类型的值拷贝给函数参数,函数内部的参数仍然指向它们的底层数据结构。

20 Golang的引用类型有哪几种

参考1:https://www.jianshu.com/p/93e205e70e83

Golang的引用类型包括 slicemapchannel

21 Golang的make和new的区别

参考1:make和new的区别

newmake 主要区别如下:

  1. make只能用来分配及初始化类型为slice、map、chan的数据。new可以分配任意类型的数据;
  2. new分配返回的是指针,即类型*Type。make返回引用,即Type;
  3. new分配的空间被清零。make分配空间后,会进行初始化;

在讲newmake的使用场景之前,先介绍一下golang中的值类型和引用类型。

引用类型和值类型
值类型: intfloatboolstring这些类型都属于值类型,使用这些类型的变量直接指向存在内存中的值,值类型的变量的值存储在栈中。当使用等号=将一个变量的值赋给另一个变量时,如 j = i ,实际上是在内存中将 i 的值进行了拷贝。可以通过 &i 获取变量 i 的内存地址。 (struct在方法中传参时是值类型而非引用类型)

引用类型:特指slicemapchannel这三种预定义类型。能够通过make()函数创建的都是引用类型,比如slicemapslice虽然看起来像数组,但是他其实是一个指向数组内存空间的一个指针类型。

使用场景:

  1. 如果方法内部会修改当前对象的字段或改变其值,需要用指针。
  2. 由于值传递是(内存)复制,因此,如果对象比较大,应该使用指针(地址),避免内存拷贝(值类型等变量指向内存中的值,如果有值类型变量存放大量元素,或造成内存的大量拷贝)

22 Mutex读写锁和互斥锁的区别

参考1:互斥锁机制,互斥锁与读写锁区别

互斥锁和读写锁的区别:

  1. 读写锁区分读者和写者,而互斥锁不区分。
  2. 互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。

23 NewTicker和NewTimer的区别

参考1:go定时器NewTicker&NewTimer

  1. NewTimer是延迟d时间后触发,如果需要循环则需要Reset。NewTimer的延迟时间并不是精确、稳定的,比如设置30ms,有可能会35、40ms后才触发,即使在系统资源充足的情况下,所以一个循环的timer在60ms内并不能保证会触发2两次,而ticker会。

  2. 它会调整时间间隔或者丢弃 tick 信息以适应反应慢的接者,所以回调触发不是稳定的,有可能在小于d的时间段触发,也有可能大于d的时间段触发,即使应用什么都不做。但在一段时间内,触发次数是保证的,比如在系统资源充足的情况下,设定触发间隔30ms,上一ticket触发间隔是44ms,下一触发间隔可能就是16ms,所以60ms内还是会触发两个ticket。

区别:
ticker的稳定性不如timer,一个空转的go程序,tickter也是不稳定的,触发间隔并不会稳定在d时间段,在ms级别上;而timer相对稳定,但也不是绝对的,timer也会在大于d的时间后触发。

24 遇到高并发场景怎么处理

回答:Golang语言上是使用多协程,还有架构设计方面等等。

25 哪些场景有使用到Goroutine、channel

答:并发处理。有缓冲的channel可以控制并发数目,从而实现多线程并发处理。

26 Golang把int转为string的方式(strconv包)

参考1:Go语言strconv包实现字符串和数值类型的相互转换

strconv包里有相关的转换方法:

  1. string 与 int 类型之间的转换
    Itoa():整型转字符串。
    Atoi():字符串转整型。

  2. Parse 系列函数
    Parse系列函数用于将字符串转换为指定类型的值,其中包括 ParseBool()ParseFloat()ParseInt()ParseUint()

  3. Format 系列函数
    Format系列函数实现了将给定类型数据格式化为字符串类型的功能,其中包括 FormatBool()FormatInt()FormatUint()FormatFloat()

  4. Append 系列函数

  • Append系列函数用于将指定类型转换成字符串后追加到一个切片中,其中包含 AppendBool()AppendFloat()AppendInt()AppendUint()
  • Append系列函数和Format系列函数的使用方法类似,只不过是将转换后的结果追加到一个切片中。

27 使用过Golang的sync包里哪些函数或方法

参考1:golang标准库-sync包使用和应用场景
参考2:Golang - sync包的使用

  1. Locker:Locker接口,包含Lock()和Unlock()两个方法,用于代表一个能被加锁和解锁的对象。
  2. Once:Once是只执行一次动作的对象,使用后不得复制,Once只有一个Do方法。
  3. Mutex:Mutex是一个互斥锁,可以创建为其他结构体的字段;零值为解锁状态。Mutex类型的锁和线程无关,可以由不同的线程加锁和解锁。实现了Locker()接口的UnLock()和Locker()方法,同一时刻一段代码只能被一个线程运行。
  4. RWMutex:读写互斥锁,该锁可以被同时多个读取者持有或唯一个写入者持有。
  5. WaitGroup:WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait() 用来控制计数器的数量。Add(n) 把计数器设置为n ,Done() 每次把计数器-1 ,wait() 会阻塞代码的运行,直到计数器的值减为0。
  6. Pool:Pool是一个可以分别存取的临时对象的集合,可以被看作是一个存放可重用对象的值的容器、过减少GC来提升性能,是Goroutine并发安全的。有两个方法 Get()、Set()。

WaitGroup、Once、Mutex、RWMutex、Cond、Pool、Map

28 Golang实现字符串拼接有几种方式及其性能

参考1:golang 几种字符串的拼接方式
参考2:Golang的五种字符串拼接方式

  1. +
func BenchmarkAddStringWithOperator(b *testing.B) {
    hello := "hello"
    world := "world"
    for i := 0; i < b.N; i++ {
        _ = hello + "," + world
    }
}

Golang里面的字符串都是不可变的,每次运算都会产生一个新的字符串,所以会产生很多临时的无用的字符串,不仅没有用,还会给GC带来额外的负担,所以性能比较差。

  1. fmt.Sprintf()
func BenchmarkAddStringWithSprintf(b *testing.B) {
    hello := "hello"
    world := "world"
    for i := 0; i < b.N; i++ {
        _ = fmt.Sprintf("%s,%s", hello, world)
    }
}

内部使用 []byte 实现,不像直接运算符这种会产生很多临时的字符串,但是内部的逻辑比较复杂,有很多额外的判断,还用到了 interface,所以性能也不是很好。

  1. strings.Join()
func BenchmarkAddStringWithJoin(b *testing.B) {
    hello := "hello"
    world := "world"
    for i := 0; i < b.N; i++ {
        _ = strings.Join([]string{hello, world}, ",")
    }
}

join会先根据字符串数组的内容,计算出一个拼接之后的长度,然后申请对应大小的内存,一个一个字符串填入,在已有一个数组的情况下,这种效率会很高,但是如果本来没有的话,去构造这个数据的代价也不小,效率也不高。

  1. buffer.WriteString()
func BenchmarkAddStringWithBuffer(b *testing.B) {
    hello := "hello"
    world := "world"
    for i := 0; i < 1000; i++ {
        var buffer bytes.Buffer
        buffer.WriteString(hello)
        buffer.WriteString(",")
        buffer.WriteString(world)
        _ = buffer.String()
    }
}

这个比较理想,可以当成可变字符使用,对内存的增长也有优化,如果能预估字符串的长度,还可以用 buffer.Grow() 接口来设置 capacity

几种方式的性能:

  1. 在已有字符串数组的场合,使用 strings.Join() 能有比较好的性能。
  2. 在一些性能要求较高的场合,尽量使用 buffer.WriteString()以获得更好的性能。
  3. 性能要求不太高的场合,直接使用运算符,代码更简短清晰,能获得比较好的可读性。
  4. 如果需要拼接的不仅仅是字符串,还有数字之类的其他需求的话,可以考虑 fmt.Sprintf()

29 Golang的int和int32区别

参考1:Golang中int, int8, int16, int32, int64和uint区别
答:

  1. int类型的大小与操作系统有关
  2. int8类型大小为 1 字节【8代表8位】
  3. int16类型大小为 2 字节【16代表16位】
  4. int32类型大小为 4 字节【32代表32位】
  5. int64类型大小为 8 字节【64代表64位】

我们看一下官方文档
int is a signed integer type that is at least 32 bits in size. It is a distinct type, however, and not an alias for, say, int32.
意思是 int 是一个至少32位的有符号整数类型。但是,它是一个不同的类型,而不是int32的别名。int 和 int32 是两码事。
uint is a variable sized type, on your 64 bit computer uint is 64 bits wide.
uint 是一种可变大小的类型,在64位计算机上,uint 是64位宽的。uint 和 uint8 等都属于无符号 int 类型。uint 类型长度取决于 CPU,如果是32位CPU就是4个字节,如果是64位就是8个字节。

总结:
go语言中的int的大小是和操作系统位数相关的,32位操作系统,int类型的大小是4字节【int32类型】。64位操作系统,int类型的大小是8个字节【int64类型】。

30 go.sum和go.mod的区别

参考1:深入理解 Go Modules 的 go.mod 与 go.sum
go.mod

  1. go.mod:它用来标记一个 module 和它的依赖库以及依赖库的版本。会放在 module 的主文件夹下,一般以 go.mod 命名。
    上面我们说到,Golang在做依赖管理时会创建两个文件,go.mod和go.sum。
  2. go.sum:go.sum 则是记录了所有依赖的 module 的校验信息,以防下载的依赖被恶意篡改,主要用于安全校验。

31 Golang依赖包的引用查询机制

参考1:Go 包管理与依赖查找顺序

32 go mod tidy做了什么事情

参考1:go mod tidy的作用

  1. 引用项目需要的依赖增加到go.mod文件。
  2. 去掉go.mod文件中项目不需要的依赖。

33 定时任务除了time.Tick(time.Second),其他的实现

Asynq,基于Redis实现的。

34 Golang为什么会有指针,指针的主要作用是什么

参考1:GO:理解指针的作用

指针是指向了一个值的内存地址。

指针的作用:

  1. 指针类型用于传递地址,而不是传递值,因为golang的函数,所有的参数都是传递一个复制的值。如果值的体积过大,,那么就会严重降低效率,而传递一个地址, 就会大大提高效率,另外传递指针也能让golang函数实现对变量值的修改。
  2. 如果一个复杂类型的值被传递了若干次后,和自己比较,虽然用于保存的容器和名称变了,但用于保存值的地址不变,这个时候,只要使用指针进行对比,就知道还是原来的东西。

35 项目中错误处理是怎么做的,比如执行了空指针异常

参考1:golang 错误处理

panic
panic的引发:

  1. 程序主动调用panic函数。
  2. 程序产生运行时错误,由运行时检测并抛出。

发生panic后,程序会从调用panic的函数位置或发生panic的地方立即返回,逐层向上执行函数的defer语句,然后逐层打印函数调用堆栈,直到被recover捕获或运行到最外层函数而退出。

此外,defer逻辑里也可以再次调用panic或抛出panic。defer里面的 panic 能够被后续执行的 defer 捕获。

recover
recover()用来捕获panic,阻止panic继续向上传递。recover()defer一起使用,但是recover()只有在defer后面的函数体内被直接调用才能捕获panic终止异常,否则返回nil,异常继续向外传递。

即在defer中使用recover捕获并处理异常。

error
Go语言内置错误接口类型error。任何类型只要实现Error() string 方法,都可以传递error 接口类型变量。Go语言典型的错误处理方式是将error作为函数最后一个返回值。在调用函数时,通过检测其返回的error值是否为nil来进行错误处理。

36 Golang面向对象的继承、多态、封装

参考1:golang实现面向对象的封装、继承、多态

37 Golang的mutex等各种锁的原理

参考1:Golang 的锁机制
Golang中的锁分为互斥锁读写锁原子锁即原子操作。

Golang 里有专门的方法来实现锁,就是 sync 包,这个包有两个很重要的锁类型。一个叫 Mutex, 利用它可以实现互斥锁。一个叫 RWMutex,利用它可以实现读写锁

sync.Mutex:互斥锁是同一时刻某一资源只能上一个锁,上锁后只能被此线程使用,直至解锁。加锁后即不能读也不能写。

sync.RWMutex:读写锁将使用者分为读者写者两个概念,支持同时多个读者一起读共享资源,但写时只能有一个,并且在写时不可以读。理论上来说,sync.RWMutex 的 Lock() 也是个互斥锁。

38 Golang系统中哪些panic是不能被捕获的

参考1:使用Golang时遇到的一些坑

  1. 并发操作map实例

39 Golang服务的优雅重启有哪些方式

参考1:Go项目实现优雅关机与平滑重启

优雅的关机:
优雅关机就是服务端关机命令发出后不是立即关机,而是等待当前还在处理的请求全部处理完毕后再退出程序,是一种对客户端友好的关机方式。而执行Ctrl+C关闭服务端时,会强制结束进程导致正在访问的请求出现问题。

实现原理:
Go 1.8版本之后,在 os/signal 包中, http.Server 内置的 Shutdown() 方法就支持优雅地关机。

Shutdown工作的机制:当程序检测到中断信号时,我们调用http.Server中的Shutdown()方法,该方法将阻止新的请求进来,同时保持当前的连接,直到当前连接完成则终止程序!

信号列表
流程
8080端口开启了一个web服务,并且只注册了一条路由,“/”, 但客户端访问127.0.0.1:8080/时,过10秒才会响应,如果这时我们按下ctrl+c,给程序发送syscall.SIGINT信号,他会等待10秒将当前请求处理完,他才会消亡,当然也取决于创建的5秒的context超时时间。

代码

package main

import (
	"context"
	"github.com/gin-gonic/gin"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

// 实现优雅关机和平滑重启
func main() {
	router := gin.Default()
	router.GET("/", func(c *gin.Context) {
		// 这个10秒的延时。是为了演示操作方便,实际上线一定注释掉
		time.Sleep(time.Second * 10)
		c.String(http.StatusOK, "hello xiaosheng")
	})
	srv := &http.Server{
		Addr:    ":8080",
		Handler: router,
	}
	// 必须开启一个go routine 因为如果不开起,下面会一直listen and serve,进入死循环
	// err != http.ErrServerClosed这个很重要
	go func() {
		// 开启一个goroutine启动服务
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen : %s\n", err)
		}
	}()
	// 等待中断信号来优雅关掉服务器, 为关闭服务器做一个5秒的延时
	quit := make(chan os.Signal, 1)
	// kill 默认会发送syscall.SIGTREN信号
	// kill -2发送syscall.SIGINT信号,我们常用的ctrl+c就是触发系统SIGINT信号
	// kill -9发送syscall.SIGKILL信号,但是不能被捕获,所以不需要添加他
	// signal.Notify把收到的syscall.SIGINT或syscall.SIGTREN信号传给quit
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // 此处不会阻塞
	<-quit                                               // 阻塞在此,当收到上述两种信号的时候才会往下执行
	log.Println("ShutDown Server ...")
	// 创建一个5秒超时的context
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
	defer cancel()
	// 5秒内优雅关闭服务, (将未处理完的请求处理完再关闭服务), 超过5秒就退出
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal("shut down:", err)
	}
	log.Println("Server exiting...")
}

优雅的重启:(实际使用的比较少)
可以使用fvbock/endless 来替换默认的 ListenAndServe启动服务来实现。

流程

  • 在终端执行go build -o graceful_restart编译,并执行./graceful_restart,终端输出当前pid(假设为44444)。
    将代码中处理请求函数返回的hello xiaosheng!修改为hello world!,再次编译go build -o graceful_restart
  • 打开浏览器访问127.0.0.1:8080/,此时客户端浏览器等待服务端返回响应。
  • 在终端执行kill -1 44444命令给程序发送syscall.SIGHUP重启信号。
  • 等第3步客户端浏览器收到响应信息hello xiaosheng!后,再次访问127.0.0.1:8080/会收到hello world!的响应。

代码

import (
	"log"
	"net/http"
	"time"

	"github.com/fvbock/endless"
	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default()
	router.GET("/", func(c *gin.Context) {
		// 这个5秒的延时。是为了演示操作方便,实际上线一定注释掉
		time.Sleep(5 * time.Second)
		c.String(http.StatusOK, "hello gin!")
	})
	// 默认endless服务器会监听下列信号:
	// syscall.SIGHUP,syscall.SIGUSR1,syscall.SIGUSR2,syscall.SIGINT,syscall.SIGTERM和syscall.SIGTSTP
	// 接收到 SIGHUP 信号将触发`fork/restart` 实现优雅重启(kill -1 pid会发送SIGHUP信号)
	// 接收到 syscall.SIGINT或syscall.SIGTERM 信号将触发优雅关机
	// 接收到 SIGUSR2 信号将触发HammerTime
	// SIGUSR1 和 SIGTSTP 被用来触发一些用户自定义的hook函数
	if err := endless.ListenAndServe(":8080", router); err!=nil{
		log.Fatalf("listen: %s\n", err)
	}
	
	log.Println("Server exiting...")

这样做在不影响当前未处理完请求的同时完成了程序代码的替换,实现了平滑重启。但实际上用的不多,因为实际都是多台服务器,或者说有类似supervisor的软件管理进程时就不适用这种方式,因为他进程pid变了,他自己重启和supervisor的软件管理进程给他重启就冲突了。

※※※※※※※※※ 2023年面试 ※※※※※※※※※※※※

40 Golang怎么排查golang的内存泄露?

  1. 使用内置工具pprof
    • Golang提供了内置的性能分析工具,如pprof,用于分析内存使用情况。
    • 通过导入net/http/pprof包,并在应用程序中启动一个HTTP服务器,可以使用浏览器访问/debug/pprof端点来查看内存分析数据。
    • 例如,使用go tool pprof工具来生成内存使用的火焰图:
    go tool pprof http://localhost:6060/debug/pprof/heap
    
  2. 使用第三方工具
    • 第三方工具如pprof以外的工具,例如PrometheusGrafana等,也可以用于内存分析。
    • 这些工具可以帮助监控应用程序的内存使用情况,并可视化展示内存数据。
  3. 检查goroutine泄漏
    • 内存泄漏有时与未正确关闭的goroutine相关。
    • 使用go tool pprofruntime/pprof包来查看当前正在运行的goroutine以及其状态,以确定是否有未释放的资源。
  4. 使用内存分析工具
    • Golang的标准库中包含了runtime/debug包,可以用于检查堆中的对象数量和大小。
    • 使用debug.FreeOSMemory来手动释放不再需要的内存。
  5. 使用Go的内存分析工具
    • go tool trace工具可以用于生成跟踪文件,该文件提供了详细的时间线信息,可用于识别内存问题。
  6. 代码审查
    • 仔细检查代码以确保没有存储指向对象的引用,但不再需要这些引用。
  7. 使用静态分析工具
    • 一些静态代码分析工具,如go vetgolint,可以帮助识别潜在的内存泄漏问题。
  8. 监控和测试
    • 集成监控和测试,确保应用程序在长时间运行时不会出现内存泄漏。

要排查内存泄漏,通常需要综合使用多种方法,包括运行时工具、静态分析、代码审查和性能测试。请注意,内存泄漏问题可能会相当复杂,因此可能需要耐心和时间来诊断和解决。

41 Golang的类型转换,怎么将interface类型的数据转为string或者指定类型的结构体?

Go中,要将interface{}类型的数据转换为其他类型,可以使用类型断言(type assertion)来完成。下面是将interface{}类型的数据转换为string和指定类型的结构体的示例:

将interface{}转换为string:

var val interface{} = "Hello, World" // 一个interface{}类型的变量

if str, ok := val.(string); ok {
    // 转换成功,str 现在是一个 string
    fmt.Println(str)
} else {
    // 转换失败
    fmt.Println("转换失败,不是一个字符串")
}

使用类型断言将其转换为string类型。如果转换成功,ok将为true,并且str将包含字符串的值。

interface{}转换为指定类型的结构体:

//结构体
type Person struct {
    Name string
    Age  int
}

//将interface{}类型的数据转换为这个结构体类型
var val interface{} = Person{Name: "Alice", Age: 30} // 一个interface{}类型的变量

if person, ok := val.(Person); ok {
    // 转换成功,person 现在是一个 Person 结构体
    fmt.Println("Name:", person.Name)
    fmt.Println("Age:", person.Age)
} else {
    // 转换失败
    fmt.Println("转换失败,不是一个 Person 结构体")
}

如果转换成功,ok将为true,并且person将包含转换后的结构体。如果转换失败,则okfalse

42 go目前用的是哪个版本,有哪些特点?

Go 1.20.5版本包含了三个安全修复,如下:

  1. cmd/go: cgo代码注入
    当使用cgo时,go命令可能在构建时生成意外的代码,导致运行一个使用cgogo程序时出现意外的行为。这可能发生在运行一个包含有换行符的目录名的不可信模块时。使用go命令获取的模块(即通过"go get")不受影响(使用GOPATH模式获取的模块,即GO111MODULE=off,可能受影响)。issue https://go.dev/issue/60167。
  2. runtime: setuid/setgid二进制文件的意外行为
    Go运行时在一个二进制文件设置setuid/setgid位时没有做任何不同的处理。在Unix平台上,如果一个setuid/setgid二进制文件在执行时标准输入/输出文件描述符被关闭,打开任何文件可能导致以提升的权限读写意外的内容。类似地,如果一个setuid/setgid程序被终止,无论是通过panic还是信号,它可能泄露它的寄存器内容。issue https://go.dev/issue/60272。
  3. cmd/go: LDFLAGS的不恰当处理
    当使用cgo时,go命令可能在构建时执行任意代码。这可能发生在运行"go get"获取一个恶意模块时,或者运行任何其他构建不可信代码的命令时。这可以通过链接器标志触发,通过"#cgo LDFLAGS"指令指定。issues https://go.dev/issue/60305 和 https://go.dev/issue/60306。

43 golang的slice?

然后我比如说现在有一个切片的一个对象,我是不断地扩容,然后可能从最开始的几个小k,然后几个小b,然后随着不断的扩容变成了几个大KB,然后但是这个对象我后来不引用了,或者说我这个对象我觉得它太大了,它 go 的话会有一种机制给它把它的占用的内存变小吗?

答:对于不再使用的切片,垃圾回收机制会自动回收。

44 对Goroutine进行一个读写,然后阻塞了它,逻辑处理器P和操作系统线程M会发生什么样的一个对应的一个操作?

Go中,Goroutine是轻量级的用户态线程,而逻辑处理器PGo运行时调度器的一部分,它负责管理Goroutine的执行。操作系统线程M是运行时的底层执行单元,Go运行时会维护一组M以执行Goroutine

当一个Goroutine发生读写并被阻塞时,以下是可能的情况:

  1. Goroutine阻塞:如果Goroutine需要等待某些资源,例如文件I/O、网络请求、锁等,它会进入阻塞状态,不再执行。此时,该Goroutine不会占用逻辑处理器P的执行时间,以便其他Goroutine可以在该P上运行。
  2. 逻辑处理器P空闲:当Goroutine阻塞时,其所在的逻辑处理器P可能会变为空闲状态,因为没有可执行的任务。Go运行时的调度器将会选择一个新的Goroutine来运行,如果有多个逻辑处理器可用,那么其他逻辑处理器上的Goroutine也会被执行。
  3. 操作系统线程M仍在运行:尽管Goroutine阻塞了,但底层的操作系统线程M通常不会因此而阻塞。Go运行时的调度器会继续管理操作系统线程M,并确保它们在需要时可以运行其他Goroutine
  4. 等待事件通知:当阻塞的Goroutine等待某个事件发生(如文件可读、网络连接成功等),它可能会进入等待事件通知的状态,这是一种非常高效的方式,因为它不会消耗CPU时间,只有在事件发生时才会被唤醒。

总结:
当一个Goroutine阻塞时,逻辑处理器P会寻找其他可运行的Goroutine来填充其空闲时间,而操作系统线程M仍然会保持活动状态以继续执行其他Goroutine。这是Go并发模型的一个关键特点,可以有效地管理大量Goroutine,确保程序在并发执行中高效运行。

45 Golang有哪些数据类型

  1. 布尔型,值只可以是常量truefalse
  2. 数字类型,支持整型和浮点型数字,并且支持复数
  3. 字符串类型,是一串固定长度的字符连接起来的字符序列
  4. 指针类型
  5. 数组类型
  6. 结构化类型
  7. Channel类型
  8. 函数类型
  9. 切片类型
  10. 接口类型
  11. Map类型

46 Golang开发中的几种引号就是单引号、双引号、反引号的区别

  • 单引号在Golang语言中表示Golang中的rune(int32)类型,byte(int8别称),单引号里面是单个字符,对应的值为改字符的ASCII值。
  • 双引号对应数据类型是string,单个字符也是字符串,字符串可以有转义字符,如\n\r\t等。
  • 反引号中的字符表示其原生字符串,在单引号中的内容可以是多行内容,不支持转义。

47 Golang的空结构一般怎么使用

参考:详解 Go 空结构体的 3 种使用场景

Golang语言中的每个值都有一个类型,值的宽度由其类型定义,并且总是8 bits的倍数。借助​​unsafe.Sizeof​​方法,来获取值的宽度。

空结构体在各类系统中频繁出现的原因之一,就是需要一个占位符,空结构体不占据内存空间,即便是变形处理也一样。

使用场景

  1. 实现集合类型
  2. 实现空通道
  3. 实现方法接收者

实现集合类型
Golang语言本身是没有集合类型(Set),通常是使用map来替代。但有个问题:就是集合类型,只需要用到key(键),不需要用到value(值),如果value使用bool来表示,实际会占用1个字节的空间,为了节省空间,这时就可以使用空结构体来替代。

package main

import "fmt"

type Set map[string]struct{}

func (s Set) Append(k string) {
	s[k] = struct{}{}
}

func (s Set) Remove(k string) {
	delete(s, k)
}

func (s Set) Exist(k string) bool {
	_, ok := s[k]
	return ok
}

func main() {
	set := Set{}
	set.Append("煎鱼")
	set.Append("咸鱼")
	set.Append("蒸鱼")
	set.Remove("煎鱼")

	fmt.Println(set.Exist("煎鱼"))
}

实现空通道
Golang语言channel的使用场景中,常常会遇到通知型channel,其不需要发送任何数据,只是用于协调Goroutine的运行,用于流转各类状态或是控制并发情况。

这类情况就特别适合使用空结构体,只做个占位,不浪费内存空间。

package main

func main() {
	ch := make(chan struct{})
	go worker(ch)
	// Send a message to a worker.
	ch <- struct{}{}
	// Receive a message from the worker.
	<-ch
	println("AAA")
	//输出:
	//BBB
	//AAA
}

func worker(ch chan struct{}) {
	// Receive a message from the main program.
	<-ch
	println("BBB")
	// Send a message to the main program.
	close(ch)
}

实现方法接收者
使用结构体类型的变量作为方法接收者,有时结构体可以不包含任何字段属性。这种情况,可以用int或者string来替代,但它们都会占用内存空间,所以使用空结构体是比较合适的。

并且也有利于未来针对该类型进行公共字段等的增加,容易扩展和维护。

package main

import "fmt"

type T struct{}

func methodUse() {
	t := T{}
	t.Print()
	t.Print2()

	//输出:
	//哈哈哈Print
	//哈哈哈Print2
}

func (t T) Print() {
	fmt.Println("哈哈哈Print")
}
func (t T) Print2() {
	fmt.Println("哈哈哈Print2")
}

总结:只用占位不用实际含义,那么我们就都可以使用空结构体,可以极大的节省不必要的内存开销。

48 哪些情况下会使用深度拷贝

  1. 避免共享引用:当你需要确保修改一个数据结构不会影响其他引用该数据结构的地方时,深度拷贝是必要的。如果你只做浅拷贝,多个变量可能会引用相同的底层数据,这样一个地方的修改会影响其他地方。
  2. 数据传递:当你需要将一个数据结构传递给一个函数,并且希望在函数内部对该数据进行修改而不影响原始数据时,你应该使用深度拷贝。否则,传递的数据可能会被多个地方共享。
  3. 数据快照:有时,你可能需要创建一个数据的快照,以记录某个时间点的数据状态。深度拷贝可以用于创建不同时间点的数据副本,以便后续比较或还原数据状态。
  4. 递归数据结构:当你处理递归数据结构,如树或图,深度拷贝通常是必要的,以确保整个结构及其子结构都被复制。
  5. 结构体中包含引用类型:如果一个结构体中包含切片、映射、通道或指针等引用类型,并且你希望复制整个结构体以及其引用的数据,而不是仅复制引用,那么深度拷贝是必要的。

在Go中,深度拷贝可以通过自定义函数来实现,遍历数据结构并创建一个完全独立的副本。有一些第三方库,如github.com/mohae/deepcopy,也提供了深度拷贝的实现。需要注意的是,深度拷贝可能会涉及到性能和内存开销,因此在使用时需要谨慎考虑。

49 Golang反射的运行速度怎么样

Go的反射(Reflection)机制提供了在运行时动态检查和操作变量、类型和结构的能力。虽然反射是一项非常强大的功能,但它通常比静态类型检查和编译时优化的代码要慢。这是因为反射需要在运行时进行类型检查和操作,而这些操作会引入额外的开销。

具体来说,Go的反射速度相对较慢主要有以下原因:

  1. 动态类型检查:反射需要在运行时进行类型检查,以确定变量的类型。这会导致额外的性能开销,因为在编译时无法进行类型优化。
  2. 间接访问:通过反射,你需要通过接口类型来访问变量的值,这通常涉及到额外的间接访问,会增加访问变量值的时间成本。
  3. 运行时分配和释放内存:反射通常涉及在运行时分配内存来存储反射对象的信息,这会增加内存分配和垃圾回收的负担。
  4. 编译器优化受限Go的编译器无法在反射代码上执行像静态代码一样的优化,因为反射的行为在编译时是未知的。

尽管反射的性能较低,但在某些情况下仍然是有用的,例如编写通用库、处理未知类型的数据、实现ORM(对象关系映射)等。然而,应该避免不必要的反射操作,尤其是在性能敏感的代码中。

如果性能是关键问题,应该尽量避免过度使用反射,而是在编译时尽可能静态类型检查和优化你的代码。在Go中,反射通常被视为最后的选择,仅在必要时才使用它。

50 项目需要做代码覆盖测试吗

Go中,可以使用内置的测试工具和go test命令来执行代码覆盖测试。代码覆盖测试可以帮助确定测试用例是否覆盖了代码库中的所有代码路径。以下是如何进行代码覆盖测试的步骤:

  1. 编写测试用例:首先,编写测试用例,通常在与被测试的代码文件相同的目录中创建一个以 _test.go 结尾的文件,并编写测试函数。确保测试覆盖到要测试的各个功能和代码路径。
  2. 启用代码覆盖率分析:在测试文件中,可以使用testing包提供的Cover函数来启用代码覆盖率分析。例如:
import "testing"
import "testing/quick"

func TestMyFunction(t *testing.T) {
    // 启用代码覆盖率分析
    c := testing.Cover(func() { myFunctionToTest() })

    // 运行测试用例
    if testing.Short() {
        t.Skip("skipping test in short mode.")
    }

    // 运行快速检查测试
    quick.Check(t, c, nil)
}
  1. 运行代码覆盖测试:使用go test命令来运行代码覆盖测试。确保使用-cover标志来启用代码覆盖测试:
go test -cover

运行测试后,Go将生成一份代码覆盖率报告,显示哪些代码路径被测试覆盖,哪些没有。
4. 查看代码覆盖率报告go test命令运行后,会生成代码覆盖率报告,显示测试覆盖的代码行和百分比。可以查看报告,以确定哪些代码路径没有被覆盖,需要进一步的测试。

代码覆盖测试是一种重要的测试工具,可以帮助发现未被测试的代码路径和潜在的问题。它可以在开发周期中定期运行,以确保代码库的稳健性。同时,还可以使用一些工具来生成更详细的代码覆盖率报告,如go tool cover。这些工具可以帮助更好地理解测试覆盖情况。

51 Golang里面的逃逸分析是什么?

Go语言中,逃逸分析(Escape Analysis)是一种编译器优化技术,用于决定一个变量是否逃逸到堆上分配内存,或者可以在栈上分配。
逃逸分析的目标是尽可能地在栈上分配内存,因为在栈上分配内存比在堆上分配更加高效。栈上分配内存通常比堆上分配更快,因为它只是简单的移动栈指针,而不需要进行垃圾回收。

以下是逃逸分析的一些基本概念和规则:

  1. 栈上分配:如果一个变量在函数内部定义,并且在函数结束后不会被引用(即不会逃逸到函数外部),那么编译器会尝试在栈上分配内存,而不是在堆上。
  2. 逃逸到堆:如果一个变量在函数内部定义,但在函数结束后仍然被引用(逃逸到函数外部),那么编译器可能会决定在堆上分配内存,以保证在函数返回后变量仍然可用。

逃逸分析的结果对于编译器的性能优化和程序的运行时行为有重要影响。通过减少对堆的使用,可以提高程序的性能,减小垃圾回收的压力。

Go语言中,可以使用-gcflags标志来查看逃逸分析的结果,例如:

go build -gcflags="-m"

上述命令会输出关于逃逸分析的详细信息,包括哪些变量逃逸到堆上。这对于优化代码、理解代码的内存使用和性能分析都是有帮助的。

52 Go语言中内存逃逸是什么,产生内存逃逸的原因是什么?

在Go语言中,内存逃逸(memory escape)是指在函数中分配的变量或对象被函数外部引用,从而导致这些变量或对象在堆上分配而不是在栈上。在Go中,栈上分配的变量有更短的生命周期,而堆上分配的变量则可能存活得更久。

产生内存逃逸的主要原因是编译器无法保证变量的生命周期仅限于函数的执行期间,因此将其分配到栈上可能导致在函数返回后仍然被引用。在这种情况下,编译器会将变量分配到堆上,以确保其在函数返回后仍然有效。

  1. 函数返回指针:当一个函数返回一个局部变量的指针时,这个指针可能被保存到函数外部,导致变量逃逸到堆上。
    func createObject() *Object {
        obj := Object{} // 局部变量
        return &obj     // 返回局部变量的指针
    }
    
  2. 闭包:当一个函数返回一个闭包,并且该闭包引用了外部函数的局部变量时,这些变量可能逃逸到堆上。
    func closureExample() func() int {
        x := 0
        return func() int {
            x++
            return x
        }
    }
    
  3. 并发:在并发程序中,如果一个goroutine分配的变量被其他goroutine访问,那么这个变量可能逃逸到堆上。
    func concurrentExample() {
        var data []int
        go func() {
            data = make([]int, 100)
            // 修改 data
        }()
        // 在其他 goroutine 中使用 data
    }
    
  4. 接口:当变量被分配给一个接口类型并被传递到函数外部时,它可能逃逸到堆上。
    func interfaceExample() interface{} {
        obj := Object{} // 局部变量
        return obj      // 返回局部变量的接口类型
    }
    

编译器通过逃逸分析来判断是否将变量分配到堆上,以及何时将其分配到堆上。Go语言的编译器在编译时会进行逃逸分析,以尽可能减少堆分配,提高性能。然而,了解内存逃逸的原因仍然对于理解代码的性能特征和调试性能问题是有帮助的。

53 Golang里面的函数,返回一个局部变量,这种是正常的吗?会不会有问题?

Go语言中,返回局部变量的函数是正常的,并且不会导致内存安全问题。Go的编译器和运行时系统在处理这种情况时会进行逃逸分析,以确定是否需要在堆上分配内存。

如果一个局部变量没有逃逸到函数外部,即没有被函数外的代码引用,编译器会进行栈上分配。栈上分配内存是一种非常高效的方式,因为它只涉及移动栈指针,而不需要进行垃圾回收。局部变量在函数返回时会被自动销毁,不需要程序员手动释放。

以下是一个例子:

package main

import "fmt"

func localVariable() int {
    // x 是局部变量,会在函数返回时自动销毁
    x := 42
    return x
}

func main() {
    result := localVariable()
    fmt.Println(result)
}

在这个例子中,localVariable函数返回一个局部变量x。由于x没有逃逸到函数外部,因此编译器可以选择在栈上分配内存。

总体来说,Go语言的设计和编译器优化使得返回局部变量是安全而高效的。在编写代码时,可以专注于代码的清晰性和可读性,而不必过度关心内存管理的细节。

54 Golang用过哪些锁?阻塞锁、饥饿锁?

Go语言提供了几种锁的实现,其中包括阻塞锁、饥饿锁等。以下是Go语言中常见的锁的种类:

  1. Mutex(互斥锁):sync.Mutex是Go语言中最基本的锁,用于保护共享资源,防止多个goroutine同时访问。它是一个阻塞锁,当一个goroutine获得锁后,其他 goroutine将被阻塞直到该goroutine释放锁。
    var mu sync.Mutex
    
    // 加锁
    mu.Lock()
    // 操作共享资源
    // 解锁
    mu.Unlock()
    
  2. RWMutex(读写锁):sync.RWMutex是一个读写锁,它允许多个goroutine同时读取共享资源,但只有一个goroutine能够写入。在读锁定时,其他读操作可以同时进行,但写操作会被阻塞。在写锁定时,所有读和写操作都会被阻塞。
    var rwmu sync.RWMutex
    
    // 读锁定
    rwmu.RLock()
    // 读取共享资源
    // 读解锁
    rwmu.RUnlock()
    
    // 写锁定
    rwmu.Lock()
    // 写入共享资源
    // 写解锁
    rwmu.Unlock()
    
  3. Once(一次性执行):sync.Once提供了一种确保某个操作只执行一次的机制。它可以用于初始化操作,确保在多个goroutine中只执行一次。
    var once sync.Once
    
    func initialize() {
        // 初始化操作
    }
    
    // 多个 goroutine 调用 initialize 函数,但只有第一个调用的 goroutine 会执行初始化操作
    once.Do(initialize)
    
  4. WaitGroup(等待组):sync.WaitGroup用于等待一组goroutine完成执行。它提供了Add、Done和Wait方法来管理计数器,确保在所有goroutine完成执行之前保持阻塞。
    var wg sync.WaitGroup
    
    // 添加一个 goroutine 到等待组
    wg.Add(1)
    
    go func() {
        defer wg.Done()
        // 执行操作
    }()
    
    // 等待所有 goroutine 完成
    wg.Wait()
    

这些锁和同步机制可以帮助控制并发访问共享资源的行为。Go语言的标准库提供了丰富的同步工具,开发者可以根据具体的场景选择适当的锁。在选择锁时,需要根据具体的需求考虑性能、可伸缩性以及代码复杂度。

55 在Go里面针对一个全局变量,除了加锁还有什么方式可以防止它被篡改?

回答:sync包里的,全局安全的map,context、channel。

除了使用锁来保护全局变量外,Go 语言还提供了其他一些机制来防止全局变量被篡改。以下是其中一些常用的方式:

  1. 原子操作:Go 语言的 sync/atomic 包提供了原子操作函数,如 Add, CompareAndSwap,可以在不使用锁的情况下实现对共享变量的原子操作。这些原子操作是以硬件原语实现的,能够确保在单一的操作中完成读取和写入。
    import "sync/atomic"
    
    var counter int64
    
    func increment() {
        atomic.AddInt64(&counter, 1)
    }
    
    这样的原子操作通常比锁的开销小,但它们仅适用于一些基本的操作,如加法、减法、比较和交换等。
  2. 读写锁(sync.RWMutex):如果对于全局变量的读操作比写操作频繁,你可以考虑使用读写锁,以允许多个 goroutine 并发地读取数据,而写操作仍然是互斥的。这可以提高程序的性能。
    import "sync"
    
    var (
        mu    sync.RWMutex
        value int
    )
    
    func readValue() int {
        mu.RLock()
        defer mu.RUnlock()
        return value
    }
    
    func writeValue(newValue int) {
        mu.Lock()
        defer mu.Unlock()
        value = newValue
    }
    
  3. 不可变对象:如果可能的话,可以通过创建不可变对象(Immutable Object)来避免全局变量的篡改。不可变对象一旦创建,其状态就不能再被修改。这种方式适用于某些场景,但并非适用于所有情况。
    type ImmutableData struct {
        Field1 int
        Field2 string
    }
    
    var globalData = ImmutableData{Field1: 42, Field2: "Hello"}
    
    // 读取全局变量
    value1 := globalData.Field1
    value2 := globalData.Field2
    
    // globalData 是不可变的,不能修改其字段值
    

这些方法可以根据具体的场景和需求选择使用。在设计时,要根据并发性能、数据的访问模式以及代码的复杂性等因素权衡选择适当的保护机制。

56 Golang你们比较常用的包有哪些?

回答:bytes、context、database、encoding、flag、fmt、go、html、io、log、math、net、os、path、reflect、regexp、runtime、sort、strings、sync、testing、time。

  • 7
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值