type
status
date
slug
summary
tags
category
icon
password

Goroutine


操作系统会为该应用程序创建一个进程。
作为一个应用程序,它像一个为所有资源而运行的容器。这些资源包括内存地址空间、文件句柄、设备和线程。 线程是操作系统调度的一种执行路径,用于在处理器执行我们在函数中编写的代码。
一个进程从一个线程开始,即主线程,当该线程终止时,进程终止。这是因为主线程是应用程序的原点。然后,主线程可以依次启动更多的线程,而这些线程可以启动更多的线程。 无论线程属于哪个进程,操作系统都会安排线程在可用处理器上运行。每个操作系统都有自己的算法来做出这些决定。
 
Go 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutine 在绑定到单个操作系统线程的逻辑处理器中运行(P)。即使使用这个单一的逻辑处理器和操作系统线程,也可以调度数十万 goroutine 以惊人的效率和性能并发运行。
Concurrency is not Parallelism. 并发不是并行。并行是指两个或多个线程同时在不同的处理器执行代码。如果将运行时配置为使用多个逻辑处理器,则调度程序将在这些逻辑处理器之间分配 goroutine,这将导致 goroutine 在不同的操作系统线程上运行。但是,要获得真正的并行性,您需要在具有多个物理处理器的计算机上运行程序。否则,goroutine 将针对单个物理处理器并发运行,即使 Go 运行时使用多个逻辑处理器。
 
空的select语句将永远阻塞
如果你的 goroutine 在从另一个 goroutine 获得结果之前无法取得进展,那么通常情况下,你自己去做这项工作比委托它( go func() )更简单。 这通常消除了将结果从 goroutine 返回到其启动器所需的大量状态跟踪和 chan 操作。
 

API对比

  1. 将目录读取到一个 slice 中,然后返回整个切片,或者如果出现错误,则返回错误。这是同步调用的,ListDirectory 的调用方会阻塞,直到读取所有目录条目。根据目录的大小,这可能需要很长时间,并且可能会分配大量内存来构建目录条目名称的 slice。
  1. ListDirectory 返回一个 chan string,将通过该 chan 传递目录。当通道关闭时,这表示不再有目录。由于在 ListDirectory 返回后发生通道的填充,ListDirectory 可能内部启动 goroutine 来填充通道。
 
ListDirectory chan存在的问题:
  1. 通过使用一个关闭的通道作为不再需要处理的项目的信号,ListDirectory 无法告诉调用者通过通道返回的项目集不完整,因为中途遇到了错误。调用方无法区分空目录与完全从目录读取的错误之间的区别。这两种方法都会导致从 ListDirectory 返回的通道会立即关闭。
  1. 调用者必须持续从通道读取,直到它关闭,因为这是调用者知道填充 chan 的 goroutine 已经停止的唯一方法。这对 ListDirectory 的使用是一个严重的限制,调用者必须花时间从通道读取数据,即使它可能已经收到了它想要的答案。对于大中型目录,它可能在内存使用方面更为高效,但这种方法并不比原始的基于 slice 的方法快。

Never start a goroutine without knowning when it will stop

优雅的控制多线程生命周期一致性 ( application lifecycle ) :
TIP: `fmt.fatal(xxxx)` 会调用os.Exit(), 不会调用defer; Only 测试的时候或者在 init , main 中才可调用
 

Memory Model

golang内存模型参考:
如何保证在一个 goroutine 中看到在另一个 goroutine 修改的变量的值,如果程序中修改数据时有其他 goroutine 同时读取,那么必须将读取串行化。为了串行化访问,请使用 channel 或其他同步原语,例如 sync 和 sync/atomic 来保护数据。

Happen-Before

在一个 goroutine 中,读和写一定是按照程序中的顺序执行的。即编译器和处理器只有在不会改变这个 goroutine 的行为时才可能修改读和写的执行顺序。由于重排,不同的 goroutine 可能会看到不同的执行顺序。例如,一个goroutine 执行 a = 1;b = 2;,另一个 goroutine 可能看到 b 在 a 之前更新。
notion image

Memory Reordering

