第一章:达梦数据库Go客户端内存泄漏定位三板斧(pprof heap分析、cgo引用计数追踪、dmStmt生命周期图谱)
达梦数据库(DM)的 Go 客户端基于 cgo 封装底层 C SDK,内存泄漏常隐匿于 Go 与 C 的边界——如未释放的 dmStmt 句柄、未调用 DmFreeStmt 的预编译语句、或 C.CString 分配后未 C.free。精准定位需协同三类技术手段,形成闭环验证。
pprof heap 分析
启动服务时启用 pprof:
import _ "net/http/pprof"
// 在 main 中启动:go func() { http.ListenAndServe("localhost:6060", nil) }()
持续压测后执行:
curl -s "http://localhost:6060/debug/pprof/heap?debug=1" > heap.out
go tool pprof --alloc_space heap.out # 查看累计分配(含已释放)
go tool pprof --inuse_objects heap.out # 查看当前存活对象
重点关注 github.com/dm-dbs/dm-go/dm.(*dmStmt).Init 和 C.DmAllocHandle 调用栈,若 inuse_objects 中 dmStmt 实例数随请求线性增长,即为强泄漏信号。
cgo 引用计数追踪
达梦 SDK 要求每个 DmAllocHandle 必须配对 DmFreeHandle。在 dmStmt 构造与析构处埋点:
// 在 dmStmt.Init 中插入:
fmt.Printf("[ALLOC] Stmt %p allocated at %s\n", s, debug.CallersFrames(debug.Callers(1)).Next().Function)
// 在 dmStmt.Close 中插入:
fmt.Printf("[FREE] Stmt %p freed at %s\n", s, debug.CallersFrames(debug.Callers(1)).Next().Function)
结合日志行数比对,若 ALLOC 与 FREE 数量不等,且缺失 FREE 日志,则确认句柄泄露。
dmStmt 生命周期图谱
| 阶段 | 触发动作 | 关键约束 |
|---|---|---|
| 创建 | db.Prepare() 或 stmt.Exec |
返回 *dmStmt,内部调用 DmAllocHandle |
| 使用 | Query, Exec, Close |
多次复用不触发新分配 |
| 显式释放 | stmt.Close() |
必须调用,否则 DmFreeStmt 不执行 |
| GC 回收 | *dmStmt 被 GC |
仅触发 Finalizer(若注册),不可依赖 |
务必禁用 SetMaxOpenConns(0)(无限制连接池)并设置合理 SetMaxIdleConns,避免连接池无限缓存未关闭的 dmStmt 实例。
第二章:pprof heap分析——从运行时堆快照定位泄漏源头
2.1 Go内存模型与达梦客户端堆分配特征解析
Go的内存模型基于goroutine私有栈 + 全局堆 + GC三色标记机制,而达梦数据库Go客户端(如dameng-go)在连接池、参数绑定和结果集扫描时频繁触发堆分配。
堆分配热点场景
sql.Rows.Scan()中字符串/字节切片的重复make([]byte, n)调用driver.Value接口实现体(如*dm.StringValue)的指针逃逸- 连接复用时
context.WithTimeout生成的timerCtx隐式堆分配
典型逃逸分析示例
func scanRow(rows *sql.Rows) (string, error) {
var name string
if err := rows.Scan(&name); err != nil { // name 逃逸至堆
return "", err
}
return name, nil // 返回堆上字符串头
}
&name迫使编译器将string底层结构(ptr,len,cap)整体分配到堆;达梦驱动未提供预分配缓冲区API,加剧小对象碎片。
| 分配位置 | 触发条件 | GC压力等级 |
|---|---|---|
dm.(*Conn).exec |
每次Prepare参数序列化 | ⚠️⚠️⚠️ |
dm.rows.Next() |
每行字段解包为interface{} | ⚠️⚠️ |
graph TD
A[SQL Query] --> B[达梦驱动参数绑定]
B --> C{是否使用预分配缓冲?}
C -->|否| D[heap: make([]byte, 256)]
C -->|是| E[stack: [256]byte]
D --> F[GC Mark-Sweep周期增加]
2.2 启动达梦应用并采集多阶段heap profile的实操指南
准备JVM启动参数
达梦Java应用需启用Native Memory Tracking(NMT)与Heap Profiling支持:
-XX:NativeMemoryTracking=detail \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintGCDetails \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/dm/logs/heap/ \
-agentlib:hprof=heap=sites,format=b,file=/dm/logs/hprof/start.hprof
heap=sites按对象分配站点统计内存占用;format=b生成二进制格式便于jhat或jvisualvm解析;首份快照捕获启动初期堆布局。
多阶段采样策略
按业务生命周期分三阶段触发采样:
- 启动完成(自动,见上参数)
- 高负载后(手动):
jcmd <pid> VM.native_memory summary scale=MB - 事务峰值时(定时):
jmap -histo:live <pid> > /dm/logs/histo-peak.txt
快照对比关键字段
| 字段 | 启动态 | 峰值态 | 差值趋势 |
|---|---|---|---|
char[] |
12 MB | 89 MB | ↑↑↑ |
java.util.HashMap$Node |
8 MB | 63 MB | ↑↑↑ |
内存增长归因流程
graph TD
A[启动阶段快照] --> B{负载上升}
B --> C[峰值阶段jmap -histo]
C --> D[比对class实例数增量]
D --> E[定位缓存未清理/连接池泄漏]
2.3 使用pprof可视化工具识别持续增长对象及持有链
Go 程序中内存持续增长常源于对象未被及时回收,或隐式持有导致 GC 无法释放。pprof 提供 --alloc_space(分配总量)与 --inuse_space(当前驻留)双视角分析能力。
启动带 pprof 的 HTTP 服务
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil)) // 开启 pprof 端点
}()
// 应用主逻辑...
}
此代码启用标准 pprof HTTP 接口;/debug/pprof/heap 默认返回 --inuse_space 快照,加 ?debug=1 可查看原始符号化堆栈。
捕获并分析增长链
# 连续采样,对比两次 heap profile
curl -s "http://localhost:6060/debug/pprof/heap?seconds=30" > heap1.pb.gz
sleep 60
curl -s "http://localhost:6060/debug/pprof/heap?seconds=30" > heap2.pb.gz
go tool pprof --base heap1.pb.gz heap2.pb.gz
--base 参数构建差分视图,聚焦新增分配;top -cum 显示持有链(如 http.HandlerFunc → cache.Store → []*Item),精准定位根持有者。
| 视角 | 关注指标 | 典型场景 |
|---|---|---|
--inuse_space |
当前存活对象大小 | 内存泄漏、缓存未驱逐 |
--alloc_space |
累计分配总量 | 高频短生命周期对象暴增 |
graph TD
A[pprof heap endpoint] --> B[采集 inuse_space 快照]
B --> C[生成 SVG 可视化]
C --> D[点击节点展开持有链]
D --> E[定位 root object 及其引用路径]
2.4 结合源码标注定位dmConn/dmStmt中异常retain的goroutine路径
核心问题现象
dmConn 和 dmStmt 在连接池复用后未被及时 GC,表现为 goroutine 持有 *C.DMCONN/*C.DMSTMT 指针长期不释放,触发 runtime.SetFinalizer 失效。
关键源码锚点
// dmstmt.go:127 — retain 链起点
func (s *dmStmt) Close() error {
if atomic.CompareAndSwapUint32(&s.closed, 0, 1) {
s.conn.retain() // ⚠️ 此处隐式延长 conn 生命周期
...
}
}
s.conn.retain() 并非原子引用计数递增,而是向 conn.mu 加锁后追加 stmt 到 conn.stmts slice — 若 stmt 未显式 Close(),该引用链持续存在。
调用链还原(mermaid)
graph TD
A[sql.Rows.Close] --> B[dmRows.close]
B --> C[dmStmt.Close]
C --> D[dmConn.retain]
D --> E[conn.stmts = append(conn.stmts, s)]
定位方法表
| 工具 | 命令示例 | 输出关键字段 |
|---|---|---|
| pprof goroutine | go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2 |
查找阻塞在 dmConn.retain 的 goroutine ID |
| delve trace | trace -p $(pidof app) runtime.mcall |
追踪 dmStmt.Close 入口栈帧 |
2.5 案例复现:未Close导致的*dmStmt实例累积与heap增长验证
数据同步机制
某金融系统使用达梦数据库(DM8)执行高频批量插入,每批次创建 dmStmt 执行 SQL,但遗漏 stmt.Close() 调用。
复现场景代码
for i := 0; i < 1000; i++ {
stmt, _ := db.Prepare("INSERT INTO t_log(id, msg) VALUES(?, ?)")
stmt.Exec(i, "test")
// ❌ 忘记 stmt.Close() —— 每次生成新 *dmStmt 实例且未释放
}
逻辑分析:
db.Prepare()在驱动层构造*dmStmt并注册到连接的 statement map;未调用Close()导致对象持续驻留 heap,且内部持有的*C.DM_STMT_HANDLE句柄不被回收。参数i和"test"触发 1000 次独立句柄分配。
内存增长观测(单位:MB)
| 时间点 | HeapAlloc | *dmStmt 数量 |
|---|---|---|
| 初始 | 5.2 | 0 |
| 循环后 | 42.7 | 998 |
资源泄漏路径
graph TD
A[db.Prepare] --> B[*dmStmt 实例创建]
B --> C[加入 conn.stmtMap]
C --> D[无 Close → map 不清理]
D --> E[句柄+内存持续累积]
第三章:cgo引用计数追踪——穿透C层资源生命周期盲区
3.1 CGO调用机制与达梦C API资源引用计数模型详解
CGO 是 Go 调用 C 函数的桥梁,其本质是通过 //export 和 C. 前缀实现跨语言 ABI 交互。达梦数据库 C API(如 dm_open, dm_exec_sql)均遵循 POSIX 风格资源管理,依赖显式引用计数。
达梦资源生命周期关键点
- 每次
dm_alloc_handle()返回句柄,内部 refcnt +1 dm_free_handle()仅在 refcnt 降为 0 时真正释放内存- 多线程共享句柄需手动同步(无内置原子操作)
引用计数典型误用场景
- Go goroutine 中重复
C.dm_free_handle(h)→ refcnt 下溢,触发段错误 - CGO 调用未
C.free()分配的 C 字符串 → 内存泄漏
// 示例:安全的连接句柄封装(Go 侧)
/*
#cgo LDFLAGS: -ldmcli
#include "dm.h"
*/
import "C"
import "unsafe"
func OpenDB() (*C.DMHANDLE, error) {
var h C.DMHANDLE
ret := C.dm_open(&h, "localhost", 5236, "SYSDBA", "SYSDBA")
if ret != C.DM_SUCCESS {
return nil, fmt.Errorf("open failed: %d", ret)
}
// 注意:h 已被达梦内部 refcnt+1,Go 不可直接 free
return &h, nil
}
该代码确保句柄由达梦 C API 自主管理 refcnt;Go 层仅传递指针,避免越权释放。参数 &h 为句柄地址,dm_open 通过二级指针写入初始化后的句柄值。
| 操作 | refcnt 变化 | 安全前提 |
|---|---|---|
dm_alloc_handle |
+1 | 必须配对 dm_free_handle |
dm_clone_handle |
+1 | 克隆后原句柄仍有效 |
dm_free_handle |
-1 | refcnt=0 时才释放底层资源 |
graph TD
A[Go 调用 C.dm_open] --> B[C 层分配句柄+refcnt=1]
B --> C[Go 保存 *C.DMHANDLE]
C --> D[后续 C.dm_exec_sql 使用该指针]
D --> E[C.dm_free_handle 触发 refcnt-1]
E --> F{refcnt == 0?}
F -->|是| G[释放内存]
F -->|否| H[仅减计数,资源继续存活]
3.2 利用runtime.SetFinalizer与自定义cgo wrapper实现引用计数埋点
核心设计思路
在 CGO 桥接场景中,Go 对象持有 C 资源(如 C.struct_handle*)时,需避免过早释放或重复释放。runtime.SetFinalizer 提供对象销毁钩子,但无法保证执行时机;因此需结合显式引用计数与 finalizer 的兜底机制。
自定义 Wrapper 结构体
type Handle struct {
cptr *C.struct_handle
refs int32 // 原子增减
}
cptr: 原始 C 资源指针,由C.create_handle()分配refs: 使用atomic.AddInt32管理,确保并发安全
引用生命周期管理流程
graph TD
A[NewHandle] --> B[atomic.AddInt32(&h.refs, 1)]
B --> C[传入C函数时再次Add]
C --> D[Go侧Drop: Decr并检查为0?]
D -->|是| E[C.destroy_handle(h.cptr)]
D -->|否| F[等待Finalizer兜底]
Finalizer 注册逻辑
func (h *Handle) initFinalizer() {
runtime.SetFinalizer(h, func(h *Handle) {
if atomic.LoadInt32(&h.refs) == 0 {
C.destroy_handle(h.cptr)
}
})
}
- Finalizer 仅在
refs == 0时触发销毁,避免 race; h是 Go 堆对象,finalizer 持有其强引用,防止提前回收。
| 场景 | 是否需 DecRef | Finalizer 是否生效 |
|---|---|---|
| 显式 Close() | ✅ | ❌(refs 已归零) |
| 忘记 Close() | ❌ | ✅(refs > 0 → 不销毁) |
3.3 基于dmcli.h头文件与DM SDK源码逆向推导关键句柄释放契约
核心发现:dm_handle_t 的生命周期语义
逆向 dmcli.h 可见其定义为不透明指针(typedef struct dm_handle_s* dm_handle_t;),但多处 API 签名隐含强约束:
// 示例:dm_open_device → 返回新分配句柄
dm_handle_t dm_open_device(const char* dev_id, uint32_t flags);
// 示例:dm_close_device → 显式释放,且禁止重复调用
int dm_close_device(dm_handle_t h);
逻辑分析:
dm_close_device()调用后,h进入无效状态;SDK 内部未做双重释放防护(实测崩溃),说明契约要求调用方严格遵循“一次打开、一次关闭”。
释放契约三原则
- ✅ 必须配对调用
dm_open_device/dm_close_device - ❌ 禁止跨线程共享并释放同一
dm_handle_t - ⚠️
dm_handle_t不可被free()或delete直接操作
关键调用时序(mermaid)
graph TD
A[dm_open_device] --> B[业务操作]
B --> C{是否完成?}
C -->|是| D[dm_close_device]
C -->|否| B
D --> E[句柄置空/失效]
| 场景 | 是否安全 | 依据 |
|---|---|---|
| 关闭后再次 close | 否 | SDK 段错误(SIGSEGV) |
| 打开后未关闭即退出 | 否 | 资源泄漏 + 设备锁残留 |
第四章:dmStmt生命周期图谱——构建Go层与C层协同管理的全链路视图
4.1 dmStmt创建、绑定、执行、关闭四阶段状态机建模
dmStmt 生命周期严格遵循确定性四阶段状态机,各阶段不可跳转、不可逆序。
状态迁移约束
- 创建(
dmStmtCreate)→ 绑定(dmStmtBind)→ 执行(dmStmtExecute)→ 关闭(dmStmtClose) - 任意阶段失败均转入
ERROR终态,需显式调用dmStmtReset恢复
核心状态流转图
graph TD
A[CREATED] -->|dmStmtBind| B[BOUND]
B -->|dmStmtExecute| C[EXECUTED]
C -->|dmStmtClose| D[CLOSED]
A -.->|fail| E[ERROR]
B -.->|fail| E
C -.->|fail| E
绑定阶段关键代码
// 绑定输入参数:idx=1为第一个?占位符,type=DM_SQL_INTEGER,val_ptr指向int变量
ret = dmStmtBind(stmt, 1, DM_SQL_INTEGER, &user_id, sizeof(int), &ind);
ind 为指示器变量:表示有效值,-1表示NULL;sizeof(int)确保内存对齐与驱动兼容性。
| 阶段 | 入口函数 | 禁止重复调用 | 可重入性 |
|---|---|---|---|
| 创建 | dmStmtCreate |
否 | 否 |
| 绑定 | dmStmtBind |
是(覆盖前值) | 是 |
| 执行 | dmStmtExecute |
否(需重绑) | 否 |
| 关闭 | dmStmtClose |
是(幂等) | 是 |
4.2 分析Prepare/Query/Exec/Scan等方法对底层C stmt句柄的隐式影响
Go 的 database/sql 包通过 Stmt 对象封装底层 C sqlite3_stmt* 句柄,但各方法调用会隐式触发不同生命周期操作。
stmt 句柄状态迁移
stmt, _ := db.Prepare("SELECT name FROM users WHERE id = ?")
// → 调用 sqlite3_prepare_v2(),创建并缓存 stmt 句柄
Prepare() 初始化句柄并进入 SQLITE_PREPARE 状态;后续 Query() 或 Exec() 触发 sqlite3_bind_* + sqlite3_step(),使句柄进入执行态;Scan() 则驱动 sqlite3_column_* 读取结果列。
隐式行为对比
| 方法 | 是否重置句柄 | 是否自动 finalizer | 是否复用预编译句柄 |
|---|---|---|---|
| Prepare | 否(新建) | 否 | 是(核心优势) |
| Query | 是(reset) | 否 | 是 |
| Exec | 是(reset) | 否 | 是 |
| Scan | 否(仅读取) | 否 | 无直接作用 |
生命周期关键点
graph TD
A[Prepare] --> B[sqlite3_prepare_v2]
B --> C[Stmt 缓存]
C --> D[Query/Exec]
D --> E[sqlite3_reset + sqlite3_step]
E --> F[Scan: sqlite3_column_text]
4.3 绘制典型业务场景下dmStmt跨goroutine传递引发的循环引用图谱
在高并发数据同步场景中,dmStmt(达梦数据库预编译语句对象)若被多个 goroutine 共享并持有引用,极易触发 GC 无法回收的循环引用。
数据同步机制
主 goroutine 创建 dmStmt 后,通过 channel 传递给 worker goroutine 执行批量插入,而回调闭包又反向捕获了外部 *sql.DB 和 dmStmt 实例:
stmt, _ := db.Prepare("INSERT INTO logs(...) VALUES (?, ?)")
go func(s *sql.Stmt) {
defer s.Close() // ❌ Close 被延迟,且可能永不执行
_, _ = s.Exec("a", "b")
}(stmt)
逻辑分析:
dmStmt内部持*driverConn引用,而*driverConn的mu锁和stmtsmap 又反向引用该dmStmt;跨 goroutine 传递后,GC 根集合中始终存在强引用链。
循环引用关键路径
| 组件 | 持有引用 | 被谁引用 |
|---|---|---|
dmStmt |
*driverConn |
driverConn.stmts[stmtID] |
driverConn |
sync.Mutex, dmStmt |
db.connPool, 闭包变量 |
graph TD
A[main goroutine: dmStmt] -->|channel send| B[worker goroutine]
B --> C[闭包捕获 dmStmt]
C --> D[dmStmt.conn → driverConn]
D --> E[driverConn.stmts → dmStmt]
E --> A
4.4 基于go:generate生成stmt生命周期检测桩代码并集成至CI流水线
在数据库访问层,SQL语句的生命周期(prepare → exec/query → close)易因开发者疏忽导致资源泄漏。我们利用 go:generate 自动生成带埋点的检测桩:
//go:generate go run stmtgen/main.go -output=stmt_hook_gen.go -pkg=repo
package repo
import "database/sql"
func (r *Repo) QueryWithTrace(query string, args ...any) (*sql.Rows, error) {
// 自动注入 traceID、记录 prepare 时间戳、校验 close 调用
return r.db.Query(query, args...)
}
该命令调用自研 stmtgen 工具,扫描所有 *sql.DB 方法调用,插入 defer traceStmtClose() 桩点,并注册 runtime.SetFinalizer 作为兜底告警。
检测机制核心能力
- ✅ 自动识别未 close 的
*sql.Rows/*sql.Stmt - ✅ 记录调用栈与超时阈值(默认 5s)
- ✅ 生成
StmtLeakReport结构体供断言
CI 流水线集成要点
| 阶段 | 动作 |
|---|---|
pre-test |
执行 go generate ./... |
unit-test |
启用 -tags=stmttrace 运行 |
post-test |
解析 leak_report.json 失败则阻断 |
graph TD
A[go generate] --> B[注入trace桩]
B --> C[测试运行时捕获泄漏]
C --> D{泄漏数 > 0?}
D -->|是| E[失败并输出调用链]
D -->|否| F[通过]
第五章:总结与展望
核心成果回顾
在本项目实践中,我们成功将 Kubernetes 集群的平均 Pod 启动延迟从 12.4s 优化至 3.7s,关键路径耗时下降超 70%。这一结果源于三项落地动作:(1)采用 initContainer 预热镜像层并校验存储卷可写性;(2)将 ConfigMap 挂载方式由 subPath 改为 volumeMount 全量注入,规避了 kubelet 多次 inode 查询;(3)在 DaemonSet 中启用 hostNetwork: true 并绑定静态端口,消除 Service IP 转发开销。下表对比了优化前后生产环境核心服务的 SLO 达成率:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| HTTP 99% 延迟(ms) | 842 | 216 | ↓74.3% |
| 日均 Pod 驱逐数 | 17.3 | 0.8 | ↓95.4% |
| 配置热更新失败率 | 4.2% | 0.11% | ↓97.4% |
真实故障复盘案例
2024年3月某金融客户集群突发大规模 Pending Pod,经 kubectl describe node 发现节点 Allocatable 内存未耗尽但 kubelet 拒绝调度。深入日志发现 cAdvisor 的 containerd socket 连接超时达 8.2s——根源是容器运行时未配置 systemd cgroup 驱动,导致 kubelet 每次调用 GetContainerInfo 都触发 runc list 全量扫描。修复方案为在 /var/lib/kubelet/config.yaml 中显式声明:
cgroupDriver: systemd
runtimeRequestTimeout: 2m
重启 kubelet 后,节点状态同步延迟从 42s 降至 1.3s,Pending 状态持续时间归零。
技术债可视化追踪
我们构建了基于 Prometheus + Grafana 的技术债看板,通过以下指标量化演进健康度:
tech_debt_score{component="ingress"}:Nginx Ingress Controller 中硬编码域名数量deprecated_api_calls_total{version="v1beta1"}:集群中仍在调用已废弃 API 的 Pod 数unlabeled_resources_count{kind="Deployment"}:未打标签的 Deployment 实例数
该看板每日自动生成趋势图,并联动 GitLab MR 检查:当 tech_debt_score > 5 时,自动拒绝合并包含新硬编码域名的代码。
下一代架构实验进展
当前已在灰度集群验证 eBPF 加速方案:使用 Cilium 替换 kube-proxy 后,Service 流量转发路径缩短 3 跳,Istio Sidecar CPU 占用下降 38%。但遇到兼容性问题——某国产数据库客户端依赖 AF_PACKET 抓包,而 Cilium 的 bpf_host 程序拦截了原始 socket 调用。解决方案正在测试中:通过 cilium config set enable-host-reachable-services=false 关闭冲突特性,并用 HostPort 显式暴露数据库端口。
社区协同实践
我们向 Kubernetes SIG-Node 提交了 PR #128473,修复了 --max-pods 参数在 Windows 节点上被忽略的缺陷。该补丁已在 v1.29.0 中合入,并被腾讯云 TKE、阿里云 ACK 等 7 家厂商确认采纳。同时,团队将内部开发的 k8s-resource-validator 工具开源至 GitHub,支持对 YAML 文件进行 23 类生产就绪性检查,包括 securityContext.privileged 禁用规则、resources.limits 缺失告警等。
生产环境约束清单
所有上线变更必须满足以下硬性条件:
- 所有 StatefulSet 必须配置
podManagementPolicy: OrderedReady - DaemonSet 更新策略需设置
updateStrategy.type: RollingUpdate且maxUnavailable: 1 - 任何涉及
hostPath的 Volume 必须通过nodeSelector锁定特定节点池 - CRD 版本升级前需执行
kubectl get <crd> --all-namespaces -o yaml > backup.yaml
该约束清单已嵌入 CI 流水线的 pre-apply 阶段,未通过校验的 YAML 将被自动拦截。
未来半年重点方向
聚焦可观测性基建升级:计划将 OpenTelemetry Collector 替换为轻量级 otelcol-contrib,通过 filelogreceiver 直接解析容器 stdout 日志,绕过 Fluent Bit 的内存拷贝;同时在 Node 上部署 eBPF-based kprobe 探针,实时捕获 TCP 重传、SYN 丢包等网络异常事件,实现故障定位时效从分钟级压缩至秒级。
