golang八股文

进程、线程、协程

  • 进程:资源分配和CPU调度的基本单位,是操作系统为一个应用程序分配的基本单位。每个进程都有自己独立的地址空间、内存、文件句柄等资源。操作系统通过进程来隔离不同的应用程序,确保它们之间不会互相干扰。
  • 线程:CPU调度的基本单位,线程运行在进程上面,线程除了有一些自己的必要的堆栈空间之外,其它的资源都是共享的线程中的,共享的资源包括:
    1. 线程ID
    2. 寄存器组的值
    3. 线程堆栈
    4. 错误返回码
    5. 信号屏蔽码
    6. 线程的优先级
  • 协程:用户态的线程,可以通过用户程序创建、删除。协程切换时不需要切换内核态
    1. 线程是操作系统的概念,而协程是程序级的概念。线程由操作系统调度执行,每个线程都有自己的执行上下文,包
      括程序计数器、寄存器等。而协程由程序自身控制。
    2. 多个线程之间通过切换执行的方式实现并发。线程切换时需要保存和恢复上下文,涉及到上下文切换的开销。而协
      程切换时不需要操作系统的介入,只需要保存和恢复自身的上下文,切换开销较小。
    3. 线程是抢占式的并发,即操作系统可以随时剥夺一个线程的执行权。而协程是合作式的并发,协程的执行权由程序自身决定,只有当协程主动让出执行权时,其他协程才会得到执行机会。

垃圾回收

三色标记法

  1. 标记开始(Marking Start): GC 暂停程序,标记根对象(比如全局变量、栈上的变量等),这些对象初始被标记为灰色。然后,GC 恢复程序运行并进入并发标记阶段。

  2. 并发标记(Concurrent Marking): 在这个阶段,GC 和程序同时运行。GC 通过遍历灰色对象,将它们标记为黑色,并将它们使用的对象标记为灰色。这个过程会持续,直到没有灰色对象为止。

  3. 清除(Sweep): 在标记阶段结束后,任何仍然为白色的对象被认为是不可达的,因此可以被回收。在这个阶段,GC 将这些白色对象的内存释放掉。

GC的触发条件

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

GC调优

  1. 控制内存分配的速度,限制Goroutine的数量,提高赋值器mutator的CPU利用率(降低GC的CPU利用率)
  2. 少量使用+连接string
  3. slice提前分配足够的内存来降低扩容带来的拷贝
  4. 避免map key对象过多,导致扫描时间增加
  5. 变量复用,减少对象分配,例如使用sync.Pool来复用需要频繁创建临时对象、使用全局变量等
  6. 增大GOGC的值,降低GC的运行频率

GMP调度和CSP模型

CSP 模型

CSP(Communicating Sequential Processes,通信顺序进程)模型是一种并发计算模型,由 Tony Hoare 提出。它的核心思想是通过消息传递而非共享内存的方式在独立的进程之间进行通信。Golang 的并发模型受到 CSP 模型的启发,主要通过 Goroutine 和 Channel 实现这一点。

Goroutine 是 Go 语言中的并发执行单元,类似于 CSP 模型中的“进程”,它们可以独立并发运行。
Channel 是在 Goroutine 之间传递消息的机制,允许 Goroutine 通过消息通信而不是共享内存来协作。Golang 的 Channel 是同步的,这意味着发送方和接收方必须彼此同步才能完成消息传递。

GMP 调度模型

GMP 模型是 Go 语言用于调度 Goroutine 的核心机制,其中:
G(Goroutine):表示一个并发执行单元(Goroutine)。
M(Machine):表示一个操作系统线程,负责实际执行 Goroutine。
P(Processor):表示一个逻辑处理器,管理 Goroutine 的调度。
在 GMP 模型中,多个 Goroutine 被分配到不同的 P,P 负责调度这些 Goroutine 到 M 上执行。M 实际运行 Goroutine 的代码,P 则通过一个本地队列管理它的 Goroutine。

