Runtime: Golang 定时器实现原理及源码解析

定时器作为开发经常使用的一种数据类型,是每个开发者需要掌握的,对于一个高级开发很有必要了解它的实现原理,今天我们runtime源码来学习一下它的底层实现。

定时器分两种,分别为 Timer 和 Ticker,两者差不多,这里重点以Timer为例。

源文件位于 src/time/sleep.gosrc/time/tick.go 。 go version 1.16.2

数据结构

Timer 数据结构

// src/runtime/sleep.go

// The Timer type represents a single event.
// When the Timer expires, the current time will be sent on C,
// unless the Timer was created by AfterFunc.
// A Timer must be created with NewTimer or AfterFunc.
type Timer struct {
	C <-chan Time
	r runtimeTimer
}

Timer 数据类型是表示单个事件。当计时器过期时,当前的时候将会发送到 Timer.C 通道,如果用 AfterFunc 创建计时器的话,则例外。

对于计时器必须由 NewTimer()AfterFunc() 创建。

// Interface to timers implemented in package runtime.
// Must be in sync with ../runtime/time.go:/^type timer
type runtimeTimer struct {
	pp       uintptr
	when     int64
	period   int64
	f        func(interface{}, uintptr) // NOTE: must not be closure
	arg      interface{}
	seq      uintptr
	nextwhen int64
	status   uint32
}

字段说明

  • PP 当前对应的处理器P的指针
  • when 定时器被唤醒的时间
  • period 唤醒间隔时间,定时器为Timer数据类型时,此字段值为 0 时,否则为 Ticker 数据类型
  • f 唤醒后执行的函数,不能为 闭包匿名函数
  • arg 唤醒后执行的函数的参数
  • seq ?
  • nextwhen 当定时器状态为 timerModifiedEarliertimerModifiedLater 时,需要使用此字段值刷新为 when 字段
  • status 定时器状态

状态值常量有以下几个

Continue reading

Golang中的CAS原子操作 和 锁

在高并发编程中,经常会出现对同一个资源并发访问修改的情况,为了保证最终结果的正确性,一般会使用 CAS原子操作 来实现。

如要对一个变量进行计数统计,两种实现方式分别为

package main

import (
	"fmt"
	"sync"
)

// 锁实现方式
func main() {
	var count int64
	var wg sync.WaitGroup
	var mu sync.Mutex

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func(wg *sync.WaitGroup) {
			defer wg.Done()
			mu.Lock()
			count = count + 1
			mu.Unlock()
		}(&wg)
	}
	wg.Wait()

	// count = 10000
	fmt.Println("count = ", count)
}

package main

import (
	"fmt"
	"sync"

	"sync/atomic"
)

// atomic CAS 原子操作
func main() {
	var count int64
	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func(wg *sync.WaitGroup) {
			defer wg.Done()
			// 失败一直重试
			for {
				old := atomic.LoadInt64(&count)
				if atomic.CompareAndSwapInt64(&count, old, old+1) {
					break
				}
			}

		}(&wg)
	}
	wg.Wait()

	// count = 10000
	fmt.Println("count = ", count)
}

可以看到两种用法的执行结果是一样的,我们再看一下两者的性能区别。

Continue reading

Golang并发同步原语之-信号量Semaphore

信号量是并发编程中比较常见的一种同步机制,它会保持资源计数器一直在0-NN表示权重值大小,在用户初始化时指定)之间。当用户获取的时候会减少一点,使用完毕后再恢复过来。当遇到请求时资源不够的情况下,将会进入休眠状态以等待其它进程释放资源。

在 Golang 官方扩展库中为我们提供了一个基于权重的信号量 semaphore 并发原语。

你可以将下面的参数 n 理解为资源权重总和,表示每次获取时的权重;也可以理解为资源数量,表示每次获取时必须一次性获取的资源数量。为了理解方便,这里直接将其理解为资源数量。

数据结构

semaphoreWeighted 结构体

type waiter struct {
	n     int64
	ready chan<- struct{} // Closed when semaphore acquired.
}

// NewWeighted creates a new weighted semaphore with the given
// maximum combined weight for concurrent access.
func NewWeighted(n int64) *Weighted {
	w := &Weighted{size: n}
	return w
}

// Weighted provides a way to bound concurrent access to a resource.
// The callers can request access with a given weight.
type Weighted struct {
	size    int64
	cur     int64
	mu      sync.Mutex
	waiters list.List
}

一个 watier 就表示一个请求,其中n表示这次请求的资源数量(权重)。

使用 NewWeighted() 函数创建一个并发访问的最大资源数,这里 n 表示资源个数。

Continue reading

学习Golang GC 必知的几个知识点

对于gc的介绍主要位于 src/runtime/mgc.go,以下内容是对注释的翻译。