指令重排- 在多核心场景下,没有办法轻易的判断两段程序是否等价;
notion image
notion image
当一个请求被取消或超时时,处理该请求的所有 goroutine 都应该快速退出(fail fast),这样系统就可以回收它们正在使用的任何资源。
为了说明读和写的必要条件,我们定义了先行发生(Happens Before)。如果事件 e1 发生在 e2 前,我们可以说 e2 发生在 e1 后。如果 e1不发生在 e2 前也不发生在 e2 后,我们就说 e1 和 e2 是并发的。
在单一的独立的 goroutine 中先行发生的顺序即是程序中表达的顺序。
当下面条件满足时,对变量 v 的读操作 r 是被允许看到对 v 的写操作 w 的:
  1. r 不先行发生于 w
  1. 在 w 后 r 前没有对 v 的其他写操作
为了保证对变量 v 的读操作 r 看到对 v 的写操作 w,要确保 w 是 r 允许看到的唯一写操作。即当下面条件满足时,r 被保证看到 w
  1. w 先行发生于 r
  1. 其他对共享变量 v 的写操作要么在 w 前,要么在 r 后。
这一对条件比前面的条件更严格,需要没有其他写操作与 w 或 r 并发发生。
 
单个 goroutine 中没有并发,所以上面两个定义是相同的: 读操作 r 看到最近一次的写操作 w 写入 v 的值。 当多个 goroutine 访问共享变量 v 时,它们必须使用同步事件来建立先行发生这一条件来保证读操作能看到需要的写操作。
  1. 对变量 v 的零值初始化在内存模型中表现的与写操作相同。
  1. 对大于 single machine word 的变量的读写操作表现的像以不确定顺序对多个 single machine word 的变量的操作。
 
注: 指针切换是原子的, 比如利用 COW( copy on write )机制拷贝一个map, 然后通过指针切换的方式进行重新复制, 从而达到切换的效果,; 但是上述操作虽然满足了原子性, 但是可能不会满足可见性( store buffer ); 在多线程编程过程中,可能导致不可预知的问题; slice, interface均存在类似的问题

Reference

Go的内存模型
如何保证在一个goroutine中看到在另一个goroutine修改的变量的值,这篇文章进行了详细说明。 如果程序中修改数据时有其他goroutine同时读取,那么必须将读取串行化。为了串行化访问,请使用channel或其他同步原语,例如sync和sync/atomic来保护数据。 在一个gouroutine中,读和写一定是按照程序中的顺序执行的。即编译器和处理器只有在不会改变这个goroutine的行为时才可能修改读和写的执行顺序。由于重排,不同的goroutine可能会看到不同的执行顺序。例如,一个goroutine执行 a = 1;b = 2;,另一个goroutine可能看到 b在 a 之前更新。 为了说明读和写的必要条件,我们定义了 先行发生(Happens Before)--Go程序中执行内存操作的偏序。如果事件 e1发生在 e2前,我们可以说 e2发生在 e1后。如果 e1不发生在 e2前也不发生在 e2后,我们就说 e1和 e2 是并发的。 1 r不先行发生于w 2 在w后r前没有对v的其他写操作 1 w先行发生于r 2 其他对共享变量v的写操作要么在w前,要么在r后。 这一对条件比前面的条件更严格,需要没有其他写操作与w或r并发发生。 单独的goroutine中没有并发,所以上面两个定义是相同的:读操作看到最近一次的写操作写入的值。当多个goroutine访问共享变量时,它们必须使用同步事件来建立先行发生这一条件来保证读操作能看到需要的写操作。 对变量的零值初始化在内存模型中表现的与写操作相同。 对大于一个字的变量的读写操作表现的像以不确定顺序对多个一字大小的变量的操作。 程序的初始化在单独的goroutine中进行,但这个goroutine可能会创建出并发执行的其他goroutine。 如果包p引入(import)包q,那么q的init函数的结束先行发生于p的所有init函数开始 main.main函数的开始发生在所有init函数结束之后 go 关键字开启新的goroutine,先行发生于这个goroutine开始执行,例如下面程序: a string func f() { print(a) } func hello()
Go的内存模型
 

Package sync

传统的线程模型(通常在编写 Java、C++ 和Python 程序时使用)程序员在线程之间通信需要使用共享内存。通常,共享数据结构由锁保护,线程将争用这些锁来访问数据。在某些情况下,通过使用线程安全的数据结构(如 Python 的Queue),这会变得更容易。
Go 的并发原语 goroutines 和 channels 为构造并发软件提供了一种优雅而独特的方法。Go 没有显式地使用锁来协调对共享数据的访问,而是鼓励使用 chan 在 goroutine 之间传递对数据的引用。这种方法确保在给定的时间只有一个 goroutine 可以访问数据。
Do not communicate by sharing memory; instead, share memory by communicating.

