当前位置:   article > 正文

超干货!彻底搞懂Golang内存管理和垃圾回收

golang释放内存和内存被回收啥区别

c8b0e787a1ff1b555f635e7acefc49cb.png

导语 | 现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而Java和Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,开发者只需关注业务代码而无需关注底层内存分配和回收,虽然语言帮我们处理了这部分,但是还是有必要去了解一下底层的架构设计和执行逻辑,这样可以更好的掌握一门语言,本文主要以go内存管理为切入点再到go垃圾回收,系统地讲解了go自动内存管理系统的设计和原理。

一、TCMalloc

go内存管理是借鉴了TCMalloc的设计思想,TCMalloc全称Thead-Caching Malloc,是google开发的内存分配器,为了方便理解下面的go内存管理,有必要要先熟悉一下TCMalloc。

d90f56a82aacb8384a9dd9351833b9a4.png

(一)Page

操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。

(二) Span

一组连续的Page被称为Span,比如可以有4个页大小的Span,也可以有8个页大小的Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位。

(三) ThreadCache

每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。

(四)CentralCache

是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache内存块不足时,可以从CentralCache取,当ThreadCache内存块多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。


(五)PageHeap

PageHeap是堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span,当CentralCache没有内存的时,会从PageHeap取,把1个Span拆成若干内存块,添加到对应大小的链表中,当CentralCache内存多的时候,会放回PageHeap。

(六)TCMalloc对象分配

小对象直接从ThreadCache分配,若ThreadCache不够则从CentralCache中获取内存,CentralCache内存不够时会再从PageHeap获取内存,大对象在PageHeap中选择合适的页组成span用于存储数据。


二、GO内存管理

经过上一节对TCMalloc内存管理的描述,对接下来理解go的内存管理会有大致架构的熟悉,go内存管理架构取之TCMalloc不过在细节上有些出入,先来看一张go内存管理的架构图:

3fc2f9798ede27e322755f3ca5920aeb.png

(一)Page

和TCMalloc中page相同,上图中最下方浅蓝色长方形代表一个page。

(二)Span

与TCMalloc中的Span相同,Span是go内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。

(三)mcache

mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。但mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcach,因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,下图是G,P,M三者之间的关系:

fec72c500f224e8e1736f39735f1979d.png

(四)mcentral

mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。但mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表。


(五)mheap

mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS(系统)申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。但mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。


(六)内存分配

Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分,其中小对象大小在16Byte到32KB之间,大对象大小大于32KB。span规格分类 上面说到go的内存管理基本单位是span,且span有不同的规格,要想区分出不同的的span,我们必须要有一个标识,每个span通过spanclass标识属于哪种规格的span,golang的span规格一共有67种,具体如下:

  1. //from runtime.gosizeclasses.go
  2. // class bytes/obj bytes/span objects tail waste max waste
  3. // 1 8 8192 1024 0 87.50%
  4. // 2 16 8192 512 0 43.75%
  5. // 3 32 8192 256 0 46.88%
  6. // 4 48 8192 170 32 31.52%
  7. // 5 64 8192 128 0 23.44%
  8. // 6 80 8192 102 32 19.07%
  9. // 7 96 8192 85 32 15.95%
  10. // 8 112 8192 73 16 13.56%
  11. // 9 128 8192 64 0 11.72%
  12. // 10 144 8192 56 128 11.82%
  13. // 11 160 8192 51 32 9.73%
  14. // 12 176 8192 46 96 9.59%
  15. // 13 192 8192 42 128 9.25%
  16. // 14 208 8192 39 80 8.12%
  17. // 15 224 8192 36 128 8.15%
  18. // 16 240 8192 34 32 6.62%
  19. // 17 256 8192 32 0 5.86%
  20. // 18 288 8192 28 128 12.16%
  21. // 19 320 8192 25 192 11.80%
  22. // 20 352 8192 23 96 9.88%
  23. // 21 384 8192 21 128 9.51%
  24. // 22 416 8192 19 288 10.71%
  25. // 23 448 8192 18 128 8.37%
  26. // 24 480 8192 17 32 6.82%
  27. // 25 512 8192 16 0 6.05%
  28. // 26 576 8192 14 128 12.33%
  29. // 27 640 8192 12 512 15.48%
  30. // 28 704 8192 11 448 13.93%
  31. // 29 768 8192 10 512 13.94%
  32. // 30 896 8192 9 128 15.52%
  33. // 31 1024 8192 8 0 12.40%
  34. // 32 1152 8192 7 128 12.41%
  35. // 33 1280 8192 6 512 15.55%
  36. // 34 1408 16384 11 896 14.00%
  37. // 35 1536 8192 5 512 14.00%
  38. // 36 1792 16384 9 256 15.57%
  39. // 37 2048 8192 4 0 12.45%
  40. // 38 2304 16384 7 256 12.46%
  41. // 39 2688 8192 3 128 15.59%
  42. // 40 3072 24576 8 0 12.47%
  43. // 41 3200 16384 5 384 6.22%
  44. // 42 3456 24576 7 384 8.83%
  45. // 43 4096 8192 2 0 15.60%
  46. // 44 4864 24576 5 256 16.65%
  47. // 45 5376 16384 3 256 10.92%
  48. // 46 6144 24576 4 0 12.48%
  49. // 47 6528 32768 5 128 6.23%
  50. // 48 6784 40960 6 256 4.36%
  51. // 49 6912 49152 7 768 3.37%
  52. // 50 8192 8192 1 0 15.61%
  53. // 51 9472 57344 6 512 14.28%
  54. // 52 9728 49152 5 512 3.64%
  55. // 53 10240 40960 4 0 4.99%
  56. // 54 10880 32768 3 128 6.24%
  57. // 55 12288 24576 2 0 11.45%
  58. // 56 13568 40960 3 256 9.99%
  59. // 57 14336 57344 4 0 5.35%
  60. // 58 16384 16384 1 0 12.49%
  61. // 59 18432 73728 4 0 11.11%
  62. // 60 19072 57344 3 128 3.57%
  63. // 61 20480 40960 2 0 6.87%
  64. // 62 21760 65536 3 256 6.25%
  65. // 63 24576 24576 1 0 11.45%
  66. // 64 27264 81920 3 128 10.00%
  67. // 65 28672 57344 2 0 4.91%
  68. // 66 32768 32768 1 0 12.50%

