Skip to main content

Go 题库

基础概念

如何理解协程

协程(Goroutine)是用户态轻量级线程。相比于线程,它的调度完全在用户态,所以会大大减少系统调度的开销。

在Go中,只需要使用 go 关键字就可以快速启动一个协程。

一个 Goroutine 会以一个很小的栈启动,大概只需要花费 2~4KB 的内存,因此可以轻易实现成千上万个 Goroutine 同时启动。


Go解析Tag原理?反射原理?

解析 Tag 通过反射。

反射是在程序运行时检查对象属性的一种机制。包括对属性的查看、赋值等。像标签也是属性的一种。 在Go中,我们可以通过reflect库实现反射操作。


什么是枚举

iota 是一个常量生成器,通常用于定义枚举值。默认从0开始

const (
B = 1 << (10 * iota)
KiB
MiB
GiB
TiB
PiB
EiB
)

介绍下 rune 类型

rune 类型是 int32 的别名,通常用于处理 unicode 字符集 和 utf8 字符编码。

  • ascii 仅支持表示英文和特殊字符
  • unicode 是一个全量的字符集
  • utf8 是一种编码方案,而且是一种可变长度的编码

字符串在 Go 中的底层数据存储是基于 []byte,当和普通的字节切片不同,Go 提供了语言级别的保护。 确保了 string 的不可变类型和正确的 UTF-8 编码。


var、new、make的区别

在 Go 语言中,varnewmake 有不同的用途,用于声明和初始化不同类型的变量。下面是它们的主要区别:

  1. var用于声明变量。

    • 对于基本数据类型(int、float64、bool 等),var 会声明变量并初始化为零值(zero value)。
    • 对于复合数据类型(如数组、结构体、map、slice 等),var 会声明变量但不进行初始化,复合数据类型会被初始化为 nil 或零值。
    var x int         // x 是 int 类型,初始值为 0
    var y string // y 是 string 类型,初始值为空字符串 ""
    var z []int // z 是切片类型,初始值为 nil
  2. new用于创建指定类型的新实例,并返回该实例的指针。

    • 对于基本数据类型,new 返回指向零值的指针。
    • 对于复合数据类型,new 返回指向零值或 nil 的指针。
    px := new(int)    // px 是指向 int 的指针,指向的值为 0
    ps := new(string) // ps 是指向 string 的指针,指向的值为空字符串 ""
    pz := new([]int) // pz 是指向切片的指针,指向的值为 nil
  3. make用于创建并初始化引用类型的实例,如切片、映射、通道等。

    • make 返回引用类型的实例,而不是指针。
    • make 仅用于创建 slice、map 和 channel,不能用于其他类型。
      • make(map[string]string, 10):初始容量为10
      • make([]string, 10, 20):长度为10,容量为20
      • make(chan string, 10):容量为10
    s := make([]int, 5)      // s 是切片,长度为 5,容量为 5
    m := make(map[string]int) // m 是 map,已初始化并可用
    c := make(chan int) // c 是 channel,已初始化并可用

总的来说:

  • var 用于声明变量,不进行初始化,零值或 nil 值取决于变量类型。
  • new 用于创建指定类型的新实例,并返回指针,零值或 nil 值取决于变量类型。
  • make 用于创建并初始化引用类型的实例,返回实例本身。

值传递和引用传递

  1. 值传递(Pass by Value):

    • 在值传递中,函数接收的是实际参数的副本。
    • 当在函数内部修改参数的值时,不会影响到原始数据。
  2. 引用传递(Pass by Reference):

    • 在引用传递中,函数接收的是实际参数的地址(指针)。
    • 当在函数内部通过指针修改参数指向的值时,会影响到原始数据。
    • 切片、映射和通道等引用类型在函数调用时通常采用引用传递。

Go语言中没有显式的引用传递。即使使用了引用类型,仍然是通过值传递传递的指针。


GC机制

GC是一种自动管理程序内存的机制。主要作用就是释放不在使用的对象,从而使得内存可以重复利用。

不同编程语言,GC机制的实现方式往往不同,但是他们的目的和难点基本都是一样的。 目的就是释放不再使用的对象,难点就是如何减少对业务进程的影响。在Go中主要就是STW会对业务进程造成较大的影响。

Go的GC机制使用的就是三色标记法-清除。他把一次完整的GC分为标记和清除两个阶段。三色表示的就是黑白灰三种颜色。 其中,白色表示未知对象,灰色表示该对象存活,但子对象未知。黑色表示该对象确认存活。

具体Go版本的GC机制我记得不是很清楚,但是大致趋势我记得:

  • 早期是并发的标记和STW的清除,由于STW对性能影响过大。
  • 因此下一个版本引入了增量GC,其实就是把一次完成的GC拆分成了多个阶段实现, 从而减少STW时间,使程序更加平稳。但STW问题并没有解决。
  • 然后是1.5版本引入了并发的标记和并发的清除,显著的减少了STW时间。
  • 1.8版本对并发标记清除做了整合,进一步优化了STW问题。

Go 1.1及之前版本:

  • 标记阶段:引入了并发标记,使得标记过程可以与程序的执行并发进行。这意味着垃圾回收器可以在不暂停整个程序的情况下标记不再使用的对象。

  • 清理阶段:清理阶段是STW的,整个程序的执行都会被暂停。在这个阶段,垃圾回收器会扫描堆上的对象,清理并回收不再使用的对象。

