第一章:Go执行时goroutine泄漏的典型现象与危害
goroutine泄漏是Go程序中隐蔽而危险的运行时问题,表现为goroutine持续创建却无法被调度器回收,最终耗尽系统资源。其典型现象包括进程内存持续增长、runtime.NumGoroutine() 返回值单调递增、PProf火焰图中出现大量阻塞在 select{} 或 chan receive 的 goroutine,以及程序响应延迟逐步恶化。
常见泄漏模式
- 未关闭的channel监听:
for range ch在发送方已关闭 channel 后仍可安全退出,但若ch永远不关闭,且无超时或退出信号,监听 goroutine 将永久阻塞。 - 忘记调用
cancel()的 context:使用context.WithCancel或WithTimeout创建子 context 后,若未显式调用cancel(),关联的 goroutine(如time.Timer驱动的清理逻辑)将无法释放。 - WaitGroup 使用不当:
wg.Add(1)被调用但对应wg.Done()永远未执行(如 panic 未 recover、提前 return、死锁),导致wg.Wait()永久挂起。
危害表现
| 指标 | 正常状态 | 泄漏严重时表现 |
|---|---|---|
runtime.NumGoroutine() |
稳态波动(数百内) | 持续增长至数万甚至十万+ |
| RSS 内存占用 | 相对稳定 | 每小时增长数百 MB,OOM 风险升高 |
| GC 周期频率 | 数秒一次 | GC 频繁触发,STW 时间显著延长 |
快速检测方法
启动程序后,通过 HTTP pprof 接口实时观测:
# 查看当前 goroutine 数量(需启用 net/http/pprof)
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | grep -c "created by"
# 导出堆栈快照用于比对
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.log
若多次采样发现相同 goroutine 栈帧反复出现且数量递增,即为强泄漏信号。配合 go tool pprof 可定位泄漏源头函数:
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list your_handler_func
第二章:从runtime·newproc1出发的五层调用栈解剖
2.1 newproc1源码级追踪:goroutine创建的汇编与调度器交互
newproc1 是 Go 运行时中 go 语句落地的核心函数,位于 src/runtime/proc.go,其职责是将新 goroutine 的栈、函数指针、参数等封装为 g 结构体,并交由调度器排队。
关键汇编入口点
调用链为:go f() → runtime.newproc(Go)→ runtime.newproc1(Go)→ runtime.asmcgocall 或直接跳转至 runtime.newproc1 汇编桩(asm_amd64.s 中的 runtime·newproc1)。
参数传递约定(AMD64)
| 寄存器 | 含义 |
|---|---|
AX |
fn *funcval(待执行函数) |
BX |
argp unsafe.Pointer(参数起始地址) |
CX |
siz uintptr(参数总大小) |
// runtime/asm_amd64.s 片段(简化)
TEXT runtime·newproc1(SB), NOSPLIT, $0
MOVQ fn+0(FP), AX // fn: *funcval
MOVQ argp+8(FP), BX // argp: unsafe.Pointer
MOVQ siz+16(FP), CX // siz: uintptr
CALL runtime·newproc1(SB)
该汇编块严格遵循 Go ABI,将高层 Go 调用参数安全映射到 newproc1 的 Go 函数签名,确保 g 初始化前的上下文完整。
调度器交互路径
graph TD
A[go f(x)] --> B[newproc]
B --> C[newproc1]
C --> D[allocg: 分配g结构体]
D --> E[stackalloc: 绑定栈]
E --> F[gogo: 入就绪队列或直接执行]
2.2 goroutine启动函数封装:_gopanic、goexit与fnv1a哈希校验实践
Go运行时在goroutine启动阶段需确保函数入口安全、退出可控,并防范元数据篡改。_gopanic与goexit并非用户调用函数,而是编译器注入的运行时钩子:前者触发栈展开与defer链执行,后者完成协程资源回收与状态归零。
fnv1a哈希校验设计动机
为防止runtime.g结构体中fn字段被非法覆写,运行时对函数指针做轻量级完整性校验:
// fnv1a-32 hash of func pointer (uintptr)
func fnv1aHash(ptr uintptr) uint32 {
h := uint32(2166136261)
for i := 0; i < 8; i++ { // 64-bit ptr → 8 bytes
b := uint32(ptr >> (i * 8)) & 0xff
h ^= b
h *= 16777619
}
return h
}
逻辑说明:对
uintptr逐字节异或+乘法散列,避免长度依赖;参数ptr为函数入口地址,输出32位校验值,嵌入g._panic或g.status字段旁作快速验证。
运行时校验流程
graph TD
A[goroutine start] --> B{fn ptr valid?}
B -->|yes| C[call fn]
B -->|no| D[_gopanic → stack unwind]
C --> E[defer exec → goexit]
E --> F[g cleanup]
| 校验位置 | 触发时机 | 错误后果 |
|---|---|---|
newproc1 |
goroutine创建时 | 拒绝调度,静默失败 |
goexit前检查 |
协程退出前 | 强制panic并打印hash mismatch |
2.3 net/http.(*conn).serve调用链还原:基于pprof trace与GODEBUG=schedtrace=1的实证分析
为精准定位 HTTP 连接处理瓶颈,我们结合两种诊断手段交叉验证:
GODEBUG=schedtrace=1输出 Goroutine 调度快照,确认(*conn).serve在 M0 上持续运行且无抢占延迟pprof trace捕获 5s 执行轨迹,导出trace.out后用go tool trace可视化关键路径
核心调用链还原结果
// pprof trace 中提取的典型栈帧(截断)
net/http.(*conn).serve
net/http.(*Server).ServeHTTP
net/http.(*ServeMux).ServeHTTP
myHandler.ServeHTTP // 用户注册的 handler
该栈表明:(*conn).serve 是连接级入口,不直接执行业务逻辑,而是委托给 Server.Handler;其阻塞点常位于 ReadRequest 或 writeResponse 的底层 conn.Read/Write 系统调用。
调度行为关键指标(schedtrace 截取)
| 字段 | 值 | 含义 |
|---|---|---|
SCHED |
M0:1/0/0 |
M0 正在运行 1 个 G,无可运行/可抢占 G |
GOMAXPROCS |
1 |
单 P 配置下易暴露 (*conn).serve 长期占用问题 |
graph TD
A[accept conn] --> B[go c.serve()]
B --> C{read request}
C -->|success| D[server.Handler.ServeHTTP]
C -->|timeout/error| E[close conn]
D --> F[write response]
2.4 serverHandler.ServeHTTP中的隐式goroutine生命周期陷阱:context.WithTimeout与defer recover失效场景复现
问题根源:HTTP handler 中的 goroutine 泄漏
当在 ServeHTTP 内部启动 goroutine 并绑定 r.Context() 时,若未显式同步父 context 生命周期,context.WithTimeout 的取消信号无法传播至子 goroutine。
失效复现场景
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel() // ✅ 主 goroutine 正常触发
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic recovered: %v", r) // ❌ 永不执行
}
}()
select {
case <-time.After(500 * time.Millisecond):
doWork()
case <-ctx.Done(): // ⚠️ ctx.Done() 不会关闭——因父 context 已被 ServeHTTP 释放
return
}
}()
}
逻辑分析:r.Context() 在 ServeHTTP 返回后即被 net/http 库回收并置为 context.Background(),子 goroutine 中的 ctx.Done() 永远阻塞;defer recover() 因 goroutine 未 panic 且无退出路径而永不触发。
关键事实对比
| 场景 | context 可取消性 | defer recover 是否生效 | goroutine 是否泄漏 |
|---|---|---|---|
直接使用 r.Context() 启动 goroutine |
❌(父 context 被提前释放) | ❌(goroutine 永不退出) | ✅ |
使用 context.WithCancel(context.Background()) + 显式 cancel |
✅ | ✅(配合正常退出) | ❌ |
graph TD
A[serverHandler.ServeHTTP] --> B[创建 ctx = r.Context()]
B --> C[启动 goroutine]
C --> D{ctx.Done() 可接收?}
D -->|否| E[goroutine 永驻内存]
D -->|是| F[正常退出 + defer 执行]
2.5 handler函数内异步操作埋点检测:通过go tool compile -S识别未被await的go语句
Go 编译器不检查 go 语句是否被 await(Go 本身无 await 关键字,此为类比 JavaScript 的语义警示),但可通过汇编输出定位潜在泄漏的 goroutine。
汇编层识别未收敛的 goroutine 启动
运行:
go tool compile -S handler.go | grep -A3 "CALL.*runtime\.newproc"
该命令提取所有 go f() 对应的 runtime.newproc 调用点,每行含参数寄存器布局(如 AX 存函数指针,BX 存栈大小),是异步起点的黄金信号。
常见风险模式对比
| 场景 | 是否易漏检 | 编译期可见性 |
|---|---|---|
go log.Println() |
高 | ✅ newproc 显式存在 |
go func(){...}() |
中 | ✅ 同上 |
defer go cleanup() |
极高 | ✅ 但语义隐蔽 |
检测流程图
graph TD
A[源码含 go 语句] --> B[go tool compile -S]
B --> C[正则提取 newproc 调用]
C --> D[关联 AST 行号与 handler 函数范围]
D --> E[标记未被 context.Done() 或 sync.WaitGroup 约束的调用]
第三章:泄漏根因定位的三大核心方法论
3.1 pprof goroutine profile + runtime.Stack()交叉验证法(含生产环境安全采样脚本)
当怀疑 goroutine 泄漏或阻塞时,单一工具易受采样时机偏差影响。pprof 的 goroutine profile 提供快照式堆栈聚合视图,而 runtime.Stack() 可按需触发全量、未截断的原始堆栈输出,二者互补验证。
安全采样设计原则
- 限频:每分钟最多1次,避免高频调用干扰调度器
- 限长:仅采集
GoroutineRunning状态堆栈(排除syscall等瞬态状态) - 限深:
runtime.Stack(buf, false)避免 full stack(true)引发内存抖动
生产就绪采样脚本(Go)
func safeGoroutineDump() []byte {
buf := make([]byte, 2<<20) // 2MB buffer
n := runtime.Stack(buf, false) // false = no full stack
if n == len(buf) {
buf = append(buf[:n], " [TRUNCATED]\n"...)
}
return buf[:n]
}
runtime.Stack(buf, false)仅捕获当前 goroutine 的调用栈(非全局),但配合pprof.Lookup("goroutine").WriteTo(w, 1)可交叉比对阻塞点。false参数规避 GC 压力,2MB缓冲兼顾深度与安全性。
交叉验证流程
graph TD
A[pprof /debug/pprof/goroutine?debug=1] -->|聚合统计| B[识别高频率阻塞模式]
C[runtime.Stack] -->|原始堆栈| D[定位具体 goroutine ID 与调用链]
B & D --> E[确认泄漏根因:如未关闭 channel、死锁 select]
| 方法 | 优势 | 局限 |
|---|---|---|
pprof goroutine |
支持 HTTP 接口、可集成 Prometheus | 默认只显示 GoroutineRunning,无完整帧 |
runtime.Stack() |
全量、精确、可编程触发 | 需手动缓冲管理,不聚合 |
3.2 GODEBUG=gctrace=1与GODEBUG=schedtrace=1双轨日志关联分析实战
当同时启用 GODEBUG=gctrace=1,schedtrace=1,Go 运行时会并行输出 GC 事件与调度器状态快照,形成时间对齐的双轨日志流。
数据同步机制
调度器 trace 每 10ms 输出一次(默认),而 GC trace 在每次 GC 阶段切换时触发。二者共享同一 monotonic clock,可基于 ms 时间戳精确对齐:
# 示例混合日志片段(截取)
gc 1 @0.242s 0%: 0.017+0.12+0.014 ms clock, 0.13+0.06/0.03/0.05+0.11 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
SCHED 0.245s: gomaxprocs=8 idle=0 threads=10 spinning=0 idlep=0 runqueue=0 [0 0 0 0 0 0 0 0]
逻辑分析:
gc 1 @0.242s表示第 1 次 GC 在启动后 242ms 发生;SCHED 0.245s紧随其后,表明调度器在 GC 标记阶段结束前完成一轮轮询,此时runqueue=0且无空闲 P,印证了 STW 后的快速恢复。
关键字段对照表
| 字段 | gctrace 含义 |
schedtrace 含义 |
|---|---|---|
@X.XXXs |
GC 起始相对时间(程序启动) | 快照采集时刻(同基准) |
threads |
— | OS 线程总数(M 数) |
MB |
堆内存变化(from→to→next) | — |
GC 与调度协同流程
graph TD
A[GC Start] --> B[STW 开始]
B --> C[Mark Phase]
C --> D[并发标记中调度器采样]
D --> E[Mark Termination]
E --> F[Goroutine 恢复运行]
F --> G[SCHED trace 显示 runqueue↑]
3.3 基于go:linkname劫持runtime.gstatus与gcWork的运行时状态观测实验
Go 运行时内部符号默认不可导出,但 //go:linkname 指令可绕过导出限制,直接绑定私有变量。该机制被用于观测 runtime.gstatus(goroutine 状态码)与 gcWork(GC 工作缓冲)的实时变化。
核心符号绑定示例
//go:linkname gstatus runtime.gstatus
var gstatus uint32
//go:linkname gcw runtime.gcWork
var gcw struct {
nproc, nFlushCache uint32
}
此处
gstatus直接映射到全局 goroutine 状态寄存器;gcw结构体字段需严格匹配src/runtime/mgcwork.go中定义,否则触发 panic(字段偏移错位)。
关键状态码含义
| 状态值 | 名称 | 含义 |
|---|---|---|
| 0 | _Gidle | 刚分配,未初始化 |
| 2 | _Grunnable | 可被调度器选取执行 |
| 4 | _Grunning | 正在 M 上运行 |
GC 工作流观测逻辑
graph TD
A[goroutine 执行] --> B{gstatus == _Grunning?}
B -->|是| C[读取 gcw.nFlushCache]
B -->|否| D[跳过 GC 缓冲采样]
C --> E[记录 flush 频次与时间戳]
该实验揭示了 GC 辅助工作的隐式触发时机,为低开销运行时诊断提供新路径。
第四章:典型泄漏模式的工程化拦截与防御体系
4.1 HTTP Handler中goroutine泄漏的静态检查规则(基于go/analysis构建golint插件)
检查核心逻辑
识别 http.HandlerFunc 内部未受控启动的 go 语句,尤其当其引用了 handler 参数(如 r, w)或闭包捕获的 request-scoped 变量时。
关键模式匹配
go func() { ... }()在 handler 函数体中直接调用go someFunc(r, w)且someFunc非同步安全(无 context.Done() 监听)
func handler(w http.ResponseWriter, r *http.Request) {
go func() { // ❌ 静态检查触发:goroutine 持有 r/w 且无取消机制
time.Sleep(5 * time.Second)
fmt.Fprintf(w, "done") // 危险:w 可能已关闭
}()
}
逻辑分析:该 goroutine 引用了响应写入器
w,但 handler 返回后w生命周期即结束;go/analysis通过inspect.NodeFilter捕获GoStmt,再沿 AST 向上追溯是否在FuncLit或FuncDecl(签名含http.Handler)内,并检查闭包捕获变量是否含*http.Request或http.ResponseWriter类型。
检查能力对比表
| 规则项 | 能检测 | 说明 |
|---|---|---|
闭包捕获 r |
✅ | 基于类型推导与符号引用 |
go f(ctx, ...) |
⚠️ | 仅当 ctx 非 r.Context() 才放行 |
time.AfterFunc |
✅ | 统一归为异步执行节点 |
graph TD
A[遍历AST] --> B{是否 GoStmt?}
B -->|是| C[获取父函数签名]
C --> D{是否 http.HandlerFunc?}
D -->|是| E[分析闭包捕获变量类型]
E --> F[报告 goroutine 泄漏风险]
4.2 context.Context超时传播缺失的自动修复工具链(含AST重写示例)
当 Go 服务中 context.WithTimeout 调用未向下传递至下游函数(如 HTTP 客户端、DB 查询),将导致隐式无限等待。手动修复易遗漏,需自动化检测与修复。
检测逻辑核心
- 静态扫描:识别
ctx, cancel := context.WithTimeout(...)后未在调用链中传递ctx参数的函数调用; - AST 匹配:定位
*ast.CallExpr中形参含context.Context但实参非ctx变量的节点。
AST 重写示例(Go/analysis)
// 原始有缺陷代码
func handle(r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db.Query("SELECT ...") // ❌ 未传 ctx
}
// 重写后(自动注入)
func handle(r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
db.QueryContext(ctx, "SELECT ...") // ✅ 自动替换 + 补全参数
}
逻辑分析:工具遍历函数体语句,对
db.Query调用匹配*ast.SelectorExpr,判断接收者类型是否支持Context方法变体;若存在QueryContext,则将原调用重写为QueryContext(ctx, ...),并验证ctx作用域可见性。
| 修复阶段 | 工具组件 | 输出物 |
|---|---|---|
| 检测 | golang.org/x/tools/go/analysis |
报告未传播位置行号 |
| 重写 | golang.org/x/tools/go/ast/inspector |
修改后的 AST 节点树 |
graph TD
A[源码文件] --> B[Parse → AST]
B --> C{Find WithTimeout assignment}
C --> D[Find downstream calls missing ctx]
D --> E[Match method variants e.g., Query→QueryContext]
E --> F[Rewrite CallExpr + insert ctx arg]
4.3 defer+recover无法捕获panic导致goroutine卡死的熔断注入方案
当 panic 发生在 go 语句启动的 goroutine 中,且未在该 goroutine 内显式调用 defer+recover,则 panic 将直接终止该 goroutine —— 此时外层无法感知,亦无法熔断,形成“静默卡死”。
熔断注入核心机制
通过 runtime.SetPanicHandler(Go 1.22+)拦截全局 panic,并结合 sync.Map 记录活跃 goroutine ID 与上下文标签:
func init() {
runtime.SetPanicHandler(func(p *panicInfo) {
gid := getGoroutineID()
if tag, ok := activeTasks.Load(gid); ok {
circuitBreaker.Trip(tag.(string)) // 触发对应熔断器
}
})
}
逻辑说明:
getGoroutineID()借助runtime.Stack提取 goroutine ID;activeTasks在任务启动时Store(),完成时Delete();Trip()标记服务不可用并拒绝后续请求。
熔断状态映射表
| 状态 | 超时阈值 | 连续失败次数 | 自动恢复策略 |
|---|---|---|---|
| closed | — | — | 全量放行 |
| open | 60s | ≥3 | 定时探测半开 |
| half-open | — | — | 首个请求试探恢复 |
关键约束
- 必须在 goroutine 启动前注册
activeTasks.Store() SetPanicHandler仅作用于当前程序,不跨进程生效- 不兼容 Go recover +
context组合兜底
4.4 基于net/http/httptest与goroutine leak detector的单元测试断言框架集成
在 HTTP handler 测试中,httptest 提供轻量级请求模拟能力,但易因未关闭响应体或 goroutine 泄漏导致隐性失败。
核心集成模式
- 将
httptest.NewServer替换为httptest.NewRecorder以避免真实监听 - 在
TestMain中注入 goroutine leak 检测钩子(如goleak.VerifyTestMain)
示例:泄漏感知的 handler 测试
func TestHandlerWithLeakCheck(t *testing.T) {
defer goleak.VerifyNone(t) // 自动比对测试前后活跃 goroutine
req := httptest.NewRequest("GET", "/api/users", nil)
w := httptest.NewRecorder()
handler(w, req)
assert.Equal(t, 200, w.Code)
}
goleak.VerifyNone(t)在测试结束时扫描所有非系统 goroutine;httptest.NewRecorder避免启动 net.Listen,消除网络层干扰源。
断言框架适配要点
| 组件 | 作用 |
|---|---|
httptest.ResponseRecorder |
拦截响应,支持多次读取 Body |
goleak |
检测 test 函数生命周期内残留 goroutine |
testify/assert |
提供语义化断言,与 recorder 输出自然衔接 |
graph TD
A[测试开始] --> B[记录初始 goroutine 快照]
B --> C[执行 handler + recorder]
C --> D[验证 HTTP 状态/响应体]
D --> E[测试结束]
E --> F[比对 goroutine 快照并报错]
第五章:从泄漏溯源到Go运行时可观测性演进
泄漏现场:一次真实 pprof 分析复盘
某支付网关在凌晨 3 点突发内存持续增长,Grafana 告警显示 go_memstats_heap_inuse_bytes 在 4 小时内从 180MB 涨至 2.1GB。运维团队紧急抓取 curl http://localhost:6060/debug/pprof/heap?debug=1,用 go tool pprof -http=:8081 heap.pb.gz 可视化后定位到 encoding/json.(*decodeState).literalStore 占用 73% 的堆对象——根源是上游未校验的嵌套 JSON 数组(深度达 42 层),导致 json.Unmarshal 构建了巨型临时 map 树且未及时 GC。
运行时指标埋点:从黑盒到白盒的关键跃迁
Go 1.21 引入 runtime/metrics 包替代旧式 runtime.ReadMemStats,支持纳秒级采样。以下代码片段在 HTTP 中间件中注入实时指标采集:
import "runtime/metrics"
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sample := make([]metrics.Sample, 3)
sample[0].Name = "/gc/heap/allocs:bytes"
sample[1].Name = "/gc/heap/frees:bytes"
sample[2].Name = "/sched/goroutines:goroutines"
metrics.Read(sample)
log.Printf("Alloc=%v, Free=%v, Goroutines=%v",
sample[0].Value, sample[1].Value, sample[2].Value)
next.ServeHTTP(w, r)
})
}
指标关联分析:构建跨维度故障图谱
当发现 goroutines 持续高于 5000 时,需联动检查以下指标组合:
| 指标名 | 阈值 | 关联风险 |
|---|---|---|
/sched/goroutines:goroutines |
> 5000 | 协程泄漏嫌疑 |
/gc/heap/objects:objects |
> 2e6 | 对象创建风暴 |
/sync/mutex/wait/total:seconds |
> 10s/min | 锁竞争瓶颈 |
实际案例中,该组合触发后通过 pprof -mutex 发现 sync.RWMutex 在日志写入路径被长期持有,因 logrus 的 Hooks 未实现异步缓冲。
eBPF 辅助观测:绕过应用侵入的深层洞察
使用 bpftrace 实时捕获 Go 程序的系统调用异常模式:
# 监控所有 goroutine 的 write() 返回 -EAGAIN 频次(网络拥塞信号)
bpftrace -e '
kprobe:sys_write /pid == 12345/ {
@write_errs[comm, args->fd] = count();
}
interval:s:10 {
print(@write_errs);
clear(@write_errs);
}
'
该脚本在 CDN 边缘节点定位出 net/http.Transport 的 MaxIdleConnsPerHost 设置为 0 导致连接池失效,引发每秒 3000+ 次 connect() 系统调用失败。
运行时热修复:用 GODEBUG 动态干预
当线上无法立即发布新版本时,通过环境变量调整运行时行为:
# 降低 GC 频率缓解 CPU 尖刺(仅限短期应急)
GODEBUG=gctrace=1,gcpacertrace=1,GOGC=200 ./myapp
# 启用 goroutine stack dump 日志(每 5 秒输出阻塞协程)
GODEBUG=schedtrace=5000,scheddetail=1 ./myapp
某电商大促期间,通过 GOGC=150 将 GC 触发阈值提升 50%,成功将 STW 时间从 12ms 压降至 4.3ms,订单成功率回升至 99.98%。
可观测性闭环:从指标到自动处置
基于 Prometheus Alertmanager 的 webhook 集成,当 process_resident_memory_bytes{job="payment"} > 3e9 持续 2 分钟时,自动触发以下动作链:
- 调用
kubectl exec抓取 pprof heap profile - 解析 profile 并提取 top3 内存分配者
- 若检测到
github.com/xxx/yyy.(*Cache).Get占比超 40%,则执行curl -X POST http://localhost:8080/api/cache/evict?pattern=user*清理缓存
该机制在 2023 年双十二期间自动处置 7 次内存泄漏事件,平均恢复时间缩短至 47 秒。
