Posted in

Go并发模型阅读密码:读懂runtime/proc.go前必须破解的6个调度状态机隐喻

第一章:Go并发模型的哲学根基与proc.go定位

Go语言的并发哲学并非简单复刻传统线程模型,而是以“轻量级、可组合、通信优于共享”为三大信条。它将并发视为程序结构的第一性原理——goroutine不是操作系统线程的别名,而是由Go运行时(runtime)完全托管的用户态协作式调度单元;channel不是消息队列的简化版,而是类型安全、带同步语义的通信原语;而go关键字本身即是一种声明式并发构造,将并发逻辑直接融入控制流。

proc.go是Go运行时的核心调度骨架文件,位于src/runtime/proc.go,承载着GMP模型中G(goroutine)、M(OS thread)、P(processor)三元组的生命周期管理。它定义了g结构体(含栈指针、状态字段、调度上下文)、mp的绑定逻辑、以及schedule()主调度循环。该文件不暴露API,但所有go f()调用最终都经由newproc()进入其调度队列。

关键机制体现于以下代码片段:

// src/runtime/proc.go 中的 goroutine 创建入口(简化)
func newproc(fn *funcval) {
    // 获取当前G(调用者goroutine)
    gp := getg()
    // 分配新G结构体,初始化栈与状态
    newg := allocg(gp.m)
    newg.sched.pc = fn.fn          // 设置入口地址
    newg.sched.sp = newg.stack.hi  // 初始化栈顶
    newg.gopc = getcallerpc()      // 记录调用位置,用于panic traceback
    // 将新G加入当前P的本地运行队列
    runqput(&_g_.m.p.ptr().runq, newg, true)
}

此函数执行后,新goroutine处于_Grunnable状态,等待schedule()在下一轮调度中将其置为_Grunning并切换至其栈上执行。

Go运行时通过proc.go实现了“M:N”调度抽象:数千goroutine可动态复用少量OS线程,避免系统调用阻塞导致的线程闲置。其哲学内核在于——程序员专注描述“做什么”,运行时负责决定“何时、何地、如何做”。这种分离使并发逻辑清晰可读,同时保持极致性能。

第二章:G状态机:从New到Dead的生命周期解码

2.1 G状态枚举定义与内存布局隐喻(理论)+ 源码断点追踪G创建全过程(实践)

Go运行时中,G(goroutine)的状态由_Gidle_Gdead共7种枚举值精确刻画,其定义位于src/runtime/runtime2.go

const (
    _Gidle  = iota // 刚分配,未初始化
    _Grunnable     // 在P的本地队列或全局队列中等待执行
    _Grunning      // 正在CPU上运行
    _Gsyscall      // 执行系统调用(OS线程阻塞)
    _Gwaiting      // 因channel、timer等被挂起
    _Gmoribund     // 即将销毁(仅调试用)
    _Gdead         // 可复用的空闲G
)

该枚举非线性映射——_Gwaiting_Gsyscall虽同属“阻塞态”,但前者由调度器主动挂起(无OS线程绑定),后者导致M脱离P,体现状态语义分层

G的内存布局采用“头部元数据+栈空间”紧邻分配策略,g.stack.hi指向高地址,g.sched.sp始终反映当前栈顶,形成可预测的栈生长边界。

断点追踪路径

  • runtime.newproc()runtime.newproc1()runtime.malg()runtime.ginit()
  • 关键断点:runtime.gnew()memclrNoHeapPointers(unsafe.Pointer(g), int(unsafe.Sizeof(g)))
字段 偏移量 作用
g.sched.sp +0x8 保存寄存器上下文栈指针
g.stack.hi +0x30 栈上限,用于栈溢出检测
g.status +0x148 状态枚举值(int32)
graph TD
    A[newproc] --> B[newproc1]
    B --> C[malg]
    C --> D[ginit]
    D --> E[g.sched.sp ← stack.hi - 256]

2.2 _Grunnable到_Grunning的抢占式跃迁条件(理论)+ 修改schedtrace触发G状态强制切换实验(实践)

抢占跃迁的核心判定逻辑

Go运行时在sysmon监控线程中周期性检查:

  • G是否处于 _Grunnable 状态且已就绪超时(g.preempt 为 true)
  • M是否空闲或正在执行非关键系统调用

