前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >图解sync.Map

图解sync.Map

作者头像
数据小冰
发布2022-08-15 14:43:31
1.1K0
发布2022-08-15 14:43:31
举报
文章被收录于专栏:数据小冰

前面的两篇文章《2万字图解map》,《从应用层面细说map》从原理层面和应用层面分别介绍了内建map是如何实现的、如何使用,以及使用过程中需要注意的地方。本篇文章将介绍sync.Map的使用和实现分析,通过图解的方式分析p的状态,为什么有expunged状态的存在。建议结合前两篇文章一起阅读,能够更深入的理解map。

sync.Map是什么

Go内建的map类型不是线程安全的,sync.Map是Go1.9中新增加的一个线程安全的map. sync.Map的添加、查询和删除元素操作的时间复杂度与内建的map是一样的,都是常数级别的。与内建map不同的是 ,sync.Map的零值是一个有效的值,它是一个空的map。需要注意的是,sync.Map并不是用来替换内建的map类型,它只能被应用在一些特殊的场景中。

为什么有sync.Map

内建的map不是线程安全的,虽然内建的map+Mutex或map+RWMutex可以保证线程安全,但在下面的两个场景中,使用sync.Map会比使用map+RWMutex性能要好很多。

  • 在只会增长的缓存系统中,一个key只写入一次而被读很多次
  • 多个goroutine在对不同的键值进行读、写和重写操作时 在实际使用的时候,还是要对当前的场景对sync.Map进行性能测评,与传统的map进行对比如果确实能够提高性能,再使用sync.Map.

怎么使用sync.Map

sync.Mapa支持基本的添加元素、查询元素、遍历、删除元素四种操作,此外还支持LoadOrStore操作。Load方法获取一个键值对的value,如果key不存在,返回的value是nil,如果key存在,返回的value是一个interface类型,需要断言成真正的类型。sycn.Map遍历使用Range方法,需要传入一个函数,函数的入参和出参已经确定了,是func(k,v interface) bool。k和v是遍历到的key和value,在函数内部实现自己的业务逻辑。

代码语言:javascript
复制
package main

import (
 "fmt"
 "sync"
)

func main() {
 // m零值是有效的,可以直接使用
 var m sync.Map

 // 添加元素到sync.Map
 m.Store("name", "mingyong")
 m.Store("age", 20)

 // 访问sync.Map中元素
 v, ok := m.Load("name")
 fmt.Println(v, ok)

 // 遍历sync.Map
 m.Range(func(k, v interface{}) bool {
  fmt.Println(k, v)
  return true
 })

 // 删除sync.Map中的元素
 m.Delete("name")
}

sync.Map实现分析

下面将从数据结构定义,到基本的添加元素(Store方法)、查询元素(Load方法)、遍历元素(Range方法)和删除元素(Delete方法)操作,结合源码来分析sync.Map的实现,以及通过图解的方式分析为什么有expunged状态的出现。「注意,下面分析的代码是Go 1.14 darwin-amd64版本」

数据结构定义

sync.Map的结构图如下图所示,可以结合下面的结构体定义一起看,就很容易理解了。

先来看下sync.Map的结构定义,它的定义如下,可以看到它只有4个字段。其中mu锁字段用户保护dirty,因为dirty是一个内建的map,是非线程安全的,read也是一个map,对read的读取不需要加锁处理。misses是一个计数器,记录在从read中读取数据的时候,没有命中的次数,一旦misses值和dirty长度相同之后,会把dirty内容提升为read.

代码语言:javascript
复制
type Map struct {
 mu Mutex
    
 // 把read看成一个安全的只读的map.atomic.Value是一个interface类型的结构体,里面实际装的是
 // 一个map,对atomic.Value中的元素更新是通过原子操作进行的,
 read atomic.Value // readOnly

 // dirty需要使用上面的mu加锁才能访问里面的元素,dirty中包含所有在read字段中但未被expunged(删除)
 // 的元素以及新加的元素
 dirty map[interface{}]*entry

 // misses是一个计数器,记录在从read中读取数据的时候,没有命中的次数,一旦misses值和dirty长度
 // 一样之后,会把dirty内容提升为read
 misses int
}

