GoLang八股(Go并发)

文章目录

1、进程、线程、协程

进程、线程和协程都是并发编程的概念

进程是操作系统分配资源的基本单位,每个进程都有自己的独立内存空间,不同进程之间的数据不能直接共享, 通常通过进程间通信(IPC)来进行数据交换,例如管道、消息队列等。

线程是操作系统调度的最小执行单位,同一进程的不同线程共享相同的内存空间,可以直接访问共享数据

协程是轻量级的用户态线程,由Go调度器进行管理,协程的创建和销毁比线程更为轻量 ,可以很容易地创建大量的协程。协程之间通过通信来共享数据 ,而不是通过共享内存。这通过使用通道(channel)等机制来实现

2、进程、线程的区别

  • 调度:进程是资源管理的基本单位,线程是程序执行的基本单位。
  • 切换:线程上下文切换比进程上下文切换要快得多。
  • 拥有资源: 进程是拥有资源的一个独立单位,线程不拥有系统资源,但是可以访问隶属于进程的资源。
  • 系统开销: 创建或撤销进程时,系统都要为之分配或回收系统资源,如内存空间,I/O 设备等,OS 所付出的开销显著大于在创建或撤销线程时的开销,进程切换的开销也远大于线程切换的开销。

3、协程和线程的区别

  • 线程和进程都是同步机制,而协程是异步机制
  • 线程是抢占式,而协程是非抢占式的。需要用户释放使用权切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力
  • 一个线程可以有多个协程,一个进程也可以有多个协程
  • 协程不被操作系统内核管理,而完全是由程序控制。线程是被分割的CPU资源,协程是组织好的代码流程,线程是协程的资源 。但协程不会直接使用线程,协程直接利用的是执行器关联任意线程或线程池
  • 协程能保留上一次调用时的状态

4、并行和并发的区别

  • 并发就是在一段时间内,多个任务都会被处理;但在某一时刻,只有一个任务在执行。单核处理器可以做到并发。比如有两个进程 A 和 B,A 运行一个时间片之后,切换到 B,B 运行一个时间片之后又切换到 A因为切换速度足够快,所以宏观上表现为在一段时间内能同时运行多个程序。
  • 并行就是在同一时刻,有多个任务在执行 。这个需要多核处理器才能完成,在微观上就能同时执行多条指令 ,不同的程序被放到不同的处理器上运行,这个是物理上的多个进程同时进行

5、Go语言并发模型

Go语言的并发模型建立在goroutine和channel之上 。其设计理念是共享数据通过通信而不是通过共享来实现

  • Goroutines 是Go中的轻量级线程 ,由Go运行时(runtime)管理。与传统线程相比,goroutines的创建和销毁开销很小。程序可以同时运行多个goroutines,它们共享相同的地址空间

  • Goroutines之间的通信通过channel(通道)实现 。通道提供了一种安全、同步 的方式,用于在goroutines之间传递数据。使用通道可以避免多个goroutines同时访问共享数据而导致竞态条件的问题

  • 多路复用:select 语句允许在多个通道操作中选择一个执行。这种方式可以有效地处理多个通道的并发操作,避免了阻塞。

  • 互斥锁和条件变量

    • Go提供了 sync 包,其中包括 Mutex(互斥锁)等同步原语,用于在多个goroutines之间进行互斥访问共享资源
    • sync 包还提供了 Cond(条件变量),用于在goroutines之间建立更复杂的同步。
  • 原子操作:Go提供了 sync/atomic 包,其中包括一系列原子性操作,用于在不使用锁的情况下进行安全的并发操作

6、什么是goroutine

goroutine(协程)是一种轻量级的线程,由Go运行时(runtime)管理,一个典型的 Go 程序可能会同时运行成千上万个 goroutine,Goroutines 使得程序可以并发执行,而无需显式地创建和管理线程。通过关键字 go 可以启动一个新的 goroutine,例如:go someFunction()。

每个 goroutine 都有自己的独立栈空间,这使得它们之间的数据不容易互相干扰 。与传统的多线程编程相比,使用 goroutines 不需要开发者显式地进行线程的创建、销毁和同步。Go 运行时会自动处理这些事务。

7、如何控制 goroutine 的生命周期

1. 启动

使用关键字 go 可以启动一个新的 goroutine。

go 复制代码
go func() {
    // goroutine 的代码逻辑
}()

2. 等待结束

希望主程序等待某个 goroutine 执行完毕后再继续执行。可以使用 sync.WaitGroup 来实现等待。

