Golang 进阶训练营

打印 上一主题 下一主题

主题 1010|帖子 1010|积分 3030

一、Golang 的 slice、map、channel

   1.1 slice vs array

  
  1. a := make([]int, 100) //切片
  2. b := [100]int{} //数组
复制代码
  array需指明长度,长度为常量且不可改变
array长度为其范例中的构成部分(给参数为长度100的数组的方法传长度为101的会报错)
array在作为函数参数时会产生copy
golang所有函数参数都是值传递
   array扩容:cap<1024时乘2,否则乘1.25,预先分配内存可以提升性能,直接使用index赋值而不是append可以提升性能
   slice作为参数被修改时,如果没有发生扩容,修改在原来的内存中;如果发生了扩容,修改会在新的内存中。
   使用[]Type{}或者make([]Type)初始化后,slice不为nil;使用var x[]Type后,slice为nil
   1.2 Map:

   map的值其实是指针,传map传的是指针,所以修改会影响整个map。
map的k、v都不可取地址,随着map的扩容地址会改变。map存的是值,会发生copy,因此不要在map里放很大的数组,很大的可以用指针来代替
map赋值会主动扩容,但删除时不会主动缩容。
map非线程安全,不能同时读写。
   1.3 Channel

   有锁
缓冲channel和非缓冲的区别,缓冲会发生两次copy,非缓冲发生一次
for+select closed channel会造成死循环,select中的break无法跳出for循环
   二、GOTT best practices

   2.1 可读性

   

  • if else 和 happy path:有错误应该提前返回,只管在正确返回时,不加 indents(缩进)。
  • init() 使用规范:在一些 package 中只管不要使用 init(),界说一个可以被调用的 Initxxx() 函数显示调用,防止运行一些使用方不知道的代码段。
  • Comments:只管写函数做了什么,而不是怎么做的。
   2.2 坚固性

   

  • panic: 在 defer 中举行 recover()
  • Errors:使用 errors.Is() 和 errors.As() 来判断 error 和断言 error
    相关文档
   2.3 效率

   

  • 指针:函数修改参数,应该传递指针;参数中含有大量的内容,避免拷贝可以传递指针;代码风格对齐,其他函数都是传递指针的;
    Tricks:布局体默认传递指针;对于 for 中界说的变量在循环中会变,取其地址得到的值永远是最后一个。
  • Deprecation:对于要废弃的函数,使用以下注释格式,从而使得 golintci-lint 能够检测而出来。
  
  1. // comments for the function
  2. //
  3. // Deprecated: use $funcName instead.
  4. func funcToBeDeprecated(){
  5. }