Map中的read字段是atomic.Value类型,它里面实际存储的结构为readOnly,readOnly结构如下,以原子方式存储在read中

代码语言:javascript
复制
// readOnly是存在Map结构中read字段中的内容,它以原子方式存储
type readOnly struct {
 m map[interface{}]*entry
 // amended为true表示dirty中包含read中没有的数据,为false表示dirty中的数据在read都
 // 存在
 amended bool // true if the dirty map contains some key not in m.
}

expunged用来标识map中的key是已经删掉的指针,在sync.Map删除一个key时,并不是立即将其从map中删除,而是将key对应的value标记为nil或者expunged,在以后的处理过程中才有机会真正删除

代码语言:javascript
复制
// expunged用来标识map中的key是已经删掉的指针,在sync.Map删除一个key时,并不是立即将其从
// map中删除,而是将key对应的value标记为nil或者expunged,在以后的处理过程中才有机会真正删除
var expunged = unsafe.Pointer(new(interface{}))

entry是dirty这个map value对应的类型,它代表一个值,entry是对*interface{}做了一个结构体的包装。如果dirty字段非nil,map的read字段和dirty字段会包含相同的非expunged的数据项,所以如果通过read字段更改了这个项的值,从dirty字段中也会读取到这个项的新值,因为它们指向的是同一个地址。

代码语言:javascript
复制
type entry struct {
 p unsafe.Pointer // *interface{}
}
Store方法

Store方法用来保存或更新一个键值对。它既可以是新增元素,也可以是更新元素。如果更新的元素在read中已存在,在下面的两种情况下会直接更新,不会用到锁:

  • key在read中,p=nil,并且dirty中不存在数据
  • key在read中,p=&entry,表示key存在,且指向一个真实的value.

在下面的两种情况下更新会用到锁:

  • key在read中,p=expunged,表示key已被删除,dirty中存在数据且该key不在dirty中
  • key不在read中 使用到了锁的时候,性能就会下降。所以sync.Map比较适合那些只会增长的缓存系统,可以进行更新操作,但最好不要删除,并且不要频繁地增加新元素。因为新增元素,会加入到dirty中,对dirty操作需要加锁。在dirty为nil,新加元素的时候,会创建一个新dirty,将read中的有效的key-value键值对复制到新dirty中,read中已删除的key(value为nil或者expunged)不会复制到dirty中。
代码语言:javascript
复制
// Store保存或更新一个键值对
func (m *Map) Store(key, value interface{}) {
 // 检查key是否在read中存在
 read, _ := m.read.Load().(readOnly)
 // 如果key在read中,有3种情况:
 // 1.p=nil,表示key已删除,并且dirty中不存在数据
 // 2.p=expunged,表示key已删除,dirty中存在数据且该key不在dirty中
 // 3.p=&entry,表示key存在,指向一个真实的value
 // 对情况1和情况3,直接将value的值存在p中,对情况2不存value,继续走后面的逻辑
 if e, ok := read.m[key]; ok && e.tryStore(&value) {
  return
 }

 m.mu.Lock()
 // 加锁后,继续检查read中是否有key存在
 read, _ = m.read.Load().(readOnly)
 // key在read中,继续检查key是否已经被删除
 if e, ok := read.m[key]; ok {
  if e.unexpungeLocked() {
   // 如果key已被删除,并且处于expunged状态,说明此key存在read但不在dirty中
   // 并且此时dirty非空,需要将此key加入到dirty中,并且更新e.p的值指向value
   m.dirty[key] = e
  }
  e.storeLocked(&value)
 } else if e, ok := m.dirty[key]; ok {
  // key不在read中但在dirty中,直接更新dirty中e.p的值,指向value
  e.storeLocked(&value)
 } else {
  // 走到这里说明key既不在read中也不在dirty中,肯定是一个新的key.
  // 并且dirty中所有的key都在read中
  if !read.amended {
   // 如果dirty为nil,需要创建dirty对象,并且标记read的amended为true,
   // 说明有元素存在于dirty中但不在read中
   m.dirtyLocked()
   m.read.Store(readOnly{m: read.m, amended: true})
  }
  // new一个新entry,将新值加入到dirty对象中
  m.dirty[key] = newEntry(value)
 }
 m.mu.Unlock()
}