go 复制代码
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    wg.Add(1) // 添加一个等待的 goroutine

    go func() {
        defer wg.Done() // goroutine 完成时调用 Done 减少计数
        // goroutine 的代码逻辑
        fmt.Println("Goroutine executing...")
    }()

    // 等待所有 goroutine 完成
    wg.Wait()

    fmt.Println("Main goroutine exiting.")
}
bash 复制代码
root@GoLang:~/proj/goforjob# go run ./main.go
Goroutine executing...
Main goroutine exiting.

3. 使用通道(channel)来通知 goroutine 退出

go 复制代码
package main

import (
    "fmt"
    "time"
)

func main() {
    quit := make(chan bool)

    go func() {
        defer fmt.Println("Goroutine exiting...")
        // goroutine 的代码逻辑
        time.Sleep(time.Second * 2)
        quit <- true // 发送退出通知
    }()

    // 主 goroutine 等待退出通知
    <-quit
    fmt.Println("Main goroutine exiting.")
}
bash 复制代码
root@GoLang:~/proj/goforjob# go run ./main.go
Main goroutine exiting.

或者

bash 复制代码
root@GoLang:~/proj/goforjob# go run ./main.go
Goroutine exiting...
Main goroutine exiting.

4. 使用 context 包

Go 标准库中的 context 可以实现超时控制、取消、传递参数等功能。

8、Go语言中的Channel是什么,有哪些用途,如何处理阻塞

Channel(通道)是用于在goroutines之间进行通信的一种机制。通道提供了一种并发安全的方式来进行goroutines之间的通信。通过通道,可以避免在多个goroutines之间共享内存而引发的竞态条件问题因为通道的读写是原子性的。

用途

  • 数据传递:主要用于在goroutines之间传递数据,确保数据的安全传递和同步。
  • 同步执行:通过Channel可以实现在不同goroutines之间的同步执行,确保某个goroutine在另一个goroutine完成某个操作之前等待。
  • 消息传递:适用于实现发布-订阅模型或通过消息进行事件通知的场景
  • 多路复用:使用 select 语句,可以在多个Channel操作中选择一个非阻塞的执行,实现多路复用。

如何处理阻塞

  1. 缓冲通道:在创建通道时指定缓冲区大小,即创建一个缓冲通道
  • 发送:当缓冲区没满时,ch <- x 不会阻塞;当缓冲区满了时,发送会阻塞(直到有人接收腾出空间)。
  • 接收:当缓冲区非空时,<-ch 不会阻塞;当缓冲区空了时,接收会阻塞(直到有人发送数据进来)。
  1. select 语句用于处理多个通道操作,可以用于避免阻塞。
  2. 使用 time.After 创建一个定时器,可以在超时后执行特定的操作,避免永久阻塞。
  3. select 语句中使用 default 分支,可以在所有通道都阻塞的情况下执行非阻塞的操作

9、什么是互斥锁(mutex)?在什么情况下会用到它们?

互斥锁是一种用于控制对共享资源访问的同步机制 。它确保在任意时刻只有一个线程能够访问共享资源,从而避免多个线程同时对资源进行写操作导致的数据竞争和不一致性。

在并发编程中,多个线程(或者Goroutines)可能同时访问共享的数据,如果不进行同步控制,可能导致以下问题:

  • 竞态条件(Race Condition):多个线程同时修改共享资源,导致最终结果依赖于执行时机,可能引发不确定的行为。
  • 数据不一致性:多个线程同时读写共享资源,可能导致数据不一致,破坏了程序的正确性。

互斥锁通过在临界区(对共享资源的访问区域) 中使用锁来解决这些问题。基本上,当一个线程获得了互斥锁时,其他线程需要等待该线程释放锁后才能获得锁。这确保了在任一时刻只有一个线程能够进入临界区

在Go语言中,互斥锁通常使用 sync 包中的 Mutex 类型来实现。以下是一个简单的示例:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

var counter int
var mutex sync.Mutex

func increment(wg *sync.WaitGroup) {
    defer wg.Done()

    // 互斥锁加锁
    mutex.Lock()
    counter++
    // 互斥锁解锁
    mutex.Unlock()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()

    fmt.Println("Counter:", counter)
}
bash 复制代码
root@GoLang:~/proj/goforjob# go run ./main.go
Counter: 1000

mutex.Lock() 用于加锁,mutex.Unlock() 用于解锁。这确保了 counter 的并发访问是安全的,避免了竞态条件。

