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 使用令牌桶算法、漏桶算法等流控方式来限制请求的并发度,避免过高并发带来的系统崩溃。
分布式支持:
- Go-Zero 架构本身适用于微服务架构,在分布式环境下也能保证高并发性能。通过服务发现、消息队列等组件的结合,Go-Zero 可以在多个服务实例间高效地进行通信和负载均衡,确保系统在高并发情况下的稳定性。
基于 Context 的分布式跟踪:
- Go-Zero 可以通过上下文(Context)来传递请求的上下文信息,实现高效的分布式追踪。这对于高并发环境下的微服务调试、性能分析和问题排查非常有帮助。
线程安全
线程安全(Thread-Safety)指:当多个线程/协程并发访问同一份数据或同一段代码时,程序的结果和内部状态始终正确、可预测,且与执行顺序/调度无关。换句话说,没有数据竞争(data race),不破坏不变式,也不会产生未定义行为或崩溃。
非线程安全的计数器(有数据竞争)
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 会相互覆盖,产生数据竞争,输出不可预测。
用互斥锁保护临界区(线程安全)
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 把读取/更新共享状态的代码包裹为临界区,保证同一时刻只有一个执行者。
用原子操作(更轻量)
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 思路)
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
}要点:“不要通过共享内存来通信,而要通过通信来共享内存”。
并发读多、写少:读写锁缓存
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
var (
once sync.Once
obj *Client
)
func GetClient() *Client {
once.Do(func() {
obj = NewClient() // 只会被并发地安全执行一次
})
return obj
}要点:避免经典的“双重检查加锁”坑。
Go map 的并发访问注意
并发写(或读写混用)map 会触发运行时 panic。
解决:用
Mutex/RWMutex保护,或使用为并发设计的sync.Map(适合键只增不删、读远多于写的场景)。
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的位置后写数据,并把该槽位seq从pos更新为pos+1(发布)。出队:CAS 抢到
tail的位置后读数据,并把该槽位seq从pos+1更新为pos+cap(释放)。通过比较
cell.seq与期望值,无锁判断空/满。
要求:容量取 2 的幂;
cell.seq初始为其索引(0..cap-1)。
使用建议:
等待策略:
Enqueue/Dequeue返回false时,使用自旋 + 渐进退避 + 超时;若需要阻塞语义,可在外层配 信号量/cond。批处理:消费者循环取到
N条或等Tms 再处理,吞吐更稳。伪共享:头尾与槽位用填充避免与其他字段同 cache line。
扩展:需要可取消/超时 → 外层加
context;需要无界 → 多个分段有界队列+链表拼接(复杂度提升,通常不必)。
一句话:每格带序号 + head/tail 原子 CAS,用序号差值无锁判断“可写/可读/满/空”,这是最简洁可靠的 CAS MPMC 环形队列套路。
它们各自代表什么?
head(入队指针)
所有生产者争抢的“入队票号”。谁用CAS(head, head+1)抢到,谁就拥有把元素写入位置pos = head & mask的权利。
抢到后:把数据写入该槽位
cell.data;再把
cell.seq从pos写成pos+1—— 发布(publish)。
tail(出队指针)
所有消费者争抢的“出队票号”。谁CAS(tail, tail+1)成功,谁就能从pos = tail & mask取元素。
取到后:读
cell.data;再把
cell.seq从pos+1写成pos + mask + 1—— 释放(free),让这个槽位回到“可写”状态,等待下一个绕圈的head使用。
注意:
head/tail不是“当前数组下标”,而是单调递增的票号(逻辑位置)。真正的数组下标通过pos & mask折返。
为什么要配合 seq?
每个槽位 cell 都有一个 序号 seq(64 位无符号整型),用来标识该槽位当前属于哪个“圈”的哪个票号。
初始化时:
- 第
i号槽位cell[i].seq = i(表示:当head == i时它是“可写”的)。
之后的生命周期:
可写:当某个生产者看到
cell.seq == pos(pos = head)→ 说明这个槽位正好属于当前head,可以写。
写完数据后把seq改成pos+1,表示“下一张票(消费票)可以读了”。可读:当某个消费者看到
cell.seq == pos+1(pos = 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,3;head=0, tail=0生产一次:P 看到
cell[0].seq == 0 (== head)→ 可写 → 写数据 →cell[0].seq = 1→head=1再生产一次:
cell[1].seq == 1→ 可写 →cell[1].seq = 2→head=2消费一次:C 看到
cell[0].seq == tail+1 == 1→ 可读 → 读 →cell[0].seq = tail + cap + 1 = 0 + 4 + 1 = 5→tail=1当
head继续前进到 4 时:pos = 4 & 3 = 0,cell[0].seq现在是 5;生产者期待
seq == pos (=4)才可写;当前是 5(>4),说明还没轮到(还在另一方占用/发布中),需要等待;等消费者真正释放到位后,再次匹配上才能写。
用
seq把“第几圈”的信息编码进槽位,避免了简单head==tail判满/判空的二义性。