GC 四个阶段

通过源文件注释得知GC共分四个阶段:

  1. GC 清理终止 (GC performs sweep termination
    a. Stop the world, 每个P 进入GC safepoint(安全点),从此刻开始,万物静止
    b. 清理未被清理的span,如果GC被强制执行时才会出现这些未清理的span
  2. GC 标记阶段(GC performs the mark phase
    a. 将gc标记从 _GCoff 修改为 _GCmark,开启写屏障(write barries)和 协助助手(mutator assists),将根对象放入队列。 在STW期间,在所有P都启用写屏障之前不会有什么对象被扫描。
    b. Start the world恢复STW)。标记工作线程和协助助手并发的执行。对于任何指针的写操作和指针值,都会被写屏障覆盖,使新分配的对象标记为黑色。
    c. GC 执行根标记工作。包括扫描所有的栈,全局对象和不在堆数据结构中的堆指针。每扫描一个栈就会导致goroutine停止,把在栈上找到的所有指针置灰色,然后再恢复goroutine运行。
    d. GC 遍历队列中的每个灰色对象,扫描完以后将灰色对象标记为黑色,并将其指向的对象标记为灰色。
    e. 由于GC工作在分布本地缓存中,采用了一种 “分布式终止算法(distributed termination algorithm)” 来检测什么时候没有根对象或灰色对象。在这个时机GC会转为标记中止(mark termination)。
  3. 标记终止(GC performs mark termination
    a. Stop the world从此刻开始,万物静止
    b. 设置阶段为 _GCmarktermination,并禁用 工作线程worker和协助助手
    c. 执行清理,flush cache
  4. 清理阶段(GC performs the sweep phase
    a. 设置清理阶段标记为 _GCoff,设置清理状态禁用写屏障
    b. Start the world恢复STW),从现在开始,新分配的对象是白色的。如有必要,请在请在使用前扫描清理
    c. GC在后台执行并发扫描,并响应分配

整个GC共四个阶段,每次开始时从上到下执行。第一步是清理上次未清理完的span,而不是直接标记阶段。具体的流程可以参考 runtime.GC() 函数

Continue reading

Runtime: Golang 之 sync.Pool 源码分析

Pool 指一组可以单独保存和恢复的 临时对象。Pool 中的对象随时都有可能在没有收到任何通知的情况下被GC自动销毁移除。

多个goroutine同时操作Pool是并发安全的。

源文件为 src/sync/pool.go go version: 1.16.2

为什么使用Pool

在开发高性能应用时,经常会有一些完全相同的对象需要频繁的创建和销毁,每次创建都需要在堆中分配对象,等使用完毕后,这些对象需要等待GC回收。我们知道在Golang中使用三色标记法进行垃圾回收的,在回收期间会有一个短暂STW(stop the world)的时间段,这样就会导致程序性能下降。

那么能否实现类似数据库连接池这种效果,用来避免对象的频繁创建和销毁,达到尽可能的资源复用呢?为了实现这种需求,标准库中有了sync.Pool 这个数据结构。看名字很知道它是一个池。但是它和我们想象中的数据库连接池还是有些差别的。对于数据库连接池这种资源只要不手动释放就可以一直利用,但对于 sync.Pool 则不一样,主要是因为Pool里的对象是随时都有可能被销毁,即这些都 临时对象。只要进行了GC,就会出现对象销毁的情况。所以不用使用Pool当作数据库连接池。

Continue reading

Runtime: Golang同步原语Mutex源码分析

sync 包里提供了最基本的同步原语,如互斥锁 Mutex。除 OnceWaitGroup 类型外,大部分是由低级库提供的,更高级别的同步最好是通过 channel 通讯来实现。

Mutex 类型的变量默认值是未加锁状态,在第一次使用后,此值将不得复制,这点切记!!!

本文基于go version: 1.16.2

Mutex 锁实现了 Locker 接口。

// A Locker represents an object that can be locked and unlocked.
type Locker interface {
	Lock()
	Unlock()
}

锁的模式

为了互斥公平性,Mutex 分为 正常模式饥饿模式 两种。

正常模式

在正常模式下,等待者 waiter 会进入到一个FIFO队列,在获取锁时waiter会按照先进先出的顺序获取。当唤醒一个waiter 时它被并不会立即获取锁,而是要与新来的goroutine竞争,这种情况下新来的goroutine比较有优势,主要是因为它已经运行在CPU,可能它的数量还不少,所以waiter大概率下获取不到锁。在这种waiter获取不到锁的情况下,waiter会被添加到队列的前面。如果waiter获取不到锁的时间超出了1毫秒,它将被切换为饥饿模式。

这里的 waiter 是指新来一个goroutine 时会尝试一次获取锁,如果获取不到我们就视其为watier,并将其添加到FIFO队列里。

饥饿模式

在正常模式下,每次新来的goroutine都会抢走锁,就这会导致一些 waiter 永远也获取不到锁,产生饥饿问题。所以为了应对高并发抢锁场景下的公平性,官方引入了饥饿模式。

在饥饿模式下,锁将直接交给队列最前面的waiter。新来的goroutine即使在锁未被持有情况下也不会参与竞争锁,同时也不会进行自旋,而直接将其添加到队列的尾部。

如果拥有锁的waiter发现有以下两种情况,它将切换回正常模式:

  1. 它是队列里的最后一个waiter,再也没有其它waiter
  2. 等待时间小于1毫秒

模式区别

正常模式 拥有更好的性能,因为即使等待队列里有抢锁的 waiter,由于新来的goroutine 正在CPU中运行,所以优先获取到锁。
饥饿模式 是对公平性和性能的一种平衡,它避免了某些 goroutine 长时间的等待锁。在饥饿模式下,优先处理的是那些一直在等待的 waiter。饥饿模式在一定机时会切换回正常模式。

Continue reading

Golang什么时候会触发GC

Golang采用了三色标记法来进行垃圾回收,那么在什么场景下会触发这个GC动作呢?

源码主要位于文件 src/runtime/mgc.go go version 1.16

触发条件从大方面来说,分为 手动触发系统触发 两种方式。手动触发一般很少用,主要通过开发者调用 runtime.GC() 函数来实现,而对于系统自动触发是 运行时 根据一些条件自行维护的,这也正是本文要介绍的内容。

不管哪种触发方式,底层回收机制是一样的,所以我们先看一下手动触发,看看能否根据它来找GC触发所需的条件。

// src/runtime/mgc.go

// GC runs a garbage collection and blocks the caller until the
// garbage collection is complete. It may also block the entire
// program.
func GC() {
	n := atomic.Load(&work.cycles)

	// 等待上一轮的标记终止
	gcWaitOnMark(n)

	// We're now in sweep N or later. Trigger GC cycle N+1, which
	// will first finish sweep N if necessary and then enter sweep
	// termination N+1.
	// 触发GC
	gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})

	// Wait for mark termination N+1 to complete.
	// 等待本轮 标记终止
	gcWaitOnMark(n + 1)

	......
}