Race detector

data race: 是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。
race detector(竞争检测器): 在构建过程中内置到程序中的代码, 用于检测并报告发现的任何竞争条件.
命令:
 

Interface说明

Go memory model 提到过: 写入单个 machine word 将是原子的,但 interface 内部是是两个 machine word 的值。另一个goroutine 可能在更改接口值时观察到它的内容。 如果是一个普通的指针、map、slice 可以安全的更新吗? 没有安全的 data race(safe data race)。您的程序要么没有 data race,要么其操作未定义。
  • 原子性
  • 可见性

sync.atomic

go中的同步语义:
  1. Mutex
  1. RWMutex
  1. Atomic
可以根据下面的demo对语义加深理解:
示例代码:
源码示例-syncatomic_test.go (2)
benchmark结果:

Copy-On-Write

Copy-On-Write 思路在微服务降级或者 local cache 场景中经常使用。写时复制指的是,写操作时候复制全量老数据到一个新的对象中,携带上本次新写的数据,之后利用原子替换(atomic.Value),更新调用者的变量。来完成无锁访问共享数据。
notion image

Mutex

实现模式:
  1. Barging: 这种模式是为了提高吞吐量, 当锁被释放时, 它会唤醒第一个等待者,然后把锁给第一个等待者或者给第一个请求锁的人;
  1. Handsoff: 当锁释放时候,锁会一直持有直到第一个等待者准备好获取锁。它降低了吞吐量,因为锁被持有,即使另一个 goroutine 准备获取它。 一个互斥锁的 handsoff 会完美地平衡两个goroutine 之间的锁分配,但是会降低性能,因为它会迫使第一个 goroutine 等待锁
  1. Spinning. 自旋在等待队列为空或者应用程序重度使用锁时效果不错。parking 和 unparking goroutines 有不低的性能成本开销,相比自旋来说要慢得多
 
Go 1.8 使用了 Barging 和 Spining 的结合实现。当试图获取已经被持有的锁时,如果本地队列为空并且 P 的数量大于1,goroutine 将自旋几次(用一个 P(runtime中的一个工作队列) 旋转会阻塞程序)。自旋后,goroutine park。在程序高频使用锁的情况下,它充当了一个快速路径。 Go 1.9 通过添加一个新的饥饿模式来解决先前解释的问题,该模式将会在释放时候触发 handsoff。所有等待锁超过一毫秒的 goroutine(也称为有界等待)将被诊断为饥饿。当被标记为饥饿状态时,unlock 方法会 handsoff 把锁直接扔给第一个等待者。 在饥饿模式下,自旋也被停用,因为传入的goroutines 将没有机会获取为下一个等待者保留的锁。

Errgroup

源码参考:
我们把一个复杂的任务,尤其是依赖多个微服务 rpc 需要聚合数据的任务,分解为依赖和并行,依赖的意思为: 需要上游 a 的数据才能访问下游 b 的数据进行组合。但是并行的意思为: 分解为多个小任务并行执行,最终等全部执行完毕。
核心原理: 利用 sync.Waitgroup 管理并行执行的goroutine
  1. 并行工作流
  1. 错误处理 或者 优雅降级
  1. context 传播和取消
  1. 利用局部变量 + 闭包

sync.Pool

sync.Pool 的场景是用来保存和复用临时对象,以减少内存分配,降低 GC 压力(Request-Driven 特别合适)。
Get 返回 Pool 中的任意一个对象。如果 Pool 为空,则调用 New 返回一个新创建的对象。 放进 Pool 中的对象,会在说不准什么时候被回收掉(不适合用于做连接池)。所以如果事先 Put 进去 100 个对象,下次 Get 的时候发现 Pool 是空也是有可能的。不过这个特性的一个好处就在于不用担心 Pool 会一直增长,因为 Go 已经帮你在 Pool 中做了回收机制。 这个清理过程是在每次垃圾回收之前做的。之前每次GC 时都会清空 pool,而在1.13版本中引入了 victim cache,会将 pool 内数据拷贝一份,避免 GC 将其清空,即使没有引用的内容也可以保留最多两轮 GC。
 