Goroutine 的切换时机

Goroutine 的切换是调度器决定的,通常发生在以下情况:

  • I/O 阻塞:当一个 Goroutine 因为 I/O 操作而阻塞时,调度器会切换到另一个 Goroutine 继续执行。
  • 主动让出(runtime.Gosched()):Goroutine 可以通过调用 runtime.Gosched() 主动让出 CPU 以允许其他 Goroutine 执行。
  • 系统调用:在执行系统调用时,Goroutine 也可能被切换。
  • 抢占式调度:当一个 Goroutine 长时间占用 CPU 时,调度器可能会强制切换。

Goroutine 调度原理

Golang 的调度器负责管理 Goroutine 的执行。当一个 P 的 Goroutine 队列为空时,它可以从其他 P 的队列中窃取 Goroutine 以继续执行,实现负载均衡,和确保 CPU 资源得到充分利用。

Goroutine 的抢占式调度

抢占式调度是指调度器可以强制中断正在运行的 Goroutine,将其切换到其他 Goroutine 上执行,以避免单个 Goroutine 长时间占用 CPU。这是为了防止一个 Goroutine 因为长时间运行而导致其他 Goroutine 不运行。Golang 的调度器通过在 Goroutine 执行一段时间后检查其状态,决定是否要进行抢占。

Context上下文

Context 是 Go 语言中用于在 Goroutine 之间传递取消信号、超时控制以及元数据的机制。它的主要作用是在并发编程中管理请求的生命周期,尤其是在处理复杂的分布式系统时非常有用。

结构

Context 是一个接口,主要有以下几种常用实现:

  1. context.Background()返回一个空的 Context,通常作为顶层 Context 使用,在主函数、初始化或测试时使用。

  2. context.TODO()也是一个空的 Context,但表示尚未确定使用的 Context,通常用于代码未完成的部分。

  3. context.WithCancel(parent Context)基于父 Context 创建一个可取消的 Context,并返回该 Context 以及一个取消函数 cancel。调用 cancel() 后,Context 会发出取消信号。

  4. context.WithDeadline 和 context.WithTimeout这两个函数基于父 Context 创建带有截止时间或超时时间的 Context。超时或达到截止时间后,Context 自动取消。

  5. context.WithValue(parent Context, key, value interface{}):基于父 Context 创建一个携带键值对的 Context。常用于在多个 Goroutine 之间传递请求相关的信息。

Context 的工作原理

Context 是一个层级结构,每个 Context 都可以有一个父 Context,形成一个树状结构。通过这种层次结构,Context 能够将取消信号、超时控制以及元数据传递给相关的子 Context。以下是它的几个关键功能:

  • 取消信号:
    当父 Context 被取消时,所有从它派生出来的子 Context 也会被取消。这使得在分布式系统中,可以在请求完成、超时或用户取消时,及时终止不再需要的操作,避免资源浪费。

  • 超时控制:
    通过 WithDeadline 或 WithTimeout 创建的 Context 能够在达到指定时间点后自动取消,这对于处理需要超时控制的操作非常有用。

  • 元数据传递:
    通过 WithValue,可以在 Goroutine 之间传递键值对信息。这通常用于传递请求范围内的元数据,例如用户身份信息、追踪 ID 等。

资源竞争

  1. 资源竞争,就是在程序中,同一块内存同时被多个 goroutine 访问。我们使用 go build、go run、go test 命令时,添加 -race 标识可以检查代码中是否存在资源竞争。解决这个问题,我们可以给资源使用互斥锁,让其在同一时刻只能被一个协程来操作。
    sync.Mutex //互斥锁
    sync.RWMutex //读写互斥锁
var mu sync.Mutex
var rw sync.RWMutex
var counter int

//读写锁
func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

//读锁,只可读取无法写入
func read() int {
    rw.RLock()
    defer rw.RUnlock()
    return counter
}