由上表可见最大的对象是32KB大小,超过32KB大小的由特殊的class表示,该class ID为0,每个class只包含一个对象。所以上面只有列出了1-66。内存大小转换,下面还要三个数组,分别是:class_to_size,size_to_class和class_to_allocnpages3个数组,对应下图上的3个箭头:

abd00cc2dcef79308ab584718f463cf7.png

以第一列为例,类别1的对象大小是8bytes,所以class_to_size[1]=8;span大小是8KB,为1页,所以class_to_allocnpages[1]=1,下图是go源码中大小转换数组。

6bfa2a455a1187255a62cfb5700af47e.png

为对象寻找span,寻找span的流程如下:

  • 计算对象所需内存大小size。

  • 根据size到size class映射,计算出所需的size class。

  • 根据size class和对象是否包含指针计算出span class。

  • 获取该span class指向的span。

以分配一个包含指针大小为20Byte的对象为例,根据映射表:

  1. // class bytes/obj bytes/span objects tail waste max waste
  2. // 1 8 8192 1024 0 87.50%
  3. // 2 16 8192 512 0 43.75%
  4. // 3 32 8192 256 0 46.88%

size class 3,它的对象大小范围是(16,32]Byte,20Byte刚好在此区间,所以此对象的size class为3,Size class到span class的计算如下:

  1. // noscan为false代表对象包含指针
  2. func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
  3. return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
  4. }

所以,对应的span class为:

span class = 3 << 1 | 0 = 6

所以该对象需要的是span class 7指向的span,自此,小对象内存分配完成。

  1. //from runtime.gomalloc.go
  2. var sizeclass uint8
  3. //step1: 确定规格sizeClass
  4. if size <= smallSizeMax-8 {
  5. sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]
  6. } else {
  7. sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]
  8. }
  9. size = uintptr(class_to_size[sizeclass])
  10. // size class到span class
  11. spc := makeSpanClass(sizeclass, noscan)
  12. //step2: 分配对应spanClass 的 span
  13. span = c.alloc[spc]
  14. v := nextFreeFast(span)
  15. if v == 0 {
  16. v, span, shouldhelpgc = c.nextFree(spc)
  17. }
  18. x = unsafe.Pointer(v)
  19. if needzero &amp;&amp; span.needzero != 0 {
  20. memclrNoHeapPointers(unsafe.Pointer(v), size)
  21. }

