Posted in

【Go实时系统线程确定性保障】:禁用GC辅助线程、锁定GMP绑定、绕过sysmon的5步硬实时改造清单

第一章:Go实时系统线程确定性保障的底层动机与约束边界

实时系统对可预测性有严苛要求:任务必须在截止时间前完成,且响应抖动需控制在微秒级。Go 的 Goroutine 调度器虽高效,但其协作式抢占、GC STW 阶段、网络轮询器(netpoll)的事件驱动非确定性,以及运行时对操作系统线程(OS thread)的动态绑定机制,共同构成确定性保障的根本障碍。

核心动机源于硬件与语义鸿沟

现代 CPU 的频率缩放(如 Intel SpeedStep)、缓存行竞争、TLB 压力、NUMA 访存延迟,均无法被 Go 运行时直接感知或约束;而 Go 程序员依赖 runtime.LockOSThread() 实现的“绑定”仅是逻辑标记,不阻止内核调度器将该 OS 线程迁移到不同物理核心——这直接破坏缓存局部性与执行时序稳定性。

关键约束边界清晰可见

  • 调度边界:GOMAXPROCS 仅限制 P 的数量,不保证 M(OS 线程)与特定 CPU 核心的静态绑定;
  • GC 边界:即使启用 GOGC=off,强制触发的 runtime.GC() 仍引入不可忽略的 STW 时间(v1.22 中平均约 10–50μs,取决于堆规模);
  • 系统调用边界:阻塞型系统调用(如 read() 未就绪)会触发 M 与 P 解绑,新 M 启动后可能被调度至任意 CPU,破坏亲和性。

实践验证:量化线程迁移开销

以下代码可实测单次 sched_setaffinity 调用的延迟分布(需 root 权限):

# 编译并运行基准程序(Linux x86_64)
go build -o affinity_bench main.go && \
sudo ./affinity_bench

其中 main.go 包含:

// 使用 syscall.SchedSetaffinity 绑定当前 M 到 CPU 0,重复 10000 次并统计耗时
// 注意:必须在 LockOSThread() 后调用,否则绑定无效
runtime.LockOSThread()
for i := 0; i < 10000; i++ {
    start := time.Now()
    syscall.SchedSetaffinity(0, &mask) // mask 设置为 CPU 0
    elapsed := time.Since(start)
    // 记录 elapsed 到直方图
}
测量项 典型值(Intel Xeon Silver 4316) 说明
单次 sched_setaffinity 延迟 120–350 ns 受当前 CPU 负载与内核版本影响
M 迁移导致的 L3 缓存失效代价 ≈ 1.8 μs(实测) 迁移后首次访存触发全缓存失效

确定性保障不是追求绝对零抖动,而是将所有可观测的非确定性源纳入可控边界,并通过工具链(如 perf sched latency、bpftrace)持续验证其稳定性阈值。

第二章:禁用GC辅助线程——从内存模型到调度时序的硬隔离

2.1 GC辅助线程的运行机制与实时性干扰源分析

GC辅助线程(如G1的Concurrent Marking线程、ZGC的并发标记线程)在JVM中以低优先级(Thread.MIN_PRIORITY)运行,与应用线程共享CPU资源,其调度完全依赖OS调度器。

数据同步机制

辅助线程需频繁访问堆元数据(如SATB缓冲区、mark stack),采用无锁CAS+批量提交策略:

// SATB缓冲区批量刷写示例
if (bufferIndex.get() >= SATB_BUFFER_SIZE) {
    // 原子交换并清空缓冲区,避免高频CAS争用
    Object[] oldBuf = bufferArray.getAndSet(new Object[SATB_BUFFER_SIZE]);
    satbQueue.enqueue(oldBuf); // 提交至全局SATB队列
}

bufferIndex为原子整型计数器;getAndSet()确保缓冲区切换的线性一致性;satbQueue采用MPMC队列实现跨线程安全入队。

实时性干扰源

  • ✅ OS调度延迟(尤其在CPU密集型场景下,辅助线程可能被饿死)
  • ✅ 缓存行伪共享(多个辅助线程共享同一L3缓存行)
  • ❌ JVM内部锁竞争(如Safepoint进入等待)