//写锁,只有一个Goroutine 可以持有写锁
func write(value int) {
    rw.Lock()
    counter = value
    rw.Unlock()
}
var mu sync.Mutex
var rw sync.RWMutex
var counter int

//读写锁
func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

//读锁,只可读取无法写入
func read() int {
    rw.RLock()
    defer rw.RUnlock()
    return counter
}

//写锁,只有一个Goroutine 可以持有写锁
func write(value int) {
    rw.Lock()
    counter = value
    rw.Unlock()
}

原子操作atomic包

Go atomic包是最轻量级的锁(也称无锁结构),可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作,不过这个包只支持int32/Int64/uint32/uint64/uintptr这几种数据类型的一些基础操作(增减、交换、载入、存储等)

使用sync/atomic包的原子 操作,它能够保证对变量的读取或修改期间不被其他的协程所影响。atomic 包提供的原子操作能够确保任一时刻只有一个goroutine对变量进行操作,善用atomic能够避免程序中出现大量的锁操作。

原子操作和锁的区别

  1. 原子操作由底层硬件支持,而锁是基于原子操作+信号量完成的。若实现相同的功能,前者通常会更有效率
  2. 原子操作是单个指令的互斥操作;互斥锁/读写锁是一种数据结构,可以完成临界区(多个指令)的互斥操作,扩大原子操作的范围
  3. 原子操作是无锁操作,属于乐观锁;说起锁的时候,一般属于悲观锁
  4. 原子操作存在于各个指令/语言层级,比如*机器指令层级的原子操作",““汇编指令层级的原子操作”,“Go语言层级的原子操作”等。
  5. 锁也存在于各个指令/语言层级中,比如“机器指令层级的锁”,“汇编指令层级的锁“Go语言层级的锁“等

内存逃逸

内存逃逸 是指当一个变量本应该分配在栈上,但由于某些原因,编译器决定将其分配在堆上。这种情况通常发生在变量的生命周期 超过了它所在的函数或 Goroutine 的作用域。

func createPointer() *int {
    x := 42
    return &x //返回了一个无效的指针地址
}
func createPointer() *int {
    x := 42
    return &x //返回了一个无效的指针地址
}

当一个函数执行完毕就会释放掉栈帧,但编译器如果不确定其类型为了也能访问就会放到堆,这样会带来额外的性能损耗。
go build -gcflags="-m" 检测内存逃逸

内存对齐

为了能让CPU可以更快的存取到各个字段,Go编译器会帮你把struct结构体做数据的对齐。所谓的数据对齐,是指内存地址是所存储数据大小(按字节为单位)的整数倍,以便CPU可以一次将该数据从内存中读取出来。编译器通过在结构体的各个字段之间填充一些空白已达到对齐的目的。

不同硬件平台占用的大小和对齐值都可能是不一样的,每个特定平台上的编译器都有自己的默认“对齐系数”,32位系统对齐系数是4,64位系统对齐系数是8
不同类型的对齐系数也可能不一样,使用Go 语言中的unsafe.Alignof函数可以返回相应类型的对齐系数,对齐系数都符合2^n这个规律,最大也不会超过8

整个结构体的地址必须是最大字节和编译器默认对齐系数两者最小值的整数倍(结构体的内存占用是1/4/8/16 byte...)

所以结构体内部的数据类型顺序也要按金字塔一样这样最节省内存

//椭圆形,这样浪费了内存
type Example1 struct {
    a int8    // 1 byte
    b int64   // 8 bytes
    c int8    // 1 byte
}

//金字塔型,这样节省了内存
type Example2 struct {
    b int64   // 8 bytes
    a int8    // 1 byte
    c int8    // 1 byte
}
//椭圆形,这样浪费了内存
type Example1 struct {
    a int8    // 1 byte
    b int64   // 8 bytes
    c int8    // 1 byte
}

