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 的调度策略

我们上篇文章(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

Runtime: Golang是如何处理系统调用阻塞的?

我们知道在Golang中,当一个Goroutine由于执行 系统调用 而阻塞时,会将M从GPM中分离出去,然后P再找一个G和M重新执行,避免浪费CPU资源,那么在内部又是如何实现的呢?今天我们还是通过访问Runtime源码的形式来看下他的内部实现细节有哪些?

go version 1.15.6

我们知道一个P有四种运行状态,而当执行系统调用函数阻塞时,会从 _Prunning 状态切换到 _Psyscall,等系统调用函数执行完毕后再切换回来。

P的状态切换
P的状态切换

从上图我们可以看出 P 执行系统调用时会执行 entersyscall() 函数(另还有一个类似的阻塞函数 entersyscallblock() ,注意两者的区别)。当系统调用执行完毕切换回去会执行 exitsyscall() 函数,下面我们看一下这两个函数的实现。

Continue reading

Runtime: 当一个goroutine 运行结束后会发生什么

上一篇我们介绍了创建一个goroutine 会经历些什么,今天我们再看下当一个goroutine 运行结束的时候,又会发生什么?

go version 1.15.6。

主要源文件为 src/runtime/proc.go

当一个goroutine 运行结束的时候,默认会执行一个 goexit1() 的函数,这是一个只有八行代码的函数,其中最后以通过 mcall() 调用 goexit0 函数结束。因此我们主要关注 goexit0 函数即可。这里为了更好让大家理解,以此之前需要先介绍一下 mcall() 函数。

stubs.go 源文件中只是对 mcall() 函数进行了声明,并无函数体,说明这个函数是使用汇编实现的。但这里我们并不需要关心它的具体实现,只要知道它的主要工作职责就可以了,所有信息我们都可以通过函数注释得知。

// mcall switches from the g to the g0 stack and invokes fn(g),
// where g is the goroutine that made the call.

// mcall函数用来从 g 切换到 g0 栈并调用 fn(g)函数,这里的 g 指发起调用的那个goroutine。


// mcall saves g's current PC/SP in g->sched so that it can be restored later.
// It is up to fn to arrange for that later execution, typically by recording
// g in a data structure, causing something to call ready(g) later.

// mcall 会将当前 g 的 PC/SP 信息到 g->sched,以便以后恢复执行
// 由 fn 函数来安排稍后的执行,一般是通过将g记录到一个数据结构中,以后再通过调用 ready(g) 进行唤醒

// mcall returns to the original goroutine g later, when g has been rescheduled.
// fn must not return at all; typically it ends by calling schedule, to let the m
// run other goroutines.

// 当 g 被重新调度后,mcall将返回原来的 g
// fn 不能返回,通常是在它调度结束后,直接让 m 运行其它的 goroutine

//
// mcall can only be called from g stacks (not g0, not gsignal).

// mcall 只能从 g 栈中发起调用,不能是 g0 或 gsignal

//
// This must NOT be go:noescape: if fn is a stack-allocated closure,
// fn puts g on a run queue, and g executes before fn returns, the
// closure will be invalidated while it is still executing.

// fn 必须是 go:noescape(非逃逸函数)
// 如果 fn 是堆栈分配的一个闭包函数, 则 fn 函数将 g 放在一个runqueue 中,g并在fn返回之前执行,当仍在执行期间闭包将失效

func mcall(fn func(*g))

从上面的注释可看出来,mcall() 函数主要有来实现从 gg0 的切换,对特殊的g0的切换过程,我们在 g0 特殊的goroutine 文章里已经讲过,每次 g 之间的切换都需要经过g0

Continue reading

Runtime: 创建一个goroutine都经历了什么?

我们都知道goroutine的在golang中发挥了很大的作用,那么当我们创建一个新的goroutine时,它是怎么一步一步创建的呢?都经历了哪些操作呢?今天我们通过源码来剖析一下创建goroutine都经历了些什么?go version 1.15.6

对goroutine最关键的两个函数是 newproc()newproc1(),而 newproc1() 函数是我们最需要关注的。

函数 newproc()

我们先看一个简单的创建goroutine的例子,找出来创建它的函数。

package main

func start(a, b, c int64) {
	_ = a + b + c
}

func main() {
	go start(7, 2, 5)
}
Continue reading