第一章:Go map并发不安全的本质与危害全景图
Go 语言中的 map 类型在设计上默认不支持并发读写,其底层实现为哈希表,增删改查操作涉及桶(bucket)分裂、迁移、指针重定向等非原子步骤。当多个 goroutine 同时对同一 map 执行写操作(如 m[key] = value 或 delete(m, key)),或“读+写”混合操作(如 if v, ok := m[k]; ok { ... } 后立即 m[k] = v+1),极易触发运行时 panic:fatal error: concurrent map writes 或 concurrent map read and map write。
这种不安全并非偶然异常,而是由内存模型与数据结构耦合导致的确定性竞态:map 的扩容过程需修改 h.buckets、h.oldbuckets、h.nevacuate 等多个字段,且无内置锁保护;同时,编译器无法对 map 操作插入内存屏障,导致 CPU 乱序执行可能使其他 goroutine 观察到中间不一致状态(如桶指针已更新但数据未迁移完成)。
常见危害包括:
- 程序崩溃(panic)——最直接表现,但仅在写冲突时触发;
- 数据静默丢失——读操作可能返回过期值或 nil,而无任何错误提示;
- 内存越界或非法指针解引用——极端情况下引发 segmentation fault;
- GC 异常行为——因 map 内部结构损坏,导致标记阶段访问无效地址。
验证并发不安全的最小可复现实例:
package main
import "sync"
func main() {
m := make(map[int]int)
var wg sync.WaitGroup
// 启动10个goroutine并发写入同一map
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 1000; j++ {
m[id*1000+j] = j // 非同步写入——触发panic概率极高
}
}(i)
}
wg.Wait()
}
运行该程序将大概率输出 fatal error: concurrent map writes。注意:即使仅并发读取(无写),Go 运行时也不保证安全——因读操作可能与后台扩容的写操作发生数据竞争,故所有共享 map 访问均需显式同步。
| 同步方案 | 适用场景 | 缺点 |
|---|---|---|
sync.RWMutex |
读多写少,需自定义封装 | 锁粒度粗,读写互斥 |
sync.Map |
键值生命周期长、读远多于写 | 不支持遍历、无 len() 方法 |
| 分片 map + 哈希分桶 | 高并发写、键空间可哈希分区 | 实现复杂,需权衡分片数 |
第二章:深入Kubernetes调度器源码剖析map panic现场
2.1 调度器核心循环中map读写竞态的代码定位与复现
关键路径定位
Kubernetes 调度器 ScheduleOne 循环中,schedulingQueue.AssignedPods(pod.Spec.NodeName) 频繁读取 assignedPods(map[string][]*v1.Pod),而 Add/Delete 方法在异步 Pod 事件处理中并发写入同一 map。
复现代码片段
// pkg/scheduler/internal/queue/scheduling_queue.go
func (q *PriorityQueue) AssignedPods(nodeName string) []*v1.Pod {
q.lock.RLock() // ❌ 错误:此处仅读锁,但底层 map 无并发安全保证
defer q.lock.RUnlock()
return q.assignedPods[nodeName] // 非线程安全 map 直接访问
}
逻辑分析:
q.assignedPods是原生 Gomap,即使加了RLock,其读操作仍可能触发 runtime panic(fatal error: concurrent map read and map write)。sync.RWMutex无法保护 map 内部结构,仅保护临界区执行顺序。
竞态触发条件
- 同一节点名下 Pod 快速增删(如滚动更新)
AssignedPods()调用与Add()在 goroutine 中交叉执行
| 场景 | 是否触发 panic | 原因 |
|---|---|---|
| 单 goroutine 访问 | 否 | 无并发 |
| 多 goroutine 读+写 | 是 | Go map runtime 检测到竞争 |
graph TD
A[调度循环调用 AssignedPods] --> B{q.assignedPods[nodeName]}
C[Informer 事件处理 Add] --> D[写入 q.assignedPods]
B -->|无同步屏障| D
2.2 runtime.throw(“concurrent map read and map write”)的汇编级触发路径
Go 运行时在检测到并发读写 map 时,会立即终止程序。该 panic 并非由 Go 源码显式调用 throw,而是由汇编层直接触发。
检测点位置
mapaccess1_fast64/mapassign_fast64等汇编函数中嵌入了checkmapbucket宏;- 当发现
h.flags&hashWriting != 0(写标志被置位)且当前为读操作时,跳转至throwWriteConflict标签。
触发流程(mermaid)
graph TD
A[mapaccess1_fast64] --> B{h.flags & hashWriting ?}
B -->|Yes| C[CALL runtime.throw]
B -->|No| D[继续读取]
C --> E["push string addr\nCALL runtime.throw"]
关键汇编片段(amd64)
// 在 mapaccess1_fast64 中节选
testb $8, (R8) // 检查 h.flags 的 hashWriting 位(bit 3)
jnz throwWriteConflict
...
throwWriteConflict:
lea runtime.throw(SB), R12
lea gostring<“concurrent map read and map write”>(SB), R13
call *(R12)
R8指向hmap结构首地址;$8对应hashWriting = 1<<3;lea加载字符串地址(只读数据段),确保throw接收正确参数;- 直接
call跳转,绕过 Go 调用约定,实现零延迟崩溃。
| 汇编指令 | 作用 |
|---|---|
testb $8, (R8) |
原子检查写标志位 |
lea ... R13 |
加载 panic 字符串地址(无 GC 扫描风险) |
call *(R12) |
强制进入运行时异常处理主干 |
2.3 pprof + delve联合调试:从goroutine dump反推map污染源头
当服务出现 fatal error: concurrent map writes 时,panic 堆栈常被 goroutine 调度掩盖。此时需结合运行时快照定位竞态源头。
获取 goroutine dump
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=2" > goroutines.txt
该请求返回所有 goroutine 的完整调用栈(含阻塞点与用户代码行号),debug=2 启用详细模式,暴露未阻塞的活跃协程。
定位可疑 map 操作
在 goroutines.txt 中搜索 mapassign 或 mapdelete 符号,筛选出多个 goroutine 同时处于 map 写操作调用链中的片段。
使用 delve 交叉验证
dlv attach $(pgrep myserver)
(dlv) goroutines -u # 列出用户代码 goroutine
(dlv) goroutine 42 frames 10 # 查看目标协程栈帧
-u 过滤系统协程,聚焦业务逻辑;frames 10 显示顶层 10 层调用,快速比对 map 写入路径是否共享同一 map 变量地址。
| goroutine ID | 状态 | 关键调用位置 | 是否持有锁 |
|---|---|---|---|
| 42 | running | user.go:127 (map assign) | ❌ |
| 89 | waiting | sync/mutex.go:74 | ✅ |
根因推演流程
graph TD
A[goroutine dump] --> B{存在多 goroutine<br>同处 mapassign}
B -->|是| C[提取各 goroutine 的 map 变量地址]
C --> D[delve 中 inspect 地址值是否相同]
D -->|相同| E[确认 map 共享污染]
2.4 etcd Watcher回调与调度器缓存map的隐式并发模型解构
数据同步机制
etcd Watcher通过长连接监听键前缀变更,触发回调函数将事件投递至调度器内部通道。该通道由 goroutine 消费,执行缓存 map 的原子更新(sync.Map 或 RWMutex 保护)。
隐式并发边界
- 回调函数本身不加锁,依赖消费者协程串行处理事件
- 缓存 map 的读写分离:读操作免锁(
Load),写操作经 channel 序列化 - 无显式
go启动新协程写入 map,规避竞态
核心代码片段
// Watcher 回调入口(简化)
watchCh := client.Watch(ctx, "/registry/pods/", clientv3.WithPrefix())
for wresp := range watchCh {
for _, ev := range wresp.Events {
eventCh <- &PodEvent{Type: ev.Type, Obj: unmarshal(ev.Kv.Value)}
}
}
逻辑分析:
eventCh是带缓冲的 channel(容量 1024),确保 Watch 事件不丢;ev.Type区分PUT/DELETE,驱动缓存LoadOrStore/Delete;unmarshal要求幂等,因 etcd 可能重发事件。
| 组件 | 并发角色 | 安全保障 |
|---|---|---|
| Watcher | 事件生产者 | 单 goroutine 连续推送 |
| eventCh | 异步缓冲队列 | channel 天然线程安全 |
| 缓存 map | 状态存储 | sync.Map Load/Store 原子 |
graph TD
A[etcd Server] -->|Watch Event| B(Watcher Callback)
B --> C[eventCh]
C --> D{Consumer Goroutine}
D --> E[sync.Map.LoadOrStore]
D --> F[sync.Map.Delete]
2.5 生产环境高频panic堆栈模式识别:12类dump模板中的3类典型映射
在千万级QPS的微服务集群中,panic堆栈的语义聚类显著提升故障定位效率。以下为最常复现的三类映射:
数据同步机制
当sync.(*Mutex).Lock紧邻runtime.gopark出现时,92%概率对应跨goroutine共享map未加锁写入:
// panic trace 片段(截取关键帧)
goroutine 42 [semacquire]:
sync.runtime_SemacquireMutex(0xc0001a2018, 0x0, 0x1)
sync.(*Mutex).Lock(0xc0001a2010) // ← 锁竞争点
main.processUserCache(0xc000012340) // ← 业务入口
该模式表明:goroutine 42 在等待 mutex 释放,而持有者已因 fatal error: concurrent map writes 崩溃——本质是 map 写操作逃逸出 sync.Map 封装。
上下文超时传播
典型堆栈含 context.deadlineExceededError.Error → http.(*Transport).roundTrip → runtime.morestack,反映 HTTP 客户端未设置 Context.WithTimeout,导致 goroutine 泄漏。
并发资源耗尽
| 模式特征 | 关键符号 | 触发阈值 | 应对动作 |
|---|---|---|---|
| Goroutine 雪崩 | runtime.newproc1 + runtime.mallocgc 高频交替 |
>50k active goroutines | 启用 GODEBUG=schedtrace=1000 |
| Channel 阻塞链 | runtime.chansend1 持续阻塞于 <-ch |
channel cap=0 且无接收方 | 插入 select{case <-ch: default:} 防御 |
graph TD
A[panic dump] --> B{匹配模板库}
B -->|模板#07| C[context timeout chain]
B -->|模板#11| D[concurrent map write]
B -->|模板#03| E[goroutine leak via unbuffered chan]
第三章:Go原生map并发安全机制的底层实现与局限
3.1 sync.Map源码级解析:read map、dirty map与amended标志的协同逻辑
sync.Map 的核心在于双 map 结构与惰性升级策略:
数据同步机制
read 是原子读取的 readOnly 结构(含 map[interface{}]interface{} 和 misses 计数器),dirty 是可写的普通 map;amended 标志表示 dirty 是否包含 read 中不存在的键。
type readOnly struct {
m map[interface{}]interface{}
amended bool // true: dirty has keys not in m
}
amended 为 true 时,Load 命中失败需尝试 dirty;Store 写入 read 失败且 amended == false 时,触发 dirty 初始化并复制 read 全量数据。
协同流程(mermaid)
graph TD
A[Load key] --> B{in read.m?}
B -->|Yes| C[return value]
B -->|No| D{amended?}
D -->|Yes| E[try dirty]
D -->|No| F[return nil]
关键状态迁移规则
misses达阈值 → 提升dirty为新read,重置amended = false- 首次写入未命中
read→ 若amended == false,则dirty拷贝read并设amended = true
| 状态 | read.amended | dirty 含义 |
|---|---|---|
| 初始/提升后 | false | 与 read 完全一致 |
| 有写入未命中 | true | 包含 read 无的键,可能冗余 |
3.2 原生map加锁方案(sync.RWMutex)在高吞吐场景下的性能衰减实测
数据同步机制
使用 sync.RWMutex 保护 map[string]int,读多写少场景下看似合理,但高并发时读锁仍存在goroutine排队与调度开销。
var (
mu sync.RWMutex
data = make(map[string]int)
)
func Get(key string) int {
mu.RLock() // 非阻塞?实际仍需原子操作+调度器介入
defer mu.RUnlock()
return data[key]
}
RLock() 并非零成本:每次调用触发 runtime_SemacquireRWMutexR,涉及信号量等待、G状态切换;压测中10k QPS下平均延迟跃升至 127μs(无锁map仅 42ns)。
性能对比(10万次操作,单核)
| 方案 | 吞吐(ops/s) | P99延迟(μs) | GC压力 |
|---|---|---|---|
| 原生 map + RWMutex | 78,400 | 127 | 中 |
sync.Map |
215,600 | 32 | 低 |
| 分片 map + CAS | 392,100 | 18 | 极低 |
根本瓶颈
graph TD
A[goroutine 调用 RLock] --> B[检查写锁持有状态]
B --> C{有活跃写锁?}
C -->|是| D[加入读锁等待队列 → 调度器唤醒开销]
C -->|否| E[原子更新 reader count → 成功]
RWMutex 的“读不阻塞写”仅指不阻止写锁获取,但所有读操作共享同一锁字,争用导致 cacheline false sharing 与内核态切换放大。
3.3 atomic.Value+immutable map组合模式的内存安全边界验证
核心设计动机
在高并发读多写少场景中,sync.Map 的锁竞争与 map + RWMutex 的写阻塞均存在性能瓶颈。atomic.Value 结合不可变 map(如 map[string]int 的每次写入生成新副本)可实现无锁读取。
数据同步机制
type Config struct {
data map[string]string
}
var config atomic.Value // 存储 *Config 指针
func Update(k, v string) {
old := config.Load().(*Config)
m := make(map[string]string)
for kk, vv := range old.data {
m[kk] = vv
}
m[k] = v
config.Store(&Config{data: m})
}
func Get(k string) string {
return config.Load().(*Config).data[k]
}
atomic.Value仅保证指针原子更新(Store/Load),不管理内部 map 的线程安全;- 每次
Update创建全新 map 副本,避免写时复制(COW)中的数据竞态; Get直接读取当前快照,零同步开销。
安全边界验证要点
| 验证维度 | 是否满足 | 说明 |
|---|---|---|
| 读操作无锁 | ✅ | Load() 是原子指针读取 |
| 写操作不破坏旧读 | ✅ | 旧 map 实例生命周期由 GC 保障 |
| ABA 问题 | ❌ | atomic.Value 不涉及指针比较,无 ABA |
graph TD
A[写协程调用 Update] --> B[拷贝旧 map]
B --> C[修改副本]
C --> D[atomic.Value.Store 新指针]
E[读协程并发调用 Get] --> F[Load 当前指针]
F --> G[直接访问其 map]
第四章:高并发系统中map安全治理的工程化实践体系
4.1 MapWrapper封装规范:基于interface{}键值的泛型安全代理层设计
MapWrapper 是对原生 map[interface{}]interface{} 的安全增强封装,解决类型擦除导致的运行时 panic 风险。
核心设计原则
- 键/值类型契约由调用方显式声明(非编译期泛型推导)
- 所有读写操作经
typeAssert双重校验 - 空值(nil)传播策略可配置(panic / zero fallback)
安全写入示例
func (mw *MapWrapper) Set(key, value interface{}) error {
if !mw.keyType.AssignableTo(reflect.TypeOf(key)) {
return fmt.Errorf("key type mismatch: expected %v, got %v", mw.keyType, reflect.TypeOf(key))
}
mw.data[key] = value // 底层仍为 interface{} map
return nil
}
mw.keyType是初始化时传入的reflect.Type,用于运行时类型守门;AssignableTo允许子类型赋值(如*string→interface{}),兼顾灵活性与安全性。
类型校验能力对比
| 场景 | 原生 map | MapWrapper |
|---|---|---|
Set("k", 42) |
✅ | ✅(若 keyType=string) |
Set(42, "v") |
✅ | ❌(类型校验失败) |
Get("k").(int) |
✅+panic | ✅(自动断言或返回 error) |
graph TD
A[Set key/value] --> B{类型匹配?}
B -->|Yes| C[写入底层 map]
B -->|No| D[返回 error]
4.2 Kubernetes Operator中cache.Store替代原生map的迁移checklist与灰度策略
数据同步机制
cache.Store 提供线程安全的键值存储、事件通知及资源版本控制,天然适配 Kubernetes 的 List-Watch 模型,而原生 map 需手动加锁、处理 ResourceVersion 冲突与 DeltaFIFO 集成。
迁移Checklist
- ✅ 替换
map[string]*v1.Pod为cache.Store(类型安全:*v1.Pod) - ✅ 注册
cache.ResourceEventHandler实现OnAdd/OnUpdate/OnDelete - ✅ 使用
cache.MetaNamespaceKeyFunc生成一致 key - ❌ 移除所有
sync.RWMutex手动保护逻辑
灰度策略对比
| 策略 | 控制粒度 | 回滚成本 | 适用阶段 |
|---|---|---|---|
| Namespace级 | 单命名空间 | 秒级 | 验证期 |
| LabelSelector | 标签匹配Pod | 低 | 生产灰度 |
| FeatureGate | 全局开关 | 原子 | 切流前终验 |
// 初始化Store替代map
store := cache.NewStore(cache.MetaNamespaceKeyFunc)
informer := cache.NewSharedIndexInformer(
&cache.ListWatch{
ListFunc: listFn,
WatchFunc: watchFn,
},
&v1.Pod{}, 0, cache.Indexers{},
)
informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) { store.Add(obj) }, // 自动序列化+key生成
})
cache.NewStore底层基于sync.Map封装,但额外维护resourceVersion快照与索引;MetaNamespaceKeyFunc保证 key 格式为"namespace/name",与 informer 内部一致,避免 key 不匹配导致缓存漏检。
4.3 Prometheus指标采集器中map并发写冲突的静态检测(go vet + custom analyzer)
Prometheus采集器常使用map[string]float64缓存瞬时指标,但未经同步保护的并发写入会触发 data race。
常见误用模式
- 直接在 HTTP handler 中
m[key] = val - 多 goroutine 轮询更新同一 map
- 使用
sync.Map但误调用LoadOrStore后仍直接赋值
静态检测双路径
// analyzer/detect_map_write.go
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
if assign, ok := n.(*ast.AssignStmt); ok {
if len(assign.Lhs) == 1 {
if idxExpr, ok := assign.Lhs[0].(*ast.IndexExpr); ok {
// 检测 lhs 是否为 *map[*]* 类型且无 sync.Mutex/sync.Map 保护
if isMapIndexWrite(idxExpr) && !hasWriteGuard(assign, idxExpr) {
pass.Reportf(idxExpr.Pos(), "concurrent map write detected: %s", idxExpr.X)
}
}
}
}
return true
})
}
return nil, nil
}
该分析器遍历 AST,识别 m[k] = v 形式赋值,并结合类型推导与作用域内锁变量声明上下文判断是否缺乏保护。isMapIndexWrite 过滤非 map 类型索引,hasWriteGuard 检查最近作用域是否存在 mu.Lock() 调用或 sync.Map 方法调用。
检测能力对比
| 工具 | 覆盖场景 | 误报率 | 需编译 |
|---|---|---|---|
go vet -race |
运行时动态检测 | 低 | 否 |
go vet --custom=mapwrite |
编译期 AST 分析 | 中 | 是 |
| 自定义 analyzer | 可扩展语义规则(如 context-aware lock scope) | 可控 | 是 |
graph TD
A[Go源码] --> B[go vet frontend]
B --> C{Custom Analyzer}
C --> D[AST IndexExpr 匹配]
D --> E[类型检查:map[K]V?]
E --> F[作用域锁/ sync.Map 调用追溯]
F -->|无保护| G[报告并发写警告]
4.4 基于eBPF的运行时map访问追踪:在kubelet中注入tracepoint捕获非法写操作
为实时捕获 kubelet 对内核 map 的越权写入,我们在 bpf_map_update_elem 内核函数入口处挂载 tracepoint。
核心eBPF程序片段
SEC("tracepoint/syscalls/sys_enter_bpf")
int trace_bpf_map_write(struct trace_event_raw_sys_enter *ctx) {
u64 op = ctx->args[0]; // BPF_MAP_UPDATE_ELEM = 2
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (op != 2) return 0;
bpf_printk("PID %d attempted map write\n", pid);
return 0;
}
该程序通过 sys_enter_bpf tracepoint 拦截所有 bpf 系统调用,检查操作码是否为 BPF_MAP_UPDATE_ELEM(值为2),并记录发起进程 PID。bpf_printk 输出经 bpftool prog dump jited 可实时捕获。
关键参数说明
| 参数 | 含义 | 来源 |
|---|---|---|
ctx->args[0] |
bpf() 系统调用的第一个参数:操作类型 | trace_event_raw_sys_enter 结构体 |
bpf_get_current_pid_tgid() |
返回 pid << 32 \| tid,右移32位得 PID |
eBPF 辅助函数 |
检测流程
graph TD
A[kubelet 调用 bpf_map_update_elem] --> B[触发 sys_enter_bpf tracepoint]
B --> C[eBPF 程序校验 op == 2]
C --> D{是非法写?}
D -->|是| E[记录 PID + 时间戳到 ringbuf]
D -->|否| F[静默放行]
第五章:从panic到韧性:构建可观测、可防御、可恢复的并发内存治理体系
在真实生产环境中,Go服务因sync.Map误用导致的内存泄漏曾引发某支付网关集群连续三小时CPU毛刺——GC周期从12ms飙升至2.3s,P99延迟突破800ms。问题根源并非代码逻辑错误,而是开发者将sync.Map当作通用缓存容器,在高频写入场景下未清理过期键,且缺乏内存增长基线告警。
内存逃逸的可观测锚点
通过go build -gcflags="-m -m"定位逃逸变量后,需结合运行时指标建立观测闭环。以下为关键Prometheus指标采集配置示例:
| 指标名 | 用途 | 查询示例 |
|---|---|---|
go_memstats_heap_alloc_bytes |
实时堆分配量 | rate(go_memstats_heap_alloc_bytes[5m]) > 50MB |
go_goroutines |
协程数突增预警 | go_goroutines > 10000 and on(job) (go_goroutines - go_goroutines offset 1h) > 2000 |
并发写冲突的防御性编码模式
避免map并发写panic的三种落地方案:
- 使用
sync.RWMutex保护普通map(适用于读多写少且key集稳定场景) - 对高频写入场景,采用分片锁策略:将
map按key哈希分片,每片独立锁,降低锁竞争 - 在必须使用
sync.Map时,强制添加清理协程:
func startCleanup(m *sync.Map, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
m.Range(func(key, value interface{}) bool {
if isExpired(value) {
m.Delete(key) // 原子删除,无需额外锁
}
return true
})
}
}
内存故障的自动恢复机制
当runtime.ReadMemStats检测到HeapInuse持续3分钟超阈值(如>1.2GB),触发分级响应:
graph TD
A[HeapInuse > 1.2GB] --> B{持续3分钟?}
B -->|是| C[触发GC强制回收]
B -->|否| D[记录warn日志]
C --> E[检查goroutine阻塞状态]
E --> F[若goroutine > 15000且阻塞率>30%]
F --> G[启动熔断器隔离异常worker]
G --> H[向SRE平台推送恢复指令]
某电商大促期间,该机制成功拦截了因商品库存缓存未设置TTL导致的OOM事件。系统在内存占用达1.4GB时自动执行debug.SetGCPercent(10),并将高内存消耗goroutine栈信息注入Jaeger trace tag,使故障定位时间从47分钟缩短至92秒。
生产级内存压测验证方法
使用go test -benchmem -cpuprofile=cpu.prof -memprofile=mem.prof生成基准数据后,需进行破坏性验证:
- 注入
GODEBUG=madvdontneed=1强制内核立即回收内存页 - 通过
/proc/<pid>/smaps比对Rss与Anonymous字段差异,识别真实物理内存占用 - 使用
pprof火焰图分析runtime.mallocgc调用链中reflect.Value.Interface等高开销路径
某物流调度服务通过此方法发现JSON序列化时json.RawMessage被意外转为[]byte拷贝,单次请求增加1.2MB堆分配,优化后QPS提升3.7倍。
多维度内存健康度看板
在Grafana中集成以下视图:
- 热力图:展示各goroutine内存分配速率TOP 20
- 散点图:X轴为goroutine存活时间,Y轴为其分配字节数,标记离群点
- 时间序列:
go_memstats_mallocs_total与go_memstats_frees_total差值趋势线
运维团队依据该看板在灰度发布阶段拦截了因新版本引入bytes.Buffer未复用导致的内存抖动问题。