//金字塔型,这样节省了内存
type Example2 struct {
    b int64   // 8 bytes
    a int8    // 1 byte
    c int8    // 1 byte
}

new和make区别

var声明值类型的变量时,系统会默认为他分配内存空间,并赋该类型的零值如果是指针类型或者引用类型的变量,系统不会为它分配内存,默认是nil。

  1. make 仅用来分配及初始化类型为 slice、map、chan 的数据。
  2. new 可分配任意类型的数据,根据传入的类型申请一块内存,返回指向这块内存的指针,即类型 *Type。
  3. make 返回引用,即 Type,new 分配的空间被清零, make 分配空间后,会进行初始。
  4. make函数返回的是slice、map、chan类型本身
  5. new函数返回一个指向该类型内存地址的指针

数组和切片

切片(Slice)

切片 是对数组的抽象,是一种动态大小的、灵活的数据结构。切片实际上是一个引用类型,它引用了一个底层数组。

特点:

  • 动态大小: 切片可以动态增长或收缩,不需要在创建时指定固定的长度。
  • 引用类型: 切片是引用类型,指向底层的数组。当你修改切片时,实际上是在修改底层数组的数据。
  • 包含三个部分:
    • 指针: 指向底层数组的起始位置。
    • 长度: 切片当前的长度,即包含的元素数。
    • 容量: 从切片起始位置到底层数组末尾的元素数

切片扩容

  1. 基础扩容规则
    • 当切片的容量小于 1024 元素时:
    • 新切片的容量将会是旧容量的两倍。即 newCap = oldCap * 2。
    • 当切片的容量大于等于 1024 元素时:
      • 新切片的容量将会以 oldCap * 1.25 的倍数增加。即 newCap = oldCap + oldCap/4,这是为了减少过大的内存分配。
  2. 触发扩容的条件
    • 当向切片中追加元素时,如果追加后的元素个数超过了当前切片的容量(cap),就会触发扩容。
    • Go 运行时会为切片分配一个新的底层数组,并将旧数组的数据复制到新数组中。
  3. 特殊情况下的处理
    • 当扩容后的新容量与当前容量相比:
    • 如果你明确指定了一个比当前容量更大的新容量(例如通过 append 指定),Go 运行时会使用你指定的容量,直接分配对应大小的内存空间。

数组(Array)

数组 是一种固定大小的、同质数据类型的集合。在定义时,数组的长度必须明确,且一旦定义,长度不可更改。

特点:

  • 固定大小: 数组的大小在声明时就固定了,无法改变。
  • 值类型: 数组是值类型,意味着当你将一个数组赋值给另一个数组时,会进行一次完整的拷贝。
  • 内存连续性: 数组的所有元素在内存中是连续存储的。

适用场景

在内存中数组存在栈,切片存放在堆。
数组 适用于需要固定大小的集合,具有更高的访问速度和内存效率,但缺乏灵活性。
切片 适用于需要动态大小和灵活内存管理的场景,提供了更强的灵活性和使用便捷性。

var arr [5]int = [5]int{1, 2, 3, 4, 5} // 定义一个长度为5的整型数组
var s []int = []int{1, 2, 3, 4, 5} // 定义一个切片
var arr [5]int = [5]int{1, 2, 3, 4, 5} // 定义一个长度为5的整型数组
var s []int = []int{1, 2, 3, 4, 5} // 定义一个切片

Map

map 是引用类型,可以使用 make 函数或字面量语法进行初始化。

// 使用 make 函数初始化
m := make(map[string]int)

// 使用字面量初始化
m := map[string]int{
    "one": 1,
    "two": 2,
}
// 使用 make 函数初始化
m := make(map[string]int)

// 使用字面量初始化
m := map[string]int{
    "one": 1,
    "two": 2,
}

键(Key):map 中的键必须是支持比较操作的类型,如字符串、整数、布尔值等。键的类型必须是唯一的,不能重复。
值(Value):map 中的值可以是任何类型。