复制代码
  

  • 延伸阅读
    Golang on the Toilet (GOTT)
    https://tech.bytedance.net/topics/2841
   三、Golang 的强人锁难

   锁的紧张性:并发场景通过 goroutine 和 channel 来实现,但是 goroutine 之间可以共享内存和变量,导致直接修改变量的时间,会存在冲突。使用锁需要考虑的:性能、重入、公平
   3.1 强人:最佳实践

   

  • 淘汰持有时间,缩小临界区
    可以的情况下只管提前释放,或者新界说一个函数,函数内部实行临界区,以及上锁释放锁,函数后举行其他的逻辑操作
  • 优化锁的粒度
    空间换时间,分片操作,每个片加锁。
  • 读写分离
    RWMutex;sync.Map(空间换时间)
  • 使用原子操作,避免使用锁
    atomic
   3.2 锁难:避免踩坑

   

  • 不要拷贝Mutex
    golang 函数传参是复制拷贝,需要传入指针
  • 锁不能重入
    防止死锁,一个 goroutine 两次调用 lock 会导致死锁
  • atomic.Value 误用
    存入的应该是只读对象,如果存入一个 map,取出来对map操作,那么map还是存在并发读写题目
  • 使用 race detector
    go test\run\build\install -race xxx:加上 race 参数,用来加强单测和压测
   3.3 暗黑:锁的进化

   

  • 原子操作

    • 古代:英特尔 80386 处理器,因为是单核处理器,所以只需要锁CPU,关闭停止开关,如许操作就不会被停止,操作完再打开停止开关。低效:需要内核态来操作停止开关
    • 近代:汇编代码提供了 CMPXCHGL 指令,在该指令前加一个 Lock 前缀,会锁定内存总线。低效:内存总线称为瓶颈
    • 当代:MESI 缓存一致性协议(降低锁的粒度:总线锁->缓存行锁)。缓存行的状态,由硬件同步。MESI 为 Modified, Exclusive, Shared, Invalid 缩写。

      • Invalid:无效。初始化状态,或者内存不可用(被其他CPU修改,需要更新缓存)
      • Exclusive:独占。仅当前CPU缓存了该内存。
      • Shared:共享。多个CPU缓存了该内存。
      • Modified:已修改、未写回。需要其他CPU的缓存失效。某个CPU更新了缓存,但是还没写回内存,其他CPU缓存的该内存信息更新为 Invalid。


                       
                  状态转移图         

  • 自旋锁(Spin Lock)

    • Linux 内核中常见,得当等候时间比较小的场景
    • Go 1.14 版本之前,没有实现抢占式调度,必须某个 goroutine 交出控制权,因此自旋锁会导致死锁。如下图:A等候B释放锁,但是实行了GC,然后B被挂起,runtime需要等候A挂起,但是A在实行自旋锁,就发生了死锁。需要在自旋锁内部调用一次 runtime.GoSched 来交出 CPU 控制权

                       
                  自旋锁死锁样例         

  • Go's Mutex

    • 效率优先,兼顾公平。
    • Mutex 有自己的一个等候队列,有自己的状态 state(正常模式和饥饿模式)。正常模式包管效率,饥饿模式包管公平。state是一个共用字段,由锁标志位,唤醒标志位,饥饿标志位和阻塞的goroutine个数构成。

                                                
                                        Go's Mutex State 字段构成(mutexLocked mutexWoken mutexStarving 位为 1 分别表示锁占用、锁唤醒、饥饿模式、mutexWaiterShift 表示偏移量,默认为3,state>>=mutexWaiterShift,state的值就表示当前阻塞等候锁的goroutine个数。最多可以阻塞2^29个goroutine)
    • 正常模式:goroutine等候队列先进先出;新来的goroutine先去抢占锁,失败了再进入等候队列;如果发现某个抢到锁的 goroutine 等候时长 > 1ms,则切换到饥饿模式。
    • 饥饿模式:严酷排队,队首接盘;牺牲效率,包管Pct99;适时回归正常模式,包管效率。如果某个goroutine加锁成功后,如果发现这个goroutine位于队尾,或者等候时间小于1ms,那么就切换回正常模式。
    • 提高效率的点:1. 新来的先去抢锁,淘汰了调度开销。2. 充分使用缓存,提高实行效率。

                       
                  加锁流程                              
                  state位界说                              
                  自旋流程                              
                  加锁流程                              
                  解锁流程1(Slow)                              
                  解锁流程2                              
                  总结         

  • Go's Once
  
  1. type Once struct {
  2.     done uint32
  3.     m    Mutex
  4. }
  5. func (o *Once) Do(f func()) {
  6.     if atomic.LoadUint32(&o.done) == 0 {
  7.         // Outlined slow-path to allow inlining of the fast-path.
  8.         o.doSlow(f)
  9.     }
  10. }
  11. func (o *Once) doSlow(f func()) {
  12.     o.m.Lock()
  13.     defer o.m.Unlock()
  14.     if o.done == 0 {
  15.         defer atomic.StoreUint32(&o.done, 1)
  16.         f()
  17.     }
  18. }