notion image
notion image
ring buffer(定长 FIFO) + 双向链表的方式,头部只能写入,尾部可以并发读取

Channels

channels 是一种类型安全的消息队列,充当两个 goroutine 之间的管道,将通过它同步的进行任意资源的交换。chan 控制 goroutines 交互的能力从而创建了 Go 同步机制。
当创建的 chan 没有容量时,称为无缓冲通道。
使用容量创建的 chan 称为缓冲通道。
要了解通过 chan 交互的 goroutine 的同步行为是什么,我们需要知道通道的类型和状态。
根据我们使用的是无缓冲通道还是缓冲通道,场景会有所不同,所以让我们单独讨论每个场景。

Unbuffered Channels

无缓冲 chan 没有容量,因此进行任何交换前需要两个 goroutine 同时准备好。
当 goroutine 试图将一个资源发送到一个无缓冲的通道并且没有goroutine 等待接收该资源时,该通道将锁住发送 goroutine 并使其等待。
当 goroutine 尝试从无缓冲通道接收,并且没有 goroutine 等待发送资源时,该通道将锁住接收 goroutine 并使其等待。 无缓冲信道的本质是保证同步。

特性

  1. Receive 先于 send 发生 —(发送者和接受者均准备好)
  1. 好处: 100% 保证能收到
  1. 代价: 延迟时间未知

参考

Buffered Channels

buffered channel 具有容量,因此其行为可能有点不同。
当 goroutine 试图将资源发送到缓冲通道,而该通道已满时,该通道将锁住 goroutine并使其等待缓冲区可用。
如果通道中有空间,发送可以立即进行,goroutine 可以继续。当goroutine 试图从缓冲通道接收数据,而缓冲通道为空时,该通道将锁住 goroutine 并使其等待资源被发送。
 
在 chan 创建过程中定义的缓冲区大小可能会极大地影响性能。
我将使用密集使用 chan 的扇出模式来查看不同缓冲区大小的影响。在我们的基准测试中,一个 producer 将在通道中注入百万个整数元素,而5个 worker 将读取并将它们追加到一个名为 total 的结果变量中。
示例代码:
测试结果

特性

  1. Send 先于 Receive 发生。
  1. 好处: 延迟更小。
  1. 代价: 不保证数据到达,越大的 buffer,越小的保障到达。buffer = 1 时,给你延迟一个消息的保障。

Go Concurrency Patterns

  • Timing out
  • Moving on
  • Pipeline
  • Fan-out, Fan-in
  • Cancellation
    • Close 先于 Receive 发生(类似 Buffered)。
    • 不需要传递数据,或者传递 nil。
    • 非常适合取消和超时控制。

Contex

Design Philosophy

  1. If any given Send on a channel CAN cause the sending goroutine to block:
    1. Not allowed to use a Buffered channel larger than 1.
      1. Buffers larger than 1 must have reason/measurements.
    2. Must know what happens when the sending goroutine blocks.
  1. If any given Send on a channel WON’T cause the sending goroutine to block:
    1. You have the exact number of buffers for each send.
      1. Fan Out pattern
    2. You have the buffer measured for max capacity.
      1. Drop pattern
  1. Less is more with buffers.
    1. Don’t think about performance when thinking about buffers.
    2. Buffers can help to reduce blocking latency between signaling.
      1. Reducing blocking latency towards zero does not necessarily mean better throughput.
      2. If a buffer of one is giving you good enough throughput then keep it.
      3. Question buffers that are larger than one and measure for size.
      4. Find the smallest buffer possible that provides good enough throughput.

Package Context

Request-scoped context

在 Go 服务中,每个传入的请求都在其自己的goroutine 中处理。
请求处理程序通常启动额外的 goroutine 来访问其他后端,如数据库和 RPC 服务。
处理请求的 goroutine 通常需要访问特定于请求(request-specific context)的值,例如最终用户的身份、授权令牌和请求的截止日期(deadline)。
notion image
Go 1.7 引入一个 context 包,它使得跨 API 边界的请求范围元数据、取消信号和截止日期很容易传递给处理请求所涉及的所有 goroutine(显示传递)。
notion image
其他语言: Thread Local Storage(TLS),XXXContext

如何将 context 集成到 API 中?

