第一章:Go map读写瓶颈的本质与典型场景
Go 语言中的 map 是基于哈希表实现的无序键值容器,其平均时间复杂度为 O(1),但在高并发或特定负载下,读写性能会显著劣化。瓶颈根源在于底层 hmap 结构的非线程安全设计与动态扩容机制:所有读写操作均需竞争同一把全局锁(在 Go 1.10+ 中为桶级分段锁,但仍存在写冲突和扩容时的全表停写),且扩容过程需遍历并重新哈希全部键值对,导致瞬时 CPU 和内存带宽飙升。
并发写入引发 panic 的典型场景
当多个 goroutine 同时对同一 map 执行写操作(如 m[key] = value)而未加同步控制时,运行时会触发 fatal error: concurrent map writes。此 panic 不可 recover,是 Go 运行时强制施加的安全保护:
func badConcurrentWrite() {
m := make(map[string]int)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m[fmt.Sprintf("key-%d", i)] = i // ⚠️ 无锁并发写,必然 panic
}(i)
}
wg.Wait()
}
高频扩容导致的延迟毛刺
当 map 元素数量持续增长且负载因子(count / BUCKET_COUNT)超过阈值(≈6.5)时,Go 触发扩容。以下代码可复现扩容抖动:
func observeResize() {
m := make(map[int]int, 1)
for i := 0; i < 1000000; i++ {
m[i] = i
if i == 256 || i == 4096 || i == 65536 { // 关键扩容点
fmt.Printf("Size %d → map buckets: %d\n", i, getBucketCount(m))
}
}
}
// 注:getBucketCount 为反射辅助函数,实际调试可用 pprof CPU profile 定位扩容耗时
常见高危使用模式
- 全局共享 map 未加锁:如配置缓存、连接池索引
- 循环中反复 make/map 赋值:触发频繁内存分配与 GC 压力
- 字符串键大量重复哈希计算:尤其短键高频访问时,哈希函数开销占比上升
| 场景 | 风险表现 | 推荐替代方案 |
|---|---|---|
| 高并发计数器 | 写冲突、panic 或数据丢失 | sync.Map 或 atomic.Int64 分片 |
| 配置热更新映射 | 扩容阻塞读请求,响应延迟突增 | sync.RWMutex + 指针原子替换 |
| 日志上下文传递 map | 小对象逃逸、GC 频繁 | 结构体字段或专用 context.Value 类型 |
第二章:go tool pprof深度剖析map性能热点
2.1 pprof CPU profile定位高频率map操作调用栈
Go 程序中高频 map 读写常引发竞争或性能瓶颈,pprof CPU profile 是精准定位的首选手段。
启动带 profiling 的服务
go run -gcflags="-l" main.go &
# 或启用 HTTP profiling 端点
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
-gcflags="-l" 禁用内联,保留清晰调用栈;seconds=30 确保捕获足够 map 操作样本。
分析关键路径
(pprof) top -cum -limit=10
重点关注 runtime.mapaccess1_fast64 / runtime.mapassign_fast64 的调用占比及上游函数。
| 函数名 | 累计耗时 | 调用次数 | 上游调用者 |
|---|---|---|---|
user.Cache.Get |
82% | 12,450 | http.HandlerFunc |
sync.(*Map).Load |
15% | 9,870 | user.Cache.Get |
数据同步机制
高频 map 访问若未加锁或误用 sync.Map,易导致 CPU 在哈希探查与扩容间空转。建议:
- 热 key 场景优先使用
sync.Map - 频繁遍历场景改用
map+RWMutex - 持续压测下用
go tool pprof -http=:8080 cpu.pprof可视化火焰图定位热点分支
2.2 pprof heap profile识别map扩容引发的内存抖动
Go 中 map 的动态扩容会触发底层 bucket 数组的成倍复制与重哈希,造成瞬时大量内存分配,pprof heap profile 可清晰捕获该模式。
内存抖动典型特征
runtime.makemap和runtime.growslice频繁出现在 top alloc_objects/alloc_space- 分配大小呈 2ⁿ 倍数分布(如 8KB → 16KB → 32KB)
复现代码示例
func benchmarkMapGrowth() {
m := make(map[int]int, 0) // 初始容量为0,首次插入即触发扩容
for i := 0; i < 100000; i++ {
m[i] = i // 每次扩容需复制旧键值+重散列
}
}
该循环在 i ≈ 65536 附近触发第 7 次扩容(2⁶→2⁷ buckets),分配约 1MB 连续内存;-inuse_space profile 显示锯齿状尖峰。
关键诊断命令
| 命令 | 用途 |
|---|---|
go tool pprof -http=:8080 mem.pprof |
启动可视化界面 |
top -cum |
定位 makemap 调用栈深度 |
peek runtime.makemap |
查看其上游调用者 |
graph TD
A[pprof heap profile] --> B[识别高频 alloc of bucket array]
B --> C{是否伴随 size=2^n?}
C -->|Yes| D[确认 map 扩容抖动]
C -->|No| E[排查其他 slice/map 预分配不足]
2.3 pprof mutex profile捕获map并发读写导致的锁竞争
Go 中 map 非并发安全,直接多 goroutine 读写会触发 fatal error: concurrent map read and map write。但更隐蔽的是——未崩溃却高争用:当使用 sync.RWMutex 包裹 map 时,若读多写少却频繁写入,RWMutex 的写锁会阻塞所有读操作,引发 mutex 竞争。
数据同步机制
常见错误模式:
- 用
sync.RWMutex保护 map,但写操作(如store())过于频繁; - 读操作(
load())被写锁长期阻塞,pprofmutex profile 显示runtime.semacquireMutex占比陡升。
复现竞争的最小示例
var (
m = make(map[string]int)
mu sync.RWMutex
)
func load(k string) int {
mu.RLock() // ① 读锁获取
defer mu.RUnlock() // ② 读锁释放
return m[k]
}
func store(k string, v int) {
mu.Lock() // ③ 写锁独占 —— 此处阻塞所有 RLock()
defer mu.Unlock()
m[k] = v
}
逻辑分析:
mu.Lock()会阻塞后续所有RLock(),即使读操作本身极快;pprof mutex profile统计的是goroutine 在 mutex 上等待的总纳秒数,而非 CPU 时间。参数--mutex_profile_rate=1启用采样(默认关闭)。
诊断与对比指标
| 指标 | 含义 | 健康阈值 |
|---|---|---|
contentions |
锁争用次数 | |
delay |
累计等待纳秒 |
graph TD
A[goroutine A: RLock] -->|等待| B[Write Lock Held]
C[goroutine B: Lock] --> B
D[goroutine C: RLock] -->|排队| B
2.4 pprof trace profile关联map操作与goroutine阻塞生命周期
Go 运行时通过 runtime/trace 将 map 操作(如 sync.Map.Load、mapassign_fast64)与 goroutine 阻塞事件(GoroutineBlocked、GoroutinePreempted)在时间轴上精确对齐。
数据同步机制
sync.Map 的 Load 调用会触发 read.amended 判断,若需升级到 dirty map,则可能引发 mutex 竞争,导致 trace 中出现 SyncBlock 事件。
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
read, _ := m.read.Load().(readOnly) // trace: "map_read_start"
if e, ok := read.m[key]; ok && e != nil {
return e.load() // 可能触发 atomic load,无阻塞
}
// 若未命中且存在 dirty map,需加锁 → 可能阻塞
m.mu.Lock()
// ...
}
该代码中 m.mu.Lock() 是潜在阻塞点,pprof trace 会记录其起止时间戳,并与 goroutine 状态切换(Gwaiting → Grunnable)严格对齐。
关键事件映射表
| trace 事件 | 对应 map 行为 | goroutine 状态变化 |
|---|---|---|
MapLoadStart |
sync.Map.Load 入口 |
Grunning |
SyncBlock |
mu.Lock() 等待 |
Gwaiting(含阻塞原因) |
GoroutineBlocked |
锁竞争导致调度让出 | Gwaiting → Gwaiting |
graph TD
A[goroutine 执行 Load] --> B{read.m 存在 key?}
B -->|是| C[atomic load → 无 trace 阻塞]
B -->|否| D[尝试 mu.Lock]
D --> E[成功 → 继续]
D --> F[失败 → trace.SyncBlock + GoroutineBlocked]
2.5 pprof自定义标签(pprof.Label)精准标记map读写上下文
Go 1.21+ 支持 pprof.Label 为采样数据动态注入语义上下文,尤其适用于并发 map 操作的归因分析。
标签绑定与作用域管理
使用 pprof.WithLabels() 创建带标签的上下文,并通过 pprof.Do() 确保标签在 goroutine 生命周期内生效:
ctx := pprof.WithLabels(ctx, pprof.Labels(
"op", "read",
"key", "user_123",
"shard", "0x7a"))
pprof.Do(ctx, func(ctx context.Context) {
value := myMap["user_123"] // 此处 CPU/alloc 采样将携带上述标签
})
逻辑说明:
pprof.Labels()构建不可变标签映射;pprof.Do()将其绑定至当前 goroutine 的 pprof 上下文栈,支持嵌套覆盖。参数"op"和"key"成为火焰图中的可筛选维度。
常见标签策略对比
| 场景 | 推荐标签键 | 说明 |
|---|---|---|
| Map读操作 | op=read, key |
区分热点 key 与冷 key |
| Map写操作 | op=write, shard |
定位写竞争热点分片 |
| 删除操作 | op=delete, ttl |
关联过期策略分析 GC 压力 |
执行链路示意
graph TD
A[goroutine 启动] --> B[pprof.WithLabels]
B --> C[pprof.Do]
C --> D[map 访问]
D --> E[CPU/heap 采样自动携带标签]
第三章:go tool trace可视化诊断map并发行为
3.1 trace视图中识别mapassign/mapaccess1等runtime关键事件时序
Go 运行时的 mapassign(写入)与 mapaccess1(读取)在 go tool trace 中表现为高频率、低延迟的离散事件,常集中于 Goroutine 执行帧的起始/结束边界。
事件特征识别要点
- 时间戳精度达纳秒级,但 trace 默认聚合为微秒粒度
- 事件名称以
runtime.mapassign和runtime.mapaccess1_fast64等形式出现(后缀因 key 类型而异) - 每次调用均关联唯一
Goroutine ID与Proc ID,可用于跨 P 协作分析
典型 trace 事件片段(JSON 截取)
{
"ts": 12489372000,
"tp": "runtime.mapassign",
"args": {"map": "0xc0000140a0", "key": 42},
"g": 19,
"p": 0
}
逻辑分析:
ts为纳秒时间戳,args.key显示传入键值(此处为整型 42),g和p揭示该操作由 G19 在 P0 上执行;注意mapassign不返回值,仅触发写屏障或扩容判断。
| 事件类型 | 是否阻塞 | 常见触发场景 |
|---|---|---|
mapassign |
否 | map[key] = value |
mapaccess1 |
否 | val := map[key](存在时) |
mapdelete |
否 | delete(map, key) |
graph TD A[trace 启动] –> B[运行时注入 map 事件钩子] B –> C{是否命中 map 操作?} C –>|是| D[记录 ts/g/p/args] C –>|否| E[跳过]
3.2 goroutine分析面板定位map写操作引发的调度延迟与抢占点
数据同步机制
Go 中 map 非并发安全,写操作可能触发 runtime.mapassign 中的扩容逻辑,导致长时间持有 h.mapLock,阻塞其他 goroutine 抢占。
典型问题代码
var m = make(map[int]int)
func unsafeWrite() {
for i := 0; i < 1e6; i++ {
m[i] = i // ⚠️ 无锁写入,扩容时触发 stop-the-world 式锁竞争
}
}
该循环在高并发下易触发 map 扩容(hashGrow),期间 runtime.growWork 会遍历旧 bucket,耗时随数据量增长——pprof 的 goroutine 面板中可见大量 GC assist marking 或 runnable 状态 goroutine 堆积。
调度延迟特征
- 抢占点被抑制:
mapassign内部未插入preemptible检查 GOMAXPROCS=1下延迟更显著runtime.trace可捕获SchedLatency> 10ms 的异常峰值
| 指标 | 正常值 | map写冲突时 |
|---|---|---|
| 平均调度延迟 | > 5ms | |
| Goroutine runnable 队列长度 | ≤ 3 | ≥ 50 |
修复路径
- 使用
sync.Map(读多写少场景) - 写密集场景改用分片 map +
sync.RWMutex - 启用
-gcflags="-d=checkptr"检测非法指针操作
graph TD
A[goroutine 执行 map write] --> B{是否触发扩容?}
B -->|是| C[lock h.mapLock]
C --> D[遍历旧 bucket 迁移键值]
D --> E[阻塞其他 G 获取 P]
E --> F[调度延迟上升]
B -->|否| G[快速完成]
3.3 network/trace事件联动分析map序列化/反序列化对GC压力的影响
数据同步机制
当 network 事件(如 HTTP 请求完成)与 trace 事件(如 Span 结束)联动时,常需将 Map<String, Object> 封装为跨进程上下文传递。高频调用下,JDK 原生 ObjectOutputStream 序列化会触发大量临时对象分配。
GC 压力热点定位
通过 -XX:+PrintGCDetails 与 AsyncProfiler 叠加 trace 标签可确认:HashMap$Node、StringUTF16 和 ByteArrayOutputStream 是 Young GC 主要晋升源。
优化前后对比
| 指标 | 未优化(JDK序列化) | 优化后(Kryo+Unsafe) |
|---|---|---|
| 单次序列化内存分配 | 12.4 KB | 3.1 KB |
| Promotion Rate | 890 MB/s | 210 MB/s |
// 使用 Kryo 预注册类型,禁用反射,规避 Class.newInstance() 开销
Kryo kryo = new Kryo();
kryo.setRegistrationRequired(true);
kryo.register(HashMap.class); // 显式注册避免动态代理开销
kryo.register(String.class);
该配置使
HashMap序列化跳过writeObject()反射调用,直接写入 size + 键值对数组,减少 67% 的临时 char[] 和 Entry 对象生成,显著降低 G1 的 Evacuation Failure 风险。
graph TD A[network event] –> B[extract context map] B –> C{serialize?} C –>|Yes| D[Kryo.writeClassAndObject] C –>|No| E[skip GC-sensitive path] D –> F[buffer.writeBytes → direct memory]
第四章:定制化runtime指标构建map健康度监控体系
4.1 基于runtime.ReadMemStats采集map底层hmap结构体内存分布
Go 运行时并不直接暴露 hmap 的内存布局,但可通过 runtime.ReadMemStats 结合 unsafe 和反射间接推断其内存开销。
hmap核心字段内存估算
hmap 结构体包含:count(8B)、flags(1B)、B(1B)、noverflow(2B)、hash0(4B)、buckets(8B)、oldbuckets(8B)、nevacuate(8B)、extra(8B)——合计约64字节基础开销(64位系统)。
采样与分析示例
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc = %v MiB\n", m.Alloc/1024/1024)
该调用触发 GC 统计快照,反映当前堆中所有对象(含 hmap 及其 buckets 数组)的总分配量。注意:Alloc 包含键值对数据、bucket数组及溢出桶,需结合 maplen() 与 bucketShift(B) 推算实际 bucket 占用。
| 字段 | 类型 | 典型大小(bytes) | 说明 |
|---|---|---|---|
hmap header |
struct | ~64 | 固定元数据 |
buckets |
[2^B]b | 8 × 2^B | 指针数组(64位) |
| 每个 bucket | b | 128 | 8 key/val pairs + tophash |
graph TD
A[ReadMemStats] --> B[获取 Alloc/TotalAlloc]
B --> C[结合 map size & B 计算 bucket 数量]
C --> D[估算 hmap 实际内存占比]
4.2 利用runtime/debug.SetMutexProfileFraction暴露map互斥锁采样率
Go 运行时通过 mutex profiling 捕获竞争激烈的互斥锁调用栈,但默认关闭(fraction = 0)。启用需显式设置采样率:
import "runtime/debug"
func init() {
debug.SetMutexProfileFraction(1) // 100% 采样;0=禁用,-1=仅阻塞超1ms的锁
}
参数说明:
fraction表示每1/fraction次锁获取尝试记录一次调用栈。值为1时全量采集;5表示约 20% 采样;负值触发阻塞时间阈值模式。
数据同步机制
sync.Map 内部虽避免全局锁,但其 dirty map 提升、misses 计数等仍依赖 mu 互斥锁——这正是 mutex profile 的关键观测目标。
采样策略对比
| fraction 值 | 行为 | 适用场景 |
|---|---|---|
| 0 | 完全禁用采样 | 生产环境默认 |
| 1 | 每次 Lock/Unlock 记录 | 调试竞态瓶颈 |
| -1 | 仅记录阻塞 ≥1ms 的锁事件 | 平衡开销与精度 |
graph TD
A[SetMutexProfileFraction] --> B{fraction ≤ 0?}
B -->|是| C[启用阻塞时间阈值]
B -->|否| D[按 1/fraction 随机采样]
C --> E[记录 >1ms 的锁等待]
D --> F[生成 runtime.MutexProfile]
4.3 注入runtime/metrics API监控buckettree深度、overflow bucket数、load factor实时值
Go 运行时哈希表(map)的底层 buckettree 结构性能指标对 GC 压力与查找延迟高度敏感。需通过 runtime/metrics 暴露关键观测维度:
监控指标注册与采集
import "runtime/metrics"
// 注册自定义指标(需 patch runtime 或使用 go1.22+ 支持的 /debug/metrics 扩展点)
metrics.Register("hash/bucket_tree/depth:histogram", metrics.KindFloat64Histogram)
metrics.Register("hash/overflow_buckets:counter", metrics.KindUint64Counter)
metrics.Register("hash/load_factor:float64", metrics.KindFloat64)
此代码需在
runtime/map.go中makemap和growWork等关键路径注入采样逻辑;depth为直方图类型,反映树状桶层级分布;overflow_buckets为累计计数器,统计溢出桶总数;load_factor为瞬时浮点值,计算公式为used_entries / (buckets * 8)。
核心指标语义对照表
| 指标名 | 类型 | 采集时机 | 典型阈值告警 |
|---|---|---|---|
hash/bucket_tree/depth |
histogram | 每次 mapassign 后采样根到叶最大深度 |
>5 表示严重退化 |
hash/overflow_buckets |
counter | newoverflow 分配时递增 |
持续增长预示内存泄漏 |
hash/load_factor |
float64 | mapiterinit 前快照 |
>6.5 触发扩容预警 |
数据流拓扑
graph TD
A[mapassign/mapdelete] --> B[Runtime Hook]
B --> C[Update depth/overflow/load_factor]
C --> D[runtime/metrics.Store]
D --> E[/debug/metrics HTTP endpoint]
4.4 结合expvar暴露map读写QPS、平均延迟、冲突率等业务语义指标
指标设计原则
需将底层并发Map操作映射为可观察的业务语义:
read_qps/write_qps:每秒原子计数器增量latency_ns_avg:滑动窗口内读/写操作纳秒级耗时均值collision_rate:哈希桶冲突次数 ÷ 总写入次数
expvar注册示例
import "expvar"
var (
readQPS = expvar.NewInt("map_read_qps")
writeQPS = expvar.NewInt("map_write_qps")
avgLatency = expvar.NewFloat("map_latency_ns_avg")
collisionRate = expvar.NewFloat("map_collision_rate")
)
// 在每次读/写后调用(需配合sync/atomic计时)
func recordRead(durationNs int64) {
readQPS.Add(1)
avgLatency.Set(float64(durationNs)) // 实际应使用加权移动平均
}
逻辑说明:
expvar.NewFloat支持并发安全更新;durationNs需由time.Since()捕获,避免阻塞;真实场景中avgLatency应采用指数加权移动平均(EWMA)而非单点覆盖。
核心指标对照表
| 指标名 | 类型 | 计算方式 | 业务意义 |
|---|---|---|---|
map_read_qps |
int | atomic.AddInt64(&reads, 1) 每秒重置 |
服务读负载强度 |
map_collision_rate |
float64 | float64(collisions)/float64(writes) |
哈希设计健康度 |
数据同步机制
graph TD
A[Map Write] –> B[原子计数+1]
B –> C[采样耗时]
C –> D[EWMA更新avgLatency]
D –> E[每秒聚合→expvar]
第五章:三件套协同诊断工作流与最佳实践总结
核心协同诊断工作流设计
在某金融核心交易系统故障复盘中,我们构建了基于 Prometheus(指标)、Loki(日志)、Tempo(链路追踪)的闭环诊断流水线:当 Prometheus 告警触发 http_request_duration_seconds_bucket{le="1.0", job="api-gateway"} 异常突增时,自动执行以下动作:
- 调用 Loki API 查询告警时间窗口前后5分钟内
level="error"且含"timeout"关键字的日志条目; - 提取日志中
traceID字段,通过 Tempo 查询对应分布式调用链; - 定位至
payment-service的processPayment()方法耗时占比达87%,进一步下钻其子 span 发现 RedisGET user:10042延迟超 2.4s; - 回溯 Prometheus 中
redis_connected_clients和redis_blocked_clients指标,确认存在客户端连接泄漏。
典型故障场景应对矩阵
| 故障类型 | Prometheus 关键指标 | Loki 日志筛选模式 | Tempo 追踪聚焦点 |
|---|---|---|---|
| 数据库慢查询 | pg_stat_database_blks_read_total 骤升 |
level=ERROR AND "pq: timeout" |
db.query span duration >5s |
| 服务雪崩 | http_server_requests_seconds_count{status=~"5.."}激增 |
"circuit breaker open" OR "fallback executed" |
feign.Client.execute 失败链 |
| 内存泄漏 | jvm_memory_used_bytes{area="heap"} 持续爬升 |
"OutOfMemoryError" OR "GC overhead limit exceeded" |
java.lang.Thread.run 内存分配热点 |
自动化诊断脚本片段
# 基于告警触发的诊断协程(简化版)
ALERT_TIME=$(date -d '5 minutes ago' +%s)
TRACE_IDS=$(curl -s "http://loki:3100/loki/api/v1/query_range?query={job=%22app%22}|~%22timeout%22&start=$ALERT_TIME&limit=100" \
| jq -r '.data.result[].values[].traceID' | sort -u)
for tid in $TRACE_IDS; do
tempo_result=$(curl -s "http://tempo:3200/api/traces/$tid" | jq -r '
.trace.batches[].scopes[].spans[] |
select(.duration > 2000000000) |
"\(.operationName) \(.duration/1000000)ms \(.attributes["service.name"])"')
echo "$tempo_result"
done
配置一致性保障机制
所有三件套组件均通过 GitOps 方式管理配置:Prometheus 的 recording_rules.yml、Loki 的 pipeline_stages、Tempo 的 otlp_http 接收器定义统一存放于 infra/observability/ 仓库。CI 流水线强制校验 traceID 字段在三者间格式一致(如正则 ^[a-f0-9]{32}$),避免因格式差异导致关联失败。
权限与数据生命周期协同
Loki 日志保留策略(7天)与 Tempo 追踪保留周期(30天)对齐,但 Prometheus 指标按粒度分层:原始样本保留15天,降采样后保留1年。权限模型采用统一 OIDC 认证网关,用户访问 /metrics /log /trace 接口时,RBAC 规则同步控制其可查看的服务命名空间。
真实压测验证结果
在 2023 年双十一大促前压测中,该工作流将平均故障定位时间从 47 分钟缩短至 6 分钟 23 秒。其中,92% 的接口超时问题在首次关联分析中即定位到下游服务线程池耗尽,而非盲目扩容前端节点。
资源开销优化实践
为降低三件套自身资源占用,实施三项关键调优:Loki 启用 chunks_cache 使用内存缓存而非 Redis;Tempo 的 search 组件关闭全量 span 存储,仅索引 traceID 和关键标签;Prometheus 配置 --storage.tsdb.min-block-duration=2h 减少 block 合并频率。集群整体 CPU 使用率下降 31%。
多租户隔离实施细节
在 SaaS 平台中,通过 Loki 的 tenant_id 标签、Tempo 的 X-Scope-OrgID Header、Prometheus 的 external_labels 三重绑定实现租户隔离。某客户投诉订单查询延迟时,诊断流程自动注入 tenant_id="customer-a" 上下文,避免跨租户日志污染。
可观测性数据血缘图谱
flowchart LR
A[Prometheus Alert] --> B{Webhook Trigger}
B --> C[Loki Log Query]
B --> D[Tempo Trace Lookup]
C --> E[Extract traceID]
D --> F[Span Analysis]
E --> F
F --> G[Root Cause: Redis Connection Leak]
G --> H[Auto-Remediation: Restart Redis Client Pool] 