<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Rinai 的私人花园</title><description>博客</description><link>https://blog.g-rinai.cn/</link><language>zh_CN</language><item><title>sync.Pool 是怎么实现的？</title><link>https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/syncpool/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/syncpool/</guid><description>都说 sync.Pool 是无锁并发访问的，你知道原理吗？</description><pubDate>Sat, 07 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前几天看见一个技术交流群有关于 sync.Pool 的交流，问他是如何做到无锁并发访问的，我对这个问题比较有兴趣，就去看了它的源码一探究竟。&lt;/p&gt;
&lt;h2&gt;sync.Pool 是什么？&lt;/h2&gt;
&lt;p&gt;简单来说就是一个对象复用池，当我们有需要频繁创建销毁对象的时候，就可以用上他来减少开销，它提供的主要 API 有 &lt;code&gt;Get&lt;/code&gt; 和 &lt;code&gt;Put&lt;/code&gt;，一个简单的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var bufPool = sync.Pool{
	New: func() any {
		return new(bytes.Buffer)
	},
}

func doSomething() []byte {
	b := bufPool.Get().(*bytes.Buffer)
	b.Reset()            // 清理旧内容
	defer bufPool.Put(b) // 用完放回

	b.WriteString(&quot;hello&quot;)
	return append([]byte(nil), b.Bytes()...)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们通过声明创建一个 bufPool 的对象，此时所有的 goroutine 都可以去并发地去调用它，并且不需要加锁，下面我们可以看看他是如何做到的内部无锁也能保证原子性。&lt;/p&gt;
&lt;h2&gt;Get 方法如何实现的？&lt;/h2&gt;
&lt;p&gt;首先我们可以来看看他的 &lt;code&gt;Get&lt;/code&gt; 方法，相对我们以前读过的调度器或者垃圾回收来说真就是小朋友级别了，先把源代码贴出来：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (p *Pool) Get() any {
    ...
    // 拿到对应的本地 shard（l）以及 pid，同时 pin 就是将 g 绑定到 p 上，此时 p 上暂时不会发生协程切换。
    l, pid := p.pin()

    // 先尝试从该 P 的 private 获取，相当于是做了一个加速，类似 p 的 runnext。
    x := l.private
    l.private = nil

    if x == nil {
        // 从本地对象池的队头获取
        x, _ = l.shared.popHead()

        // 没有从本地的 p 所持有的缓存块获取到，从其他的 p 对象池的队尾窃取。
        if x == nil {
            x = p.getSlow(pid)
        }
    }

    // 解除与 P 的绑定。
    runtime_procUnpin()
	...
    // 如果最终还是 nil，并且用户提供了 New 函数，就用 New 生成一个新对象。
    if x == nil &amp;amp;&amp;amp; p.New != nil {
        x = p.New()
    }
    return x
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总体逻辑注释已经介绍地差不多了，首先是我们会调用 &lt;code&gt;pin&lt;/code&gt; 方法，他会获取到我们的 p 当前的 m 对象，并且将他的 locks 成员 ++，这个成员很关键，我们在很多协程抢占、调度的位置都会去检查这个锁定位，如果锁定了，此时的 goroutine 就不会让出我们的线程资源，从而达到了绑定的效果。&lt;/p&gt;
&lt;p&gt;随后我们的 &lt;code&gt;pin&lt;/code&gt; 方法会从 Pool 的对象池里面通过 p 的 id 去从 p 的全局数组里面找到对应的对象池，其实就是通过 pid 进行资源的分片访问，这样的话，我们在单一 p 上的资源是只有一个 p 上的 g 会进行访问，并且此时我们的 g 是没办法切换的，所以在我们 &lt;code&gt;pin&lt;/code&gt; 到 &lt;code&gt;unpin&lt;/code&gt; 的这个时间段，有且仅有一个 g 会去访问这个 p 的对象池的资源，所以这样看来确实无锁就能实现安全的并发读写。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (p *Pool) pin() (*poolLocal, int) {
	if p == nil {
		panic(&quot;nil Pool&quot;)
	}

	// 就是 p.mp.locks ++
	pid := runtime_procPin()

	s := runtime_LoadAcquintptr(&amp;amp;p.localSize)

	l := p.local
	
	// 这里检查的是 pool 是否为足够的 p 分配了空间
	if uintptr(pid) &amp;lt; s {
		// 如果是，直接在 pool 的数组里面找到 p 对应的对象池
		return indexLocal(l, pid), pid
	}

	// 否则走慢路径，这里会初始化
	return p.pinSlow()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以去仔细看看 &lt;code&gt;pinSlow&lt;/code&gt; 方法，其实从我们最开始的示例代码就可以知道，我们最开始没有为 &lt;code&gt;p.local&lt;/code&gt; 或者 &lt;code&gt;p.localsize&lt;/code&gt; 赋值的，这当然是在 sync.Pool 的考虑范围之内，当我们第一次执行 Get 的时候，内存池完全是空的，就会走慢路径为我们初始化内存池：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (p *Pool) pinSlow() (*poolLocal, int) {
	// 处于 pin 状态不能去执行 lock，先 unpin
	runtime_procUnpin()

	// 所有 Pool 的全局锁：保护 allPools
	allPoolsMu.Lock()
	defer allPoolsMu.Unlock()

	// 重新 pin，现在是安全的
	pid := runtime_procPin()

	s := p.localSize
	l := p.local

	// 看看之前 unpin 到加锁这段期间可能已经被初始化好了。
	if uintptr(pid) &amp;lt; s {
		return indexLocal(l, pid), pid
	}

	// 这里说明当前 pid 超出 localSize，说明 local 尚未初始化或 GOMAXPROCS 变大导致不够用

	if p.local == nil {
		// 第一次初始化该 Pool：把它加入 allPools，
		allPools = append(allPools, p)
	}

	// local 数组大小按当前 GOMAXPROCS 分配，
	// 每个 P 一个 poolLocal，减少竞争。
	size := runtime.GOMAXPROCS(0)

	// 分配新的 poolLocal 数组
	local := make([]poolLocal, size)

	atomic.StorePointer(&amp;amp;p.local, unsafe.Pointer(&amp;amp;local[0]))
	runtime_StoreReluintptr(&amp;amp;p.localSize, uintptr(size)) 

	// 返回当前 pid 对应池子槽位
	return &amp;amp;local[pid], pid
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时当然已经初始化完了，但是我们的 local 池子里面的链表还是空的，所以此时还是会走慢路径，如果慢路径也没有窃取到，则会走 &lt;code&gt;New&lt;/code&gt; 去初始化新的对象。&lt;/p&gt;
&lt;p&gt;慢路径就是从其他 p 的对象池的队尾进行窃取，这里不从队头窃取的原因之一是避免和当前正在窃取的队列的 p 发生冲突，注意，这里会和其他也在窃取这个队列的 p 发生冲突，当这个 p 的对象池队头遇到队尾也会有冲突，之后我们会讲；如果没有窃取到一块对象池，就会尝试用 victim 机制加载对象池，否则直接返回 nil：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (p *Pool) getSlow(pid int) any {
	size := runtime_LoadAcquintptr(&amp;amp;p.localSize)
	locals := p.local

	// 尝试从其他 P 的对象池里面去窃取一个对象
	for i := 0; i &amp;lt; int(size); i++ {
		l := indexLocal(locals, (pid+i+1)%int(size))

		// 这里是从队尾窃取，不是队头，减少和其他 p 的冲突
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}

	// victim 机制，后面会讲
	size = atomic.LoadUintptr(&amp;amp;p.victimSize)
	if uintptr(pid) &amp;gt;= size {
		return nil
	}

	locals = p.victim
	l := indexLocal(locals, pid)

	if x := l.private; x != nil {
		l.private = nil
		return x
	}
	for i := 0; i &amp;lt; int(size); i++ {
		l := indexLocal(locals, (pid+i)%int(size))
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}

	atomic.StoreUintptr(&amp;amp;p.victimSize, 0)

	return nil
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;是的，这里返回了 nil，在我们上面的逻辑可以知道，在检测到获取的对象为 nil 的时候，就会通过我们传入的 &lt;code&gt;New&lt;/code&gt; 方法去创建一块新的内存，之后我们将这块 &lt;code&gt;New&lt;/code&gt; 出来的对象还会通过 &lt;code&gt;Put&lt;/code&gt; 放回队列。&lt;/p&gt;
&lt;p&gt;这里我们先提及我们底层的这个队列的并发安全操作是如何实现的吧，首先我们这里主要是 &lt;code&gt;popTail&lt;/code&gt; 和 &lt;code&gt;popHead&lt;/code&gt; 这两个方法。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (c *poolChain) popHead() (any, bool) {
	d := c.head

	// 遍历查找
	for d != nil {
		// 先从当前双端队列的头部弹出一个元素
		if val, ok := d.popHead(); ok {
			return val, ok
		}

		// 这个队列为空了，找下一个
		d = d.prev.Load()
	}

	return nil, false
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还得往下翻，当前 p 的对象池是个链表，链表的元素是个双端队列，也就是个数组，现在只需要看看这个数组里面是如何实现并发安全的就行了，其实就是个 CAS 原子操作）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (d *poolDequeue) popHead() (any, bool) {
	var slot *eface
	for {
		// 原子读取 head/tail 的打包值，因为 head 和 tail 都是 uint32
		// 打包之后就是一个 uint64，可以直接用于原子操作。
		// 这样既可以防止和窃取当前队列的协程发生冲突。
		ptrs := d.headTail.Load()
		head, tail := d.unpack(ptrs)

		if tail == head {
			return nil, false
		}

		head--
		ptrs2 := d.pack(head, tail)

		if d.headTail.CompareAndSwap(ptrs, ptrs2) {
			// 通过环形数组下标定位该 slot
			slot = &amp;amp;d.vals[head&amp;amp;uint32(len(d.vals)-1)]
			break
		}
		// CAS 失败说明 headTail 被别人改过（比如 popTail），重试
	}

	val := *(*any)(unsafe.Pointer(slot))

	if val == dequeueNil(nil) {
		val = nil
	}

	// 把 slot 清零。
	// 与 popTail 不同，这里不会和 pushHead 发生竞态（因为 popHead 只给单生产者用），
	// 所以可以直接清空，不需要特别小心内存顺序。
	*slot = eface{}

	return val, true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可以看见，其实就是将队头和队尾的 index 值打包成一个 uint64 的数字来实现 CAS 原子操作保证操作队头和队尾是并发安全的，同时由于这里队头一般只会由一个 p 进行操作，所以这里的 slot 可以无锁安全操作的。&lt;/p&gt;
&lt;p&gt;其实我们的队尾整体上也是一样的原理，但是他在检测到队尾某个数组节点为空时，需要通过 CAS 原子切换队尾的元素，保证每次正确得到有元素的队尾节点开始窃取。这里就不再赘述了。&lt;/p&gt;
&lt;h2&gt;Put 方法如何实现的？&lt;/h2&gt;
&lt;p&gt;直接上代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (p *Pool) Put(x any) {
	...
	
	// 依旧绑定 g 到 p 上，并得到 p 在 pool 里面的对应的对象池链表
	l, _ := p.pin()
	if l.private == nil {
		// 快速路径
		l.private = x
	} else {
		// 直接走 pushHead
		l.shared.pushHead(x)
	}
	runtime_procUnpin()
	if race.Enabled {
		race.Enable()
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pin&lt;/code&gt; 方法都是一样的，后面干的第一件事就是检查快速路径的 &lt;code&gt;private&lt;/code&gt; 位置，否则直接走 &lt;code&gt;pushHead&lt;/code&gt; 从队头推入对象，这里我们可以知道，p 对应的对象池队头在同一时刻总是只有一个 g 进行操作，所以这里是相对比较安全的，但是也要注意和队尾的窃取的竞争问题。&lt;/p&gt;
&lt;p&gt;下面来看看 &lt;code&gt;pushHead&lt;/code&gt; 的实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func (c *poolChain) pushHead(val any) {
	// 取当前链表头结点
	d := c.head
	if d == nil {
		// 第一次插入：初始化整个 chain（只有一个 deque 节点）
		const initSize = 8
		d = new(poolChainElt)
		d.vals = make([]eface, initSize)
		c.head = d
		c.tail.Store(d)
	}

	// 尝试直接往当前 head 节点的头部入队
	// pushHead 返回 true 表示成功
	if d.pushHead(val) {
		return
	}
	
	// 扩容，需要新建一个 head
	newSize := len(d.vals) * 2
	if newSize &amp;gt;= dequeueLimit {
		newSize = dequeueLimit
	}

	// 新建链表头节点 d2
	d2 := &amp;amp;poolChainElt{}
	d2.prev.Store(d)
	d2.vals = make([]eface, newSize)

	// 把新节点设置为 head，并把旧 head 的 next 指向新 head
	c.head = d2
	d.next.Store(d2) // 原子写 next，这里主要是防止和队尾产生冲突

	// 新 head 肯定是空的，把元素 push 进去
	d2.pushHead(val)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到了这里，其实我们可以发现，我们第一次调用 &lt;code&gt;Get&lt;/code&gt; 或者 &lt;code&gt;Put&lt;/code&gt; 都是一个懒加载的策略，在 &lt;code&gt;pin&lt;/code&gt; 的时候才会真正地去根据 GOMAXPROCS 的数量去分配对象池的空间，第一次 Put 的时候，才会真正给对象池的链表头分配空间。&lt;/p&gt;
&lt;p&gt;这里的 &lt;code&gt;pushHead&lt;/code&gt; 其实相比之下是比较无趣的，他和 &lt;code&gt;popHead&lt;/code&gt; 实现也是大差不差，这里不再赘述。&lt;/p&gt;
&lt;p&gt;我们可以小结一下，通过传入一个 &lt;code&gt;New&lt;/code&gt; 函数来指定这个对象池里面存放的元素，之后我们第一次从中 &lt;code&gt;Get&lt;/code&gt; 肯定是没办法从对象池里面拿到任何数据的，只能通过 &lt;code&gt;New&lt;/code&gt; 去新分配对象，直到我们尝试往里面 &lt;code&gt;Put&lt;/code&gt; 对象，他就会缓存到这个 p 的对象池里面了，并且他是可以被其他 p 从队尾窃取的。那么回到最初的问题，他是如何实现无锁访问的？首先他是通过 pid 进行分片，让每个 p 上的 g 可以在 &lt;code&gt;pin&lt;/code&gt; 之后能够安全的访问本地对象池，但是这里如果是从对象池队列里面访问数据的话，是需要和队尾的窃取 goroutine 竞争的，这里通过 CAS 操作解决了这个问题。当然，我们的 &lt;code&gt;sync.Pool&lt;/code&gt; 他并不是完全无锁的，我们所有的初始化的 sync.Pool 都需要加入全局的 Pool 数组，原因之后会说，如果需要访问这个全局的 Pool 数组就需要通过加一个全局锁来访问了。&lt;/p&gt;
&lt;p&gt;下面介绍一些我们之前提过的，但是没有详细说的优化机制。&lt;/p&gt;
&lt;h2&gt;其他一些优化机制&lt;/h2&gt;
&lt;h3&gt;victim 机制&lt;/h3&gt;
&lt;p&gt;我们之前在 &lt;code&gt;getSLow&lt;/code&gt; 的方法里面看见过 &lt;code&gt;sync.Pool&lt;/code&gt; 的一个字段叫做 &lt;code&gt;victim&lt;/code&gt;，这里和我们的垃圾回收有关，在垃圾回收期间，我们会将 &lt;code&gt;sync.Pool&lt;/code&gt; 里面的对象进行清理，如果不进行清理就可能会导致内存问题，但是如果直接进行清理就意味着我们每个 p 的本地缓存就没了，如果当前的 pool 是热点数据，就会直接导致程序性能下降，为了应对这个问题，就引入了 &lt;code&gt;victim&lt;/code&gt; 机制。&lt;/p&gt;
&lt;p&gt;在进行垃圾回收的时候，会将当前的对象池存入 &lt;code&gt;sync.Pool&lt;/code&gt; 的 &lt;code&gt;victim&lt;/code&gt; 对象，而将 &lt;code&gt;local&lt;/code&gt; 置为 nil，当我们之后执行 &lt;code&gt;Get&lt;/code&gt; 的时候，如果发现 &lt;code&gt;local&lt;/code&gt; 没有数据，会尝试走慢路径，这里包括窃取和走 &lt;code&gt;victim&lt;/code&gt;，我们此时可以直接同 &lt;code&gt;victim&lt;/code&gt; 里面获取对象，于是就不需要走 &lt;code&gt;New&lt;/code&gt; 方法了，并且之后我们在执行 &lt;code&gt;Put&lt;/code&gt; 归还这个对象的时候，也是归还到 &lt;code&gt;local&lt;/code&gt; 里面，可以近似看成一个渐进式迁移的策略。&lt;/p&gt;
&lt;p&gt;如果当前的 &lt;code&gt;sync.Pool&lt;/code&gt; 调用比较频繁，那么之前在里面缓存的对象可以很快地被迁移到新的 &lt;code&gt;local&lt;/code&gt; 里面，如果调用比较少，那么 &lt;code&gt;victim&lt;/code&gt; 成员里面的数据基本不会被迁移，在下一轮 GC 中，就会取消对它的引用，从而能够被 GC 给回收掉，这样在性能和减少内存占用上取得了平衡。&lt;/p&gt;
&lt;h3&gt;cache line 优化&lt;/h3&gt;
&lt;p&gt;我们可以观察，每一个 sync.Pool 关于 p 的本地缓存的结构体如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type poolLocalInternal struct {
	private any 
	shared  poolChain
}

type poolLocal struct {
	poolLocalInternal

	// Prevents false sharing on widespread platforms with
	// 128 mod (cache line size) = 0 .
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 pad 成员我们并没有看见它的使用，通过注释我们可以知道，这是一个关于 cacheline 的优化策略，pad 的作用就是将 &lt;code&gt;poolLocal&lt;/code&gt; 的大小补齐到 128 个字节对齐，大部分 CPU Cacheline 的大小都是 64 字节，这样就保证了每个 &lt;code&gt;poolLocal&lt;/code&gt; 可以覆盖常见的甚至 CPU Cacheline 更宽的情况，从而避免了两个 p 的本地对象池落在同一个 Cacheline，进而导致伪共享的问题，至于什么是伪共享并不是本文的重点，可以参考 &lt;a href=&quot;https://www.xiaolincoding.com/os/1_hardware/how_cpu_deal_task.html&quot;&gt;小林 coding&lt;/a&gt;，我觉得他的图是讲得比较清晰的。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;总的来讲，sync.Pool 的源码是非常简短的，但是他在设计上也有很多值得我们注意和学习的点，比如如何并发无锁访问、victim 机制、如何避免伪共享问题，这些都是很有意思的知识。&lt;/p&gt;
</content:encoded></item><item><title>2025 结束了</title><link>https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/2025-end/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/2025-end/</guid><description>寒假之际，故且总结一下我的 2025</description><pubDate>Sat, 24 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;总算是把前面的期末周撑过去了，今天下午到家，写了一下蓝妹，晚上抽空来写一下 2025 的年度总结了。感觉自己的闲聊类文章算写得比较多的了，最近一次总结到现在其实也没什么能聊的，故且从 1 月份开始说起吧。&lt;/p&gt;
&lt;h2&gt;时间线&lt;/h2&gt;
&lt;p&gt;一月份，还正处于冬天，那时我就了解到了很多厉害的学长，也看了很多学长的年度总结，于是我也完成了自己的第一份总结文章，立志要好好学习后端，整个一月份我做的最多的应该是补全和扩展自己的后端知识面吧，因为在此之前我还只会用 gin 写简单的 crud 项目，后面我跟着蓝山的考核学了很多，比如 websocket、elasticsearch，还有消息队列、微服务什么的。&lt;/p&gt;
&lt;p&gt;二月份，要过年了，由于什么都不懂，我不知天高地厚地去参加了字节青训营，那个时候我还没认识到微服务架构是个什么东西，服务注册和发现这些也没搞懂，稀里糊涂去组了个队，到头来也没做个项目出来，难绷。不过在这个月，我还做了很多乱七八糟的尝试，比如去看南大的 PA 和操作系统，虽然没看出什么名堂就是了；差不多我总算能理解微服务的时候，于是就学着样子把蓝山寒假考核写成微服务架构了，虽然是屎山，但是完成的时候还是很有成就感的。&lt;/p&gt;
&lt;p&gt;三月份，开学之际我就陷入了瓶颈，我在这个月也做了很多尝试，比如看《Go 语言设计与实现》、系统地学了消息队列/redis、去看 OSTEP、CSAPP、去写了个 Docker，虽然都有所收获，但是并没有明显的进步，我的心情依旧低落，处于停滞状态。&lt;/p&gt;
&lt;p&gt;四月，命运的齿轮开始转动，写完 Docker 之后，我决定重启操作系统的学习，先是学习了王爽的汇编，然后迟疑要不要看实模式到保护模式那本书的时候去问了问学长的意见，决定直接转向 riscv，开启了我的 mit6.s081 的旅途，在这个月，除了吃饭睡觉上课，我几乎所有的时间都是放在这上面的，回寝室、去食堂路上是难得的听歌的放松时间。&lt;/p&gt;
&lt;p&gt;五月初，五一假期期间，我结束了将近一个月的 mit6.s081 的学习，留下了网络和 mmap 两个实验没做，也不想做了，也在那个时候，我又写了一篇总结，那个时候心情是真舒畅，就跟冬天晚上在街边吃完面，长吐出去一口气变成白雾一样痛快。也是差不多在这个时候，蓝山最终考核副本也开了，有多个选题，本来最开始想要去写抽奖系统的，但是后来学长还是强烈推荐了另一个选题——在线课堂，最终就做了这个，主要是用 webrtc 或者其他的直播推拉流服务或者框架搭建一个在线课堂平台，感觉重心主要是在 webrtc 上面，虽然有点意思，但是本身的核心和一般的后端业务可能不一样，学了一段时间 webrtc，然后把之前学的微服务什么的全部塞了进去，也算勉强过关吧，之后决定学习计网，准备开 cs144。&lt;/p&gt;
&lt;p&gt;六月份，做 cs144 到一半，和学长沟通了一下，意识到 cs144 的实验可能没有太大的营养价值，于是中道崩徂了，然后学了学网络编程，了解了 reactor、proactor 这些东西，然后看了看刘丹冰的 zinx，其实也没看多少🤣，然后就准备期末复习了。&lt;/p&gt;
&lt;p&gt;七月份，开始看八股，又陷入迷茫了，给 otel 的文档翻译了 3k 行，感觉到没啥帮助，就润了。之前还想参加 ospp 来着，奈何没实力当了分母。这段时间，我还接触了一个开源社区 dubbo-go，它比我以前见过的任何一个开源社区都要活跃，非常理想的社区，但是自己后来也并没有太多精力能分到这上面，还是对不住里面的老登和拉我进去的人的心意。主线还是熟悉了一下工作室的项目，了解了比较规范的项目开发和自动化部署流程，自己以前完全没接触过，感觉还挺新奇的（后面运维和维护基本都是我和另一个人在干，笑死），简单写了个 qq 的迎新 bot，其实也就调了一下大模型接口，没啥了不起的。说起 qqbot，最近还开放了 md 模板，尽管如此我现在的评价还是野机 &amp;gt;&amp;gt; 官机。&lt;/p&gt;
&lt;p&gt;八月份，依旧迷茫ing，干了很多乱七八糟的事情，记忆比较深的是受某位其他学校的大佬影响，去给他参与的项目提了几个 PR，感觉也算一次难得的学习机会，后面半个月感觉不知道干什么，开启了 mit6.5840 的课程学习，严格来说，lab 可能就写了 20 天，其中 raft 的调试是最难的，后面几个 lab 就是在 raft 基础上写个 kv，都是砍瓜切菜，水到渠成的事。&lt;/p&gt;
&lt;p&gt;九月，开学了，利用贴吧的滔天权利给蓝山拉了很多人（七八九月份都在干这个），虽然和红岩那边是同一批，但是只能说不愧是玩贴吧的，又因为暑假就在接触，小登的综合素质确实高，所以就开学甩了别人几条街；差不多九月中上，我的 mit6.5840 也写完了，后面随便看了看八股，又陷入了迷茫，在犹豫简历上放什么项目。&lt;/p&gt;
&lt;p&gt;十月份，国庆期间比较摆，闲着没事学了一下 rust，当然是没有后话的🤣。和 AI 斗智斗勇了很久（浪费了很多时间），最终决定写一个 AI 电商放简历上，参考了很多其他的项目，感觉这块其实都做得不是很好，后面磕磕绊绊还是把它写出来了，也学到了很多以前没用过的技术，也有很多收获。&lt;/p&gt;
&lt;p&gt;十一月份，2025 快结束了，承接上文，写完了简历的项目，在电脑上装了个 fedora 做日常开发主力，然后我决心回归计算机基础，javaguide、小林 coding、csapp 我都重新过了一遍，很多东西又都有了新的收获和理解，本来想再看看现代操作系统的，感觉没啥意思还不如 csapp 好玩就弃坑了，到这里其实十一月也就结束了。&lt;/p&gt;
&lt;p&gt;十二月份，开始研究 go 的 runtime 和一些底层库，虽然早在 3 月份我就看过《Go 语言设计与实现》，但是我觉得对我来讲还是不够，因为我之前学这些的时候还没有学过操作系统这些东西，所以对很多底层细节比如 gmp 是不求甚解的。想起来，那段一点一点啃源码的时间对我当时的认知的冲击的频率真是太大了，每天都有顿悟的那种时候，也是很享受了😋。&lt;/p&gt;
&lt;p&gt;差不多也是十二月的月底到现在，boss 上聊了三百多家，投了三十几份简历，有大约 4、5 家约面，有两家 super 草台班子，说什么选拔机制，一个月只有 1k 补贴，吓得我直接拉黑，一家没消息，两家过了，工资活不下去就没去，等年后再努力了。&lt;/p&gt;
&lt;p&gt;难绷，没想到自己的 2025 年度总结竟然是流水账。不过质朴也算有我的风格，别人那种分为几个模块来总结的我还是模仿不来，就不予追究了。&lt;/p&gt;
&lt;h2&gt;闲言碎语&lt;/h2&gt;
&lt;p&gt;或许是我以往的人生太无趣了，并没有什么现充般的生活，不是学术上有了很大的成就，也不是说自己上了大学就过上了少爷生活，即便是这样，我也感觉 2025 是我目前为止人生最精彩的一年，我有了真正意义上的爱好——计算机，而不是高中那种在应试的压力下误以为自己对语文英语感兴趣的那种喜欢，我喜欢并享受着投入到计算机的学习上的时间；我认识了许多和我持有一个目标、一个爱好而努力的人，虽然他人的机遇和成就有时候真令人羡慕😭😭，不过自己至少不是一个人。&lt;/p&gt;
&lt;p&gt;今年总体的旋律虽然是迷茫，大多数时间是在焦虑和迷茫中度过的，但即便是一边迷茫一边前进，即便有遗憾，我也收获颇丰，至少是有意义的时间。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;快临近终结了&lt;/em&gt;&lt;/strong&gt;，想讲一下近期的规划，最近几天在仿照 maibot 把蓝妹改写一下，随后安安心心过个年，准备准备八股和面试。最后只希望 2026 能够对我温柔一点，我也能拥有属于自己的 &lt;strong&gt;&lt;em&gt;Wings of Courage&lt;/em&gt;&lt;/strong&gt; 吧。&lt;/p&gt;
</content:encoded></item><item><title>Go 的调度模型</title><link>https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/go-%E8%B0%83%E5%BA%A6%E5%99%A8/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/go-%E8%B0%83%E5%BA%A6%E5%99%A8/</guid><description>G、M、P 及其牵扯到的各种应用绝对是 Go 语言最核心，最复杂的部分。</description><pubDate>Sun, 21 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;我们都知道 GMP 模型，也知道经典的 &lt;code&gt;schedule&lt;/code&gt; 函数的大体步骤，但是隐藏在 g、m、p 这三个结构体之后的还有更加有趣的细节。&lt;/p&gt;
&lt;h2&gt;Go 程序的启动以及 GMP 的初始化&lt;/h2&gt;
&lt;p&gt;抛开 gmp 不谈，Go 在启动之初只是在一步一步执行汇编代码，首先初始化好了 m0 和 g0，之后执行初始化逻辑都是在 m0 和 g0 上面执行的，然后将写在 &lt;code&gt;runtime&lt;/code&gt; 中的 main 函数传入 &lt;code&gt;newproc&lt;/code&gt; 创建第一个 goroutine 并放入 p 的执行队列，除开我们当前的 g0 之外，此时已经有了一个 g，随后执行 &lt;code&gt;mstart&lt;/code&gt; 最终会进入到调度循环，开始调度最开始 &lt;code&gt;newproc&lt;/code&gt; 创建的 goroutine，此时会开始执行 &lt;code&gt;runtime.main&lt;/code&gt; 而不是 &lt;code&gt;main.main&lt;/code&gt;，所谓的 &lt;code&gt;main.main&lt;/code&gt; 会通过 linkname 去拉取我们自己实现的 main 函数，而在 &lt;code&gt;runtime.main&lt;/code&gt; 会经过一些初始化，最后才会执行我们的 main.main，如果对 Go 程序的启动步骤好奇，可以看看&lt;a href=&quot;https://chenbc.xlog.app/go-bootstrap&quot;&gt;这边文章&lt;/a&gt;，我感觉这位大佬写得还是挺好的。&lt;/p&gt;
&lt;p&gt;我们可以知道，随着程序的一步步初始化，我们的 gmp 模型才有了雏形，runtime 也并不是什么黑魔法，也是程序写出来的，但是我们还是需要注意一些 gmp 的初始化函数，比如 &lt;code&gt;schedinit&lt;/code&gt; ：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 他会在 osinit 之后，newproc 之前调用。
func schedinit() {
	...
	// 设置M的最大数量，默认 10000 个。
	sched.maxmcount = 10000

	// The world starts stopped. (世界在启动之初是“停止”的)
	worldStopped()
	// 一系列初始化，包括但不限于 GC，堆栈内存分配初始化。
	...
	
	// 确定 procs 的数量。
	var procs int32
	if n, ok := strconv.Atoi32(gogetenv(&quot;GOMAXPROCS&quot;)); ok &amp;amp;&amp;amp; n &amp;gt; 0 {
		procs = n // 优先使用GOMAXPROCS环境变量
		sched.customGOMAXPROCS = true
	} else {
		procs = defaultGOMAXPROCS(numCPUStartup) // 否则使用检测到的CPU核心数
	}

	// 根据 procs 的数量初始化所有的 p，并放在 allp 数组里面，
	// 里面包含全局的 p。
	if procresize(procs) != nil {
		throw(&quot;unknown runnable goroutine during bootstrap&quot;)
	}
	
	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们需要关心的部分只有 &lt;code&gt;procresize&lt;/code&gt;，可以看看它是如何初始化 p 的，这对我们后面对 gmp 的理解很重要，虽然这个函数非常长，但是其中涉及到的扩缩容操作并不是我们当前所关心的，我们只想要知道，p 到底是咋初始化的，初始化了哪些资源？&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 根据 nprocs 去调整 p 的数量，并对没有初始化的 p 进行初始化。
// 返回的是需要有待执行 g 的 p，在程序初始化的时候理所应当为空，所以非空时要抛出异常。
func procresize(nprocs int32) *p {
	...

	// 一般此时我们会经过这里，然后在这里初始化
	for i := old; i &amp;lt; nprocs; i++ {
		pp := allp[i]
		if pp == nil {
			pp = new(p)
		}
		// 真正的初始化操作
		pp.init(i)
		atomicstorep(unsafe.Pointer(&amp;amp;allp[i]), unsafe.Pointer(pp))
	}
	// 缩容操作啥的，不重要
	...
	
	// 这里一般会把没有运行队列（一般是刚初始化的 p）放入空闲队列
	for i := nprocs - 1; i &amp;gt;= 0; i-- {
		pp := allp[i]
		if gp.m.p.ptr() == pp {
			continue
		}
		pp.status = _Pidle
		if runqempty(pp) {
			pidleput(pp, now)
		} else {
			pp.m.set(mget())
			pp.link.set(runnablePs)
			runnablePs = pp
		}
	}
	...
	// 统计 allp 里面谁有待执行的 g，在 schedinit 阶段一定为空。
	return runnablePs
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以注意到，我们的 p 在 &lt;code&gt;init&lt;/code&gt; 之后，由 &lt;code&gt;pidleput&lt;/code&gt; 放入了空闲列表，而这个空闲列表正是我们程序准备完毕之后，gmp 的一个关键，随后我们可以进入到 &lt;code&gt;p.init&lt;/code&gt; 这个方法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 初始化 p
func (pp *p) init(id int32) {
	...
	pp.sudogcache = pp.sudogbuf[:0]
	
	pp.deferpool = pp.deferpoolbuf[:0]
	
	pp.wbBuf.reset()

	// 分配 mcache
	if pp.mcache == nil {
		if id == 0 {
			if mcache0 == nil {
				throw(&quot;missing mcache?&quot;) // 启动阶段 mcache0 必须存在。
			}
			// 使用引导阶段预先创建的 mcache0。
			// 只有一个 P 能得到 mcache0，那就是0号 P。
			// mcache0 是在 mallocinit 期间创建的，那时还没有 P。
			pp.mcache = mcache0
		} else {
			// 其他 p 调用 allocmcache() 分配一个新的 mcache。
			// mcache 是啥？可以看我的上篇文章。
			pp.mcache = allocmcache()
		}
	}
	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看见，它仅仅是初始化了一些极少的资源，其中不涉及 m，也不涉及 g，这也就意味着，在之后我们创建一个新的 goroutine 之后，如果被调度到一个全新的 p 上，那么这个 p 必然需要去获取或者创建一个新的 m 才能正常执行这个 g，当然，之后在源码中我们一定会找到这部分的逻辑。&lt;/p&gt;
&lt;p&gt;根据我们上面提到的 Go 程序的启动顺序，我们可以知道 &lt;code&gt;schedinit&lt;/code&gt; 的下一步是通过 &lt;code&gt;newproc&lt;/code&gt; 创建一个执行 &lt;code&gt;runtime.main&lt;/code&gt; 函数的 goroutine，并将其放入当前 p 的执行队列，但是此时我们还没有去运行任何一个调度循环，无法去调度我们 p 本地队列中的 goroutine，所以之后我们会通过 &lt;code&gt;mstart&lt;/code&gt; 去执行一系列操作，最终，调用大名鼎鼎的 &lt;code&gt;schedule&lt;/code&gt; 去对我们队列中的 goroutine 进行调度。而这个所谓的 &lt;code&gt;newproc&lt;/code&gt; 事实上就是我们 &lt;code&gt;go [函数调用]&lt;/code&gt; 转换到运行时的函数调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func newproc(fn *funcval) {
	...
	// systemstack 之前也提过，他会切换到 g0 的栈进行执行
	// g0 的栈，本质上其实就是一个初始栈空间更大的 goroutine，细节暂时未研究。
	systemstack(func() {
		// 1. 创建Goroutine结构体
		// newproc1是真正负责分配和初始化g结构体的函数。
		// 它会设置好新g的栈、起始PC（指向fn）、状态(_Grunnable)等。
		// fn: 新goroutine要执行的函数。
		// gp: 创建者goroutine。
		// pc: 创建位置。
		newg := newproc1(fn, gp, pc, false, waitReasonZero)

		// 获取当前的 p
		pp := getg().m.p.ptr()
		
		// 通过刚刚获取的 p，可以放到本地队列，如果 runnnext 槽位是空的
		// 就会放入 runnext 槽位，此时 schedule 在下一轮调度会执行这个函数，
		// 不然就正常放入 runq，
		// 当然，如果本地队列满了，那么就会包括这个 g 在内，取一部分 g 到全局队列
		runqput(pp, newg, true)
		
		// 在程序完成所有初始化之后，
		// 每次创建一个新的goroutine，都尝试唤醒一个可能在休眠的P。
		// 要求是此时没有正在自旋的 m（意思是此时足够空闲）
		// 有了它，每次启动一个协程都有可能启动一个 p，从而真正意义上的利用了多核的优势。
		if mainStarted {
			wakep()
		}
	})
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然这里的 &lt;code&gt;newproc1&lt;/code&gt; 和 &lt;code&gt;runqput&lt;/code&gt; 看起来都很有意思，但是这并不是我们的重点，你只需要知道，&lt;code&gt;newproc1&lt;/code&gt; 其实就是根据一系列参数创建了一个结构体，而 &lt;code&gt;runqput&lt;/code&gt; 则是让这个 g 能够被 p 调度运行即可。这里虽然并不会执行，但是等到 &lt;code&gt;runtime.main&lt;/code&gt; 执行过程中，&lt;code&gt;main.main&lt;/code&gt; 开始之前，这个 &lt;code&gt;mainStart&lt;/code&gt; 会标记为 true，之后每次启动一个 goroutine 都有可能唤醒我们之前初始化好的 p，然后让他去陷入 &lt;code&gt;schedule&lt;/code&gt; 调度循环，进入到 &lt;code&gt;findRunnable&lt;/code&gt; 去寻找可以执行的 g，他可能会去获取全局队列上的 g，也可能窃取其他队列上的 g，从而实现多个 p 的负载均衡。总之，有了这个，我们就能够充分的利用多核的优势，真正意义上的有多个 g-m-p 正在运行。&lt;/p&gt;
&lt;p&gt;而我们当前处于 go 程序初始化的阶段，这里传入的 fn 参数的当然是 &lt;code&gt;runtime.main&lt;/code&gt;，于是，我们可以知道，我们当前 p 的本地队列有一个待执行的 g 了！接下来，我们会调用 &lt;code&gt;mstart&lt;/code&gt; 最终执行到 &lt;code&gt;schedule&lt;/code&gt; 我们就能够看见真正的 gmp 调度了。而 &lt;code&gt;mstart&lt;/code&gt; 其实是一段汇编代码，他会调用 &lt;code&gt;mstart0&lt;/code&gt;，但是其实 &lt;code&gt;mstart0&lt;/code&gt; 并没有什么值得注意的代码，我们可以直接看 &lt;code&gt;mstart1&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func mstart1() {
	...
	// 注册信号 handler
	if gp.m == &amp;amp;m0 {
		mstartm0()
	}

	...

	// 我们熟悉的 schedule 来了
	schedule()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其实我们当前只需要关注 &lt;code&gt;schedule&lt;/code&gt; 这一个函数的执行即可，其余无关紧要，熟悉他的朋友都知道，他其实就是一个调度循环，会不断去寻找可运行的 g 去运行，现在不用关心他的逻辑，我们只需要知道现在我们可以执行 &lt;code&gt;runtime.main&lt;/code&gt; 了！&lt;/p&gt;
&lt;p&gt;当然，到了这里，事情并不会就这样简单的结束，在真正地调用 &lt;code&gt;main.main&lt;/code&gt; 之前，还需要进行一些处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 之前提到的 linkname 拉模式
//go:linkname main_main main.main
func main_main()
...
// The main goroutine. (主goroutine执行的函数)
func main() {
	...
	// 允许 newproc 去唤醒休眠的 p，创建新的 m。
	mainStarted = true
	// 系统监控，专门启动一个线程去执行它
	// 系统监控没有 p，尽管有着 g0，但是他已经不归 go 的调度器管理了
	// 是一个纯粹的操作系统线程。
	if haveSysmon {
		systemstack(func() {
			newm(sysmon, nil, -1)
		})
	}
	...
	// runtime 的 init 函数
	doInit(runtime_inittasks)
	...
	// gcenable 会让 gc 正式开始工作
	gcenable()
	// cgo 相关初始化
	if iscgo {
		...
		// 通知Cgo，Go的运行时已经初始化完毕。
		cgocall(_cgo_notify_runtime_init_done, nil)
	}
	// 看到这里我非常惊讶，我们之前写的 init 函数都是在这里进行调用的
	for m := &amp;amp;firstmoduledata; m != nil; m = m.next {
		doInit(m.inittasks)
	}
	// 执行我们编写的 main
	fn := main_main
	
	fn()
	...
	// 清理工作
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到这里，我们可以看见熟悉的 &lt;code&gt;init&lt;/code&gt; 函数，熟悉的 &lt;code&gt;cgo&lt;/code&gt; 还有熟悉的系统监控，以及的 &lt;code&gt;main.main&lt;/code&gt; 的调用，不得不感叹，计算机的世界没有魔法。之后，在我们运行 main 函数的过程中，如果调用了 &lt;code&gt;go [函数调用]&lt;/code&gt;，那么就会转换为 &lt;code&gt;newproc&lt;/code&gt; 来创建一个新的 g，并放入待运行的队列中，随着程序的运行，g 的数量可能不断增多，它可能会被调度到其他 p 上，也可能会被放到全局队列上等待执行，毫无疑问的是，庞大的 GMP 帝国此时正在运转。（感觉自己像在写记叙文）&lt;/p&gt;
&lt;h2&gt;调度时机&lt;/h2&gt;
&lt;p&gt;虽然目前为止，我们对 GMP 的实现以及运转有了大体的认识，但是我们对于协作调度，抢占调度还是一无所知，有了 &lt;code&gt;schedule&lt;/code&gt; 然后呢？它是如何在某个时刻停止运行这个 g，转而去运行下一个 g 的？针对于内核级线程这很简单，常见的调度算法就是时间片调度，只需要操作系统对硬件发出的时间片中断进行处理，进入到内核去运行下一个线程即可。那么我们的 goroutine 呢？由于他是在用户态实现的，并不能在进行系统调用的时候进行调度，也不能通过监听时间片中断来切换 goroutine 执行。Go 的 goroutine 调度不仅会通过 runtime 的一些安全点去检查抢占位来进行调度防止单个 g 运行过久，也会结合操作系统的信号机制来实现抢占式调度来尽可能地去保证公平调度。&lt;/p&gt;
&lt;p&gt;其实几乎所有的调度最终都会通过 &lt;code&gt;schedule&lt;/code&gt; 去找到下一个等待执行的 g 并运行，所以我们只需要在 &lt;code&gt;runtime&lt;/code&gt; 找到调用 &lt;code&gt;schedule&lt;/code&gt; 的地方就可以了。&lt;/p&gt;
&lt;h3&gt;Gosched&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Gosched&lt;/code&gt; 是一个主动让出线程资源的函数，除了我们自己编写代码的时候可以调用它以外，它也被许多运行时代码所调用，比如在 gc 期间分配内存会触发债务机制，如果当前 g 无法还清债务，会检查当前 g 的 &lt;code&gt;preempt&lt;/code&gt; 标志位，即抢占请求位，此时会通过 &lt;code&gt;Gosched&lt;/code&gt; 让出 m，如果没有抢占标志位，那么也会 &lt;code&gt;park&lt;/code&gt; 当前 g 的执行，放到 &lt;code&gt;assistQueue&lt;/code&gt; 队列里面，等待唤醒。（这个机制可以看我的 gc 文章）&lt;/p&gt;
&lt;h3&gt;基于协作的抢占式调度&lt;/h3&gt;
&lt;p&gt;我们常说的抢占调度点有一个函数调用，其实本质上是在栈扩张时，会调用 &lt;code&gt;newstack&lt;/code&gt; ，其中会有一个抢占位的检查，检查当前的 g 是否应该被抢占，当然，这里关于栈的操作并不是我们的重点，我们关注的是抢占：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//go:nowritebarrierrec
func newstack() {
	...

	// 这个字段可能会由系统监控和 GC 修改。
	stackguard0 := atomic.Loaduintptr(&amp;amp;gp.stackguard0)

	// 有没有抢占请求
	preempt := stackguard0 == stackPreempt
	
	if preempt {
		// 有一些情况导致不满足安全点导致无法抢占
		if !canPreemptM(thisg.m) {
			// 恢复正常的栈“警戒线”，否则G会无限次地触发morestack。
			gp.stackguard0 = gp.stack.lo + stackGuard
			// 回到原来的执行点，这次暂时不执行抢占逻辑
			gogo(&amp;amp;gp.sched)
		}

		...
		// 如果需要缩容
		if gp.preemptShrink {
			gp.preemptShrink = false
			shrinkstack(gp)
		}

		if gp.preemptStop {
			preemptPark(gp)
		}

		// 正常的抢占，让出当前的 m
		gopreempt_m(gp)
	}
	
	// ... (省略栈增长的逻辑)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里，我们的 &lt;code&gt;stackguard0&lt;/code&gt; 可能会是系统监控检测到这个 g 已经运行很久了所以去设置的，也可能是垃圾回收需要 STW 时设置的抢占，总之这里会进行一定的抢占逻辑，如果不满足安全点的条件，那么这次会暂时不执行抢占，这里的 &lt;code&gt;preemptStop&lt;/code&gt; 代表的是 GC 步骤中，&lt;code&gt;markroot&lt;/code&gt; 这个函数需要扫描 goroutine 的栈空间所以必须要暂停这个 g 的执行，于是会调用 &lt;code&gt;suspendG&lt;/code&gt;，扫描完成之后，会通过 &lt;code&gt;resumeG&lt;/code&gt; 来恢复，所以他其实和我们想要探究的调度没有太大关系，只是 GC 中的一个步骤。&lt;/p&gt;
&lt;p&gt;正常的 &lt;code&gt;gopreempt_m&lt;/code&gt; 底层和 &lt;code&gt;Goshed&lt;/code&gt; 调用的方法一样，都是让出当前的 m，而当前正在执行的 g 则会放入全局队列。&lt;/p&gt;
&lt;h3&gt;抢占式调度&lt;/h3&gt;
&lt;p&gt;当然上面提到的抢占依旧是有局限性的，它依赖于函数调用会触发 &lt;code&gt;morestack&lt;/code&gt; 才能够进行抢占检查。如果我们有一个 g 陷入了 &lt;code&gt;for&lt;/code&gt; 循环空转，在 1.14 版本之前是无法被抢占或者让出 m 的，在 1.14 之后，引入了一个真正的抢占式调度。它不再依赖于 &lt;code&gt;stackguard0&lt;/code&gt; 的状态位，而是基于信号中断处理的机制来实现的真正的抢占式调度。当然，了解真正的抢占式调度之前，我们需要补充一下信号是什么。这是 Go 实现异步抢占的关键知识。&lt;/p&gt;
&lt;p&gt;我们这里所说的信号其实进程或线程之间进行异步通信的机制，常见的比如 &lt;code&gt;Ctrl+C&lt;/code&gt; 退出进程就是我们所说的信号，他们都会跳转到内核指定的执行程序来实现处理。&lt;/p&gt;
&lt;p&gt;而我们的 Go 程序巧妙地使用了操作系统提供的信号机制来实现了异步的抢占式调度，Go 程序启动时，它会向内核注册一个自定义的信号处理器，也就是 &lt;code&gt;sighandler&lt;/code&gt;，表示只要收到某个指定的信号，都会去执行这个处理器的代码。以我以前学过的 xv6 为例子，当任何一个操作系统线程因为时间片中断，系统调用或者页中断陷入内核，完成了一系列处理任务之后，准备返回用户空间之前，会检查当前线程是否存在待处理的信号，如果检查到我们 &lt;code&gt;SIGURG&lt;/code&gt; 这个待处理信号，那么在返回用户空间时并不会返回到原本的执行点，而是程序启动时预先设置的 &lt;code&gt;sighandler&lt;/code&gt; 从而实现了基于信号的抢占逻辑，而这个抢占信号通常是由&lt;strong&gt;系统监控&lt;/strong&gt;发出的，我们的系统监控会遍历每个 p，判断这个 g 是否运行太久了，如果是，那么就会对这个 p 上的 m 发出抢占信号。&lt;/p&gt;
&lt;p&gt;回到代码层面，当我们想要抢占当前的 m 的时候，我们会向这个 m 发出 &lt;code&gt;sigPreempt&lt;/code&gt; 信号，会在&lt;strong&gt;系统监控&lt;/strong&gt;中检测这个 g 是否运行了过长时间来决定是否抢占的，而我们可以在 &lt;code&gt;src/runtime/signal_unix.go&lt;/code&gt; 找到这个函数 &lt;code&gt;sighandler&lt;/code&gt; 他会检查信号的类型，如果是 &lt;code&gt;sigPreempt&lt;/code&gt; 信号，那么他最终也会进入到 &lt;code&gt;preemptPark&lt;/code&gt; 或者 &lt;code&gt;gopreempt_m&lt;/code&gt; 来取消执行当前的 g，然后进入到 &lt;code&gt;schedule&lt;/code&gt; 调度新的 g。值得注意的是，虽然我们的协作式抢占和信号抢占在系统监控中重复执行了，但是他俩并不会重复执行，这是因为他们的处理程序在执行调度之前都会进行一定的状态检查，从而防止了重复调度。&lt;/p&gt;
&lt;h3&gt;小结&lt;/h3&gt;
&lt;p&gt;Go 触发调度的方式总的来说有三种：主动让出，协作式抢占和基于信号的抢占调度。&lt;/p&gt;
&lt;p&gt;而协作式抢占和基于信号的抢占在 &lt;code&gt;sysmon&lt;/code&gt; 中我们可以看出他是双管齐下同时触发的，尽管我们的信号处理依赖了不可避免的中断机制，但是从内核返回的用户空间的变化明显违反了局部性原理，引起了更大的开销。为了减小这个开销，Go 语言的设计者完全可以设置两个不同的超时阈值来防止每次抢占都会触发信号抢占，但是事实上 Go 语言选择了同时执行，我们可以看出 Go 语言比起性能更加注重简洁和公平性。尽管是同时触发，但是也不意味着他会触发两次调度，最终会有一个状态检查来决定这次是否应该触发调度从而防止重复地调度。&lt;/p&gt;
&lt;h2&gt;调度循环&lt;/h2&gt;
&lt;p&gt;前面提过了我们会触发调度的时机，通过抢占式调度尽量做到了调度的公平性，那么现在我们可以进入到 &lt;code&gt;schedule&lt;/code&gt; 来看看调度循环到底要干些什么。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 寻找可以用的 g 进行运行调度。
func schedule() {
	...
top: // 这是无限循环的起点
	...
	// 核心是通过 findRunnable 去寻找一个可以执行的 g
	gp, inheritTime, tryWakeP := findRunnable()

	...

	// 开始执行这个 g
	execute(gp, inheritTime)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以直接进入到 &lt;code&gt;findRunnable&lt;/code&gt; 来看看它会怎么找到一个待执行的 g。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 寻找一个可运行的goroutine来执行。
// 它会尝试从其他P窃取，从本地或全局队列获取g，轮询网络。
func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	mp := getg().m // 获取当前的M
	
top: // 循环起点
	pp := mp.p.ptr()
	// 看看垃圾回收的 stw 有没有等待。
	if sched.gcwaiting.Load() {
		gcstopm()
		goto top
	}
	...
	// 检查并调度 GC worker goroutine
	if gcBlackenEnabled != 0 {
		if gp, _ := gcController.findRunnableGCWorker(pp, now); gp != nil {
			return gp, false, true // tryWakeP = true 建议唤醒一个新 p。
		}
	}
	// 检查常规任务队列
	// 每 61 次调度循环就检查一次全局队列，防止饿死。
	if pp.schedtick%61 == 0 &amp;amp;&amp;amp; !sched.runq.empty() {
		if gp := globrunqget(); gp != nil {
			return gp, false, false
		}
	}

	// 检查自己的本地队列，当然这里会先用 runnext
	if gp, inheritTime := runqget(pp); gp != nil {
		return gp, inheritTime, false // 找到了！返回。
	}
	
	// 全局队列，这里会批量拿一些 g 塞到自己的本地队列，防止每次都拿。
	if !sched.runq.empty() {
		lock(&amp;amp;sched.lock)
		gp, q := globrunqgetbatch(int32(len(pp.runq)) / 2)
		unlock(&amp;amp;sched.lock)
		if gp != nil {
			if runqputbatch(pp, &amp;amp;q); !q.empty() {
				throw(&quot;Couldn&apos;t put Gs into empty local runq&quot;)
			}
			return gp, false, false
		}
	}
	
	// 网络轮询器，主要是帮 net/http 的多路复用干活。
	if netpollinited() &amp;amp;&amp;amp; netpollAnyWaiters() &amp;amp;&amp;amp; sched.lastpoll.Load() != 0 &amp;amp;&amp;amp; sched.pollingNet.Swap(1) == 0 {
		list, delta := netpoll(0)
		sched.pollingNet.Store(0)
		if !list.empty() { // 非阻塞
			gp := list.pop()
			injectglist(&amp;amp;list)
			netpollAdjustWaiters(delta)
			trace := traceAcquire()
			casgstatus(gp, _Gwaiting, _Grunnable)
			if trace.ok() {
				trace.GoUnpark(gp, 0)
				traceRelease(trace)
			}
			return gp, false, false
		}
	}
	
	// 工作窃取
	if mp.spinning || 2*sched.nmspinning.Load() &amp;lt; gomaxprocs-sched.npidle.Load() {
		...
		gp, inheritTime, tnow, w, newWork := stealWork(now)
		if gp != nil {
			// 窃取到了
			return gp, inheritTime, false
		}
		...
	}

		// 帮 gcMarkWorker 干活
	if gcBlackenEnabled != 0 &amp;amp;&amp;amp; gcMarkWorkAvailable(pp) &amp;amp;&amp;amp; gcController.addIdleMarkWorker() {
		node := (*gcBgMarkWorkerNode)(gcBgMarkWorkerPool.pop())
		if node != nil {
			...
			gp := node.gp.ptr()
			...
			return gp, false, false
		}
		...
	}
	
	...

	// 最后再检查一遍
	if !sched.runq.empty() {
		// 发现全局队列又有 g 了
		unlock(&amp;amp;sched.lock)
		return gp, false, false
	}
	
	// 放弃 p
	if releasep() != pp { throw(&quot;...&quot;) } // M与P解绑
	pidleput(pp, now) // 将P放入全局空闲列表
	unlock(&amp;amp;sched.lock)

	// 此时 m 失去了 p，它可以干的就是阻塞地检查网络轮询器，负责多路复用和计时器的监听
	if netpollinited() &amp;amp;&amp;amp; (netpollAnyWaiters() || pollUntil != 0) &amp;amp;&amp;amp; sched.lastpoll.Swap(0) != 0 {
		...
		list, delta := netpoll(delay)
		...
		if faketime != 0 &amp;amp;&amp;amp; list.empty() {
			// 还没有就睡了
			stopm()
			goto top
		}
		// 否则取一个 p 来执行
		lock(&amp;amp;sched.lock)
		pp, _ := pidleget(now)
		unlock(&amp;amp;sched.lock)
		if pp == nil {
			// 如果没有找到 p 可以执行，可以注入到别人的队列里面去执行这些 g
		} else {
			acquirep(pp)
			if !list.empty() {
				...
				return gp, false, false
			}
			goto top
		}
	}
	...
	
	// 如果连网络轮询都不需要做，就调用 stopm()，让M彻底进入休眠。
	// 它会一直睡，直到被 wakep() 明确地唤醒。
	stopm()
	goto top // 重新开始找
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然 &lt;code&gt;findRunnable&lt;/code&gt; 函数实现非常复杂，但是它的目的只有一个，那就是找到一个可以执行的 g，他的先后顺序是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;gcWorker，垃圾回收阶段，如果当前标记进度严重落后，那么就会去执行一个专门的 gcWorker。&lt;/li&gt;
&lt;li&gt;每 61 次调度会先去全局队列中找 g 执行保证公平性&lt;/li&gt;
&lt;li&gt;本地队列。&lt;/li&gt;
&lt;li&gt;全局队列。&lt;/li&gt;
&lt;li&gt;非阻塞的网络轮询。&lt;/li&gt;
&lt;li&gt;窃取其他 p 的本地队列。&lt;/li&gt;
&lt;li&gt;尽力去处理 gcWorker。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;最后再检查一遍全局队列里面有没有可以执行的 g，如果没有，那么这个 M 就会调用 &lt;code&gt;releasep&lt;/code&gt; 和当前的 p 解绑并将它放入空闲 p 列表，而我们的 m 则用来阻塞地调用网络轮询，等待多路复用事件的到来或者计时器事件的到来。当然，我们的 &lt;code&gt;findRunnable&lt;/code&gt; 肯定不是单纯的线性执行，中间存在着不少 goto 语句，这也意味着并不是说执行了某个阶段之后一定会执行下一个阶段，有可能会跳转到第一个阶段从头开始，这就是它叫做调度循环的原因。&lt;/p&gt;
&lt;p&gt;虽然这个一步一步去找的步骤很无聊，但是也能看见一些有趣的优化，比如从全局队列拿 g 的时候是批量抓取而不是一个一个地获取，减少了锁竞争，当然，从其他 p 的本地队列窃取 g 也是批量窃取 g 塞入自己的队列中，这里和全局队列窃取不一样，当我们从其他 p 的队列中窃取没有用互斥锁，而是使用的一种乐观锁的机制，有兴趣可以自己去看看。当然除了这些以外，&lt;code&gt;findRunnableGCWorker&lt;/code&gt; 本身也是一个有趣的实现，它做的不仅仅是从 gcWorker 里面取出一个 g 执行，他会衡量当前的 GC 任务是否紧张，根据实际需要决定是否应该在这个时候调度 gcWorker。&lt;/p&gt;
&lt;h2&gt;一些有趣的细节&lt;/h2&gt;
&lt;h3&gt;系统调用&lt;/h3&gt;
&lt;p&gt;我们经常会说在陷入阻塞系统调用的时候，这个 m 会和 p 立即解绑，会去另外找一个 m 去执行，而非阻塞的系统调用虽然也会解绑，但是这个 m 会记住这个 p，等从系统调用返回之后，会优先去获取这个 p，刘丹冰的《深入理解Go语言》也正是这么写的，但是这个说法（阻塞和非阻塞）并不算准确：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func reentersyscall(pc, sp, bp uintptr) {
	...

	// g 状态改变
	casgstatus(gp, _Grunning, _Gsyscall)

	...
	
	// 记录下当前P的调度tick，sysmon会用它来判断syscall是否超时。
	gp.m.syscalltick = gp.m.p.ptr().syscalltick

	// 解绑逻辑
	pp := gp.m.p.ptr() // 获取当前M绑定的P
	pp.m = 0
	gp.m.oldp.set(pp) // 记住它，回来时优先找他
	gp.m.p = 0 
	
	atomic.Store(&amp;amp;pp.status, _Psyscall)
	...
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以看见，这里仅仅只有解绑逻辑，并没有任何关于阻塞或者非阻塞的判断，然而在任何 syscall 的包里面都只有 &lt;code&gt;reentersyscall&lt;/code&gt; 的调用，所以并不存在明显的阻塞系统调用或者非阻塞的系统调用的界限，而 &lt;code&gt;reentersyscall&lt;/code&gt; 的作用也只是将 m 和 p 解绑，并不存在任何判断阻塞或者非阻塞的逻辑。而我们说的阻塞和非阻塞的逻辑判断以及是否让出挂起 m，其实是在系统监控中做的。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;retake&lt;/code&gt; 中，我们检查状态位 &lt;code&gt;_Psyscall&lt;/code&gt; 的 p 是否已经陷入很久了，并决定去是否应该为他设置新的 m。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if s == _Psyscall {
	// 如果P在syscall状态超过1个sysmon tick（至少20µs），就收回它。
	t := int64(pp.syscalltick) // 读取P进入syscall时的tick计数。

	// pd 是 sysmon 自己维护的，记录了它上次巡逻时，每个P的状态。
	if !sysretake &amp;amp;&amp;amp; int64(pd.syscalltick) != t {
		// 这是sysmon第一次发现这个P进入了syscall状态（tick值变了）。
		pd.syscalltick = uint32(t)
		pd.syscallwhen = now
		
		// 只是记录，不采取行动，再给它一次机会。
		// 也许这个syscall很快就返回了。
		continue 
	}

	// 一个优化
	if runqempty(pp) &amp;amp;&amp;amp; // 条件1: 这个P自己的本地队列是空的，
	   sched.nmspinning.Load()+sched.npidle.Load() &amp;gt; 0 &amp;amp;&amp;amp; // 条件2: 并且系统里有其他空闲/自旋的资源，说明不缺P，
	   pd.syscallwhen+10*1000*1000 &amp;gt; now { // 条件3: 并且卡住的时间还不到10毫秒。
		
		// 如果同时满足这三个条件，意味着：
		// 1. “抢救”回这个P，也没活给它干。
		// 2. 整个系统不忙，不急需这个P。
		// 3. 卡住的时间还不算太长。
		//
		// 结论：再等等看，现在“抢救”的性价比不高。
		continue
	}

	// 如果代码走到了这里，说明“抢救”是必要的。
	// 要么是P上有任务，要么是系统很忙，要么是P卡得太久了。
	...
	if atomic.Cas(&amp;amp;pp.status, s, _Pidle) {
		if trace.ok() { trace.ProcSteal(pp, false) }
		
		pp.syscalltick++
		
		// handoffp会检查这个P是否有工作，
		// 如果有，就为它找一个M；如果没有，就把它放入空闲列表。
		handoffp(pp)
	}
	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然对于阻塞和非阻塞并没有明确的规定，但是我觉得这里的说法还是可以再优化一下，阻塞还是非阻塞，要不要给这个 P 安排新的 M 完全取决于系统监控以及当时的资源紧张情况。&lt;/p&gt;
&lt;p&gt;当然，在退出执行系统调用的时候，会执行 &lt;code&gt;exitsyscall&lt;/code&gt; 这个函数，此时会优先去获取原来的 p，即陷入系统调用之前记录的 &lt;code&gt;oldp&lt;/code&gt;，如果这个 p 已经转而去执行其他 m 了，那么就会从空闲的 p 中找一个来执行这个 m，如果没有任何能够执行这个 m 的 p，那么就会将这个 g 放入全局的运行队列，而这个 m 则通过 &lt;code&gt;stopm&lt;/code&gt; 进入到空闲的 m 列表，并陷入睡眠。&lt;/p&gt;
&lt;p&gt;还有一个值得说的是，cgo 调用也会经过 &lt;code&gt;reentersyscall&lt;/code&gt;，换而言之，系统监控把 cgo 调用也当作系统调用处理。&lt;/p&gt;
&lt;p&gt;另外，很多人会认为 &lt;code&gt;entersyscallblock&lt;/code&gt; 会在调用会引发阻塞的系统调用时调用，实际上并不是这样，前面也说了，&lt;code&gt;syscall&lt;/code&gt; 包里面只会在进入系统调用之前调用 &lt;code&gt;reentersyscall&lt;/code&gt;，那么 &lt;code&gt;entersyscallblock&lt;/code&gt; 到底是干什么的？我们可以注意到调用这个 &lt;code&gt;entersyscallblock&lt;/code&gt; 的 &lt;code&gt;notetsleepg&lt;/code&gt; 主要是由 &lt;code&gt;signal_recv&lt;/code&gt; 调用，然后通过 linkname 链接到了 &lt;code&gt;os/signal&lt;/code&gt; 包，目前来看主要是用于一个信号通知的机制，也就是我们所说的 &lt;code&gt;Notify&lt;/code&gt; 会用到他，但是这里我也没咋（懒得）研究，有兴趣的可以自己看看吧。&lt;/p&gt;
&lt;h3&gt;线程 M&lt;/h3&gt;
&lt;p&gt;之前在小红书上看见一个面经，问 m 什么时候会销毁，我确实不知道这个玩意，在看源码的时候也没有找到确切的答案什么时候会销毁 m，到处搜文章，找到了一篇 &lt;a href=&quot;https://zhuanlan.zhihu.com/p/1908657908163511866&quot;&gt;go对M和线程的管理&lt;/a&gt; 感觉总结的还不错。&lt;/p&gt;
&lt;p&gt;上面我们提到了 &lt;code&gt;handoffp&lt;/code&gt; 会为有 g 的 p 找一个 m 执行命令，如果找不到就会创建一个新的 m，其中涉及到的系统调用就是 &lt;code&gt;clone&lt;/code&gt;，&lt;code&gt;clone&lt;/code&gt; 这个系统调用会新建一个内核线程，从 &lt;code&gt;mstart&lt;/code&gt; 开始执行，最后进入到 &lt;code&gt;schedule&lt;/code&gt; 永不返回。这里有个问题就是，如果 m 进入到了 schedule，那么它到底该怎么退出呢？在运行过程中，正常情况下，Go 的 m 线程只会增长，当空闲时会进入到空闲 m 列表，忙碌时会先从空闲 m 列表中获取 m 来执行，如果没有 m 那么就会再创建一个新的 m 参与调度，这也意味着在某个时刻如果有突发的流量进入系统，就可能会导致在这个时候创建很多 m，从而导致系统中出现大量的 m 而无法销毁，所以我们在编写业务代码的时候需要限制任务数量，或许协程池是一个有效的手段。&lt;/p&gt;
&lt;p&gt;那么有没有不正常的情况呢？是有的，通过 &lt;code&gt;runtime.LockOSThread()&lt;/code&gt; 锁定到线程的 goroutine 退出时，Go 就会终止这个线程，具体的链路可以参考我上面贴的文章或者在 &lt;code&gt;Goexit&lt;/code&gt; 中找到。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;Go 的调度器还是比较复杂的，涉及到方方面面，其实在这里还想介绍一下网络轮询器的源码，但是我已经找到一个无法超越的文章了（放下面的），写得特别好，在本文的最后贴一下几篇让我受益匪浅的文章吧。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://kydenul.github.io/posts/golang-netpoll/&quot;&gt;揭秘 Go 网络轮询器：从 Epoll 到 Netpoll 的架构实现&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/1908657908163511866&quot;&gt;go对M和线程的管理&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://draven.co/golang/docs/part3-runtime/ch06-concurrency/golang-goroutine/&quot;&gt;Go 语言设计与实现之调度器&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://chenbc.xlog.app/go-bootstrap&quot;&gt;Go 运行时之程序的启动&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>关于 Go 的内存管理这档事</title><link>https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/go-%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/go-%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/</guid><description>内存管理是 go 运行时的躯干，了解他有助于我们对 runtime 的深入理解。</description><pubDate>Fri, 12 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Go 语言里面，不论是堆还是栈，本质都是在操作系统的堆上进行分配的，本篇文章不会重点分析 Go 里面的多级缓存的内存分配模型，主要梳理一边堆栈内存的链路，来帮助我们把 Go 的 Runtime 串联起来，但是在梳理过程中，其实也能够看见多级缓存分配的身影。&lt;/p&gt;
&lt;h2&gt;1. 堆内存的分配链路&lt;/h2&gt;
&lt;h3&gt;1.1 mallocgc 的入口&lt;/h3&gt;
&lt;p&gt;最适合入手进行分析的就是我们的 &lt;code&gt;mallocgc&lt;/code&gt; 了，几乎所有的内存分配都要经过他，包括但不限于 channel，map，结构体的内存分配。（PS：这里的注释其实非常好玩，严肃批评了很多用 golinkname 技术来访问 go runtime 的内部细节的库，其中就包括字节的 sonic）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 分配一个指定大小的对象。
// 小对象从每个 P 的缓存空闲链表中分配。
// 大对象（大于 32 kB）直接从堆中分配。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	...

	// 剔除了不必要的部分，实际上我们只需要看他是如何分配内存的即可
	// 这里实际执行分配操作。
	var x unsafe.Pointer
	var elemsize uintptr
	if size &amp;lt;= maxSmallSize-gc.MallocHeaderSize {
		// 如果对象类型为 nil 或不含指针，分配小对象。
		if typ == nil || !typ.Pointers() {
			if size &amp;lt; maxTinySize {
				x, elemsize = mallocgcTiny(size, typ)
			} else {
				x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
			}
		} else {
			// 如果对象包含指针且不需要零初始化，抛出异常。
			if !needzero {
				throw(&quot;objects with pointers must be zeroed&quot;)
			}
			// 如果该对象是堆段中的一部分，分配扫描小对象。
			if heapBitsInSpan(size) {
				x, elemsize = mallocgcSmallScanNoHeader(size, typ)
			} else {
				x, elemsize = mallocgcSmallScanHeader(size, typ)
			}
		}
	} else {
		// 分配大对象。
		x, elemsize = mallocgcLarge(size, typ, needzero)
	}
	...
	return x
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以发现，&lt;code&gt;mallocgc&lt;/code&gt; 会根据分配对象的大小来选择合适的策略进行内存分配，看到这里，其实我们剩下的任务就是对三种分配策略进行分析了。&lt;/p&gt;
&lt;h3&gt;1.2 微小内存分配&lt;/h3&gt;
&lt;p&gt;首先是微小对象的分配，这个为了节省空间做了很强的优化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func mallocgcTiny(size uintptr, typ *_type) (unsafe.Pointer, uintptr) {
    mp := acquirem()
	...

	// Tiny allocator（微小对象分配器）
	//
	// tiny 分配器会把多个“极小”的分配请求合并到一个内存块里。
	// 只有当这个块里的所有子对象都不可达时，这个大块才会被释放。
	// 子对象必须是 noscan（不含指针），这样可以保证潜在浪费的内存有上界。
	//
	// 用于合并的块大小（maxTinySize）是可调的。
	// 当前设置 16 字节：对应最坏情况下 2 倍浪费（只剩 1 个子对象可达）。
	// 8 字节：几乎不浪费，但可合并机会更少。
	// 32 字节：合并机会更多，但最坏可到 4 倍浪费。
	// 最佳情况下的收益与块大小无关，最高可减少到 1/8（8x winning）。
	//
	// 从 tiny 分配器拿到的对象不能被显式 free。
	// 因此，如果一个对象将来会被显式 free，会保证它 size &amp;gt;= maxTinySize。
	//
	// SetFinalizer 对可能来自 tiny 分配器的对象有特殊处理：
	// 它允许给“一个大块内部的某个字节”设置 finalizer。
	//
	// tiny 分配器的主要目标：小字符串、以及单独逃逸到堆上的小变量。
	// 在某个 json benchmark 上，它把分配次数减少约 12%，堆大小减少约 20%。
	
	// 这里是获取了当前 m 对应的 p 所拥有的 mcache。
	c := getMCache(mp)
	off := c.tinyoffset

	...
	// 省略一些字节对齐操作

	// 这里算是一个比较核心的操作，这个 c.tiny 其实就是当前指向的
	// 最后一个 tiny 合并块，如果当前合并块能够放下这块需要分配的小内存，那么就
	// 会直接将内存分配到这块地方，如果不够，那么只能向后移动 tiny 合并块，来找到新的足够的待分配内存。
	// 值得注意的是，虽然 mcache 在多次分配之后肯定会已经分配了多个 tiny 块，但是此时只会这个 mcache
	// 只会指向最后一个块，即便之前的空间够分配，也不会将内存分配到之前的 tiny 合并块上。
	if off+size &amp;lt;= maxTinySize &amp;amp;&amp;amp; c.tiny != 0 {
		// 这个对象能塞进现有的 tiny 块里，直接从现有块切一段出来。
		x := unsafe.Pointer(c.tiny + off)
		c.tinyoffset = off + size
		c.tinyAllocs++
		mp.mallocing = 0
		releasem(mp)
		return x, 0
	}

	// 这里就不能从当前的块找了，得从 span 里面再找一块空闲内存。
	checkGCTrigger := false
	span := c.alloc[tinySpanClass]
	v := nextFreeFast(span)
	if v == 0 {
		v, span, checkGCTrigger = c.nextFree(tinySpanClass)
	}
	x := unsafe.Pointer(v)

	// tiny 块总是按 16 字节（或 maxTinySize）大小分配，这里把它清零（总是零）。
	(*[2]uint64)(x)[0] = 0
	(*[2]uint64)(x)[1] = 0

	// 用新分配的 tiny 块替换旧的合并块
	if !raceenabled &amp;amp;&amp;amp; (size &amp;lt; c.tinyoffset || c.tiny == 0) {
		c.tiny = uintptr(x)
		c.tinyoffset = size
	}

	...

	// 返回：对象指针 x，以及这个分配槽位的大小 span.elemsize
	return x, span.elemsize
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里涉及到了一些 sizeclass，其实就是将内存按照不同内存大小分块，然后把相同 size 的内存块放在一起的 class，针对于每个 class 的每个区块对应多大的内存，可以在 &lt;a href=&quot;https://github.com/golang/go/blob/master/src/internal/runtime/gc/sizeclasses.go&quot;&gt;src/internal/runtime/gc/sizeclasses.go&lt;/a&gt; 中找到答案，而这里的 tinyClass 为 2，tinySpanClass 为 5，所以对应的每个块是 16 个字节，在代码中除了翻译了一些注释，还添加了很多其他的说明，比如合并块逻辑的解释，这算是微内存分配的核心了。&lt;/p&gt;
&lt;p&gt;其实在微内存分配的逻辑里面，去掉合并块的逻辑，其他逻辑和小内存分配大差不差，重点就是 &lt;code&gt;nextFreeFast&lt;/code&gt; 和 &lt;code&gt;nextFree&lt;/code&gt; 这两个函数，他们的实现还是很有意思的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// nextFreeFast 快速查找并返回下一个可用的空闲对象。
// 如果能快速找到，则返回该对象的指针；否则，返回 0。
func nextFreeFast(s *mspan) gclinkptr {
	// theBit 表示在 allocCache 里面最后一个为 0 的位置，即未分配的区块
	// 这里用了一个很高级的算法
	theBit := sys.TrailingZeros64(s.allocCache)

	// 如果 theBit 小于 64，表示在 allocCache 中找到了一个 1，即存在一个快速可用的空闲对象。
	if theBit &amp;lt; 64 {
		// result 计算出该空闲对象在整个 mspan 中的绝对索引。
		// s.freeindex 是当前 allocCache 位图所映射的 64 个对象的起始索引。
		// theBit 是该空闲对象在 allocCache 内部的偏移量。
		result := s.freeindex + uint16(theBit)

		// 检查计算出的对象索引是否在 mspan 的有效范围内 (小于总对象数 s.nelems)。
		if result &amp;lt; s.nelems {
			// freeidx 是分配该对象后，下一个对象的索引。
			freeidx := result + 1

			// 这是一个优化和边界条件处理：
			// 如果 freeidx 刚好是 64 的倍数 (即当前 allocCache 对应的 64 个槽位已用完)
			// 并且 freeidx 还没有达到 mspan 的末尾 (s.nelems)，
			// 那么当前 allocCache 无法再快速提供空闲对象，需要更复杂的分配逻辑（走慢路径）。
			// 此时返回 0，表示无法快速分配。
			if freeidx%64 == 0 &amp;amp;&amp;amp; freeidx != s.nelems {
				return 0
			}

			// 将 s.allocCache 右移 (theBit + 1) 位。
			// 这会将刚刚分配的对象的位标记为已使用 (从位图中移除)，
			// 并将 allocCache 的“窗口”向前推进，以便下次从新的起始位开始查找。
			s.allocCache &amp;gt;&amp;gt;= uint(theBit + 1)

			// 更新 s.freeindex 为下一个可用的起始索引。
			s.freeindex = freeidx

			// 增加 mspan 中已分配对象的计数。
			s.allocCount++

			// 返回新分配对象的内存地址。
			// uintptr(result)*s.elemsize 计算对象相对于 mspan 基地址的偏移量。
			// s.base() 是 mspan 的起始内存地址。
			// gclinkptr 是 Go 运行时中用于表示指向 GC 对象的指针的类型。
			return gclinkptr(uintptr(result)*s.elemsize + s.base())
		}
	}
	return 0
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;代码很少，但是并不代表他好理解，&lt;code&gt;freeidx&lt;/code&gt; 是当前 span 的分配位图中的绝对索引，表示当前 &lt;code&gt;s.allocCache&lt;/code&gt; 的最后一个 bit 的绝对索引位，每次从 &lt;code&gt;allocCache&lt;/code&gt; 中找到新的可分配的块时，就会将 &lt;code&gt;allocCache&lt;/code&gt; 右移，意思是将右侧已经分配的块全部移除，同时将 &lt;code&gt;s.freeindex&lt;/code&gt; 赋值为当前 &lt;code&gt;allocCache&lt;/code&gt; 的最后一位 bit 的索引，我们的 &lt;code&gt;result&lt;/code&gt; 变量其实就是 &lt;code&gt;freeidx&lt;/code&gt;（绝对索引） + &lt;code&gt;theBit&lt;/code&gt;（相对索引），就算之后已经移除的位已经回收空闲了，我们也不会去管他，而是直接从当前新的 bitmap 里面分配。&lt;/p&gt;
&lt;p&gt;最终，我们当前的 bitmap 块已经用完的时候，会返回 0，此时会先扫描一下，是不是真的没有空闲的块了，如果没有，那么就会寻求分配一个新的 bitmap 块，这些逻辑都是在 &lt;code&gt;nextFree&lt;/code&gt; 中实现的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// nextFree 函数从当前 mcache 中缓存的 span 中返回下一个空闲对象（如果可用）。
// 如果当前 span 已满，它会用一个含有可用对象的新 span 重新填充 mcache 的缓存，
// 并返回该对象，同时附带一个标志，表明这是一次“重量级”的内存分配。
// 如果是重量级分配，调用者必须判断是否需要启动一个新的 GC 周期，
// 或者如果 GC 正在活跃，该 goroutine 是否需要协助 GC。
//
// 此函数必须在不可抢占的上下文中运行，否则 mcache &apos;c&apos; 的所有者可能会发生变化（导致并发问题）。
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, checkGCTrigger *bool) {
	// 获取 mcache 为指定 spanClass (spc) 缓存的当前 mspan。
	s = c.alloc[spc]
	// 默认情况下，不需要检查 GC 触发器，即不是“重量级”分配。
	checkGCTrigger = false

	// 调用 mspan 的 nextFreeIndex 方法，从 span 里面获取下一个空闲对象的索引。
	freeIndex := s.nextFreeIndex()

	// 如果 freeIndex 等于 s.nelems，表示当前 mspan 已经完全满了，没有空闲对象。
	if freeIndex == s.nelems {
		// 检查 s.allocCount 是否确实等于 s.nelems。
		// 如果不相等，说明计数有问题，抛出运行时错误。
		if s.allocCount != s.nelems {
			println(&quot;runtime: s.allocCount=&quot;, s.allocCount, &quot;s.nelems=&quot;, s.nelems)
			throw(&quot;s.allocCount != s.nelems &amp;amp;&amp;amp; freeIndex == s.nelems&quot;)
		}

		// 当前 span 已满，需要重新填充 mcache。
		// c.refill(spc) 会从 mcentral 获取一个新的、有空闲对象的 mspan 来替换当前的。
		c.refill(spc)

		checkGCTrigger = true
		
		s = c.alloc[spc]
		freeIndex = s.nextFreeIndex()
	}

	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的逻辑并不算复杂，重点在 &lt;code&gt;nextFreeIndex&lt;/code&gt; 需要遍历当前 &lt;code&gt;span&lt;/code&gt; 的所有区块来查找是否存在空闲块以及 &lt;code&gt;refill&lt;/code&gt; 方法，它会从 &lt;code&gt;mcentral&lt;/code&gt; 里面重新填充一份 &lt;code&gt;mspan&lt;/code&gt; 到本地的 &lt;code&gt;mcache&lt;/code&gt;，并且当前本地的 &lt;code&gt;mcache&lt;/code&gt; 会归还给 &lt;code&gt;mcentral&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;首先我们可以先看看遍历当前 span 的 &lt;code&gt;nextFreeIndex&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// nextFreeIndex returns the index of the next free object in s at
// or after s.freeindex.
// There are hardware instructions that can be used to make this
// faster if profiling warrants it.

// nextFreeIndex 返回 mspan &apos;s&apos; 中，位于或开始于 s.freeindex 的下一个空闲对象的索引。
// 如果性能分析表明有必要，可以使用硬件指令来加速此过程。
func (s *mspan) nextFreeIndex() uint16 {
	...

	// 这里相当于重新走一遍 nextFreeFast 的逻辑
	aCache := s.allocCache
	bitIndex := sys.TrailingZeros64(aCache)

	// 如果 bitIndex 等于 64，表示当前的 aCache (64位) 已经全部是 0，没有空闲位了。
	// 加载 s.allocBits 中的下一段 64 位位图到 aCache。
	for bitIndex == 64 {
		// 将 freeindex 移动到下一个 64 位的起始位置。
		// (sfreeindex + 64) 向上取整到 64 的倍数。
		sfreeindex = (sfreeindex + 64) &amp;amp;^ (64 - 1) // 等同于 (sfreeindex + 63) / 64 * 64

		// 如果移动后的 sfreeindex 已经超出了 mspan 的总对象数，
		// 则说明整个 mspan 已经没有空闲对象了。
		if sfreeindex &amp;gt;= snelems {
			s.freeindex = snelems // 更新 mspan 的 freeindex 为末尾
			return snelems
		}

		// 计算需要从 s.allocBits 数组中加载的字节索引。
		// s.allocBits 是一个字节数组，每 8 个字节（64位）存储一个 allocCache。
		whichByte := sfreeindex / 8

		// 从 s.allocBits 中重新填充 s.allocCache。
		// 这会加载与 sfreeindex 对应的下一段 64 位空闲位图。
		s.refillAllocCache(whichByte)
		aCache = s.allocCache
		// 再次查找新加载的 aCache 中是否有空闲位。
		bitIndex = sys.TrailingZeros64(aCache)
		// 如果新的 aCache 仍然没有空闲位，继续循环，尝试加载再下一段。
	}

	// 找到了空闲位。计算该空闲对象在整个 mspan 中的绝对索引。
	result := sfreeindex + uint16(bitIndex)

	// 最终检查 result 是否超出了 mspan 的总对象数。
	if result &amp;gt;= snelems {
		s.freeindex = snelems
		return snelems
	}

	// 将 s.allocCache 右移 (bitIndex + 1) 位。
	// 这会“消耗”掉刚刚找到并标记为已使用的空闲位，同时将位图向前推进。
	s.allocCache &amp;gt;&amp;gt;= uint(bitIndex + 1)

	// 更新 sfreeindex 为下一个可能的空闲对象的起始索引。
	sfreeindex = result + 1

	// 如果 sfreeindex 达到了 64 的倍数，并且没有超出 mspan 总对象数，
	// 说明当前的 64 位 allocCache 已经用尽。
	if sfreeindex%64 == 0 &amp;amp;&amp;amp; sfreeindex != snelems {
		// 计算需要从 s.allocBits 数组中加载的字节索引，以填充下一个 allocCache。
		whichByte := sfreeindex / 8
		// 重新填充 s.allocCache，准备处理下一个 64 位的对象块。
		s.refillAllocCache(whichByte)
	}

	// 更新 mspan 的 freeindex。
	s.freeindex = sfreeindex
	// 返回找到的空闲对象的绝对索引。
	return result
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的逻辑是，将 freeidx 增加 64，也就是 64 bit，一个区块的长度，为什么要移动这里的 &lt;code&gt;freeidx&lt;/code&gt;？其实很多人在这里有个误解，对于整个 mspan，并不是只有一个 64 位的 bitmap 块，而是多个 bitmap 块，形成了一个数组，而 &lt;code&gt;allocCache&lt;/code&gt; 仅仅是当前正在使用 bitmap 块的视图，而不是仅有的一个，所以这里会后移 freeIdx，然后通过 &lt;code&gt;refillAllocCache&lt;/code&gt; 将新的区块填充进去，如果实在没有新的区块了，那么就会通过之前提到的 &lt;code&gt;refill&lt;/code&gt; 从 &lt;code&gt;mcentral&lt;/code&gt; 里面找一块空闲的 &lt;code&gt;mspan&lt;/code&gt; 来填充到本地，这样就能继续分配了。&lt;/p&gt;
&lt;p&gt;下面来看看 &lt;code&gt;refill&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// refill 为 mcache &apos;c&apos; 获取一个指定 spanClass (spc) 的新 mspan。
// 这个新的 mspan 至少会包含一个空闲对象。
// 调用此函数时，mcache 中当前对应 spc 的 span 必须是满的。
//
// 此函数必须在不可抢占的上下文中运行，否则 mcache &apos;c&apos; 的所有者可能会发生变化（导致并发问题）。
func (c *mcache) refill(spc spanClass) {
	s := c.alloc[spc] // 获取 mcache 中当前为该 spanClass 缓存的 mspan。

	...
	// 这里省略一些关于 s 的校验

	// 如果当前 span 不是一个空的 mspan (emptymspan)，则进行处理。
	if s != &amp;amp;emptymspan {
		...
		// uncacheSpan 会将其从 mcache 中移除，并放入 mcentral 的相应列表中。
		mheap_.central[spc].mcentral.uncacheSpan(s)
		...
	}

	// cacheSpan 会从 mcentral 获取一个新的、有空闲对象的 mspan 来缓存到本地。
	s = mheap_.central[spc].mcentral.cacheSpan()

	...

	c.alloc[spc] = s
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里将 mspan 归还到 mcentral，然后还要再拿一块的代码太多了，这里直接贴出 &lt;code&gt;cacheSpan&lt;/code&gt; 的代码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 为 mcache 分配一个 span。
func (c *mcentral) cacheSpan() *mspan {
	// 扣除清扫信用（sweep credit），待会会详细讲。
    spanBytes := uintptr(gc.SizeClassToNPages[c.spanclass.sizeclass()]) * pageSize
    deductSweepCredit(spanBytes, 0)

    traceDone := false
    trace := traceAcquire()
    if trace.ok() {
        trace.GCSweepStart()
        traceRelease(trace)
    }

    // 如果在尝试了 spanBudget 个 span 后仍然没有找到 free object，
    // 就直接从 heap 分配一个新的 span。
    // 这样限制了查找时间，并使 sweeping 的成本被摊销。
    // 100 这个数字意味着只有 1% 的空间开销。
    spanBudget := 100

    var s *mspan
    var sl sweepLocker

    // 首先看看 “部分空闲”（部分清扫）的 mspan 集合里面有没有可用的 mspan
    // 它表示的是有空闲对象，且已经经过 gc 扫描回收过的 mspan
    sg := mheap_.sweepgen
    if s = c.partialSwept(sg).pop(); s != nil {
        goto havespan
    }

    // 如果上面的集合里面找不到，那么就会从部分未清扫的集合里面找，
    // 它表示的是还没有经过 gc 扫描回收，但是有一定的空闲空间的 mspan。
    sl = sweep.active.begin()
    if sl.valid {
        for ; spanBudget &amp;gt;= 0; spanBudget-- {
            s = c.partialUnswept(sg).pop()
            if s == nil {
                break
            }
            if s, ok := sl.tryAcquire(s); ok {
                // 成功获得这个 span 的所有权，执行 sweeping。
                s.sweep(true)
                sweep.active.end(sl)
                goto havespan
            }
        }

        // 实在找不到空闲的区域了，可以从全满但是还没有清扫的 mspan 集合中获取，
        // 因为经过清扫之后，可能会有空闲的空间。
        for ; spanBudget &amp;gt;= 0; spanBudget-- {
            s = c.fullUnswept(sg).pop()
            if s == nil {
                break
            }
            if s, ok := sl.tryAcquire(s); ok {
                // 成功获得该 span，进行 sweeping。
                s.sweep(true)
                // 检查 sweeping 后是否有 free slot。
                freeIndex := s.nextFreeIndex()
                if freeIndex != s.nelems {
                    s.freeindex = freeIndex
                    sweep.active.end(sl)
                    goto havespan
                }
                // sweeping 后仍无空位，将其放回 fullSwept 列表。
                c.fullSwept(sg).push(s.mspan)
            }
            // 同上，若无法获取 ownership，跳过。
        }
        sweep.active.end(sl)
    }

    // 真的一滴都不剩了，只能从堆里面分配了。
    trace = traceAcquire()
    if trace.ok() {
        trace.GCSweepDone()
        traceDone = true
        traceRelease(trace)
    }

    s = c.grow()
    if s == nil {
        return nil
    }

    // 至此一定有可用 span。
havespan:
	... 
	// 一些处理

    return s
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以从上面的代码窥见 go 的内存模型的多级缓存的框架，当然其中也蕴含了许多细节，包括，&lt;code&gt;mcentral&lt;/code&gt; 里面为 &lt;code&gt;mspan&lt;/code&gt; 列表做了分类处理，确保了 &lt;code&gt;mspan&lt;/code&gt; 的分配高效，先从已经扫描的 &lt;code&gt;mspan&lt;/code&gt; 集合里面找，然后从未扫描，但是有空闲空间的 &lt;code&gt;mspan&lt;/code&gt; 里面寻找，当然，这种未扫描的 &lt;code&gt;mspan&lt;/code&gt; 在获取的时候会进行清扫，如果还是没有，那么就会从未清扫并且 mspan 没有空闲空间的集合里面寻找，如果还是没有，那么就会通过 &lt;code&gt;grow&lt;/code&gt; 方法从堆中分配空间。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;grow&lt;/code&gt; 这个函数中，调用链路为 grow -&amp;gt; heap.alloc -&amp;gt; 系统栈调用 heap.allocSpan，系统栈调用是什么？其实就是通过 &lt;code&gt;systemstack&lt;/code&gt; 来调用一个函数，他会切换到 g0 来执行这个函数，总之，在系统栈上执行的函数并不会被抢占或是调度，在 runtime 的很多地方都会去调用系统栈去执行一些操作，也就是会用到 g0 的地方。总之，这里会从堆中去分配 &lt;code&gt;mspan&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;小结&lt;/h4&gt;
&lt;p&gt;当我们分配小内存的时候，会通过 &lt;code&gt;mallocgc&lt;/code&gt; 中的 &lt;code&gt;mallocgcTiny&lt;/code&gt; 尝试去分配一个微小内存，微小内存的分配做了一些优化，比如合并块，我们一个块的大小是 16 字节，因此在分配极小的对象时可能用不到一个块的大小的空闲，所以可以将多个极小的对象分配到一个块上。&lt;/p&gt;
&lt;p&gt;如果此时这个块已经满了，就会通过 &lt;code&gt;nextFreeFast&lt;/code&gt; 从 64 bit 的一个位图里面查找一个未分配的位，这个位通过计算可以对应到一个实际的块，如果当前位图已经没有可以分配的位，那么就会通过 &lt;code&gt;nextFree&lt;/code&gt; 来获取新的 &lt;code&gt;allocCache&lt;/code&gt; ，首先会通过 &lt;code&gt;nextFreeIndex&lt;/code&gt; 来向后遍历 64 bit 的位图，并缓存到 &lt;code&gt;allocCache&lt;/code&gt; 中，因为我们的 &lt;code&gt;mspan&lt;/code&gt; 并不是只有一个 64 bit 位图，总体是形成了一个数组，当所有的位图都用完了，没办法，只能调用 &lt;code&gt;refill&lt;/code&gt; 到 &lt;code&gt;mcentral&lt;/code&gt; 上重新分配一个 &lt;code&gt;mspan&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;而我们的 &lt;code&gt;mcentral&lt;/code&gt; 维护了不同类别的 &lt;code&gt;span&lt;/code&gt; 集合，根据优先级依次从空闲已清理、空闲未清理、无空闲未清理集合里面找，如果没有任何可用的 &lt;code&gt;mspan&lt;/code&gt;，就直接通过 &lt;code&gt;grow&lt;/code&gt; 方法从堆中分配新的可用的 &lt;code&gt;mspan&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;其实我们微小内存的分配和小内存分配的链路大差不差，微小内存多了一个合并块逻辑，而小内存分配多了一个 spanClass 的计算，本质就是通过需要分配的内存大小计算一个 spanClass，确保分配的内存大小合适，这里就是一个块一个对象了，后续值得一提的就是大内存分配了，他会直接从堆内存中分配内存。&lt;/p&gt;
&lt;h3&gt;1.3 大内存分配&lt;/h3&gt;
&lt;p&gt;大内存分配 &lt;code&gt;mallocgcLarge&lt;/code&gt; 会直接通过 &lt;code&gt;allocLarge&lt;/code&gt; 从堆里面分配 npage 个大小的页。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// allocLarge 为一个大对象分配一个内存块（span）。
// 这是 mcache 的一个方法，mcache 是每个处理器（P）本地的缓存，但大对象分配不经过 mcache，而是直接走向主堆 mheap。
func (c *mcache) allocLarge(size uintptr, noscan bool) *mspan {

	...
	// 计算 npages，就是 size 所需的页数量。

	// 扣除清扫信用（sweep credit），待会会详细讲。
	deductSweepCredit(npages*pageSize, npages)

	spc := makeSpanClass(0, noscan)

	// 从主堆（mheap）分配 npages 数量的连续内存页。
	s := mheap_.alloc(npages, spc)
	if s == nil {
		// 如果 mheap 返回 nil，说明物理内存不足。
		throw(&quot;out of memory&quot;) // 抛出内存溢出异常
	}

	...
	// 省略一些对我们来说不太重要的东西

	// 将这个新分配的大对象 span 放入 mcentral 的 &quot;fullSwept&quot; 列表中。
	// mcentral 是集中管理某种规格 span 的地方。
	// 这样做是为了让后台清扫器（background sweeper）能够看到并管理这个 span。
	mheap_.central[spc].mcentral.fullSwept(mheap_.sweepgen).push(s)

	...
	
	return s
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们看到这里可以知道，不论是大内存申请，还是小内存申请最终都会回到 &lt;code&gt;_mheap.alloc&lt;/code&gt;，也就是从堆中申请内存，但是我们先不讲堆中的内存是如何申请的，想必在看代码的过程中，你已经注意到了有一个东西叫做清扫信用（sweep credit），这个东西你可能感觉有点熟悉，懂 GC 的朋友可能觉得这玩意不就是之前说过的 &lt;code&gt;assist credit&lt;/code&gt; 吗？但是其实并不是，这个 &lt;code&gt;sweep credit&lt;/code&gt; 和 &lt;code&gt;assist credit&lt;/code&gt; 是两个不一样的东西，但都在垃圾回收里面起着一定的作用，下面我们可以看这个 &lt;code&gt;sweep credit&lt;/code&gt; 有着什么样的作用。&lt;/p&gt;
&lt;h3&gt;1.4 SweepCredit&lt;/h3&gt;
&lt;p&gt;几乎所有的内存分配到最后都会调用 &lt;code&gt;deductSweepCredit&lt;/code&gt;，他主要是在我们需要申请一个新的 &lt;code&gt;mspan&lt;/code&gt; 时触发（包括向 &lt;code&gt;mcentral&lt;/code&gt; 和堆申请 &lt;code&gt;mspan&lt;/code&gt; 都会触发）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// deductSweepCredit 函数为分配一个大小为 spanBytes 的 span（内存块）扣除清扫信用。
// 这个操作必须在 span 被真正分配 *之前* 执行，以确保系统有足够的信用。
// 如果信用不足，它会强制执行清扫操作以避免进入“债务”状态。如果调用者自己也会
// 清扫一些页面（例如，在进行一次大内存分配时），它可以传递一个非零的 callerSweepPages
// 参数，这样函数会少计算这部分页面的清扫任务。
//
// deductSweepCredit 做了一个最坏情况的假设：即最终分配的 spanBytes 字节
// 将全部用于对象分配。
//
// deductSweepCredit 是“按比例清扫”（proportional sweep）系统的核心。
// 它利用垃圾收集器收集的统计数据来执行足够的清扫工作，以确保在两次 GC 周期
// 之间的并发清扫阶段，所有的内存页都能被清扫完毕。
//
// 调用此函数时，mheap_（内存堆）不能被锁定。
func deductSweepCredit(spanBytes uintptr, callerSweepPages uintptr) {
	...
retry:
	// 获取当前的清扫基准。这个基准是在上一次调整清扫步调时记录的已清扫页面数。
	sweptBasis := mheap_.pagesSweptBasis.Load()
	// 获取当前的堆上存活对象大小。
	live := gcController.heapLive.Load()
	// 获取计算清扫步调时使用的堆上存活对象大小的基准值。
	liveBasis := mheap_.sweepHeapLiveBasis

	// 估算新的堆大小。初始值是即将分配的 spanBytes。
	newHeapLive := spanBytes
	// 如果当前的存活对象大小 `live` 大于设置步调时的基准 `liveBasis`，
	// 说明在步调设置之后，又有新的对象被分配了。
	// 这部分增量也需要计入清扫任务。
	if liveBasis &amp;lt; live {
		// 加上这个增量 `live - liveBasis`。
		// 这里的代码是有意设计成有竞争条件的，在极少数情况下（例如并发调整GC参数），
		// `live` 可能小于 `liveBasis` 导致溢出。注释中提到，这是为了防止计算出一个
		// 巨大的 pagesTarget，从而卡在清扫循环里。如果发生这种情况，newHeapLive
		// 会是一个较小的值，本次清扫可能会被跳过，等待状态恢复正常。
		newHeapLive += uintptr(live - liveBasis)
	}

	// 计算目标需要清扫的页面数。
	// `mheap_.sweepPagesPerByte` 是一个比率，代表“每分配一字节内存，需要清扫多少页”。
	// `newHeapLive` 是估算的新增内存，乘以这个比率，就得到了需要完成的清扫页数。
	// `callerSweepPages` 是调用者承诺自己会清扫的页数，所以可以从目标中减去。
	pagesTarget := int64(mheap_.sweepPagesPerByte*float64(newHeapLive)) - int64(callerSweepPages)

	// 循环清扫，直到达到要求
	for pagesTarget &amp;gt; int64(mheap_.pagesSweEpt.Load()-sweptBasis) {
		// 调用 sweepone() 来清扫一个 span（通常包含多个页）。
		// 如果 sweepone() 返回 ^uintptr(0)，表示所有可清扫的 span 都已清扫完毕。
		if sweepone() == ^uintptr(0) {
			// 既然没东西可扫了，就将 sweepPagesPerByte 设为 0，禁用按比例清扫。
			mheap_.sweepPagesPerByte = 0
			// 跳出循环。
			break
		}

		// 在清扫过程中，GC 的步调可能会被其他 goroutine 改变（例如，通过 `runtime.GC()`）。
		// 如果 `pagesSweptBasis` 发生了变化，说明清扫的基准和目标已经过时。
		if mheap_.pagesSweptBasis.Load() != sweptBasis {
			// 必须重新计算债务。通过 goto retry 跳转回循环的开始。
			goto retry
		}
	}

	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;只有 AI 给的注释还是很难读，因为这里涉及到各种参数有点复杂，我总结了一下，大体逻辑其实就是这个函数的调用者会传入一个当前需要分配的字节数 &lt;code&gt;spanBytes&lt;/code&gt;，和一个自己承诺会执行清理的页数 &lt;code&gt;callerSweepPages&lt;/code&gt;（表示他自己会执行清理，这部分承诺的债务不会在当前函数中执行），然后我们会按照&lt;strong&gt;每分配一个 byte 需要自己清理 &lt;code&gt;sweepPagesPerByte&lt;/code&gt; 个页&lt;/strong&gt;的比例来偿还债务，然后这个函数会不断执行 &lt;code&gt;sweepone&lt;/code&gt; 一直到债务还清或者没有任何页可以清理的时候，就会停止清理并跳出循环，执行下一步操作。&lt;/p&gt;
&lt;p&gt;很显然我们可以知道，他主要是针对于 GC 的清扫阶段的债务，如果欠债，那么就要主动的去执行 &lt;code&gt;sweepone&lt;/code&gt;，而之前所提到的 &lt;code&gt;assist credit&lt;/code&gt; 主要是针对于 GC 的标记阶段，如果欠债了，那么就会主动挂起这个请求分配内存的 goroutine，等待其他生产者 goroutine 通过标记内存来生产债务，然后这些挂起的协程才可以被继续运行，他们的欠债机制和触发时机都是不一样的。&lt;/p&gt;
&lt;p&gt;到目前为止，我们梳理完了 &lt;code&gt;mallocgc&lt;/code&gt; 链路的比较核心的部分，其实我们目前可以大致看见堆内存的多级分配的大致框架，其实现在我们直接去看栈内存分配的代码会发现有很多重复的函数方法，这当然是因为我们的 goroutine 栈都是在操作系统的堆上分配的，对于栈内存管理，我们可以从 &lt;code&gt;stackalloc&lt;/code&gt; 入手。&lt;/p&gt;
&lt;h2&gt;2. 栈内存管理&lt;/h2&gt;
&lt;p&gt;首先我们可以在 &lt;a href=&quot;https://github.com/golang/go/blob/master/src/runtime/stack.go#L344&quot;&gt;/src/runtime/stack.go&lt;/a&gt; 找到 &lt;code&gt;stackalloc&lt;/code&gt; 的实现，我们在创建一个 goroutine 的时候，都会去使用 &lt;code&gt;stackalloc&lt;/code&gt; 来为新的 goroutine 分配一个栈空间，参数则是固定的 2048 字节，也就是经典的 2kb。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// stackalloc 分配一个 n 字节的栈。
//
// stackalloc 必须在系统栈上运行，因为它使用每个 P（处理器）
// 的资源，并且不能分割栈。
//
//go:systemstack
func stackalloc(n uint32) stack {
	// Stackalloc 必须在调度器栈上调用，这样我们
	// 在 stackalloc 运行的代码期间就永远不会尝试增长栈。
	// 这样做会导致死锁 (issue 1547)。
	
	...
	// 省略一些校验和 debug 代码。
	

	var v unsafe.Pointer
	if n &amp;lt; fixedStack&amp;lt;&amp;lt;_NumStackOrders &amp;amp;&amp;amp; n &amp;lt; _StackCacheSize {
		// 如果需要的栈很小，会通过空闲列表分配器进行分配。
		order := uint8(0)
		n2 := n
		// 先根据尺寸定类别，到指定的列表分配。
		for n2 &amp;gt; fixedStack {
			order++
			n2 &amp;gt;&amp;gt;= 1
		}
		var x *gclinkptr
		if stackNoCache != 0 || thisg.m.p == 0 || thisg.m.preemptoff != &quot;&quot; {
			// thisg.m.p == 0 可能会在 exitsyscall 或 procresize 的内部发生。
			// 只需从全局池中获取一个栈。
			// 另外，在 gc 期间不要触碰 stackcache，
			// 因为它是并发刷新的。
			lock(&amp;amp;stackpool[order].item.mu)
			x = stackpoolalloc(order)
			unlock(&amp;amp;stackpool[order].item.mu)
		} else {
			c := thisg.m.p.ptr().mcache
			x = c.stackcache[order].list
			if x.ptr() == nil {
				// 如果没了，那就从上头分配一些内存到本地，和堆内存
				// 管理的 refill 逻辑差不多。
				stackcacherefill(c, order)
				x = c.stackcache[order].list
			}
			c.stackcache[order].list = x.ptr().next
			c.stackcache[order].size -= uintptr(n)
		}
		...
		v = unsafe.Pointer(x)
	} else {
		// 如果我们需要一个更大尺寸的栈，我们会去堆中到分配
		// 一个专用的 span，当然，肯定会有个缓存。
		var s *mspan
		// 计算需要多少页并对应到缓存的列表索引，作用类似 spanClass
		npage := uintptr(n) &amp;gt;&amp;gt; gc.PageShift
		log2npage := stacklog2(npage)

		// 尝试从大栈缓存中获取一个栈。
		lock(&amp;amp;stackLarge.lock)
		if !stackLarge.free[log2npage].isEmpty() {
			s = stackLarge.free[log2npage].first
			stackLarge.free[log2npage].remove(s)
		}
		unlock(&amp;amp;stackLarge.lock)

		lockWithRankMayAcquire(&amp;amp;mheap_.lock, lockRankMheap)
		if s == nil {
			// 从堆上分配一个新栈。
			s = mheap_.allocManual(npage, spanAllocStack)
			if s == nil {
				throw(&quot;out of memory&quot;)
			}
			osStackAlloc(s)
			s.elemsize = uintptr(n)
		}
		v = unsafe.Pointer(s.base())
	}

	...
	
	return stack{uintptr(v), uintptr(v) + uintptr(n)}
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我们可以很清楚地看见，在进行栈分配的时候，会根据大小从不同的地方分配内存，需要的栈比较小的时候，会直接从 p 的本地 &lt;code&gt;mcache&lt;/code&gt; 中的栈缓存获取，如果本地没有可以供分配的内存了，那么就会通过 &lt;code&gt;stackcacherefill&lt;/code&gt; 从 &lt;code&gt;stackpool&lt;/code&gt; 中获取栈内存来填充到本地，如果还是没有，那么会直接从堆内存中获取，也就是所谓的 &lt;code&gt;mheap.allocManual&lt;/code&gt;；如果需要的栈很大，就会从大栈缓存中获取，如果没有，就会直接从堆中获取，也就是 &lt;code&gt;_mheap.allocManual&lt;/code&gt; 他其实就是直接调用的 &lt;code&gt;allocSpan&lt;/code&gt;，走的和我们堆内存管理都是一条路。&lt;/p&gt;
&lt;p&gt;然后我们可以再去看看我们的 &lt;code&gt;newstack&lt;/code&gt;，他主要是在函数调用的时候，&lt;code&gt;morestack&lt;/code&gt; 判断需要进行栈扩容的时候就会调用 &lt;code&gt;newstack&lt;/code&gt; 这个函数。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 当需要更多栈时，由 runtime·morestack 调用。
// 分配一个更大的栈并将内容迁移到新栈。
// 栈的增长是乘法式的，以获得均摊的常数成本。
//
// 进入时，g-&amp;gt;atomicstatus 将为 Grunning 或 Gscanrunning。
// 如果调度器试图停止这个 g，它会设置 preemptStop。
//
// 这必须是 nowritebarrierrec，因为它可以作为
// 其他 nowritebarrierrec 函数栈增长的一部分被调用，但是
// 编译器不会检查这一点。
//
//go:nowritebarrierrec
func newstack() {
	...

	// 这里是一个 goroutine 抢占点
	preempt := stackguard0 == stackPreempt
	if preempt {
		if !canPreemptM(thisg.m) {
			// 暂时让 goroutine 继续运行。
			// gp-&amp;gt;preempt 已被设置，所以它会在下一次被抢占。
			gp.stackguard0 = gp.stack.lo + stackGuard
			gogo(&amp;amp;gp.sched)
		}
	}

	...
	
	if preempt {
		if gp == thisg.m.g0 {
			throw(&quot;runtime: preempt g0&quot;)
		}
		if thisg.m.p == 0 &amp;amp;&amp;amp; thisg.m.locks == 0 {
			throw(&quot;runtime: g is running but p is not&quot;)
		}

		if gp.preemptShrink {
			// 我们现在处于一个同步安全点，所以
			// 执行待处理的栈收缩操作。
			gp.preemptShrink = false
			shrinkstack(gp)
		}

		gp.syncSafePoint = true

		if gp.preemptStop {
			preemptPark(gp)
		}
		gopreempt_m(gp)
	}

	// 分配一个更大的段并移动栈。
	oldsize := gp.stack.hi - gp.stack.lo
	newsize := oldsize * 2

	...

	// 当我们进行复制时，并发 GC 不会扫描栈，因为
	// gp 处于 Gcopystack 状态。
	copystack(gp, newsize)

	if stackDebug &amp;gt;= 1 {
		print(&quot;stack grow done\n&quot;)
	}
	casgstatus(gp, _Gcopystack, _Grunning)
	gogo(&amp;amp;gp.sched)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;newstack&lt;/code&gt; 最终会将通过 &lt;code&gt;copystack&lt;/code&gt; 将旧的栈复制到新开辟的栈空间，当然在此之前也有一个协程的抢占点，这也是实现抢占式调度的其中一个关键点，如果没有被抢占，那么就可以直接执行栈增长的逻辑，我们的 &lt;code&gt;copystack&lt;/code&gt; 会直接为新的尺寸通过 &lt;code&gt;stackalloc&lt;/code&gt; 去分配栈内存，然后将旧的栈空间的数据复制到新的栈空间，这样就实现了栈的增长，栈缩小也是一样的逻辑，栈缩小最后调用的也是 &lt;code&gt;copystack&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;3. 结语&lt;/h2&gt;
&lt;p&gt;在阅读这部分源码的过程中，我们可以看见许多其他的运行时比如 GC、GMP 模型的身影，虽然没有事无巨细的去看每部分细节，但是这样我们也能很好地理解 runtime 中 go 的内存管理，还有经典的八股文 goroutine 的栈是在操作系统的堆上分配的这个逻辑。&lt;/p&gt;
</content:encoded></item><item><title>Go 的 GC 链路梳理</title><link>https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/go-%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/go-%E7%9B%B8%E5%85%B3/go-%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6/</guid><description>众所周知，我们现版本的 Go 默认是使用的三色标记法，八股文已经听腻了，来看点源码理解一下 GC 流程。</description><pubDate>Thu, 04 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;众所周知，我们现版本的 Go 默认是使用的三色标记法，八股文已经听腻了，来看点源码理解一下 GC 流程。&lt;/p&gt;
&lt;h2&gt;何时会触发垃圾回收？&lt;/h2&gt;
&lt;h3&gt;系统监控&lt;/h3&gt;
&lt;p&gt;懂行的都知道，gc 的入口是 &lt;a href=&quot;https://github.com/golang/go/blob/2b62144069a130cc469f33009c0c392cc6de8810/src/runtime/mgc.go#L733&quot;&gt;gcStart&lt;/a&gt;，所以我们只需要顺着他的调用链路向上找，可以知道会有一个后台协程 &lt;a href=&quot;https://github.com/golang/go/blob/2b62144069a130cc469f33009c0c392cc6de8810/src/runtime/proc.go#L365&quot;&gt;forcegchelper&lt;/a&gt; 会重复检测是否满足 GC 的状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// init 函数在包初始化时运行，启动一个强制 GC 的辅助 goroutine
func init() {
	go forcegchelper()  // 启动一个独立 goroutine，专门负责触发强制 GC
}

// forcegchelper 是强制 GC 的后台辅助 goroutine
func forcegchelper() {
	forcegc.g = getg()

	lockInit(&amp;amp;forcegc.lock, lockRankForcegc)

	for {
		lock(&amp;amp;forcegc.lock)

		if forcegc.idle.Load() {
			throw(&quot;forcegc: phase error&quot;)
		}

		forcegc.idle.Store(true)

		// 将当前 goroutine 挂起，释放锁，等待 sysmon（系统监控 goroutine）唤醒
		goparkunlock(&amp;amp;forcegc.lock, waitReasonForceGCIdle, traceBlockSystemGoroutine, 1)

		if debug.gctrace &amp;gt; 0 {
			println(&quot;GC forced&quot;)
		}

		gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然是使用的 for 循环不断检测是否满足 gc 条件，但是这里有一个 gopark，稍微有了解 go 源码的都知道， gopark 意味着将这个协程挂起，也就是将 M 线程资源让出来，从而避免长时间阻塞在这里等待满足 gc 条件。那么什么时候会唤醒这个 goroutine 呢？答案写在注释里面，当系统监控觉得确实应该触发 GC 了，就会唤醒这个后台强制 GC 的 goroutine。&lt;/p&gt;
&lt;p&gt;他是如何被唤醒的？可以在 &lt;a href=&quot;https://github.com/golang/go/blob/41d8e61a6b9d8f9db912626eb2bbc535e929fefc/src/runtime/proc.go#L5026&quot;&gt;sysmon()&lt;/a&gt; 的末尾找到答案，通过 &lt;a href=&quot;https://github.com/golang/go/blob/41d8e61a6b9d8f9db912626eb2bbc535e929fefc/src/runtime/mgc.go#L1234&quot;&gt;gcTrigger&lt;/a&gt; 去判断是否应该触发强制 GC，如果应该 gc 了，那么就会将这个 goroutine 唤醒，其实就是将他放回调度队列里面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() &amp;amp;&amp;amp; atomic.Load(&amp;amp;forcegc.idle) != 0 {
			lock(&amp;amp;forcegc.lock)
			forcegc.idle = 0
			var list gList
			// 放回队列
			list.push(forcegc.g)
			injectglist(&amp;amp;list)
			unlock(&amp;amp;forcegc.lock)
		}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;申请内存&lt;/h3&gt;
&lt;p&gt;还有 &lt;a href=&quot;https://github.com/golang/go/blob/2b62144069a130cc469f33009c0c392cc6de8810/src/runtime/arena.go#L739&quot;&gt;newUserArenaChunk&lt;/a&gt; ，什么时候会触发这个所谓的 newUserArenaChunk 呢？简单来说就是堆内存需要新申请的时候，此时就会去检测是否应该触发 GC，除此之外，检测是否应该触发 GC 的地方 &lt;a href=&quot;https://github.com/golang/go/blob/2b62144069a130cc469f33009c0c392cc6de8810/src/runtime/malloc.go#L1119&quot;&gt;mallocgc&lt;/a&gt; ，这是一个通用的分配内存的函数，总之，当我们申请内存的时候，我们都会去检查是否应该去触发 GC，就这么简单；除此之外说一句题外话，我们可以在这些 malloc 函数中看见读写屏障的具体逻辑，当开启了写屏障时，此时就会帮助直接标记为灰色。&lt;/p&gt;
&lt;h2&gt;gcStart 干了啥？&lt;/h2&gt;
&lt;h3&gt;大体流程&lt;/h3&gt;
&lt;p&gt;剔除一些无关紧要的代码，如下所示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// gcStart 启动 Go 垃圾回收（GC）。
//
// trigger: 指示 GC 启动条件的触发器，例如堆大小超过阈值或手动触发。
//
// 注意：
// - 如果当前在系统栈上或持有锁，不会启动 GC。
// - 根据 debug.gcstoptheworld 的设置，可能执行并发 GC 或 Stop-The-World GC。
func gcStart(trigger gcTrigger) {
	...

    // 启动后台 mark 工作 goroutine
    // 就是这里面做的标记工作
    gcBgMarkStartWorkers()

    // 初始化 STW（Stop-The-World）相关信息
    work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
    if work.stwprocs &amp;gt; numCPUStartup {
        work.stwprocs = numCPUStartup
    }
    work.heap0 = gcController.heapLive.Load()
    work.pauseNS = 0
    work.mode = mode

    now := nanotime()
    work.tSweepTerm = now

    // 系统栈执行 STW
    var stw worldStop
    systemstack(func() {
        stw = stopTheWorldWithSema(stwGCSweepTerm)
    })

    // 累计暂停时间
    work.cpuStats.accumulateGCPauseTime(stw.stoppingCPUTime, 1)

    // 在系统栈完成 sweep
    systemstack(func() {
        finishsweep_m()
    })

    // 清理对象池
    clearpools()

    // GC 周期计数加一
    work.cycles.Add(1)

    // 启用协助机制和工作线程
    gcController.startCycle(now, int(gomaxprocs), trigger)
    gcCPULimiter.startGCTransition(true, now)

    if mode != gcBackgroundMode {
        schedEnableUser(false) // STW 模式下禁止用户 goroutine 调度
    }

    // 进入并发 mark 阶段，并启用写屏障
    setGCPhase(_GCmark)
    gcBgMarkPrepare()
    // 这个函数挺重要的，会把所有的待扫描的对象空间分成多个 task。
    gcPrepareMarkRoots()
    gcMarkTinyAllocs()
    atomic.Store(&amp;amp;gcBlackenEnabled, 1)

    mp = acquirem()

    // 更新 CPU 统计信息
    work.cpuStats.accumulateGCPauseTime(nanotime()-stw.finishedStopping, work.maxprocs)

    // 并发 mark 开始，STW 停止
    systemstack(func() {
        now = startTheWorldWithSema(0, stw)
        work.pauseNS += now - stw.startedStopping
        work.tMark = now

        gcCPULimiter.finishGCTransition(now)
    })

	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中最值得注意的函数就是 &lt;code&gt;gcBgMarkStartWorkers&lt;/code&gt; 和 &lt;code&gt;gcPrepareMarkRoots&lt;/code&gt; 这两个函数在我们之后的分析里面算是最重要的。&lt;/p&gt;
&lt;p&gt;首先我们看看 &lt;code&gt;gcBgMarkStartWorkers&lt;/code&gt; 干了什么，一串下去的链路是&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;gcBgMarkStartWorkers -&amp;gt; gcBgMarkWorker -&amp;gt; gcDrainMarkWorkerIdle -&amp;gt; gcDrain&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;而这个 &lt;code&gt;gcDrain&lt;/code&gt; 函数就是最后我们需要分析的地方，这里很复杂。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gcDrain&lt;/code&gt; 会扫描 root 对象，不断将灰色对象标记为黑色，直到没有更多任务可以标记，&lt;code&gt;gcDrain&lt;/code&gt; 并不是在一个专门的 M 上执行，所以我们需要考虑到其他业务任务的执行，如果长期执行 &lt;code&gt;gcDrain&lt;/code&gt; 就会导致负责业务的 goroutine 饿死，所以 &lt;code&gt;gcDrain&lt;/code&gt; 也提供了一些抢占点检查是否应该让出 M。&lt;/p&gt;
&lt;p&gt;首先我们需要知道，这个抢占点的检查是什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;checkWork := int64(1&amp;lt;&amp;lt;63 - 1)
var check func() bool
if flags&amp;amp;(gcDrainIdle|gcDrainFractional) != 0 {
    checkWork = initScanWork + drainCheckThreshold
    if idle {
        check = pollWork
    } else if flags&amp;amp;gcDrainFractional != 0 {
        check = pollFractionalWorkerExit
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 pollWork 是什么？其实就是看当前程序中是否有网络 IO 就绪非阻塞调用一下 netpoll，查看是否有事件已经准备好了，防止 gc 阻塞了重要任务的执行。&lt;/p&gt;
&lt;p&gt;第二个 pollFractionalWorkerExit 则是一个检测自己有没有执行过长时间，如果执行时间太长了，那么就会让出当前的 M 线程，让其他 goroutine 执行；在后续，我们每次循环标记的过程中都会去调用这个 check() 来防止任务被 GC 任务阻塞了，因为相对来说，GC 后台标记这个任务优先级是比较低的。&lt;/p&gt;
&lt;p&gt;下面是第一个标记循环，目的很清晰，就是从之前提到的 &lt;code&gt;gcPrepareMarkRoots&lt;/code&gt; 中的 Tasks 中通过原子操作去获取一个 Task 来标记，同时，在每次任务标记完之后，就会检查一遍是否应该让出 M 线程。这里的原子操作保证了 go 中的 GC 可以并发安全的进行标记，而 markroot 就是对所有的 root 对象进行扫描标记，root 是可达活对象的起点，包括但不限于全局变量，栈。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for !(gp.preempt &amp;amp;&amp;amp; (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {
    job := atomic.Xadd(&amp;amp;work.markrootNext, +1) - 1
    if job &amp;gt;= work.markrootJobs {
        break
    }
    markroot(gcw, job, flushBgCredit)
	if check != nil &amp;amp;&amp;amp; check() {
		goto done
	}
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;想要知道 root 包含那些内存数据，我们可以在之前提过的 &lt;code&gt;gcPrepareMarkRoots&lt;/code&gt; 里面找到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// gcPrepareMarkRoots 准备 GC 根对象扫描任务
func gcPrepareMarkRoots() {
    // 确认此时世界已停止（STW），防止在扫描过程中有 goroutine 修改 root
    assertWorldStopped()

    // 用于计算需要多少个 root block（数据块）来存储给定字节数的 root
    nBlocks := func(bytes uintptr) int {
        return int(divRoundUp(bytes, rootBlockBytes))
    }

    // 初始化 data 和 BSS root 的数量
    work.nDataRoots = 0
    work.nBSSRoots = 0

    // 扫描全局变量段（data / BSS 段）
    for _, datap := range activeModules() {
        nDataRoots := nBlocks(datap.edata - datap.data) // data 段需要多少 root block
        if nDataRoots &amp;gt; work.nDataRoots {
            work.nDataRoots = nDataRoots
        }

        nBSSRoots := nBlocks(datap.ebss - datap.bss) // BSS 段需要多少 root block
        if nBSSRoots &amp;gt; work.nBSSRoots {
            work.nBSSRoots = nBSSRoots
        }
    }

    // 扫描 span roots（用于 finalizer 特殊对象）
    // GC 会扫描在 mark 阶段开始时可用的 heapArenas（即 markArenas）
    mheap_.markArenas = mheap_.heapArenas[:len(mheap_.heapArenas):len(mheap_.heapArenas)]
    work.nSpanRoots = len(mheap_.markArenas) * (pagesPerArena / pagesPerSpanRoot)

    // 扫描 goroutine 栈
    // 注意，之后新创建的 goroutine 不会被扫描，但它们的 root 会被写屏障捕获
    work.stackRoots = allGsSnapshot()
    work.nStackRoots = len(work.stackRoots)

    // 初始化 root 扫描任务索引
    work.markrootNext = 0
    // 总共需要扫描的 root 数量
    work.markrootJobs = uint32(fixedRootCount + work.nDataRoots + work.nBSSRoots + work.nSpanRoots + work.nStackRoots)

    // 计算每类 root 的起始索引，用于 markroot 调度
    work.baseData = uint32(fixedRootCount)                      // data root 起始索引
    work.baseBSS = work.baseData + uint32(work.nDataRoots)     // BSS root 起始索引
    work.baseSpans = work.baseBSS + uint32(work.nBSSRoots)     // span root 起始索引
    work.baseStacks = work.baseSpans + uint32(work.nSpanRoots) // stack root 起始索引
    work.baseEnd = work.baseStacks + uint32(work.nStackRoots)  // 所有 root 的结束索引
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里其实就是做了一些计算工作，将当前的程序中的一些内存数据保存下来，并分块成多个 task，方便之后并发的进行标记处理，我们不需要太过于在意这些数据是怎么得出来的，只需要知道是这么回事即可。&lt;/p&gt;
&lt;p&gt;那么我们标记 root 之后，我们便需要去标记 heap 对象了，此时我们当然需要依赖之前从 root 中标记的对象去标记 heap 内存中的对象：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 这是 GC 的 heap 标记循环，用于从灰色对象队列中继续标记对象，直到队列为空或需要暂停。
// 此循环在 GC 的标记阶段执行（_GCmark）。
//
// 循环条件：如果当前 G 被标记为可抢占，并且满足以下任意条件则停止循环：
// - preemptible 为 true
// - sched.gcwaiting 表示有人想触发 STW（Stop The World）
// - pp.runSafePointFn != 0 表示有 P 正在执行安全点函数
for !(gp.preempt &amp;amp;&amp;amp; (preemptible || sched.gcwaiting.Load() || pp.runSafePointFn != 0)) {

    // 尝试保证全局队列中有可用工作。
    // 如果 work.full == 0，说明本地队列空了，需要从全局队列平衡一些工作。
    if work.full == 0 {
        gcw.balance()
    }

    // 从工作队列中获取下一个待扫描对象或 span
    var b uintptr  // 单个对象指针
    var s objptr   // span 指针

    // 尝试按优先级获取灰色对象
    if b = gcw.tryGetObjFast(); b == 0 {          // 优先尝试快速队列
        if s = gcw.tryGetSpan(false); s == 0 {   // 没有对象，尝试获取 span
            if b = gcw.tryGetObj(); b == 0 {     // 再尝试普通队列
                wbBufFlush()                     // 写屏障缓冲区 flush，可能产生新的灰色对象
                if b = gcw.tryGetObj(); b == 0 { // 再次尝试获取对象
                    s = gcw.tryGetSpan(true)     // 最后尝试获取 span
                }
            }
        }
    }

    // 如果拿到对象或 span，就扫描它们
    if b != 0 {
        scanobject(b, gcw)  // 扫描对象，将其引用的对象加入灰色队列
    } else if s != 0 {
        scanSpan(s, gcw)    // 扫描 span，处理里面的对象
    } else {
        // 队列空，无法获取更多工作，循环结束
        break
    }

    // 如果实验性 GreenTea GC 需要新 worker，则启动
    if goexperiment.GreenTeaGC &amp;amp;&amp;amp; gcw.mayNeedWorker {
        gcw.mayNeedWorker = false
        if gcphase == _GCmark {
            gcController.enlistWorker()
        }
    }

    // 将本地累积的扫描工作量计入全局，供 mutator assist 使用
    if gcw.heapScanWork &amp;gt;= gcCreditSlack {
        gcController.heapScanWork.Add(gcw.heapScanWork) // 增加全局 heapScanWork
        if flushBgCredit {
            gcFlushBgCredit(gcw.heapScanWork - initScanWork) // flush 背景扫描信用
            initScanWork = 0
        }
        checkWork -= gcw.heapScanWork
        gcw.heapScanWork = 0

        // 检查，之前提到的 check
        if checkWork &amp;lt;= 0 {
            checkWork += drainCheckThreshold
            if check != nil &amp;amp;&amp;amp; check() {
                break
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;到这里其实已经把 GC 的逻辑梳理的差不多了，其他诸如 STW，StartTheWorld 都没有讲述。但是其实我们还可以更细粒度的去看看 &lt;code&gt;tryget&lt;/code&gt; 还有 &lt;code&gt;markroot&lt;/code&gt; 都干了些什么，这里有点不太想贴源码，就直接口述了。&lt;/p&gt;
&lt;h3&gt;迭代标记&lt;/h3&gt;
&lt;p&gt;markroot的函数签名是 &lt;code&gt;markroot(gcw *gcWork, i uint32, flushBgCredit bool) int64&lt;/code&gt;，这个 i 就是所谓的 taskId，我们可以根据这个 id 找到当前需要进行扫描标记的区域，大多数都是调用 &lt;code&gt;scanblock&lt;/code&gt; 去扫描的，而在这个函数中，经历一系列复杂的变换和扫描，由于我不懂 GC 的扫描逻辑，所以就不乱讲，最终我们会把扫描到的可达对象通过 &lt;code&gt;greyobject&lt;/code&gt; 将这个对象标记为&lt;strong&gt;灰色&lt;/strong&gt;，如果对象不可扫描，则标记为黑色，在将他标记为灰色之后，我们还会将他通过 &lt;code&gt;gcw.putObj&lt;/code&gt; 放入到本地的标记处理队列里面，这一步的意义其实就是迭代处理，在第二阶段标记的时候，我们也是最终会调用 &lt;code&gt;greyobject&lt;/code&gt; 将这个函数染灰，并推送到本地标记处理队列里面，用于迭代处理，思想上有点类似广度优先搜索。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// greyobject 将一个堆对象标记为灰色（可扫描），并将其加入到 P 的本地工作队列 gcw 中，以便后续扫描其内部指针。
// 如果对象不可扫描（noscan），则直接标记为黑色。
//
// 参数说明：
// obj       : 要标记的对象起始地址
// base, off : 调试信息，用于记录对象是通过哪个 root 扫描到的
// span      : 对象所在的内存 span
// gcw       : 当前 P 的本地 GC 工作队列
// objIndex  : 对象在 span 中的索引
//
// go:nowritebarrierrec 表示此函数不会触发写屏障，且可递归调用
func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
	...

	// 将对象加入 P 的本地工作队列，以便后续 scanobject 扫描其指针
	if !gcw.putObjFast(obj) { // 快速入队
		gcw.putObj(obj)       // 慢速入队（如果快速失败）
	}
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你可能会注意到，第二阶段的扫描只调用了 &lt;code&gt;scanobject&lt;/code&gt; ，实际上，它内部也是调用的 &lt;code&gt;greyobject&lt;/code&gt;，他会将这个对象引用的指针通过 &lt;code&gt;greyobject&lt;/code&gt; 变为灰色，并放入本地工作队列，以便于下一次的迭代。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// scanobject 扫描以 b 开头的堆对象，将对象内部的指针加入 gcw 队列。
// b 必须指向一个堆对象或 oblet（大对象的分块）。
// scanobject 会根据 GC 的位图获取指针掩码，并通过 span 获取对象大小。
//
//go:nowritebarrier 表示该函数不会触发写屏障。
func scanobject(b uintptr, gcw *gcWork) {
	...
	var scanSize uintptr
	for {
		var addr uintptr
		// 尝试快速获取下一个指针
		if tp, addr = tp.nextFast(); addr == 0 {
			// 如果没有快速指针，再走慢路径
			if tp, addr = tp.next(b + n); addr == 0 {
				break
			}
		}

		// 更新扫描范围，用于统计 heapScanWork
		scanSize = addr - b + goarch.PtrSize

		// 读取对象中的潜在指针
		obj := *(*uintptr)(unsafe.Pointer(addr))

		// 过滤掉 nil 和指向当前对象内部的指针
		if obj != 0 &amp;amp;&amp;amp; obj-b &amp;gt;= n {
			// 判断 obj 是否指向 Go 堆中的对象，如果是则标记
			// 注意可能存在与分配同时发生的竞争，findObject 可能失败
			if !tryDeferToSpanScan(obj, gcw) {
				if obj, span, objIndex := findObject(obj, b, addr-b); obj != 0 {
					// 将指针对象标记为灰色，并入队等待扫描
					greyobject(obj, b, addr-b, span, gcw, objIndex)
				}
			}
		}
	}

	// 更新本地 GC 工作队列的统计信息
	gcw.bytesMarked += uint64(n)
	gcw.heapScanWork += int64(scanSize)
	if debug.gctrace &amp;gt; 1 {
		gcw.stats[s.spanclass.sizeclass()].sparseObjsScanned++
	}
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;综上所述，三色标记的大致的流程如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;markroot（扫描 root 对象：全局变量、栈、span specials）
    ↓
发现堆对象 → greyobject → 标灰 + 入本地队列 gcw
    ↓
heap 扫描阶段（drain heap marking jobs）
    ↓
从 gcw 队列取灰对象（tryGetObj/tryGetSpan）
    ↓
scanobject 扫描对象内部指针
    ↓
扫描出的新对象 → greyobject → 入 gcw 队列（迭代处理）
    ↓
重复直到队列为空 → 所有可达对象都被标记
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这下我们知道了，网上图解的三色标记法其实就是一个在三色的基础上进行广度优先搜索，图还是很生动形象的，然而，有的东西也需要真正去看这部分逻辑才能学到，GC 不仅仅就是个垃圾回收，他的运行过程还和系统监控，网络轮询器有着一定的关系，感觉看源码有助于对整个 runtime 的认知，虽然我把 STW，读写屏障还有三色标记的具体算法没有重点讲解，但是本篇文章主要注重逻辑梳理。&lt;/p&gt;
&lt;h2&gt;一些优化&lt;/h2&gt;
&lt;p&gt;除了上面所说的标记以外，我们的 &lt;code&gt;mallocgc&lt;/code&gt; 其实也会在 gc 阶段帮助我们进行部分标记工作，这就是我们常说的 Mutator Assist 优化&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// mallocgc 分配一个指定大小的对象。
// 小对象从 P（处理器本地）缓存的 free list 分配。
// 大对象（&amp;gt; 32 KB）直接从堆分配。
// mallocgc 是 runtime 内部接口，但一些第三方库通过 //go:linkname 调用。
// 请勿修改函数签名，否则可能破坏 runtime。
//
// 参数:
//   size     - 需要分配的字节数
//   typ      - 对象类型信息 (_type)，用于 GC 扫描指针；nil 表示 noscan
//   needzero - 是否需要将分配的内存清零
//
// 返回值:
//   unsafe.Pointer - 指向分配好的对象
//
// go:linkname 指令允许其他包直接调用 runtime.mallocgc
//
// mallocgc 核心功能:
// 1. 检查 GC assist，决定 mutator 是否需要帮忙做标记。
// 2. 根据对象大小选择 tiny allocator / small allocator / large allocator。
// 3. 调用 sanitizers（race、msan、asan、valgrind）。
// 4. 调整 GC assist 债务。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
	...

	// 当前是否在 GC mark 阶段且 write barrier 启用
	// 如果需要，mutator（分配者）需要帮忙标记一些对象
	if gcBlackenEnabled != 0 {
		deductAssistCredit(size) // 借款，并可能触发 gcDrain
	}

	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这里借款之后，如果发现分配的 size 过大，哪怕是从全局借款中也没办法抵消债务，那么就会触发 gcDrainN，强制执行一部分标记工作，当然，如果还是不够，那么就会直接让这个 goroutine “坐牢”，即不能让他继续被 P 调度，同时放入 AssistQueue，这里的意思就是说，当前 goroutine 借款过多，无法继续调度，需要等待其他的 gcWorker 去执行标记工作，以此来生产借款到全局债务中，此时，我们又可以唤醒这些 AssistQueue 中的 goroutine，也就是让他们能够继续运行。总的来说，其实就是一个生产者消费者的协作模型，当一部分 goroutine 需要申请大量内存，而标记的 worker 速度跟不上的时候，此时就会阻塞这些 goroutine 进行执行，直到 gcworker 的速度跟上申请的速度，此时就会让他们继续执行，借款的链路为 gcAssistAlloc-&amp;gt;gcParkAssist：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// gcParkAssist 将当前的 goroutine 放入 assist 队列并将其挂起，
// 直到满足 GC 协助条件。协助标记的任务由多个 goroutine 共同完成。
// 
// 当返回值为 true 时，表示该协助任务已经完成，goroutine 可继续执行。
// 如果返回 false，说明协助任务还未完成，调用者应当重试协助。
//
// 该函数通过加锁、检查 GC 状态、挂起当前 goroutine 来保证协助任务的顺利进行。
// 它帮助实现协作式垃圾回收，避免 GC 阻塞或资源浪费。
func gcParkAssist() bool {
    // 加锁以确保对 assistQueue 的操作是线程安全的
    lock(&amp;amp;work.assistQueue.lock)

    // 如果 GC 循环已经完成，则直接退出协助，返回 true
    // 因为在持有锁时，GC 周期无法结束
    if atomic.Load(&amp;amp;gcBlackenEnabled) == 0 {
        unlock(&amp;amp;work.assistQueue.lock)
        return true
    }

    // 获取当前的 goroutine（gp），并将其加入 assist 队列
    gp := getg()
    oldList := work.assistQueue.q
    work.assistQueue.q.pushBack(gp)

    // 重新检查背景扫描的 credit，以确保当前的挂起 goroutine 不会被漏掉
    // 如果背景标记已生成足够的 credit，则可以让当前 goroutine 继续执行
    if gcController.bgScanCredit.Load() &amp;gt; 0 {
        // 恢复队列状态，取消挂起的 goroutine
        work.assistQueue.q = oldList
        if oldList.tail != 0 {
            oldList.tail.ptr().schedlink.set(nil)
        }
        unlock(&amp;amp;work.assistQueue.lock)
        return false
    }

    // 如果 credit 不够，挂起当前 goroutine
    goparkunlock(&amp;amp;work.assistQueue.lock, waitReasonGCAssistWait, traceBlockGCMarkAssist, 2)
    return true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;而我们的每次 gcWorker 执行了标记工作之后，都会去调用 &lt;code&gt;gcFlushBgCredit&lt;/code&gt; 尝试去唤醒这些消费者：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// gcFlushBgCredit 将指定数量的后台扫描工作单位（scanWork）信用刷新到后台扫描信用池。
// 它首先会满足阻塞在工作队列中的 goroutine 的协助债务，然后将剩余的信用刷新到
// gcController.bgScanCredit，供其他需要的协助任务使用。
//
// 由于这是由 gcDrain 使用，在执行时确保所有的工作都已完成，所以在该函数中不允许
// 写入屏障。
// 
// 该函数的核心逻辑是分配信用并协助完成挂起的协助任务，保证 GC 协作过程的平衡。
//go:nowritebarrierrec
func gcFlushBgCredit(scanWork int64) {
    // 如果 assist 队列为空，则表示没有待协助的 goroutine，直接将扫描工作信用加到后台信用池
    if work.assistQueue.q.empty() {
        // 快速路径；没有阻塞的协助任务。这里有一个小的窗口，如果有协助任务被加入并挂起，
        // 它会在下一次调用时处理。
        gcController.bgScanCredit.Add(scanWork)
        return
    }

    // 计算每单位扫描工作需要多少字节的协助信用
    assistBytesPerWork := gcController.assistBytesPerWork.Load()
    scanBytes := int64(float64(scanWork) * assistBytesPerWork)

    // 加锁，确保对 assistQueue 操作的线程安全
    lock(&amp;amp;work.assistQueue.lock)

    // 遍历队列中的所有阻塞 goroutine，尝试用当前扫描信用偿还它们的协助债务
    for !work.assistQueue.q.empty() &amp;amp;&amp;amp; scanBytes &amp;gt; 0 {
        gp := work.assistQueue.q.pop()

        // 注意，gp.gcAssistBytes 是负数，因为 goroutine 之前积累了协助债务
        // 判断当前扫描信用是否能够满足 goroutine 的债务
        if scanBytes+gp.gcAssistBytes &amp;gt;= 0 {
            // 如果当前的信用足够偿还整个债务，更新扫描字节并清空债务
            scanBytes += gp.gcAssistBytes
            gp.gcAssistBytes = 0

            // 注意：不要将这个 goroutine 放到 runnext 队列中，以避免它的高优先级
            // 被滥用，阻塞其他 goroutine 执行。
            ready(gp, 0, false)
        } else {
            // 如果信用不足以偿还整个债务，只偿还部分债务
            gp.gcAssistBytes += scanBytes
            scanBytes = 0

            // 为了避免大的协助任务堵塞队列，我们将该任务移到队列的末尾，
            // 确保小的协助任务能及时得到处理。
            work.assistQueue.q.pushBack(gp)
            break
        }
    }

    // 如果仍然有剩余的扫描字节（信用不足以偿还所有的协助债务），
    // 我们将它们转回到后台扫描信用池中
    if scanBytes &amp;gt; 0 {
        // 将剩余的扫描字节转换为相应的工作量
        assistWorkPerByte := gcController.assistWorkPerByte.Load()
        scanWork = int64(float64(scanBytes) * assistWorkPerByte)
        gcController.bgScanCredit.Add(scanWork)
    }

    // 解锁，完成当前的工作信用分配
    unlock(&amp;amp;work.assistQueue.lock)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;通过上面的分析，我们可以发现，我们的 GC 是通过广度优先搜索的方式去从堆上扫描对象来进行回收，也就是说，如果堆上的内存小，但是对象多，就会给 GC 带来很大的压力，所以这就是我们需要进行逃逸分析，在一些情况下尽量避免内存逃逸到堆上；除了这些 GC 的步骤之外，还引入了租约的制度来平衡申请内存和标记的速度。&lt;/p&gt;
&lt;p&gt;看完这部分源码我觉得《Go 语言设计与实现》讲的是真的不错，但是真的得自己再去看看源码才能把整个链路搞明白，光看书还是很糊里糊涂的。&lt;/p&gt;
</content:encoded></item><item><title>新的开始--astro</title><link>https://blog.g-rinai.cn/posts/%E9%97%B2%E8%81%8A/new-start-for-astro/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E9%97%B2%E8%81%8A/new-start-for-astro/</guid><description>迁移到 Astro 主题的随笔。</description><pubDate>Tue, 04 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;之前的博客有点审美疲劳，而且功能实在太少，无法满足我的需求🤣&lt;/p&gt;
&lt;p&gt;现在迁移到&lt;a href=&quot;https://github.com/saicaca/fuwari&quot;&gt;这个主题&lt;/a&gt;，算是一个新的开始吧？&lt;/p&gt;
&lt;p&gt;不过以前的博客还保留着，算是自己珍贵的记忆了。&lt;/p&gt;
</content:encoded></item><item><title>大一结束了</title><link>https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/freshman-end/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/freshman-end/</guid><description>大一学年的终章总结与暑期计划。</description><pubDate>Sun, 06 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;大一下赛季总结&lt;/h2&gt;
&lt;p&gt;挂科数量：0🥳&lt;/p&gt;
&lt;p&gt;翘课百分比：80% +🥳&lt;/p&gt;
&lt;p&gt;加入了蓝山，结识了一些志同道合的朋友。🥳&lt;/p&gt;
&lt;p&gt;看番数：未知。🥳&lt;/p&gt;
&lt;h2&gt;暑期规划&lt;/h2&gt;
&lt;p&gt;继续学计网，了却之前 CS144 中道崩殂的遗憾。&lt;/p&gt;
&lt;p&gt;复习操作系统（并且继续深入），因为之前学 xv6 可能有一些细节和源码没有读到，并且很多东西也忘了，这次就不局限于 xv6 的源码了，可能会去看看其他操作系统的实现。&lt;/p&gt;
&lt;p&gt;复习 Go 底层设计，这部分再看看《Go 语言设计与实现》吧，然后自己再去看看源码。&lt;/p&gt;
&lt;p&gt;继续参与开源&lt;/p&gt;
&lt;p&gt;最后就是学一下 k8s 吧，感觉这方面挺有意思的，一直都想学一下。&lt;/p&gt;
&lt;p&gt;看番，通关一直想玩的 galgame。😍😍😍&lt;/p&gt;
&lt;p&gt;姑且列个目标在这，暑假还有一堆事情等着我，不知道能完成多少。&lt;/p&gt;
</content:encoded></item><item><title>期末之前的闲聊</title><link>https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/a-leisure-before-fresh-end/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/a-leisure-before-fresh-end/</guid><description>期末前的学习计划和感想。</description><pubDate>Sun, 08 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;期末之前的闲暇&lt;/h2&gt;
&lt;p&gt;马上要开期末副本了，各种考试都要开始准备了，趁现在还闲，随便写一写。&lt;/p&gt;
&lt;p&gt;蓝山的最终考核也是写完了，很多东西最开始感觉自己都难以写出来，但是最终还是写出来了，在这方面姑且给自己点个赞。&lt;/p&gt;
&lt;p&gt;写完蓝山考核的时候，想留个两周时间给自己学一学 cs144，最后感觉没什么帮助，就不打算做了，以前看过一些计网的书，加上看了 cs144，对计算机网络有了大概的理解，接下来就是随便看看一些网络库和多路复用的代码实现吧，然后就是上八股文了，计网其实最开始看的时候确实是很枯燥，我以为他仅仅就是一堆协议，加上一堆报文格式，光从这些来看，其实就是一些网络上的节点，确实没什么意思，但是扩展到了多个节点，涵盖到了现实里面的通信，比如像 NAT，DHCP 这些东西，感觉就挺有意思的（就我来看，觉得 NAT 应该是最好玩的），除此之外的比如 ip 的一些通信逻辑，其实说无聊也无聊，说有趣，也没那么有趣吧，当然只是个人看法。&lt;/p&gt;
&lt;p&gt;蓝山答辩不知道在什么时候，可能在期末周复习的时候，也可能在之后，听说要考八股文，不过倒是没那么多时间去复习八股文了。虽说算法题一直在刷，但是不知道在考试周的压力下能不能坚持写下去。&lt;/p&gt;
&lt;p&gt;话说回来，前几周开始写力扣周赛，分数一直是 down 的状态，最近总算开始 up 了，感觉倒不是得益于灵神的题单，而是恰好周赛遇到了我相对熟悉的题目，看了看袁神曾经打的周赛，很难想象竟然看过 acwing 之后就可以一路 AK 过去，虽然自己最开始也是看的 acwing，但是却做不到像袁神那么厉害啊。&lt;/p&gt;
&lt;p&gt;最近在看 zinx 这个仓库，竟然还自带视频教程，带着我们去手搓框架，确实是一个很好的学习资源，然后在它的 issue 里面发现了一些有意思的问题，仔细研究，思考如何解决这个问题的同时，我发现这样的学习，不仅仅是看源码，如果去参与开源，认真的去思考这个框架应该如何去改善，确实能够让自己的认知提升不少，感觉自己有机会还是应该去多参与开源，现在是对网络库很感兴趣，如果能够深入学习一下这些网络库的源码，应该会让自己对一些网络框架的理解更加深入吧。&lt;/p&gt;
&lt;p&gt;接下来，就是一场恶战了。&lt;/p&gt;
</content:encoded></item><item><title>CS144？启动！</title><link>https://blog.g-rinai.cn/posts/%E9%97%B2%E8%81%8A/cs144-start/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E9%97%B2%E8%81%8A/cs144-start/</guid><description>开始学习 CS144 的心情记录。</description><pubDate>Mon, 26 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;hr /&gt;
&lt;h2&gt;今天正式开始学习计算机网络&lt;/h2&gt;
&lt;p&gt;之前看过 《图解 tcp/ip》 和 《图解 http》 感觉挺无聊，本来不抱希望地去看看 cs144 的课，看了一会 cs144，感觉很有意思，于是立贴为证，坚持到最后！&lt;/p&gt;
</content:encoded></item><item><title>路途的一半</title><link>https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/freshman-half/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/freshman-half/</guid><description>大一下阶段的学习反思与规划。</description><pubDate>Sun, 04 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;目前关于操作系统的学习也算告一段落了，最后一个 mmap 的 lab ，我 debug 了半天，有一个样例始终过不去，感觉自己也算能够理解其流程，就作罢了，想起来，最近的几个月也是发生了很多心境的变化，特此来写一篇文章，既是总结，也是展望。&lt;/p&gt;
&lt;h2&gt;正文&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;之前&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;迷茫是我这几个月的主旋律，早在二月份，仿照着前辈的路子，我想要学习操作系统，虽然早早下定决心是好事，但是错误的选择直接让我浪费掉了整整一个月的学习时间。&lt;/p&gt;
&lt;p&gt;最开始，我选择学习南大的操作系统，具体的不再多说了，由于课堂内容较为浅显而 lab 难度较高，我选择了只看课，不写 lab ，但是从现在来看，那个时候的我是根本没有学懂操作系统的，虽然老师讲得比较生动，让我误以为自己懂了，但是实际上是根本不知道怎么一回事的，实际上，那段时间力扣也没怎么刷，光去看课，几乎没什么效果，甚至浪费了大把的时间，说是玩了一个寒假也不为过。&lt;/p&gt;
&lt;p&gt;迷茫着，到了开学，那个时候已经是二月底了，到当时为止，我还去看了一些学长的项目，将一些限流，链路追踪什么的功能也加入到了自己做着玩的 demo 中，但是即便是用了这些，感觉也是空空的，感觉自己还是仅仅是知道这玩意是去干什么的，太过浅薄，看了看蓝山的课件，也不知道应该怎么去进一步学习了。这种感觉真的不好受，既想继续精进，但是感觉还是仅仅给 CRUD 镶了一些花边，自己的实际能力根本没有增长。&lt;/p&gt;
&lt;p&gt;接下来一个月，为了给自己找到方向，我总是痴迷于去看看同期的同学，以及学长们，在我这个时候，他们在干些什么事情，虽然并没有实质性的参考，但是让我回想起了 java 和 go 一样都是后端，所以我就去看了一些中间件的底层，包括 redis/kafka ，同时学习的时候写了份笔记，偶尔复习感觉用处还是有的，不过感觉也不够深，之后写了个 docker ，感受到了 Linux 的神秘，遂重启操作系统的学习，这次，我最初的计划是去学习 HIT 的 OS 课，咨询过学长的意见，转而去学习 xv6 ，这也确实是一个正确的选择，在接下来的一个月，我也确实坚持下来了，而现在我也能够理解了 love6 学长的那句“没有看过操作系统源码，没有写过操作系统是没有办法理解操作系统的”，刚开始确实感觉挺疑惑的，现在我也持有一样的观点。&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;现在&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;自己总是希望不要落后于他人，包括自己学习底层，学习计算机基础的时候，也会时常去担心，哎，我业务方面会不会落后于他人？我实操能力没有提升怎么办？这些想法总是伴随在我的身边，害怕被别人给抛下。&lt;/p&gt;
&lt;p&gt;但是人总需要取舍，我不希望自己仅仅是一个 CRUD 战士，相反，我希望对于计算机来说，我是一个掌控者，而不是一个操作员（说是掌控者还太远了，我只是希望能够理解计算机），而单纯跟着工作室的学习仅仅会让我能够进行一些简单的 CRUD ，我直到最近才醒悟这一点，工作室教的仅仅是起着引领的作用，它可以给你指引一些方向，而包括自己的能力，技术，就业的核心竞争力还得靠自己来打磨。（说实话关于go的后端方向资源还是太少了😭😭）&lt;/p&gt;
&lt;p&gt;在这几个月，对我来说意义最大的就是完成了 xv6 的学习了，这也是我来写这篇文章的原因，除此之外，也学会了去逛逛牛客，看着对应岗位的面经，以此来告诉自己还有什么欠缺的，还有什么是要去学的，之后的一个月，就必须得投入到后端的业务学习之中了，毕竟，蓝山考核马上就开始了😱😱😱😱，工位啊啊啊，好想要工位，尤其是 51 期间，这种感觉特别强烈（因为放假了，图书馆闭馆，自己白天没有去处了🥲）&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;之后&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;考核结束之后的计划，大概是回归计算机基础的学习吧，还剩下网络的内容得好好学一下，虽然也想过去参与开源，但是还是得看看自己的时间精力是否充沛，关于 go 的八股感觉也挺碎片的，之后也得好好看一看，算法也要继续刷，之前刷了两百多道题，但是都没有按着专题来刷，而都是跟着 hot100 和 codetop 上面来的，总感觉效果并不算太好，最近开始慢慢刷灵茶山艾府的 dp 题单了，目前来看，效果还算不错。&lt;/p&gt;
&lt;p&gt;最后，只希望自己能够坚持下去，有一个不算坏的结果。&lt;/p&gt;
</content:encoded></item><item><title>今天弄好了一个窝</title><link>https://blog.g-rinai.cn/posts/%E9%97%B2%E8%81%8A/hello-world/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E9%97%B2%E8%81%8A/hello-world/</guid><description>博客搭建完成后的随记。</description><pubDate>Tue, 15 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;+++&lt;/p&gt;
&lt;p&gt;===&lt;/p&gt;
&lt;h2&gt;一切的起源&lt;/h2&gt;
&lt;p&gt;今天花了两个小时左右给整好了，以后就不在csdn上面发牢骚了，当然，技术类的还会继续发在上面，关于个人的，倒是会集中在这里。😎&lt;/p&gt;
&lt;p&gt;索性现在就随便写一些：&lt;/p&gt;
&lt;p&gt;最近准备期中考试了，过两天就是高数了，一点没&lt;strong&gt;预习&lt;/strong&gt;，前两天把大物和线代速通了，争取这两天速通高数吧，内容倒是也不算太多，希望我没事。&lt;/p&gt;
&lt;p&gt;感觉期中复习真的是花费了很多没有意义的时间，这个网站也是，高数学烦了才来索性搭一个的，并且今天硬控课比较多，都是碎片化的时间，不利于学习，那么直接来试着用一下hexo来搭建blog了，主题方面也是采用了&lt;a href=&quot;https://github.com/HiNinoJay/hexo-theme-A4&quot;&gt;A4主题&lt;/a&gt;，这个简洁风格我还挺喜欢的，像万神他们那种感觉太简洁了，甚至代码块都是那种样子😨，在hexo主题库里面随便翻翻一下子就中意这个主题了😇。&lt;/p&gt;
&lt;h2&gt;接下来是一些测试&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;package main

func main() {
    //代码块测试
    return
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;例子测试&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一个例子&lt;/li&gt;
&lt;li&gt;第二个例子&lt;/li&gt;
&lt;/ul&gt;
&lt;ol&gt;
&lt;li&gt;数字测试&lt;/li&gt;
&lt;li&gt;数字二&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;h2&gt;也差不多就这样吧&lt;/h2&gt;
&lt;p&gt;结束✨&lt;/p&gt;
</content:encoded></item><item><title>2025-03-23记</title><link>https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/2025-03-23-record/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/2025-03-23-record/</guid><description>Kafka 学习后的迷茫记录。</description><pubDate>Sun, 23 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;跟着尚硅谷把kafka学完了，虽然这方面并不算完结，但是今天早上感觉到一阵迷茫，于是想来随便写一下。&lt;/p&gt;
&lt;p&gt;在寒假我就在看南大jyy的操作系统课，PA做了一些，就没有下文了，然后就去迫不及待地看jyy的课了，怎么说呢，讲课方式很新颖，也确实非常不错，但是一到lab的时候就感到不知道如何下手，还是继续学着走，后面干脆lab完全看不懂了，就放弃了，这时候又想起来，love6这位学长，以及Lance学长似乎都是去看的哈工大的操作系统课，于是也开始学汇编，最开始，感觉嗯，挺简单的，越到后面，越感觉这些内存，数字什么的很变态，“差不多了吧？”，然后我就去开始学习哈工大的os课，说实话，最开始，bootstrap，我也不是很理解，为什么要到这个地址？为什么是那个数值？很多疑问我都留在了心里。慢慢的，我感觉，似乎用C和汇编去做实验，写组件，似乎脱离了我的技术栈，毕竟我最初的想法是go后端开发，但是慢慢的，我感觉我偏离了道路...&lt;/p&gt;
&lt;p&gt;“有必要这样学吗?”每当遇到困难，这样的疑问时不时出现在我心中，并且，我对c的很多内容也忘完了，汇编也感觉很无聊，随后便是退堂鼓，不了了之。&lt;/p&gt;
&lt;p&gt;但是总不能不学吧？这个时候我找到了OSTEP这本书，感觉很适合我，三下五除二，便是看完了，很多抽象的东西似乎变得有点清晰，但是我依旧找不到前进的方向，浑浑噩噩，便到了今天，&lt;/p&gt;
&lt;p&gt;晚上的时候喜欢看看牛客或者同届的其他大佬在干什么，发现北京的一位同学，大一上结束408，手写了一个内核，最近在学java，我感觉他的路线倒是很完美的，相对比下来，感觉自己一无是处，虽然牛客上很多人都说，硬怼八股就好了，但是总感觉作为一个计算机的学生，竟然会以八股的形式入门计算机，就总感觉很多不对劲。&lt;/p&gt;
&lt;p&gt;即便是盯着CS自学指南上面一套套的精品课程的推荐，也总感觉无从下手。&lt;/p&gt;
&lt;p&gt;此时面临一个交叉路口，应该继续深挖golang技术栈？还是深入计算机基础课程？虽然一个月前，已经决定了，继续深挖golang技术栈，但是最近学习kafka，又会难以避免的遇见操作系统的内容，不仅是消息队列，在go语言的设计与实现中，也会撞见例如epoll这种知识，虽然我学过这些零散的内容，能够理解是什么，但是，我也恨自己，如果我学好了计算机基础的话，再来学这些，肯定会有更好的理解吧？这样的想法也盘旋在我身边。&lt;/p&gt;
&lt;p&gt;有点不知道怎么办才好了，不确定的未来以及路，都由自己描绘，但也会为这种不确定感到害怕。&lt;/p&gt;
</content:encoded></item><item><title>大一上的完结</title><link>https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/freshman-start/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/%E6%80%BB%E7%BB%93/2025/freshman-start/</guid><description>大一上学期的成长记录。</description><pubDate>Sat, 04 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;大一上总结&lt;/h1&gt;
&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;源于学长们都喜欢写总结，今晚也正好听见一首有点触动心灵的歌，深有感慨，故来此写下这篇总结&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;正文&lt;/h2&gt;
&lt;h3&gt;1.暑假前的准备&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;暑假之前姑且还是学习了基本的C语法，大概是到了结构体的地方，进度很慢，主要是我在家确实很懈怠，而且确实也有其他不少的事情。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.军训之后到10月22日&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;我在学校的代码之旅是在军训之后开始的，军训完本来想在寝室学习编程的，却发现室友都在go/瓦/王者启动...感觉有点吵，于是转战到了图书馆，其实从军训之后，我的日常就是每天就是寝室--图书馆--教室--食堂了，在军训完到10月22号这个期间一直在学习C和C++的基本语法，我也忘记当时是咋学的了，大抵就是看鹏哥，然后去mooc上接着看C++的语法，竟然花了这么久...&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;晚上回寝室在qq里面冲浪的同时，也交到了一些比较卷的朋友，同时了解到了红岩蓝山这些工作室，便选择了比较感兴趣的后端go组进行学习，其实之后放在这上面的注意力也比较少，当然这是后话了。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在这期间，从朋友（想打acm的）那里知道了acm协会，随后参加了萌新赛，大败而归。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.10月22日之后到11月20日左右&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;在学完基本的C和C++语法之后，知道了萌新赛里面大佬的实力，感觉自己能力还差得远，再加上自己当时有打acm的一个想法（之后就放弃了），就去朋友介绍的acwing上学习算法基础课了，这之后一个月的重心都是在这上面。&lt;/li&gt;
&lt;li&gt;大概到中间，了解到了csp认证，当时也学了一部分数据结构和算法的知识，便也进去历练了一番，最后也是进入了培训阶段，我记得那个时候每周第一时间就是去做新发布的oj题，没学过的就去acw看算法课，但是还好，出题的顺序大概就是acw视频课的顺序，学习的过程也算比较顺利，现在想来，那真是很舒服的一段学习时光，到了11月20日左右，我也算结束了acw上面算法基础课的学习，之后就在洛谷上面做题了，毕竟虽然在学习的过程中遭受了一点打击，但还是有一点打acm的想法的，这个想法其实是在一点一点消失的。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. 11月20日左右至今&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;在此之前，先讲讲我想法的转变，当时我也是了解到了我们学校的acm算比较弱的，而且周围也有很多是初中都在搞信息竞赛的，感觉自己还是竞争不过这群童子功，而且真正让我想法转变的还是认识的一个oier直接全身心投入到了java学习中，也是通过他，我了解到了CSDN里面用户名&lt;strong&gt;Love 6&lt;/strong&gt;的学长，这时候我才知道，自己到目前为止都是比较漫无目的的学习，看着学长的经历，像他这样的人都差点没找到工作，想到我之前学了点算法，比其他同学进度快一点就自满的想法，我感觉自己还是太天真了，从这之后，我也开始写一些博客，开始自己的后端学习之旅了。&lt;/li&gt;
&lt;li&gt;大概是这个时候，我也是真的感觉到有压力了，每天补着golang的知识，赶上工作室教学的进度，每天除此之外就是去leetcode做题了，值得一提的是，期间我参加了csp认证，虽然还是依托，只拿到了180分，在年末，跟着尚硅谷的教学视频，结束了关于计组的学习（虽然还很浅薄，后面还得再认真看看书）&lt;/li&gt;
&lt;li&gt;最痛苦的一件事就是，脚骨折了，被迫在寝室学习，学习效率真的低下...&lt;/li&gt;
&lt;li&gt;期间又是闲来无事，在github上面乱翻，突然发现我们学校还藏着很多大佬，唉唉，不得不前进了...&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;大一上虽然比较充实，但是很多时间都没有利用好，放松也没好好放松，学也学的浅，不足之处还太多了，最近也得准备期末考试了，只能寒假再好好努力了。
什么都想要，什么都学得还浅薄，接下来就真正应该专注于后端方向了，幸好数据结构与算法也算相关的，不然就是真的烂了
(∩ᄑ_ᄑ)⊃━☆
写这篇总结，算是警示自己了。&lt;/p&gt;
&lt;hr /&gt;
</content:encoded></item><item><title>Simple Guides for Fuwari</title><link>https://blog.g-rinai.cn/posts/guide/</link><guid isPermaLink="true">https://blog.g-rinai.cn/posts/guide/</guid><description>How to use this blog template.</description><pubDate>Mon, 01 Apr 2024 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Cover image source: &lt;a href=&quot;https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/208fc754-890d-4adb-9753-2c963332675d/width=2048/01651-1456859105-(colour_1.5),girl,_Blue,yellow,green,cyan,purple,red,pink,_best,8k,UHD,masterpiece,male%20focus,%201boy,gloves,%20ponytail,%20long%20hair,.jpeg&quot;&gt;Source&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This blog template is built with &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;. For the things that are not mentioned in this guide, you may find the answers in the &lt;a href=&quot;https://docs.astro.build/&quot;&gt;Astro Docs&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Front-matter of Posts&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;---
title: My First Blog Post
published: 2023-09-09
description: This is the first post of my new Astro blog.
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
---
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The title of the post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;published&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The date the post was published.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A short description of the post. Displayed on index page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The cover image path of the post.&amp;lt;br/&amp;gt;1. Start with &lt;code&gt;http://&lt;/code&gt; or &lt;code&gt;https://&lt;/code&gt;: Use web image&amp;lt;br/&amp;gt;2. Start with &lt;code&gt;/&lt;/code&gt;: For image in &lt;code&gt;public&lt;/code&gt; dir&amp;lt;br/&amp;gt;3. With none of the prefixes: Relative to the markdown file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The tags of the post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The category of the post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If this post is still a draft, which won&apos;t be displayed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Where to Place the Post Files&lt;/h2&gt;
&lt;p&gt;Your post files should be placed in &lt;code&gt;src/content/posts/&lt;/code&gt; directory. You can also create sub-directories to better organize your posts and assets.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/content/posts/
├── post-1.md
└── post-2/
    ├── cover.png
    └── index.md
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item></channel></rss>