1. slice

1.1 底层实现

  • 利用数组创建切片时,比如array[:] arrary [1:3],切片的数组指针都是指向arrary的,当然从1开始切的地方地址有偏移(64位系统int偏移8字节)。
  • 切片追加元素,修改元素都是在修改底层数组,对于共享底层数组的多个切片来说,会相互产生影响。与Map一样,Slice也不是并发安全的。用copy函数。
  • 切片时如果长度为1的切片可以切[1:],返回空切片,因为要求的切片低位<=高位<=len.

1.2 扩容

  • 避免重建slice的底层数组,在扩展切片时,并非按需分配,而是2的幂次方,当二的幂次方变得很大时<1024,是按照1/4。所以大部分时候cap大于len。实际上还要考虑内存对齐,扩容是大于或者等于1.25 倍。
  • 扩容的影响导致原切片和传递后的切片不再有关联。

1.3 slice 与数组

切片与数组:可以覆盖数组全部功能吗?那么数组存在的意义是什么?

- 数组值类型,赋值或传递时会复制整个数据。这种特性在某些场景下是有用的,比如:

  • 确保数据不会被意外修改(因为数组每次传递副本)
  • 对数据隔离性要求高的场景(如并发安全)

- 切片引用类型,赋值或传递时共享底层数据。如果多个切片引用同一个底层数组,修改一个切片可能影响其他切片。

- 数组更有可能在栈上分配,而切片由于额外的元数据和动态性,更容易逃逸到堆上

- 强制要求数组的某些库或框架的接口(如加密库中固定长度的哈希值、固定大小的缓冲区)。

1.4 for range 注意事项

for a,b := range c遍历中,a和b在内存中只会存在一份,即之后每次循环时遍历到的数据都是以值覆盖的方式赋给a和b,ab的内存地址始终不变。由于有这个特性,for循环里面如果开协程,不要直接把a或者b的地址传给协程。解决办法:在每次循环时,创建一个临时变量。


2. rune和go字符串

