Posted in

delete()后map迭代顺序突变?Go 1.22 map迭代器随机化与删除操作的耦合影响分析

第一章:delete()后map迭代顺序突变?Go 1.22 map迭代器随机化与删除操作的耦合影响分析

Go 1.22 引入了更严格的 map 迭代随机化机制:每次调用 range 遍历同一 map 时,起始哈希桶(bucket)位置由运行时生成的伪随机种子决定,且该种子在每次迭代开始前重置。这一变更并非仅影响“首次遍历”,而是与 map 内部状态深度耦合——特别是 delete() 操作会触发 bucket 的渐进式搬迁(evacuation)与 overflow chain 重构,从而改变底层内存布局和哈希冲突链结构。

迭代顺序突变的本质原因

delete() 并非简单标记键为“已删除”,而是:

  • 若目标键位于非溢出桶(normal bucket),直接清空槽位并设置 tophashemptyOne
  • 若该桶已处于 evacuation 状态,则可能将剩余键值对迁移至新 bucket,导致原 bucket 链被截断或重组;
  • 后续 range 迭代器依据当前 bucket 数组长度、mask 及随机种子计算起始索引,而 delete() 导致的 bucket 结构变化直接影响迭代器扫描路径的拓扑连通性。

复现不可预测顺序的最小示例

package main

import "fmt"

func main() {
    m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
    fmt.Println("初始遍历:")
    for k := range m {
        fmt.Print(k, " ")
    }
    fmt.Println()

    delete(m, "c") // 触发潜在 bucket 重组

    fmt.Println("delete 后遍历:")
    for k := range m {
        fmt.Print(k, " ")
    }
    fmt.Println()
    // 多次运行输出顺序不同,如:a b d e / d a e b / b e a d ...
}

注意:需在 Go 1.22+ 环境下运行;若使用 -gcflags="-d=mapiter" 可观察迭代器内部桶扫描序列。

关键事实对照表

行为 Go ≤1.21 Go 1.22+
单次 range 顺序 确定(但不保证跨版本一致) 每次 range 独立随机起始桶
delete() 后再 range 顺序通常不变(仅键缺失) 顺序大概率改变(因 bucket 布局已扰动)
可预测性保障 无,但实践中较稳定 彻底放弃可预测性,强制开发者不依赖顺序

任何依赖 map 遍历顺序的逻辑(如基于顺序的序列化、测试断言键序)必须重构为显式排序或使用 maps.Keys() + slices.Sort()

第二章:Go map底层实现与迭代机制深度解析

2.1 hash表结构与bucket分布原理:从源码看map的内存布局

Go 运行时中 hmap 是 map 的核心结构,其内存布局围绕 buckets 数组与 bmap(bucket)展开。

bucket 的内存组织

每个 bucket 固定容纳 8 个键值对,采用紧凑数组+位图标记方式存储:

  • 高 8 位为 tophash 数组(标识哈希高位,用于快速跳过空槽)
  • 后续连续存放 key、value、overflow 指针(若发生溢出)
// src/runtime/map.go 中 bmap 的逻辑视图(简化)
type bmap struct {
    tophash [8]uint8   // 哈希高8位,0x00 表示空槽
    keys    [8]keyType
    values  [8]valueType
    overflow *bmap      // 溢出桶指针
}

tophash 是查找加速关键:无需比对完整哈希值,仅凭 tophash[i] == hash>>24 即可预筛;overflow 形成单向链表,解决哈希冲突。

bucket 分布策略

  • 初始 B = 02^0 = 1 个 bucket
  • 每次扩容 B++,bucket 数量翻倍(2^B),并按 hash & (2^B - 1) 定位主桶
  • 负载因子 > 6.5 时触发扩容,旧 bucket 按 hash >> B 决定迁移至新 bucket 的低/高位
字段 含义 示例值
B bucket 数量对数 B=3 → 8 buckets
mask 2^B - 1,用于取模定位 0b111
count 总键值对数 42
graph TD
    A[Key → full hash] --> B[取高8位 → tophash]
    B --> C[取低B位 → bucket index]
    C --> D{bucket slot empty?}
    D -- Yes --> E[写入首个空位]
    D -- No --> F[检查 tophash 匹配 → 继续线性探测]

