Posted in

Go期末最后一搏:用go tool trace反向推导3道调度器相关大题的标准答案

第一章:Go期末最后一搏:用go tool trace反向推导3道调度器相关大题的标准答案

go tool trace 是 Go 运行时调度行为的“X光机”——它不依赖源码注释或文档推测,而是直接捕获 Goroutine 创建、阻塞、唤醒、P/M/G 状态切换等真实事件。期末考前最后冲刺,可利用 trace 文件反向还原调度器核心机制,精准命中高频考点。

准备可追踪的调度场景

先编写一个典型多 Goroutine 争抢 I/O 和 CPU 的测试程序:

package main

import (
    "fmt"
    "runtime/trace"
    "time"
)

func main() {
    f, _ := trace.Start("trace.out") // 启动追踪
    defer f.Close()

    for i := 0; i < 5; i++ {
        go func(id int) {
            time.Sleep(time.Millisecond * 10) // 模拟阻塞型 I/O
            fmt.Printf("G%d done\n", id)
        }(i)
    }

    // 主 Goroutine 占用 CPU 防止提前退出
    time.Sleep(time.Millisecond * 50)
}

执行 go run main.go && go tool trace trace.out,浏览器中打开交互式界面,重点查看 “Goroutines”“Scheduler” 视图。

识别三类经典调度现象

  • Goroutine 唤醒延迟:在 “Goroutines” 视图中筛选 G1,观察其从 runnablerunning 的时间差,若 >100μs,说明存在 P 竞争或全局队列窃取延迟;
  • M 频繁创建/销毁:切换至 “Threads” 视图,若 M 数量在短时间内剧烈波动(如从 1→4→1),对应题目中“为何系统调用后 M 会脱离 P”的标准答案;
  • Work-Stealing 发生时刻:在 “Scheduler” 视图中定位 steal 事件(蓝色小方块),其左侧必有某 P 的本地队列为空,右侧为另一 P 的本地队列非空——这是调度器负载均衡的铁证。

反向推导标准答案的关键路径

考题类型 trace 中定位点 对应原理
Goroutine 为何不立即执行? 查看 G 的 runnable 时间戳与首次 running 时间戳差值 本地队列满 → 入全局队列 → 等待窃取或 schedule 循环
系统调用后 G 是否丢失? 搜索 GoSysExit 事件 → 观察后续 G 是否绑定新 M runtime 将 G 放入全局队列,由其他 M 获取
为什么 2 个 G 并发 sleep 却几乎同时唤醒? 对比多个 G 的 blockingrunnable 时间戳精度 netpoller 批量唤醒 + 事件循环统一调度

运行 go tool trace -http=localhost:8080 trace.out 后,所有时间轴、事件标记、协程状态流转均以毫秒级精度可视化,无需记忆理论,直接“看懂”调度器正在做什么。

第二章:Goroutine调度机制核心考点精析

2.1 G、M、P三元组状态转换与trace事件映射

Go 运行时通过 G(goroutine)、M(OS thread)、P(processor)协同实现并发调度,其状态跃迁直接受 trace 事件驱动。

状态映射核心逻辑

runtime.traceGoStart() 触发 G_Grunnable_Grunning,同时绑定 PtraceGoPark() 记录 _Grunning_Gwaiting,解绑 P 并可能唤醒 M

关键 trace 事件对照表

Trace Event G 状态变迁 P/M 影响
GoStart runnable → running P 被占用,M 绑定 G
GoPark running → waiting P 释放,M 可能进入自旋或休眠
GoUnpark waiting → runnable G 入 P 的本地队列
// runtime/trace.go 片段:GoPark 事件记录
func traceGoPark(traceEv byte, skip int) {
    if !trace.enabled { return }
    // 参数说明:
    // traceEv: 事件类型码(如 traceEvGoPark)
    // skip: 跳过调用栈帧数,用于准确定位 park 位置
    traceEvent(traceEv, 0, skip+1)
}

该函数在 gopark 调用链中插入,确保调度器状态变更与 trace 时间线严格对齐。

graph TD
    A[G: _Grunnable] -->|GoStart| B[G: _Grunning]
    B -->|GoPark| C[G: _Gwaiting]
    C -->|GoUnpark| D[G: _Grunnable]
    B -->|GoStop| E[G: _Gdead]