在将 context 集成到 API 中时,要记住的最重要的一点是,它的作用域是 请求级别的
例如,沿单个数据库查询存在是有意义的,但沿数据库对象存在则没有意义。
目前有两种方法可以将 context 对象集成到 API 中:
  1. The first parameter of a function call 首参数传递 context 对象,比如,参考 net 包 Dialer.DialContext。此函数执行正常的 Dial 操作,但可以通过 context 对象取消函数调用。
notion image
  1. Optional config on a request structure 在第一个 request 对象中携带一个可选的 context 对象。例如 net/http 库的 Request.WithContext,通过携带给定的 context 对象,返回一个新的 Request 对象。
notion image

不要将一个context直接放在一个结构体中

  • Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:
notion image
  • Incoming requests to a server should create a Context.
使用 context 的一个很好的心智模型是它应该在程序中流动,应该贯穿你的代码。
这通常意味着您不希望将其存储在结构体之中。
它从一个函数传递到另一个函数,并根据需要进行扩展。
理想情况下,每个请求都会创建一个 context 对象,并在请求结束时过期。 不存储上下文的一个例外是,当您需要将它放入一个结构中时,该结构纯粹用作通过通道传递的消息。如下例所示。
notion image

context.WithValue

context.WithValue 内部基于 valueCtx 实现:
notion image
为了实现不断的 WithValue,构建新的 context,内部在查找 key 时候,使用递归方式不断从当前,从父节点寻找匹配的 key,直到 root context(Backgrond 和 TODO Value 函数会返回 nil)。
notion image
notion image
WithCancel(ctx) 参数 ctx 认为是 parent ctx,在内部会进行一个传播关系链的关联。

替换一个context 一定是使用 WithCancel, WithDeadline, Withtimeout, or WithValue来生成新的context, 继续向下传递

比如我们新建了一个基于 context.Background() 的 ctx1,携带了一个 map 的数据,map 中包含了 “k1”: “v1” 的一个键值对, ctx1 被两个 goroutine 同时使用作为函数签名传入:
如果我们修改了 这个map,会导致另外进行读 context.Value 的 goroutine 和修改 map 的 goroutine,在 map 对象上产生 data race。
因此我们要使用 copy-on-write 的思路,解决跨多个 goroutine 使用数据、修改数据的场景。

当一个context被取消时, 所有派生自这个context的context也都会被取消

当一个 context 被取消时,从它派生的所有 context 也将被取消。
Done() 返回 一个 chan,当我们取消某个parent context, 实际上上会递归层层 cancel 掉自己的 child context 的 done chan 从而让整个调用链中所有监听 cancel 的 goroutine 退出。
notion image
notion image
 

All blocking/long operations should be cancelable

如果要实现一个超时控制,通过上面的 context 的 parent/child 机制,其实我们只需要启动一个定时器,然后在超时的时候,直接将当前的 context 给 cancel 掉,就可以实现监听在当前和下层的额 context.Done() 的 goroutine 的退出。

Final Notes

  1. 如果是针对一个服务的每一个接收的请求, 都应该在接收请求后立马创建一个context;
  1. 调用其他服务也应该接收一个context; (上游有一定的生命周期管理义务)
  1. 不要在结构体中存储一个context, 你需要显示的通过函数去传递它;
  1. 一系列的函数调用链其实可以通过context关联起来, 可以级联取消或唤醒;
  1. 替换一个context,通过 WithCancel, WithDeadline, WithTimeout, or WithValue 实现;
  1. 当一个context被取消时, 所有派生自这个context的context也都会被取消;
  1. context是可以被多个goroutine并发去使用的, 并且它是安全的;
  1. 即是一个方法允许传递nil, 但是也不要轻易传递一个 nil context, 如果你不确定的话可以使用 TODO context ;
  1. Context的值仅用于传递 请求上下文级别的数据, 他通过这样一种方式透明传递到各个goroutine而不是作为一个函数的可选参数来使用的;
  1. 所有阻塞的/长调用操作应该是能被取消的;
  1. Context.Value不应该用于程序的控制流设计;
  1. 尽量不要使用context.Value

References

 
工程化实践go语言实践-error
Loading...
leiax00
leiax00
让每一天都有意义🍚
最新发布
异地组网-zerotier
2025-4-10
apt/snap常用操作
2025-4-7
ubuntu操作备忘录
2025-4-1
git常用操作
2025-4-1
小智聊天机器人使用记录
2025-3-31
k8s/docker常用操作
2025-3-26
公告