0%

golang 问题整理

Go语言Slice是否线程安全

Go语言实现线程安全常用的几种方式:1.互斥锁;2.读写锁;3.原子操作;4.sync.once;5. sync.atomic;6.channel
slice底层结构并没有使用加锁等方式,不支持并发读写,所以并不是线程安全的,使用多个goroutine对类型为slice的变量进行操作,每次输出的值大概率都不会一样,与预期值不一致; slice在并发执行中不会报错,但是数据会丢失。

slice内存泄漏分析

(1)发生场景:截取长slice中的一段导致长slice未释放

由于底层都是数组,如果截长slice的一段,其实相当于引用了底层数组中的一小段。只要还有引用,golang的gc就不能回收数组。这种情况导致未使用的数组空间,未及时回收。

解决方案:新建一个长度为0的slice,将需要的一小段slice使用append方法添加到新的slice。再将原来的slice置为nil。

2)发生场景:没有重置丢失的子切片元素中的指针

没有及时将不再使用的slice置为nil

解决方案:如果slice中包含很多元素,再只有一小部分元素需要使用的情况下。建议重新分配一个slice将需要保留的元素加入其中,将原来的长slice整个置为nil。

Golang Slice 的底层实现

切片是基于数组实现的,它的底层是数组,它自己本身非常小,可以理解为对 底层数组的抽象。因为基于数组实现,所以它的底层的内存是连续分配的,效 率非常高,还可以通过索引获得数据。

切片本身并不是动态数组或者数组指针。它内部实现的数据结构通过指针引用 底层数组,设定相关属性将数据读写操作限定在指定的区域内。切片本身是一 个只读对象,其工作机制类似数组指针的一种封装。

切片对象非常小,是因为它是只有 3 个字段的数据结构:

  • 指向底层数组的指针

  • 切片的长度

  • 切片的容量

golang里的数组和切片

数组长度是固定的,而切片是可变长的。可以把切片看作是对底层数组的封装,每个切片的底层数据结构中,一定会包含一个数组。数组可以被称为切片的底层数组,切片也可以被看作对数组某一连续片段的引用。因此,Go中切片属于引用类型,而数组属于值类型,通过内建函数len,可以取得数组和切片的长度。通过内建函数cap,可以得到数组和切片的容量。但是数组的长度和容量是相等的,并且都不可变,而且切片容量是有变化规律的。

对已经关闭的channel进行读写操作会发生什么?

  1. 读已关闭的channel
    读已经关闭的channel无影响。
    如果在关闭前,通道内部有元素,会正确读到元素的值;
    如果关闭前通道无元素,则会读取到通道内元素类型对应的零值。
    若遍历通道,如果通道未关闭,读完元素后,会报死锁的错误。
    fatal error: all goroutines are asleep - deadlock!

  2. 写已关闭的通道
    会引发panic: send on closed channel

  3. 关闭已关闭的通道
    会引发panic: close of closed channel

总结:对于一个已初始化,但并未关闭的通道来说,收发操作一定不会引发 panic。但是通道一旦关闭,再对它进行发送操作,就会引发 panic。如果我们试图关闭一个已经关闭了的通道,也会引发 panic。

go struct 能不能比较

需要具体情况具体分析,如果struct中含有不能被比较的字段类型,就不能被比较,如果struct中所有的字段类型都支持比较,那么就可以被比较。

不可被比较的类型:
① slice,因为slice是引用类型,除非是和nil比较
② map,和slice同理,如果要比较两个map只能通过循环遍历实现
③ 函数类型

其他的类型都可以比较。

还有两点值得注意:

结构体之间只能比较它们是否相等,而不能比较它们的大小。
只有所有属性都相等而属性顺序都一致的结构体才能进行比较。

数组是如何实现根据下标随机访问数组元素的吗?

例如: a := [10]int{0}

  • 计算机给数组a,分配了一组连续的内存空间。
  • 比如内存块的首地址为 base_address=1000。
  • 当计算给每个内存单元分配一个地址,计算机通过地址来访问数据。当计算机需要访问数组的某个元素的时候,会通过一个寻址公式来计算存储的内存地址。

GMP模型

  • G(Goroutine):G 就是我们所说的 Go 语言中的协程 Goroutine 的缩写,相当于操作系统中的进程控制块。其中存着 goroutine 的运行时栈信息,CPU 的一些寄存器的值以及执行的函数指令等。
  • M(Machine):代表一个操作系统的主线程,对内核级线程的封装,数量对应真实的 CPU 数。一个 M 直接关联一个 os 内核线程,用于执行 G。M 会优先从关联的 P 的本地队列中直接获取待执行的 G。M 保存了 M 自身使用的栈信息、当前正在 M上执行的 G 信息、与之绑定的 P 信息。
  • P(Processor):Processor 代表了 M 所需的上下文环境,代表 M 运行 G 所需要的资源。是处理用户级代码逻辑的处理器,可以将其看作一个局部调度器使 go 代码在一个线程上跑。当 P 有任务时,就需要创建或者唤醒一个系统线程来执行它队列里的任务,所以 P 和 M 是相互绑定的。总的来说,P 可以根据实际情况开启协程去工作,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

