Skip to content

Daily Study

更新: 9/25/2025 字数: 0 字 时长: 0 分钟

Daily Plan

#todo

  • [ ]

知识点回顾

Go-zero的高并发体现

  • 异步处理和 goroutine 并发

    • Go-Zero 框架通过提供异步处理机制,使得服务能够高效地处理大量并发请求。例如,在处理 HTTP 请求时,Go-Zero 会利用 goroutine 异步地执行任务,减少阻塞操作带来的延迟,从而提高服务的并发能力。
  • 高效的路由与请求处理

    • Go-Zero 使用高效的路由调度和请求处理模型,使得并发请求的路由和处理能够非常快速地进行。例如,Go-Zero 使用了基于 router 的高效查找方式来决定请求的处理路径,这个过程是线程安全的,可以高效支持大量并发请求。
  • 内置支持的负载均衡

    • Go-Zero 提供了内建的负载均衡和服务发现机制,支持动态调整负载,确保在高并发场景下,各个微服务实例能够均匀分担请求负载,避免单个节点过载。
  • 请求限流与熔断机制

    • Go-Zero 提供了内置的请求限流和熔断机制,能够在高并发环境下控制请求的流量,避免系统超载。例如,Go-Zero 使用令牌桶算法、漏桶算法等流控方式来限制请求的并发度,避免过高并发带来的系统崩溃。
  • 分布式支持

    • Go-Zero 架构本身适用于微服务架构,在分布式环境下也能保证高并发性能。通过服务发现、消息队列等组件的结合,Go-Zero 可以在多个服务实例间高效地进行通信和负载均衡,确保系统在高并发情况下的稳定性。
  • 基于 Context 的分布式跟踪

    • Go-Zero 可以通过上下文(Context)来传递请求的上下文信息,实现高效的分布式追踪。这对于高并发环境下的微服务调试、性能分析和问题排查非常有帮助。

线程安全

线程安全(Thread-Safety)指:当多个线程/协程并发访问同一份数据或同一段代码时,程序的结果和内部状态始终正确、可预测,且与执行顺序/调度无关。换句话说,没有数据竞争(data race),不破坏不变式,也不会产生未定义行为或崩溃。

非线程安全的计数器(有数据竞争)

go
package main

import (
	"fmt"
	"time"
)

var counter int

func main(){

	for i := 0; i < 1000; i ++ {
		go func(){
			counter ++
		}()
	}
	time.Sleep(100 * time.Millisecond)
	fmt.Println("counter = ", counter)
}

问题:counter++ 是读-改-写三步操作,多个 goroutine 会相互覆盖,产生数据竞争,输出不可预测。

用互斥锁保护临界区(线程安全)

go
package main

import (
	"fmt"
	"sync"
)

var (
	counter int
	mu      sync.Mutex
	wg      sync.WaitGroup
)

func main() {
	wg.Add(1000)
	for i := 0; i < 1000; i++ {
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("counter =", counter) // 始终为 1000
}

要点:用 sync.Mutex 把读取/更新共享状态的代码包裹为临界区,保证同一时刻只有一个执行者。

用原子操作(更轻量)

go
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter int64
	var wg sync.WaitGroup

	wg.Add(1000)
	for i := 0; i < 1000; i++ {
		go func() {
			defer wg.Done()
			atomic.AddInt64(&counter, 1) // 原子自增
		}()
	}
	wg.Wait()
	fmt.Println("counter =", counter) // 1000
}

要点:sync/atomic 提供无锁的原子读写,适合简单整数/指针的并发更新。

用 Channel 串行化状态(CSP 思路)

go
package main

import (
	"fmt"
)

func main() {
	incr := make(chan struct{})
	done := make(chan int)

	// 仅由这个 goroutine 持有状态,外部通过消息驱动
	go func() {
		counter := 0
		for i := 0; i < 1000; i++ {
			<-incr
			counter++
		}
		done <- counter
	}()

	for i := 0; i < 1000; i++ {
		go func() { incr <- struct{}{} }()
	}

	fmt.Println("counter =", <-done) // 1000
}

要点:“不要通过共享内存来通信,而要通过通信来共享内存”。

并发读多、写少:读写锁缓存

go
type Cache struct {
	m  map[string]string
	rw sync.RWMutex
}

func (c *Cache) Get(k string) (string, bool) {
	c.rw.RLock()
	defer c.rw.RUnlock()
	v, ok := c.m[k]
	return v, ok
}