复制代码
  

  • 源码很简单,如上:

    • 题目:1. 为什么 Do 内里用 atomic,doSlow 内里用 o.done==0?2. 为什么 doSlow 内里用 atomic 来设置 done?3. 为什么 doSlow 内里用 defer 设置值?能否直接设置?
    • 解答:1. Do 内里没有加锁,如果直接 o.done == 0 大概观测到非常规值,使用 atomic 包管操作有序。而 doSlow 内里已经是锁内部,不大概存在其他的 goroutine 修改值,因此可以直接观测。2. 由于大概存在其他 goroutine 在 Do 内观测 done 的值,因此需要 atomic 设置值来包管有序性。3. 不可以直接设置,如果直接设置值再实行f,那么大概 f 还没实行,别的 goroutine 已经观测到 done 为 1,直接 Do 中返回,但是由于 f 的初始化函数还没完成,从而导致 panic(空指针等)。因此在 f 未实行完的过程中,所有实行 once.Do 的 goroutine 都被阻塞在 doSlow 的 Lock 阶段,等候 f 实行完成才可以返回。
    • 异常:假设 Do 使用 o.done == 0 来观测值,读取的同时当 atomic 正在修改值时,读取到的值大概是异常值;假设使用 o.done=1 来设定值,实行的同时当其他 goroutine 在 Do 中读取 o.done 时,大概看到异常值。
    • 总结:这里的两个 atomic 是为了包管多个 goroutine 观测和设定同时发生的有序性。而锁操作的临界区内可以直接观测变量值。

  • Go's WaitGroup
    下文 第八部分。奇妙地避免了锁的使用。
  • 锁的进化总结
    单核:关停止->CAS指令
    多核:LOCK内存总线->MESI协议
    自旋锁:效率和公平不够好
    Go Mutex:效率优先,兼顾公平。
  • 思考探索
                       
                  思考探索         

  • 延伸阅读

    • 踩坑记:Go服务灵异panic:https://mp.weixin.qq.com/s/wmdmYDenmOY2un6ymlO6SA
    • Go: 关于锁的1234: https://mp.weixin.qq.com/s/TRE8_0wLYv22NHXpMmvKqw
    • Go中锁的那些姿势,估计你不知道: https://studygolang.com/articles/26030
    • Race Detector: https://blog.golang.org/race-detector
    • sync: mutex.TryLock:https://github.com/golang/go/issues/6123
    • Recursive locking in Go:https://stackoverflow.com/questions/14670979/recursive-locking-in-go

   四、Golang 并发数据布局和算法实践

   弁言

   scalable:当计算资源更多时,性能会有提升
   
                       
                  Golang数据布局并发测试(-x代表使用的CPU数)          4.1 并发安全题目

   

  • Data Race
    原因:多个 goroutine 同时接触一个变量,行为不可预知。
    认定条件:两个及以上 goroutine
    一写多读:atomic
    多写一读:Lock + atomic
    多写多读:Lock + atomic
   4.2 实践一:有序链表并行化

   界说插入删除的多个步骤,举例不同场景,考虑并发情况下是否满足,如何调整步骤。
   4.3 实践二:skiplist并行化

   从 4.2 延伸过来,每一层的 list 都可以使用 4.2 的实现。
   4.4 总结

   五、Golang 并发数据布局和算法实践

   5.1 调度循环的创建

   

  • GM 模子和 GMP 模子
  • 调度循环的创建
                       
                  调度循环的创建          5.2 协作与抢占

   

  • 调度器的坑
    go 运行一个死循环在 go1.13 版本会被卡死,go1.14 引入基于信号的抢占,从而不会被卡死。
  • Go 的调度方式
    协作式调度:依靠被调度放主动弃权
    抢占式调度:依靠调度器逼迫将被调度方停止
    s

                                     
                              Go的调度方式
                       
                  基于信号的抢占         

  • 小结
                       
                  协作与抢占小结          六、垃圾回收和Golang内存管理

   6.1 GC根本理论

   

  • 主动内存管理:Reference Counting 引用计数法;Tracing GC
  • Tracing GC:

    • 目标:找出活的对象,剩下的就是垃圾。
    • 两部分:GC root 和 GC heap。
    • 风格:Copying GC 和 Mark-Sweep GC。
    • 并发:Concurrent GC(GC过程用户代码不需要停下来) 和 Parallel GC(GC过程中用户代码停息)

   6.2 Go内存管理

                       
                  Go GC简史          历史

   

  • Go 1.10 版本从前接纳的方式是线性内存。所有申请的内存以Page方式分割,每个Span管理一个或者多个Page。Golang垃圾回收的时间,会通过判断指针的地址来判断对象是否在堆中。之后弃用原因:1. 在C,Go混用时,分配的内存地址会发生冲突,导致堆得初始化和扩容失败;2. 没有被预留的大块内存大概会被分配给 C 语言,导致扩容后的堆不连续。
  • Go 1.10 之后接纳稀疏内存管理。
   分配

   

  • 关键词

    • Tcmalloc 风格分配器:thread cache 分配器、三级内存管理
    • 按照不同大小分类:Tiny(8), Small(16~32K), Huge(>32K)
    • mcache 来减轻锁的开销(每个处理器 P 维护一段内存)
    • 表里部碎片
    • Object 定位
    • Bitmap 标志

  • 总览

    • Go在步伐启动时,会向操作体系申请一大块内存,之后自行管理。
    • Go内存管理的根本单元是mspan,它由若干个页构成,每种mspan可以分配特定大小的object。
      mcache, mcentral, mheap是Go内存管理的三大组件,层层递进。mcache管理线程在本地缓存的mspan;mcentral管理全局的mspan供所有线程使用;mheap管理Go的所有动态分配内存。
    • 极小对象(小于16字节)会分配在一个object中,以节流资源,使用tiny分配器分配内存;一般小对象(16字节到32768字节)通过mspan分配内存,根据对象大小选择对应的额mspan;大对象(大于32768字节)则直接由mheap分配内存,并记载 spanClass=0。

      • 微对象 (0, 16B) — 先使用微型分配器,再依次实验线程缓存、中心缓存和堆分配内存;(注:对于(0, 16B) 的指针对象,直接归类为小对象。微型分配器不分配指针范例对象)
      • 小对象 [16B, 32KB] — 依次实验使用线程缓存、中心缓存和堆分配内存;
      • 大对象 (32KB, +∞) — 直接在堆上分配内存;


  • 详解
    与TCMalloc非常类似.Golang内存分配由mspan,mcache,mcentral,mheap构成。可以说根本对应了TCMalloc中的Span,Pre-Thread,Central Free List,以及Page Heap。分配逻辑也很像TCMalloc中依次向前端,中端,后端请求内存。
                       
                  Golang内存管理组件         

  • 在Golang的步伐中,每个处理器都会分配一个线程缓存 mcache 用于处理微对象以及小对象的内存分配,mcache管理的单位就是mspan。

    • mcache会被绑定在并发模子中的 P 上.也就是说每一个 P(处理器) 都会有一个mcache,用于给对应的协程的对象分配内存;
    • mspan 是真正的内存管理单元,其根据界说的 67 种 spanClass 来管理内存(从8bytes到32768bytes==32KB),不同大小的对象,向上取整到对应的 spanClass 中管理。type spanClass uint8,其实 spanClass 的载体就是一个8位的数据,他的前七位用于存储当前 mspan 属于68种的哪一种,最后一位代表当前 mspan(当前对象) 是否存储了指针,这个非常紧张,因为是否存在指针意味着是否需要在垃圾回收的时间举行扫描;
    • mcache中的缓存对象数组 alloc [numSpanClasses]*mspan 一共有(67) * 2个,此中*2是将spanClass分成了有指针和没有指针两种,方便与垃圾回收;

  • 如果mcache中缓存的对象数目不够了,也就是alloc数组中缓存的对象不敷,会向mheap持有的 numSpanClasses*2 个mcentral获取新的内存单元(这里的 *2 也是mcache中的 *2,对应了无指针和有指针)

    • 每个 mcentral 维护一种 mspan,而 mspan 的种类会导致其分割的 object 大小不同。mcentral 被所有的工作线程共同享有,存在多个Goroutine竞争的情况,因此会消耗锁资源;
    • mcache向 mcentral 申请空间的方法 mheap_.central[spc].mcentral.cacheSpan()

  • mcentral中心缓存是属于全局布局mheap的,mheap就是用来管理Golang所申请的所有内存,如果mheap的内存也不够,则会向操作体系申请内存
  • heapArena用于管理真实的内存
   回收

                       
                  Go GC         

  • STW Mark
  • Concurrent mark
  • Mark-Sweep

    • 三色法 黑灰白:黑 标活且内容全部扫描完;灰 标活且内容未扫描完;白 未扫描到

  • Non-generational
   何时触发回收

   

  • GOGC threshold。阈值,假设设定 export GOGC=100,那么每次GC竣事后,剩余活对象的内存占用空间的两倍(1+$(GOGC)%)作为下次GC的阈值,达到或者超过,则启动GC
  • runtime.GC()
  • runtime.forcegcperiod(2min)
   3.编程者指南

   六、性能 pprof 工具

                       
                  pprof工具         

  • 使用方式:go tool pprof -http=:8080。输入网页检察:http://localhost:6060/debug/pprof

    • 网页后缀 /profile 检察 CPU 采样信息
    • 网页后缀 /heap 检察 堆占用 采样信息

   七、缓存相关

   1. local cache

                       
                  local cache 对比                              
                  local 选型          大key题目:
   

  • 考虑拆分成多个key来存储

    • 比如用hash取余/位掩码的方式决定放在哪个key中
    • 对于需要全量数据的场景,会增加一定数据请求和组装的资源

  • 考虑拆分冷热数据

    • redis中只存储热数据,对于命中率不高的冷数据,使用其他异构数据库
    • 如粉丝列表场景使用zset,只缓存前10页数据,后续走db/hbase

   保举阅读:
《redis redlock 是否可靠?》
   八、内存对齐

   依次看:Golang 是否有须要做内存对齐?、Golang 内存对齐
简单总结:对齐是因为CPU不是支持任意字节获取内存的,而是一块一块获取,所以对齐的好处是防止CPU需要两次操作才能读取数据,从而降低效率。如果未对齐,则通过padding来补齐未对齐部分。x86 是4字节对齐,现在的64位体系通常是8字节对齐(比如 int64 刚好够,int8 int32 单独的就需要补齐,同时出现的话可以将 int32 补在 int8 后面,形成1字节int8,3字节padding,4字节int32的8字节对齐格式)。
Go Struct 偏移量还会内存分配的知识点:比如 SpanClass 的选定也会影响到数据分配的偏移量。(32, 48] 字节的 struct 会使用 48 字节的 Span。因此即使是序次分配,也是 48 字节的 offset 间隔。
   

  • 内存对齐使用举例:Go WaitGroup
    state函数会判断编译器是否是8字节对齐来决定 waiter 计数器、counter 计数器以及信号量的排列序次。
  
  1. type WaitGroup struct {
  2.    noCopy noCopy      // 辅助vet工具检查是否通过copy赋值WaitGroup
  3.    state1 [3]uint32   // 数组,组成 waiter 计数器、counter 计数器以及信号量
  4. // counter 代表目前尚未完成的个数。WaitGroup.Add(n) 将会导致 counter += n, 而 WaitGroup.Done() 将导致 counter--。
  5. // waiter 代表目前已调用 WaitGroup.Wait 的 goroutine 的个数。
  6. // sema 对应于 golang 中 runtime 内部的信号量的实现。
  7. //   WaitGroup 中会用到 sema 的两个相关函数,runtime_Semacquire 和 runtime_Semrelease。
  8. //   runtime_Semacquire 表示增加一个信号量,并挂起 当前 goroutine。
  9. //   runtime_Semrelease 表示减少一个信号量,并唤醒 sema 上其中一个正在等待的 goroutine
  10. }
  11. func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
  12.    if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 { // 8字节对齐
  13.       return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
  14.    } else { // 4字节对齐
  15.       return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
  16.    }
  17. }
