Posted in

Go程序CPU飙升到900%?:perf record -g + go tool pprof + runtime/pprof CPU火焰图精准归因指南

第一章:Go程序CPU飙升问题的典型现象与底层归因挑战

当Go服务在生产环境中突然出现CPU使用率持续90%以上、tophtopgolang进程长期占据核心位置,且pprof火焰图显示大量时间耗在runtime.mcallruntime.goparkruntime.netpoll等运行时函数上时,往往并非应用逻辑显式循环导致,而是深层调度与资源交互失衡的外在表征。

常见表层现象

  • HTTP服务响应延迟激增,但QPS未明显上涨,netstat -s | grep -i "retrans" 显示重传异常升高;
  • go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 采集30秒CPU profile后,火焰图顶部频繁出现syscall.Syscall调用栈,指向阻塞式系统调用未被goroutine调度器有效解耦;
  • GOMAXPROCS设置远低于CPU核心数,而runtime.GOMAXPROCS(0)返回值恒定为1,暗示环境变量GOMAXPROCS被硬编码覆盖或启动时冻结。

底层归因复杂性根源

Go调度器(M:P:G模型)的非抢占式协作调度机制,使得一个长时间运行的CGO调用、死循环或同步I/O操作会独占P,导致其他goroutine饥饿;同时,net/http默认启用KeepAlive连接复用,若客户端异常断连而服务端未及时触发read timeoutconn.Read()将陷入不可中断的epoll_wait等待,绑定的M无法释放,形成“伪高CPU”——实为内核态等待而非用户态计算。

快速定位指令链

# 1. 获取高CPU进程PID(假设为12345)
pid=12345

# 2. 查看该进程线程级CPU占用(识别阻塞线程)
ps -T -p $pid -o pid,tid,%cpu,time,comm | sort -k3nr | head -10

# 3. 对高耗线程抓取堆栈(需进程已启用pprof)
curl "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.txt

# 4. 检查是否启用cgo及系统调用类型
lsof -p $pid | awk '$5 ~ /REG|DIR/ {next} $5 !~ /DEL/ {print $5,$9}' | sort | uniq -c | sort -nr | head -5
现象特征 可能根因 验证方式
runtime.futex高频出现 CGO阻塞或sync.Mutex争用 pprof -symbolize=none分析符号栈
internal/poll.runtime_pollWait集中 文件描述符未设timeout或net.Conn未配置Deadline 检查net.Dialer.KeepAliveSetReadDeadline调用
多个runtime.mcall并行 goroutine频繁切换+栈扩容开销 go tool compile -S main.go观察CALL runtime.morestack频次

第二章:perf record -g 原理剖析与Go运行时栈采集实战

2.1 perf事件采样机制与内核/用户态上下文切换开销分析

perf 通过硬件性能监控单元(PMU)或软件事件触发采样,采样点默认在中断上下文中执行,引发额外的内核态入口开销。

采样触发路径示意

// kernel/events/core.c 中 perf_event_overflow() 简化逻辑
void perf_event_overflow(struct perf_event *event, struct perf_sample_data *data,
                         struct pt_regs *regs) {
    // regs 指向被中断时的寄存器上下文(用户态或内核态)
    if (regs && user_mode(regs)) {
        // 用户态被采样:需保存完整用户寄存器、切换到内核栈、处理 perf buffer
        perf_event_output(event, data, regs); // 开销显著
    }
}

该函数在硬中断/软中断上下文中运行;user_mode(regs) 判定当前是否处于用户态,决定是否需做完整的上下文快照——此即上下文切换开销的核心来源之一。

上下文切换开销对比(典型 x86-64)

场景 平均开销(cycles) 主要耗时环节
用户态 → 内核采样中断 ~1200–1800 栈切换、寄存器保存、perf buffer 写入
内核态 → 内核采样中断 ~400–700 仅需局部寄存器压栈与 event 分发

关键影响因素

  • perf_event_attr.sample_period 越小,采样越密,中断频率越高;
  • mmap() 映射的 ring buffer 大小影响写入延迟;
  • CONFIG_PERF_EVENTS=y 编译选项启用完整支持。