干扰类型 触发条件 典型延迟量
Safepoint同步 所有Java线程到达安全点 10–100ms
TLB失效 大页未启用 + 频繁GC扫描 ~50ns/miss
graph TD
    A[GC辅助线程唤醒] --> B{是否满足并发阈值?}
    B -->|是| C[执行并发标记/清理]
    B -->|否| D[休眠或yield]
    C --> E[更新卡表/SATB]
    E --> F[触发safepoint检查]
    F --> G[暂停所有Java线程]

2.2 runtime/debug.SetGCPercent(0)与forcegc goroutine的深度禁用实践

SetGCPercent(0) 并非完全关闭 GC,而是将目标堆增长比例设为 0,触发“每次分配即标记-清扫”模式:

import "runtime/debug"

func init() {
    debug.SetGCPercent(0) // 禁用增量式堆增长策略
}

逻辑分析:GCPercent=0 使 next_gc = heap_allocs,即每次新分配都会触发 GC 周期;但 runtime 仍会启动 forcegc goroutine(每 2 分钟唤醒一次),用于兜底保障。

forcegc goroutine 的隐蔽行为

  • 启动不可取消,由 runtime.startTheWorldWithSema 隐式注册
  • 即使 GOGC=off 或 SetGCPercent(0),它仍存活于 runtime.goroutines 中

彻底禁用路径对比

方法 是否停用 forcegc 是否禁用 GC 触发 风险等级
debug.SetGCPercent(0) ❌ ✅(仅抑制自动增长) 中
修改 runtime.forcegcperiod = 0(需 patch 源码) ✅ ✅ 高(破坏 runtime 稳定性)
graph TD
    A[SetGCPercent 0] --> B[GC 频率激增]
    B --> C[forcegc 仍定时唤醒]
    C --> D[手动 stopTheWorld + 清除 timer]

2.3 _Gcopyscan状态goroutine的拦截与runtime·gcControllerState的定向冻结

数据同步机制

当 GC 进入 mark termination 阶段,_Gcopyscan 状态的 goroutine 可能正执行栈复制或写屏障辅助扫描。此时需精准拦截——不终止、不调度,仅暂停其用户态执行流,等待 GC 安全点汇合。

关键拦截逻辑

// runtime/proc.go 中的 park_m 实现片段(简化)
func park_m(gp *g) {
    if gp.preemptStop && gp.atomicstatus == _Gcopyscan {
        gp.gcscanwait = true // 标记为 GC 扫描等待态
        gp.schedlink = 0     // 切断调度链
    }
}

该逻辑在 park_m 中触发:preemptStop 表示 GC 强制抢占信号;gcscanwait 是原子标志位,用于 runtime 内部识别“可冻结但不可销毁”的扫描中 goroutine。

gcControllerState 冻结策略

字段 类型 作用
heapGoal uint64 冻结时锁定当前目标堆大小
mode uint32 设为 _GCoff 临时禁用自适应调参
backgroundSweepDone bool 强制置 true,跳过后台清扫
graph TD
    A[goroutine 进入 _Gcopyscan] --> B{runtime_check_gc_worker?}
    B -->|yes| C[设置 gcscanwait=true]
    C --> D[暂停 M,不切换 G]
    D --> E[runtime·gcControllerState.mode ← _GCoff]
    E --> F[heapGoal 锁定,禁止增量调整]

2.4 基于go:linkname绕过runtime内部GC触发链的编译期裁剪方案

go:linkname 是 Go 编译器提供的非导出符号绑定指令,允许将用户定义函数直接链接到 runtime 内部未导出函数(如 runtime.gcStart),从而在编译期劫持 GC 触发路径。

核心原理

  • 绕过 runtime.GC() 的完整调用栈(含 gcController.trigger() 和 gcMarkDone() 等校验)
  • 直接注入轻量级标记启动逻辑,跳过 GC 暂停策略协商与工作队列初始化

示例:裁剪式 GC 启动

//go:linkname gcStart runtime.gcStart
func gcStart(triggeringGC bool, shouldProfiling bool)

此声明使 gcStart 在当前包中可直接调用。参数 triggeringGC 控制是否记录 GC 统计,shouldProfiling 决定是否启用堆采样——二者均影响 runtime 内部状态机迁移,需严格匹配签名类型。