// tryStore尝试将value的值存在e.p中
func (e *entry) tryStore(i *interface{}) bool {
 for {
  p := atomic.LoadPointer(&e.p)
  // 如果p为expunged,不能直接存储,因为此时的read中所有处于非expunged状态的key都
  // 在dirty中,将key加回到read的时候,也需要将其加入到dirty中,此处不处理这种情况
  // 直接返回
  if p == expunged {
   return false
  }
  // p为nil或指向&entry对象,设置e.p为i的值,即将e.p指向存入的value
  if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
   return true
  }
 }
}

// unexpungeLocked将e.p从expunged修改为nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
 return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}

// storeLocked原子操作,将i存储到e.p中,此处的i不能是expunged值
func (e *entry) storeLocked(i *interface{}) {
 atomic.StorePointer(&e.p, unsafe.Pointer(i))
}

func (m *Map) dirtyLocked() {
 // 如果dirty字段已经存在,就不需要在创建了
 if m.dirty != nil {
  return
 }
 // 获取read字段
 read, _ := m.read.Load().(readOnly)
 m.dirty = make(map[interface{}]*entry, len(read.m))
 // 遍历read字段,将里面不是punged状态的键值对复制到dirty中
 for k, e := range read.m {
  if !e.tryExpungeLocked() {
   m.dirty[k] = e
  }
 }
}
Load方法

Load方法返回一个key对应的value值。优先从read中开始查询,因为访问read不需要加锁。如果很幸运,可以从read中读取到了key对应的value,就不用加锁,这种情况下性能是最好的。但是,如果请求的key不在read中,并且dirty中存在有元素不在read中,需要进一步查询dirty,对dirty操作需要加锁。所以,读取不在read中的key会因为加锁而导致性能下降。Load操作过程中可能存将dirty提升为read操作,在查询dirty的时候,会执行missLocked操作,该操作会增加misses值,如果misses值等于dirty长度,就会将dirty提升为read,并将dirty置为空。

代码语言:javascript
复制
// Load方法返回key对应的value,第二bool型参数表示key是否在map中存在
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
 read, _ := m.read.Load().(readOnly)
 // 优先从无锁map read中读取
 e, ok := read.m[key]
 // 如果无锁map中不存在并且dirty中有元素不在read中,进一步从dirty中读取
 if !ok && read.amended {
  m.mu.Lock()
  // 双重检查,进一步查看read中是已有key存在
  read, _ = m.read.Load().(readOnly)
  // 如果key已存在,直接返回对应的value
  e, ok = read.m[key]
  // key还是不存在,并且dirty中有数据,不得不检查dirty中是否有
  if !ok && read.amended {
   // 从dirty中获取数据
   e, ok = m.dirty[key]
   // 不管key在不在dirty中,命中记录数misses都会加1,当misses大于等于
   // dirty中数据元素的个数时,dirty中的数据会被提升到read中,提升之后
   // dirty将会被清空,命中记录数misses清零
   m.missLocked()
  }
  m.mu.Unlock()
 }
 // key在read和dirty中都不存在,返回nil和false
 if !ok {
  return nil, false
 }
 // 返回value值,e可能来自read也可能来自dirty,所以value可能是从read获得的,也可能是从
 // dirty获得的
 return e.load()
}

func (m *Map) missLocked() {
 // misses计数+1
 m.misses++
 // 如果没有达到临界值(dirty的长度),直接返回
 if m.misses < len(m.dirty) {
  return
 }
 // 将dirty字段的内容提升为read
 m.read.Store(readOnly{m: m.dirty})
 // 清空dirty,dirty为map类型,清空方法是直接赋值nil,让GC清理掉里面的内容
 m.dirty = nil
 // misses计数重置为0
 m.misses = 0
}

func (e *entry) load() (value interface{}, ok bool) {
 // 原子操作,获取e.p中值
 p := atomic.LoadPointer(&e.p)
 // p为nil或expunged状态都表示key被删除了,所以直接返回nil,false
 if p == nil || p == expunged {
  return nil, false
 }
 // 返回value的值
 return *(*interface{})(p), true
}