graph TD
    A[PMU溢出/定时器到期] --> B[触发NMI或IRQ]
    B --> C{user_mode?}
    C -->|Yes| D[切换至内核栈<br>保存完整用户上下文]
    C -->|No| E[复用当前内核栈<br>轻量寄存器捕获]
    D --> F[perf_output_sample]
    E --> F

2.2 Go runtime对帧指针(frame pointer)的特殊处理及-g编译标志影响验证

Go runtime 默认禁用帧指针(frame pointer),以节省寄存器并提升性能,但调试与栈回溯依赖其存在。

帧指针启用机制

  • -gcflags="-l" 禁用内联,间接影响帧布局
  • -gcflags="-d=checkptr" 不影响帧指针,但可辅助内存安全验证
  • 关键开关:-gcflags="-g" —— 强制为所有函数插入 MOVQ BP, (SP) 类型的帧指针保存指令

编译标志对比效果

标志 帧指针存在 DWARF 调试信息 runtime.Stack() 可读性
默认 ✅(精简) 部分丢失帧边界
-g ✅✅(完整) 完整符号化调用链
// go tool compile -S -gcflags="-g" main.go 中典型片段
TEXT ·add(SB) /path/main.go
  MOVQ BP, SP      // ← 帧指针显式入栈(-g 启用)
  LEAQ -16(SP), BP // 建立新帧基址

此汇编表明:-g 触发 runtime 在函数入口插入 BP 保存逻辑,使 runtime.gentraceback 能精确遍历栈帧。未加 -g 时,该指令被完全省略,依赖 SP 偏移推算帧边界,易在内联/优化后失效。

graph TD
  A[Go源码] --> B{是否含-g?}
  B -->|是| C[插入BP保存指令<br>生成完整DWARF]
  B -->|否| D[跳过BP管理<br>依赖SP偏移推断]
  C --> E[pprof/race/gdb 全功能]
  D --> F[部分栈追踪失败]

2.3 在容器/Kubernetes环境中安全启用perf并规避权限限制的实操方案

安全启用 perf 的最小权限模型

默认情况下,容器中 perfCAP_SYS_ADMIN 缺失而失败。推荐采用细粒度能力授权而非特权模式:

# Dockerfile 片段:仅授予 perf 所需能力
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y linux-tools-common linux-tools-generic
# 不使用 --privileged,仅添加必要 capability

逻辑分析perf 核心依赖 CAP_SYS_ADMIN(用于 mmap ring buffer)、CAP_SYS_RESOURCE(调整 perf event limit)。但 Kubernetes 中应避免直接授予权限,转而通过 securityContext.capabilities.add 精确控制。

Kubernetes Pod 配置示例

securityContext:
  capabilities:
    add: ["SYS_ADMIN", "SYS_RESOURCE"]
  seccompProfile:
    type: RuntimeDefault

参数说明SYS_ADMIN 启用内核事件采集,SYS_RESOURCE 允许提升 perf_event_paranoid 限制;RuntimeDefault seccomp 策略在保障安全前提下兼容 perf 系统调用。

权限与风险对照表

能力 必需性 潜在风险
SYS_ADMIN ✅ 强依赖 可能被滥用于挂载/命名空间操作
SYS_RESOURCE ✅ 推荐 仅影响资源限制,风险较低
DAC_OVERRIDE ❌ 禁止 完全绕过文件权限检查

安全加固流程

  • 步骤1:将 perf_event_paranoid 设为 -1(需 host 或 initContainer 提前配置)
  • 步骤2:Pod 中通过 securityContext.capabilities.add 显式声明最小能力集
  • 步骤3:配合 seccompapparmor 策略限制非 perf 相关系统调用
graph TD
  A[容器启动] --> B{检查 perf_event_paranoid}
  B -->|≥2| C[失败:需 host 预配置]
  B -->|≤-1| D[加载 perf kernel module]
  D --> E[按 capability 白名单启用 perf]

2.4 识别Go goroutine调度器伪栈与真实CPU热点的交叉干扰模式

当pprof火焰图中出现高频 runtime.goparkruntime.mcall 栈帧,却伴随 crypto/aes.(*aesCipher).Encrypt 等纯计算函数的高采样率时,即暗示伪栈与真实热点耦合干扰。

