第一章:Goroutine泄漏排查实战:从pprof到trace,3步定位90%的线上面试压轴题
Goroutine泄漏是Go服务线上最隐蔽却高频的稳定性隐患——它不报panic、不触发OOM,却在数小时后悄然耗尽调度器资源,导致新请求排队、延迟飙升。真正的挑战在于:泄漏的Goroutine往往处于select{}阻塞、time.Sleep挂起或chan recv等待状态,常规日志完全静默。
启动pprof并捕获goroutine快照
确保服务已启用pprof(通常通过import _ "net/http/pprof" + http.ListenAndServe(":6060", nil))后,执行:
# 获取当前活跃goroutine堆栈(-debug=2输出完整调用链)
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.log
# 对比两次快照,识别持续增长的协程模式
diff goroutines-before.log goroutines-after.log | grep "goroutine [0-9]* \[.*\]"
重点关注状态为[chan receive]、[select]或[sleep]且调用栈中反复出现相同业务函数(如(*UserService).SyncProfile)的条目。
使用runtime/trace深入时序分析
启动服务时启用trace采集:
import "runtime/trace"
// 在main()开头启动
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
defer f.Close()
运行一段时间后生成trace文件,用go tool trace trace.out打开交互界面,点击Goroutines → View traces of all goroutines,筛选出生命周期超5分钟且未结束的goroutine,观察其阻塞点是否位于未关闭的channel读取或未设置超时的http.Client.Do调用。
关键泄漏模式速查表
| 泄漏诱因 | 典型表现 | 修复方案 |
|---|---|---|
| 未关闭的HTTP响应体 | http.(*body).Read + io.copy |
defer resp.Body.Close() |
| 无缓冲channel发送阻塞 | goroutine ... [chan send] |
改用带缓冲channel或select default |
| Context未传递或未取消 | select { case <-ctx.Done(): }缺失 |
确保所有goroutine监听同一ctx |
记住:90%的泄漏源于“忘记取消”和“忘记关闭”,而非并发逻辑错误。
第二章:Goroutine泄漏的本质与典型场景剖析
2.1 Goroutine生命周期管理与泄漏定义(理论)+ runtime.Stack()动态检测泄漏goroutine(实践)
Goroutine 是 Go 并发的基石,其生命周期始于 go 关键字启动,终于函数返回或 panic 退出。泄漏指 goroutine 启动后因阻塞(如死锁 channel、空 select、未关闭的 WaitGroup)而永久驻留,持续占用栈内存与调度资源。
Goroutine 泄漏的典型成因
- 无缓冲 channel 写入未被读取
time.After()在循环中误用导致定时器堆积http.Server未调用Shutdown(),遗留连接协程
动态检测:runtime.Stack()
func dumpGoroutines() {
buf := make([]byte, 1024*1024) // 1MB 缓冲区,防截断
n := runtime.Stack(buf, true) // true: 打印所有 goroutine 栈帧
fmt.Printf("Active goroutines: %d\n%s", n, buf[:n])
}
runtime.Stack(buf, true)将当前所有 goroutine 的调用栈写入buf;n返回实际写入字节数。需确保buf足够大,否则栈信息被截断——这是检测泄漏的关键前提。
| 检测场景 | 推荐阈值 | 风险提示 |
|---|---|---|
| 常规 Web 服务 | > 500 | 可能存在未回收连接协程 |
| 短生命周期任务 | > 10 | 极可能已泄漏 |
graph TD
A[触发检测] --> B{runtime.NumGoroutine()}
B -->|突增且持续| C[调用 runtime.Stack]
C --> D[解析栈帧关键词]
D --> E["grep 'chan receive' \| 'select' \| 'http.serve"]
2.2 常见泄漏模式识别:channel阻塞、WaitGroup误用、defer延迟执行陷阱(理论)+ 模拟泄漏代码复现与断点验证(实践)
channel 阻塞:无缓冲通道的单向写入
当向无缓冲 channel 发送数据,但无 goroutine 同步接收时,发送方永久阻塞:
func leakByChannel() {
ch := make(chan int) // 无缓冲
ch <- 42 // 永久阻塞:无接收者
}
ch <- 42 触发 goroutine 挂起,运行时无法调度退出,导致 Goroutine 泄漏。调试时在该行设断点可观察 goroutine 状态为 chan send。
WaitGroup 误用:Add/Wait 不配对
常见错误:Add() 在 goroutine 内调用,或 Wait() 被跳过:
| 错误类型 | 后果 |
|---|---|
| Add() 晚于 Go 启动 | Wait() 永不返回 |
| 忘记 Add() | Wait() 立即返回,逻辑错乱 |
defer 延迟陷阱:闭包捕获循环变量
for i := 0; i < 3; i++ {
defer fmt.Println(i) // 输出:3, 3, 3
}
i 是循环变量地址,defer 闭包延迟求值时 i==3 已为终值——若用于资源释放(如 defer file.Close()),可能关闭错误句柄。
2.3 Context取消机制失效导致的goroutine悬挂(理论)+ ctx.WithTimeout未被传播/未监听Done通道的调试实操(实践)
核心问题本质
Context取消信号需显式监听 ctx.Done() 并主动退出;若 goroutine 忽略该通道或父 ctx 未正确传递,将永久阻塞。
常见失效模式
- 父 context 超时创建后未传入子函数
- 子 goroutine 中未
select { case <-ctx.Done(): return } - 使用
context.Background()替代传入的ctx
典型错误代码
func badHandler(ctx context.Context) {
go func() {
time.Sleep(5 * time.Second) // ❌ 未监听 ctx.Done()
fmt.Println("done")
}()
}
逻辑分析:匿名 goroutine 完全脱离
ctx生命周期控制;即使ctx已超时,该 goroutine 仍运行 5 秒后才退出,造成悬挂。参数ctx形参未被实际消费。
调试验证步骤
| 步骤 | 操作 |
|---|---|
| 1 | 在 goroutine 入口添加 log.Printf("ctx.Err()=%v", ctx.Err()) |
| 2 | 使用 pprof 查看 goroutine stack trace |
| 3 | 检查所有 go fn(...) 调用是否传入 ctx |
正确传播范式
func goodHandler(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
go func(ctx context.Context) {
select {
case <-time.After(5 * time.Second):
fmt.Println("work done")
case <-ctx.Done(): // ✅ 主动响应取消
fmt.Println("canceled:", ctx.Err())
}
}(ctx)
}
逻辑分析:
ctx显式传入 goroutine,并通过select双路监听;WithTimeout基于原始ctx衍生,确保取消链完整。cancel()延迟调用避免资源泄漏。
2.4 Timer/Ticker未Stop引发的隐式泄漏(理论)+ pprof goroutine profile中定时器goroutine特征提取(实践)
定时器泄漏的本质
time.Timer 和 time.Ticker 若创建后未调用 Stop(),其底层 goroutine 将持续驻留于 runtime 的 timer heap 中,即使原引用已脱离作用域——这是典型的 GC 不可达但运行时不可回收 的隐式泄漏。
典型泄漏代码模式
func startLeakyTicker() {
ticker := time.NewTicker(1 * time.Second)
go func() {
for range ticker.C { // 永不退出
doWork()
}
}() // ❌ 忘记 ticker.Stop()
}
逻辑分析:
ticker.C是无缓冲 channel,Stop()未被调用 → runtime 会维持一个专用 goroutine 驱动该 ticker,且该 goroutine 在pprof中恒为runtime.timerproc,状态为syscall或running。
pprof 中识别特征
| 字段 | 值 | 说明 |
|---|---|---|
runtime.timerproc |
占比高、stack depth ≥3 | 标志性 goroutine |
time.startTimer / time.stopTimer |
出现在 stack trace 中 | 确认 timer 生命周期异常 |
自动化识别流程
graph TD
A[pprof goroutine profile] --> B{是否含 runtime.timerproc}
B -->|是| C[过滤 stack 含 time.*Ticker/Timer]
C --> D[统计重复 goroutine 数量 & 持续时间]
D --> E[标记潜在未 Stop 实例]
2.5 并发Map写竞争与sync.Pool误用导致的goroutine滞留(理论)+ go tool trace中标记goroutine状态变迁路径(实践)
数据同步机制
map 非并发安全,多 goroutine 同时写入会触发 panic:fatal error: concurrent map writes。sync.Map 仅缓解读多写少场景,但高频写仍引发 mu 锁争用,导致 goroutine 阻塞在 runtime.semacquire。
sync.Pool 误用陷阱
var bufPool = sync.Pool{
New: func() interface{} {
return bytes.Buffer{} // ❌ 返回栈对象,逃逸失败,每次 New 都分配新实例
},
}
逻辑分析:bytes.Buffer{} 是值类型,无指针字段,但 New 函数返回后无法复用底层字节数组;若 Get() 后未 Put() 回池,对象永久驻留堆,且关联 goroutine 可能因等待池资源而滞留。
goroutine 状态追踪
使用 go tool trace 时,在浏览器中启用 Goroutines → View traces,可观察状态变迁: |
状态 | 触发条件 |
|---|---|---|
running |
被 M 抢占执行 | |
runnable |
就绪但无空闲 P | |
waiting |
阻塞于 channel、mutex 或 pool 获取 |
关键诊断流程
graph TD
A[goroutine 创建] --> B{是否调用 Put?}
B -- 否 --> C[对象泄漏 + GC 压力]
B -- 是 --> D[是否复用正确?]
D -- 否 --> E[stale buffer 持有旧引用]
E --> F[goroutine 在 runtime.park 中滞留]
第三章:pprof深度分析——从火焰图到goroutine快照
3.1 /debug/pprof/goroutine?debug=2源码级解析与goroutine栈帧语义解读(理论)+ 线上环境安全导出与栈聚合分析(实践)
/debug/pprof/goroutine?debug=2 是 Go 运行时暴露的深度调试端点,其核心逻辑位于 src/runtime/pprof/pprof.go 中的 writeGoroutine 函数。
栈帧语义关键字段
goroutine N [status]:N 为 goroutine ID,status 如runnable、waiting、syscallcreated by ... at ...:标识启动该 goroutine 的调用点(含文件/行号)- 每帧包含
PC,SP,FP及符号化函数名,debug=2启用完整符号展开
安全导出实践要点
- 生产环境必须启用
GODEBUG=gctrace=0避免干扰 GC - 使用带超时的
curl -m 5 'http://localhost:6060/debug/pprof/goroutine?debug=2' > goroutines.txt - 禁止在高 QPS 路由共用同一 pprof mux,应隔离到独立 admin 端口
// runtime/pprof/pprof.go 片段(简化)
func writeGoroutine(w http.ResponseWriter, r *http.Request) {
debug := parseDebug(r) // debug=2 → full stack + creation traces
goroutines := runtime.GoroutineProfile() // 获取所有 goroutine 的 runtime.StackRecord
for _, gr := range goroutines {
fmt.Fprintf(w, "goroutine %d [%s]:\n", gr.ID, gr.Status)
for _, frame := range gr.Stack() { // debug=2 时含 runtime.CallerFrames
fmt.Fprintf(w, "\t%s\n", frame.Function)
}
}
}
该函数通过 runtime.GoroutineProfile() 获取运行时快照,debug=2 触发 runtime.CallerFrames 符号解析,代价较高但语义完备。线上使用需严格限频与权限控制。
| debug 值 | 输出粒度 | 是否含创建栈 | 性能开销 |
|---|---|---|---|
| 0 | 汇总统计(数量) | ❌ | 极低 |
| 1 | 当前栈(无创建) | ❌ | 中 |
| 2 | 全栈+创建踪迹 | ✅ | 高 |
3.2 使用pprof CLI工具定位高存活goroutine家族(理论)+ grep + awk + go tool pprof流水线自动化诊断(实践)
goroutine家族的识别逻辑
高存活 goroutine 往往呈现模式化堆栈前缀(如 http.(*Server).Serve、database/sql.(*DB).connPool),而非单个 goroutine。pprof 的 --symbolize=none 可避免符号解析开销,加速原始堆栈提取。
自动化诊断流水线
# 从运行中服务抓取 goroutine profile,过滤活跃 >10s 的堆栈,统计家族频次
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" | \
grep -A 10 "created by" | \
awk '/^goroutine [0-9]+.*$/ { g = $2; next } /^created by/ { print $3 " " g }' | \
sort | uniq -c | sort -nr | head -10
逻辑说明:
grep -A 10提取每个 goroutine 后续 10 行以捕获created by行;awk提取 goroutine ID 和启动函数名;sort | uniq -c实现按“启动点”聚类计数,暴露高频 goroutine 家族。
典型家族特征对照表
| 启动函数 | 风险暗示 | 常见根因 |
|---|---|---|
time.AfterFunc |
泄漏的定时器 | 未调用 Stop() |
runtime.goexit |
协程卡死(非正常退出) | channel 阻塞或死锁 |
net/http.(*Server).Serve |
HTTP 连接未关闭 | 客户端 Keep-Alive 异常 |
流程图:诊断决策流
graph TD
A[获取 goroutine profile] --> B{是否含 created by?}
B -->|是| C[提取启动函数+goroutine ID]
B -->|否| D[视为 runtime 系统协程,跳过]
C --> E[按启动函数聚合计数]
E --> F[筛选 top-K 高频家族]
F --> G[结合源码定位创建点]
3.3 goroutine profile内存视图与阻塞链路可视化(理论)+ Graphviz生成goroutine依赖关系图(实践)
Go 运行时通过 runtime/pprof 暴露的 goroutine profile 包含所有 goroutine 的栈快照,分为 debug=1(摘要)和 debug=2(完整调用链)两种模式,是分析阻塞、泄漏与协作依赖的核心数据源。
内存视图本质
每个 goroutine 栈帧携带:
- 当前 PC 地址与函数符号
- 阻塞点(如
chan receive、Mutex.Lock、netpoll) - 所属 P/M/G 状态及等待队列指针
可视化关键步骤
- 采集
curl http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt - 解析栈帧,提取
goroutine N [state]+ 调用链 - 构建
goroutine → blocked_on → goroutine有向边
Graphviz 生成示例
digraph G {
rankdir=LR;
g1 [label="g1: net/http.(*conn).serve"];
g2 [label="g2: runtime.gopark"];
g1 -> g2 [label="blocked on chan recv"];
}
此 DOT 片段描述一个 HTTP 处理 goroutine 因通道接收而阻塞于另一个 goroutine;
rankdir=LR强制左→右布局,提升阻塞流向可读性。
| 字段 | 含义 | 示例 |
|---|---|---|
goroutine 19 [chan receive] |
ID + 阻塞状态 | 表明该 goroutine 在等待 channel 接收 |
selectgo |
运行时调度点 | 是 select 语句的底层入口,常为阻塞枢纽 |
第四章:go tool trace进阶追踪——时序行为建模与泄漏根因定位
4.1 trace事件模型详解:GoSysBlock、GoBlockRecv、GoSched等关键事件语义(理论)+ trace viewer中过滤泄漏goroutine生命周期(实践)
Go runtime trace 通过结构化事件刻画调度行为。核心事件语义如下:
GoSysBlock:goroutine 主动进入系统调用并阻塞,等待内核返回GoBlockRecv:因 channel 接收无数据且无其他 goroutine 发送而挂起GoSched:主动让出 CPU(如runtime.Gosched()或时间片耗尽)
trace viewer 实践技巧
在 go tool trace 的 Web UI 中,使用过滤器表达式可定位异常生命周期:
goid == 12345 && (event == "GoCreate" || event == "GoEnd" || event == "GoBlockRecv")
关键事件对比表
| 事件名 | 触发条件 | 是否可恢复 | 关联状态转移 |
|---|---|---|---|
GoSysBlock |
系统调用陷入内核 | 是 | Running → Syscall |
GoBlockRecv |
chan recv 无 sender 且缓冲空 |
是 | Running → Waiting |
GoSched |
显式让出或抢占 | 是 | Running → Runnable |
goroutine 泄漏检测流程
graph TD
A[启动 trace] --> B[运行可疑代码]
B --> C[导出 trace 文件]
C --> D[go tool trace trace.out]
D --> E[Filter: GoCreate - GoEnd 无配对]
E --> F[定位长期处于 'Waiting' 或 'Runnable' 的 goid]
4.2 Goroutine创建-阻塞-唤醒-退出全链路时序标注(理论)+ 自定义trace.Event标记业务关键节点辅助归因(实践)
Goroutine生命周期包含四个原子态跃迁:created → runnable → running → blocked/finished。运行时通过 g0 栈调度、_Grunnable 状态位与 waitreason 字段实现精确时序锚定。
数据同步机制
使用 runtime.traceEvent 注入业务语义节点:
import "runtime/trace"
func processOrder(ctx context.Context) {
trace.WithRegion(ctx, "order-processing", func() {
trace.Log(ctx, "stage", "validation")
validate()
trace.Log(ctx, "stage", "persistence")
saveToDB()
})
}
逻辑分析:
trace.WithRegion创建嵌套事件范围,trace.Log写入带键值对的用户事件;参数ctx必须由trace.NewContext初始化,否则静默丢弃。事件在go tool trace中以“User Events”轨道呈现,毫秒级对齐 GC 和 Goroutine 调度事件。
关键状态映射表
| 状态 | 触发条件 | trace.Event 类型 |
|---|---|---|
GoroutineCreate |
go f() 执行 |
trace.EvGoCreate |
GoroutineBlock |
ch <- v 阻塞、time.Sleep |
trace.EvGoBlockSend |
GoroutineWake |
channel 接收方就绪 | trace.EvGoUnblock |
graph TD
A[go fn()] --> B[G created<br/>EvGoCreate]
B --> C[G runnable<br/>EvGoStart]
C --> D[G running]
D --> E{I/O or sync?}
E -->|yes| F[G blocked<br/>EvGoBlock]
E -->|no| G[exit<br/>EvGoEnd]
F --> H[G woken<br/>EvGoUnblock]
H --> C
4.3 GC触发与goroutine泄漏的耦合现象分析(理论)+ trace中GC STW阶段goroutine堆积模式识别(实践)
GC STW期间的goroutine调度冻结效应
当GC进入STW(Stop-The-World)阶段,所有P(Processor)被暂停,运行中的goroutine无法被调度,但新创建的goroutine仍可入队(如go f()在STW前一刻执行),导致G队列在P恢复前持续堆积。
trace中典型堆积模式识别
使用go tool trace观察/synchronization/goroutines视图,若在GC标记起始(GCStart事件)后出现以下特征,则高度可疑:
- goroutine数量阶梯式跃升(非平滑增长)
- 大量goroutine状态长期停留在
runnable(而非running或waiting) - 对应时间轴上
GCSweep或GCMark事件与goroutine峰值严格对齐
关键诊断代码片段
// 模拟GC触发瞬间的goroutine爆发(注意:仅用于分析,勿在生产使用)
func leakOnGC() {
for i := 0; i < 1000; i++ {
go func() {
time.Sleep(10 * time.Second) // 阻塞型泄漏
}()
}
runtime.GC() // 强制触发GC,放大STW耦合效应
}
逻辑说明:
runtime.GC()触发STW时,1000个goroutine已启动但尚未被调度执行,全部滞留在全局或P本地runqueue中;因STW冻结调度器,这些goroutine无法转入running状态,形成trace中可见的“瞬时goroutine洪峰”。
耦合机制本质
| 因素 | 作用方向 | 后果 |
|---|---|---|
| GC STW时长 | 时间窗口约束 | goroutine积压不可调度 |
| 泄漏goroutine阻塞态 | 状态不可释放 | G对象无法被GC回收 |
| 未关闭的channel接收 | 持续创建新goroutine | 堆积速率远超STW恢复速度 |
graph TD
A[GC Start] --> B[STW激活]
B --> C[所有P暂停调度]
C --> D[新goroutine入runqueue]
D --> E[goroutine状态卡在runnable]
E --> F[STW结束→瞬时调度风暴→系统抖动]
4.4 结合runtime/trace与pprof goroutine profile做交叉验证(理论)+ 三步法:采样→对齐→归因(实践)
数据同步机制
runtime/trace 记录事件级时序(如 goroutine 创建、阻塞、唤醒),而 pprof 的 goroutine profile 是快照式堆栈采样(默认每 10ms 一次)。二者时间精度与语义粒度不同,需通过统一纳秒时间戳对齐。
三步法实践核心
-
采样:启用双通道采集
# 启动 trace + goroutine profile 并发采集 go run -gcflags="-l" main.go & curl "http://localhost:6060/debug/trace?seconds=30" -o trace.out & curl "http://localhost:6060/debug/pprof/goroutine?debug=2" -o goroutines.txt &debug=2输出完整栈(含运行中/阻塞中 goroutine),seconds=30确保 trace 覆盖足够长窗口,避免时序剪裁。 -
对齐:使用
go tool trace提取事件时间戳,与 pprof 中created by行的@时间戳比对(单位均为纳秒)。 -
归因:构建如下映射表定位根因:
| Goroutine ID | 状态(pprof) | trace 中最后事件 | 持续阻塞时长 | 归因结论 |
|---|---|---|---|---|
| 12894 | syscall | SyscallEnter | 2.3s | read() 卡在磁盘IO |
graph TD
A[启动双采集] --> B[按时间戳对齐事件流]
B --> C{状态匹配?}
C -->|是| D[标记 goroutine 生命周期阶段]
C -->|否| E[检查时钟漂移/采样丢失]
D --> F[聚合阻塞链路:net.Read → syscall.Syscall → futex]
第五章:总结与展望
关键技术落地成效回顾
在某省级政务云平台迁移项目中,基于本系列所实践的容器化微服务架构与 GitOps 持续交付流水线,核心审批系统完成重构后实现:平均部署耗时从 47 分钟压缩至 92 秒;生产环境故障平均恢复时间(MTTR)由 38 分钟降至 4.3 分钟;API 请求 P95 延迟稳定控制在 187ms 以内。下表为关键指标对比:
| 指标项 | 迁移前 | 迁移后 | 提升幅度 |
|---|---|---|---|
| 日均自动发布次数 | 0.8 | 14.6 | +1725% |
| 配置变更错误率 | 12.7% | 0.34% | -97.3% |
| 资源利用率(CPU) | 31%(峰值) | 68%(稳态) | +119% |
真实故障复盘中的架构韧性验证
2024年3月,该平台遭遇区域性网络抖动(持续 11 分钟),因采用 Istio 多集群服务网格+本地优先路由策略,用户请求自动降级至最近可用 AZ 的副本,未触发全局熔断。日志分析显示,/v2/approval/submit 接口在抖动期间 99.2% 的请求仍完成端到端处理,仅 0.8% 回退至缓存兜底页——该路径在设计阶段即通过 Chaos Mesh 注入 network-partition 场景完成 7 轮压测验证。
# 生产环境 ServiceEntry 配置节选(已脱敏)
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
name: legacy-approval-db
spec:
hosts:
- "legacy-approval-db.internal.gov"
location: MESH_INTERNAL
resolution: DNS
endpoints:
- address: 10.244.3.121
locality: cn-shenzhen-az1
labels:
region: cn-shenzhen
- address: 10.244.5.89
locality: cn-shenzhen-az2
labels:
region: cn-shenzhen
下一代可观测性演进路径
当前平台已接入 OpenTelemetry Collector 统一采集指标、链路与日志,但存在两个待解瓶颈:一是跨部门业务链路追踪缺失(如税务系统调用审批服务时 trace context 丢失);二是 Prometheus 中自定义指标标签基数超 280 万,导致查询响应延迟突增。下一步将实施两项改造:① 在网关层强制注入 W3C TraceContext,并对存量 Java 应用注入 opentelemetry-javaagent;② 采用 VictoriaMetrics 替换 Prometheus 并启用 --storage.tsdb.max-series-per-metric=500k 限流策略。
边缘智能协同场景探索
深圳某智慧园区试点项目已部署轻量级 K3s 集群(节点数 12),运行基于 ONNX Runtime 的实时视频分析模型。当检测到消防通道占用事件时,边缘节点直接触发 MQTT 消息至中心平台,端到端延迟
安全合规加固方向
在等保 2.0 三级测评中,发现容器镜像签名验证覆盖率仅 61%。现已在 CI 流水线中嵌入 Cosign 签名步骤,并通过 OPA Gatekeeper 策略强制拦截未签名镜像拉取请求。下一步将对接国家信创适配认证平台,完成 ARM64 架构下国产操作系统(统信 UOS、麒麟 V10)的全栈兼容性验证,首批 23 个 Helm Chart 包已完成基础功能测试。