Go 1.2及之后版本:

  • 增量垃圾回收:引入了增量垃圾回收,将标记和清理过程分解成多个小步骤,每个步骤都在程序执行中交替进行。这样可以减小STW的时间,提高程序的响应性。

  • 读写屏障:引入了读写屏障,用于确保在并发标记的过程中,对对象的读取和写入是安全的。

Go 1.5及之后版本:

  • 并发垃圾回收:引入了并发垃圾回收,标记和清理过程都可以与业务进程并发执行。这意味着整个垃圾回收过程都不再需要STW,减小了对程序执行的干扰。

  • GOGC参数:引入了GOGC参数,通过调整这个参数,开发者可以影响垃圾回收的频率,以更好地适应不同类型的应用程序。

  • 并发标记和清理:标记和清理过程的并发执行大大提高了垃圾回收的性能和响应性。

Go 1.8及之后版本:

  • 整合并发标记和清理:引入了基于并发标记-清除和并发清理的垃圾回收器。标记和清理的整个过程都是并发执行的,没有明显的STW阶段。

  • 性能提升:通过整体性能的提升,进一步降低了对程序执行的影响。


GMP模型

Go 的 GMP 模型是一种用于实现并发的模型。它包含三种不同的角色:

  • G(Goroutine):Goroutine 是 Go 语言中的轻量级线程,也是此并发模型中的最小调度单位。
  • M(Machine):Machine 是 Go 语言中的执行线程。它负责运行 Goroutine。
  • P(Processor):Processor 是 Go 语言中的处理器。它负责管理一组 M 并将 G 分配给它们运行。每一个 P 都维护有本地队列,此外还共享一个全局队列。
    • 本地队列:当前 P 独享,用于存储当前正在执行或等待执行的 G。
    • 全局队列:所有 P 共享,用于存储等待执行的 G。

个人认为 GMP 模型的最大亮点,是在用户态完成了所有任务的调度,相比较于多线程模型,大大减少了内核态线程调度的开销。

创建一个协程

  1. 通过 go 关键字创建一个协程,它会被放入当前线程的本地队列
  2. 当本地队列满了时,会取本地队列前一半的协程任务丢到全局队列中。

执行一个协程

  1. M 和 P 相互绑定,从 P 的本地队列获取一个协程任务执行
  2. 当本地队列为空时,会从全局队列获取协程任务执行
  3. 当全局队列为空时,会从其他 P 偷取(work stealing)一定协程任务执行

协程阻塞

  1. 当协程发生IO阻塞时,阻塞中的 M 会主动让出,让 P 可以和空闲 M 绑定执行任务
  2. 当阻塞中的 M 完成IO操作后,会尝试和之前的 P 获取关联,如果无法获取,则将任务放到全局队列

抢占机制

  • hand off(协作式抢占):遇到阻塞时主动让出
  • 时间片:调度器会在某个 goroutine 运行的时间片用完时,发生强制抢占,一般是10ms


切片(slice)

如何申明和初始化一个切片

var tmp []string
tmp = new([]string)
tmp = make([]string, 0, 10)
tmp := []string{} // 简短变量申明

切片和数组有什么区别

对于数组来说:

  • 数组长度是固定的,长度就是它的属性之一,定义后不可更改
  • 数组属于值类型,赋值或者作为参数传递后,不会影响原来的数据

对于切片来说

  • 切片长度是动态的,容量不足时可以扩容
  • 切片属于引用类型,赋值或者作为参数传递后,会影响原来的数据

切片扩容机制

Go1.18 之前切片的扩容是以 1024 为临界点,即:

  • 当容量小于 1024 时,扩容一倍。
  • 当容量大于 1024 时,扩容四分之一。

Go1.18开始引入了更平滑的机制。

切片底层数据结构

在 Go 语言中,切片是对数组的一个动态视图,它由三个部分组成:指向底层数组的指针、切片的长度和切片的容量。切片的底层数据结构可以表示为:

type slice struct {
array unsafe.Pointer
len int
cap int
}
  • array: 一个指针,指向底层数组的起始地址。
  • len: 切片的长度,即切片中实际存储的元素数量。
  • cap: 切片的容量,即底层数组中从切片起始位置到底层数组末尾的元素数量。

对未被初始化的slice进行append会如何?

对一个未被初始化的切片使用 append 是可以成功的。Go 中的 append 函数可以用于向切片中添加元素,而且它对于未被初始化的切片也是安全的。当切片为 nil 时,append 会创建一个新的切片,并将元素添加到其中。

例如:

var mySlice []int

func main() {
// 对未被初始化的切片使用 append
mySlice = append(mySlice, 42)
fmt.Println(mySlice) // 输出: [42]
}

在上述示例中,mySlice 是一个未被初始化的切片,但通过使用 append,我们成功地向其中添加了元素。这是因为 append 在处理 nil 切片时会自动创建一个新的底层数组,并将元素追加到其中。

请注意,虽然 append 可以对未被初始化的切片进行添加元素操作,但对未被初始化的切片进行索引访问仍然会导致 panic。例如,mySlice[0] 会导致运行时 panic,因为切片中并没有元素。