复制代码
                     
                  Go WaitGroup state 字段含义(如果是8字节对齐,也就是满足第一个 if,那么使用前两个 uint32 构成一个 uint64 来返回,根据移位操作确认 waiter 和 counter 值,如果是4字节对齐,那么 if 条件判断失败,使用后两个 uint32 构成一个 uint64 返回。固然,这里 4 字节对齐的时间,也大概 state1 刚好处于 8 字节对齐的位置,那么会按照 8 字节对齐处理,这紧张取决于内存分配时的具体情况。)         

  • Add 操作
    使用规范:默认使用者传入的 delta 为正数,使用者不应该传入 Add 函数一个负数
  
  1. func (wg *WaitGroup) Add(delta int) {
  2.    statep, semap := wg.state()
  3.    // delta左移32位,将delta原子添加到高位计数器上
  4.    state := atomic.AddUint64(statep, uint64(delta)<<32)
  5.    v := int32(state >> 32)    // 右移32位,获取高32位计数器,因为 v 存在被 delta 操作,所以可能为负数。
  6.    w := uint32(state)         // 高位截断,获取低32位Waiter计数器,w 只可能在 Wait 函数中被 atomic +1,不可能为负数
  7.    if v < 0 {                 // 计数器不能小于0(使用者非预估:调用 Add 加了负数)
  8.       panic("sync: negative WaitGroup counter")
  9.    }
  10.    // 计数器数据不一致,计数器和delta一样的情况下,waiter 不是 0(使用者非预估:调用 Add 且调用 Wait 时,又调用 Add,导致并发问题)
  11.    if w != 0 && delta > 0 && v == int32(delta) {
  12.       panic("sync: WaitGroup misuse: Add called concurrently with Wait")
  13.    }
  14.    if v > 0 || w == 0 { // 正确add,return
  15.       return
  16.    }
  17.    // 走到这里,说明 v==0 && w>0 (v<0被panic,v>0被返回,w==0被返回)
  18.    if *statep != state { // state数值发生不一致(使用者非预估:v==0时,w发生变化,说明在Add调用过程中,Wait或者Add被非预估调用)
  19.       panic("sync: WaitGroup misuse: Add called concurrently with Wait")
  20.    }
  21.    *statep = 0 // 直接至零,表明 v==0 且 w==0,同时唤醒所有的 wait 状态的 goroutine,只有最后一个 Done 的 goroutine 会这么做
  22.    for ; w != 0; w-- { // 依次唤醒
  23.       runtime_Semrelease(semap, false, 0) // 释放信号量
  24.    }
  25. }