特点:

  • 无序:map 中的元素没有顺序,遍历时键的顺序是随机的。
  • 动态扩展:map 的大小是动态的,会根据存储的数据量自动扩展。
  • 高效查找:通过哈希表实现,map 提供了常数时间复杂度的查找、添加和删除操作。
  • 使用前要初始化

Channel

  1. 定义和初始化

    1. ch := make(chan int)// 创建一个传递 int 类型数据的 Channel,无缓冲
    2. ch := make(chan int, 3) // 创建一个有缓冲区的 Channel,缓冲区大小为 3
  2. 发送和接收数据
    1. 发送数据:ch <- 42 // 将数据 42 发送到 Channel ch
    2. 接收数据: value := <-ch // 从 Channel ch 接收数据,并赋值给变量 value
    3. 复用 select 语句来实现非阻塞的接收

    select {
    case value := <-ch:
    fmt.Println("Received:", value)
    default:
    fmt.Println("No data received")
    }
    select {
    case value := <-ch:
    fmt.Println("Received:", value)
    default:
    fmt.Println("No data received")
    }

使用场景

  1. 停止信号监听
  2. 定时任务
  3. 生产方和消费方解耦
  4. 控制并发数

Channel是异步进行的,它有三种状态

  1. nil,未初始化的状态,只进行了声明,或者手动赋值为nil
  2. active,正常的channel,可读或者可写
  3. closed,已关闭,千万不要误认为关闭channel后,channel的值是nil,对已关闭channel读写都会panic
状态 nil 空值 有值
发送 阻塞 发送成功 发送成功 阻塞
接收 阻塞 阻塞 接收成功 接收成功
关闭 panic 关闭成功 关闭成功 关闭成功

Channel死锁场景

  1. 无缓冲 Channel 的发送或接收操作没有对应的 Goroutine
  2. 所有 Goroutine 都在等待 Channel 数据
  3. 对已关闭的 Channel 继续发送数据
  4. 单 Goroutine 中既发送又接收
  5. 循环中的 Channel 死锁
  6. select 语句中所有通道都阻塞

Groutine的泄露

  1. Goroutine内进行channel/mutex等读写操作被一直阻塞。
  2. Goroutine内的业务逻辑进入死循环,资源一直无法释放。
  3. Goroutine内的业务逻辑进入长时间等待,有不断新增的Goroutine进入等待

查看和限制Goroutine的数量

在开发过程中,如果不对goroutine加以控制而进行滥用的话,可能会导致服务整体崩溃。比如耗尽系统资源导致程序崩溃,或者CPU使用率过高导致系统忙不过来。

  • 通过GOMAXPROCS可以查看Goroutine的数量
  • 使用通道。每次执行的go之前向通道写入值,直到通道满的时候就阻塞了

Goroutine和线程的区别?

1.一个线程可以有多个协程
2.线程、进程都是同步机制,而协程是异步
3.协程可以保留上一次调用时的状态,当过程重入时,相当于进入了上一次的调用状态
4.协程是需要线程来承载运行的,所以协程并不能取代线程,「线程是被分割的CPU资源,协程是组织好的代码流程」

Go的Struct能不能比较?

1.相同struct类型的可以比较
2.不同struct类型的不可以比较,编译都不过,类型不匹配