2.2 Goroutine创建与阻塞唤醒在trace中的可视化识别

Go trace 工具(go tool trace)将 goroutine 生命周期映射为时间轴上的状态跃迁,核心状态包括 GrunnableGrunningGsyscallGwaiting

goroutine 状态跃迁关键信号

  • 创建:proc.start 事件触发 Grunnable → Grunning
  • 阻塞:block 事件标记 Grunning → Gwaiting(如 semacquirenetpollwait
  • 唤醒:unblock 事件触发 Gwaiting → Grunnable

典型阻塞唤醒代码示例

func blockingIO() {
    conn, _ := net.Dial("tcp", "example.com:80")
    conn.Write([]byte("GET / HTTP/1.1\r\n\r\n"))
    // 此处 read 可能触发 netpollwait → Gwaiting
    buf := make([]byte, 1024)
    conn.Read(buf) // trace 中显示为 block → unblock → runnable
}

该调用链在 trace UI 中表现为:绿色(running)→ 灰色(waiting)→ 黄色(runnable),对应 runtime.goparkruntime.ready 的调用栈。

trace 中的 Goroutine 状态对照表

trace 状态名 runtime 状态 触发时机
Goroutine created Grunnable newproc1 分配 G 结构体
Scheduling Grunning execute 抢占调度
Block Sync Gwaiting gopark 显式阻塞(如 mutex)
Netpoll Gwaiting netpollblock 网络 I/O 阻塞
graph TD
    A[Goroutine created] --> B[Scheduling]
    B --> C{I/O or sync?}
    C -->|Yes| D[Block Sync / Netpoll]
    C -->|No| E[Running]
    D --> F[unblock via netpoll or semawakeup]
    F --> G[Goroutine runnable]

2.3 系统调用(Syscall)与网络轮询(netpoll)在trace中的双路径判别

在 Go 运行时 trace 中,read/write 等 I/O 操作会分叉为两条可观测路径:

  • Syscall 路径:阻塞式系统调用,直接陷入内核,runtime.syscall 记录完整上下文;
  • netpoll 路径:非阻塞 I/O + epoll_wait(Linux)或 kqueue(macOS),由 runtime.netpoll 驱动,goroutine 挂起于 Gwaiting 状态。

核心判别依据

  • proc.status == "syscall" → Syscall 路径
  • proc.status == "gwaiting" + netpoll 在栈帧中 → netpoll 路径
// trace 示例片段(简化)
// goroutine 17: syscall path
runtime.syscall -> sys_read -> ... // trace event: "SyscallEnter"
// goroutine 19: netpoll path  
runtime.netpoll -> epollwait -> ... // trace event: "NetPollBlock"

逻辑分析:SyscallEnter 事件携带 fdop 参数,标记内核态入口;NetPollBlock 则附带 mode=0x1(读就绪)等位掩码,反映用户态轮询调度意图。

路径类型 触发条件 trace 关键事件 goroutine 状态
Syscall O_NONBLOCK 未设 SyscallEnter/Exit Gsyscall
netpoll 文件描述符注册至 netpoll NetPollBlock/Unblock Gwaiting
graph TD
    A[I/O 操作发起] --> B{fd 是否注册 netpoll?}
    B -->|是| C[进入 netpoll 循环<br>goroutine 挂起]
    B -->|否| D[执行 raw syscall<br>线程阻塞]
    C --> E[epoll_wait 返回就绪]
    D --> F[内核返回 syscall 结果]

2.4 抢占式调度触发点(如preemptible point)在trace timeline中的定位方法

抢占式调度触发点是内核可被中断并切换任务的关键位置,常见于 cond_resched()might_resched() 或系统调用返回路径。

关键识别模式

  • 在 ftrace 或 perf script 输出中,搜索 sched_preempt_enablepreempt_schedule__schedule 入口;
  • preempt_count 变化前后伴随 preempt_disable/preempt_enable 调用对。

典型 trace 片段分析

// kernel/sched/core.c: __schedule() 调用前的 preempt check
if (should_resched(prev)) {         // 判定是否到达抢占点
    preempt_schedule();            // 显式触发调度(trace 中可见)
}

should_resched() 检查 TIF_NEED_RESCHED 标志与 preempt_count == 0;仅当两者同时满足时,该位置才构成有效抢占点。

常见抢占点类型对照表

触发场景 trace 事件名 是否用户态可见
系统调用返回 sys_exitpreempt_schedule
中断返回 irq_handler_exitpreempt_schedule_irq 是(需开启 irq trace)
显式让出(cond_resched) cond_resched__cond_resched

定位流程图

graph TD
    A[采集 sched:sched_switch + preempt:* 事件] --> B[过滤 preempt_count == 0 的上下文]
    B --> C[匹配 __schedule 入口前的 TIF_NEED_RESCHED 置位点]
    C --> D[标记为有效抢占点]

2.5 GC STW与Mark Assist对P状态和G队列的trace痕迹分析

Go 运行时在 GC 标记阶段通过 Mark Assist 机制分摊标记工作,避免 STW 时间过长。该过程深度影响 P(Processor)的调度状态与 G(Goroutine)队列的可见性。

Mark Assist 触发时的 P 状态迁移

当 Goroutine 分配内存触发辅助标记时,当前 P 会从 _Prunning 进入 _Pgcassist 状态,暂停常规调度,专注执行标记任务。

G 队列在 trace 中的关键痕迹

启用 GODEBUG=gctrace=1 后,可观察到:

  • gc assist start → P 状态切换日志
  • gc assist done → 恢复调度前清空本地 G 队列缓存
// runtime/proc.go 中关键逻辑片段
if gp.m.p.ptr().status == _Pgcassist {
    gcMarkDone() // 协助标记完成后,重置 P 状态
    mp := gp.m
    mp.p.ptr().status = _Prunning // 恢复运行态
}

此代码确保 P 在完成协助后立即回归调度循环;status 字段变更被 runtime.traceProcStatusChange() 捕获并写入 trace event,形成 P 状态跃迁链。

Event P 状态变化 G 队列影响
gc assist start _Prunning → _Pgcassist 本地运行队列冻结
gc assist done _Pgcassist → _Prunning 全局队列扫描+本地队列重载
graph TD
    A[goroutine 分配触发 assist] --> B{P.status == _Prunning?}
    B -->|是| C[切换为 _Pgcassist]
    C --> D[执行 markroot & scan]
    D --> E[恢复 _Prunning]
    E --> F[resume scheduler loop]

第三章:MOS调度关键场景建模与反推验证

3.1 长时间阻塞导致M脱离P:trace中P.idle与M.blocked事件链分析

当 Goroutine 因系统调用(如 readnetpoll)陷入不可中断等待时,运行时会将当前 M 与 P 解绑,触发 M.blocked 事件;随后该 P 进入空闲状态,记录 P.idle 事件。

事件链时序特征

  • M.blocked 先于 P.idle 发生(延迟通常
  • 同一 M 的 M.blocked 与后续 M.unblocked 构成闭合周期
  • M.blocked 持续 > 10ms,P 将被窃取给其他 M 复用

trace 事件链示例

M.blocked: m=3, when=124567890123, reason="syscalls"
P.idle: p=2, when=124567890215

此处 when 为纳秒级单调时间戳;reason 字段标识阻塞根源(如 "syscalls""gc assist"),是定位阻塞类型的关键依据。

典型阻塞场景对比

场景 是否触发 M 脱离 P P.idle 持续时间 可恢复性
网络 I/O 阻塞 ~ms–s 级 异步唤醒
锁竞争(mutex) 自旋/休眠
垃圾回收辅助 主动让出
graph TD
    A[M 执行 syscall] --> B{是否可异步?}
    B -->|否| C[M.blocked → P.idle]
    B -->|是| D[注册 netpoller → 继续绑定]
    C --> E[P 被 steal 或 GC 抢占]

3.2 工作窃取(Work-Stealing)失败场景:runqueue为空但trace显示G持续等待的归因推演

runtime.trace 显示 Goroutine(G)长期处于 Gwaiting 状态,而其所属 P 的本地运行队列(_p_.runq)与全局队列(sched.runq)均为空时,需排查窃取窗口关闭自旋竞争失效的耦合故障。

数据同步机制

P 在尝试窃取前会检查 atomic.Load(&sched.nmspinning) —— 若为 0,则跳过窃取直接挂起;此时即使其他 P 有任务,本 P 也因未置位 nmspinning 而无法进入窃取循环。

// src/runtime/proc.go:4921
if atomic.Load(&sched.nmspinning) == 0 && 
   atomic.Cas(&sched.nmspinning, 0, 1) {
    // 进入自旋窃取模式
}

逻辑分析:Cas 失败即表示已有其他 M 抢占了自旋权,本 M 将放弃窃取直接 stopm()。参数 nmspinning 是全局计数器,非原子累加而是 CAS 控制唯一性,易在高并发下形成“饥饿漏斗”。

关键状态表

状态变量 合法值 含义
_p_.runqhead == runqtail 本地队列空
sched.runqsize == 0 全局队列空
sched.nmspinning == 0 无 M 正在自旋窃取 → 根本原因

故障传播路径

graph TD
    A[G 阻塞于 channel recv] --> B{P.runq 为空?}
    B -->|是| C[尝试 work-stealing]
    C --> D{sched.nmspinning == 0?}
    D -->|是| E[跳过窃取 → parkm]
    D -->|否| F[成功窃取并唤醒]
    E --> G[trace 显示 Gwaiting 持续]

3.3 全局队列溢出与批量迁移:trace中gqueue.growth与schedule.trace事件关联解读

当全局任务队列(gqueue)容量触达阈值,运行时触发 gqueue.growth 事件并启动批量迁移——将待调度的 goroutine 批量转移至 P 的本地队列,以缓解中心化调度压力。

数据同步机制

schedule.trace 事件在迁移完成后发出,携带关键字段:

  • p_id: 目标 P 编号
  • batch_size: 迁移 goroutine 数量
  • overflow: 溢出前队列长度
// runtime/trace.go 片段(简化)
traceGQueueGrowth(uint32(len(gqueue)), uint32(cap(gqueue)))
// → 触发 gqueue.growth 事件,记录扩容前 size/cap

该调用在 gqueue.push() 检测到 len >= cap*0.9 时执行,参数为当前长度与容量,用于定位高水位点。

关联性验证

事件类型 触发条件 关联字段
gqueue.growth 队列使用率 ≥ 90% old_cap, new_cap
schedule.trace 批量迁移完成 p_id, batch_size
graph TD
    A[gqueue.push] -->|len ≥ 0.9*cap| B[gqueue.growth]
    B --> C[select N goroutines]
    C --> D[schedule.trace]

第四章:基于trace的典型考题还原与标准答案生成

4.1 题目一:「高并发HTTP服务中goroutine泄漏」的trace特征提取与调度器归因

关键trace信号识别

go tool trace 中,goroutine泄漏表现为持续增长的 Goroutines 曲线,且大量 goroutine 停留在 GC sweep waitchan receive 状态超时(>5s)。

典型泄漏模式代码

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ch := make(chan string, 1)
    go func() { defer close(ch); ch <- fetchFromDB(r.Context()) }() // ❌ 无超时控制
    select {
    case data := <-ch: w.Write([]byte(data))
    case <-time.After(3 * time.Second): w.WriteHeader(http.StatusGatewayTimeout)
    }
    // ch 未被消费完,goroutine 永久阻塞于 close(ch) 后的 runtime.gopark
}

逻辑分析:defer close(ch) 在匿名 goroutine 执行完毕后触发,但若主协程已退出且 ch 无接收者,该 goroutine 将卡在 runtime.closechan 的锁等待;time.After 不影响子 goroutine 生命周期,导致泄漏。参数 ch 容量为1,但 fetchFromDB 若 panic 或阻塞,close(ch) 永不执行。

调度器归因路径

状态码 占比 关联调度事件
Gwaiting 68% block on chan recv
Grunnable 22% ready but never scheduled
graph TD
    A[HTTP handler] --> B[spawn goroutine]
    B --> C{fetchFromDB returns?}
    C -- yes --> D[close chan]
    C -- no --> E[block at closechan → Gwaiting]
    E --> F[Scheduler skips G due to no runnable M]

4.2 题目二:「定时器大量触发导致CPU飙升」在trace中识别timerproc争抢与netpoll延迟

trace关键观测点

  • runtime.timerproc 在 pprof CPU profile 中高频出现,且常与 runtime.netpoll 堆栈交织
  • go tool traceTimerGoroutine 持续运行、NetPoll 事件延迟 >100μs 是典型信号

timerproc 争抢的典型堆栈

// go tool trace -http=:8080 trace.out → View Trace → Goroutines → Filter "timer"
runtime.timerproc
  runtime.adjusttimers
    runtime.doaddtimer
      runtime.addtimerLocked // 竞争热点:全局 timers heap 锁

addtimerLocked 需持 timersLock 全局互斥锁;高并发 time.AfterFunc/time.NewTicker 导致 goroutine 阻塞排队,引发 timerproc 持续唤醒与自旋。

netpoll 延迟关联表

指标 正常值 异常表现 根因线索
netpoll delay avg > 200μs timerproc 占用 P,抢占 netpoll 执行时机
timerproc run count ~100/s > 5000/s 定时器泄漏或误用短周期 ticker

关键诊断流程

graph TD
  A[trace.out] --> B{TimerGoroutine 活跃度}
  B -->|过高| C[检查 addtimerLocked 锁等待]
  B -->|伴随 NetPoll 延迟| D[确认 P 被 timerproc 长期占用]
  C & D --> E[定位高频创建定时器的业务代码]

4.3 题目三:「channel操作卡死」对应trace中block、unblock与select.go调度点交叉验证

当 goroutine 在 chan receivechan send 上阻塞时,运行时会调用 gopark 并标记状态为 Gwaiting,同时在 trace 中记录 block 事件;待另一端就绪后触发 goready,生成 unblock 事件。关键交叉点位于 runtime.selectgo——它统一处理多路 channel 操作,其内部 block/unblock 调度逻辑与 select.go 中的 selpc(选中的 case PC)强绑定。

数据同步机制

以下为典型阻塞场景的 trace 关键字段映射:

trace event 对应 runtime 函数 调度点位置
GoBlock park_mgopark chanrecv/chansend
GoUnblock readygoready chansend/chanrecv
Select selectgo 循环末尾 select.go:421

核心调度路径

// runtime/select.go#L421(简化)
func selectgo(cas0 *scase, order0 *uint16, ncase int) (int, bool) {
    // ... 尝试非阻塞 case
    for i := 0; i < ncase; i++ {
        cas := &cas0[order0[i]]
        if cas.kind == caseRecv && chantryrecv(cas.chan, cas.elem) {
            return int(order0[i]), true // 快速路径,无 block
        }
    }
    // 进入 park:此处触发 trace.GoBlock
    gopark(nil, nil, waitReasonSelect, traceEvGoBlockSelect, 1)
}

该代码块表明:仅当所有 case 均不可就绪时,selectgo 才调用 gopark,此时 trace 中 GoBlockGoUnblock 必须成对出现,且时间戳严格嵌套于同一 Select 事件区间内。

graph TD A[selectgo entry] –> B{any case ready?} B –>|Yes| C[return immediately] B –>|No| D[gopark → GoBlock trace] D –> E[wait on sudog queue] E –> F[sender/receiver wakes up] F –> G[goready → GoUnblock trace]

4.4 综合题:融合GC、sysmon、netpoll的多线程竞争trace图谱解构与标准答案反向组装

数据同步机制

当 runtime 启动时,sysmon 线程周期性采集 netpoll 就绪事件与 GC 栈快照,通过 traceEvent 注入统一 trace buffer。关键在于时间戳对齐与 goroutine ID 关联。

核心 trace 事件流

// runtime/trace.go 中的典型注入点(简化)
traceGCStart()          // GC mark start → 触发 STW 标记
traceNetpollWait(0x1a)  // netpoller 等待 fd=26,ts=12489321 ns
traceGoSched()          // G127 主动让出,因 netpoll 被抢占

逻辑分析:traceNetpollWaitfd 参数标识就绪文件描述符;ts 为单调递增纳秒时间戳,用于后续与 GC mark termination 阶段对齐;traceGoSched 的隐含上下文表明该 goroutine 正在等待 netpoll 唤醒,但被 GC stop-the-world 中断。

事件关联维度表

维度 GC Phase sysmon Tick netpoll State
时间精度 µs 级标记点 ms 级轮询 ns 级就绪事件
关键 ID gcCycleID mID goroutine ID

反向组装流程

graph TD
    A[原始 trace buffer] --> B{按 ts 排序}
    B --> C[提取 GC-start / GC-end]
    C --> D[定位其间所有 netpoll-wait + go-sched]
    D --> E[构建 goroutine 生命周期图谱]

第五章:附录:go tool trace实战速查表与期末冲刺建议

常用 trace 启动命令速查

启动带 trace 的 HTTP 服务(生产环境慎用,仅限调试):

go run -gcflags="-l" -trace=trace.out main.go
# 或对已编译二进制注入 trace(推荐)
GOTRACEBACK=all ./myserver -trace=trace.out 2>/dev/null &

采集 10 秒运行时 trace(配合 pprof 采样更精准):

go tool trace -http=localhost:8080 trace.out

关键 trace 视图功能对照表

视图名称 快捷键 核心诊断场景 注意事项
Goroutine view g 定位阻塞 goroutine、长生命周期协程 需结合 Start/Stop 时间轴交叉验证
Network blocking n 发现 net/http.ServeHTTP 中的 Accept 阻塞 通常伴随 runtime.netpollblock 调用栈
Scheduler latency s 识别 P 抢占延迟、G 等待 M 超过 10ms 延迟 >20ms 表明调度器压力显著上升
GC events v 查看 STW 时间、GC 周期频率与堆增长曲线 GC pause 占比 >5%,需检查内存泄漏

典型性能问题 trace 模式识别

  • 高频 GC 导致吞吐骤降:在 View trace 中观察到密集的紫色 GC 标记条(GC sweep + GC mark),同时 Heap profile 显示 runtime.mallocgc 占比超 35%;此时应立即导出 go tool pprof -alloc_space 分析分配热点。
  • 锁竞争瓶颈Goroutine 视图中多个 G 在同一 sync.Mutex.Lock 处长时间等待(>50ms),且 Sync blocking 子视图显示 runtime.semacquire 调用栈集中于某结构体方法——需改用 sync.RWMutex 或分片锁。

期末冲刺实操清单

  • ✅ 每日固定时段执行 go tool trace -http=:8081 trace_$(date +%s).out 采集线上灰度节点 trace(启用 -trace 编译标志并配置 SIGUSR2 动态触发)
  • ✅ 使用 go tool trace -pprof=goroutine trace.out > goroutines.pb.gz 生成可导入 pprof 的 goroutine 快照
  • ✅ 对比两次 trace:用 go tool trace -diff trace_v1.out trace_v2.out 输出差异报告,聚焦 Scheduler latencyGoroutine creation 变化量
  • ✅ 将 trace.out 文件压缩为 trace_20240615_prod.tar.gz 并上传至内部 trace 归档系统(路径 /traces/prod/20240615/

Mermaid 性能排查流程图

flowchart TD
    A[收到 CPU 使用率突增告警] --> B{是否已采集 trace?}
    B -->|否| C[立即执行 go run -trace=trace_alert.out main.go]
    B -->|是| D[go tool trace -http=:8080 trace_alert.out]
    C --> D
    D --> E[打开 Goroutine view 查看高亮阻塞项]
    E --> F{是否存在 >100ms 的 runtime.chansend/recv?}
    F -->|是| G[检查 channel 缓冲区大小与消费者速率]
    F -->|否| H[切换至 Scheduler view 查看 P idle 时间]
    G --> I[调整 buffer size 或引入 worker pool]
    H --> J[确认 GOMAXPROCS 设置与 CPU 核数匹配]

trace 文件体积控制技巧

默认 trace 会记录所有事件,导致文件爆炸性增长。生产环境必须添加过滤:

# 仅记录关键事件,体积减少 70%
go run -gcflags="-l" -trace=trace.out -trace-alloc=1M -trace-gc=on main.go
# 或通过环境变量控制
GOTRACE=1,GOTRACEALLOCSAMPLE=1048576,GOTRACEGC=1 ./myserver

线上 trace 安全规范

  • trace 文件含完整调用栈与部分内存地址,严禁上传至公网 GitHub/GitLab;
  • 所有 trace 文件须经 sha256sum trace.out | cut -d' ' -f1 生成校验码,并与归档元数据绑定;
  • 自动化脚本需在 trace 采集后 5 分钟内执行 rm -f trace.out && gzip trace_*.out,防止磁盘写满。

浪迹代码世界,寻找最优解,分享旅途中的技术风景。

发表回复

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