第一章:Go并发编程实战精讲(狂神说课程隐藏章节深度还原):从goroutine泄漏到channel死锁的全链路诊断手册
Go 并发模型简洁有力,但 goroutine 与 channel 的组合极易在生产环境中埋下隐蔽性极强的运行时隐患。本章聚焦真实故障场景,还原狂神说课程中未公开的调试实战路径,直击 goroutine 泄漏与 channel 死锁的根因定位与修复闭环。
goroutine 泄漏的三步定位法
- 实时观测:执行
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2,查看活跃 goroutine 堆栈; - 过滤可疑协程:重点关注阻塞在
<-ch、select{}无 default 分支、或time.Sleep长周期等待的调用链; - 代码验证:在疑似泄漏点插入
runtime.NumGoroutine()日志,对比任务启停前后的数值变化。
channel 死锁的典型模式与修复
以下代码将触发 fatal error: all goroutines are asleep – deadlock:
func main() {
ch := make(chan int, 1)
ch <- 1 // 缓冲满,但无人接收
// ← 此处阻塞,主 goroutine 挂起 → 程序崩溃
}
修复方案:使用带超时的 select 或确保配对收发。推荐安全写法:
select {
case ch <- 1:
// 发送成功
default:
log.Println("channel full, skip send") // 非阻塞兜底
}
关键诊断工具速查表
| 工具 | 启动方式 | 核心用途 |
|---|---|---|
pprof/goroutine |
http://localhost:6060/debug/pprof/goroutine?debug=2 |
查看所有 goroutine 当前状态与堆栈 |
pprof/heap |
go tool pprof http://localhost:6060/debug/pprof/heap |
辅助识别因泄漏导致的内存持续增长 |
GODEBUG=gctrace=1 |
环境变量启动程序 | 观察 GC 频次突增——goroutine 泄漏常伴随对象长期驻留 |
真正的并发健壮性不在于“写对”,而在于“证无错”。每一次 go run -gcflags="-m" main.go 的逃逸分析、每一份 pprof 报告中的 goroutine 堆栈快照,都是对并发契约的现场验真。
第二章:goroutine生命周期与资源泄漏全景剖析
2.1 goroutine创建开销与调度器底层机制解析
goroutine 是 Go 并发的核心抽象,其轻量性源于用户态调度(GMP 模型)与栈动态伸缩机制。
栈分配与初始开销
新建 goroutine 仅分配约 2KB 栈空间(非固定),远低于 OS 线程的 MB 级开销:
go func() {
// 此 goroutine 初始栈为 2KB,按需增长/收缩
fmt.Println("hello from small stack")
}()
逻辑分析:go 关键字触发 newproc 系统调用,构造 g 结构体并入运行队列;参数通过寄存器传递,避免堆分配,延迟至栈溢出时才扩容。
GMP 调度核心角色
| 角色 | 职责 |
|---|---|
| G (Goroutine) | 用户代码执行单元,含栈、状态、上下文 |
| M (Machine) | OS 线程,绑定内核调度器 |
| P (Processor) | 逻辑处理器,持有本地运行队列与调度资源 |
协作式抢占流程
graph TD
A[新 goroutine 创建] --> B[G 放入 P 的 local runq]
B --> C[调度器轮询 runq]
C --> D[M 执行 G,遇函数调用/系统调用/阻塞]
D --> E[自动让出 P,触发 work-stealing]
- goroutine 创建平均耗时
- 非阻塞场景下,P 可每毫秒调度数千 G
2.2 常见泄漏模式识别:未关闭channel、阻塞等待、闭包捕获导致的隐式引用
未关闭 channel 引发的 Goroutine 泄漏
当 range 遍历一个未关闭的 channel 时,接收 goroutine 将永久阻塞:
func leakyWorker(ch <-chan int) {
for v := range ch { // ch 永不关闭 → goroutine 永不退出
fmt.Println(v)
}
}
逻辑分析:range 在 channel 关闭前不会退出循环;若生产者忘记调用 close(ch),该 goroutine 及其栈帧将持续驻留内存,且无法被 GC 回收。
闭包隐式捕获导致的生命周期延长
func makeHandler(data []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Write(data) // data 被闭包捕获 → 整个切片无法释放
}
}
逻辑分析:即使 data 很大,只要 handler 存活,底层底层数组即被强引用,GC 无法回收。
| 模式 | 触发条件 | 典型征兆 |
|---|---|---|
| 未关闭 channel | range ch + 无 close |
goroutine 数量持续增长 |
| 闭包隐式引用 | 捕获大对象(如 []byte) |
内存占用与请求量非线性上升 |
graph TD
A[启动 goroutine] --> B{channel 是否关闭?}
B -- 否 --> C[永久阻塞]
B -- 是 --> D[正常退出]
C --> E[内存/Goroutine 泄漏]
2.3 pprof + trace双引擎实战:定位泄漏goroutine栈与内存快照
当服务持续增长却未释放 goroutine,pprof 与 trace 协同可精准定位根因。
启动双引擎采集
# 同时启用 goroutine profile 与 execution trace
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine?debug=2
go tool trace http://localhost:6060/debug/trace
?debug=2 输出完整栈(含 runtime 调用),trace 捕获调度事件流,二者时间轴对齐可交叉验证阻塞点。
关键诊断路径
- 访问
/debug/pprof/goroutine?debug=2查看活跃 goroutine 栈; - 在
go tool traceUI 中筛选Goroutines → View traces,定位长期Runnable或Syscall状态的 G; - 对比两者中相同函数调用链(如
http.HandlerFunc → sync.WaitGroup.Wait)。
常见泄漏模式对照表
| 现象 | pprof 表现 | trace 辅证 |
|---|---|---|
| WaitGroup 未 Done | 大量 goroutine 卡在 Wait |
G 长期处于 Runnable 无调度执行 |
| channel 无接收者 | 卡在 chan send |
对应 GoCreate 后无匹配 GoStart |
graph TD
A[HTTP 请求触发 goroutine] --> B{是否调用 wg.Wait?}
B -->|Yes| C[检查 wg.Add/wg.Done 是否配对]
B -->|No| D[检查 channel 发送端是否有接收协程]
C --> E[定位缺失 wg.Done 的分支]
D --> F[通过 trace 查找阻塞的 chan send]
2.4 泄漏复现与最小化用例构建:基于net/http与time.Ticker的典型场景
数据同步机制
常见错误是将 time.Ticker 作为 HTTP handler 的局部变量启动,却未在请求结束时停止:
func handler(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(1 * time.Second) // ❌ 每次请求创建新ticker
go func() {
for range ticker.C {
// 同步逻辑(如调用API)
}
}()
w.WriteHeader(http.StatusOK)
}
逻辑分析:ticker 未被 ticker.Stop(),导致 goroutine 与 timer 持续存活;GC 无法回收,形成 goroutine + timer 双泄漏。time.Ticker 底层持有非垃圾可回收的系统级定时器资源。
最小化复现步骤
- 启动 HTTP server 并高频请求
/sync - 使用
pprof/goroutine观察 goroutine 数线性增长 pprof/heap显示timer对象持续累积
关键修复模式
| 问题位置 | 安全替代方案 |
|---|---|
| Handler 内启动 | 改为全局复用 + 请求上下文控制 |
| 无超时退出 | 增加 context.WithTimeout 配合 select |
graph TD
A[HTTP Request] --> B{启动Ticker?}
B -->|Yes, 未Stop| C[goroutine泄漏]
B -->|No, 全局+WithContext| D[安全退出]
2.5 自动化检测方案:静态分析工具go vet增强与运行时泄漏钩子注入
go vet 的定制化扩展
通过 go tool vet -help 可查看内置检查器,但需自定义 http 包误用检测:
// custom-checker.go:注册自定义检查器
func init() {
vet.Register("httpclient-leak", func() interface{} {
return &HTTPClientLeakChecker{}
})
}
该代码注册名为 httpclient-leak 的检查器;vet.Register 要求返回实现 vet.Checker 接口的结构体,参数名即为命令行启用标识(如 go vet -httpclient-leak)。
运行时泄漏钩子注入
在 init() 中动态注入资源追踪逻辑:
func init() {
http.DefaultClient = &tracingClient{Client: http.DefaultClient}
}
替换默认客户端,使所有未显式指定 client 的 HTTP 调用均经由带生命周期记录的包装器。
检测能力对比
| 方式 | 检测时机 | 覆盖场景 | 误报率 |
|---|---|---|---|
| 原生 go vet | 编译前 | 明显语法/类型错误 | 低 |
| 增强 vet | 编译前 | http.Client 长期持有 |
中 |
| 运行时钩子 | 运行中 | net.Conn 未关闭泄漏 |
高 |
graph TD
A[源码] --> B[go vet 增强扫描]
A --> C[构建时注入钩子]
B --> D[报告潜在泄漏点]
C --> E[运行时连接生命周期监控]
第三章:channel语义本质与同步建模
3.1 channel底层结构与内存模型:hchan、sendq、recvq与lock的协同机制
Go 的 channel 并非简单队列,而是由运行时结构 hchan 封装的同步原语。其核心字段包括:
lock:mutex实现的自旋锁,保护所有临界操作sendq/recvq: 分别为sudog链表,挂起阻塞的 goroutinebuf: 循环缓冲区指针(仅 buffered channel 非 nil)qcount,dataqsiz: 当前元素数与缓冲容量
数据同步机制
当 ch <- v 执行时:
- 若
recvq非空 → 直接唤醒头节点 goroutine,跳过缓冲区 - 否则若
buf未满 → 拷贝到环形队列,qcount++ - 否则将当前 goroutine 封装为
sudog,入队sendq并休眠
// runtime/chan.go 简化逻辑片段
func chansend(c *hchan, ep unsafe.Pointer, block bool) bool {
lock(&c.lock)
if !block && c.recvq.first == nil && c.qcount == c.dataqsiz {
unlock(&c.lock)
return false // 非阻塞且无接收者、缓冲满 → 快速失败
}
// ... 实际发送路径
unlock(&c.lock)
return true
}
此函数在持锁下检查接收方就绪性与缓冲状态;
block=false时避免 goroutine 挂起,适用于 select default 分支。
协同流程示意
graph TD
A[goroutine 发送] --> B{recvq有等待者?}
B -->|是| C[拷贝数据→sudog.ep,唤醒recvq头]
B -->|否| D{缓冲区有空位?}
D -->|是| E[写入buf,qcount++]
D -->|否| F[封装sudog入sendq,gopark]
| 字段 | 类型 | 作用说明 |
|---|---|---|
lock |
mutex | 串行化所有 send/recv/close 操作 |
sendq |
waitq | 阻塞发送者的 sudog 双向链表 |
recvq |
waitq | 阻塞接收者的 sudog 双向链表 |
buf |
unsafe.Pointer | 环形缓冲区首地址(可为 nil) |
3.2 无缓冲/有缓冲/channel关闭三态下的行为边界实验验证
数据同步机制
无缓冲 channel 要求发送与接收严格配对,否则阻塞;有缓冲 channel 在容量内允许异步写入:
ch1 := make(chan int) // 无缓冲
ch2 := make(chan int, 2) // 缓冲容量=2
go func() { ch1 <- 1 }() // 立即阻塞,无接收者
ch2 <- 1; ch2 <- 2 // 成功:缓冲未满
ch2 <- 3 // 阻塞:缓冲已满(2个元素)
ch1 的发送需等待 goroutine 启动并执行 <-ch1 才能继续;ch2 第三次写入会永久挂起,除非有接收操作释放空间。
关闭语义边界
channel 关闭后:
- 再次关闭 panic;
- 向已关闭 channel 发送 panic;
- 从已关闭 channel 接收:立即返回零值 +
ok=false。
| 状态 | 发送行为 | 接收行为(带ok) |
|---|---|---|
| 未关闭 | 阻塞或成功 | 阻塞或成功 |
| 已关闭 | panic | 返回零值,ok=false |
| 关闭后重关 | panic | — |
行为协同图示
graph TD
A[goroutine A] -->|ch <- v| B[Channel]
B -->|<- ch| C[goroutine B]
D[close(ch)] -->|触发| B
B -->|后续recv| E[zero, false]
3.3 select多路复用的公平性陷阱与default分支的误用反模式
公平性缺失的根源
select 在 Go 中并非轮询调度器,而是随机选择就绪 channel。当多个 case 同时就绪时,运行时以伪随机方式选取,导致高优先级或高频 channel 可能长期“饥饿”。
default 分支的典型误用
for {
select {
case msg := <-ch1:
process(msg)
case <-timeout:
log.Warn("timeout")
default: // ❌ 错误:退化为忙循环,CPU 100%
runtime.Gosched()
}
}
逻辑分析:
default立即执行,使select失去阻塞语义;Gosched()仅让出时间片,无法保证其他 goroutine 调度时机,形成空转。应改用带超时的select或time.After。
两种典型场景对比
| 场景 | 是否阻塞 | 公平性保障 | 推荐替代方案 |
|---|---|---|---|
select + default |
否 | 无 | select + time.After |
select 无 default |
是 | 随机 | case <-time.After(d) |
正确模式示意
graph TD
A[进入 select] --> B{是否有 channel 就绪?}
B -->|是| C[随机选一个执行]
B -->|否| D[阻塞等待]
C --> E[继续下一轮]
D --> E
第四章:死锁诊断体系与高阶并发原语协同
4.1 死锁四要素在Go中的映射:goroutine阻塞图构建与cycle检测算法实现
Go运行时虽不显式暴露goroutine依赖关系,但可通过runtime.Stack()与debug.ReadGCStats()辅助推断阻塞拓扑。死锁四要素(互斥、占有并等待、不可剥夺、循环等待)在Go中映射为:
- 互斥 →
sync.Mutex/sync.RWMutex - 占有并等待 → goroutine持锁后调用
ch <- v或<-ch阻塞 - 不可剥夺 → Go无锁抢占机制,一旦持有即持续至显式释放
- 循环等待 → goroutine A 等待 B 持有的 channel/ch,B 等待 A 持有的 mutex
数据同步机制
使用sync.Map记录活跃goroutine ID及其等待资源类型(channel/mutex/cond),结合pprof.Lookup("goroutine").WriteTo()获取全量栈快照。
cycle检测核心逻辑
// 构建有向边:g1 → g2 表示 g1 在等待 g2 持有的资源
func detectCycle(edges map[uint64][]uint64) bool {
visited, recStack := make(map[uint64]bool), make(map[uint64]bool)
for g := range edges {
if !visited[g] && dfs(g, edges, visited, recStack) {
return true // 发现环
}
}
return false
}
edges键为goroutine ID(GetGID()获取),值为它直接等待的goroutine列表;dfs执行深度优先遍历,recStack标记当前递归路径,避免误判跨路径依赖。
| 要素 | Go原语示例 | 检测方式 |
|---|---|---|
| 互斥 | mu.Lock() |
栈帧含 sync.(*Mutex).Lock |
| 循环等待 | ch1 <- ch2 + ch2 <- ch1 |
双向channel阻塞链 |
graph TD
G1["Goroutine #1\nmu.Lock()\n<-chA"] --> G2["Goroutine #2\nchA <-\nmu.Lock()"]
G2 --> G3["Goroutine #3\n<-chB"]
G3 --> G1
4.2 channel死锁的七种典型场景复现与gdb调试断点注入技巧
常见死锁模式速览
- 向无缓冲channel发送而无接收者
- 从空channel接收而无发送者
- 在select中仅含send/receive但全部阻塞
- goroutine泄漏导致channel等待永久挂起
- 关闭已关闭channel引发panic(非死锁但常伴生)
- 循环依赖:A→B→C→A跨goroutine channel链
- 主goroutine等待子goroutine完成,而子goroutine在channel上阻塞
复现最简死锁示例
func main() {
ch := make(chan int) // 无缓冲
ch <- 42 // 阻塞:无接收者,主goroutine挂起
}
逻辑分析:make(chan int)创建容量为0的channel;ch <- 42立即阻塞,因无其他goroutine执行<-ch,触发fatal error: all goroutines are asleep - deadlock!。参数说明:chan int类型决定通信数据为整型,零容量是阻塞根源。
gdb断点注入关键指令
| 命令 | 作用 |
|---|---|
runtime.Breakpoint() |
在Go代码中插入软断点,需配合dlv debug |
b runtime.chansend1 |
在gdb/dlv中对channel发送底层函数下断 |
p *(struct hchan*)ch |
查看channel内部结构(buf、sendq、recvq等) |
graph TD
A[main goroutine] -->|ch <- 42| B[chan.sendq 为空]
B --> C{recvq有等待者?}
C -->|否| D[永久阻塞 → 死锁检测触发]
4.3 sync.Mutex/RWMutex与channel混合使用时的竞争放大效应分析
数据同步机制
当 sync.Mutex 或 RWMutex 与 channel 混合用于同一临界资源协调时,可能因双重同步开销引发竞争放大:锁保护的临界区中若包含阻塞 channel 操作(如 ch <- x),将延长持锁时间,使其他 goroutine 在 Lock() 上排队加剧。
典型误用示例
var mu sync.RWMutex
var data map[string]int
var ch = make(chan int, 1)
func badWrite(val int) {
mu.Lock()
data["key"] = val
ch <- val // ⚠️ 阻塞操作延长持锁!
mu.Unlock() // 实际释放延迟不可控
}
逻辑分析:ch <- val 若缓冲满或无接收者,将导致 mu.Lock() 持有时间从微秒级拉长至毫秒级甚至更久;RWMutex 的读写互斥特性在此场景下完全失效,写锁竞争陡增。
竞争放大对比(单位:ns/op)
| 场景 | 平均延迟 | goroutine 阻塞率 |
|---|---|---|
| 纯 mutex(无 channel) | 85 ns | |
| mutex + 同步 channel | 12,400 ns | 67% |
| mutex + 异步解耦(推荐) | 92 ns |
正确解耦模式
func goodWrite(val int) {
mu.Lock()
data["key"] = val
mu.Unlock() // 立即释放
select {
case ch <- val: // 非阻塞尝试
default:
}
}
graph TD A[goroutine 调用 badWrite] –> B[acquire mu.Lock] B –> C[写入 data] C –> D[ch E[mu.Unlock 延迟] E –> F[其他 goroutine 在 Lock 处排队]
4.4 context.Context在超时取消与goroutine协作中的防死锁设计范式
核心矛盾:取消信号传递与资源释放的竞态
当多个 goroutine 共享一个 context.Context 并依赖其 Done() 通道关闭来终止工作时,若某 goroutine 在接收到取消信号后仍尝试向已关闭的 channel 发送数据(如结果回传),将触发 panic;更隐蔽的是,若等待方未设超时地阻塞在接收端,而发送方因取消提前退出,则形成双向等待——即死锁。
防死锁三原则
- ✅ 始终使用
select+ctx.Done()配合默认分支或超时分支 - ✅ 所有发送操作前检查
ctx.Err() != nil或用select非阻塞判断 - ✅ 关键资源(如 mutex、channel)的持有者必须是 ctx 生命周期的“责任方”
典型错误模式与修复
func riskyWorker(ctx context.Context, ch chan<- int) {
select {
case <-ctx.Done():
return // 正确:及时退出
default:
ch <- 42 // 危险:ch 可能已被接收方关闭!
}
}
逻辑分析:
default分支导致无条件写入,若ch已被关闭(如主 goroutine 因超时提前close(ch)),则引发 panic。应改用带ctx.Done()的select,或先检查ctx.Err()再决定是否发送。
安全协作流程(mermaid)
graph TD
A[主 Goroutine 创建 ctx.WithTimeout] --> B[启动 worker1/worker2]
B --> C{worker 持续 select ctx.Done() or work}
C -->|ctx done| D[清理资源并退出]
C -->|work done| E[安全发送结果 via select with default]
D --> F[关闭结果 channel]
E --> F
超时协作关键参数对照表
| 参数 | 类型 | 说明 |
|---|---|---|
ctx.Deadline() |
time.Time, bool |
获取绝对截止时间,用于外部调度对齐 |
ctx.Err() |
error |
返回 Canceled 或 DeadlineExceeded,可多次调用且线程安全 |
<-ctx.Done() |
<-chan struct{} |
仅用于监听,不可读取其值(零值 struct{}) |
第五章:从goroutine泄漏到channel死锁的全链路诊断手册
常见泄漏模式:未关闭的HTTP服务器与goroutine堆积
当使用 http.ListenAndServe() 启动服务但未配合 context.WithTimeout 或显式调用 srv.Shutdown() 时,http.Server 内部的 Serve() 方法会持续监听并启动新 goroutine 处理连接。若服务进程意外崩溃或 SIGTERM 未被正确捕获,已启动的 handler goroutine 可能因阻塞在日志写入、数据库查询或未超时的 time.Sleep() 中而永久存活。以下代码片段即典型隐患:
func startServer() {
http.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(10 * time.Second) // 模拟慢响应且无上下文取消检查
w.Write([]byte("done"))
})
http.ListenAndServe(":8080", nil) // 无 Shutdown 机制,进程退出时 goroutine 无法回收
}
Channel死锁的三类触发场景
| 场景类型 | 触发条件 | 典型代码特征 |
|---|---|---|
| 单向发送无接收 | 向无缓冲 channel 发送数据,且无其他 goroutine 接收 | ch := make(chan int); ch <- 42 |
| 接收方永远阻塞 | select 中仅含 default 分支,但主逻辑依赖 channel 通知 |
select { default: time.Sleep(100ms) } 循环中遗漏 case <-done: |
| 关闭后继续读写 | 对已关闭 channel 执行发送操作(panic)或重复关闭(panic) | close(ch); close(ch) 或 ch <- 1 后再次发送 |
使用pprof定位泄漏goroutine的完整流程
- 在应用中启用 pprof HTTP 端点:
import _ "net/http/pprof"并启动http.ListenAndServe("localhost:6060", nil) - 持续压测 5 分钟后执行
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt - 统计活跃 goroutine 数量:
grep -c "created by" goroutines.txt - 过滤高频堆栈:
awk '/created by/ {print $NF}' goroutines.txt | sort | uniq -c | sort -nr | head -10
Mermaid流程图:死锁检测自动化决策路径
flowchart TD
A[程序启动] --> B{是否启用 deadlock detector?}
B -->|是| C[启动 goroutine 监控协程]
B -->|否| D[跳过检测]
C --> E[每30秒扫描 runtime.NumGoroutine()]
E --> F{goroutine 数量连续3次 > 阈值?}
F -->|是| G[触发 stack dump 到 /tmp/deadlock-$(date +%s).log]
F -->|否| E
G --> H[分析 goroutine 等待链:chan send/recv 状态]
H --> I[输出可疑 channel 地址与创建位置]
实战案例:Websocket广播服务中的双向channel陷阱
某实时通知服务使用 map[*Client]chan Message 存储客户端通道,并通过中心 goroutine 广播消息。问题出现在客户端断开时仅关闭了读 channel,但写 channel 仍被广播 goroutine 持有并持续尝试发送:
// 错误:未同步关闭写通道,导致广播 goroutine 在 ch <- msg 处永久阻塞
func broadcast(msg Message) {
for client, ch := range clients {
select {
case ch <- msg: // 若 client 已断开且 ch 未关闭,此处死锁
default:
delete(clients, client)
}
}
}
正确修复需在 client 断开时立即关闭其写 channel,并在广播前检查 channel 是否已关闭(通过 select 的 default + len(ch) == 0 组合判断)。
工具链协同诊断清单
go tool trace:捕获 5 秒 trace 数据,使用go tool trace trace.out查看 goroutine 阻塞时间轴gops:运行时动态 attach 进程,执行gops stack <pid>获取实时堆栈快照go vet -race:静态检测潜在竞态,虽不直接发现死锁,但可暴露 channel 使用不一致问题
生产环境熔断策略:基于goroutine数量的自动降级
当 runtime.NumGoroutine() 超过预设阈值(如 5000),触发以下动作:
- 拒绝新 HTTP 连接(设置
http.Server.ReadHeaderTimeout = 1s) - 关闭所有非核心 channel(如 metrics reporting channel)
- 强制 GC 并记录告警事件到 Prometheus:
goroutines_high{service="notification"} 1
深度排查:从 runtime.Stack() 提取 channel 等待信息
在 panic hook 中注入如下逻辑,可捕获死锁发生瞬间的 channel 等待详情:
func captureChannelState() string {
var buf bytes.Buffer
pprof.Lookup("goroutine").WriteTo(&buf, 2)
// 正则提取 "chan send" / "chan receive" 行并统计频次
re := regexp.MustCompile(`chan (send|receive)`)
matches := re.FindAllString(buf.String(), -1)
return fmt.Sprintf("send:%d, receive:%d",
len(filter(matches, "send")),
len(filter(matches, "receive")))
} 