映射(map)

Go 里面使用 Map 时应注意问题?

首先在初始化的时候,需要注意,因为 map 的零值是一个 nil,因此使用前需要确保 map 不是一个 nil 避免 panic。
然后在查询映射时,读取一个不存在的键时,Go 语言会返回该类型的零值,为了避免造成困惑,需要使用一个额外的参数用于检查该值是否存在。
在 map 的使用过程中,需要注意,他不是并发安全的,会 panic。并且这个panic 是无法被 recover 捕获的

为什么 map 的 panic 无法被 recover 捕获呢

recover一般用于延迟函数中,获取程序运行时的panic。

当并发访问非并发安全的map时可能导致竞态条件,这种错误在go中是致命级别的,无法被recover捕获。 此时系统将抛出:fatal error: concurrent map writes致命错误。

如何申明和初始化一个map

var tmp map[string]string
tmp = new(map[string]string)
tmp = make(map[string]string)
tmp := map[string]string{} // 简短变量申明

如何删除map中的元素

delete(myMap, "b")

Map 扩容是怎么做的?

在Go语言中,map的扩容是通过重新哈希(rehashing)和重新分配内存来实现的。当map中的元素数量达到一定阈值(负载因子)时,Go语言会触发map的扩容操作,以保持性能和内存使用的平衡。

map的负载因子是指已存储元素数量与哈希表桶数量的比值。当负载因子超过某个阈值时,Go会触发map的扩容操作,即增加哈希表的桶数量,并将已有的元素重新分配到新的桶中。

以下是map扩容的主要步骤:

  1. 创建新的哈希表: 新的哈希表的桶数量通常会扩大为当前的两倍。这个新的桶数量将成为新哈希表的大小。

  2. 重新哈希: 对于每个已存在的键值对,都会计算新哈希表中的新桶索引,并将其移到新的桶中。这个过程称为重新哈希。

  3. 切换哈希表: 当所有键值对都被重新分配后,map会切换到新的哈希表,并释放旧的哈希表的内存。

Map 的 panic 能被 recover 掉吗?了解 panic 和 recover 的机制吗?

在Go语言中,panicrecover是用于处理程序运行时错误的机制。

  • panic: panic是一个内建的函数,用于表示发生了严重错误,导致程序无法继续正常执行。当panic被调用时,程序会立即停止执行当前函数的代码,开始沿调用堆栈向上执行所有的延迟函数(defer语句)。如果没有被捕获(recover), 程序将会退出,并打印出panic的信息。
  • recover: recover也是一个内建函数,用于从panic中恢复。它只能在延迟函数中使用,并且返回panic调用传递的错误值。如果在延迟函数中调用了recover,那么程序不会因为panic而退出,而是会继续执行后续的代码。

即程序主动 panic 是会调用 延迟函数的。但是如果 map 因为并发查询导致的 panic,属于运行时的错误,会导致程序直接崩溃。

Map 怎么知道自己处于竞争状态?是 Go 编码实现的还是底层硬件实现的?

这是go系统级别的一些检测机制。 是实际开发中,我们也可以通过竞态检测器去提前检查可能存在的竟态场景。可以通过 -race 启动。

sync.Map 的底层结构

在1.21源码中,sync.Map 包含锁、实体数据、脏数据,查询未命中次数。

一次Load过程会先从实体数据中查询,因为实体是只读的,索引不会存在竞态场景。若命中直接返回。 如果实体没有命中,且存在脏页,则在脏数据中查询。返回命中结果,并尝试刷新脏页。刷新机制,查询未命中次数大于脏数据时,会触发脏数据写回。

sync.Map 和加锁的有什么区别?

sync.Map和显式加锁的map之间有一些关键的区别。以下是它们之间的对比:

  1. 并发安全性:
    • sync.Map sync.Map是Go语言提供的一种并发安全的映射类型。它内部使用了一些复杂的算法,允许在并发读写时不使用显式锁,从而提高性能。
    • 加锁的map 使用互斥锁来保护对map的并发读写操作。在每次访问map时,需要先获取锁,执行完操作后再释放锁。这样可以确保并发安全,但可能导致性能瓶颈,特别是在高并发环境下。
  2. 性能:
    • sync.Map 由于内部采用了一些优化手段,sync.Map在读多写少的情况下通常比加锁的map更高效。对于大量并发读操作,sync.Map的性能较好。
    • 加锁的map 每次对map的读写都需要获取和释放锁,这可能导致并发度不高,性能较低,尤其在高并发的情况下。
  3. 使用方式:
    • sync.Map sync.Map提供了LoadStoreLoadOrStoreDelete等方法来进行映射操作,不需要显式的加锁和解锁。
    • 加锁的map 需要在访问map之前使用sync.Mutex或其他同步机制进行加锁,然后在操作完成后解锁。
  4. 复杂性:
    • sync.Map 使用起来较为简单,不需要开发者自己管理锁。
    • 加锁的map 需要开发者自己负责加锁和解锁,容易出现错误,例如忘记解锁可能导致死锁等问题。
  5. 功能差异:
    • sync.Map 提供了一些特殊的功能,如Range用于遍历映射中的所有键值对。
    • 加锁的map 需要开发者自行实现类似的功能。