Go的内存泄露

  1. Goroutine 泄露

    • Goroutine 是轻量级的线程,但如果 Goroutine 被创建后没有适当的退出条件,或者因为逻辑问题阻塞而不能结束,可能会导致 Goroutine 泄露,进而造成内存泄露。
    • 例如,在一个无缓冲的 Channel 上等待数据而没有发送者,或者等待条件永远无法满足。
  2. 持久引用导致的内存无法回收

    • 如果某些数据结构(如切片、Map、指针等)对对象的引用一直存在,垃圾回收器将无法回收这些对象,导致内存泄露。
    • 例如,切片底层数组未释放,即使缩短了切片的长度,但其底层数组的内存仍然被保留。
  3. 全局变量或长生命周期变量

    • 全局变量或生命周期较长的变量持有大量数据,如果不及时释放或清理,会导致内存泄露。
    • 例如,一个全局 Map 持有大量数据,但程序并未清理不再使用的条目。
  4. 闭包引用

    • 闭包可能引用外部变量,这些变量不会被垃圾回收,直到闭包不再被引用。如果闭包被长时间持有,可能导致内存泄露。
  5. 缓存不当管理

    • 不适当的缓存策略,特别是当没有淘汰机制时,缓存可能会无限增长,导致内存泄露。

解决内存泄漏

  • 用命令检测:
    go tool pprof http://localhost:8080/debug/pprof/heap

  • 优化 Goroutine 使用
    使用 context.Context 来管理 Goroutine 的生命周期,确保 Goroutine 在不再需要时能及时退出。

    func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    
    go func(ctx context.Context) {
        select {
        case <-ctx.Done():
            return
        }
    }(ctx)
    }
    func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    
    go func(ctx context.Context) {
        select {
        case <-ctx.Done():
            return
        }
    }(ctx)
    }
  • 数据结构优化

    • 定期清理缓存或引用,避免长时间持有不再使用的内存。
    • 例如,可以使用 sync.Map 或 time.Ticker 结合清理过期的缓存条目。

Go语言中的内存对齐

CPU 并不会以一个一个字节去读取和写入内存。相反 CPU 读取内存是一块一块读取的,块的大小可以为 2、4、6、8、16 字节等大小。块大小我们称其为内存访问粒度,内存访问粒度跟机器字长有关。

对齐规则:

  1. 结构体的成员变量,第一个成员变量的偏移量为 0。往后的每个成员变量的对齐值必须为编译器默认对齐长度或当前成员变量类型的长度,取最小值作为当前类型的对齐值。其偏移量必须为对齐值的整数倍
  2. 结构体本身,对齐值必须为编译器默认对齐长度,或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值
  3. 结合以上两点,可得知若编译器默认对齐长度,超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的

go 打印时 %v %+v %#v 的区别?

  • %v 只输出所有的值;
  • %+v 先输出字段名字,再输出该字段的值;
  • %#v 先输出结构体名字值,再输出结构体(字段名字+字段的值);

什么是 rune 类型?

Go语言的字符有以下两种:

  1. uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。
  2. rune 类型,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型等价于 int32 类型。

空 struct{} 占用空间么?用途是什么?

空结构体 struct{} 实例不占据任何的内存空间。

用途:

  • 将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
  • 不发送数据的信道(channel),使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。
  • 结构体只包含方法,不包含任何的字段

指针

  • 如果方法的接收者是指针类型,无论调用者是对象还是对象指针,修改的都是对象本身,会影响调用者

  • 如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者

defer延迟函数调用

defer 关键字用于延迟函数调用,直到包含 defer 的函数返回时才执行。这种机制经常用于确保资源释放(如文件关闭、锁解锁)或其它必须在函数结束时执行的操作
由于 defer 会引入运行时的额外操作,特别是在高频调用的函数中,defer 可能带来一定的性能开销
defer 经常与 recover 和 panic 一起使用,用于异常处理。即使发生 panic,被 defer 的函数也会执行,这样可以确保资源被正确释放或进行必要的清理工作。

func example() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("这是运行错误:", r)
        }
    }()
    panic("遇到错误停止运行")
}
func example() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("这是运行错误:", r)
        }
    }()
    panic("遇到错误停止运行")
}

select语句

select 语句用于监听和处理多个通道(channel)上的数据传递。它可以看作是 Go 语言中用于处理并发编程的一种控制结构,类似于 switch 语句,但专门用于处理通道的操作。