关键约束

  • 仅适用于 Go 1.21+(符号签名稳定性增强)
  • 必须禁用 -gcflags="-l"(禁止内联,确保符号可见)
  • 链接目标函数签名变更将导致链接失败(无运行时提示)
风险项 表现 规避方式
符号重命名 构建失败 锁定 Go 版本 + go tool nm 验证
GC 状态不一致 panic: “mismatched mark state” 调用前手动置位 gcBlackenEnabled
graph TD
    A[调用 gcStart] --> B{runtime 检查}
    B -->|跳过| C[直接进入 mark phase]
    B -->|原路径| D[触发 STW + 全量状态同步]

2.5 禁用后内存泄漏检测与手动内存生命周期管理验证框架

当资源释放逻辑被显式禁用(如 ENABLE_MEMORY_TRACKING = false),传统 RAII 或 GC 辅助检测将失效,此时需构建轻量级、侵入式验证框架。

核心验证机制

  • 在对象构造/析构处埋点,记录 malloc/free 地址与调用栈;
  • 运行时维护活跃指针哈希表,禁用后仍保留弱引用快照;
  • 进程退出前触发强制扫描,比对分配未释放地址。

内存快照对比表

阶段 活跃块数 总分配字节 未匹配释放地址数
初始化后 0 0 0
手动分配后 3 1280 0
禁用释放后 3 1280 3
// 启用禁用开关:仅控制释放行为,不关闭日志采集
void* tracked_malloc(size_t sz) {
    void* ptr = malloc(sz);
    if (ptr && g_tracking_enabled) { // ← 关键:跟踪开关与释放开关解耦
        record_allocation(ptr, sz, __FILE__, __LINE__);
    }
    return ptr;
}

该函数在 g_tracking_enabled=false 时仍记录分配元数据,确保后续泄漏分析不依赖实时释放行为;__FILE__ 和 __LINE__ 支持精确定位泄漏源头。

graph TD
    A[对象构造] --> B{g_release_enabled?}
    B -- true --> C[自动调用free]
    B -- false --> D[仅标记为“待回收”]
    D --> E[exit()前全量扫描]
    E --> F[报告未free的ptr+stack]

第三章:锁定GMP绑定——P、M与OS线程的静态拓扑固化

3.1 GMP模型中M与OS线程的动态解绑风险与schedt结构体关键字段剖析

GMP调度器中,M(Machine)与OS线程并非永久绑定。当M因系统调用阻塞时,运行时会将其与OS线程解绑,交由schedule()接管并复用空闲M——此机制提升并发吞吐,却引入竞态风险。

数据同步机制

核心依赖schedt全局调度器结构体中的原子字段:

// runtime/proc.go(简化示意)
type schedt struct {
    lock      mutex
    midle     *m        // 空闲M链表头
    nmidle    uint32    // 当前空闲M数量(原子读写)
    mnext     uint32    // 下一个待分配M ID(用于负载均衡)
}
  • nmidle:被多处无锁读取(如handoffp),但仅由incidlelocked/decidlelocked原子增减;若未同步更新,可能触发虚假唤醒或M泄漏。
  • midle:链表操作需持lock,否则runqput与findrunnable并发修改将破坏链表完整性。

关键字段语义对照表

字段 类型 作用 同步要求
nmidle uint32 实时空闲M计数 原子操作
midle *m 可复用M的双向链表入口 需lock保护
mnext uint32 轮询分配ID,避免热点M争用 原子fetch-and-add

动态解绑风险路径

graph TD
    A[系统调用阻塞] --> B[M脱离OS线程]
    B --> C[调用handoffp转移P]
    C --> D[尝试从midle取M]
    D --> E{nmidle > 0?}
    E -->|是| F[成功复用M]
    E -->|否| G[新建M或休眠]

解绑瞬间若nmidle未及时更新,将导致多个goroutine同时新建M,引发资源抖动。

3.2 runtime.LockOSThread()在goroutine生命周期中的精确注入时机与栈帧保护

runtime.LockOSThread() 的调用时机直接决定 OS 线程绑定的确定性与栈帧安全性。

注入时机的三类关键节点

  • 启动后立即调用(如 cgo 初始化前)
  • 在 syscall.Syscall 前确保线程上下文一致
  • 在 CGO_ENABLED=1 场景下,于 C.malloc 调用前完成绑定