可以看到开始执行GC的是 gcStart() 函数,它有一个 gcTrigger 参数,是一个触发条件结构体,它的结构体也很简单。

Continue reading

Golang 基于信号的异步抢占与处理

在Go1.14版本开始实现了 基于信号的协程抢占调度 模式,在此版本以前执行以下代码是永远也无法执行完成。

本文基于go version 1.16

package main

import (
    "runtime"
    "time"
)

func main() {
    runtime.GOMAXPROCS(1)
    go func() {
        for {
        }
    }()

    time.Sleep(time.Millisecond)
    println("OK")
}

原因很简单:在main函数里只有一个CPU,从上到下执行到 time.Sleep() 函数的时候,会将 main goroutine 放出入运行队列,出让了P,开始执行匿名函数,但匿名函数是一个for循环,没有任何IO语句,也就无法引起对G的调度,所以当前仅有的一个P永远被其占用,导致无法打印OK。

这个问题在1.14版本开始有所改变,主要是因为引入了基于信号的抢占模式。在程序启动时,初始化信号,并在 runtime.sighandler 函数注册了 SIGURG 信号的处理函数 runtime.doSigPreempt,然后在触发垃圾回收的栈扫描时或执行 sysmon 监控线程,调用函数挂起goroutine,并向M发送信号,M收到信号后,会让当前goroutine陷入休眠继续执行其他的goroutine。

Continue reading

Golang 的调度策略之G的窃取

我们上篇文章(Golang 的底层引导流程/启动顺序)介绍了一个golang程序的启动流程,在文章的最后对于最重要的一点“调度“ (函数  schedule()) 并没有展开来讲,今天我们继续从源码来分析一下它的调度机制。

在此之前我们要明白golang中的调度主要指的是什么?在 src/runtime/proc.go 文件里有一段注释这样写到

// Goroutine scheduler
// The scheduler’s job is to distribute ready-to-run goroutines over worker threads.

这里指如何找一个已准备好运行的 G 关联到PM 让其执行。对于G 的调度可以围绕三个方面来理解:

  • 时机:什么时候关联(调度)。对于调度时机一般是指有空闲P的时候都会去找G执行
  • 对象:选择哪个G进行调度。这是我们本篇要讲的内容
  • 机制:如何调度。execute() 函数

理解了这三个问题,基本也就明白了它的调度策略了,本篇主要对G的获取。

源文件 src/runtime/proc.go , go version 1.15.6

Continue reading