需要注意的是,在使用互斥锁时,要确保在临界区内的代码执行时间较短,以减小锁的持有时间,从而提高程序的并发性能过长的锁持有时间可能导致其他线程被阻塞,降低并发性。

10、Mutex有几种模式

mutex有两种模式:normal 和 starvation

正常模式

在正常模式中,锁的获取是非公平 的,即等待锁的 Goroutine 不保证按照先来先服务(FIFO)的顺序获得锁。新到来的 Goroutine 有可能在等待时间较长的 Goroutine 之前获得锁。

饥饿模式

在饥饿模式中,系统保证等待锁的 Goroutine 按照一定的公平原则获得锁避免某些 Goroutine 长时间无法获得锁的情况

11、Mutex有几种状态

  • mutexLocked --- 表示互斥锁的锁定状态
  • mutexWoken --- 表示已经有一个等待锁的 goroutine 被唤醒/被安排去竞争这把锁了("已经派了一个人去排队窗口了,别再叫第二个人起床了。")
  • mutexStarving --- 当前的互斥锁进入饥饿状态
  • waitersCount --- 当前互斥锁上等待的 Goroutine 个数

12、无缓冲的 channel 和有缓冲的 channel 的区别?

对于无缓冲区channel:

发送的数据如果没有被接收方接收,那么发送方阻塞;如果一直接收不到发送方的数据,接收方阻塞

有缓冲的channel:

发送方在缓冲区满的时候阻塞,接收方不阻塞;接收方在缓冲区为空的时候阻塞,发送方不阻塞

13、Go什么时候发生阻塞?阻塞时调度器会怎么做。

  • 由于原子、互斥量或通道操作导致goroutine阻塞 ,调度器将把当前阻塞的goroutine从本地运行队列LRQ换出,并重新调度其它goroutine
  • 由于网络请求和IO导致的阻塞Go提供了网络轮询器(Netpoller) 来处理,后台用epoll等技术实现IO多路复用

其它回答:

  • channel 阻塞:当goroutine读写channel发生阻塞时,会调用gopark函数,该G脱离当前的M和P,调度器将新的G放入当前M。
  • 系统调用:当某个G由于系统调用陷入内核态,该P就会脱离当前M,此时P会更新自己的状态为Psyscall,M与G相互绑定,进行系统调用。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使用闲置的处理器处理该G。
  • 系统监控:当某个G在P上运行的时间超过10ms时候,或者P处于Psyscall状态过长等情况就会调用retake函数,触发新的调度。
  • 主动让出:由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运行。

14、goroutine什么情况会发生内存泄漏?如何避免。

在Go中内存泄露分为暂时性内存泄露和永久性内存泄露。

暂时性内存泄露(temporary / transient)

  • 获取长字符串中的一段导致长字符串未释放
  • 获取长slice中的一段导致长slice未释放
  • 在长slice新建slice导致泄漏

string相比切片少了一个容量的cap字段,可以把string当成一个只读的切片类型。获取长string或者切片中的一段内容,由于新生成的对象和老的string或者切片共用一个内存空间,会导致老的string和切片资源暂时得不到释放,造成短暂的内存泄漏

永久性内存泄露(permanent / true leak)

有对象一直被引用(可达),GC 永远不会回收。

现象是:内存持续增长或维持高位,不会随着时间/请求回落;并且堆 profile 会显示"某些对象一直存活"。

避免泄漏的"原则清单"

  • 每个 goroutine 都要有退出条件(done/ctx/close)
  • 谁创建谁负责关闭/取消(ownership)
  • 不要无限制创建 goroutine(worker pool/限流)
  • channel 发送必须可中断(select + ctx)
  • 所有 WithCancel/WithTimeout 必须 defer cancel
  • Ticker 必须 Stop

15、go的垃圾回收机制了解吗?

Go1.3之前采用标记清除法,Go1.3之后采用三色标记法,Go1.8采用三色标记法+混合写屏障。

1. 标记清除法

初始版本的Go语言使用了一个基于标记-清扫(Mark-Sweep)算法的垃圾回收器。

  • 在标记清除算法中,首先从根对象(如全局变量、栈中的引用等)出发,标记所有可达对象。这一过程通常使用深度优先搜索或广度优先搜索进行。标记的方式通常是将对象的标记位从未标记改为已标记。所有的可达对象都被标记为"活动"或"存活"。

  • 在清扫阶段,遍历整个堆内存,将未被标记的对象视为垃圾,即不再被引用。所有未被标记的对象都将被回收,它们的内存将被释放,以便后续的内存分配。

  • 标记清除算法执行完清扫阶段后,可能会产生内存碎片,即一些被回收的内存空间可能是不连续的。为了解决这个问题,一些实现中可能会进行内存碎片整理。

  • 标记清除算法的主要优势是能够回收不再使用的内存,但它也有一些缺点。其中一个主要的缺点是清扫阶段可能会引起一定程度的停顿,因为在这个阶段需要遍历整个堆内存。另外,由于标记清除算法只关注"存活"和"垃圾"两种状态,不涉及内存分配的具体位置,可能导致内存碎片的产生