大对象(>32KB)的分配则简单多了,直接在mheap上进行分配,首先计算出需要的内存页数和span class级别,然后优先从free中搜索可用的span,如果没有找到,会从scav中搜索可用的span,如果还没有找到,则向OS申请内存,再重新搜索2棵树,必然能找到span。如果找到的span比需求的span大,则把span进行分割成2个span,其中1个刚好是需求大小,把剩下的span再加入到free中去。

三、垃圾回收

(一)标记-清除

标记-清除算法是第一种自动内存管理,基于追踪的垃圾收集算法。算法思想在70年代就提出了,是一种非常古老的算法。内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度。这个时候系统会挂起用户程序,也就是STW,转而执行垃圾回收程序。垃圾回收程序对所有的存活单元进行一次全局遍历确定哪些单元可以回收。算法分两个部分:标记(mark)和清除(sweep)。标记阶段表明所有的存活单元,清扫阶段将垃圾单元回收。可视化可以参考下图。

057a781bd10d9e5eff80295d05e0b67b.gif

标记-清除算法的优点也就是基于追踪的垃圾回收算法具有的优点:避免了引用计数算法的缺点(不能处理循环引用,需要维护指针)。缺点也很明显,需要STW。


(二)三色可达性分析

三色标记算法是对标记阶段的改进,原理如下:

  • 起初所有对象都是白色。

  • 从根出发扫描所有可达对象,标记为灰色,放入待处理队列。

  • 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。

  • 重复上一步,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。

61c4935b42c0208bbe67dc13a8c4cf7b.gif

三色标记的一个明显好处是能够让用户程序和mark并发的进行,不过三色标记清除算法本身是不可以并发或者增量执行的,它需要STW,而如果并发执行,用户程序可能在标记执行的过程中修改对象的指针,导致可能将本该死亡的对象标记为存活和本该存活的对象标记为死亡,为了解决这种问题,go v1.8之后使用混合写屏障技术支持并发和增量执行,将垃圾收集的时间缩短至0.5ms以内。


(三)gc触发

在堆上分配大于32K byte对象的时候进行检测此时是否满足垃圾回收条件,如果满足则进行垃圾回收

  1. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
  2. ...
  3. shouldhelpgc := false
  4. // 分配的对象小于 32K byte
  5. if size <= maxSmallSize {
  6. ...
  7. } else {
  8. shouldhelpgc = true
  9. ...
  10. }
  11. ...
  12. // gcShouldStart() 函数进行触发条件检测
  13. if shouldhelpgc && gcShouldStart(false) {
  14. // gcStart() 函数进行垃圾回收
  15. gcStart(gcBackgroundMode, false)
  16. }
  17. }

上面是自动垃圾回收,还有一种主动垃圾回收,通过调用runtime.GC(),这是阻塞式的。

  1. // GC runs a garbage collection and blocks the caller until the
  2. // garbage collection is complete. It may also block the entire
  3. // program.
  4. func GC() {
  5. gcStart(gcForceBlockMode, false)
  6. }

系统gc触发条件:触发条件主要关注下面代码中的中间部分:forceTrigger||memstats.heap_live>=memstats.gc_trigger。forceTrigger是forceGC的标志,后面半句的意思是当前堆上的活跃对象大于我们初始化时候设置的GC触发阈值,在malloc以及free的时候heap_live会一直进行更新。

  1. // gcShouldStart returns true if the exit condition for the _GCoff
  2. // phase has been met. The exit condition should be tested when
  3. // allocating.
  4. //
  5. // If forceTrigger is true, it ignores the current heap size, but
  6. // checks all other conditions. In general this should be false.
  7. func gcShouldStart(forceTrigger bool) bool {
  8. return gcphase == _GCoff && (forceTrigger || memstats.heap_live >= memstats.gc_trigger) && memstats.enablegc && panicking == 0 && gcpercent >= 0
  9. }
  10. //初始化的时候设置 GC 的触发阈值
  11. func gcinit() {
  12. _ = setGCPercent(readgogc())
  13. memstats.gc_trigger = heapminimum
  14. ...
  15. }
  16. // 启动的时候通过 GOGC 传递百分比 x
  17. // 触发阈值等于 x * defaultHeapMinimum (defaultHeapMinimum 默认是 4M)
  18. func readgogc() int32 {
  19. p := gogetenv("GOGC")
  20. if p == "off" {
  21. return -1
  22. }
  23. if n, ok := atoi32(p); ok {
  24. return n
  25. }
  26. return 100
  27. }