典型干扰模式

  • goroutine 频繁阻塞唤醒(如 channel 操作)导致调度器插入大量 gopark
  • CPU 密集型任务恰在 Grunning → Gwaiting → Grunning 切换窗口执行,被误归因于调度开销

关键诊断代码

// 启用细粒度调度事件追踪(需 go build -gcflags="-m")
runtime.SetMutexProfileFraction(1)
runtime.SetBlockProfileRate(1) // 暴露阻塞点

此配置强制记录每次 goroutine 阻塞/唤醒事件及锁竞争;SetBlockProfileRate(1) 将 block profile 精度提升至每次阻塞,使 runtime.block 栈帧不再淹没真实计算路径。

干扰类型 表现特征 定位命令
伪栈主导 gopark 占比 >70%,无对应系统调用 go tool pprof -http=:8080 cpu.pprof
真实热点掩埋 runtime.mcall 下挂载 syscall.Syscall go tool pprof --callgrind cpu.proof
graph TD
    A[goroutine 执行 AES 加密] --> B{是否触发 GC 栈扫描?}
    B -->|是| C[插入 runtime.scanstack 帧]
    B -->|否| D[直接执行 asm 指令]
    C --> E[pprof 将 CPU 时间错误归属至 scanstack]

2.5 多线程Go程序中perf call graph丢失根节点的修复与–call-graph dwarf补全实践

Go运行时默认禁用帧指针(-fno-omit-frame-pointer),导致perf record -g依赖的FP(frame pointer)模式无法回溯至goroutine启动根节点(如runtime.goexitruntime.mstart)。

根因定位

  • Go 1.17+ 默认编译不保留帧指针;
  • perf script 解析调用栈时在runtime.mcall/runtime.gogo处截断;
  • DWARF调试信息完整,但perf未启用解析。

修复方案:强制启用DWARF调用图

# 编译时保留DWARF(默认已含),并显式启用调试符号
go build -gcflags="all=-N -l" -ldflags="-s -w" -o app .

# 采集时强制使用DWARF而非FP/DSO
perf record -g --call-graph dwarf,8192 ./app

--call-graph dwarf,8192:启用DWARF解析,8192为栈深度采样上限;相比fp模式,DWARF可穿透Go汇编胶水代码,恢复go func()runtime.goexitruntime.mcall全链路。

效果对比