总体而言,sync.Map适用于读多写少、并发度较高的场景,而加锁的map适用于简单的并发控制需求或者并发度较低的情况。选择使用哪一种取决于具体的使用场景和性能要求。

sync.Map常用于什么场景

  1. 读多写少
  2. 在多协程并发处理时,操作key不相交

这样可以大量减少并发冲突问题

实际使用 sync.Map 的时候碰到过什么问题?

功能有限,连最基础的容量计数也不支持。

在实际使用 sync.Map 时,一些开发者可能会遇到以下问题:

  1. 功能的局限性: sync.Map 相对于传统的 map 来说,功能相对较少。它不支持对整个映射的加锁或解锁,也没有提供像计数器或者一些其他的高级功能。如果应用场景需要这些功能,可能需要考虑其他数据结构或手动管理锁。

map底层原理

map底层原理基本都是基于哈希表实现的。他底层是一个数组,每一个元素都可以叫做哈希桶。 当操作map的时候,会先对key值进行哈希计算,并基于桶大小,可以通过类似取余算法唯一映射到一个桶中。从而实现快速定位。

在map中有一个特别的点需要注意,就是哈希冲突。当不同的key映射到同一个哈希桶中,就是哈希冲突。 这时可以通过拉出一个链表来解决。当链表长度过长的时候,就需要rehash重新申请更大的哈希表。


通道

有缓存和无缓存通道

无缓冲通道,是同步通信的。有缓冲通道,支持一定容量的异步通信。

对已经关闭的chan进行读写会怎么样

对已经关闭的通道(chan)进行读取操作是安全的,它会返回通道元素的零值(对于大多数数据类型是零值)。
对已经关闭的通道进行写入操作会引发 panic

channel 底层数据结构是怎样的,尝试用结构体来表述一下?

在 Go 中,chan(通道)是一种用于在 goroutine 之间进行通信和同步的数据结构。chan 的底层实现涉及到一些复杂的数据结构和同步机制。

数据结构

chan 的底层数据结构由 hchan 表示,定义如下:

type hchan struct {
qcount uint // 等待接收的元素数量
dataqsiz uint // 通道容量
buf unsafe.Pointer // 指向环形缓冲区的指针
elemsize uint16 // 元素大小
closed uint32 // 标记通道是否已关闭
elemtype *_type // 元素类型
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 接收等待队列
sendq waitq // 发送等待队列
lock mutex // 用于保护通道的互斥锁
}
  • qcount: 表示当前等待接收的元素数量。
  • dataqsiz: 表示通道容量。
  • buf: 指向环形缓冲区的指针,用于存储通道中的元素。
  • elemsize: 表示元素的大小。
  • closed: 用于标记通道是否已关闭。
  • elemtype: 元素类型。
  • sendx: 发送索引。
  • recvx: 接收索引。
  • recvq: 接收等待队列,用于存储等待接收的 goroutine。
  • sendq: 发送等待队列,用于存储等待发送的 goroutine。
  • lock: 用于保护通道的互斥锁。

发送操作

  1. 锁定通道: 如果通道是非缓冲的或者缓冲区已满,发送操作将会阻塞,等待接收者或者缓冲区可用。在此期间,goroutine 会加锁,以防止其他 goroutine 对通道进行并发修改。
  2. 判断通道是否关闭: 如果通道已关闭,则发送操作将会导致 panic。
  3. 将元素写入缓冲区: 如果通道是缓冲的,将元素写入缓冲区的合适位置,并更新 sendx 索引。
  4. 唤醒接收者: 如果有等待接收的 goroutine,唤醒其中一个。
  5. 解锁通道: 解锁通道,允许其他 goroutine 进行发送或接收操作。

接收操作

  1. 锁定通道: 如果通道是非缓冲的或者缓冲区为空,接收操作将会阻塞,等待发送者或者缓冲区有数据可用。在此期间,goroutine 会加锁,以防止其他 goroutine 对通道进行并发修改。
  2. 判断通道是否关闭: 如果通道已关闭且缓冲区为空,接收操作将返回通道类型的零值。
  3. 从缓冲区读取元素: 如果通道是缓冲的,从缓冲区的合适位置读取元素,并更新 recvx 索引。
  4. 唤醒发送者: 如果有等待发送的 goroutine,唤醒其中一个。
  5. 解锁通道: 解锁通道,允许其他 goroutine 进行发送或接收操作。

关闭操作

  1. 加锁: 执行关闭操作前,需要加锁以确保不会有并发操作。
  2. 标记通道为已关闭: 设置 closed 标志。
  3. 唤醒等待的 goroutine: 如果有等待接收或发送的 goroutine,唤醒它们。
  4. 解锁: 解锁通道。

字符串

单引号,双引号,反引号的区别