2.2 迭代器初始化与遍历路径:runtime.mapiterinit的执行逻辑与随机种子注入点

runtime.mapiterinit 是 Go 运行时为 map 构造迭代器的核心入口,其首要任务是确定遍历起始桶(bucket)与偏移位置。

随机种子注入点

Go 为防止哈希碰撞攻击,在每次迭代器初始化时注入运行时随机种子:

// src/runtime/map.go
func mapiterinit(t *maptype, h *hmap, it *hiter) {
    // ...
    r := uintptr(fastrand()) // ← 关键注入点:fastrand() 使用 runtime·fastrand64,种子来自 memstats.next_gc
    it.startBucket = r & bucketShift(h.B) // 掩码取桶索引
    it.offset = uint8(r >> h.B & 7)         // 低3位决定桶内起始cell
}

fastrand() 返回伪随机值,其底层依赖 memstats.next_gc 的微秒级时间戳作为熵源,确保每次迭代起始位置不可预测。

遍历路径生成逻辑

  • 迭代器按桶序(startBucket → (startBucket+1) % nbuckets)线性推进
  • 每桶内从 offset 开始扫描,跳过空 slot,直至桶尾
  • 遇到迁移中 map 时自动切换 oldbuckets
组件 作用 可变性
startBucket 首个访问桶索引 每次调用不同
offset 桶内首个检查的 cell 序号(0–7) startBucket 耦合生成
bucketShift 1 << h.B,决定桶数量 编译期固定
graph TD
    A[mapiterinit] --> B[fastrand 获取随机值 r]
    B --> C[计算 startBucket = r & bucketMask]
    B --> D[提取 offset = r >> B & 7]
    C --> E[设置 hiter.curbucket]
    D --> F[设置 hiter.offset]

2.3 delete操作对bucket链表与overflow指针的实时修改:基于debug build的汇编级观测

汇编断点观测关键指令

hash_delete() 的 debug build 中,mov rax, [rdi+0x10](读取 bucket->overflow)与 mov [rdi+0x10], rsi(写入新 overflow 地址)构成原子更新对。

; 删除节点后重连链表:rdi = bucket, rsi = new_overflow_ptr
mov rax, [rdi + 0x8]    ; load bucket->next (first node)
cmp rax, rdx            ; compare with target node addr
jne skip_update
mov rax, [rdx + 0x10]   ; load target->overflow
mov [rdi + 0x10], rax   ; update bucket->overflow in place

该指令序列表明:bucket->overflow 不是延迟更新,而是在目标节点被解引用前一周期即完成重定向,避免后续 find 遍历跳过已删除节点。

数据同步机制

  • 删除时严格遵循「先更新 overflow 指针,再释放节点内存」顺序
  • 所有 bucket 访问均通过 volatile 语义的 atomic_load_acquire 保障可见性
字段 偏移 作用
bucket->next +0x8 主链首节点指针
bucket->overflow +0x10 溢出页起始地址
graph TD
    A[delete key] --> B{定位bucket}
    B --> C[读取bucket->overflow]
    C --> D[遍历链表定位target]
    D --> E[更新bucket->overflow = target->overflow]
    E --> F[free target]

2.4 迭代过程中遭遇已删除键的跳转行为:通过GDB单步跟踪验证iterator.next()状态机

GDB单步关键断点设置

iterator.next() 入口及内部 skipDeleted() 调用处下断点:

(gdb) b kv_iterator.c:142          # next() 开始
(gdb) b kv_iterator.c:89           # skipDeleted() 循环体

状态机核心跳转逻辑

当当前节点 node->deleted == true 时,状态机强制推进至下一个有效节点:

// kv_iterator.c:89–92
while (curr && curr->deleted) {
    curr = curr->next;  // 跳过已删节点(无返回值,纯指针迁移)
}
return curr;  // 返回首个非deleted节点或NULL

参数说明curr 为当前游标指针;deleted 是原子布尔标记(由写线程置位,读线程无锁检查);该循环不修改链表结构,仅做前向遍历。

验证观察表

步骤 GDB p *curr 输出 curr->deleted 行为
1 {key="k3", val=42, deleted=1} true 执行 curr = curr->next
2 {key="k5", val=99, deleted=0} false 返回该节点
graph TD
    A[enter next()] --> B{curr exists?}
    B -->|no| C[return NULL]
    B -->|yes| D{curr->deleted?}
    D -->|true| E[curr = curr->next]
    E --> D
    D -->|false| F[return curr]

