Posted in

Go调度器深度解剖,彻底搞懂GMP模型、抢占式调度与GC协同机制

第一章:Go调度器的核心演进与设计哲学

Go 调度器(Goroutine Scheduler)并非静态组件,而是随 Go 语言版本持续演进的精密系统。其设计始终锚定三大哲学内核:轻量并发的可伸缩性用户态调度的确定性开销与操作系统协同而非对抗的务实主义。从早期的 G-M 模型(Goroutine–Machine),到引入 P(Processor)实现工作窃取(Work-Stealing)的 G-M-P 模型,再到 Go 1.14 引入异步抢占式调度,每一次迭代都直面真实世界的性能瓶颈。

调度模型的关键跃迁

  • G-M 阶段(Go 1.0–1.1):Goroutine 直接绑定 OS 线程(M),缺乏本地运行队列,频繁系统调用导致调度抖动;
  • G-M-P 阶段(Go 1.2+):P 作为逻辑处理器抽象,持有本地 Goroutine 队列与运行时资源(如内存分配器缓存),M 必须绑定 P 才能执行 G,实现负载均衡与缓存局部性;
  • 抢占式调度(Go 1.14+):通过向线程发送 SIGURG 信号触发异步抢占点,解决长时间运行的 goroutine(如密集循环)阻塞调度的问题。

观察当前调度行为

使用 GODEBUG=schedtrace=1000 可实时输出调度器追踪日志,每秒打印一次全局状态:

GODEBUG=schedtrace=1000 ./your-program
输出中关键字段含义: 字段 含义
SCHED 调度器统计快照时间戳
GOMAXPROCS 当前 P 的数量
Goroutines 活跃 goroutine 总数
runqueue 全局运行队列长度
pN runqueue 第 N 个 P 的本地运行队列长度

设计哲学的实践体现

当 goroutine 进行系统调用时,Go 运行时不销毁 M,而是将其与 P 解绑,让其他 M 接管该 P 继续执行就绪的 G——这避免了线程创建/销毁开销,同时保障 I/O 密集型场景的吞吐。这种“M 复用 + P 持有资源”的分离,正是“协同而非对抗”哲学的具象化:OS 负责线程调度,Go 运行时专注协程生命周期管理。

第二章:GMP模型的底层实现与运行时剖析

2.1 G(goroutine)的创建、状态迁移与栈管理机制

Go 运行时通过 newproc 函数启动 goroutine,底层调用 newg 分配 g 结构体并初始化其栈、状态与调度上下文。

创建流程关键步骤

  • 调用 go f(x) → 编译器生成 runtime.newproc(size, fn, args...)
  • newproc 将函数指针、参数及栈大小压入当前 g 的栈,再调用 newproc1
  • newproc1 分配新 g,设置 g.sched.pc = fn, g.sched.sp = top_of_stack + stack_size
// runtime/proc.go 简化示意
func newproc(fn *funcval, args ...interface{}) {
    size := uintptr(0)
    // 计算参数大小,对齐
    systemstack(func() {
        newproc1(fn, uintptr(unsafe.Pointer(&args[0])), size)
    })
}

fn 是闭包封装后的 *funcvalsize 包含参数+返回值总字节数;systemstack 确保在系统栈执行,避免用户栈溢出。

状态迁移图谱

graph TD
    A[Runnable] -->|schedule| B[Running]
    B -->|syscall| C[Syscall]
    B -->|gc stop| D[Gcstop]
    C -->|return| A
    D -->|resume| A

栈管理特性

特性 说明
初始大小 2KB(Go 1.19+),按需动态增长
栈上限 默认 1GB(可通过 GOMEMLIMIT 影响)
复制式扩容 溢出时分配新栈,拷贝旧数据并更新 g.stack

2.2 M(OS thread)的绑定、复用与系统调用阻塞处理

Go 运行时通过 M(Machine)抽象 OS 线程,实现与 P(Processor)的动态绑定与解绑,以应对系统调用阻塞场景。

阻塞系统调用的 M 脱离机制

当 Goroutine 执行阻塞系统调用(如 read()accept())时,运行时自动将当前 MP 解绑,并标记为 Msyscall 状态,允许其他 M 接管该 P 继续调度其他 G。