select {
case <-chan1:
    // 当 chan1 有数据可读时,执行此 case
case chan2 <- value:
    // 当 chan2 可以写入数据时,执行此 case
default:
    // 如果没有任何 case 可以执行,则执行此 default
}
select {
case <-chan1:
    // 当 chan1 有数据可读时,执行此 case
case chan2 <- value:
    // 当 chan2 可以写入数据时,执行此 case
default:
    // 如果没有任何 case 可以执行,则执行此 default
}

select主要特性

  • 多通道选择:select 可以同时监听多个通道的读写操作,当其中一个通道可以操作时(即通道可以读取数据或写入数据),相应的 case 就会被执行。

  • 随机选择:如果有多个 case 同时准备好,select 会随机选择其中一个 case 来执行。因此,select 可以用于实现均衡的负载分配。

  • 阻塞行为:如果所有的通道都没有准备好,且没有 default 分支,select 会阻塞,直到有一个通道可以进行操作。

  • 非阻塞行为:如果 select 包含 default 分支且所有通道都没有准备好,则 default 分支会立即执行,从而实现非阻塞的行为。

select使用场景

  • 多通道并发处理:通过 select 可以同时监听多个通道,从而实现对多个并发操作的处理。例如,在处理多个网络连接时,可以使用 select 来同时监听多个连接的状态变化。

  • 超时控制:通过 select 可以很方便地实现对某个操作的超时控制。常见的做法是使用一个带有超时的通道来与其他通道一起监听。

  • 实现消息优先级:通过在 select 中定义不同的 case 顺序,可以实现消息的优先级处理。例如,可以先处理紧急通道的数据,然后再处理普通通道的数据。

gRPC

gRPC是基于go的远程过程调用。RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。

反射

反射(reflection)是一种在运行时检查、修改和操作程序结构的机制。反射允许程序在不知道具体类型的情况下处理任意类型的数据,这对于一些动态场景(如序列化、反序列化、测试框架等)非常有用。

反射的弊端

  1. 代码不易阅读,不易维护,容易发生线上panic
  2. 性能很差,比正常代码慢一到两个数量级

Go 语言的反射主要由 reflect 包提供,反射机制围绕以下三个核心概念:

  1. 类型(Type):通过反射可以获得变量的类型信息,即它是什么类型的。
  2. 值(Value):通过反射可以获得变量的值,即具体的数据是什么。
  3. 接口(Interface):反射的入口通常是一个接口类型,反射机制通过接口来接收和处理任意类型的数据。
package main
import (
    "fmt"
    "reflect"
)
func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(x)
    fmt.Println("value:", v)          // 输出: value: 3.4
    fmt.Println("type:", v.Type())    // 输出: type: float64
    fmt.Println("kind:", v.Kind())    // 输出: kind: float64
}
package main
import (
    "fmt"
    "reflect"
)
func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(x)
    fmt.Println("value:", v)          // 输出: value: 3.4
    fmt.Println("type:", v.Type())    // 输出: type: float64
    fmt.Println("kind:", v.Kind())    // 输出: kind: float64
}

修改值

通过反射可以修改变量的值,但前提是这个变量必须是可设置的(即传入的是指针类型)。

package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(&x) // 注意这里是传入指针
    v = v.Elem()             // 获取指针指向的元素
    v.SetFloat(7.1)          // 修改值
    fmt.Println(x)           // 输出: 7.1
}
package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(&x) // 注意这里是传入指针
    v = v.Elem()             // 获取指针指向的元素
    v.SetFloat(7.1)          // 修改值
    fmt.Println(x)           // 输出: 7.1
}

反射的使用场景

  1. 通用函数:反射可以用来编写通用的函数,能够处理不同类型的输入。例如,一个可以打印任意类型变量的函数。

  2. 序列化/反序列化:反射常用于实现通用的序列化(如 JSON、XML)和反序列化函数,它可以根据结构体的字段动态地构造和解析数据。

  3. 测试框架:反射在自动化测试框架中也很有用,可以根据函数的签名自动执行测试用例。

