Golang常见面试题及解答_golang面试题,保洁阿姨看完都会了

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Golang全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip1024b (备注go)
img

正文

分析:

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

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

//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

  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

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++

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
img

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
ase 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++

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-q7nViTG8-1713131561586)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 10
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值