在 Go 中,单引号(')、双引号(")和反引号(`)分别用于表示字符、字符串字面值和原始字符串字面值,它们在用法和含义上有不同的区别:

  • 单引号用一个整数值(通常是 rune 类型)。例如,'A' 表示字符 'A',而不是字符串 "A"。
  • 双引号表示字符串。会对字符串内容进行转移。
  • 反引号表示多行字符串。不会对字符串内容进行转移。

如何高效地拼接字符串

strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf

  • strings.Builder基于指针与切片,直接把[]byte转换为string,从而避免变量拷贝
  • strings.Join:基于strings.Builder,且优化了切片空间性能更高。
  • +:会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
  • fmt.Sprintf基于反射实现,有一定性能损耗。

fmt

结构体如何输出日志

  • %v:输出结构体各成员的值
  • %+v:输出结构体各成员的名称和值
  • %#v:输出结构体名称和结构体各成员的名称与值

struct

空struct有什么用

占位符

类型转化与断言

对于一个 interface 类型数据,我们可以通过 interface.(type) 进行类型转化。 这种属于强制类型转化,转化不同类型值时是会报错的,所以还支持 _, ok = interface.(type) 返回两个参数, 第二个参数是布尔类型,用于判断是否正确转化类型。

对于一个 struct 结构,我们可以通过定义一个 interface 空值来声明转化类型。一般用于判断结构体是否实现 interface 接口。

type Interface interface{}

type Struct struct {}

var _ Interface = (*Struct)(nil)


测试和性能优化

内存泄露和协程泄露

什么场景会导致内存泄露

Go 是一种自带内存管理(GC)机制的编程语言,这意味着通常我们不需要手动管理内存。

然而,仍然有些情况会导致内存泄露。一般内存泄露场景大概率是资源管理不当,或者算法实现导致的,比如:

  • 没有释放资源:例如打开文件、网络连接等资源,但没有及时的关闭。那这些资源会一直占用内存,进而导致内存泄露。
  • 大量字符串拼接:在 Go 中字符串是不可变对象。因此每次对字符串进行拼接操作都会创建一个新的字符串。一旦在某个循环中进行大量字符串拼接操作,将创建大量临时字符串,可能会导致内存泄露。一般通过 string.Builder 解决。

什么场景会导致协程泄露

Go 协程泄露是指协程启动后,无法被及时正确地关闭或释放,从而导致内存泄露或其他资源泄露问题。

以下是一些可能导致 Go 协程泄露的场景:

  • 协程无法正常退出:当一个协程被启动后,如果没有被正确关闭或没有在适当的时候退出,那么该协程会一直存在并占据资源,进而导致资源泄露。
  • 协程之间的循环引用:当多个协程之间形成循环引用时,它们之间的关系可能导致无法释放相关资源,从而导致泄露。
  • 网络操作超时或阻塞:当一个协程执行一个网络操作时,如果该操作超时或被阻塞,那么该协程可能会一直存在,导致泄露。

什么是逃逸分析

逃逸分析用于确定一个变量是分配在栈上还是堆上。 这个分析是为了优化内存分配和性能,以确保在不需要的情况下不会分配不必要的堆内存,从而降低垃圾回收的负担。

Go 语言的逃逸分析可以通过在编译时添加 -gcflags=-m 标志来启用。 eg:go build -gcflags '-m -m -l' xxx.go

一般逃逸的场景有:

  • 变量大小不确定
  • 变量类型不确定
  • 变量分配内存超过用户栈最大值
  • 暴露给了外部指针

工具

trace

trace 包提供了一种轻量级的跟踪工具,可以用来分析程序的执行流程。使用 go tool trace trace.out 来可视化分析程序的执行。

package main

import (
"os"
"runtime/trace"
)

func main() {
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()

err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

// 你的代码逻辑
}

pprof

pprof 包提供了性能分析工具,可以用于查看 CPU 和内存使用情况。

web服务在线查看
package main

import (
"net/http"
_ "net/http/pprof"
)

func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()

// 你的代码逻辑
}
文件记录
package main

import (
"os"
"runtime/pprof"
)

func main() {
f, err := os.Create("cpu.pprof")
if err != nil {
panic(err)
}
defer f.Close()

pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()

// 你的代码逻辑
}

cover

用于代码覆盖率分析

# 运行测试并生成覆盖率文件
go test -coverprofile=coverage.out

# 查看覆盖率报告(在浏览器中打开)
go tool cover -html=coverage.out

坑点收录

坑点-中文长度计算

在 Go 中,字符串底层使用的 byte 序列存储,而 byte 仅占据一个字节,一些中文字符则需要占据 2~4 个字节。 故我们直接通过 len(string) 函数计算一个字符串长度时,会得到意想不到的结果,因为实际统计的是 []byte 的长度。 因为字符串的长度是以字节数来计算的,而不是字符数。

而rune是int32的别名,长度4个字节。对于包含中文的字符串,统计方法有:

  • 转化成 []rune 类型在计算。
  • 使用 utf8.RuneCountInString(string) 来获取长度。

坑点-slice

slice 底层是一个数组,在发送扩容前,切片会复用同一个底层数组,所以可能存在多个切片相互修改数据的坑。


坑点-defer+修改返回值

defer 是 Go 的一大特色,也叫延迟函数。其特(坑)点有:

  • 后进先出
  • 入参在执行时确定
  • 具名参数,可以被延迟函数修改
    • 对于非具名返回,会创建临时变量存储结果
    • 对于具名参数返回,则不会创建临时变量

什么情况下会修改返回值:

  • 具名返回
  • 指针返回

坑点-uint 类型溢出