强制切换实验:patch schedtrace

修改 src/runtime/trace.gotraceGoSched(),注入状态跃迁钩子:

// 在 traceGoSched 开头插入:
if g.status == _Grunnable && atomic.Load(&g.preempt) != 0 {
    g.status = _Grunning // 强制跃迁(仅用于调试)
    atomic.Store(&g.preempt, 0)
}

逻辑分析:该补丁绕过调度器正常路径,在 trace 采样点直接修改 G 状态。g.preempt 是原子标志位,表示需被抢占;_Grunnable → _Grunning 跳变违反调度一致性,仅限 GODEBUG=schedtrace=1000 场景下观测异常调度行为。

关键参数说明

参数 含义 典型值
g.status G 当前状态枚举 _Grunnable, _Grunning
g.preempt 抢占请求标志(int32) 1 表示待抢占
graph TD
    A[_Grunnable] -->|preempt==1 ∧ sysmon 检测| B[_Grunning]
    B -->|执行完成| C[_Gwaiting]

2.3 _Gsyscall与_Gwaiting的系统调用语义分野(理论)+ strace + GDB双视角观测netpoll阻塞态转换(实践)

Go 运行时中,_Gsyscall 表示 Goroutine 正在执行系统调用且独占 M,而 _Gwaiting 表示 Goroutine 已主动让出 M、等待事件(如 netpoller 就绪),此时 M 可被复用调度其他 G。

关键语义差异

  • _Gsyscall:内核态阻塞,M 不可被抢占,g.m.lockedm == g.m
  • _Gwaiting:用户态挂起,M 已解绑,g.m == nil,依赖 netpoll() 轮询 I/O 事件

strace + GDB 协同观测要点

# 在 netpoll 阻塞前注入断点(GDB)
(gdb) b runtime.netpoll
(gdb) r
# 同时 strace -p $(pidof myprog) -e trace=epoll_wait
状态 epoll_wait 返回值 G 状态 M 绑定状态
初始阻塞 —(挂起) _Gwaiting g.m == nil
事件就绪唤醒 >0 _Grunnable 待调度重绑
// runtime/netpoll.go 片段(简化)
func netpoll(block bool) *g {
    // block=true → epoll_wait(-1) 阻塞
    // 若超时或信号中断,返回 nil;否则返回就绪 G 链表
}

该调用逻辑决定 Goroutine 是否从 _Gwaiting 迁移至 _Grunnable,是 netpoller 事件驱动调度的核心枢纽。

2.4 _Gdead状态复用机制与gc标记清除协同逻辑(理论)+ runtime.GC()前后G结构体字段快照对比分析(实践)

Go 运行时通过 _Gdead 状态实现 Goroutine 结构体的高效复用,避免频繁堆分配。当 Goroutine 退出后,其 g 结构体不立即释放,而是置为 _Gdead,加入全局 sched.gfreeStacksched.gfreeNoStack 链表,等待后续 newproc1 复用。

数据同步机制

GC 标记阶段跳过 _Gdead 状态的 g,因其栈与寄存器已无效;清扫阶段仅回收真正无引用的 g,而 _Gdead 由调度器直接重置字段后复用:

// src/runtime/proc.go:3521
func gfput(_g_ *g, gp *g) {
    if gp.stack.lo == 0 {
        gp.status = _Gdead // 清空用户栈指针
        gp.stack = stack{} // 复位栈元信息
        gp.sched = gobuf{} // 彻底清空调度上下文
        ...
    }
}

gp.sched 清零确保下次 gogo 调用前必须经 gogo 初始化,杜绝残留寄存器污染。

字段快照对比(关键字段)

字段 runtime.GC() 前 runtime.GC() 后 语义变化
status _Grunning _Gdead 退出调度,进入复用池
stack.lo 0x7f...a000 栈内存归还至 mcache
m *m nil 解绑线程,解除所有权

协同流程示意

graph TD
    A[Goroutine exit] --> B[gp.status ← _Gdead]
    B --> C[gp.stack.lo ← 0; gp.m ← nil]
    C --> D[GC Mark: skip _Gdead]
    D --> E[GC Sweep: retain g struct in freelist]
    E --> F[newproc1 → gfget → reset & reuse]

2.5 G状态迁移图谱与runtime.checkdead校验原理(理论)+ 注入非法状态触发panic验证状态守卫边界(实践)