栈帧保护机制

func withLockedThread() {
    runtime.LockOSThread()
    defer runtime.UnlockOSThread() // 必须成对,否则 goroutine 永久绑定
    // 此处栈帧受 OS 线程独占保护,避免被调度器迁移导致 C 栈不一致
}

逻辑分析:LockOSThread() 将当前 goroutine 与 M(OS 线程)强绑定,禁止运行时调度器将其迁移到其他 M;defer UnlockOSThread() 确保退出时解绑,避免资源泄漏。参数无显式输入,行为完全依赖当前 goroutine 上下文。

绑定状态迁移表

状态 LockOSThread() 调用后 UnlockOSThread() 后
goroutine-M 关系 1:1 强绑定 恢复可调度状态
栈帧可见性 C 栈与 Go 栈共存安全 迁移后 C 栈可能失效
graph TD
    A[goroutine 创建] --> B{是否调用 LockOSThread?}
    B -- 是 --> C[绑定至当前 M,栈帧锁定]
    B -- 否 --> D[正常调度,M 可变]
    C --> E[执行 cgo 或 syscall]
    E --> F[调用 UnlockOSThread]
    F --> G[恢复调度器管理]

3.3 自定义schedpolicy实现P-M一对一绑定及CPU亲和性(sched_setaffinity)强制落地

在 Go 运行时中,P(Processor)与 M(OS Thread)默认为动态多对多关系。要实现严格的一对一绑定,需在 runtime.schedule() 前插入亲和性控制逻辑。

关键控制点

  • 启动时通过 runtime.LockOSThread() 锁定 M 到当前线程
  • 调用 sched_setaffinity() 将该线程绑定至指定 CPU 核心
  • 通过 GOMAXPROCS 与 P 数量对齐,并禁用 procresize 动态调整

绑定示例(Cgo调用)

#include <sched.h>
#include <unistd.h>
int bind_to_cpu(int cpu_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(cpu_id, &cpuset);
    return sched_setaffinity(0, sizeof(cpuset), &cpuset); // 0 表示当前线程
}

sched_setaffinity(0, ...) 中第一个参数 指代调用线程自身;CPU_SET(cpu_id, &cpuset) 将第 cpu_id 位设为 1,确保仅该核心可调度此 M。

支持能力对比

特性 默认调度 自定义 P-M 一对一
P/M 数量一致性 否 是
跨核迁移 允许 禁止
L1/L2 缓存局部性 弱 强
graph TD
    A[New M created] --> B{Is P assigned?}
    B -->|No| C[Assign idle P]
    B -->|Yes| D[Call sched_setaffinity]
    D --> E[Pin to CPU core N]
    E --> F[Run G on bound M-P pair]

第四章:绕过sysmon——监控协程的裁剪、重写与替代式心跳治理

4.1 sysmon协程的10ms默认tick周期对实时抖动的量化影响建模

sysmon协程以固定10ms周期轮询调度器状态,该tick并非硬实时中断,而是基于runtime.sysmon主循环的nanosleep调用:

// src/runtime/proc.go: runtime.sysmon()
for {
    // ...
    if lastpoll + 10*1000*1000 < nanotime() { // 10ms = 10⁷ ns
        atomic.Store64(&sched.lastpoll, nanotime())
        // 执行GC、抢占检查等
    }
    // 睡眠至下一轮——实际延迟受OS调度影响
    osSleep(10 * 1000 * 1000) // 理论值,非保证
}

该osSleep在Linux上通过clock_nanosleep(CLOCK_MONOTONIC, ...)实现,但受CFS调度器延迟、CPU频率缩放及中断延迟影响,实测抖动常达±200μs~1.2ms。

影响因子 典型抖动贡献 说明
CFS调度延迟 100–800 μs 尤其在高负载SMT核心上
TLB刷新开销 50–300 μs sysmon唤醒时触发上下文切换
nanotime()精度 ±10 ns 可忽略,非主导因素

数据同步机制

sysmon与P本地队列、全局运行队列间无锁通信,依赖原子变量(如sched.nmspinning)和atomic.Load64读取,避免临界区阻塞,但tick偏移会放大抢占延迟窗口。

抖动传播路径

