第一章:Go并发编程真相:5个被90%开发者忽略的runtime调度陷阱及修复方案
Go 的 goroutine 轻量、易用,但其底层 runtime 调度器(M:P:G 模型)在高负载、边界场景下极易暴露隐性缺陷。多数开发者仅依赖 go func() 直觉编码,却对调度器如何抢占、窃取、阻塞与唤醒一无所知——这导致性能毛刺、goroutine 泄漏、CPU 空转等疑难问题长期难以定位。
长时间系统调用阻塞 P,导致其他 G 饿死
当 goroutine 执行未封装为 runtime.nanotime() 或 syscall.Syscall 的阻塞式系统调用(如 os.Open 未配 O_NONBLOCK,或直接调用 C.sleep),当前 P 会被挂起,无法调度其他 G。修复方案:优先使用 Go 标准库封装的异步接口(如 net.Conn 的读写默认支持非阻塞+网络轮询),或显式启用 GOMAXPROCS 并确保 GODEBUG=schedtrace=1000 监控 P 状态。
for-select 循环中缺少 default 分支引发调度饥饿
for {
select {
case v := <-ch:
process(v)
// ❌ 缺失 default → 当 ch 无数据时,G 永久陷入 park 状态,无法被抢占
}
}
修复方案:添加 default: runtime.Gosched() 强制让出时间片,或改用带超时的 select { case <-time.After(1ms): }。
大量短生命周期 goroutine 触发频繁 GC 与调度抖动
每秒启动 >10k goroutine 时,newproc1 分配栈帧与 gcache 管理开销剧增。验证命令:go tool trace -http=:8080 ./app → 查看 “Scheduler” 视图中 Goroutines Created/Second 峰值。
channel 关闭后仍向已关闭 chan 发送 panic 被静默吞没
若 sender 不检查 ok 且未 recover,panic 将终止整个 goroutine,但主流程无感知。安全模式:
select {
case ch <- data:
// 正常发送
default:
if _, ok := <-ch; !ok {
log.Warn("chan closed, skip send")
return
}
}
syscall.Syscall 兼容性陷阱:Linux 与 macOS 行为不一致
Syscall(SYS_write, ...) 在 macOS 上可能永不返回,因 runtime 未为其注册异步信号中断。替代方案:统一使用 syscall.Write(Go 内部已做平台适配)或 os.File.Write。
第二章:Goroutine调度器底层机制与隐性开销
2.1 GMP模型全链路解析:从newproc到schedule的生命周期追踪
Goroutine 的诞生与调度并非原子操作,而是横跨 newproc、gogo、schedule 三阶段的协同过程。
创建:newproc 注入执行上下文
// runtime/proc.go
func newproc(fn *funcval) {
gp := getg() // 获取当前 Goroutine
_g_ := getg() // 获取 g0(系统栈)
siz := uintptr(unsafe.Sizeof(uintptr(0)))
pc := getcallerpc() // 调用方返回地址(即 fn 入口)
systemstack(func() {
newproc1(fn, &pc, siz, gp, _g_)
})
}
newproc 不直接分配栈,仅记录 fn 地址与调用上下文;pc 将成为新 G 的 sched.pc,决定首次执行入口。
调度流转关键状态
| 状态 | 触发点 | 含义 |
|---|---|---|
_Grunnable |
newproc1末尾 |
已就绪,等待 M 抢占执行 |
_Grunning |
execute 开始 |
正在 M 上运行 |
_Gwaiting |
gopark 调用 |
主动让出,挂起于 channel 等 |
生命周期主干流程
graph TD
A[newproc] --> B[newproc1 → 创建_g_]
B --> C[放入 P.runq 或全局 runq]
C --> D[schedule 循环中获取 G]
D --> E[execute → 切换至 G 栈]
E --> F[gogo → 跳转 sched.pc]
2.2 全局队列与P本地队列的竞争失衡:实测goroutine饥饿场景复现与pprof定位
当高并发任务持续投递至全局运行队列(sched.runq),而P本地队列(p.runq)长期空闲时,调度器可能因 runqsteal 频率不足导致部分P“饿死”——即其绑定的M无法获取新goroutine。
复现场景代码
func main() {
runtime.GOMAXPROCS(2)
for i := 0; i < 1000; i++ {
go func() { // 大量goroutine统一由main goroutine spawn → 倾向入全局队列
time.Sleep(time.Millisecond)
}()
}
time.Sleep(time.Second)
}
此写法使
newproc跳过P本地队列缓存路径(runqput未命中p.runqhead == p.runqtail优化),直接落库runq.push()到全局队列;结合GOMAXPROCS=2,易触发steal延迟,造成1个P持续空转。
pprof定位关键指标
| 指标 | 含义 | 异常阈值 |
|---|---|---|
runtime.schedule duration |
单次调度耗时 | >100μs |
sched.runqsize |
全局队列长度 | >500 |
sched.plocalrunq |
P本地队列平均长度 | ≈0 |
调度路径失衡示意
graph TD
A[goroutine 创建] --> B{是否命中P本地队列?}
B -->|否| C[push to global runq]
B -->|是| D[push to p.runq]
C --> E[需 runqsteal 触发]
D --> F[立即被M执行]
E --> G[延迟 ≥ stealTick 20ms]
2.3 抢占式调度失效的三大临界条件:长循环、CGO调用、sysmon未触发的深度验证
Go 运行时依赖协作式抢占(cooperative preemption),在特定临界路径下无法强制中断 Goroutine,导致调度器“失明”。
长循环阻塞调度点
无函数调用或 channel 操作的纯计算循环(如 for i := 0; i < 1e9; i++ {})不触发 morestack 检查,P 无法被剥夺。
func longLoop() {
for i := 0; i < 1<<30; i++ { // ❌ 无调度点:无函数调用、无栈增长检查、无 GC 安全点
_ = i * i
}
}
该循环不触发
runtime.retake()调度请求;g.preempt = true被忽略,因checkPreemptMS仅在函数入口/ret/gcscan 等少数安全点生效。
CGO 调用绕过 GMP 协议
CGO 函数执行期间,M 脱离 P 管理,sysmon 无法观测其状态,且 m.lockedg != nil 时禁止抢占。
| 条件 | 是否可抢占 | 原因 |
|---|---|---|
| 纯 Go 函数 | ✅ | 入口/ret 有安全点 |
C.sleep(10) |
❌ | M 进入 OS 线程独占模式 |
runtime.Gosched() |
✅ | 主动让出,插入调度点 |
sysmon 未触发的深度验证
sysmon 默认每 20ms 扫描一次,若 m.lockedg 或 g.m.lockedExt 非零,直接跳过该 M:
graph TD
A[sysmon tick] --> B{M has lockedG?}
B -->|Yes| C[Skip preempt check]
B -->|No| D[Check g.preempt && g.stackguard0]
D --> E[Signal M to preempt]
三者叠加时(如 CGO + 长循环 + 高负载导致 sysmon 延迟),Goroutine 可持续运行数秒,引发可观测性断层。
2.4 netpoller与调度器协同漏洞:IO密集型服务中goroutine泄漏的火焰图归因分析
火焰图关键模式识别
典型泄漏火焰图中,runtime.gopark → internal/poll.runtime_pollWait → net.(*conn).Read 链路持续高位,且 go:linkname 标记的 netpollblock 调用栈反复出现,表明 goroutine 在 netpoller 中挂起后未被调度器及时唤醒。
协同失效的核心路径
// src/runtime/netpoll.go(简化)
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
gpp := &pd.rg // 或 pd.wg,指向等待的 G
for {
old := *gpp
if old == 0 && atomic.CompareAndSwapPtr(gpp, nil, unsafe.Pointer(g)) {
break // 成功注册
}
if old == pdReady { // 竞态:epoll 已就绪但 g 尚未被调度器消费
return true // 本应唤醒,却跳过 park
}
// ... 自旋/阻塞逻辑
}
}
该函数在 epoll_wait 返回就绪事件后,若调度器未及时执行 findrunnable() 扫描 netpoll 队列,*gpp 可能残留为 pdReady,导致后续 goroutine 调用 netpollblock 时误判为“已就绪”而跳过挂起,实际却未进入运行队列——形成隐形泄漏。
关键参数说明
pd.rg:读等待 goroutine 指针,竞争写入点pdReady:原子状态常量(值为 1),表示内核已就绪但用户态未处理waitio:控制是否在无 IO 时阻塞,false时易触发漏判
修复策略对比
| 方案 | 原子性保障 | 调度延迟容忍 | 实测泄漏下降 |
|---|---|---|---|
| 双检+自旋锁 | 弱(依赖 CAS) | 低( | 42% |
netpoll 队列主动推送至 runq |
强(runtime 内部锁) | 高(~5ms) | 99.1% |
graph TD
A[epoll_wait 返回就绪] --> B{netpollblock 检查 pd.rg}
B -->|pd.rg == pdReady| C[跳过 park,goroutine 悬停]
B -->|pd.rg == 0| D[成功注册,进入 gopark]
C --> E[调度器未扫描 netpoll 队列 → 泄漏]
2.5 GC STW期间的调度冻结效应:如何通过runtime.ReadMemStats与trace分析规避响应尖刺
Go 的 STW(Stop-The-World)阶段会暂停所有 G(goroutine)的执行,导致 P(processor)空转、网络请求延迟骤增——即“响应尖刺”。
数据同步机制
runtime.ReadMemStats 可在 GC 前后高频采样,捕获 PauseNs 和 NumGC 突变:
var m runtime.MemStats
runtime.ReadMemStats(&m)
log.Printf("GC pause: %v ns, total: %d", m.PauseNs[len(m.PauseNs)-1], m.NumGC)
PauseNs是环形缓冲区(默认256项),末项代表最近一次STW时长(纳秒级);NumGC单调递增,用于检测GC频次突增。
trace 分析路径
启用 GODEBUG=gctrace=1 或 pprof.Lookup("goroutine").WriteTo(w, 1) 结合 go tool trace 可定位 STW 时间轴。
| 指标 | 含义 | 健康阈值 |
|---|---|---|
GC pause > 1ms |
单次STW过长 | ≤ 300μs(低延迟服务) |
GC frequency > 1/s |
内存压力过大 | ≤ 0.2/s |
规避策略
- 预分配切片避免逃逸
- 使用
sync.Pool复用临时对象 - 用
debug.SetGCPercent(-1)临时禁用GC(仅调试)
graph TD
A[HTTP 请求] --> B{是否临近GC?}
B -->|是| C[降级非核心逻辑]
B -->|否| D[正常处理]
C --> E[避免内存分配]
第三章:系统调用与阻塞操作的调度反模式
3.1 syscall.Syscall导致的M级阻塞:基于strace+go tool trace的跨线程阻塞链路还原
当 Go 程序调用 syscall.Syscall(如 read, write, accept)时,若系统调用未立即返回,当前 M(OS 线程)将陷入内核态等待,无法被调度器复用,进而阻塞其绑定的 G 和潜在的 P。
阻塞链路特征
- strace 显示
read(12, ...)处于futex(0x..., FUTEX_WAIT_PRIVATE, ...)或直接<... read resumed>挂起; go tool trace中可见该 G 状态为Syscall,持续时间长,且对应 M 的Executing状态长时间不切换。
典型复现代码
// 示例:阻塞式文件读取触发 M 级阻塞
fd, _ := syscall.Open("/tmp/blocking_file", syscall.O_RDONLY, 0)
var buf [1]byte
syscall.Read(fd, buf[:]) // 此处若文件被其他进程锁住或 NFS 暂不可达,M 将挂起
syscall.Read是封装后的Syscall(SYS_read, ...). 参数fd为文件描述符,buf[:]是用户空间缓冲区指针,内核在数据就绪前不会返回,M 无法执行其他 G。
| 工具 | 观测维度 | 关键线索 |
|---|---|---|
strace -p <pid> |
系统调用生命周期 | read(...) 后无 = N 返回,持续挂起 |
go tool trace |
Goroutine 状态迁移 | G 状态卡在 Syscall > 10ms |
graph TD
A[G 调用 syscall.Read] --> B[M 进入内核态]
B --> C{内核是否立即返回?}
C -- 否 --> D[M 挂起等待 I/O 完成]
C -- 是 --> E[M 返回,G 继续运行]
D --> F[其他 G 无法被此 M 执行]
3.2 time.Sleep与定时器堆竞争:高并发Timer滥用引发的P窃取失效实战修复
当大量 goroutine 频繁创建 time.NewTimer 或 time.AfterFunc,底层 timerHeap 的插入/删除操作会激烈争用全局 timerLock,阻塞 runtime.findrunnable() 中的 P(Processor)窃取路径,导致调度延迟飙升。
定时器堆锁竞争热点
- 每次
addTimerLocked需持有timerLock,高并发下形成锁队列 findrunnable()在无本地 G 可运行时需尝试从其他 P 窃取,但被timerLock长期阻塞- P 窃取失败 → 大量 P 进入自旋或休眠 → 吞吐骤降
修复对比方案
| 方案 | CPU 开销 | 内存复用 | 调度干扰 |
|---|---|---|---|
time.Sleep(短时) |
低 | ✅(复用系统调用) | ❌(不触发 timerHeap) |
time.AfterFunc(高频) |
高 | ❌(每次新建 timer 结构) | ✅(严重) |
sync.Pool + time.Reset |
中 | ✅✅ | ⚠️(需手动管理) |
// ✅ 推荐:复用 Timer 实例,避免频繁 heap 插入
var timerPool = sync.Pool{
New: func() interface{} { return time.NewTimer(time.Hour) },
}
func safeDelay(d time.Duration) {
t := timerPool.Get().(*time.Timer)
t.Reset(d)
select {
case <-t.C:
case <-time.After(5 * time.Second): // 防死锁兜底
}
timerPool.Put(t) // 归还前确保已停止或过期
}
上述代码通过 sync.Pool 复用 *time.Timer,消除 timerHeap 动态插入开销;t.Reset() 触发 O(log n) 堆调整而非全量重建,显著降低 timerLock 持有时间。兜底超时防止 t.C 漏收导致 goroutine 泄漏。
3.3 sync.Mutex在调度器视角下的“伪非阻塞”陷阱:通过go tool schedtrace解构自旋与唤醒延迟
数据同步机制
sync.Mutex 表面提供“快速获取/释放”,但底层依赖 runtime_SemacquireMutex,其行为受 GMP 调度器深度干预——自旋(spin)不等于非阻塞。
调度器可观测性
启用调度追踪:
GODEBUG=schedtrace=1000 ./your-program
每秒输出调度器快照,揭示 Goroutine 在 semacquire 状态的驻留时长与唤醒延迟。
自旋阶段的隐式开销
// runtime/sema.go(简化示意)
func semacquire1(addr *uint32, msigsave *sigmask, handoff bool) {
for i := 0; i < active_spin; i++ {
if *addr == 0 && atomic.CompareAndSwapUint32(addr, 0, mutexLocked) {
return // 成功自旋获取
}
procyield(1) // CPU pause,不触发调度
}
// → 进入 park 状态,G 被挂起,M 可能被复用
}
procyield(1) 仅暂停当前逻辑核,不交出时间片;若临界区过长,自旋失败后将触发 gopark,导致 G 状态切换 + M 解绑 + P 抢占延迟。
关键指标对照表
| 指标 | 含义 | 高值警示 |
|---|---|---|
SCHED 行 spinning |
当前自旋中 G 数 | 持续 >1 表明锁竞争激烈 |
SCHED 行 parking |
正在 park 的 G 数 | 配合 awake 延迟判断唤醒效率 |
G 行 status=S |
G 处于 syscall 或 sema 等待 | 若长期停留,说明唤醒链路阻塞 |
调度路径关键分支
graph TD
A[Mutex.Lock] --> B{自旋成功?}
B -->|是| C[进入临界区]
B -->|否| D[调用 gopark]
D --> E[G 状态设为 Gwaiting]
E --> F[M 解绑 P,P 可被其他 M 获取]
F --> G[唤醒时需 runtime_ready → 抢占 P 或排队]
第四章:并发原语与调度器的耦合风险
4.1 channel发送/接收的调度路径剖析:缓冲区满/空时goroutine入队策略的源码级验证
当向满缓冲channel发送数据时,chansend检测到full()为真,调用goparkunlock(&c.lock, "chan send", traceEvGoBlockSend, 3)将当前goroutine挂起,并通过enqueueSudoG(&c.sendq, sg)插入发送等待队列。
数据同步机制
// runtime/chan.go: chansend
if c.qcount == c.dataqsiz {
if !block {
return false
}
// 阻塞:构造sudog并入队
gp := getg()
sg := acquireSudog()
sg.g = gp
sg.elem = ep
gp.waiting = sg
gp.param = nil
enqueueSudoG(&c.sendq, sg) // 关键入队操作
goparkunlock(&c.lock, "chan send", traceEvGoBlockSend, 3)
}
enqueueSudoG将sg追加至sendq双向链表尾部,确保FIFO语义;goparkunlock释放锁并触发调度器切换。
goroutine入队关键行为对比
| 场景 | 触发函数 | 队列类型 | 唤醒条件 |
|---|---|---|---|
| 缓冲区满发送 | chansend |
sendq |
接收方消费后唤醒 |
| 缓冲区空接收 | chanrecv |
recvq |
发送方写入后唤醒 |
graph TD
A[goroutine尝试send] --> B{缓冲区满?}
B -->|是| C[构造sudog → enqueueSudoG]
B -->|否| D[直接写入buf]
C --> E[goparkunlock挂起]
4.2 select语句的随机公平性幻觉:多case竞争下goroutine唤醒顺序的实测偏差与规避方案
Go 官方文档称 select 在多个就绪 case 间“伪随机”选择,但实测表明:调度器唤醒顺序受底层 GMP 状态、抢占点及 runtime 版本显著影响,并非统计公平。
实测偏差现象
启动 1000 次含 3 个就绪 channel send 的 select,记录首个被选中 case 的频次: |
Case | 频次(Go 1.22) | 偏差率 |
|---|---|---|---|
| ch1 | 482 | +14.2% | |
| ch2 | 317 | −24.3% | |
| ch3 | 201 | −52.6% |
核心原因
select {
case <-ch1: // 优先被 runtime.pollcache 扫描到的 fd 可能更早就绪
case <-ch2:
case <-ch3:
}
runtime.selectgo 按 case 声明顺序线性扫描就绪队列,非真正随机打乱;若 ch1 总在 ch2/ch3 前注册或缓存命中率高,则持续偏向。
规避方案
- ✅ 使用
rand.Perm(len(cases))手动轮询顺序 - ✅ 将高优先级通道拆分为独立 goroutine +
time.After降权 - ❌ 避免依赖
select天然“公平性”做负载均衡
graph TD
A[select 开始] --> B[按源码顺序遍历 case]
B --> C{当前 case 是否就绪?}
C -->|是| D[立即返回该 case]
C -->|否| E[继续下一个]
D --> F[唤醒对应 goroutine]
4.3 WaitGroup误用导致的goroutine永久挂起:结合gdb调试runtime.gopark状态机的诊断流程
数据同步机制
sync.WaitGroup 要求 Add() 必须在 Go 启动前调用,否则 Done() 可能触发未定义行为,使 goroutine 在 runtime.gopark 中永久阻塞。
典型误用代码
func badExample() {
var wg sync.WaitGroup
go func() { // wg.Add(1) 在 goroutine 内部 —— 危险!
wg.Add(1)
defer wg.Done()
time.Sleep(time.Second)
}()
wg.Wait() // 主 goroutine 等待,但 Add 尚未执行 → 永久挂起
}
逻辑分析:wg.Wait() 在 wg.Add(1) 之前执行,内部计数器为 0,runtime.gopark 被调用后无唤醒路径;参数 reason="semacquire" 表明等待信号量。
gdb诊断关键步骤
info goroutines查看阻塞状态goroutine <id> bt定位至runtime.goparkp *g检查g.status == _Gwaiting与g.waitreason == "semacquire"
| 字段 | 值 | 含义 |
|---|---|---|
g.status |
_Gwaiting |
已暂停,不可调度 |
g.waitreason |
"semacquire" |
等待 WaitGroup 信号量 |
graph TD
A[main goroutine calls wg.Wait] --> B{wg.counter == 0?}
B -->|Yes| C[runtime.gopark<br>state: _Gwaiting]
C --> D[无 goroutine 调用 Done/Unlock → 永不唤醒]
4.4 context.WithTimeout在调度器中的双刃剑效应:deadline超时后goroutine清理延迟的trace量化验证
context.WithTimeout看似精准终止任务,实则无法强制回收已阻塞的goroutine——调度器仅在下一次调度点检查ctx.Done()信号。
goroutine清理延迟的根源
- Go运行时不抢占网络I/O或系统调用中的goroutine;
select未命中ctx.Done()分支时,goroutine持续驻留于G队列;- 清理延迟 = 调度周期 + 阻塞退出时间。
trace量化示例
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
go func() {
select {
case <-time.After(500 * time.Millisecond): // 模拟长阻塞
case <-ctx.Done(): // 实际不会在此触发,因time.After未受ctx控制
}
}()
此代码中
ctx.Done()永远不就绪;time.After独立启动timer,与ctx无关联。正确做法应使用context.WithDeadline配合可中断I/O(如net.Conn.SetReadDeadline)。
延迟分布(实测P99=327ms)
| 场景 | 平均延迟 | P95 | P99 |
|---|---|---|---|
| 纯CPU密集型循环 | 12ms | 18ms | 24ms |
http.Get阻塞 |
112ms | 203ms | 327ms |
graph TD
A[WithTimeout创建] --> B[deadline写入timer heap]
B --> C{goroutine是否在调度点检查ctx?}
C -->|是| D[及时退出]
C -->|否| E[等待阻塞结束/调度唤醒]
E --> F[实际退出延迟 >> deadline]
第五章:构建可预测、低延迟的Go并发系统
关键指标定义与可观测性基线
在真实金融行情推送服务中,我们定义 P99 延迟 ≤ 8ms 为 SLO 边界,同时要求 GC STW 时间稳定在 100μs 以内。通过 expvar + Prometheus 暴露 runtime.MemStats 和自定义 concurrent_queue_length 指标,并在 Grafana 中建立熔断看板:当每秒 goroutine 创建数突增超 5000 且队列积压 > 3000 时自动触发降级开关。
零拷贝通道与内存池协同模式
避免频繁堆分配是降低延迟的核心手段。以下代码展示使用 sync.Pool 管理预分配的 TradeEvent 结构体,并通过无锁环形缓冲区替代 chan *TradeEvent:
var eventPool = sync.Pool{
New: func() interface{} {
return &TradeEvent{Timestamp: nanotime()}
},
}
type RingBuffer struct {
buf []*TradeEvent
mask uint64
read uint64
write uint64
}
func (r *RingBuffer) Push(e *TradeEvent) bool {
next := atomic.AddUint64(&r.write, 1) - 1
idx := next & r.mask
if atomic.LoadUint64(&r.read) > next-r.mask {
return false // full
}
atomic.StorePointer(&unsafe.Pointer(&r.buf[idx])[:], unsafe.Pointer(e))
return true
}
Goroutine 泄漏的根因定位实战
某日压测中发现 RSS 内存持续增长但 GC 回收无效。通过 pprof/goroutine?debug=2 抓取全量 goroutine stack trace,发现 127 个 http.(*persistConn).readLoop 卡在 select { case <-t.conn.needRead: } —— 根因是上游未按 HTTP/1.1 规范发送 Connection: close,导致连接池复用异常。修复后 goroutine 数量从 1.2k 稳定至 83。
并发控制的三层防御体系
| 层级 | 手段 | 实例参数 | 触发阈值 |
|---|---|---|---|
| 接入层 | net.Listener.SetDeadline | ReadDeadline: 50ms | 连接空闲 > 3s |
| 业务层 | semaphore.NewWeighted | weight=10(每请求) | 并发数 > 200 |
| 底层资源层 | ringbuffer.Size | 固定 4096 slot | 写入失败率 > 5% |
CPU 亲和性与 NUMA 绑定调优
在 64 核 AMD EPYC 服务器上,将行情解析 goroutine 绑定到 L3 缓存独占的 8 个物理核(CPUSet 0-7),并通过 taskset -c 0-7 ./trader 启动。实测跨 NUMA 访问延迟下降 42%,P99 波动标准差从 3.1ms 收敛至 0.8ms。
混沌工程验证稳定性边界
使用 Chaos Mesh 注入随机网络延迟(2–15ms jitter)、模拟 etcd leader 切换(平均耗时 1.2s),并观察服务是否维持 P99 epoll_wait 性能拐点。引入指数退避后,重连峰值 goroutine 数从 2100 降至 43。
生产环境热重启零中断方案
基于 github.com/braintree/manners 改造的平滑重启流程:新进程启动后,通过 Unix domain socket 向旧进程发送 SIGUSR2;旧进程停止 accept 新连接,但继续处理已建立连接中的未完成交易指令(最大等待 3s),同时将 pending request queue 通过共享内存传递给新进程。灰度期间 100% 请求无 5xx,连接中断率为 0。