Go 运行时通过 G(goroutine)的有限状态机严格约束执行生命周期。核心状态包括 _Gidle_Grunnable_Grunning_Gsyscall_Gwaiting_Gdead,迁移受调度器原子操作保护。

状态守卫关键点

  • 所有状态变更需经 casgstatus() 原子校验
  • runtime.checkdead() 在每轮调度循环末尾扫描:若所有 G 处于 _Gdead_Gidle 但无待运行 G,且无活跃 PM,则判定为“死锁”并 panic

非法状态注入验证(实践)

以下代码手动篡改 g.status 触发校验失败:

// ⚠️ 仅用于调试环境!生产禁用
func forceInvalidState() {
    g := getg()
    old := g.m.g0 // 切换到系统栈
    *(*uint32)(unsafe.Pointer(&g.status)) = 0x100 // 伪造非法状态码
}

逻辑分析g.statusuint32 字段,合法值由 gstatus 常量定义(如 _Grunning=2)。写入 0x100(256)超出枚举范围,checkdead 在遍历 allgs 时调用 readgstatus(g) 检查状态有效性,触发 throw("bad g->status")

状态码 含义 是否可被 checkdead 接受
0 _Gidle
2 _Grunning
256 非法值 ❌ → panic
graph TD
    A[_Gidle] -->|schedule| B[_Grunnable]
    B -->|execute| C[_Grunning]
    C -->|block| D[_Gwaiting]
    C -->|syscall| E[_Gsyscall]
    D & E -->|ready| B
    C -->|exit| F[_Gdead]

第三章:M状态机:OS线程绑定与调度权移交的契约

3.1 M状态枚举(_Midle/_Mrunning/_Mspinning)与自旋锁语义(理论)+ perf record观测M在空闲/运行/自旋三态CPU周期分布(实践)

Go运行时的M(Machine)代表OS线程,其核心状态由mstatus字段刻画:

// src/runtime/runtime2.go
const (
    _Midle    = 0 // 空闲,等待G;无栈、未绑定P
    _Mrunning = 2 // 正在执行G;已绑定P,栈活跃
    _Mspinning = 3 // 自旋中;试图获取全局runq或P,不挂起,持续消耗CPU
)

_Mspinning是自旋锁语义的关键体现:当M找不到可运行G时,不立即休眠,而是循环尝试窃取任务,避免上下文切换开销——这是对“短临界区”场景的性能优化。

perf观测三态分布

使用perf record -e cycles,instructions,task-clock -g -- ./myprogram采集后,解析M状态切换点(需patch runtime插入tracepoint),可统计各态CPU周期占比:

状态 典型周期占比 触发条件
_Midle 15–40% P本地队列空 + 全局队列空 + 无netpoll事件
_Mrunning 50–75% 正在执行用户G或系统G
_Mspinning 2–12% findrunnable()循环重试 > 20次

自旋行为可视化

graph TD
    A[enter findrunnable] --> B{local runq?}
    B -- yes --> C[return G]
    B -- no --> D{steal from other P?}
    D -- success --> C
    D -- fail --> E[spin++ < maxspin?]
    E -- yes --> B
    E -- no --> F[call notesleep → _Midle]

_Mspinning占比往往暗示调度器负载不均或G阻塞过久。

3.2 _Msyscall状态与信号处理、栈复制的耦合关系(理论)+ sigaltstack异常栈切换路径源码级跟踪(实践)

_Msyscall 状态是 Plan 9 内核中协程(proc)进入系统调用时的关键标记,它与信号递送存在强耦合:当信号在 _Msyscall 状态下抵达,内核必须延迟投递,直至协程退出系统调用或显式让出 CPU,否则将破坏 syscall 上下文完整性。

栈切换的触发条件

  • sigaltstack() 设置备用栈后,若当前栈空间不足(如嵌套信号),内核强制切换至 ss_sp
  • 切换仅发生在 sigprocmask() 解除阻塞且 SA_ONSTACK 标志置位时。