2.5 Go 1.22 mapiternext随机化增强:对比1.21与1.22 runtime/map.go中hashShift与seed传播差异

Go 1.22 对 mapiternext 迭代器引入更强随机性,核心在于 hashShift 初始化时机与 h.seed 传播路径的重构。

seed 传播路径变化

  • Go 1.21h.seed 仅在 makemap 时写入,迭代器复用哈希表结构体但不重校验 seed 有效性
  • Go 1.22mapiterinit 显式调用 memhash0(&h.seed, ...),确保每次迭代起始均绑定当前 h.seed

hashShift 计算差异

// Go 1.22 runtime/map.go(节选)
func mapiterinit(t *maptype, h *hmap, it *hiter) {
    // ...
    it.B = h.B
    it.seed = h.seed // 直接继承,不再掩码扰动
    // ...
}

it.seed 不再经 ^uintptr(unsafe.Pointer(&it)) 混淆,避免低熵地址导致的可预测性;hashShifth.B 决定,而 h.Bmakemap 后恒定,但 it.seed 的纯净传递使 alg.iterate 调用具备更高熵基底。

版本 seed 传递方式 hashShift 依赖源 迭代序列可预测性
1.21 地址异或扰动 h.B + 隐式偏移 中等(易被时序攻击)
1.22 原值直传 + memhash 校验 纯 h.B 显著降低
graph TD
    A[mapiterinit] --> B{Go 1.21}
    A --> C{Go 1.22}
    B --> D[seed ^= addr]
    C --> E[it.seed = h.seed]
    E --> F[memhash0(&it.seed)]

第三章:delete()触发迭代顺序变异的关键场景实证

3.1 高频删除+连续迭代下的bucket重散列时机捕捉:perf trace + pprof CPU profile交叉验证

在哈希表高频删除与遍历交织场景下,bucket 重散列(rehash)并非仅由负载因子触发,更常由 delete → next iterator → resize check 链式条件隐式激活。

perf trace 捕获关键事件

# 捕获内核级哈希表 resize 动作(以 Linux kernel’s rhashtable 为例)
perf trace -e 'rhashtable:*' -s --filter 'action == "rehash_start"'

该命令精准捕获 rehash_start 事件点,避免采样偏差;--filter 确保仅聚焦重散列起始瞬间,为时间锚点。

pprof 与 perf 时间对齐验证

工具 触发信号 时间精度 关联维度
perf trace 内核 tracepoint ~ns 事件语义明确
pprof 用户态 CPU 样本 ~ms 调用栈上下文完整

重散列决策逻辑示意

func (h *HashTable) maybeRehash() {
    if h.deletesSinceLastRehash > h.buckets/4 && // 删除量阈值
       h.iterating &&                            // 当前正被迭代
       h.loadFactor() < 0.3 {                    // 低负载但需收缩
        h.startRehash() // ← 此处即 perf trace 捕获点
    }
}

该逻辑揭示:非负载驱动,而是“删除扰动+迭代敏感”双条件触发,解释为何传统监控易漏判。