在 Go 中,溢出不会引发运行时错误或异常,而是会导致结果截断或循环。

以 uint8 为例,它的值范围是0-255,当值超过255之后,会发生一些意料之外的事情。

package main

import "fmt"

func main() {
var u uint8 = 255
fmt.Println(u) // 输出 255

u = u + 1
fmt.Println(u) // 输出 0(溢出了)
}

坑点-for range

note

for range 地址不会发生变化。在循环遍历时,是以值覆盖的形式实现,其内存地址不会改变。

对于 for range,其特(坑)点有:

  • 返回副本。即 for range 返回的值并不是原始数据,而是副本,对于需要修改数据的场景,直接修改副本是不生效的,而需要直接操作原始数据。
  • 可以使用 _ 占位符。
  • 循环变量重复引用。操作指针类型数据时要额外小心。

坑点-对比interface

interface 底层结构包含值类型和指针。不用类型的值做对比,可能会有意想不到的结果。

两个nil不相等
var value interface
value = nil
fmt.Println(value == nil)

rand.Seed(time.Now().UnixNano()) 这段代码有什么含义和作用

rand.Seed(time.Now().UnixNano()) 这段代码的作用是设置伪随机数生成器的种子。在计算机中,伪随机数是通过算法产生的,而非真正的随机数。这个算法需要一个初始值作为种子,如果种子相同,那么生成的随机数序列也是相同的。

rand.Seed() 函数接受一个 int64 类型的参数作为种子,它用于初始化伪随机数生成器。通常情况下,我们会使用当前时间的 Unix 时间戳作为种子,以确保每次程序运行都有不同的种子,从而生成不同的随机数序列。

具体来说,time.Now().UnixNano() 返回当前时间的纳秒级别的 Unix 时间戳,而 rand.Seed(time.Now().UnixNano()) 则将这个时间戳作为种子传递给伪随机数生成器。这样做的目的是使得每次程序运行时都有一个不同的起始点,以增加生成的伪随机数的随机性。



sync

sync.Mutex 的数据结构可以说一下吗?

sync.Mutex 是 Go 语言提供的一个基本的互斥锁实现,用于控制对共享资源的并发访问。它的数据结构比较简单,主要包含了一个整型字段 state 和一个等待队列。

type Mutex struct {
state int32 // 用于表示锁的状态,0 表示未加锁,1 表示已加锁
sema uint32 // 等待队列的信号量
}

主要的字段有两个:

  1. state: 一个 int32 类型的字段,用于表示锁的状态。state 的值为 0 表示锁是未加锁状态,为 1 表示锁是已加锁状态。

  2. sema: 一个 uint32 类型的字段,用于实现等待队列。它是一个信号量,表示等待队列中的等待数量。

Mutex 的操作方法主要包括 LockUnlock,以及一些内部的方法。下面是 Mutex 的主要代码片段:

func (m *Mutex) Lock() {
// 加锁
}

func (m *Mutex) Unlock() {
// 解锁
}

Lock 方法中,会通过自旋锁和 CAS(Compare-And-Swap)的方式尝试获取锁。如果获取失败,会将当前的协程加入等待队列,然后通过信号量等待锁的释放。而在 Unlock 方法中,会释放锁,并唤醒等待队列中的下一个协程。

Mutex 的实现使用了底层的原子操作,因此在多核 CPU 上能够实现高效的并发控制。Mutex 是一种互斥锁,保证同一时刻只有一个协程可以获取锁,其他协程需要等待。

sync.Map 比加锁的方案好在哪里,它的底层数据结构是怎样的?

sync.Map 是Go语言标准库中提供的并发安全的 Map 实现。相较于使用显式的锁来保护并发访问的Map,sync.Map 在设计上采用了一些优化,以提供更好的性能和并发访问的可伸缩性。

以下是 sync.Map 相对于加锁方案的一些优势:

  1. 无锁读取: sync.Map 允许多个goroutine同时读取(查询)map,而不需要任何锁。这是通过在底层使用一种特殊的数据结构来实现的,该结构允许无锁的并发读取。
  2. 按段锁: sync.Map 内部使用了一组分段(segment)来控制写入操作的并发性。这些分段类似于哈希表的桶,每个分段都有自己的锁,不同的分段之间是相互独立的。这样,写入操作只需要锁定被影响的分段,而不需要锁定整个map,从而提高并发写入的效率。
  3. 自动扩容: sync.Map 内部自动管理 map 的扩容。在高负载情况下,当负载因子达到一定阈值时,sync.Map 会进行自动扩容,而不需要显式的锁定整个map。
  4. 删除操作优化: sync.Map 在删除操作时,如果分段中的数据项很多,会采用懒惰删除策略,而不是立即删除。这有助于减少删除操作对并发性的影响。
  5. 适用于大规模并发: sync.Map 设计的初衷是为了在大规模并发环境下提供高性能的 Map 实现。它适用于高并发的读写场景,同时避免了全局锁的性能瓶颈。

sync.Map 缓存原理

sync.Map 在实现上确实使用了一种缓存策略来提高性能。在每个分段(segment)内部,sync.Map 使用了一个缓存,用于存储最近被访问的键值对。这个缓存是一个小型的哈希表,通常包含 8 个槽位。