2. 三色标记法

  • 三色标记:将对象分为三种颜色:白色、灰色、和黑色。初始时,所有对象都被标记为白色,表示它们都是未被访问的垃圾对象。

  • 根搜索:垃圾回收从根对象开始搜索,根对象包括全局变量、栈上的对象以及其他一些持有对象引用的地方。所有根对象被标记为灰色,表示它们是待处理的对象。

  • 标记阶段:从灰色对象开始,垃圾回收器遍历对象的引用关系,将其引用的对象标记为灰色,然后将该对象标记为黑色。这个过程一直进行,直到所有可达对象都被标记为黑色。

  • 并发标记:在标记阶段,垃圾回收器采用并发标记的方式,与程序的执行同时进行。这意味着程序的执行不会因为垃圾回收而停顿,从而减少了对程序性能的影响。

  • 清扫阶段:在标记完成后,垃圾回收器会扫描堆中的所有对象,将未被标记的对象回收(释放其内存)。这些未被标记的对象被认为是不可达的垃圾。

  • 内存返还:垃圾回收完成后,系统中的内存得以回收并用于新的对象分配。

  • GC触发:垃圾回收的触发条件通常是在分配新对象时,如果达到一定的内存分配阈值,就会触发垃圾回收。另外,一些特定的事件(如系统调用、网络阻塞等)也可能触发垃圾回收。

3. 三色标记法+混合写屏障

这种方法有一个缺陷,如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色。

一次完整的GC分为四个阶段:

  • 准备标记(需要STW),开启写屏障。

  • 开始标记

  • 标记结束(STW),关闭写屏障

  • 清理(并发)

写屏障的作用是在标记过程中保护并发修改操作,确保GC能正确地标记存活对象,防止遗漏或者误标记。

基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:

  1. GC开始时,将栈上的全部对象标记为黑色(不需要二次扫描,无需STW);

  2. GC期间,任何栈上创建的新对象均为黑色

  3. 被删除引用的对象标记为灰色

  4. 被添加引用的对象标记为灰色

总而言之就是确保黑色对象不能引用白色对象,这个改进直接使得GC时间从2s降低到2us。

16、Go语言中GC的流程是什么

Go1.14 版本以 STW 为界限,可以将 GC 划分为五个阶段:

  • GCMark 标记准备阶段,为并发标记做准备工作,启动写屏障

  • STWGCMark 扫描标记阶段,与赋值器并发执行,写屏障开启并发

  • GCMarkTermination 标记终止阶段,保证一个周期内标记任务完成,停止写屏障

  • GCoff 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭

  • GCoff 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭。

17、GC如何调优

通过 go tool pprof 和 go tool trace 等工具

  • 控制内存分配的速度,限制 Goroutine 的数量,从而提高赋值器对 CPU的利用率。

  • 减少并复用内存,例如使用 sync.Pool 来复用需要频繁创建临时对象,例如提前分配足够的内存来降低多余的拷贝。

  • 需要时,增大 GOGC 的值,降低 GC 的运行频率。

18、GMP(重要)

GMP 指的是 Go 的运行时系统(Runtime)中的三个关键组件:G(Goroutine)、M(Machine)、P(Processor)。

Goroutine:

Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理。Goroutines 是并发执行的基本单位,相比于传统的线程,它们更轻量,消耗更少的资源,并由运行时系统调度。在 Go 中,你可以创建成千上万个 Goroutine,并且它们可以非常高效地运行。

M(Machine):

M 表示调度器的线程,它负责将 Goroutines 映射到真正的操作系统线程上。在运行时系统中,有一个全局的 M 列表,每个 M 负责调度 Goroutines。当一个 Goroutine 需要执行时,它会被分配给一个 M,并在该 M 的线程上运行。M 的数量可以根据系统的负载动态调整。

P(Processor):