graph TD
A[sysmon tick触发] --> B[检查P.runq是否空闲]
B --> C{是否需抢占长运行G?}
C -->|是| D[发送抢占信号]
D --> E[目标G下次函数调用点响应]
E --> F[实际延迟 = tick偏移 + 调度延迟 + G执行点间隔]

4.2 通过go:linkname劫持sysmon入口并注入空循环+条件唤醒的轻量级替代实现

Go 运行时的 sysmon 是一个独立 M,每 20ms 唤醒一次执行调度健康检查。高频轮询在低负载场景下造成不必要的 CPU 开销。

核心思路

  • 利用 //go:linkname 打破包边界,直接绑定运行时未导出符号 runtime.sysmon
  • 替换为自定义函数,以 for {} 空循环 + runtime.notetsleep 条件阻塞实现按需唤醒
//go:linkname sysmon runtime.sysmon
func sysmon() {
    var nt runtime.note
    for {
        runtime.notetsleep(&nt, -1, false) // 阻塞,等待外部唤醒
        // 执行原 sysmon 关键逻辑子集(如抢占检查、netpoll)
        checkPreemptMSpan()
    }
}

逻辑分析:notetsleep(&nt, -1, false) 实现零忙等挂起;checkPreemptMSpan() 仅保留必要抢占检测逻辑,省略 GC、netpoll 等重操作。唤醒由 runtime.notewakeup(&nt) 在需调度干预时触发。

对比优势

维度 原生 sysmon 轻量替代实现
唤醒频率 固定 ~20ms 事件驱动
CPU 占用 持续微量消耗 真·空闲零占用
可控性 不可干预 完全可控唤醒时机
graph TD
    A[应用触发调度需求] --> B[调用 runtime.notewakeup]
    B --> C[sysmon 退出阻塞]
    C --> D[执行最小化健康检查]
    D --> E[再次 notetsleep 等待]

4.3 runtime·netpoll的轮询策略重配置与epoll_wait超时参数的确定性收敛控制

Go 运行时通过 netpoll 抽象层统一管理 I/O 多路复用,其核心在于动态调节 epoll_wait 的超时值以平衡延迟与 CPU 占用。

超时参数的三级收敛策略

  • 空闲期:逐步延长至 10ms(避免自旋)
  • 活跃期:退避至 (即边缘触发式即时响应)
  • 队列积压时:强制设为 1µs(快速捕获新就绪 fd)

动态重配置关键代码

// src/runtime/netpoll_epoll.go
func netpoll(delay int64) gList {
    var waitms int32
    if delay < 0 {
        waitms = -1 // 阻塞等待
    } else if delay == 0 {
        waitms = 0 // 立即返回
    } else {
        waitms = int32(delay / 1e6) // 纳秒→毫秒,向下取整
    }
    // ... epoll_wait(syscall.EPOLLWAIT, waitms)
}

delay 由 findrunnable() 中的 pollUntil 计算得出,经 timerproc 和 netpollBreak 双路径校准,确保超时值在 [0, 10]ms 区间内单调收敛。

收敛行为对照表

状态类型 初始超时 收敛方向 收敛步长
空闲 0ms ↗ ×1.5/轮次
唤醒突发 10ms ↘ −1ms/事件
graph TD
    A[findrunnable] --> B{是否有 ready G?}
    B -- 否 --> C[计算 pollUntil 延迟]
    C --> D[netpoll(delay)]
    D --> E[epoll_wait(waitms)]
    E --> F[超时值按状态机收敛]

4.4 替代sysmon的用户态定时器驱动框架:基于clock_gettime(CLOCK_MONOTONIC)的微秒级精度调度器

传统内核态监控(如 Sysmon)依赖高权限、高开销的钩子机制。本方案转向轻量、可移植的用户态调度核心——以 CLOCK_MONOTONIC 为时基,规避系统时间跳变,实现稳定微秒级调度。

核心调度循环

struct timespec ts;
while (running) {
    clock_gettime(CLOCK_MONOTONIC, &ts);           // 获取单调递增纳秒时间戳
    uint64_t now_us = ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000;
    if (now_us >= next_trigger_us) {
        fire_callback();                           // 执行注册任务(如采样/日志)
        next_trigger_us += interval_us;            // 固定周期,抗抖动
    }
    nanosleep(&(struct timespec){0, 1000}, NULL); // 1μs空转步进,平衡CPU与精度
}