这个缓存的目的是为了提高最近被访问的键值对的查找速度。在 LoadStore 等操作中,sync.Map 首先会在缓存中查找对应的键值对,如果在缓存中找到了,就避免了对整个桶(bucket)的读取或写入,从而提高了性能。

具体而言,缓存的作用如下:

  1. 提高读取性能: 当执行 Load 操作时,sync.Map 首先会在缓存中查找对应的键值对,如果找到了,就直接返回结果,而不需要进一步查找桶中的数据。

  2. 提高写入性能: 当执行 Store 操作时,sync.Map 也会先在缓存中查找对应的键值对,如果找到了,就在缓存中进行写入,而不需要锁住整个桶。这可以减小写入操作的锁冲突范围,提高并发性。

需要注意的是,虽然缓存提高了性能,但它并不是绝对准确的,因为在并发环境下,缓存的内容可能会发生变化。因此,当缓存未命中时,sync.Map 仍然需要在整个桶中进行查找。

sync.Map 无锁读取的原理

sync.Map 的无锁读取原理基于分段锁(segmented lock)的思想。它将整个 map 划分为多个独立的段(segments),每个段内部有自己的锁,而不同段之间相互独立。这种分段的方式使得多个 goroutine 可以同时读取不同段的数据而不需要互斥锁。

下面是 sync.Map 的无锁读取的简要原理:

  1. 分段设计: sync.Map 将底层的哈希表分为多个段,每个段都是一个独立的哈希表。默认情况下,sync.Map 使用了 32 个段,每个段的数据结构是 maptype 中的一个 maptype.bucket
  2. 读取操作: 当一个 goroutine 需要读取 map 中的某个键值对时,它首先会计算键的哈希值,然后通过哈希值找到对应的段。接着,这个 goroutine 会在该段上进行无锁读取,即不需要获取段内部的锁。
  3. 并发读取: 多个 goroutine 可以同时在不同的段上进行读取操作,因为各个段之间是相互独立的,每个段内部的读取都是无锁的。
  4. 写入操作时的锁定: 当需要进行写入操作时,比如插入、更新、删除操作,sync.Map 会使用段内的锁来保护写入操作的原子性。这样,写入操作只会锁定当前需要进行写入的段,而不会锁定整个 map。

这种分段的设计使得读取操作在无锁的情况下能够并发进行,提高了读取操作的性能。而写入操作时,只需要锁定当前影响的段,减小了写入操作的锁冲突范围,从而提高了整个 map 的并发性。

sync.Map 中,读取到一个桶(bucket)的分段数据并不意味着完全不需要加锁。虽然 sync.Map 设计了分段锁来支持并发读取,但实际上在每个桶内部,仍然可能需要使用锁来保护并发读取时的一致性。

在每个分段内部,sync.Map 使用了 maptype.bucket 结构,该结构中包含了一个互斥锁(mutex),用于保护桶内部数据结构的一致性。当有多个协程并发访问同一个桶时,这个互斥锁起到了保护共享资源的作用,防止并发读写带来的问题。

虽然 sync.Map 的设计中允许多个协程在不同分段上进行并发读取,但在每个分段内部,由于存在共享的桶,仍然需要使用互斥锁来保护桶内部的数据。这就是为什么 sync.Map 适用于高并发读取场景的原因,因为分段锁减小了锁的范围,但并不代表在桶内就完全不需要锁。

总体来说,sync.Map 的设计是在平衡并发性和一致性之间的,通过分段锁和桶内锁的结合,实现了在大多数情况下高并发读取,同时保持了写入操作的原子性。

sync.Map 的 Load() 方法流程?

sync.MapLoad 方法用于获取指定键对应的值。它是一个读操作,因此通常是无锁的。以下是 sync.MapLoad 方法的简要流程:

  1. 计算哈希值: Load 方法首先会计算给定键的哈希值。
  2. 定位分段: 使用哈希值找到对应的分段。sync.Map 内部使用了多个分段,每个分段维护了一部分键值对。
  3. 定位桶: 在找到对应的分段后,根据哈希值定位到分段内的桶(bucket)。
  4. 获取桶锁: 在这个时候,Load 方法会尝试获取桶的读锁(read lock)。这是一个非阻塞的尝试,如果成功获取到锁,则可以继续进行后续操作;否则,会尝试使用其他机制来获取锁。
  5. 在桶中查找键值对: 获取到锁之后,Load 方法会在桶中查找给定键对应的值。桶内部通常使用链表或其他数据结构来存储键值对。
  6. 释放桶锁: 查找完成后,释放桶的读锁,允许其他协程在该桶上执行读操作。
  7. 返回结果: 返回查找到的值,如果找不到对应键的值,返回零值。

需要注意的是,Load 方法在查找键值对时并没有使用全局的互斥锁,而是在桶级别使用了读锁。这使得多个协程可以在不同的桶上并发执行读取操作,提高了读取的性能。然而,为了保证并发的正确性,Load 方法仍然需要在桶级别使用读锁来保护并发读取操作。

sync.Map Store() 如何保持缓存层和底层 Map 数据是相同的? 是不是每次执行修改都需要去加锁?

sync.MapStore 方法用于将键值对存储到 map 中。在 sync.Map 中,Store 操作通常涉及到写入,因此会涉及到对分段的写锁的获取。