go垃圾回收,什么时候触发

主动触发(手动触发),通过调用 runtime.GC 来触发GC,此调用阻塞式地等待当前GC运行完毕。
被动触发,分为两种方式:
1)使用步调(Pacing)算法,其核心思想是控制内存增长的比例,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC。
2)使用系统监控,当超过两分钟没有产生任何GC时,强制触发 GC。

gc算法有哪些?

常见的垃圾回收算法有以下几种:

引用计数 :对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减1,当引用计数器为0时回收该对象。
优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价。
代表语言:Python、PHP
标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收。
优点:解决了引用计数的缺点。
缺点:需要STW,即要暂时停掉程序运行。
代表语言:Golang(其采用三色标记法)
分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不能的回收算法和回收频率。
优点:回收性能好
缺点:算法复杂
代表语言: JAVA
三色标记法
1)初始状态下所有对象都是白色的。
2)从根节点开始遍历所有对象,把遍历到的对象变成灰色对象
3)遍历灰色对象,将灰色对象引用的对象也变成灰色,然后将遍历过的灰色对象变成黑色对象。
4)循环步骤3,直到灰色对象全部变黑色。
5)回收所有白色对象(垃圾)。

make 与 new 的区别

引用类型与值类型

  • 引用类型 变量存储的是一个地址,这个地址存储最终的值。内存通常在堆上分配。通过 GC 回收。包括 指针、slice 切片、管道 channel、接口 interface、map、函数等。

  • 值类型是 基本数据类型,int,float,bool,string, 以及数组和 struct 特点:变量直接存储值,内存通常在栈中分配,栈在函数调用后会被释放

  • 对于引用类型的变量,我们不光要声明它,还要为它分配内容空间

  • 对于值类型的则不需要显示分配内存空间,是因为go会默认帮我们分配好

new()

1
func new(Type) *Type

new()对类型进行内存分配,入参为类型,返回为类型的指针,指向分配类型的内存地址

make()

1
func make(t Type, size ...IntegerType) Type

make()也是用于内存分配的,但是和new不同,它只用于channel、map以及切片的内存创建,而且它返回的类型就是这三个类型本身,而不是他们的指针类型,因为这三种类型就是引用类型,所以就没有必要返回他们的指针了。

注意,因为这三种类型是引用类型,所以必须得初始化,但是不是置为零值,这个和new是不一样的。

简而言之make()用于初始化slice, map, channel等内置数据结构

如何判断channel是否关闭?

  • 读channel的时候判断其是否已经关闭

    _,ok := <- jobs

    此时如果 channel 关闭,ok 值为 false

  • 写入channel的时候判断其是否已经关闭

  1. _,ok := <- jobs

    此时如果 channel 关闭,ok 值为 false,如果 channel 没有关闭,则会漏掉一个 jobs中的一个数据

  2. 使用 select 方式

    再创建一个 channel,叫做 timeout,如果超时往这个 channel 发送 true,在生产者发送数据给 jobs 的 channel,用 select 监听 timeout,如果超时则关闭 jobs 的 channel。

go 的锁是可重入的吗?

不是可重入锁。

讨论这个问题前,先解释一下“重入”这个概念。当一个线程获取到锁时,如果没有其他线程拥有这个锁,那么这个线程
就会成功获取到这个锁。线程持有这个锁后,其他线程再请求这个锁,其他线程就会进入阻塞等待的状态。但是如果游泳这个锁的
线程再请求这把锁的话,就不会阻塞,而是成功返回,这就是可重入锁。可重入锁也叫做递归锁。
为什么 go 的锁不是可重入锁,因为 Mutex 的实现中,没有记录哪个 goroutine 拥有这把锁。换句话说,我们可以通过
扩展来将 go 的锁变为可重入锁,这里就不展开了。下面是一个误用 Mutex 的重入例子:https://github.com/guowei-gong/go-demo/commit/a6fc236853f5cd0efd4e62269cfe60a19de7a96e

go 中用 for 遍历多次执行 goroutine会存在什么问题

  1. 假如在协程中打印for的下标i或当前下标的元素,会随机打印载体中的元素.

    原因有二:

    • golang是值拷贝传递 for循环很快就执行完了,但是创建的10个协程需要做初始化。上下文准备,堆栈,和内核态的线程映射关系的工作,是需要时间的,比for慢,等都准备好了的时候,会同时访问i。这个时候的i肯定是for执行完成后的下标。(也可能有个别的协程已经准备好了,取i的时候,正好是5,或者7,就输出了这些数字)。

      解决的方法就是闭包,给匿名函数增加入参,因为是值传递,所以每次for创建一个协程的时候,会拷贝一份i传到这个协程里面去。 或者在开启协程之前声明一个新的变量 = i。

  2. 假如当前for是并发读取文件

程序会panic:too many open files

解决的方法:通过带缓冲的channel和sync.waitgroup控制协程并发量。

空结构体占不占内存空间? 为什么使用空结构体?