(四)gc过程

下列源码是基于go 1.8,由于源码过长,所以这里尽量只关注主流程

  • gcStart

  1. // gcStart 是 GC 的入口函数,根据 gcMode 做处理。
  2. // 1. gcMode == gcBackgroundMode(后台运行,也就是并行), _GCoff -> _GCmark
  3. // 2. 否则 GCoff -> _GCmarktermination,这个时候就是主动 GC
  4. func gcStart(mode gcMode, forceTrigger bool) {
  5. ...
  6. //在后台启动 mark worker
  7. if mode == gcBackgroundMode {
  8. gcBgMarkStartWorkers()
  9. }
  10. ...
  11. // Stop The World
  12. systemstack(stopTheWorldWithSema)
  13. ...
  14. if mode == gcBackgroundMode {
  15. // GC 开始前的准备工作
  16. //处理设置 GCPhase,setGCPhase 还会 开始写屏障
  17. setGCPhase(_GCmark)
  18. gcBgMarkPrepare() // Must happen before assist enable.
  19. gcMarkRootPrepare()
  20. // Mark all active tinyalloc blocks. Since we're
  21. // allocating from these, they need to be black like
  22. // other allocations. The alternative is to blacken
  23. // the tiny block on every allocation from it, which
  24. // would slow down the tiny allocator.
  25. gcMarkTinyAllocs()
  26. // Start The World
  27. systemstack(startTheWorldWithSema)
  28. } else {
  29. ...
  30. }
  31. }
  • Mark

  1. func gcStart(mode gcMode, forceTrigger bool) {
  2. ...
  3. //在后台启动 mark worker
  4. if mode == gcBackgroundMode {
  5. gcBgMarkStartWorkers()
  6. }
  7. }
  8. func gcBgMarkStartWorkers() {
  9. // Background marking is performed by per-P G's. Ensure that
  10. // each P has a background GC G.
  11. for _, p := range &allp {
  12. if p == nil || p.status == _Pdead {
  13. break
  14. }
  15. if p.gcBgMarkWorker == 0 {
  16. go gcBgMarkWorker(p)
  17. notetsleepg(&work.bgMarkReady, -1)
  18. noteclear(&work.bgMarkReady)
  19. }
  20. }
  21. }
  22. // gcBgMarkWorker 是一直在后台运行的,大部分时候是休眠状态,通过 gcController 来调度
  23. func gcBgMarkWorker(_p_ *p) {
  24. for {
  25. // 将当前 goroutine 休眠,直到满足某些条件
  26. gopark(...)
  27. ...
  28. // mark 过程
  29. systemstack(func() {
  30. // Mark our goroutine preemptible so its stack
  31. // can be scanned. This lets two mark workers
  32. // scan each other (otherwise, they would
  33. // deadlock). We must not modify anything on
  34. // the G stack. However, stack shrinking is
  35. // disabled for mark workers, so it is safe to
  36. // read from the G stack.
  37. casgstatus(gp, _Grunning, _Gwaiting)
  38. switch _p_.gcMarkWorkerMode {
  39. default:
  40. throw("gcBgMarkWorker: unexpected gcMarkWorkerMode")
  41. case gcMarkWorkerDedicatedMode:
  42. gcDrain(&_p_.gcw, gcDrainNoBlock|gcDrainFlushBgCredit)
  43. case gcMarkWorkerFractionalMode:
  44. gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit)
  45. case gcMarkWorkerIdleMode:
  46. gcDrain(&_p_.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit)
  47. }
  48. casgstatus(gp, _Gwaiting, _Grunning)
  49. })
  50. ...
  51. }
  52. }