graph TD A[高频Delete] –> B{deletesSinceLastRehash > threshold?} B –>|Yes| C[检查是否iterating] C –>|Yes| D[检查loadFactor |Yes| E[startRehash → perf trace hit]

3.2 边界case复现:单bucket满载后delete首个键导致nextBucket偏移突变的单元测试设计

核心复现逻辑

需构造恰好填满单个 bucket(如容量为4)的键值对,再删除索引0处的键,触发 nextBucket 指针从 0x00 跳变为 0xFF 的异常偏移。

测试用例关键步骤

  • 初始化哈希表,设置 bucket 容量 = 4,启用线性探测
  • 插入 4 个键:k0, k1, k2, k3 → 均落入同一 bucket
  • 执行 delete(k0) → 触发 rehash 阶段的 nextBucket 计算逻辑缺陷
def test_next_bucket_offset_jump():
    ht = HashTable(bucket_size=4)
    for i in range(4):  # 填满 bucket
        ht.insert(f"k{i}", i)
    ht.delete("k0")  # 删除首个键
    assert ht.buckets[0].next_bucket == 0xFF  # 断言偏移突变

逻辑分析delete("k0") 后,原 bucket 的 first_valid_idx 变为 1,但 next_bucket 未按探测链尾部重定位,而是错误继承了初始化值 0xFF。参数 bucket_size=4 精确控制探测范围,0xFF 是溢出哨兵值,暴露指针更新缺失。

场景 next_bucket 值 是否符合预期
插入4键后 0x00
删除 k0 后 0xFF ❌(应为 0x01)
修复后 delete(k0) 0x01

3.3 并发map读写与delete交织下的迭代崩溃模式:利用-gcflags=”-d=checkptr”定位非法指针访问

Go 中 map 非并发安全,读/写/delete 在无同步下交织极易触发迭代器 panic(如 fatal error: concurrent map iteration and map write)或更隐蔽的内存越界。

数据同步机制

  • sync.Map 适用于读多写少场景,但不支持遍历一致性保证;
  • 常规方案:sync.RWMutex + 普通 map,但易因锁粒度疏漏引入竞态。

复现崩溃的最小代码

m := make(map[int]int)
go func() { for range m { } }() // 迭代
go func() { delete(m, 1) }()     // 删除
time.Sleep(time.Millisecond)

此代码在 -gcflags="-d=checkptr" 下会立即报 invalid pointer alignmentinvalid pointer dereference,暴露底层哈希桶指针被并发修改导致的非法访问。

检测效果对比表

标志位 是否捕获非法指针 是否影响性能 是否需重新编译
默认
-d=checkptr 是(运行时检查) 显著下降
graph TD
    A[goroutine A: range m] --> B[读取 bucket 指针]
    C[goroutine B: delete] --> D[释放并置空 bucket]
    B --> E[解引用已释放指针] --> F[crash with checkptr]

第四章:工程化应对策略与稳定性加固方案

4.1 迭代前快照构造:sync.Map替代方案与immutable map构建的性能损耗量化分析

数据同步机制

sync.Map 在高并发读多写少场景下表现优异,但其 Range() 遍历不保证原子性——迭代期间写入可能被跳过或重复。为支持一致快照迭代,需在迭代前构造不可变副本。

immutable map 构建开销

以下代码演示基于 map[interface{}]interface{} 的浅拷贝构造:

func snapshot(m *sync.Map) map[interface{}]interface{} {
    s := make(map[interface{}]interface{})
    m.Range(func(k, v interface{}) {
        s[k] = v // 无锁复制,但触发内存分配与指针复制
    })
    return s
}

逻辑分析:Range 内部加读锁逐对遍历;s[k] = v 对值类型零拷贝,但对 *struct 等指针类型仅复制地址,不深拷贝数据make(map[…]) 分配哈希桶,平均扩容 1.3×,带来额外 GC 压力。

性能对比(10w 键值对,Go 1.22)

方案 构造耗时(ns/op) 内存分配(B/op) 分配次数
sync.Map.Range 0 0
浅拷贝 map 82,400 1,250,000 2

注:浅拷贝虽牺牲一致性保障换取确定性快照,但内存开销随数据规模线性增长。

架构权衡

graph TD
    A[并发写入] --> B{是否要求迭代强一致性?}
    B -->|是| C[采用 immutable snapshot]
    B -->|否| D[直接 Range + 应用层容错]
    C --> E[权衡:延迟 vs 内存 vs GC频率]

4.2 删除标记法(tombstone)实践:自定义map wrapper在GC友好性与迭代确定性间的权衡

删除标记法通过保留已删键的占位符(tombstone),避免真实移除元素,从而规避哈希表重散列或切片缩容引发的GC压力与迭代器失效问题。

数据同步机制

自定义 TombstoneMap 封装 map[interface{}]valueWrapper,其中 valueWrapper 包含 data interface{}deleted bool 字段:

type valueWrapper struct {
    data    interface{}
    deleted bool // true 表示逻辑删除,不参与迭代
}

该设计使 Delete(k) 仅置 deleted = true,延迟物理清理;Iterate() 跳过所有 deleted == true 条目,保障迭代顺序稳定。

权衡对比

维度 物理删除 Tombstone 标记
GC 压力 高(频繁 alloc/free) 低(复用结构体)
迭代确定性 弱(扩容触发重排) 强(顺序恒定)
内存常驻开销 中(需存储 tombstone)
graph TD
    A[Delete key] --> B{是否启用tombstone?}
    B -->|是| C[标记deleted=true]
    B -->|否| D[map delete + GC 触发]
    C --> E[Iterate时跳过]
    D --> F[可能触发map resize]

4.3 编译期与运行期检测工具链集成:go vet扩展规则识别危险迭代模式,以及gotestsum+stress测试覆盖验证

危险迭代模式的静态识别

go vet 默认不检查 for range 中对切片/映射的并发修改,但可通过自定义分析器捕获:

// analyzer.go — 自定义 go vet 规则片段
func (a *analyzer) Visit(n ast.Node) ast.Visitor {
    if forRange, ok := n.(*ast.RangeStmt); ok {
        // 检测 range 右值是否为局部 map/slice 且循环体内存在写操作
        if isMutableMapOrSlice(forRange.X) && hasWriteInBody(forRange.Body) {
            a.Pass.Reportf(forRange.Pos(), "dangerous iteration: concurrent read/write on %v", forRange.X)
        }
    }
    return a
}

该分析器在 go vet -vettool=./analyzer 下生效,参数 forRange.X 表示被遍历对象,hasWriteInBody 递归扫描赋值、delete、append 等写操作节点。

运行期压力验证闭环

使用 gotestsum 统一聚合结果,配合 stress 工具触发竞态:

工具 作用
gotestsum 并行执行测试、输出结构化 JSON
stress -p 4 启动 4 个 goroutine 重复运行
gotestsum -- -race | stress -p 4 -m "TestDangerousRange"

验证流程

graph TD
A[go vet 扫描源码] –> B[报告潜在迭代风险]
B –> C[编写针对性单元测试]
C –> D[gotestsum + stress 多轮压测]
D –> E[确认 panic/竞态是否复现]

4.4 生产环境map行为可观测性增强:基于eBPF注入runtime.mapdelete探针捕获删除上下文与迭代栈帧关联

传统 bpf_trace_printk 仅记录 map_delete_elem 系统调用入口,丢失 Go runtime 层级的 map 删除语义(如 delete(m, k))及调用链上下文。

探针注入点选择

  • 目标函数:runtime.mapdelete_fast64(及其他 mapdelete_* 变体)
  • 注入方式:uprobe + uretprobe 组合,覆盖删除前参数与返回后栈帧

核心 eBPF 代码片段(C 风格伪代码)

SEC("uprobe/runtime.mapdelete_fast64")
int trace_map_delete(struct pt_regs *ctx) {
    u64 key = bpf_probe_read_kernel(&key, sizeof(key), (void *)PT_REGS_PARM2(ctx));
    u64 map_ptr = PT_REGS_PARM1(ctx);
    struct delete_event event = {};
    event.timestamp = bpf_ktime_get_ns();
    event.map_ptr = map_ptr;
    event.key_hash = jhash_64(&key, sizeof(key), 0);
    bpf_get_stack(ctx, event.stack, sizeof(event.stack), 0); // 捕获 16 帧内核栈
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
    return 0;
}

逻辑分析PT_REGS_PARM2(ctx) 提取 key 参数地址(非值),需二次 bpf_probe_read_kernel 安全读取;bpf_get_stack 启用 BPF_F_USER_STACK 可选标志以同时采集用户栈,但此处禁用以降低开销,专注 runtime 栈帧定位。jhash_64 用于轻量键指纹,规避原始 key 内存不可达问题。

关联能力对比表

能力维度 传统 sys_enter/delete_elem eBPF uprobe mapdelete
Go 源码行号映射 ✅(通过 DWARF + 用户栈回溯)
迭代中删除检测 ✅(栈帧含 runtime.mapiternext 调用链)
键类型/长度推断 ❌(仅字节流) ✅(结合 Go 类型系统符号解析)

数据同步机制

  • Perf buffer → ringbuf → 用户态守护进程(mapwatchd
  • 事件携带 goid(从 runtime.g 结构偏移提取),实现 goroutine 粒度聚合
graph TD
    A[uprobe: mapdelete_fast64] --> B[读取 key 地址 & map 指针]
    B --> C[采样内核栈帧]
    C --> D[perf_event_output]
    D --> E[ringbuf 缓冲]
    E --> F[mapwatchd 解析 DWARF + 符号表]
    F --> G[关联源码位置与迭代状态]

第五章:总结与展望

实战项目复盘:某金融风控平台的模型迭代路径

在2023年Q3上线的实时反欺诈系统中,团队将LSTM+Attention融合模型部署至Kubernetes集群,日均处理交易请求1270万次。通过Prometheus+Grafana监控发现,模型推理P99延迟从初始482ms优化至196ms,关键改进包括:TensorRT量化(INT8精度)、ONNX Runtime动态批处理(batch_size=32)、GPU显存预分配策略。下表对比了三代模型在生产环境的关键指标:

模型版本 推理吞吐量(TPS) 内存占用(GB) AUC-ROC 部署镜像大小
v1.0(PyTorch原生) 842 4.7 0.921 2.1 GB
v2.3(ONNX+TRT) 2156 2.3 0.928 840 MB
v3.1(动态量化+缓存) 3410 1.8 0.932 610 MB

边缘AI落地挑战与应对方案

某智能仓储机器人项目在ARM64边缘设备上部署YOLOv8n时遭遇内存溢出,经分析发现OpenCV DNN模块默认启用CPU多线程导致资源争抢。解决方案采用分阶段裁剪:

  • 移除非必要后处理层(NMS阈值从0.45提升至0.6,减少候选框数量37%)
  • 使用TFLite Micro替换完整推理框架,内存峰值从1.2GB降至312MB
  • 通过Linux cgroups限制容器内存上限为512MB并启用swapiness=10
# 生产环境内存隔离脚本片段
echo "512000000" > /sys/fs/cgroup/memory/ai-inference/memory.limit_in_bytes
echo "10" > /sys/fs/cgroup/memory/ai-inference/memory.swappiness

大模型轻量化工程实践

在客服对话系统升级中,将Qwen-7B蒸馏为3B参数模型,但发现LoRA微调后KV Cache仍超限。最终采用混合精度+分块缓存策略:

  • 前6层使用FP16,后12层切换为BF16
  • KV Cache按sequence length分片存储,单片最大长度设为512
  • 引入Ring Buffer机制复用历史缓存,使长会话内存占用下降63%

技术演进路线图

未来12个月重点推进以下方向:

  • 构建统一模型注册中心,支持ONNX/TFLite/PTX多格式元数据自动提取
  • 在K8s集群中试点eBPF加速模型热更新,目标实现
  • 开发基于CUDA Graph的异步推理流水线,解决GPU kernel launch开销问题
graph LR
A[原始模型] --> B{量化策略选择}
B -->|高精度场景| C[FP16+动态范围校准]
B -->|边缘设备| D[INT4+AWQ权重压缩]
C --> E[生成ONNX模型]
D --> E
E --> F[TRT引擎编译]
F --> G[CI/CD流水线注入]
G --> H[灰度发布验证]
H --> I[全量滚动更新]

开源工具链整合成效

将MLflow、DVC与Argo Workflows深度集成后,某电商推荐系统A/B测试周期从平均14天缩短至3.2天。关键改造包括:

  • DVC远程存储对接MinIO,模型版本回滚耗时从27分钟降至48秒
  • Argo Workflow自动触发MLflow实验跟踪,记录每次训练的GPU利用率、显存带宽等硬件指标
  • 通过自定义Operator实现模型签名自动校验,拦截3次因Tensor shape不匹配导致的线上事故

跨云部署一致性保障

在阿里云ACK与AWS EKS双集群运行同一模型服务时,通过HashiCorp Nomad封装模型容器,利用Consul服务网格统一配置gRPC超时策略。实测显示跨云调用成功率从92.7%提升至99.98%,核心措施包括:

  • 自动注入Envoy Sidecar并强制启用HTTP/2 ALPN协商
  • 基于Prometheus指标动态调整重试策略(错误码503时启用指数退避)
  • 使用SPIFFE身份证书替代传统TLS双向认证,证书轮换时间从小时级压缩至12秒

技术演进不是终点而是持续重构的起点。

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

发表回复

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