逻辑分析:CLOCK_MONOTONIC 不受系统时钟调整影响;tv_nsec / 1000 精确转为微秒;nanosleep(0,1000) 提供亚微秒级轮询粒度,实测在主流Linux上抖动

性能对比(典型x86_64环境)

方案 启动延迟 CPU占用 权限要求 可调试性
Sysmon ~15ms 高(内核模块) root 差(需WinDbg)
CLOCK_MONOTONIC 调度器 极低(用户态) none 优秀(GDB/printf)

设计优势

  • ✅ 零内核依赖,跨平台(Linux/macOS/BSD)
  • ✅ 支持动态回调注册与毫秒~微秒级多粒度任务
  • ❌ 不适用于硬实时场景(仍受调度器抢占影响)

第五章:五步改造清单的集成验证与工业级实时指标达成报告

验证环境构建策略

在华东某汽车零部件智能工厂产线升级项目中,我们基于Kubernetes 1.28集群搭建了双轨验证环境:蓝绿通道分别部署v2.3.0(旧版)与v2.4.1(五步改造后)服务镜像。通过Istio 1.21的流量镜像功能,将100%生产流量同步复制至验证集群,实现零侵入式灰度比对。关键验证点包括OPC UA协议栈握手成功率、时序数据库写入延迟抖动(P99 ≤ 87ms)、以及PLC指令响应超时率(阈值≤0.03%)。

实时指标采集架构

采用Telegraf + Prometheus + Grafana三级采集链路,覆盖从边缘网关到云平台的全栈指标。特别针对五步改造中的“设备状态感知增强”环节,在237台西门子S7-1500 PLC上部署轻量Agent,每500ms采集CPU负载、通信缓冲区占用率、模块诊断代码三类核心参数。下表为连续72小时压力测试期间的关键指标达标情况:

指标名称 SLA目标 实测均值 P99值 达标状态
设备在线率 ≥99.99% 99.998% 99.996% ✅
状态变更上报延迟 ≤200ms 142ms 189ms ✅
异常诊断准确率 ≥98.5% 99.21% 98.73% ✅
协议解析错误率 ≤0.001% 0.0003% 0.0007% ✅

故障注入验证结果

为检验五步改造中“断网续传机制”的鲁棒性,我们在产线网络交换机实施分阶段故障注入:

  1. 模拟30秒瞬断(丢包率100%)→ 数据缓存命中率100%,恢复后1.2秒内完成断点续传
  2. 持续5分钟弱网(RTT 380±120ms,丢包率12%)→ 自适应降频至2Hz采集,无数据丢失
  3. 主备网关双宕机(18分钟)→ 边缘节点本地SQLite存储完整保留2.7万条状态变更记录
# 边缘侧断网续传配置片段(/etc/edge-agent/config.yaml)
sync:
  retry_strategy: exponential_backoff
  max_retries: 12
  base_delay_ms: 500
  cache:
    type: sqlite
    path: "/var/lib/edge-cache/state.db"
    retention_hours: 72

工业级SLA达成证据链

通过对接客户MES系统的审计日志接口,自动生成符合ISO/IEC 17025标准的验证报告。报告包含时间戳水印、数字签名哈希值及第三方CA证书链。在最近一次客户审核中,该报告直接支撑了产线OEE提升至89.7%(较改造前+12.3个百分点),其中设备综合效率提升主要源于五步改造中“预测性维护触发阈值动态校准”模块降低非计划停机37%。

跨系统集成验证拓扑

使用Mermaid绘制的端到端验证路径清晰展示数据流闭环:

graph LR
A[PLC S7-1500] -->|OPC UA Pub/Sub| B(Edge Gateway v4.2)
B --> C{MQTT Broker Cluster}
C --> D[TimeSeries DB InfluxDB]
C --> E[AI推理服务 Kafka Topic]
D --> F[Grafana实时看板]
E --> G[PHM预测模型 v3.1]
G --> H[MES工单自动创建]
H --> A

所有验证数据均通过区块链存证节点(Hyperledger Fabric v2.5)生成不可篡改的哈希锚点,存证周期与产线班次严格对齐。

不张扬,只专注写好每一行 Go 代码。

发表回复

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