// runtime/proc.go 中关键逻辑节选
func entersyscall() {
  mp := getg().m
  mp.mpreemptoff = "entersyscall"
  mp.oldp = mp.p // 保存当前 P
  mp.p = 0       // 解绑 P
  mp.spinning = false
  mp.blocked = true
}

mp.oldp 缓存原 P 指针;mp.p = 0 触发调度器切换;mp.blocked = true 通知调度器该 M 不可再用于 G 执行。

M 的复用策略

  • 复用空闲 M:阻塞返回后,优先尝试复用原 M(若未被回收)
  • 创建新 M:空闲 M 不足时,按需创建(上限受 GOMAXPROCS 与负载影响)
  • 回收超时 M:空闲超 10 分钟的 M 自动退出线程
状态 是否持有 P 是否可调度 G 典型触发场景
_Mrunning 正常执行用户代码
_Msyscall 阻塞系统调用中
_Mspin 否(自旋中) 尝试获取空闲 P
graph TD
  A[Go routine 执行 syscall] --> B{是否阻塞?}
  B -->|是| C[entersyscall: 解绑 P, 标记 blocked]
  B -->|否| D[继续执行]
  C --> E[OS 线程挂起]
  E --> F[其他 M 接管 P 调度剩余 G]
  F --> G[syscall 返回]
  G --> H[exitsyscall: 尝试重绑原 P 或转入自旋]

2.3 P(processor)的资源隔离、本地队列与工作窃取实践

Go 运行时通过 P(Processor)抽象绑定 OS 线程(M),实现 GMP 调度模型的核心资源隔离单元。

本地运行队列:高效无锁调度

每个 P 持有独立的 runq(环形数组,长度 256),用于存放待执行的 goroutine:

// src/runtime/proc.go
type p struct {
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr // 无锁环形队列,CAS 更新 head/tail
}

runqheadrunqtail 使用原子操作维护;入队写 runqtail,出队读 runqhead,避免锁竞争。容量固定保障 O(1) 操作,溢出时转入全局队列。

工作窃取:动态负载均衡

当本地队列为空,P 尝试从其他 P 尾部“偷”一半 goroutine:

行为 策略
窃取时机 findrunnable() 中触发
窃取量 len/2(向下取整)
目标选择 随机轮询其他 P(避免热点)
graph TD
    A[P1: runq empty] --> B{Steal from P2?}
    B -->|Yes| C[P2.runq[0:len/2] → P1.runq]
    B -->|No| D[Check P3...]

关键参数影响

  • GOMAXPROCS 决定 P 总数,直接影响并行度与窃取频率;
  • 本地队列过小→频繁窃取开销;过大→goroutine 调度延迟升高。

2.4 全局运行队列与P本地队列的协同调度策略验证

Go 运行时采用 GMP 模型,其中全局运行队列(global runq)与每个 P 的本地运行队列(runq)通过“窃取(work-stealing)”机制动态平衡负载。

数据同步机制

P 在本地队列空时,按固定概率(默认 1/61)尝试从全局队列或其它 P 窃取 G:

// src/runtime/proc.go:findrunnable()
if gp, _ := runqget(_p_); gp != nil {
    return gp
}
if n > 0 && sched.runqsize != 0 {
    // 尝试从全局队列获取
    gp := globrunqget(&_p_, n/2) // 取一半,避免饥饿
    if gp != nil {
        return gp
    }
}

globrunqget(p, max) 从全局队列批量摘取最多 max 个 G,并原子更新 sched.runqsize,确保并发安全。

调度决策优先级

  • ✅ 优先消费本地队列(零开销、缓存友好)
  • ✅ 其次尝试窃取其他 P(降低锁竞争)
  • ⚠️ 最后回退至全局队列(需 sched.lock 保护)
来源 平均延迟 锁开销 触发条件
P本地队列 ~0 ns runq.len > 0
其他P窃取 ~50 ns runq.len == 0 && rand() < stealProb
全局队列 ~200 ns 前两者均失败
graph TD
    A[当前P本地队列非空] --> B[直接执行]
    A --> C[为空?]
    C --> D{随机触发窃取?}
    D -->|是| E[扫描其它P本地队列]
    D -->|否| F[尝试全局队列]
    E --> G[成功则返回G]
    F --> H[加锁取globrunq]