以下是 sync.MapStore 方法的简要流程:

  1. 计算哈希值: Store 方法首先会计算给定键的哈希值。
  2. 定位分段: 使用哈希值找到对应的分段。sync.Map 内部使用了多个分段,每个分段维护了一部分键值对。
  3. 定位桶: 在找到对应的分段后,根据哈希值定位到分段内的桶(bucket)。
  4. 获取桶锁: 在这个时候,Store 方法会尝试获取桶的写锁(write lock)。这是一个独占锁,确保只有一个协程能够在该桶上执行写操作。
  5. 写入键值对: 获取到锁之后,Store 方法会将给定的键值对写入桶中。在写入时,需要确保写入的操作是原子的,以防止并发写入引起的数据不一致性。
  6. 释放桶锁: 写入完成后,释放桶的写锁,允许其他协程在该桶上执行读操作或写操作。

通过上述流程,Store 方法保证了在写入键值对时,只有一个协程能够修改对应的桶,从而保持了并发写入的一致性。在这个过程中,sync.Map 使用分段级别的锁来降低锁的粒度,提高了写入操作的并发性。

需要注意的是,虽然 Store 方法涉及到锁的获取和释放,但由于使用了分段锁,它并不会锁住整个 map,而是只锁住影响的分段,从而减小了锁的范围。这是 sync.Map 设计的一个关键点,使得在大多数情况下并发写入的性能表现得相当不错。

sync.Map 在实现上确实使用了一种缓存策略来提高性能。在每个分段(segment)内部,sync.Map 使用了一个缓存,用于存储最近被访问的键值对。这个缓存是一个小型的哈希表,通常包含 8 个槽位。

这个缓存的目的是为了提高最近被访问的键值对的查找速度。在 LoadStore 等操作中,sync.Map 首先会在缓存中查找对应的键值对,如果在缓存中找到了,就避免了对整个桶(bucket)的读取或写入,从而提高了性能。

具体而言,缓存的作用如下:

  1. 提高读取性能: 当执行 Load 操作时,sync.Map 首先会在缓存中查找对应的键值对,如果找到了,就直接返回结果,而不需要进一步查找桶中的数据。
  2. 提高写入性能: 当执行 Store 操作时,sync.Map 也会先在缓存中查找对应的键值对,如果找到了,就在缓存中进行写入,而不需要锁住整个桶。这可以减小写入操作的锁冲突范围,提高并发性。

需要注意的是,虽然缓存提高了性能,但它并不是绝对准确的,因为在并发环境下,缓存的内容可能会发生变化。因此,当缓存未命中时,sync.Map 仍然需要在整个桶中进行查找。

sync.Map 中,写入操作时,如果发现对应的键值对已经存在于缓存中,就可以直接在缓存中进行更新,而无需锁定整个桶内的数据。这是一种通过缓存优化写入操作的策略。

这样的设计带来了一些好处:

  1. 减小锁范围: 直接在缓存中进行更新避免了锁定整个桶,减小了写入操作的锁冲突范围。这有助于提高写入操作的并发性。
  2. 提高写入性能: 由于直接在缓存中进行更新而不涉及桶内的数据,写入操作可以更快速地完成。这在高并发写入的情况下能够提供性能优势。
  3. 延迟实际写入: 更新操作可能会延迟实际的桶内写入。这是因为在某些情况下,sync.Map 可以通过直接更新缓存来快速响应写入请求,而将实际的桶内写入操作留待稍后执行。

在并发场景下,更新缓存时可能仍然会存在一些问题。尽管在 sync.Map 中采用了一些优化策略,但在高并发写入的情况下,仍然需要注意一致性和并发性之间的权衡。

以下是一些可能的问题和注意事项:

  1. 缓存更新的原子性: 虽然 sync.Map 会尝试在缓存中直接更新,但这并不能保证更新操作的原子性。在高并发写入的情况下,多个协程可能同时尝试更新同一个缓存槽位,导致竞态条件。为了解决这个问题,sync.Map 在缓存的更新上仍然需要使用互斥锁来确保原子性。
  2. 缓存不命中: 在实际写入数据时,如果缓存中没有找到对应的键值对,仍然需要锁定整个桶并在桶内进行实际的写入。这时,锁的范围变得较大,可能影响并发性。
  3. 延迟写入的一致性问题: 由于缓存更新可能导致实际的桶内写入被延迟,可能会出现缓存中已经更新了,但桶内数据尚未同步的情况。这引入了一致性的延迟。

context

Context 平时什么场景使用到?

context 是 go 中常用的上下文库。通常用于多协程之间的状态管理、信息传递。

最常用的应该是 传递取消信号。取消可以是超时、可以是主动cancel 等

context.WithTimeout原理

底层基于time.AfterFunc实现,在指定时间执行cancel函数。



编程实战

1w个任务,请实现一个并发任务处理器

设计一段优雅停止服务的代码

在服务收到系统退出信号时,如何让服务优雅的退出。

  1. 信号处理:启动一个协程监听系统信号
  2. 上下文:初始化一个root context & cancel,当收到退出指令时执行 cancel

select chan 如何实现优先级

如何将环境变量映射到结构体