复制代码
  

  • Done 操作
    预期内只有调用 Done 时,才会调用 Add 并传入负值
  
  1. // Done decrements the WaitGroup counter by one.
  2. func (wg *WaitGroup) Done() {
  3.     wg.Add(-1)
  4. }
复制代码
  

  • Wait 操作
    通过自旋和乐观锁,包管计数器正确被更新
  
  1. func (wg *WaitGroup) Wait() {
  2.    statep, semap := wg.state()
  3.    for { // 自旋循环
  4.       state := atomic.LoadUint64(statep)
  5.       v := int32(state >> 32)  // 右移32位,获取高32位计数器
  6.       w := uint32(state)       // 高位截断,获取低32位Waiter计数器
  7.       if v == 0 {              // 计数器为0,不需要继续等待
  8.          return
  9.       }
  10.       // 如果计数器不为0,调用wait方法的goroutine需要等待,等待计数器+1,并发调用安全
  11.       // 如果 state 发生了变化,则自旋,并重新观测 counter 并更新 waiter
  12.       if atomic.CompareAndSwapUint64(statep, state, state+1) {
  13.          runtime_Semacquire(semap) // 获取信号量
  14.          // 被唤醒时,肯定是最后一个 goroutine Done,并依次唤醒所有的在 Wait 的 goroutine,此过程中不期望 state 发生变化(即存在并发的 Done 操作或者 Add 操作或者 Wait 操作)
  15.          if *statep != 0 {         // 在wait返回前,WaitGroup被重用了(不期望的事情发生了)
  16.             panic("sync: WaitGroup is reused before previous Wait has returned")
  17.          }
  18.          return
  19.       }
  20.    }
  21. }
复制代码
  

  • 思考:

    • 把 waiter 和 counter 归并成一个变量:
      为了避免使用锁,直接使用 atomic 操作,包管两者的改动是同时的。比如Wait时通过乐观锁举行操作,如果同时Done或者Wait被调用,那么会自旋重新观测v,如果v==0则直接返回,否则 waiter 计数+1且进入挂起等候。
    • 冲突考虑(这里的 Add 表示 Add正值,Add负值的情况视为Done):Add 和 Wait 不能并发,Add 和 Done 可以并发,Wait 和 Done 可以并发。

  • 使用规范:

    • Add 不能和 Wait 并发调用,必须由一个 goroutine 调用这两个函数。
    • 不应该 Add 一个负值。Done 即为 Add(-1)。
    • 不能在 Add 之前调用 Done。

         ©    著作权归作者所有,转载或内容合作请联系作者     

喜欢的朋友记得点赞、收藏、关注哦!!!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

水军大提督

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表