Golang字符串拼接

  • +号拼接:最简单,但在大量拼接时性能最差,适合小规模拼接。
  • fmt.Sprintf:功能强大但性能较差,不推荐在性能关键的地方大量使用。
  • strings.Builder:对于纯字符串拼接,性能最好,推荐在大量拼接时使用。
  • bytes.Buffer:性能仅次于 strings.Builder,适合处理 []byte 的场景。
//+号拼接
str := "Hello" + " " + "World"
//fmt.Sprintf
str := fmt.Sprintf("%s %s", "Hello", "World")
//性能最佳的拼接方法
var builder strings.Builder
builder.WriteString("Hello")
builder.WriteString(" ")
builder.WriteString("World")
str := builder.String()
//适合字节的拼接
var buffer bytes.Buffer
buffer.WriteString("Hello")
buffer.WriteString(" ")
buffer.WriteString("World")
str := buffer.String()
//+号拼接
str := "Hello" + " " + "World"
//fmt.Sprintf
str := fmt.Sprintf("%s %s", "Hello", "World")
//性能最佳的拼接方法
var builder strings.Builder
builder.WriteString("Hello")
builder.WriteString(" ")
builder.WriteString("World")
str := builder.String()
//适合字节的拼接
var buffer bytes.Buffer
buffer.WriteString("Hello")
buffer.WriteString(" ")
buffer.WriteString("World")
str := buffer.String()

常见字符集

  • ASCII:采用了一个字节存储一个字符,首位是0,总共能能表示128个字符(英文、符号等)
  • UTF-8:可变长编码方案(兼容标准ASCII编码),总共分为四个长度区:1个字节、2个字节、3个字节、4个字节。英文字符、数字等只占1个字节,汉字字符占用3个字节。

golang中指针的作用

  1. 传递大对象
  2. 修改函数外部变量
  3. 动态分配内存
  4. 函数返回指针

gin的架构模式

Gin 框架主要基于 MVC(Model-View-Controller) 架构模式和 中间件链(Middleware Chain) 模式来设计和组织代码。

MVC(Model-View-Controller)架构模式

  • Model(模型): 负责数据的处理与逻辑操作,通常是与数据库交互的部分。在 Gin 应用中,这部分通常由结构体(struct)和对应的方法实现。

  • View(视图): 负责用户界面的呈现。在 Gin 中,视图层可以使用 HTML 模板渲染或直接返回 JSON、XML 等格式的数据给客户端。

  • Controller(控制器): 控制器是应用程序的核心部分,负责处理用户请求、调用模型中的逻辑,并将结果返回给视图层。在 Gin 中,控制器通常是路由对应的处理函数,它们接收 *gin.Context,进行相应的业务逻辑处理。

中间件(Middleware Chain)

中间件链: 请求到达 Gin 的时候,会依次通过一系列中间件。这些中间件可以选择是否调用 c.Next() 来继续下一个中间件或者处理函数,或者直接终止请求链。通过这种方式,Gin 可以灵活地处理请求的各个阶段。

协程池

  1. 限制协程的数量,不让协程无限制地增长
  2. 减少GC和协程创建的开销

math/rand为何加锁

math/rand 包用于生成伪随机数。math/rand 中的伪随机数生成器(PRNG)并不是线程安全的,因为它内部维护了一些状态(比如种子 seed),在生成随机数时需要对这些状态进行读写操作。如果多个 Goroutine 同时访问和修改这些状态,可能会导致数据竞争,产生不可预测的行为和错误结果

  • rand.New 生成独立的 Rand 实例: math/rand 提供了 rand.New 方法,可以生成一个新的 Rand 对象,每个 Rand 实例有自己独立的状态。如果每个 Goroutine 使用自己的 Rand 实例,就不需要加锁,从而避免竞争条件,同时还能提高性能。