Range方法

Range方法对sync.Map进行遍历,需要传入一个func(key, value interface{}) bool类型的函数f, 对遍历到的每个键值对调用f进行处理。如果函数f返回false, 对sync.Map的迭代将会停止。

代码语言:javascript
复制
// Range 方法对sync.Map进行遍历操作,需要传入一个func(key, value interface{}) bool类型的
// 函数f,会对遍历到的键值对调用f进行处理,如果函数f返回false,对sync.Map的迭代将停止。
// Range 方法在遍历的时候会对sync.Map的元素至多访问一次,如果在执行Range操作的时候,有其他协程并发
// 的添加或删除元素,可能会导致有些元素未被遍历到。
// Range 方法是一个O(N)时间复杂度的操作,对于存在元素在dirty不在read的情况,进行了一个优化,将dirty
// 提升为read了,所以下次在进行Range的时候,直接对read进行遍历,不用加锁。
func (m *Map) Range(f func(key, value interface{}) bool) {
 
 // 如果所有的元素都在read中,直接对read进行遍历
 read, _ := m.read.Load().(readOnly)
 // 确认是否有元素存在dirty中而不在read中
 if read.amended {
  m.mu.Lock()
  read, _ = m.read.Load().(readOnly)
  if read.amended {
   // 有元素在dirty中,对dirty进行遍历
   read = readOnly{m: m.dirty}
   // 进行一个优化,将dirty提升为read
   m.read.Store(read)
   // 将dirty提升为read之后,dirty置为nil
   m.dirty = nil
   // 计数器清理0
   m.misses = 0
  }
  m.mu.Unlock()
 }
    // 对遍历到的每个元素,调用传入的函数f进行处理
 for k, e := range read.m {
  v, ok := e.load()
  if !ok {
   continue
  }
  if !f(k, v) {
   break
  }
 }
}

Delete方法

Delete方法删除一个元素,同样还是优先检查key是否在read中,如果在read中,就不需要检查dirty了。为啥呢?key在read中分为两种情况,一种是此key只在read中,不在dirty中,很好反正不在dirty中,直接将read中元素删掉就行了,注意此处的删除并不是真正删除,而是标记为一个删除的状态,方便后面又有操作加入此key时,直接修改read中e.p的值。另一种是此key也存在dirty中,此时dirty中key对应的e和read中该key对应的e是同一个,为什么是同一个后面在介绍e.p的状态时有详细说明,这里只需明白它们是同一个e,这种情况将e.p设置为nil,其实也是将dirty中e.p也设置为了nil. 如果key在read中不存在,恰好当前存在元素在dirty中而不在read中,则需要进一步确认key是否在dirty中,这种情况需要加锁,如果key在dirty中,直接调用delete将dirty中的key删除。重复调用Delete操作删除同一个key也是可以的,只有第一次会标记删除,后面调用不做处理,因为此key对应的value已标记为删除状态了。

代码语言:javascript
复制
// Delete删除一个key
func (m *Map) Delete(key interface{}) {
 // 先检查key是否在read中
 read, _ := m.read.Load().(readOnly)
 e, ok := read.m[key]
 // 如果key不在read中,并且此时dirty中存在数据不在read中,继续检查dirty
 if !ok && read.amended {
  m.mu.Lock()
  // 加锁再进行一次检查
  read, _ = m.read.Load().(readOnly)
  e, ok = read.m[key]
  if !ok && read.amended {
   // 直接从dirty中删除key,此时read中是不存在的,dirty中删掉之后,两边都是
   // 不存在了
   delete(m.dirty, key)
  }
  m.mu.Unlock()
 }
 // 满足ok为true,read中肯定是有该key的, dirty有两种情况:
 // 1是dirty中没有该key, 2是dirty中也有该key
 // 对于情况1,因为dirty中不存,直接将read中e.p设置为nil,标记为删除状态
 // 对于情况2,dirty中key对应的e和read中该key对应的e是同一个,所以直接将
 // read中的e.p设置为nil,其实也是将dirty中e.p也设置为nil了
 if ok {
  e.delete()
 }
}