2.5 GMP三元组生命周期跟踪:从runtime.newproc到goexit的完整链路实测

GMP三元组(Goroutine、M、P)的创建与销毁并非原子事件,而是贯穿调度器核心路径的协同过程。

创建起点:runtime.newproc

// src/runtime/proc.go
func newproc(fn *funcval) {
    _g_ := getg()                    // 获取当前G
    _g_.m.p.ptr().mcache.alloc[0]     // 触发P绑定与M缓存初始化
    newg := gfget(_g_.m.p.ptr())      // 从P本地gfree队列获取G结构体
    casgstatus(newg, _Gidle, _Grunnable)
}

newproc不直接启动G,仅将其置为 _Grunnable 状态并入P的运行队列;此时G尚未绑定M,P也未被M独占。

调度跃迁:schedule()execute()

graph TD
    A[newg入runq] --> B[findrunnable返回G]
    B --> C[execute将G与M/P绑定]
    C --> D[调用fn,进入用户代码]
    D --> E[函数返回后执行goexit]

终止归宿:goexit 清理链

阶段 关键操作 影响对象
用户函数返回 自动插入 runtime.goexit 调用点 当前G
goexit1 将G状态设为 _Gdead,归还至 gfput G结构体内存池
handoffp 若P无其他G,则解绑M,触发 park_m M与P关系

G死亡后,其栈内存由 stackfree 异步回收,而P可能被其他M acquirep 复用。

第三章:抢占式调度的触发条件与精确控制

3.1 基于系统调用/网络I/O/定时器的协作式抢占实践分析

协作式抢占并非强制中断,而是让任务在可预见的阻塞点(如 read()epoll_wait()timerfd_settime())主动让出控制权,为调度器提供安全插入时机。

关键抢占触发点

  • 系统调用:recvfrom() 返回 EAGAIN 时隐式让渡
  • 网络I/O:epoll_wait() 超时返回即为调度窗口
  • 定时器:timerfd 可读事件天然构成时间片边界

epoll + timerfd 协同示例

// 创建可被抢占的事件循环入口
int epfd = epoll_create1(0);
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
struct itimerspec ts = {.it_value = {.tv_sec = 0, .tv_nsec = 10000000}}; // 10ms
timerfd_settime(tfd, 0, &ts, NULL);

struct epoll_event ev = {.events = EPOLLIN, .data.fd = tfd};
epoll_ctl(epfd, EPOLL_CTL_ADD, tfd, &ev);
// 此处阻塞点即为协作式抢占锚点
int n = epoll_wait(epfd, events, MAX_EVENTS, -1); // 返回即触发调度决策

epoll_wait() 在超时或事件就绪时返回,内核已更新就绪队列,用户态可立即执行上下文切换逻辑;tfd 的加入确保即使无网络事件,每10ms仍获得一次调度机会。

抢占能力对比表

触发源 最小延迟 可预测性 内核态介入深度
read() 高(依赖数据到达)
epoll_wait() 中(毫秒级) 低(仅轮询就绪链表)
timerfd ±1ms 低(仅检查计时器到期)
graph TD
    A[任务运行] --> B{是否到达I/O/定时器边界?}
    B -->|是| C[保存寄存器上下文]
    B -->|否| A
    C --> D[调用调度器选择新任务]
    D --> E[恢复目标任务上下文]
    E --> A

3.2 基于信号(SIGURG)与异步抢占点(asyncPreempt)的硬抢占机制解构

Go 运行时通过 SIGURG 信号触发用户态异步抢占,绕过系统调用等待点,实现对长时间运行 Goroutine 的精确中断。

抢占触发路径

  • 运行时向目标 M 发送 SIGURG(非实时信号,内核保证递达)
  • 信号 handler 中设置 g.preempt = true 并唤醒 sysmon 协程
  • 下一个 asyncPreempt 汇编桩点(如函数入口、循环回边)检查 g.preempt 并跳转至 preemptPark

asyncPreempt 汇编桩示例

TEXT asyncPreempt(SB), NOSPLIT|NOFRAME, $0-0
    MOVQ g_preempt_addr(GS), AX   // 加载 g.preempt 地址
    CMPB $1, (AX)                 // 检查是否被标记抢占
    JNE  asyncPreempt2
    CALL runtime·preemptPark(SB)  // 主动让出 P,进入调度循环