模式 根节点可见性 goroutine入口识别 跨CGO边界支持
fp ❌(止于runtime.gogo
dwarf ✅(直达main.main
graph TD
    A[perf record -g] --> B{--call-graph}
    B -->|fp| C[栈帧寄存器回溯]
    B -->|dwarf| D[DWARF .debug_frame解析]
    D --> E[runtime.goexit]
    E --> F[goroutine用户函数]

第三章:go tool pprof深度解析与符号化调优

3.1 pprof元数据解析流程:从perf.data到proto.Profile的转换链路拆解

pprof 工具链将 Linux perf 采集的二进制 perf.data 转换为跨语言可消费的 proto.Profile,核心依赖 pprof/internal/driverpprof/profile 包协同解析。

关键转换阶段

  • 采样数据解包:调用 perf script -F comm,pid,tid,ip,sym,period 提取原始事件流
  • 符号化重写:通过 /proc/<pid>/mapsobjdump 补全函数名与地址偏移
  • Profile 构建:映射至 profile.Profile 结构,再序列化为 Protocol Buffer

核心代码片段(带注释)

p, err := profile.ParseFile("perf.data") // 自动识别 perf.data 格式并触发 perfreader
if err != nil {
    log.Fatal(err)
}
// 内部调用 perfreader.Read() → perfparser.Parse() → profile.New()

该调用链完成 ELF 符号解析、样本归一化(按 period 加权)、location/function 映射表构建,并最终填充 proto.Profilesample, mapping, function, location 字段。

数据结构映射关系

perf.data 元素 proto.Profile 字段 说明
PERF_RECORD_SAMPLE sample[] location_id, value[0](如 CPU cycles)
mmap2 event mapping[] 提供 memory_start, file(二进制路径)
dso + symtab function[], location[] 符号名、源码行号、地址范围
graph TD
    A[perf.data] --> B[perfreader.Read]
    B --> C[perfparser.Parse]
    C --> D[profile.New]
    D --> E[proto.Profile]

3.2 Go二进制中DWARF、Go symbol table与runtime.funcnametab的三重符号对齐验证

Go运行时依赖三套独立但需语义一致的符号系统:调试信息(DWARF)、链接器生成的Go符号表(.gosymtab)、以及启动时构建的runtime.funcnametab(紧凑函数名索引)。三者若偏移或名称不一致,将导致pprof采样失准、delve断点错位或runtime.FuncForPC返回空。

数据同步机制

构建阶段,cmd/link 通过 symtab.go 同时写入 .gosymtab.dwarf 段,并在 runtime/funcdata.go 中生成 funcnametab —— 三者共享同一 sym.Sym 源。

验证工具链

# 提取三元组并比对
go tool objdump -s "main\.add" ./main | head -3     # PC范围(funcnametab)
go tool nm ./main | grep "main\.add"                 # 符号地址(.gosymtab)
readelf -w ./main | grep -A2 "main\.add"            # DWARF function entry
来源 位置 是否含完整路径 运行时可访问
DWARF .debug_info ❌(仅调试器)
.gosymtab 自定义段 ❌(仅函数名)
runtime.funcnametab .text 附近 ✅(FuncForPC
// runtime/funcdata.go 中关键对齐逻辑
func addfunctab(name string, entry uintptr, pcsp, pcfile, pcinline *byte) {
    // name 必须与 .gosymtab 中的符号名、DWARF DW_TAG_subprogram 名完全一致
    // 否则 pprof 将无法映射采样 PC 到函数名
}

该逻辑确保 entry 地址、name 字符串、pcsp 偏移三者在三个载体中严格对齐。

3.3 针对CGO混合调用场景的symbol resolution失败诊断与手动映射修复

常见失败现象

当 Go 代码通过 //export 声明函数供 C 调用,但链接时出现 undefined reference to 'my_func',通常源于符号名修饰(name mangling)或导出缺失。

符号验证方法

使用 nm 检查生成的 .a.so 文件中是否真实存在目标符号:

nm -C libmylib.a | grep my_func  # -C 启用 C++ demangle(对 C 符号亦安全)

若无输出,说明函数未被正确导出——需确认 //export 前无空行、函数为 extern "C" 兼容签名且非内联。

手动符号映射修复

当动态链接器无法解析时,可显式绑定符号:

符号原始名 期望链接名 修复方式
MyFunc my_func #cgo LDFLAGS: -Wl,--def,symbols.def
// symbols.def(Windows)或使用 --version-script(Linux)
{
  global: my_func;
  local: *;
};

诊断流程图

graph TD
  A[Go 编译生成 .a/.so] --> B{nm -C 检查符号存在?}
  B -->|否| C[检查 //export 位置与函数可见性]
  B -->|是| D[ldd / objdump 检查依赖与重定位]
  C --> E[修正导出声明并重建]
  D --> F[添加 -Wl,--no-as-needed 或符号映射文件]

第四章:runtime/pprof与火焰图协同诊断的工程化闭环

4.1 启动时pprof CPU profile采集粒度控制:rate参数与SIGPROF信号频率的底层权衡

Go 运行时通过 runtime.SetCPUProfileRate 设置采样频率,本质是调整 SIGPROF 信号发送周期:

// 设置每毫秒触发一次 SIGPROF(即 1000 Hz)
runtime.SetCPUProfileRate(1e6) // 参数单位:纳秒;1e6 ns = 1 ms

逻辑分析:rate 值越小,信号越频繁,采样精度越高,但上下文切换开销越大;rate=0 表示禁用,rate=1 表示理论最高频(约 1 GHz),实际受内核调度限制。

关键权衡维度:

  • ✅ 高精度场景(如性能瓶颈定位):rate ≤ 100000(10 kHz)
  • ⚠️ 生产默认(平衡开销与可用性):rate = 1000000(1 kHz)
  • ❌ 过载风险:rate < 10000(>100 kHz)易引发调度抖动
rate 值(ns) 理论频率 典型用途 调度开销增幅
10000 100 kHz 微秒级热点分析 ++++
1000000 1 kHz 常规 CPU profiling +
0 关闭采样 0
graph TD
    A[SetCPUProfileRate(rate)] --> B{rate == 0?}
    B -->|Yes| C[禁用 SIGPROF]
    B -->|No| D[计算 timer interval = rate ns]
    D --> E[注册 SIGPROF handler]
    E --> F[内核定时器触发信号]

4.2 火焰图中goroutine状态(running/waiting/syscall)的可视化语义标注与归因优先级判定

火焰图中不同颜色区块需承载明确的运行时语义:running(深红)表示CPU密集执行,waiting(浅蓝)对应channel阻塞或锁竞争,syscall(橙黄)标识系统调用等待(如read/write/accept)。

goroutine状态映射规则

  • Grunnablewaiting(就绪但未调度)
  • Gwaitingwaiting(如semacquire
  • Gsyscallsyscall(内核态停留)
  • Grunningrunning(当前M绑定P执行)
// runtime/trace/trace.go 中状态采样片段
func traceGoStart() {
    gp := getg()
    switch gp.atomicstatus {
    case _Grunning: traceEvent(traceEvGoRunning, 0, 0)
    case _Gwaiting: traceEvent(traceEvGoWaiting, 0, 0)
    case _Gsyscall: traceEvent(traceEvGoSyscall, 0, 0)
    }
}

该代码通过原子读取goroutine状态位,触发对应trace事件;traceEvGoRunning等常量决定火焰图着色策略,确保采样零开销且状态精确对齐调度器视角。

状态 典型诱因 归因优先级 可观测性来源
running CPU-bound循环、GC标记 pprof -cpu + trace
syscall 文件I/O、网络accept 中高 strace + runtime trace
waiting mutex争用、chan recv pprof -mutex, go tool trace
graph TD
    A[goroutine状态采样] --> B{atomicstatus值}
    B -->|_Grunning| C[标记为running]
    B -->|_Gsyscall| D[标记为syscall]
    B -->|_Gwaiting| E[结合waitreason细化waiting子类]
    C --> F[红色区块:立即归因至热点函数]
    D --> G[橙色区块:关联fd与syscall类型]
    E --> H[蓝色区块:触发锁/chan分析链]

4.3 基于pprof profile的stack collapse算法优化:消除runtime.mcall等调度噪声的过滤策略

Go 程序 profiling 中,runtime.mcallruntime.goparkruntime.schedule 等调度器调用频繁出现在火焰图顶层,掩盖真实业务热点。

过滤核心策略

  • 识别并标记 Go runtime 调度相关符号(正则匹配 ^runtime\.(mcall|gopark|schedule|goexit|park_m)$
  • 在 stack collapse 阶段跳过这些帧,将调用链“向上折叠”至最近的用户函数
  • 支持动态白名单扩展(如保留 runtime.nanotime 用于性能基线分析)

关键代码片段

func shouldSkipFrame(fn string) bool {
    return runtimeSymRe.MatchString(fn) && // 匹配调度符号
        !whitelistRe.MatchString(fn)        // 排除白名单
}

runtimeSymRe 为预编译正则 ^runtime\.(mcall|gopark|schedule|goexit|park_m|exitsyscall|entersyscall)$whitelistRe 允许保留特定 runtime 函数用于诊断。

过滤效果对比(10k samples)

指标 过滤前 过滤后
有效业务栈占比 38% 82%
平均栈深度 12.7 9.1
graph TD
    A[原始pprof stack] --> B{帧名匹配 runtime.*?}
    B -->|是且非白名单| C[跳过该帧]
    B -->|否| D[保留并折叠]
    C --> E[连接上一用户帧]
    D --> E
    E --> F[生成clean profile]

4.4 从火焰图反向定位GC触发点、锁竞争热点与netpoller阻塞路径的三维归因法

火焰图不是终点,而是归因起点。需沿调用栈逆向追溯三类关键事件源头:

  • GC触发点:识别 runtime.gcStart, runtime.markroot 等符号在火焰图顶部的异常高频出现,结合 GODEBUG=gctrace=1 日志交叉验证;
  • 锁竞争热点:聚焦 sync.(*Mutex).Lockruntime.semacquire1 的深色宽幅火焰,对应 P 持有时间过长;
  • netpoller阻塞:追踪 runtime.netpollepoll_wait 调用链底部是否持续挂起,反映 I/O 多路复用层瓶颈。
# 采集含调度与 GC 标签的火焰图
perf record -e cpu-clock,u,s -g -p $(pidof myserver) --call-graph dwarf,16384
go tool pprof -http=:8080 ./myserver.perf

上述命令启用 DWARF 栈展开(精度优于 frame pointer),-g 捕获内核/用户态混合调用链,确保 runtime.netpollepoll_wait 可关联。

维度 关键符号 定位线索
GC 触发 runtime.gcStart, gcAssistAlloc 高频出现在火焰图顶层且周期性脉冲
锁竞争 sync.(*Mutex).Lock, runtime.semacquire1 宽幅火焰 + 长尾调用深度
netpoller 阻塞 runtime.netpoll, epoll_wait 底部无子节点、持续占据 CPU 时间片
graph TD
    A[火焰图根节点] --> B{调用栈分析}
    B --> C[GC 相关符号?]
    B --> D[锁同步符号?]
    B --> E[netpoll/epoll 符号?]
    C --> F[关联 gctrace 日志]
    D --> G[检查 Mutex Contention Profile]
    E --> H[验证 epoll_wait 超时与 fd 数量]

第五章:Go CPU性能问题的系统性防御体系构建

全链路可观测性基座建设

在字节跳动某核心推荐服务中,团队将 pprof 采集与 OpenTelemetry SDK 深度集成,实现每30秒自动抓取 CPU profile,并通过 Jaeger 关联请求 trace ID。当某次上线后 P99 延迟突增 120ms,通过火焰图快速定位到 runtime.mapassign_fast64 占用 CPU 时间达 37%,进一步排查发现是高频写入未预分配容量的 map[int64]*User 导致持续扩容重哈希。该案例验证了实时 profile + 分布式追踪的组合对 CPU 瓶颈的秒级归因能力。

自适应熔断与降级策略

采用基于 CPU 使用率的动态熔断器(非传统 QPS 维度),当宿主机 node_cpu_seconds_total{mode="user"} 5分钟均值突破 85% 时,自动触发以下动作:

  • /v1/predict 接口启用轻量级响应缓存(TTL=200ms)
  • feature_enrichment 模块切换至预计算特征快照模式
  • 降低 goroutine 池上限至原值的 60%
    该机制在 2023 年双十一流量洪峰期间成功避免三次雪崩,CPU 利用率稳定在 72%±5% 区间。

编译期与运行期协同优化

优化维度 实施手段 效果(某支付网关服务)
编译期 go build -gcflags="-l -m -m" + 自定义脚本过滤逃逸分析警告 发现 17 处 []byte 频繁堆分配,改用 sync.Pool 后 GC Pause 减少 41%
运行期 GODEBUG=gctrace=1 + Prometheus 抓取 go_gc_duration_seconds 定位到 time.Ticker 泄漏导致 goroutine 持续增长,修复后 CPU idle 提升 19%

生产环境 CPU 热点防护清单

  • 禁止在 HTTP handler 中直接调用 time.Now().Format()(字符串拼接触发大量小对象分配)
  • for range 遍历 map 时预先 make([]kv, 0, len(m)) 避免 slice 扩容
  • 使用 unsafe.Slice 替代 reflect.SliceHeader 构造切片(实测减少 23% CPU 指令周期)
  • json.Marshal 高频路径启用 github.com/json-iterator/go 并配置 ConfigCompatibleWithStandardLibrary().Froze()
flowchart LR
    A[HTTP 请求] --> B{CPU 使用率 > 80%?}
    B -->|是| C[启用 CPU-aware 限流]
    B -->|否| D[正常处理]
    C --> E[降级 feature_enrichment]
    C --> F[启用响应缓存]
    C --> G[收缩 goroutine 池]
    E --> H[返回预计算特征]
    F --> I[Cache Hit 返回]
    G --> J[阻塞新 goroutine 创建]

持续验证机制

在 CI/CD 流水线中嵌入 benchstat 对比:每次 PR 提交需通过 go test -bench=. -cpuprofile=cpu.prof ./...,若 BenchmarkProcessRequest-16ns/op 相比主干分支恶化超过 5%,则阻断合并。2024 年 Q1 共拦截 12 次潜在 CPU 回归,其中 3 次源于 strings.ReplaceAll 替换为 strings.Replacer 后未复用实例。

扎根云原生,用代码构建可伸缩的云上系统。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注