func (c *Cache) Set(k, v string) {
	c.rw.Lock()
	defer c.rw.Unlock()
	c.m[k] = v
}

要点:RWMutex 允许多个并发读单个写,适合读多写少的场景

单例/惰性初始化:sync.Once

go
var (
	once sync.Once
	obj  *Client
)

func GetClient() *Client {
	once.Do(func() {
		obj = NewClient() // 只会被并发地安全执行一次
	})
	return obj
}

要点:避免经典的“双重检查加锁”坑。

Go map 的并发访问注意

  • 并发写(或读写混用)map 会触发运行时 panic

  • 解决:用 Mutex/RWMutex 保护,或使用为并发设计的 sync.Map(适合键只增不删、读远多于写的场景)。

go
var m = make(map[string]int)
var mu sync.RWMutex

func SafeRead(k string) (int, bool) {
	mu.RLock(); defer mu.RUnlock()
	v, ok := m[k]
	return v, ok
}

func SafeWrite(k string, v int) {
	mu.Lock(); defer mu.Unlock()
	m[k] = v
}

线程安全的判断与实践要点

  • 判断:并发访问时是否可能出现数据竞争?是否破坏不变式?是否有死锁/活锁/饥饿风险?

  • 手段:互斥锁、读写锁、原子操作、消息传递(channel)、不可变数据(拷贝写-读快照)、线程安全容器。

  • 设计:尽量缩小临界区、减少共享、保证不变量、控制锁顺序避免死锁;用 -race 检测竞争:

    go test -race ./... go run -race main.go

  • 概念区分:线程安全 ≠ 可重入(reentrant)。可重入强调同一函数在被中断后再次调用仍安全;线程安全强调并发访问共享状态的安全

Go设计一个多并发的队列

Channel 有界队列 + 分片 + 批处理 + ctx超时 + 指标限流

  • 用有缓冲的 channel 做队列(首选)

    • q := make(chan T, N);生产者 select { case q <- v: ... default: ... }

    • 消费端启动 M 个 worker:for v := range q { handle(v) }

    • 背压:满了就阻塞或用 context 超时返回;关闭close(q),消费者自然退出。

  • 分片(Sharding)降低争用、提升吞吐

    • 建立 k 个子队列:qs[i] = make(chan T, cap)

    • i := hash(key) % k 路由进对应分片;每个分片配 w_i 个 worker。

    • 单分片出问题互不影响,整体更平稳。

  • 批处理(Batch)提高效率

    • 消费者按“最多 B 条或等待 T ms”成批拿数据再处理/写库/发网包,减少系统调用与锁竞争。
  • 可取消与超时

    • Enqueue(ctx, v)select { case q<-v: case <-ctx.Done(): } 实现入队超时/取消

    • Worker 内部对下游请求也统一用 ctx 控制。

  • 可观测性与限流

    • 统计:当前长度、入/出队速率、拒绝/超时次数、处理时长分布。

    • 在入队前加令牌桶并发信号量做限速/并发上限,防止雪崩。

  • 优雅关闭

    • 提供 Close():先标记停止接收新任务,再 close(chan)

    • 选择:drain 处理完剩余元素或快速丢弃(视业务而定)。

  • 需要更细控制再换实现(可选)

    • 若要自定义阻塞/唤醒与丢弃策略:用环形缓冲 + Mutex/Cond

    • 极致性能再考虑无锁 MPMC(复杂度高,通常不必)。

CAS原子操作MPMC(多生产者多消费者)有界队列

基于经典的 Vyukov Bounded MPMC 圈队列(环形缓冲区):

核心思想:

  • 环形数组作为缓冲;每个槽位 cell 带一个序号 seq

  • head(入队位置)/tail(出队位置)为全局原子递增指针。

  • 入队:CAS 抢到 head 的位置后写数据,并把该槽位 seqpos 更新为 pos+1(发布)。

  • 出队:CAS 抢到 tail 的位置后读数据,并把该槽位 seqpos+1 更新为 pos+cap(释放)。

  • 通过比较 cell.seq 与期望值,无锁判断空/满

要求:容量取 2 的幂cell.seq 初始为其索引(0..cap-1)。