(1) 底层

  • byte 等同于int8,常用来处理ascii字符;rune 等同于int32,常用来处理unicode或utf-8字符;
  • Go 的字符串本质上是一个只读的字节切片([]byte与编码无关:字符串只是字节序列,不关心内容是否是 UTF-8。虽然字符串底层是字节数组,但Go约定字符串字面量()range 遍历等场景默认按 UTF-8 (占3字节)处理。结构体实现:
type string struct {
    data *byte  // 指向底层字节数组的指针
    len  int    // 字符串的字节长度(非字符数!)
}

(2)单引号,双引号,反引号的区别

  • 单引号,表示byte类型或rune类型,对应 uint8和int32类型,默认是 rune 类型
  • 双引号,才是字符串,实际上是字符数组。可以用索引号访问某字节。
  • 反引号,表示字符串字面量,但不支持任何转义序列。字面量 raw literal string 的意思是,你定义时写的啥样,它就啥样,你有换行,它就换行。你写转义字符,它也就展示转义字符。

3. make和new

(1)区别

  1. 作用变量类型不同;new给string,array分配内存;make给slice,map,channel分配内存。
  2. 返回类型不一样,new返回指向变量的指针,make返回变量本身
  3. new分配的空间被清零;make分配空间后,会进行初始化

(2)相同

都是给变量分配内存, make与new对堆栈分配处理是相同的,编译器优先进行逃逸分析,逃逸的才分配到堆上。


4. map

  • 切片、函数、map不能作为key,但通道、接口、结构体可以。在golang规范中,可比较的类型都可以作为map key。(切片底层数组指针会不一样,map hash会重排,两个函数地址一样,但闭包不同,可能行为完全不同(每个闭包都带着自己的“上下文”)。)
  • nil map未初始化,取出来的东西返回零值,一定要先初始化,否则赋值会panic(无哈希桶指针为nil)。初始化后空map是长度为空,底层已分配哈希桶结构,可安全读写。
  • map中删除一个key,它的内存会释放么? 其内存是否释放取决于值的类型。基本类型的内存由map复用,不会立即释放;由map的桶管理。引用类型的内存由GC回收,但map的桶内存不会立即缩容。

4.1 底层

// map数据结构:
type hmap struct{
    count int // 元素总个数
    flags uint8
    B     uint8 // 桶个数的对数,2^B个桶
    noverflow uint16 // 溢出桶的个数
    hash0 uint32 // hash种子
    
    buckets unsafe.Pointer // map的底层是一个桶数组,这是那个数组的指针
    oldbuckets unsafe.Pointer // 发生桶迁移时,指向旧桶的指针
    nevacuate uintptr // 迁移进度
    
    extra *mapextra // 存map溢出桶
}

// 桶的结构:
type bmap struct { // bucketCnt=8,每个桶存8个
    tophash [bucketCnt]uint8 // key的哈希值前8位,存在时再进一步匹配key,当key hash在这里不存在时会去溢出桶访问
    
    // 后面的字段是在编译时期动态追加的
    key [bucketCnt]T // 分开key、val放置主要是为了字节对齐,压缩空间
    value [bucketCnt]T
    pad uintptr
    overflow uintptr // 指向下一个桶的指针
}
  • map访问无序的原因:map 因扩张而重新哈希时,各键值项存储位置都可能会发生改变,顺序自然也没法保证了,所以官方避免大家依赖顺序,直接打乱处理。就是for range map在开始处理循环逻辑的时候,就做了随机播种。
  • 如果发生了哈希冲突两个key的hash值落到同一个bucket,但这个bucket还没满、负载因子也没超标未超过6.5,Go会怎么处理?将这个key插入同一个bucket的空位中,使用开放地址法解决冲突。如果当前bucket已满,则创建 overflow bucket(链表串接)。采用拉链法防止碰撞,但是链表①地址不连续没法高效利用CPU缓存;②整个map会多出存指针的开销。

4.2 map并发怎么锁

(1) 锁之间区别

map和sync.map的区别是什么?加了锁的map和sync.map的区别? sync.map详解

map非线程安全,map不支持并发读写但是可以并发读。sync.RWMutex+map实现简单,但是适合读多写少场景。写操作阻塞所有读写,高并发下性能差。当写操作增加会导致性能接近完全串行化。除了锁,原子操作也能达到类似并发安全的目的。sync.map在查找指定的key的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当read中没有,但dirty中可能会有这个key的时候,才会在锁的保护下去访问dirty。正因如此sync.Mapread/dirty机制会导致约30%的内存冗余

不同读写并发下怎么使用锁。(其中在竞争加剧时,相较于互斥锁[使得数据访问完全串行化了],RWMutex需要维护更复杂的状态[读者计数、写者等待等],导致更多原子操作和内存屏障,所以性能会下降。更进一步,分片锁是一种将互斥锁与数据分片(Sharding)结合的高性能并发控制策略。)

graph TD
    A[需要并发安全?] -->|Yes| B{数据规模}
    B -->|小数据量| C[全量替换map指针]
    B -->|大数据量| D{读写比例}
    D -->|读多写少| E[sync.Map]
    D -->|读写均衡| F[RWMutex+Map]
    D -->|写密集型| G[分片锁+Map]
// Map指针原子替换策略,指通过原子操作(如atomic.Value)来原子性地替换整个map的指针,从而实现无锁(lock-free)的并发访问。
var config atomic.Value // 存储map的指针
config.Store(make(map[string]string))
方案 核心优势 劣势 典型适用场景
Mutex+Map 强一致性 完全串行化 写密集型+低延迟要求
RWMutex+Map 读性能优化 写操作仍完全互斥, 高竞争时可能不如Mutex 读写均衡的业务逻辑
sync.Map 读旧数据无锁(read map) 内存占用高,写多的场景read map缓存失效,冲突变多性能急剧下降 配置/元数据等读多写少
分片锁 减少锁竞争 分片导致的热点放大,热点分片退化 超高并发写入场景,KV和缓存
原子替换(Map指针) 零竞争 内存占用翻倍 分钟级更新,数据量极小

(2) 异常捕获

多个goroutine对同一个map写会panic,异常是否可以用defer捕获?可以捕获异常,但是只能捕获一次。

5. channel

  • 是协程安全的消息队列,多协程间通信手段。如果多个goroutine监听同一个channel,如果这个channel被关闭,则所有goroutine都能收到退出信号
  • 使用场景: 消息传递、消息过滤,信号广播,事件订阅与广播,请求、响应转发,任务分发,结果汇总,并发控制,限流,同步与异步。
状态\行为 关闭(close 发送(ch <- v 接收(<-ch
未初始化 panic 永久阻塞(所以说要make啊) 永久阻塞
正常 成功关闭 阻塞或写入(视缓冲区情况) 阻塞或读取(视数据/关闭状态)
已关闭 panic panic 读完数据后返回零值(ok=false

5.1 意义

go为什么要有channel?没有channel会怎样?

总:channel是Go并发的第一公民,如果没有它goroutine的优势就难以发挥出来。

分:①goroutine之间需要协作、同步、传递数据,如果没有channel,那就得用共享内存、锁、信号量等原语去实现,复杂又容易出错。② Go的并发模型不是用锁管理共享资源,而是通过channel把数据交给需要的人让资源拥有者处理自己的数据

总:是CSP(Communicating Sequential Processes)模型的体现。“进程之间不共享内存,而是通过通信(发送消息)来协作”。

5.2 底层

type hchan struct {
    elemsize uint16 // 通道类型的大小
    closed uint32 // 关闭状态
    elemtype *_type // 通道的数据类型

    recvq waitq // 等待从channel中读取数据,正在被阻塞的协程队列
    sendq waitq // 等待从channel中写入数据,正在被阻塞的协程队列
    lock mutex // 并发保护,先获取互斥锁才能操作channel
    
    // -------如果有缓冲区的话-------
    qcount uint // channel里的数据个数
    dataqsize uint // 缓冲区大小
    buf unsafe.Pointer // 缓冲区指针,指向循环队列 (sendx和recvx是其中的下标)
    sendx uint // 缓冲区中,已经被读取的数据的位置
    recvx uint // 缓冲区中,当前已经写入的数据的位置 
}

5.3 goroutine 接收消息细节

当协程A写入数据时:if recvq 有,则代表无缓冲区或者缓冲区中无数据,A要写的数据交给 recvq的第一个;if没有,那就尝试写入缓冲区;当缓冲区已满或者没有缓冲区,阻塞A这个协程,并将A加入sendq。

graph TD
    A[协程A尝试写入数据] --> B{recvq队列是否为空?}
    B -->|No| C[将数据直接交给recvq第一个等待者]
    C --> D[唤醒对应接收协程]
    B -->|Yes| F{缓冲区是否可用且未满?数据写入哪里等另一个协程告诉你或者放缓冲区}
    F -->|Yes| G[数据写入缓冲区]
    F -->|No| H[阻塞协程A并加入sendq队列]
  • 协程B读取数据时:检查sendq,为空尝试从缓冲区中获取,缓冲区无则把自己也加入进去;sendq不为空,且无缓冲区,那么从第一个sendq读数据;sendq不为空,且有缓冲区,从缓冲区读数据。(但是也有看到一些书说FIFO,应该先读缓冲区)
graph TD
    A[协程B尝试读取数据] --> B{sendq队列是否为空?}
    B -->|No| H{是否有缓冲区?}
    H -->|No| C[直接从sendq第一个发送者读取数据]
    H -->|Yes| I[说明缓冲区满了,从缓冲中拿出第一个\n并唤醒一个发送goroutin写入缓冲区]
    I --> E
    C --> D[唤醒该发送者]
    D --> E[流程结束]
    B -->|Yes| F{缓冲区是否有数据?}
    F -->|Yes| G[从缓冲区读取数据]
    G --> E
    F -->|No| K[阻塞协程B并加入recvq队列]
    K --> E

6. select

6.1 执行

select仅支持管道,而且是单协程操作。

graph TD    
    C[开始执行select] --> E[按随机顺序遍历所有case]
    E --> F{当前case是否就绪?}
    F -->|Yes| G[执行该case操作]
    G --> H[返回选择的case索引]
    F -->|No| I{是否遍历完所有case?}
    I -->|No| E
    I -->|Yes| J{存在default case?}
    J -->|Yes| K[执行default case]
    K --> H
    J -->|No| L[将Goroutine加入所有channel的等待队列]
    L --> M[挂起Goroutine, 等待唤醒]
    M --> N[被唤醒后重新检查所有case]
    N --> F

6.2 超时控制

select可以结合time.After实现超时控制。但是在for循环里不要使用select + time.After的组合会导致内存泄漏。来看一个发布订阅的简化代码:

for i := 0; i < concurrency; i++ {  // 启动多个协程
    go pub(i)                       // 每个协程执行 pub(i)
}

func pub(start int) {               // start 就是传入的 i
    for j := start; j < count; j += concurrency {
        // 向 subscribers[j] 发送消息,分片任务分配的经典模式,每个协程间隔处理多个订阅者
        select {
            case subscribers[j] <- msg:  // 尝试发送消息
            case <-time.After(time.Millisecond * 5): // 底层行为:每次调用time.After()方法会创建一个新的time.Timer,并返回其通道Timer.C,每次循环执行到select时,都会调用并产生一个新的Timer(即使前一个定时器还未触发)。
            case <-b.exit:  // 监听退出信号
                return
        }
    }
}

每次执行select,无论去不去那个case都会先创建好Timer,因为当运行到 select 语句时,先初始化所有 case 的表达式,生成所有可能的 scase 对象,(time.After 对应的 scasekindcaseRecv(接收操作))。当一个for循环中select已经执行了,当前select实列已经没有了,没有执行到Timer这个case,由于select 的机制只是监听多个通道的就绪状态,它不负责管理通道的生命周期。已创建的Timer不会消失,它会留在Go运行时的时间堆(timer heap)中,直到5ms后触发并向通道发送数据。这些Timer会在5ms后陆续触发,但触发前会堆积在内存中。最终导致内存泄漏(大量未释放的Timer对象)和不必要的CPU开销(处理无用的定时事件)。

// 解决方案:使用NewTimer来做定时器,不需要每次都创建定时器对象,添加上time.Reset每次重新激活定时器
pub := func(start int) {
    idleDuration := 5 * time.Millisecond
    idleTimeout := time.NewTimer(idleDuration) // 这个协程在处理分给他的订阅者时都共用一个计时器。
    defer idleTimeout.Stop() // 确保函数退出时释放定时器,可以被回收
       for j := start; j < count; j += concurrency {
      if !idleTimeout.Stop() {
         <-idleTimeout.C // drain排空channel一定要做!reset的是时间,但是timer通道里可能有上一次的残留信号
      }
      idleTimeout.Reset(idleDuration) // 重置定时器
      select {
          case subscribers[j] <- msg:
          case <-idleTimeout.C:
          case <-b.exit:
             return
       }
   }
}

6.3 底层

select中的每个case在运行时都是scase结构体,在编译阶段,select 语句会被转换为以下结构:

type scase struct{
    c *hchan // 操作的 channel
    elem unsafe.Pointer // 数据指针(发送值或接收地址)
    kind uint16 // 4种类型,caseNil,caseRecv,caseSend,caseDefault,行为不同
    ……
}

caseNil 运行时直接跳过它(既不执行也不阻塞),(如果直接对nil channel操作,会触发panic;但selectcaseNil会安全跳过)用途:

// 可以通过 nil channel 临时禁用某个 case(类似 disable 功能)
var ch chan int
if enable {
    ch = make(chan int) // 启用
} // 不过你disable的时候,原channel的缓冲区(如果有)会被GC回收,会丢失数据

select {
case x := <-ch: // 如果ch是nil,此case被忽略
    handle(x)
default:
    fmt.Println("channel disabled")
}

6.4 无case阻塞

空的 select{} 会永久阻塞(常用于main函数阻止退出)。**select{}for{}阻塞有什么区别?
select{}会释放线程M,让出CPUfor{} 是忙等待,占满CPU核心。for会不断check循环条件,所以会占用CPU资源。select的话,goroutine会因为阻塞自己挂起,然后休眠,不会让cpu利用率升高。存在default语句,select将不会阻塞,但是存在default会影响性能。

6.5 为什么select要随机

初始化阶段随机数生成器(用于后续 case 的随机选择顺序,避免饥饿

6.6 性能开销

select比单独的channel操作更耗时(因需遍历和调度)


7. defer

  • defer函数紧跟在资源打开后面,否则defer可能得不到执行,导致内存泄露。
  • 场景:释放锁,关闭文件,关闭链接;捕获panic。

7.1 执行流程

  • defer,return,return value(函数返回值) 执行顺序:首先return,其次return value,最后defer。
  • defer可以修改函数最终返回值,修改时机:有名返回值或者函数返回指针
  • 多个defer调用顺序是LIFO(后入先出)可以理解为压入栈中。每个defer语句都对应一个_defer 实例,多个实例使用指针连接起来形成一个单连表,保存在gotoutine 数据结构中,每次插入_defer实例,均插入到链表的头部,函数结束再一次从头部取出,从而形成后进先出的效果。

7.2 原理

Go 1.13(栈分配) Go 1.14+(开放编码)
尝试将 _defer 结构体分配在当前函数的栈帧上,如果 defer 出现在循环或无法静态分析的情况下(如条件判断),编译器会退回到使用旧的堆分配方式。 编译时直接插入调用代码
大部分静态 defer 简单、少量的 defer
后备机制 首选机制

7.3 如何用C语言模拟这种机制

step1:首先需要用链表管理defer,参考go需要哪些参数在:

type _defer struct {
    siz     int32       // 参数和结果的内存大小
    started bool        // 是否已执行
    sp      uintptr     // 调用者栈指针(用于区分调用层级)
    pc      uintptr     // 程序计数器
    fn      *funcval    // 要执行的函数
    _panic  *_panic     // 关联的 panic
    link    *_defer     // 指向下一个 defer
}
  • 函数指针:指向需要被延迟执行的函数。
  • 参数地址:存储被延迟函数的参数。在 defer 语句执行时,参数会立刻被计算并拷贝_defer 结构体的特定位置。这就是为什么 defer 对参数的求值是“立即”的,但函数执行是“延迟”的。
  • 链表指针:指向下一个 _defer 结构体,形成一个链表。
  • PC/SP 寄存器值:存储程序计数器(PC)和栈指针(SP)。这至关重要,它记录了 defer 语句所在的作用域。当函数返回时,运行时通过比较当前的 SP 与 _defer 中记录的 SP,来判断哪些 defer 需要在该函数返回时执行(即,只执行当前函数栈帧对应的 defer)。
    step2:需要一个全局的或线程局部的(Thread-Local)栈(可以用链表实现)来存储上述结构体。
    step3:手动在作用域出口执行延迟调用。
    但是编译时直接把代码段插入好像没法在C实现,c的编译器没有那种功能。

文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !
  目录