func (e *entry) delete() (hadValue bool) {
 for {
  p := atomic.LoadPointer(&e.p)
  // p为nil和expunged都表示之前已删除过了,直接返回false表示未进行实际的删除
  if p == nil || p == expunged {
   return false
  }
  // 将e.p置为nil, key并未从read中删除,如果key存在于dirty中,key也是未从dirty
  // 中删除的
  if atomic.CompareAndSwapPointer(&e.p, p, nil) {
   return true
  }
 }
}
LoadOrStore

LoadOrStore方法可以看做Load操作和Store操作的组合,如果key已经在sync.Map中,返回当前key对应的value,否则将存储传入的value值。

代码语言:javascript
复制
// LoadOrStore 可以看做Load操作和Store操作的组合,如果key已存在m中(无论是在read中还是dirty中),
// 就是只要key没有被删除,就返回当前的key对应的value值,否则将存储传入的value值,
// 第二返回参数是一个bool值,表示最后执行的是load操作还是store操作
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) {
 // 还是优先检查read,避免加锁
 read, _ := m.read.Load().(readOnly)
 if e, ok := read.m[key]; ok {
  // 尝试load和store操作,如果ok为true,表示load成功
  actual, loaded, ok := e.tryLoadOrStore(value)
  if ok {
   return actual, loaded
  }
 }

 m.mu.Lock()
 // 双重检查
 read, _ = m.read.Load().(readOnly)
 if e, ok := read.m[key]; ok {
  // e.p为expunged状态,表示m.dirty非空且dirty不存在该key
  // 需要将key-value加到dirty中,这里dirty和read实际上key
  // 指向的是同一个e,更新e值,dirty和read中都存有该key-value了
  if e.unexpungeLocked() {
   m.dirty[key] = e
  }
  actual, loaded, _ = e.tryLoadOrStore(value)
 } else if e, ok := m.dirty[key]; ok {
  // key在dirty中不在read中,
  actual, loaded, _ = e.tryLoadOrStore(value)
  m.missLocked()
 } else {
  if !read.amended {
   m.dirtyLocked()
   m.read.Store(readOnly{m: read.m, amended: true})
  }
  m.dirty[key] = newEntry(value)
  actual, loaded = value, false
 }
 m.mu.Unlock()

 return actual, loaded
}

func (e *entry) tryLoadOrStore(i interface{}) (actual interface{}, loaded, ok bool) {
 p := atomic.LoadPointer(&e.p)
 // key已被删除,需要进一步判断处理,这里先终止处理
 if p == expunged {
  return nil, false, false
 }
 // key存在,value值有效,直接返回之前的value值,即执行Load操作
 if p != nil {
  return *(*interface{})(p), true, true
 }

 // 走到这里说明key也是已经被删除,e.p为nil,并且dirty是空的,所以直接将i存储在e.p中即可,不用关心dirty
 ic := i
 for {
  // 原子更新e.p的值,更新前为nil
  if atomic.CompareAndSwapPointer(&e.p, nil, unsafe.Pointer(&ic)) {
   return i, false, true
  }
  // 进一步判断e.p是不是被别的地方已经修改为非nil了
  p = atomic.LoadPointer(&e.p)
  // 如果p为expunged,说明key在其他地方已经被删除了,需要进一步判断处理,这里先终止处理
  if p == expunged {
   return nil, false, false
  }
  // e.p已经被其他地方设置值了,这里直接返回已设置的值
  if p != nil {
   return *(*interface{})(p), true, true
  }
 }
}
p的三种状态

再回头看下sync.Map的数据结构,无论是dirty还是read中的m它们都是内建的map,并且这个map的value是*entry类型。entry的定义如下

代码语言:javascript
复制
type entry struct {
 p unsafe.Pointer // *interface{}
}

可以看到, entry是对unsafe.Pointer做了一层包装,这里标题中说的p就是entry中的p. p有三种状态,分别是nil, expunged, 正常状态(指向一个有效的value地址)