使用建议:

  • 等待策略Enqueue/Dequeue 返回 false 时,使用自旋 + 渐进退避 + 超时;若需要阻塞语义,可在外层配 信号量/cond

  • 批处理:消费者循环取到 N 条或等 T ms 再处理,吞吐更稳。

  • 伪共享:头尾与槽位用填充避免与其他字段同 cache line。

  • 扩展:需要可取消/超时 → 外层加 context;需要无界 → 多个分段有界队列+链表拼接(复杂度提升,通常不必)。

一句话:每格带序号 + head/tail 原子 CAS,用序号差值无锁判断“可写/可读/满/空”,这是最简洁可靠的 CAS MPMC 环形队列套路。

它们各自代表什么?

  • head(入队指针)
    所有生产者争抢的“入队票号”。谁用 CAS(head, head+1) 抢到,谁就拥有把元素写入位置 pos = head & mask 的权利。
    抢到后:

    1. 把数据写入该槽位 cell.data

    2. 再把 cell.seqpos 写成 pos+1 —— 发布(publish)

  • tail(出队指针)
    所有消费者争抢的“出队票号”。谁 CAS(tail, tail+1) 成功,谁就能从 pos = tail & mask 取元素。
    取到后:

    1. cell.data

    2. 再把 cell.seqpos+1 写成 pos + mask + 1 —— 释放(free),让这个槽位回到“可写”状态,等待下一个绕圈的 head 使用。

注意:head/tail 不是“当前数组下标”,而是单调递增的票号(逻辑位置)。真正的数组下标通过 pos & mask 折返。


为什么要配合 seq

每个槽位 cell 都有一个 序号 seq(64 位无符号整型),用来标识该槽位当前属于哪个“圈”的哪个票号
初始化时:

  • i 号槽位 cell[i].seq = i(表示:当 head == i 时它是“可写”的)。

之后的生命周期:

  • 可写:当某个生产者看到 cell.seq == pospos = head)→ 说明这个槽位正好属于当前 head,可以写。
    写完数据后把 seq 改成 pos+1,表示“下一张票(消费票)可以读了”。

  • 可读:当某个消费者看到 cell.seq == pos+1pos = tail)→ 正好属于当前 tail,可以读。
    读完后把 seq 改成 pos + mask + 1,表示“当 head 绕一圈后(差 cap=mask+1),再次来到这里时,这格又可写”。

  • 判满/判空:通过 seq 与期望的 pos 的差值判断:

    • 生产者看 diff = int64(seq) - int64(pos)

      • diff == 0 → 可写

      • diff < 0(这个槽位还没被下一圈的 tail 释放)

    • 消费者看 diff = int64(seq) - int64(pos+1)

      • diff == 0 → 可读

      • diff < 0

这样做的关键好处:

  • 不需要额外的“满/空”计数器,也不需要锁;

  • 即使 head/tail 绕圈很多次seq 也能分清是第几圈的票,避免 ABA 问题。

操作顺序为什么这么安排?

  • 入队发布顺序:先写 data → 再 Store(seq, pos+1)
    确保消费者看到 seq == pos+1 时,数据已经写好(发布-可见性)。

  • 出队释放顺序:先读 data → 再 Store(seq, pos+cap)
    确保生产者看到 seq == head 时,旧数据已经被消费,不会被覆盖未读数据。

在 Go 中,atomic.StoreUint64/LoadUint64 默认具备顺序一致性(SC),满足上述可见性要求(发布-订阅)。其他语言需要确保使用 release/acquire 语义。

一个极小例子(cap = 4)

  • 初始:cell[0..3].seq = 0,1,2,3head=0, tail=0

  • 生产一次:P 看到 cell[0].seq == 0 (== head) → 可写 → 写数据 → cell[0].seq = 1head=1

  • 再生产一次:cell[1].seq == 1 → 可写 → cell[1].seq = 2head=2

  • 消费一次:C 看到 cell[0].seq == tail+1 == 1 → 可读 → 读 → cell[0].seq = tail + cap + 1 = 0 + 4 + 1 = 5tail=1

  • head 继续前进到 4 时:

    • pos = 4 & 3 = 0cell[0].seq 现在是 5;

    • 生产者期待 seq == pos (=4) 才可写;当前是 5(>4),说明还没轮到(还在另一方占用/发布中),需要等待;

    • 等消费者真正释放到位后,再次匹配上才能写。

seq 把“第几圈”的信息编码进槽位,避免了简单 head==tail 判满/判空的二义性。

菜就多练

本站访客数 人次 本站总访问量