准确的来说,空结构体有一个特殊起点: zerobase 变量。zerobase是一个占用 8 个字节的uintptr全局变量。每次定义 struct {} 类型的变量,编译器只是把zerobase变量的地址给出去。也就是说空结构体的变量的内存地址都是一样的。

进程、线程、协程的区别?

概念定义

进程: 进程是一个具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统资源分配和独立运行的最小单位。
线程: 线程是进程的一个执行单元,是任务调度和系统执行的最小单位。
协程: 协程是一种用户态的轻量级线程,协程的调度完全由用户控制。

进程与线程的区别

  1. 根本区别:进程是操作系统资源分配和独立运行的最小单位;线程是任务调度和系统执行的最小单位。
  2. 地址空间区别: 每个进程都有独立的地址空间,一个进程崩溃不影响其它进程;一个进程中的多个线程共享该进程的地址空间,一个线程的非法操作会使整个进程崩溃。
  3. 上下文切换开销区别: 每个进程有独立的代码和数据空间,进程之间上下文切换开销较大;线程组共享代码和数据空间,线程之间切换的开销较小。

线程和协程的区别

  1. 内存开销:创建一个协程需要2kb, 栈空间不够会自动扩容, 创建一个线程需要1M空间。
  2. 创建和销毁:创建线程是和操作系统打交道,内核态 开销更大, 协程是由runtime管理,属于用户态 开销小。
  3. 切换成本:线程切换 需要保存各种寄存器,切换时间大概在1500-2000us, 协程保存的寄存器比较少, 切换时间大概在200us, 它能执行更多的指令。

defer recover panic 执行顺序

执行顺序应该为panic、defer、recover

  • 发生panic的函数并不会立刻返回,而是先层层函数执行defer,再返回。如果有办法将panic捕获到panic,就正常处理(若是外部函数捕获到,则外部函数只执行defer),如果没有没有捕获,程序直接异常终止。
  • Go语言提供了recover内置函数。前面提到,一旦panic逻辑就会走到defer(defer必须在panic的前面!)。调用recover函数将会捕获到当前的panic,被捕获到的panic就不会向上传递了
  • 在panic发生时,在前面的defer中通过recover捕获这个panic,转化为错误通过返回值告诉方法调用者。

如何解决孤儿进程的出现

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

解决方案

孤儿进程结束后会被 init 进程善后,并没有危害,而僵尸进程则会一直占着进程号,操作系统的进程数量有限则会受影响。

僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

僵尸进程解决方案

进程等待—wait函数和waitpid函数
wait函数
创建一个子进程,子进程正常逻辑,父进程调用wait函数来进行等待,当子进程退出的时候,由于父进程在等待,所以子进程就不会变成僵尸进程
父进程一开始调用wait函数,就会阻塞在wait函数中,等待子进程
直到子进程退出,wait函数调用才返回,父进程接着执行wait函数之后的代码

说一下reflect

recflect是golang用来检测存储在接口变量内部(值value;类型concrete type) pair对的一种机制。它提供了两种类型(或者说两个方法)让我们可以很容易的访问接口变量内容,分别是reflect.ValueOf() 和 reflect.TypeOf()。

ValueOf用来获取输入参数接口中的数据的值,如果接口为空则返回0
TypeOf用来动态获取输入参数接口中的值的类型,如果接口为空则返回nil

Golang 里怎么避免内存逃逸?

  1. 不要盲目使用变量指针作为参数,虽然减少了复制,但变量逃逸的开销更大。
  2. 预先设定好slice长度,避免频繁超出容量,重新分配。
  3. 一个经验是,指针指向的数据大部分在堆上分配的,请注意。

出现内存逃逸的情况有:

  1. 发送指针或带有指针的值到channel,因为编译时候无法知道那个goroutine会在channel接受数据,编译器无法知道什么时候释放。

  2. 在一个切片上存储指针或带指针的值。比如[]*string,导致切片内容逃逸,其引用值一直在堆上。

  3. 切片的append导致超出容量,切片重新分配地址,切片背后的存储基于运行时的数据进行扩充,就会在堆上分配。

  4. 调用接口类型时,接口类型的方法调用是动态调度,实际使用的具体实现只能在运行时确定,如一个接口类型为io.Reader的变量r,对r.Read(b)的调用将导致r的值和字节片b的后续转义并因此分配到堆上。

  5. 在方法内把局部变量指针返回,被外部引用,其生命周期大于栈,导致内存溢出。

defer的执行顺序

  1. 一个函数中多个defer的执行顺序

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

  1. defer、return、返回值的执行返回顺序

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

go init 的执行顺序,注意是不按导入规则的(这里是编译时按文件名的顺序执行的)

  1. init函数是用于程序执行前做包的初始化的函数,比如初始化包里的变量等
  2. 每个包可以拥有多个init函数
  3. 包的每个源文件也可以拥有多个init函数
  4. 同一个包中多个init函数的执行顺序go语言没有明确的定义(说明)
  5. 不同包的init函数按照包导入的依赖关系决定该初始化函数的执行顺序
  6. init函数不能被其他函数调用,而是在main函数执行之前,自动被调用