源码关键路径(sys/src/9/port/sig.c

void
postsig(int i)
{
    if(p->state == Msyscall && !caninterrupt(p)) // 阻断信号投递
        p->sigmask |= (1ULL<<i); // 延迟挂起
    else
        (*p->sigtramp)(i, &p->sigstack, p->sigpc); // 实际交付
}

caninterrupt(p) 检查是否允许中断当前 _Msyscall 状态;p->sigstack 是经 sigaltstack() 初始化的备用栈描述符,含 ss_sp/ss_size/ss_flags

字段 含义 典型值
ss_sp 备用栈基址 0x7f800000
ss_size 栈大小(字节) 0x10000
ss_flags SS_DISABLE
graph TD
A[信号抵达] --> B{p->state == Msyscall?}
B -->|Yes| C[检查 caninterrupt]
B -->|No| D[立即调用 sigtramp]
C -->|False| E[挂起至 sigmask]
C -->|True| D

3.3 M状态与GMP队列归属动态绑定机制(理论)+ 手动调用runtime.LockOSThread()观察M绑定行为变更(实践)

Go 运行时中,M(OS线程)默认处于“自由调度”状态,可被调度器动态分配给任意 P 执行 G;一旦调用 runtime.LockOSThread(),当前 M 即与当前 goroutine 绑定,脱离全局/本地运行队列调度。

绑定前后状态对比

状态字段 解绑态(默认) 锁定态(LockOSThread后)
m.lockedg nil 指向绑定的 goroutine
m.p 可被 steal/reassign 保持绑定期间不释放
g.m.lockedm 0 非0(表示已锁定)

实践验证代码

package main

import (
    "runtime"
    "time"
)

func main() {
    println("Before LockOSThread: M is unbound")
    runtime.LockOSThread()
    println("After LockOSThread: M is bound to this goroutine")
    time.Sleep(time.Millisecond) // 触发调度观察点
}

逻辑分析:runtime.LockOSThread() 将当前 goroutine 的 g.m.lockedm 置为 1,并将 m.lockedg 指向该 g。此后该 M 不再响应 work-stealing,也不会被 schedule() 从 P 上摘离;若该 G 阻塞(如 syscalls),M 仍保持绑定,但 P 可能被其他 M 抢占——体现“M-G 绑定”而非“M-P 绑定”。

调度流示意(绑定后关键路径)

graph TD
    A[goroutine 调用 LockOSThread] --> B[设置 g.m.lockedm = 1]
    B --> C[m.lockedg = g]
    C --> D[调度器跳过该 M 的 steal & handoff]
    D --> E[仅当 UnlockOSThread 或程序退出才解绑]

第四章:P状态机:处理器资源池与本地队列的自治逻辑

4.1 P状态(_Pidle/_Prunning/_Psyscall/_Pgcstop)与GC安全点协作模型(理论)+ GC STW阶段P状态轮询日志注入分析(实践)

Go运行时中,P(Processor)的四种核心状态构成GC安全点协作的基础:_Pidle(空闲等待G)、_Prunning(执行用户G)、_Psyscall(系统调用中)、_Pgcstop(被GC暂停)。其中 _Pgcstop 是STW的关键入口态。

GC安全点触发机制

当GC进入STW阶段,runtime.stopTheWorldWithSema() 遍历所有P,通过原子状态切换强制其进入 _Pgcstop;仅当所有P均处于 _Pgcstop_Pidle 时,STW才完成。

P状态轮询日志注入示例

// 在 runtime/proc.go 的 park_m() 中注入调试日志
if mp.p.ptr().status == _Pgcstop {
    println("P", mp.p.ptr().id, "entered _Pgcstop at", nanotime())
}

该日志在每次P被挂起前输出,用于验证STW期间各P的同步收敛行为;mp.p.ptr().id 标识P编号,nanotime() 提供纳秒级时间戳,支撑时序分析。

状态 可中断性 是否参与STW等待 典型触发条件
_Prunning 否(需自陷) 用户代码执行中
_Psyscall 是(信号唤醒) read/write等阻塞系统调用
_Pidle G队列为空
_Pgcstop 终态 GC主动设置
graph TD
    A[GC进入STW] --> B{遍历所有P}
    B --> C[尝试CAS至_Pgcstop]
    C --> D[_Prunning → 自陷至安全点]
    C --> E[_Psyscall → 信号中断唤醒]
    C --> F[_Pidle → 直接接受]
    D & E & F --> G[全部P ∈ {_Pgcstop, _Pidle}]
    G --> H[STW完成]

4.2 _Pidle状态下的work stealing触发阈值与负载均衡策略(理论)+ 修改stealOrder扰动调度器观察goroutine迁移轨迹(实践)

Go运行时在 _Pidle 状态下启用 work stealing 的核心阈值是 atomic.Load(&p.runqsize) == 0 && sched.nmidle > 0,即本地队列为空且存在空闲P时,才触发跨P偷取。

stealOrder扰动实验设计

修改 runtime/proc.gostealOrder 数组初始化逻辑:

// 原始:stealOrder = [1,2,3,...,GOMAXPROCS-1]
// 修改为:引入偏移扰动,使偷取顺序非单调
stealOrder := make([]uint32, nproc)
for i := range stealOrder {
    stealOrder[i] = (uint32(i) + 3) % uint32(nproc) // 模偏移扰动
}

此改动使P0优先尝试从P3而非P1偷取,打破默认轮询序,便于通过 GODEBUG=schedtrace=1000 观察goroutine迁移路径变化。

负载均衡关键参数对照

参数 默认值 作用 敏感度
forcegcperiod 2min 强制GC周期
stealLoadFactor 1/64 偷取门槛(本地队列长度占比)
maxPreemptMS 10ms 协程抢占最大耗时
graph TD
    A[当前P进入_Pidle] --> B{runqsize == 0?}
    B -->|Yes| C[遍历stealOrder索引]
    C --> D[尝试从targetP.runq.pop()]
    D -->|成功| E[唤醒goroutine执行]
    D -->|失败| F[尝试下一个targetP]

4.3 P本地运行队列(runq)与全局队列(runqhead/runqtail)状态同步协议(理论)+ race detector捕获P本地队列竞态访问场景(实践)

数据同步机制

Go调度器采用“本地优先 + 全局兜底”策略:每个P维护独立的runq(环形缓冲区),而runqhead/runqtail构成全局可伸缩队列。两者通过原子CAS+内存屏障实现无锁同步——runq满时批量迁移至全局队列,空时尝试窃取。

竞态复现代码

// 模拟P本地队列非原子读写(禁用编译器优化以暴露race)
func unsafeRunqAccess() {
    var p struct{ runq [256]g }
    go func() { p.runq[0] = g{} }() // 写
    go func() { _ = p.runq[0] }()   // 读
}

go run -race main.go 将报告Read at ... Write at ...,因[256]g未加锁且无同步原语,违反Go内存模型中对同一地址的并发读写约束。

同步协议关键约束

  • runq操作需配对使用atomic.LoadUint32(&p.runqhead)atomic.StoreUint32(&p.runqtail, ...)
  • 全局队列迁移必须满足runqtail - runqhead ≤ len(runq),否则触发panic
场景 同步方式 内存序要求
本地入队 atomic.AddUint32 relaxed
全局窃取 atomic.CompareAndSwapUint32 acquire/release
graph TD
    A[goroutine创建] --> B{P本地队列有空位?}
    B -->|是| C[原子写入runq[tail]]
    B -->|否| D[批量推入全局runqhead/runqtail]
    C --> E[执行goroutine]
    D --> E

4.4 P状态与mcache/mspan分配器的生命周期绑定(理论)+ p.mcache字段内存地址变化与GC周期关联性验证(实践)

Go运行时中,P(Processor)结构体的mcache字段并非静态指针,其指向的mcache对象与P的启用/停用状态强绑定:当PstopTheWorld暂停或被handoffp移交时,原mcache会被清空并归还至mcentral,随后在acquirep时重新绑定新分配的mcache

GC触发时的mcache重绑定行为

// runtime/proc.go 片段(简化)
func stopTheWorldWithSema() {
    // ... STW前遍历所有P
    for _, p := range allp {
        if p != nil && p.status == _Prunning {
            p.mcache = nil // 清除引用,触发后续GC可达性判定
        }
    }
}

该操作使原mcache脱离P的根集合,若无其他引用,将在下个GC周期被回收;p.mcache字段本身地址不变(栈/堆位置固定),但其所指*mcache对象地址在acquirep()中被更新为新分配地址。

验证路径:GC周期与p.mcache地址变更关系

GC阶段 p.mcache指针值变化 是否触发mcache重建
GC start 保持不变
GC mark termination 变更(若P被重调度) 是(依赖P状态迁移)
GC sweep done 可能再次变更 是(如P从idle恢复)
graph TD
    A[goroutine调用runtime.GC] --> B[STW: stopTheWorldWithSema]
    B --> C[遍历allp, 置p.mcache = nil]
    C --> D[GC mark phase]
    D --> E[GC sweep → mcache内存释放]
    E --> F[resume: acquirep → new(mcache)]
    F --> G[p.mcache = 新地址]

第五章:状态机统一视图:从proc.go到真实世界的调度脉搏

Go 运行时的 proc.go 文件是调度器的核心实现载体,其中 g(goroutine)、m(OS thread)、p(processor)三者构成的状态流转并非线性逻辑,而是一张密集耦合的状态机网络。在生产环境的高并发服务中,我们曾观测到某支付网关在流量突增时出现 goroutine 积压但 CPU 利用率仅 35% 的异常现象——通过 runtime.ReadMemStatsdebug.ReadGCStats 搭配 pprof trace 分析,最终定位到 g.status_Grunnable → _Grunning → _Gsyscall 转换过程中因系统调用阻塞未及时归还 p,导致其他就绪 g 长期等待,形成“伪饥饿”。

状态跃迁的真实代价

Go 1.21 引入的 MPreemptible 标志使 m 可被抢占,但这依赖 g.statusm.lockedg 的协同更新。一次线上故障复盘显示:当 g 处于 _Gwaiting 并持有 runtime.semtable 锁时,若 m 被强制切换,p 上的本地运行队列(_p_.runq)会因 g.status 未同步置为 _Grunnable 而跳过该 g,造成延迟达 47ms(高于 P99 SLA 20ms)。修复方案是在 semacquire1 中插入 atomic.Storeuintptr(&gp.atomicstatus, _Grunnable) 显式状态同步。

生产级状态监控实践

我们在 Kubernetes DaemonSet 中部署了轻量级状态探针,通过 /debug/pprof/goroutine?debug=2 解析文本格式并提取关键字段:

字段 示例值 含义
status runnable 对应 _Grunnable,可被调度
waitreason semacquire 阻塞原因,需关联 g.waiting 地址
m 0xc0000a8000 所属 M 地址,用于追踪绑定关系

该探针每 5 秒采样一次,聚合后写入 Prometheus,构建 go_goroutines_by_status_total 指标。当 waiting 状态 g 数量突增 300% 且 waitreason 集中为 selectgo 时,自动触发告警并关联 netpollepoll_wait 调用栈。

// 实际部署的探针核心逻辑(简化)
func trackGoroutineStates() {
    buf := make([]byte, 2<<20)
    n, _ := http.DefaultClient.Get("http://localhost:6060/debug/pprof/goroutine?debug=2")
    io.ReadFull(n.Body, buf[:n])
    lines := strings.Split(string(buf[:n]), "\n")
    for _, line := range lines {
        if strings.Contains(line, "goroutine") && strings.Contains(line, "runnable") {
            // 提取 g ID、status、stack depth
        }
    }
}

跨版本状态语义差异

Go 1.19 将 _Gcopystack 状态移除,合并至 _Grunnable;而 Go 1.22 新增 _Gscanrunnable 用于 GC 扫描期间的临时标记。某金融风控服务升级至 1.22 后,runtime.GC() 触发时出现 g 状态卡在 _Gscanrunnable 超过 200ms,根源在于自定义的 unsafe.Pointer 缓存未按新规范调用 runtime.KeepAlive,导致 GC 误判活跃性。通过 go tool compile -S 查看汇编,确认 CALL runtime.gcmarknewobject 前缺失屏障指令。

stateDiagram-v2
    [*] --> _Gidle
    _Gidle --> _Grunnable: newproc
    _Grunnable --> _Grunning: execute
    _Grunning --> _Gsyscall: entersyscall
    _Gsyscall --> _Grunnable: exitsyscall
    _Grunning --> _Gwaiting: block
    _Gwaiting --> _Grunnable: ready
    _Grunnable --> [*]: exit

这种状态机不是理论模型,而是每一毫秒都在 etcd 集群心跳、Kafka 消费者组重平衡、TiDB 分布式事务提交中真实搏动的脉搏。

用实验精神探索 Go 语言边界,分享压测与优化心得。

发表回复

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