Posted in

【Go标准库架构图谱】:一张图看懂runtime、os、net三大子系统交互关系(基于Go 1.22.6源码逆向绘制)

第一章:Go标准库架构图谱总览与核心设计哲学

Go标准库不是松散模块的集合,而是一个经过精密协同设计的有机整体。其架构以“最小可行接口”为基石,强调组合优于继承、显式优于隐式、可组合性优先于功能完备性——这正是Go语言“少即是多”(Less is exponentially more)设计哲学在库层面的具象体现。

核心分层逻辑

标准库按抽象层级自然划分为三类:

  • 基础运行时支撑:runtime、unsafe、reflect 提供底层能力,直接对接编译器与内存模型;
  • 通用编程原语:io、sync、errors、context 定义跨领域协作契约,如 io.Reader/Writer 接口被 net/http、os、compress/gzip 等数十个包统一实现;
  • 领域专用工具集:net/http、database/sql、encoding/json 等封装行业共识协议,但始终严格依赖上层原语,不引入额外抽象层。

接口驱动的可组合性范例

以 http.Handler 为例,其定义极简却极具延展性:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

该接口仅声明一个方法,却支撑中间件链式调用:

// 日志中间件:包装原始 handler,注入日志逻辑
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("REQ: %s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r) // 委托执行
    })
}
// 使用:logging(auth(mux)) —— 无侵入、无反射、零运行时开销

标准库依赖关系特征

维度 表现
循环依赖 严格禁止(go list -f '{{.Imports}}' net/http 可验证)
构建时耦合 所有包均可独立编译,无隐式全局状态
版本演进约束 向后兼容性由 go tool api 工具强制校验

这种架构确保了标准库既轻量又稳健——开发者无需记忆复杂框架生命周期,只需理解少数核心接口与组合模式,即可构建高可靠系统。

第二章:runtime子系统深度解析与源码实践

2.1 Goroutine调度器(M/P/G模型)的运行时行为观测

Goroutine调度器的运行时行为可通过runtime包和pprof工具动态捕获,核心在于理解M(OS线程)、P(处理器上下文)、G(goroutine)三者间的绑定与迁移机制。

运行时状态快照

import "runtime"
func observeScheduler() {
    var stats runtime.MemStats
    runtime.ReadMemStats(&stats)
    println("NumGoroutine:", runtime.NumGoroutine()) // 当前活跃G数量
}

runtime.NumGoroutine()返回全局G队列+所有P本地队列中可运行态G总数,不包含已阻塞或已完成的G;该值瞬时波动,反映调度器负载压力。

P与G的绑定关系

组件 状态特征 生命周期
M 绑定至OS线程,可被抢占 长期存在,但可能休眠/复用
P 持有本地G队列、运行时状态 与M绑定后激活,M退出时回收
G 有栈、状态(runnable/waiting/running) 创建→调度→完成→GC回收

调度关键路径

graph TD
    A[New Goroutine] --> B{P本地队列非满?}
    B -->|是| C[入P.runnable]
    B -->|否| D[入全局G队列]
    C --> E[调度循环:findrunnable]
    D --> E
    E --> F[M执行G]

数据同步机制

P的本地队列采用无锁环形缓冲区,与全局队列通过atomic.Load/Store协同;当本地队列空时,P会尝试从其他P“偷取”一半G(work-stealing),确保负载均衡。

2.2 内存分配器(tcmalloc演进版)的堆内存布局与pprof验证

tcmalloc 演进版采用多级缓存结构:Per-CPU CentralCache → PageHeap → System Allocator,显著降低锁争用。