逻辑分析:该桩点无栈操作、零副作用,由编译器自动插入关键位置;g_preempt_addr(GS) 通过 GS 段寄存器快速定位当前 G 的抢占标志字段,确保低开销。

SIGURG 与抢占点协同关系

维度 SIGURG 信号 asyncPreempt 桩点
触发时机 内核级异步通知 用户态可控的汇编检查点
响应延迟 ≤ 10ms(依赖 sysmon 周期) 纳秒级(紧邻下一条指令)
可靠性保障 信号队列+重试机制 编译器全量插桩+逃逸分析校验
graph TD
    A[sysmon 检测超时 G] --> B[向目标 M 发送 SIGURG]
    B --> C[信号 handler 设置 g.preempt=true]
    C --> D[执行流抵达 asyncPreempt 桩]
    D --> E{g.preempt == true?}
    E -->|是| F[跳转 preemptPark,触发调度]
    E -->|否| G[继续执行]

3.3 抢占敏感代码识别与goroutine长时间运行的主动让渡实验

Go 运行时依赖协作式抢占,但循环密集型 goroutine 可能阻塞调度器。识别抢占敏感点是保障系统响应性的关键。

抢占敏感模式识别

常见敏感场景包括:

  • 无函数调用的纯计算循环(如 for { i++ }
  • 长时间阻塞在非可抢占系统调用(如 syscall.Syscall 未触发 GC 检查点)
  • runtime.LockOSThread() 后未及时释放

主动让渡实践

func cpuBoundWork() {
    for i := 0; i < 1e9; i++ {
        if i%10000 == 0 {
            runtime.Gosched() // 主动让出 CPU,触发调度器检查
        }
        // ... 计算逻辑
    }
}

runtime.Gosched() 显式触发当前 goroutine 让渡,不阻塞、不挂起,仅将 M 交还 P 并尝试唤醒其他 goroutine。每万次迭代插入一次,平衡吞吐与公平性。

让渡策略 触发条件 调度开销 适用场景
runtime.Gosched 手动插入 极低 确定性长循环
runtime.UnlockOSThread 线程绑定结束 CGO 交互后恢复调度
time.Sleep(0) 零时延休眠 较高 兼容性兜底(不推荐)
graph TD
    A[进入长循环] --> B{是否达到让渡阈值?}
    B -->|是| C[runtime.Gosched]
    B -->|否| D[继续计算]
    C --> E[调度器重新分配P]
    E --> F[其他goroutine获得执行机会]

第四章:GC与调度器的深度协同机制

4.1 STW阶段对M/P/G状态的冻结与恢复流程实测

在GC触发的STW期间,运行时需原子性冻结所有M(OS线程)、P(处理器)和G(goroutine)的状态,确保堆一致性。

冻结关键操作

  • 遍历所有P,调用 stopTheWorldWithSema() 进入安全点;
  • 每个M通过 park_m() 暂停执行,等待gcstopm信号;
  • G队列被切换至g0栈并标记为_Gwaiting

状态同步机制

// runtime/proc.go 片段(简化)
func stopTheWorldWithSema() {
    atomic.Store(&sched.gcwaiting, 1) // 全局冻结标志
    for _, p := range allp {
        if p != nil && p.status == _Prunning {
            p.status = _Pgcstop // 原子状态变更
        }
    }
}

sched.gcwaiting 是全局可见的内存屏障变量,强制编译器与CPU禁止重排序;_Pgcstop 表示该P已退出调度循环,不再窃取或执行G。

恢复流程时序

阶段 M状态 P状态 G可调度性
STW开始 _Mgcstop _Pgcstop 全部暂停
GC完成 _Mrunning _Prunning runqhead 重建
graph TD
    A[STW触发] --> B[atomic.Store &gcwaiting=1]
    B --> C[各P自旋检查gcstop]
    C --> D[M主动park并切换到g0]
    D --> E[GC标记/清扫]
    E --> F[atomic.Store &gcwaiting=0]
    F --> G[P恢复运行,G重新入runq]

4.2 GC标记阶段的并发扫描与调度器让权策略(assistGC)

Go 运行时在标记阶段采用 并发三色标记法,为避免 STW 过长,需工作线程主动参与标记(assistGC)。

协作式标记触发条件

当当前 P 的本地标记队列耗尽,且全局标记工作未完成时,调度器会插入 assistGC 协作标记:

// src/runtime/mgc.go: assistGCMark()
if gcBlackenEnabled != 0 && work.nproc > 0 {
    // 按当前分配速率反推需协助标记的对象字节数
    assistBytes := int64(atomic.Load64(&gcController.assistBytesPerUnit)) * 
                   (mheap_.allocBytes - gcController.lastAllocBytes)
    if assistBytes > 0 {
        blackenWorker(assistBytes) // 扫描并标记可达对象
    }
}

assistBytes 表示需补偿的标记工作量,单位为字节;blackenWorker 以增量方式扫描栈与堆对象,每处理约 32KB 暂停一次,主动让出 P 给其他 Goroutine。

调度器让权机制

  • 每次扫描约 1000 个对象或 32KB 后调用 gopark()
  • 通过 preemptMSpan 标记待扫描 span,避免重复工作
策略 触发时机 让权方式
assistGC 分配速率超阈值 主动 park 当前 G
backgroundGC 全局标记进度滞后 降低后台 G 优先级
graph TD
    A[分配内存] --> B{是否触发 assistGC?}
    B -->|是| C[计算 assistBytes]
    C --> D[blackenWorker 扫描]
    D --> E{扫描量 ≥ 32KB?}
    E -->|是| F[gopark,释放 P]
    E -->|否| G[继续标记]

4.3 写屏障启用期间的G调度延迟观测与性能影响建模

写屏障(Write Barrier)在Go运行时中触发额外的内存同步操作,直接影响Goroutine调度器(Sched)对G的抢占与切换时机。

数据同步机制

当写屏障激活时,gcWriteBarrier会插入store指令前的屏障序列,强制刷新CPU缓存行并更新GC标记位:

// runtime/writebarrier.go 伪代码示意
func gcWriteBarrier(dst *uintptr, src uintptr) {
    if writeBarrier.enabled { // 仅在GC标记阶段启用
        atomic.Or64((*int64)(unsafe.Pointer(&dst)), 1<<63) // 标记为"已写入"
        *dst = src // 实际写入
    }
}

该函数引入约8–12ns固定开销(基于AMD EPYC 7763实测),且在高写入密度场景下加剧LLC争用,导致G被延迟调度。

延迟建模关键参数

参数 含义 典型值
δ_wb 单次写屏障延迟 9.2 ns
λ_g G单位时间写操作频次 0.3–4.1×10⁶/s
ρ_sch 调度器响应衰减系数 0.87(屏障启用时)

性能影响路径

graph TD
    A[G执行写操作] --> B{写屏障启用?}
    B -->|是| C[插入原子标记+缓存同步]
    C --> D[LLC miss率↑ → P调度延迟↑]
    D --> E[G切换延迟Δt ≈ δ_wb × λ_g × 1.3]
    B -->|否| F[直写,无额外延迟]

4.4 GC触发阈值、后台标记线程与P空闲周期的动态耦合分析

Go 运行时中,GC 启动并非仅由堆大小决定,而是三者实时博弈的结果:

  • GOGC 基准阈值(默认100)仅设初始触发条件
  • 后台标记线程(markworker)的活跃度受 P 的 idleTime 动态抑制
  • 每个 P 在空闲超 forcegcperiod = 2min 或检测到 sweepdone == false 时主动唤醒 GC
// src/runtime/mgc.go: gcTrigger
func (t gcTrigger) test() bool {
    return t.kind == gcTriggerHeap && memstats.heap_live >= memstats.heap_marked*(1+uint64(gcpercent))/100 ||
           t.kind == gcTriggerTime && t.now-t.last_gc > forcegcperiod
}

该逻辑表明:时间触发(gcTriggerTime)与堆增长触发(gcTriggerHeap)是或关系heap_marked 为上一轮标记结束时的基准,gcpercent 可运行时调优。

关键耦合参数表

参数 默认值 作用
GOGC 100 控制堆增长倍率触发阈值
forcegcperiod 2min P 空闲超时后强制检查 GC 必要性
gcBackgroundPercent 25% CPU 限制后台标记线程占用 P 的调度配额
graph TD
    A[P空闲检测] -->|idleTime ≥ 2min| B{是否需GC?}
    B -->|heap_live增长超标 ∨ sweep未完成| C[唤醒markworker]
    B -->|否| D[继续休眠]
    C --> E[标记阶段抢占P执行]

第五章:面向生产环境的调度器调优与可观测性建设

调度延迟根因定位实战:Kubernetes Scheduler Profiling 分析

在某金融核心交易系统升级至 Kubernetes 1.26 后,Pod 平均调度延迟从 82ms 飙升至 410ms。通过启用 --profiling=true 并结合 pprof 抓取 30 秒 CPU profile,发现 generic_scheduler.findNodesThatFit() 占用 67% CPU 时间,进一步定位到自定义 NodeAffinity 规则中存在未索引的 node-labels 字段匹配(如 env in (prod, staging, dev))。修复方案为将该 label 加入 scheduler.config.k8s.io/v1beta3nodeSelectorLabelBlacklist 白名单,并启用 PriorityClass 优先级预筛机制,调度延迟回落至 95ms ± 12ms。

Prometheus 指标体系分层建模

构建三级可观测指标体系,覆盖调度全链路:

层级 指标示例 数据源 采集频率
基础层 scheduler_pending_pods_total kube-scheduler /metrics 15s
行为层 scheduler_schedule_attempts_total{result="unschedulable"} kube-scheduler structured logs(JSON) 实时流式解析
业务层 order_service_pod_startup_seconds_bucket{phase="scheduled_to_running"} 自定义 exporter(埋点 Pod annotation startup-time 1m

调度器高可用配置陷阱与规避

某电商大促期间发生调度器单点故障,根源在于未启用 leader election 的 --leader-elect=true 参数,且 etcd 连接超时设置为默认 30s(实际网络抖动达 42s)。修复后配置如下:

# scheduler-config.yaml
apiVersion: kubescheduler.config.k8s.io/v1beta3
kind: KubeSchedulerConfiguration
leaderElection:
  leaderElect: true
  leaseDuration: 15s
  renewDeadline: 10s
  retryPeriod: 2s
clientConnection:
  burst: 200
  qps: 100
  timeout: 5s  # 显式缩短超时

调度决策可追溯性增强:Event + OpenTelemetry Trace 联动

在调度器启动参数中注入 OTel 环境变量:

--otel-exporter-otlp-endpoint=https://jaeger-collector:4317 \
--otel-service-name=kube-scheduler-prod \
--otel-resource-attrs=cluster=shanghai-prod,zone=cn-shanghai-b

当某次批量部署出现 12 个 Pod 卡在 Pending 状态时,通过 Trace ID 关联 SchedulingCycleStarted 事件与 Filter 阶段 span,发现 VolumeBinding 插件因 StorageClass csi-qcloudWaitForFirstConsumer 模式与 PVC 绑定超时(等待 PV 创建达 300s),最终通过改用 Immediate 绑定策略+预置 PV 池解决。

动态阈值告警规则设计

基于历史数据训练 LightGBM 模型预测 scheduler_e2e_scheduling_latency_microseconds_bucket 的 P99 值,生成动态阈值:

# 告警规则(Prometheus Rule)
- alert: HighSchedulingLatency
  expr: |
    histogram_quantile(0.99, sum by (le) (rate(scheduler_e2e_scheduling_latency_microseconds_bucket[1h]))) 
    > on() group_left() 
    (label_replace(
      avg_over_time(scheduler_dynamic_p99_latency{job="kube-scheduler"}[7d]) * 1.8,
      "job", "kube-scheduler", "", ""
    ))
  for: 5m
  labels:
    severity: critical

生产环境调度器资源配额基准测试

对 scheduler 容器进行 48 小时压力测试(模拟 2000 节点集群、每秒 8 个 Pod 创建请求),观测不同 resource limits 下表现:

CPU Limit Memory Limit P95 调度延迟 OOM Kill 次数 调度吞吐(Pod/s)
500m 1Gi 320ms 7 5.2
1500m 2Gi 87ms 0 9.8
2500m 3Gi 79ms 0 10.1

最终采用 1500m/2Gi 配置,在稳定性与成本间取得平衡。

在 Kubernetes 和微服务中成长,每天进步一点点。

发表回复

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