为什么搞这么复杂,只有nil和正常状态两种状态可以吗?可以的,之所以搞这么复杂,是为了尽可能榨干性能,提升程序的性能。下面通过图解的方式分析p中的expunged,看完之后你就很清楚的明白expunged是干啥的,为什么有expunged状态。将read和dirty理解为两个集合,key是在read还是dirty中,理解为key是在集合read和还是在集合dirty中。这里重点是梳理清楚p的状态,所以图中只画了key,不关心value细节。每个集合中的一个小方块代表一个key。不同颜色的小方块代表的含义不同,具体含义如下:

对于一个新的sync.Map,开始向里面添加两个元素,key分别为a和b.得到集合状态如下图所示,此时read是空的,dirty中有两个元素。

然后进行两次查询元素操作,因为read为空,两次查询都是从dirty中获取到的,misses未命中计数达到dirty的长度,会将dirty提升为read,并将旧dirty清空,所以得到如下集合状态,read集合中有两个元素,dirty集合是空的。

然后执行删除(key为a)元素操作,将a对应的e.p设置为nil,标记为删除状态,并不是直接delete掉。此时dirty中没有元素,read.amended为false,所以无需对dirty做任何处理。得到的集合状态如下图所示。

在上图的状态的基础上,向里面添加新元素c,因为c在read和dirty中都不存在,需要将其添加到dirty中(注意,添加新元素都是添加在dirty中)。在添加c到dirty之前,需要将read中非删除的元素(key为b)拷贝一份到dirty中,并将read中删除的元素即e.p=nil修改为e.p=expunged状态。得到的集合状态如下图所示。

好了,现在可以分析为什么有e.p=expunged这个状态了,对比上面两张图,可以看到他们的差别是一个dirty是空的,另一个dirty是非空的。现在向里面重新添加回元素a的时候,对于key为a对应的e.p=nil状态,直接在read中添加,这个过程是不需要进行加锁的。但是对于key为a对应的e.p=expunged状态,需要在read和dirty中都添加,这个过程是需要进行加锁的。经过上面的分析,我们也就明白了,为什么要搞一个expunged状态,是为了dirty为空的时候,直接对read进行操作不用加锁,提升程序性能。

总结

理解sync.Map就是要搞清楚read和dirty中元素的分布情况,什么情况下元素在read中什么情况下元素在dirty中,记住一句话,「只要dirty不是nil,dirty中拥有所有的元素,这个时候最全的数据以dirty为准,只要dirty为nil,所有的元素都在read中,这个时候最全的数据以read为准」。下面对sync.Map一些要点进行一个总结。

  • sync.Map通过读写分离,优先从read中读,写往dirty中写,减少锁的使用来提高效率。
  • sync.Map是线程安全的,多个线程可以并发执行。
  • sync.Map的添加、查询和删除元素操作都保持着O(1)的时间复杂度。
  • sync.Map并不是替代内建map的,主要用在读多写少和多个goroutine并发操作不同key的场景中。
  • 删除sync.Map中一个元素,key可能并不是立即删除。如果key存在于read中,会延迟删除,具体删除时间是下一次dirty提升为read的时候。如果key不存在read而存在于dirty中,会立即删除掉。
  • sync.Map的Load、Store、Range等方法中,如果read中元素不存在,加锁后还要双检查,防止在从加锁前已有其他goroutine修改了read.
  • 调用Load或LoadOrStore方法时,如果read中没有该元素,会进行misses+1操作,当misses值和dirty长度相同时,会将dirty提升为read,以减少“miss”读。

Go sync.Map 看一看[1]深入理解Go-sync.Map原理剖析[2]

Reference

[1]

Go sync.Map 看一看: https://segmentfault.com/a/1190000018657984

[2]

深入理解Go-sync.Map原理剖析: https://segmentfault.com/a/1190000020325763

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-07-02,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • sync.Map是什么
  • 为什么有sync.Map
  • 怎么使用sync.Map
  • sync.Map实现分析
    • 数据结构定义
      • Store方法
        • Load方法
          • Range方法
            • Delete方法
              • LoadOrStore
                • p的三种状态
                • 总结
                  • Reference
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档