Mark阶段的标记代码主要在函数gcDrain()中实现

  1. // gcDrain scans roots and objects in work buffers, blackening grey
  2. // objects until all roots and work buffers have been drained.
  3. func gcDrain(gcw *gcWork, flags gcDrainFlags) {
  4. ...
  5. // Drain root marking jobs.
  6. if work.markrootNext < work.markrootJobs {
  7. for !(preemptible && gp.preempt) {
  8. job := atomic.Xadd(&work.markrootNext, +1) - 1
  9. if job >= work.markrootJobs {
  10. break
  11. }
  12. markroot(gcw, job)
  13. if idle && pollWork() {
  14. goto done
  15. }
  16. }
  17. }
  18. // 处理 heap 标记
  19. // Drain heap marking jobs.
  20. for !(preemptible && gp.preempt) {
  21. ...
  22. //从灰色列队中取出对象
  23. var b uintptr
  24. if blocking {
  25. b = gcw.get()
  26. } else {
  27. b = gcw.tryGetFast()
  28. if b == 0 {
  29. b = gcw.tryGet()
  30. }
  31. }
  32. if b == 0 {
  33. // work barrier reached or tryGet failed.
  34. break
  35. }
  36. //扫描灰色对象的引用对象,标记为灰色,入灰色队列
  37. scanobject(b, gcw)
  38. }
  39. }
  • Sweep

  1. func gcSweep(mode gcMode) {
  2. ...
  3. //阻塞式
  4. if !_ConcurrentSweep || mode == gcForceBlockMode {
  5. // Special case synchronous sweep.
  6. ...
  7. // Sweep all spans eagerly.
  8. for sweepone() != ^uintptr(0) {
  9. sweep.npausesweep++
  10. }
  11. // Do an additional mProf_GC, because all 'free' events are now real as well.
  12. mProf_GC()
  13. mProf_GC()
  14. return
  15. }
  16. // 并行式
  17. // Background sweep.
  18. lock(&sweep.lock)
  19. if sweep.parked {
  20. sweep.parked = false
  21. ready(sweep.g, 0, true)
  22. }
  23. unlock(&sweep.lock)
  24. }

对于并行式清扫,在GC初始化的时候就会启动 bgsweep(),然后在后台一直循环

  1. func bgsweep(c chan int) {
  2. sweep.g = getg()
  3. lock(&sweep.lock)
  4. sweep.parked = true
  5. c <- 1
  6. goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1)
  7. for {
  8. for gosweepone() != ^uintptr(0) {
  9. sweep.nbgsweep++
  10. Gosched()
  11. }
  12. lock(&sweep.lock)
  13. if !gosweepdone() {
  14. // This can happen if a GC runs between
  15. // gosweepone returning ^0 above
  16. // and the lock being acquired.
  17. unlock(&sweep.lock)
  18. continue
  19. }
  20. sweep.parked = true
  21. goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1)
  22. }
  23. }
  24. func gosweepone() uintptr {
  25. var ret uintptr
  26. systemstack(func() {
  27. ret = sweepone()
  28. })
  29. return ret
  30. }

不管是阻塞式还是并行式,最终都会调用sweepone()。上面说过go内存管理都是基于span的,mheap_是一个全局的变量,所有分配的对象都会记录在mheap_中。在标记的时候,我们只要找到对对象对应的span进行标记,清扫的时候扫描span,没有标记的span就可以回收了。

  1. // sweeps one span
  2. // returns number of pages returned to heap, or ^uintptr(0) if there is nothing to sweep
  3. func sweepone() uintptr {
  4. ...
  5. for {
  6. s := mheap_.sweepSpans[1-sg/2%2].pop()
  7. ...
  8. if !s.sweep(false) {
  9. // Span is still in-use, so this returned no
  10. // pages to the heap and the span needs to
  11. // move to the swept in-use list.
  12. npages = 0
  13. }
  14. }
  15. }
  16. // Sweep frees or collects finalizers for blocks not marked in the mark phase.
  17. // It clears the mark bits in preparation for the next GC round.
  18. // Returns true if the span was returned to heap.
  19. // If preserve=true, don't return it to heap nor relink in MCentral lists;
  20. // caller takes care of it.
  21. func (s *mspan) sweep(preserve bool) bool {
  22. ...
  23. }

参考资料:

1.《go语言设计与实现》

 作者简介

f7f4278994dd31444b92069579a46e8b.jpeg

冷易

腾讯后台开发工程师

腾讯后台开发工程师,目前负责腾讯医药平台后端开发工作,精通java、go底层设计架构,有丰富的高并发性能优化,分布式系统开发经验。

 推荐阅读

5G正当时,无人驾驶未来将驶向何方?

C++异步:structured concurrency实现解析!

图文并茂!带你深度解析Kubernetes

万卷共知,一书一页总关情,TVP读书会带你突围阅读迷障!

99d07ab7995edee919718d2a3d31649e.gif

温馨提示:因公众号平台更改了推送规则,公众号推送的文章文末需要点一下“赞”和“在看”,新的文章才会第一时间出现在你的订阅列表里噢~

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/不正经/article/detail/359816
推荐阅读
相关标签
  

闽ICP备14008679号