P 表示处理器,它是用于执行 Goroutines 的上下文。P 可以看作是调度上下文,它保存了 Goroutines 的执行状态、调度队列等信息。P 的数量也是可以动态调整的,它不是直接与物理处理器核心对应的,而是与运行时系统中的 Goroutines 数目和负载情况有关。

GMP 模型的工作原理如下:

  • 当一个 Goroutine 被创建时,它会被放入一个 P 的本地队列。

  • 当 P 的本地队列满了,或者某个 Goroutine 长时间没有被调度执行时,P 会尝试从全局队列中获取 Goroutine。

  • 如果全局队列也为空,P 会从其他 P 的本地队列中偷取一些 Goroutines,以保证尽可能多地利用所有的处理器。

  • M 的数量决定了同时并发执行的 Goroutine 数目。如果某个 M 阻塞(比如在系统调用中),它的工作会被其他 M 接管。

19、Go 中的内存逃逸现象是什么?

内存逃逸(Memory Escape)是指一个变量在函数内部创建,但在函数结束后仍然被其他部分引用,导致变量的生命周期超出了函数的范围,从而使得该变量的内存需要在堆上分配而不是在栈上分配

存逃逸的情况可能发生在以下几种情况:

1、当在函数内部创建一个局部变量,然后返回该变量的指针,而该指针被函数外部的代码引用时,这个局部变量会发生内存逃逸

go 复制代码
func createPointer() *int {
    x := 42
    return &x // x 的内存逃逸
}

2、当将局部变量通过 channel 或 goroutine 传递给其他部分,而这些部分可能在原始函数退出后访问这个变量时,也会导致内存逃逸

go 复制代码
func sendData(ch chan<- *int) {
    x := 42
    ch <- &x // x 的内存逃逸
}

3、如果在函数内部使用 new 或 make 分配的变量 ,即使返回的是指针,但这个指针可能被外部持有,从而导致变量在堆上分配而不是在栈上分配

go 复制代码
func createWithNew() *int {
    x := new(int) // x 的内存逃逸
    return x
}

20、CAP 理论,为什么不能同时满足

CAP 理论是分布式系统设计中的三个基本属性,它们分别是一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance)。CAP 理论由计算机科学家 Eric Brewer 在2000年提出。

1. 一致性(Consistency):

一致性要求系统在所有节点上的数据是一致的。即,如果在一个节点上修改了数据,那么其他节点应该立即看到这个修改。这意味着在任何时刻,不同节点上的数据应该保持一致。

2. 可用性(Availability):

可用性要求系统能够对用户的请求做出响应,即使在出现节点故障的情况下仍然保持可用。可用性意味着系统在出现故障时仍然能够提供服务,尽管可能是部分服务。

3. 分区容错性(Partition Tolerance):

分区容错性是指系统在面对网络分区的情况下仍能够正常工作。即,当节点之间的网络出现故障或无法通信时,系统仍能够保持一致性和可用性。

CAP 理论提出的是在分布式系统中这三个属性不可能同时被满足。这是由于在分布式系统中,网络的不确定性和延迟会导致无法同时满足一致性、可用性和分区容错性。

之后我会持续更新,如果喜欢我的文章,请记得一键三连哦,点赞关注收藏,你的每一个赞每一份关注每一次收藏都将是我前进路上的无限动力 !!!↖(▔▽▔)↗感谢支持!

相关推荐
Anastasiozzzz2 小时前
Redis脑裂问题--面试坑点【Redis的大脑裂开?】
java·数据库·redis·缓存·面试·职场和发展
罗汉松驻扎的工作基地2 小时前
sql server开启远程(适用于2014、2017和2008R2)
运维·服务器·数据库
橙露2 小时前
Linux 运维进阶:Shell 脚本自动化部署与服务器监控实战
linux·运维·服务器
源代码•宸2 小时前
Golang原理剖析(彻底理解Go语言栈内存/堆内存、Go内存管理)
经验分享·后端·算法·面试·golang·span·mheap
myloveasuka2 小时前
汉明编码的最小距离、汉明距离
服务器·数据库·笔记·算法·计算机组成原理
橘颂TA2 小时前
【Linux 网络】从理论到实践:IP 协议的报头分析与分段技术详解
linux·运维·服务器·网络·tcp/ip
呉師傅2 小时前
东芝复印机简单使用说明(2010AC等黑壳机)
运维·服务器·windows·电脑·wps
阿蒙Amon2 小时前
C#每日面试题-break、continue和goto的区别
java·面试·c#
那就回到过去2 小时前
PIM-DM嫁接机制
运维·服务器·网络·智能路由器·pim·ensp