堆内存分层视图

  • Small Object(
  • Large Object(≥256KB):直接从 PageHeap 分配,按页对齐
  • Huge Page Support:自动启用 2MB 大页(需 MALLOC_CONF="huge_page_size:2097152")

pprof 验证关键命令

# 启用内存采样(每分配 1MB 触发一次堆快照)
GODEBUG=madvdontneed=1 MALLOC_CONF="profile:true,heap_profile:1048576" ./app
go tool pprof --alloc_space heap.pprof  # 查看分配热点

此配置使 pprof 捕获真实堆布局:alloc_space 显示累计分配量,可定位未释放的大对象;--inuse_objects 则反映当前驻留对象数。

区域 典型大小 管理方式
ThreadCache ~2MB/线程 无锁 LIFO 栈
CentralCache 全局共享 自旋锁保护
PageHeap 可达数GB 二叉搜索树索引
graph TD
    A[Thread Alloc] -->|small| B(ThreadCache)
    A -->|large| C[CentralCache]
    B -->|evict| C
    C -->|page request| D[PageHeap]
    D -->|system call| E[mmap/madvise]

2.3 垃圾回收器(GC)三色标记-清除流程的断点调试实操

三色标记法将对象分为白(未访问)、灰(已入队、待扫描)、黑(已扫描完毕)三类。在 Go 1.22+ 中,可通过 GODEBUG=gctrace=1 触发 GC 并配合 dlv 断点深入观察。

断点设置关键位置

  • runtime.gcStart():GC 启动入口
  • runtime.gcDrain():核心标记循环
  • runtime.markroot():根对象扫描起点

标记阶段典型调用链

// 在 dlv 中执行:
(b) runtime.gcDrain
(b) runtime.greyobject // 将对象从白转灰,并加入 workbuf

三色状态转换表

颜色 内存状态 GC 阶段行为
白 未标记、可回收 初始全部为白,最终存活者必不为白
灰 已入队、未扫描 从 workbuf 取出并扫描其指针字段
黑 已扫描、存活 其引用的对象必须非白(否则违背不变量)

核心不变量验证流程

graph TD
    A[GC 开始] --> B[所有对象置白]
    B --> C[根对象置灰并入队]
    C --> D[取灰对象→扫描→白子对象置灰]
    D --> E[灰对象置黑]
    E --> F{workbuf为空?}
    F -->|否| D
    F -->|是| G[二次扫描确保无漏标]

调试时重点关注 gcWork.full 和 gcWork.partial 缓冲区切换,这是并发标记的关键同步点。

2.4 系统调用封装(entersyscall/exitsyscall)与阻塞态追踪

Go 运行时通过 entersyscall 和 exitsyscall 钩子精确标记 M 进入/退出系统调用的边界,从而实现对阻塞态的细粒度追踪。

阻塞态生命周期管理

  • entersyscall:将当前 M 标记为 Gsyscall 状态,解绑 P,允许其他 M 接管调度;
  • exitsyscall:尝试重新绑定原 P;失败则触发 handoffp,移交 P 给空闲 M。

关键状态流转

// src/runtime/proc.go 中 entersyscall 的核心逻辑
func entersyscall() {
    mp := getg().m
    mp.preemptoff = "syscalls"     // 禁止抢占
    mp.syscalltick = mp.p.ptr().syscalltick // 记录 syscall tick
    mp.oldpc = getcallerpc()
    mp.g0.stack = mp.g0.stack0      // 切换至 g0 栈
}

mp.syscalltick 用于检测 P 是否在 syscall 期间被窃取;mp.g0.stack0 是专为系统调用准备的独立栈空间,隔离用户 goroutine 栈。

状态迁移图

graph TD
    Grunning -->|syscall| Gsyscall
    Gsyscall -->|exitsyscall success| Grunning
    Gsyscall -->|exitsyscall fail| Gwaiting
场景 M 状态 P 状态 备注
正常 syscall Running → Syscall 绑定 → 解绑 P 可被其他 M 抢占调度
syscall 返回成功 Syscall → Running 重新绑定 原路复位
syscall 返回失败 Syscall → Waiting 转交至空闲 M 触发 pidle.add()

2.5 运行时信号处理(SIGURG/SIGPROF)与goroutine抢占式调度验证

Go 运行时利用 SIGPROF 实现基于时间的 goroutine 抢占,而 SIGURG 在特定网络场景下辅助用户态通知——二者均绕过传统系统调用开销,直接触发 runtime 的异步抢占逻辑。

SIGPROF 触发抢占的关键路径

// runtime/signal_unix.go 中注册 SIGPROF 处理器
func setsigstack() {
    sigprocmask(_SIG_SETMASK, &old, nil)
    signal.Notify(sigch, syscall.SIGPROF) // 仅用于调试观察
}

该注册使内核每 10ms 向 M 发送 SIGPROF(由 setitimer(ITIMER_PROF) 驱动),触发 runtime.signalM → runtime.sigprof → runtime.preemptM,最终在 goschedImpl 中插入 Gpreempted 状态。

抢占生效条件对比

条件 SIGPROF 抢占 SIGURG 场景
触发频率 ~10ms 定时 TCP OOB 数据到达时
是否需 G 处于运行态 是(非 GC/系统调用中) 否(可唤醒阻塞 G)
Go 版本支持起点 1.14+ 1.21+(实验性支持)

抢占验证流程

  • 启动 goroutine 并循环执行 runtime.Gosched() 前置标记
  • 通过 strace -e trace=rt_sigaction,kill 观察 SIGPROF 注册与投递
  • 使用 GODEBUG=schedtrace=1000 输出调度器 trace,确认 preempted 计数增长
graph TD
    A[内核定时器到期] --> B[SIGPROF 送达 M]
    B --> C[runtime.sigprof]
    C --> D{是否可安全抢占?}
    D -->|是| E[设置 g.preempt = true]
    D -->|否| F[延迟至下一个安全点]
    E --> G[goschedImpl 执行切换]

第三章:os子系统与底层操作系统交互机制

3.1 文件描述符管理与epoll/kqueue/inotify的跨平台抽象层逆向分析

现代I/O抽象库(如libuv、mio)常通过宏定义与条件编译隐藏底层差异:

// 伪代码:跨平台事件循环初始化片段
#ifdef __linux__
    int epfd = epoll_create1(0);
    struct epoll_event ev = {.events = EPOLLIN | EPOLLET, .data.fd = fd};
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
#elif defined(__APPLE__)
    int kq = kqueue();
    struct kevent ev;
    EV_SET(&ev, fd, EVFILT_READ, EV_ADD | EV_CLEAR, 0, 0, NULL);
    kevent(kq, &ev, 1, NULL, 0, NULL);
#endif

该代码揭示核心策略:统一事件注册语义,隔离系统调用细节。epoll_ctl 的 EPOLLET 与 kqueue 的 EV_CLEAR 均服务于边缘触发语义对齐;inotify 则被封装为普通 fd 注入同一事件队列。

抽象层关键设计原则

  • 事件类型归一化(READ/WRITE/ERROR)
  • 文件描述符生命周期由RAII或引用计数托管
  • 错误码映射表实现 errno → 统一错误枚举
系统 原生机制 抽象后接口 触发模式支持
Linux epoll add_fd(fd, READ) ET/ET+LT 混合
macOS/BSD kqueue add_fd(fd, READ) 边沿/水平自动适配
Linux (fs) inotify watch_path(path) 仅事件驱动式监听
graph TD
    A[应用层: add_fd] --> B{抽象调度器}
    B --> C[Linux: epoll_ctl]
    B --> D[macOS: kevent]
    B --> E[Linux: inotify_add_watch]

3.2 进程生命周期控制(fork/exec/wait)在不同OS上的syscall桥接策略

Linux、macOS 和 Windows Subsystem for Linux(WSL2)对 fork/exec/wait 系统调用的实现存在根本差异:Linux 原生支持完整 POSIX 进程语义;macOS 基于 XNU 内核,fork 使用 copy-on-write 优化但 vfork 已弃用;WSL2 则通过轻量级虚拟机内核(Linux)直接承载 syscall,而 WSL1 依赖 syscall 翻译层。

跨平台桥接核心挑战

  • fork() 返回值语义需统一(父进程返回 child PID,子进程返回 0)
  • execve() 的路径解析与动态链接器行为因 rootfs 差异而不同
  • waitpid() 的状态码(如 WIFEXITED)必须跨 ABI 保持二进制兼容

WSL1 的 syscall 翻译示例

// WSL1 中 fork() 的简化桥接逻辑(用户态翻译层)
int wsl1_fork() {
    // 将 Linux syscall 映射为 NT API CreateProcessW 的模拟
    HANDLE hProc = CreateProcessW(
        NULL, L"bash -c 'exec /bin/sh'", // 模拟 exec 上下文
        NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
    return hProc ? translate_nt_pid_to_linux_pid(pi.dwProcessId) : -1;
}

该实现不真正复刻 fork 的内存克隆语义,而是采用“进程模板+延迟加载”策略,在 exec 时才注入目标镜像,牺牲 fork 原子性换取 Windows 兼容性。

主流 OS syscall 行为对比

OS fork() 实现 exec 路径解析根目录 wait 可等待对象
Linux COW + do_fork() /(真实 rootfs) 所有子进程
macOS COW + unix_fork() /(但受 SIP 限制) 同 session leader
WSL2 直通 Linux kernel /(ext4 虚拟磁盘) 完全兼容 Linux 语义
graph TD
    A[用户调用 fork()] --> B{OS 类型判断}
    B -->|Linux| C[调用 __do_fork]
    B -->|macOS| D[调用 unix_fork + task_create]
    B -->|WSL2| E[vmcall → Linux kernel]
    B -->|WSL1| F[CreateProcessW + PID 映射]

3.3 环境变量与信号传递的原子性保障与竞态规避实践

原子写入:sigprocmask + volatile sig_atomic_t

Linux 中,仅 sig_atomic_t 类型变量在信号中断下具备读写原子性。环境变量(如 getenv/putenv)本身非线程安全且非信号安全,直接在信号处理函数中修改会导致未定义行为。

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

volatile sig_atomic_t reload_flag = 0;

void sighup_handler(int sig) {
    reload_flag = 1; // ✅ 安全:sig_atomic_t 赋值是原子的
}

// 主循环中检查并安全重载
if (reload_flag) {
    reload_flag = 0;
    setenv("CONFIG_RELOAD", "true", 1); // ❌ 仅在此处调用,非信号上下文
}

逻辑分析:reload_flag 是唯一可在 sighup_handler 中安全修改的变量;setenv() 必须推迟至主控制流执行,避免 malloc/strdup 等非异步信号安全操作。参数 1 表示覆盖已有键值。

竞态规避关键原则

  • ✅ 使用 sigprocmask() 阻塞关键信号段,保护共享资源访问
  • ❌ 禁止在信号处理器中调用 printf、malloc、setenv、getenv
  • ⚠️ environ 全局指针本身无锁,多线程修改需 pthread_mutex_t 保护
场景 是否安全 原因
sig_atomic_t 读写 ✅ 编译器保证单条指令完成
setenv() 在主流程 ✅ 不在信号上下文中
getenv() 在信号 handler ❌ 可能触发内部 malloc
graph TD
    A[收到 SIGHUP] --> B{信号被阻塞?}
    B -- 是 --> C[暂存至 pending 队列]
    B -- 否 --> D[执行 sighup_handler]
    D --> E[仅更新 volatile sig_atomic_t]
    E --> F[主循环检测 flag]
    F --> G[调用 setenv/getenv 等安全时机]

第四章:net子系统网络协议栈实现与协同调度

4.1 net.Conn抽象与底层fd绑定机制(pollDesc与runtime.netpoll)联动剖析

Go 的 net.Conn 是一个接口抽象,其真实实现(如 tcpConn)通过 conn.fd 持有操作系统文件描述符(fd),而该 fd 并非直接暴露给用户,而是被封装进 pollDesc 结构中:

// src/internal/poll/fd_poll_runtime.go
type pollDesc struct {
    runtimeCtx uintptr // 指向 runtime.pollDesc(非导出)
    lock       sync.Mutex
}

pollDesc 是连接运行时网络轮询器(runtime.netpoll)的桥梁:它在 fd.init() 时调用 runtime.pollOpen(fd),将 fd 注册到 epoll/kqueue/iocp,并获取一个 runtime.pollDesc* 句柄存入 runtimeCtx。

数据同步机制

pollDesc.waitRead() 和 waitWrite() 会触发 runtime.netpollblock(),使 goroutine 挂起并交由 netpoll 管理;当 fd 就绪,runtime.netpoll() 唤醒对应 goroutine。

关键联动流程

graph TD
    A[net.Conn.Read] --> B[fd.read]
    B --> C[pollDesc.waitRead]
    C --> D[runtime.netpollblock]
    D --> E[runtime.netpoll 循环检测就绪事件]
    E --> F[唤醒 goroutine]
组件 作用 所在包
pollDesc fd 与 runtime 的状态桥接器 internal/poll
runtime.netpoll 平台无关的异步 I/O 调度核心 runtime

pollDesc 的 prepare()/unblock() 方法确保 fd 在多 goroutine 并发操作中安全复用。

4.2 TCP连接建立(三次握手)在net.Listen/net.Dial中的状态机映射验证

Go 的 net.Listen 与 net.Dial 调用底层 socket API,其行为严格对应 TCP 状态机。Dial 启动主动打开,触发 SYN 发送(SYN_SENT);监听端在 Accept 前处于 LISTEN,收到 SYN 后进入 SYN_RECV,完成三次握手后转为 ESTABLISHED。

Go 运行时状态映射示意

// net/tcpsock.go 中 DialContext 的关键路径(简化)
conn, err := net.Dial("tcp", "127.0.0.1:8080", nil)
// → syscall.Connect() → 内核触发 SYN → 用户态阻塞直至 ESTABLISHED

该调用隐式等待三次握手完成,返回的 *net.TCPConn 对象仅在内核 TCP_ESTABLISHED 状态就绪后才被封装返回,不暴露中间状态。

状态机关键映射表

Go API 内核 TCP 状态 触发事件
net.Listen() LISTEN socket 绑定 + listen()
net.Dial() SYN_SENT→ESTABLISHED connect() 阻塞返回
listener.Accept() ESTABLISHED(已建立) 从已完成队列取连接

握手过程可视化

graph TD
    A[net.Dial] -->|SYN| B[Server LISTEN]
    B -->|SYN+ACK| C[Client SYN_SENT → ESTABLISHED]
    C -->|ACK| D[Server SYN_RECV → ESTABLISHED]
    D --> E[net.Listener.Accept 返回 Conn]

4.3 HTTP/1.1长连接复用与goroutine泄漏检测实战(go tool trace分析)

HTTP/1.1 默认启用 Connection: keep-alive,客户端可复用 TCP 连接发送多个请求,但若未正确关闭响应体,底层 net/http 会阻塞读取,导致 goroutine 永久等待。

goroutine泄漏典型场景

resp, err := http.Get("http://example.com")
if err != nil {
    return
}
// ❌ 忘记 resp.Body.Close() → 连接无法复用,goroutine 卡在 readLoop

该 goroutine 由 transport.go 中 readLoop 启动,负责读取响应流;未关闭 Body 时,它将持续等待 EOF,永不退出。

使用 go tool trace 定位

go run -trace=trace.out main.go
go tool trace trace.out

在 trace UI 中筛选 Goroutine 标签,观察长时间处于 running 或 syscall 状态的 goroutine,结合堆栈定位 net/http.(*persistConn).readLoop。

状态 含义 是否可疑
GC sweep 垃圾回收清扫阶段 否
IO wait 等待网络 I/O(正常) 否
running 持续运行超 10s(异常) 是
graph TD
    A[HTTP Client 发起请求] --> B[Transport 复用 persistConn]
    B --> C[启动 readLoop goroutine]
    C --> D{resp.Body.Close() 调用?}
    D -->|否| E[goroutine 挂起,泄漏]
    D -->|是| F[连接归还 idle pool,复用]

4.4 DNS解析(net.Resolver)与runtime.GOMAXPROCS协同下的并发解析优化

DNS解析常成为高并发服务的隐性瓶颈。net.Resolver 默认复用系统解析器,但其底层调用可能受 GOMAXPROCS 影响——尤其在多核环境下,解析阻塞会拖慢 goroutine 调度。

并发解析策略对比

方案 并发控制 GOMAXPROCS 敏感性 适用场景
单 Resolver + WaitGroup 手动限流 高(阻塞 syscall 线程) 小规模批量查询
多 Resolver + context.WithTimeout 每 resolver 独立 中(隔离阻塞) 中等 QPS 微服务
自定义 Dialer + UDP 缓存 无阻塞 I/O 低(绕过系统调用) 超高吞吐 DNS 客户端

关键代码:动态 Resolver 池

func NewResolverPool(size int) []*net.Resolver {
    resolvers := make([]*net.Resolver, size)
    for i := range resolvers {
        resolvers[i] = &net.Resolver{
            PreferGo: true, // 启用 Go 原生解析器,避免 cgo 阻塞
            Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
                d := net.Dialer{Timeout: 2 * time.Second}
                return d.DialContext(ctx, network, addr)
            },
        }
    }
    return resolvers
}

PreferGo: true 强制使用纯 Go DNS 解析器,规避 cgo 导致的 GOMAXPROCS 线程抢占;Dial 自定义超时防止单次解析阻塞整个 goroutine 批次。解析器实例数建议设为 GOMAXPROCS 的 1–2 倍,以平衡 CPU 与 I/O 密集型调度。

graph TD
    A[并发解析请求] --> B{GOMAXPROCS ≥ 4?}
    B -->|是| C[启动 N=2×GOMAXPROCS Resolver 实例]
    B -->|否| D[启动 N=GOMAXPROCS 实例]
    C --> E[Round-robin 分配请求]
    D --> E
    E --> F[并行解析+context 超时控制]

第五章:三大子系统协同演进趋势与工程启示

在某头部券商的智能风控平台升级项目中,数据采集子系统(Kafka+Flink CDC)、实时决策子系统(Flink SQL + 自研规则引擎)与反馈闭环子系统(Delta Lake + 在线特征服务)实现了跨层协同演进。过去三年间,三者迭代节奏从“异步解耦”逐步转向“语义对齐、节奏共振”。

实时性边界持续前移

2022年Q3起,采集子系统将端到端延迟从850ms压降至120ms,倒逼决策子系统启用状态快照增量Checkpoint机制;同时闭环子系统同步将特征回填延迟从4小时缩短至90秒,使模型A/B测试周期从7天压缩至1.5天。下表对比了关键指标演进:

指标 2021年基准 2024年实测 提升幅度
决策链路P99延迟 680ms 210ms 69%
规则热更新生效时间 142s 8.3s 94%
特征一致性校验覆盖率 61% 99.2% +38.2pp

跨子系统契约驱动演进

团队引入OpenAPI Schema + Protobuf IDL双轨契约管理:采集子系统输出Schema自动触发决策子系统Flink DDL变更脚本生成;而闭环子系统通过Delta Lake的DESCRIBE HISTORY监听Schema版本跃迁,自动触发特征注册中心元数据刷新。该机制已在17次大版本发布中零人工干预完成接口适配。

flowchart LR
    A[采集子系统] -->|Avro Schema v3.2| B[Schema Registry]
    B --> C{契约变更检测}
    C -->|触发| D[决策子系统DDL自动生成]
    C -->|触发| E[闭环子系统特征元数据刷新]
    D --> F[Flink Job重启]
    E --> G[Feature Store Schema同步]

工程治理范式迁移

运维监控不再按子系统割裂埋点,而是构建统一“协同健康度看板”:包含跨链路Trace ID透传率(当前99.98%)、子系统间SLA履约偏差率(阈值±5%)、以及联合熔断触发频次(月均0.7次)。当采集延迟突增时,看板自动关联展示决策子系统反压线程堆栈与闭环子系统特征缓存命中率衰减曲线,定位效率提升5.3倍。

架构弹性验证机制

每个季度执行“混沌协同演练”:向采集子系统注入15%乱序消息,同步在决策子系统注入规则语法错误,观察闭环子系统能否在30秒内识别并启动降级特征流。2023年四次演练中,三次达成SLA,一次因Delta Lake小文件合并阻塞超时——该问题直接推动团队将Compaction策略从定时改为基于写入吞吐量的动态触发。

技术债偿还路径显性化

通过Git Blame与子系统间调用链日志交叉分析,识别出23处“隐性耦合点”,例如采集子系统中硬编码的决策子系统Topic分区数。所有此类问题被纳入Jira“协同债看板”,按影响面分级处理:高危项强制要求PR附带跨子系统集成测试用例,中低危项绑定季度架构重构Sprint。

该平台已支撑日均47亿笔交易实时风控,单日拦截异常行为120万次,平均响应耗时稳定在227ms。

在并发的世界里漫游,理解锁、原子操作与无锁编程。

发表回复

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