普通视图

发现新文章,点击刷新页面。
昨天以前编程沉思录

高性能服务之优雅终止

作者 cyhone
2021年3月18日 20:55

「优雅终止」指的是当服务需要下线或者重启时,通过一些措施和手段,一方面能够让其他服务尽快的感知到当前服务的下线,另一方面也尽量减小对当前正在处理请求的影响。优雅终止可提升服务的高可用,减少下线造成的服务抖动,提升服务稳定性和用户体验。

下线服务不仅仅是运维层面的工作,需要整个 RPC 实现、服务架构以及运维体系的配合,才能完美的实现服务的优雅下线。本文将基于服务下线的整个流程,分析如何实现微服务的优雅终止。主要包含以下方面:

  • 服务注册中心的主动下线
  • 基于 gRPC-Go 的源码,分析 gRPC 如何实现优雅终止
  • 探讨 k8s 的优雅终止

服务注册中心的主动下线

如果服务使用了服务注册中心(例如 Consul、etcd 等),那第一步就是首先将服务从注册中心下线。这样可以尽快保证新的请求不会打到这台节点上。

虽然绝大部分的服务注册中心都有节点的心跳和超时自动清理的机制,但是心跳也是有固定间隔的,注册中心需要等到预设的心跳超时后才能发现节点的下线。因此,主动下线可以极大缩短这个异常发现的过程。

如果服务是基于 k8s 进行管理和调度,那这件事情就做起来非常方便了。

首先,k8s 本身自带了一个可靠的 服务发现,在 k8s 上进行 pod 的上下线,k8s 自然都会第一时间感知到。

如果使用的是外置的名字服务,则可以使用 k8s 的 preStop 功能。k8s 原生支持了 容器生命周期回调, 我们可以定义 pod 的 preStop 钩子,来实现服务下线前的清理操作。如下:

例如:

1
2
3
4
5
6
7
containers:
- name: my-app-container
image: my-app-image
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","/app/pre_stop.sh"]

pod在下线之前,首先会执行 /app/pre_stop.sh 命令,在这个命令中,我们可以做很多预清理策略。

RPC 的优雅终止

将服务节点从名字服务中摘除,可以阻挡新流量进入到该节点,这是优雅终止的第一步。但是,对于该节点上已建立的客户端连接,如果贸然下线,将会造成正在的业务逻辑的突然中止。因此,我们需要实现RPC级别的,对连接和请求处理进行优雅终止,以保证业务逻辑尽量少的受到影响。

以 gRPC-Go 为例,gRPC 实现了两个停止接口 GracefulStopStop,分别代表服务的优雅终止和非优雅终止。我们来看下 gRPC 是如何优雅终止的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (s *Server) GracefulStop() {
s.quit.Fire()
defer s.done.Fire()

...
s.mu.Lock()

// 首先关闭监听 socket,保证不会有新的连接到来
for lis := range s.lis {
lis.Close()
}

s.lis = nil
if !s.drain {
for st := range s.conns {
st.Drain()
}
s.drain = true
}

// Wait for serving threads to be ready to exit. Only then can we be sure no
// new conns will be created.
s.mu.Unlock()
s.serveWG.Wait()
s.mu.Lock()

for len(s.conns) != 0 {
s.cv.Wait()
}
...
s.mu.Unlock()
}
  • 第一步: 调用 s.quit.Fire()。当该语句执行后,gRPC对于所有新 Accept 到来的连接,都会直接丢弃。
  • 第二步: 逐个调用 lis.Close()。关闭监听 Socket,这样将不会再有新连接到达。
  • 第三步: 对已建立的连接,逐个调用 st.Drain()。由于 gRPC 是基于 HTTP2 实现,因此这里将会应用到 HTTP2 的 goAway帧。
    goAway 帧相当于服务器端主动给客户端发送的连接关闭的信号,客户端收到这个信号后,将会关闭该连接上所有的 HTTP2 的流。这样客户端侧可以主动感知到连接关闭,同时不会继续发送新的请求过来。
  • 第四步: 调用 s.serveWG.Wait()。保证 gRPC 的 Serve 函数已正常退出。
  • 第五步: 调用 s.cv.Wait()。这个逻辑用于等待所有已建立连接的业务处理逻辑的正常结束。这样就不会因为服务的突然关闭,造成业务逻辑的异常。

以上就是 gRPC 的优雅终止过程。简单来说,gRPC 需要从外至内的保证了各层逻辑的正常关闭。

但是,这里有个问题可能容易忽视。最后一步调用 s.cv.Wait(),用来等待业务处理逻辑的正常结束。但这里可能有异常情况是,如果业务逻辑由于代码 bug,发生了死锁或者死循环,那么业务逻辑将永远无法结束,s.cv.Wait() 也将会一直卡住。这样,GracefulStop 也将永远无法结束。

针对于这个问题,需要配合外置的部署系统,对服务进行强行的超时终止。接下来,我们看下 k8s 是如何实现这一点的。

k8s 的优雅终止

在 k8s 下线 pod 之前,集群并不会强制的杀死 pod,而是需要执行一系列步骤才会让 pod 体面的下线。

  1. 检查 pod 的生命周期,如果配置有 preStop,则先执行 preStop 钩子。我们可以做一些预先清理和服务注册中心下线等工作。
  2. 向 pod 发送 SIGTERM 信号。SIGTERM 其实就对应于 linux 命令 kill -15。这就需要 RPC 自行监听 SIGTERM 信号,一旦收到信号,即可执行优雅终止。
  3. 等待一段时间,如果 pod 依然没有自行停止,则向 pod 发送 SIGKILL 信号,相当于 linux 命令 kill -9,pod 将被强行终止。而等待的时长取决于 pod 配置的优雅终止时间 terminationGracePeriodSeconds 参数,默认为 30 秒。

这个时候,突然想到了《让子弹飞》里面的一句话:“黄老爷是个体面人,他要是体面,你就让他体面。他要是不体面,你就帮他体面。”

k8s 允许 pod 体面的下线,如果 pod 不体面,那么就强行让他体面的下线。

优雅终止的流程总结

以上内容分别讲了如何在各个方面实现服务的优雅终止,总结下整个优雅终止的流程:

  1. 首先将服务节点主动从服务注册中心下线,保证服务注册中心。如果服务基于 k8s 进行调度和管理,可使用 preStop 回调进行服务注册中心的下线。
  2. RPC需要实现一整套的优雅终止逻辑。保证现有业务逻辑尽量不受损。
  3. k8s等待pod优雅终止期过后,强制停止pod。

基于以上一整套流程,可以实现服务的优雅终止, 这对于无状态服务来说基本已经够用了。但对于有状态服务,优雅终止的挑战会更难一些。TiDB 这里有一篇文章,讲述了 有状态分布式应用的优雅终止挑战,有兴趣的同学可以扩展看一下。

本文主要讲了服务的优雅终止,那么既然有优雅终止,那同时也会对应服务的优雅启动。服务的优雅终止是从外至內的,首先关闭掉最外层的流量进入,再逐步向内停止逻辑。而优雅启动要从内至外地保证各层逻辑正常打开,才能完成最终的上线。

参考

一致性 Hash 原理及 GroupCache 源码分析

作者 cyhone
2021年2月21日 11:55

一致性 Hash 常用于缓解分布式缓存系统扩缩容节点时造成的缓存大量失效的问题。一致性 Hash 与其说是一种 Hash 算法,其实更像是一种负载均衡策略。

GroupCache 是 golang 官方提供的一个分布式缓存库,其中包含了一个简单的一致性 Hash 的实现。其代码在 github.com/golang/groupcache/consistenthash。本文将会基于 GroupCache 的一致性 Hash 实现,深入剖析一致性 Hash 的原理。

本文会着重探讨以下几点内容:

  1. 传统的 Hash 式负载均衡在集群扩缩容时面临的缓存失效问题。
  2. 一致性 Hash 的原理。
  3. Golang 的开源库 GroupCache 如何实现一致性 Hash。

集群扩缩容导致缓存的问题

我们先看下传统的 Hash 式负载均衡,当集群扩缩容时会遇到哪些问题。

假设我们有三台缓存服务器,每台服务器用于缓存一部分用户的信息。最常见的 Hash 式负载均衡做法是:对于指定用户,我们可以对其用户名或者其他唯一信息计算 hash 值,然后将该 hash 值对 3 取余,得到该用户对应的缓存服务器。如下图所示:

而当我们需要对集群进行扩容或者缩容时,增加或者减少部分服务器节点,将会带来大面积的缓存失效。

例如需要扩容一台服务器,即由 3 台缓存服务器增加为 4 台,那么之前 hash(username) % 3 这种策略,将变更为 hash(username) % 4。整个负载均衡的策略发生了彻底的变化,对于任何一个用户都会面临Hash失效的风险。

而一旦缓存集体失效,所有请求无法命中缓存,直接打到后端服务上,系统很有可能发生崩溃。

一致性 Hash 的原理

针对以上问题,如果使用一致性 Hash 作为缓存系统的负载均衡策略,可以有效缓解集群扩缩容带来的缓存失效问题。

相比于直接对 hash 取模得到目标 Server 的做法,一致性 Hash 采用 有序 Hash 环 的方式选择目标缓存 Server。如下图所示:

对于该有序 Hash 环,环中的每个节点对应于一台缓存 Server,同时每个节点也包含一个整数值。各节点按照该整数值从小到大依次排列。

对于指定用户来说,我们依然首先出计算用户名的 hash 值。接着,在 Hash 环中按照值大小顺序,从小到大依次寻找,找到 第一个大于等于该 hash 值的节点,将其作为目标缓存 Server。

例如,我们 hash 环中的三个节点 Node-ANode-BNode-C 的值依次为 3、7、13。假设对于某个用户来说,我们计算得到其用户名的 hash 值为 9,环中第一个大于 9 的节点为 Node-C,则选用 Node-C 作为该用户的缓存 Server。

缓存失效的缓解

以上就是正常情况下一致性 Hash 的使用,接下来我们看下,一致性 Hash 是如何应对集群的扩缩容的。

当我们对集群进行扩容,新增一个节点 New-Node, 假设该节点的值为 11。那么新的有序 Hash 环如下图所示:

我们看下此时的缓存失效情况:在这种情况下, 只会造成 hash 值范围在 Node-BNewNode 之间(即(7, 11])的数据缓存失效。这部分数据原本分配到节点 Node-C(值为 13),现在都需要迁移到新节点 NewNode 上。

而原本分配到 Node-ANode-B 两个节点上的缓存数据,不会受到任何影响。之前值范围在 NewNodeNode-B 之间(即(11, 13])的数据,被分配到了 Node-C 上面。新节点出现后,这部分数据依然属于 Node-C,也不会受到任何影响。

一致性 Hash 利用有序 Hash 环,巧妙的缓解了集群扩缩容造成的缓存失效问题。注意,这里说的是 “缓解”,缓存失效问题无法完全避免,但是可以将其影响降到最低。

这里有个小问题是,因为有序 Hash 环需要其中每个节点有持有一个整数值,那这个整数值如何得到呢?一般做法是,我们可以利用该节点的特有信息计算其 Hash 值得到, 例如 hash(ip:port)

数据倾斜与虚拟节点

以上介绍了一致性 hash 的基本过程,这么看来,一致性 hash 作为缓解缓存失效的手段,的确是行之有效的。

但我们考虑一个极限情况,假设整个集群就两个缓存节点: Node-ANode-B。则 Node-B 中将存放 Hash 值范围在 (Node-A, Node-B] 之间的数据。而 Node-A 将承担两部分的数据: hash < Node-Ahash > Node-B

从这个值范围,我们可以轻易的看出,Node-A 的值空间实际上远大于 Node-B。当数据量较大时,Node-A 承担的数据也将远超于 Node-B。实际上,当节点过少时,很容易出现分配给某个节点的数据远大于其他节点。这种现象我们往往称之为 “数据倾斜”。

对于此类问题,我们可以引入虚拟节点的概念,或者说是副本节点。每个真实的缓存 Server 在 Hash 环上都对应多个虚拟节点。如下图所示:

对于上图来说,我们其实依然只有三个缓存 Server。但是每个 Server 都有一个副本,例如 V-Node-ANode-A 都对应同一个缓存 Server。

GroupCache 的一致性 Hash 实现

GroupCache 提供了一个简单的一致性 hash 的实现。其代码在 github.com/golang/groupcache/consistenthash

我们先看下它的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import (
"fmt"
"github.com/golang/groupcache/consistenthash"
)

func main() {
// 构造一个 consistenthash 对象,每个节点在 Hash 环上都一共有三个虚拟节点。
hash := consistenthash.New(3, nil)

// 添加节点
hash.Add(
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
)

// 根据 key 获取其对应的节点
node := hash.Get("cyhone.com")

fmt.Println(node)
}

consistenthash 对外提供了三个函数:

  1. New(replicas int, fn Hash):构造一个 consistenthash 对象,replicas 代表每个节点的虚拟节点个数,例如 replicas 等于 3,代表每个节点在 Hash 环上都对应有三个虚拟节点。fn 代表自定义的 hash 函数,传 nil 则将会使用默认的 hash 函数。
  2. Add 函数:向 Hash 环上添加节点。
  3. Get 函数:传入一个 key,得到其被分配到的节点。

Add 函数

我们先看下其 Add 函数的实现。Add 函数用于向 Hash 环上添加节点。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
func (m *Map) Add(keys ...string) {
for _, key := range keys {
for i := 0; i < m.replicas; i++ {
hash := int(m.hash([]byte(strconv.Itoa(i) + key)))
m.keys = append(m.keys, hash)
m.hashMap[hash] = key
}
}

// 排序,这个动作非常重要,因为只有这样,才能构造一个有序的 Hash 环
sort.Ints(m.keys)
}

在 Add 函数里面涉及两个重要的属性:

  1. keys: 类型为 []int。这个其实就是我们上面说的有序 Hash 环,这里用了一个数组表示。数组中的每一项都代表一个虚拟节点以及它的值。
  2. hashMap:类型为 map[int]string。这个就是虚拟节点到用户传的真实节点的映射。map 的 key 就是 keys 属性的元素。

在这个函数里面有生成虚拟节点的操作。例如用户传了真实节点为 ["Node-A", "Node-B"], 同时 replicas 等于 2。则 Node-A 会对应 Hash 环上两个虚拟节点:0Node-A,1Node-A,这两个节点对应的值也是直接进行对其计算 hash 得到。

需要注意的是,每次 Add 时候,函数最后会对 keys 进行排序。因此最好一次把所有的节点都加进来,以避免多次排序。

Get 函数

接下来我们分析下 Get 函数的使用,Get 函数用于给指定 key 分配对应节点。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (m *Map) Get(key string) string {
if m.IsEmpty() {
return ""
}

hash := int(m.hash([]byte(key)))

// Binary search for appropriate replica.
// 二分查找
idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash })

// Means we have cycled back to the first replica.
// 如果没有找到,则使用首元素
if idx == len(m.keys) {
idx = 0
}

return m.hashMap[m.keys[idx]]
}

首先计算用户传的 key 的 hash 值,然后利用 sort.Searchkeys 中二分查找,得到数组中满足情况的最小值。因为 keys 是有序数组, 所以使用二分查找可以加快查询速度。

如果没有找到则使用首元素,这个就是环形数组的基本操作了。最后利用 hashMap[keys[idx]], 由虚拟节点,得到其真实的节点。

以上就是 Groupcache 对一致性 Hash 的实现了。这个实现简单有效,可以帮助我们快速理解一致性 Hash 的原理。

Golang sync.Cond 条件变量源码分析

作者 cyhone
2021年2月4日 22:55

sync.Cond 条件变量是 Golang 标准库 sync 包中的一个常用类。sync.Cond 往往被用在一个或一组 goroutine 等待某个条件成立后唤醒这样的场景,例如常见的生产者消费者场景。

本文将基于 go-1.13 的源码 分析 sync.Cond 源码,将会涉及以下知识点:

  • sync.Cond 的基本用法
  • sync.Cond 的底层结构及原理分析
  • sync.Cond 的惯用法及使用注意事项

sync.Cond 的基本用法

在正式讲 sync.Cond 的原理之前,我们先看下 sync.Cond 是如何使用的。这里我给出了一个非常简单的单生产者多消费者的例子,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var mutex = sync.Mutex{}
var cond = sync.NewCond(&mutex)

var queue []int

func producer() {
i := 0
for {
mutex.Lock()
queue = append(queue, i)
i++
mutex.Unlock()

cond.Signal()
time.Sleep(1 * time.Second)
}
}

func consumer(consumerName string) {
for {
mutex.Lock()
for len(queue) == 0 {
cond.Wait()
}

fmt.Println(consumerName, queue[0])
queue = queue[1:]
mutex.Unlock()
}
}

func main() {
// 开启一个 producer
go producer()

// 开启两个 consumer
go consumer("consumer-1")
go consumer("consumer-2")

for {
time.Sleep(1 * time.Minute)
}
}

在以上代码中,有一个 producer 的 goroutine 将数据写入到 queue 中,有两个 consumer 的 goroutine 负责从队列中消费数据。而 producer 和 consumer 对 queue 的读写操作都由 sync.Mutex 进行并发安全的保护。

其中 consumer 因为需要等待 queue 不为空时才能进行消费,因此 consumer 对于 queue 不为空这一条件的等待和唤醒,就用到了 sync.Cond

我们看下 sync.Cond 接口的用法:

  1. sync.NewCond(l Locker): 新建一个 sync.Cond 变量。注意该函数需要一个 Locker 作为必填参数,这是因为在 cond.Wait() 中底层会涉及到 Locker 的锁操作。
  2. cond.Wait(): 等待被唤醒。唤醒期间会解锁并切走 goroutine。
  3. cond.Signal(): 只唤醒一个最先 Wait 的 goroutine。对应的另外一个唤醒函数是 Broadcast,区别是 Signal 一次只会唤醒一个 goroutine,而 Broadcast 会将全部 Wait 的 goroutine 都唤醒。

接下来,我们将分析下 sync.Cond 底层是如何实现这些操作的。

sync.Cond 底层原理分析

底层数据结构

sync.Cond 的 struct 定义如下:

1
2
3
4
5
6
7
8
9
type Cond struct {
noCopy noCopy

// L is held while observing or changing the condition
L Locker

notify notifyList
checker copyChecker
}

其中最核心的就是 notifyList 这个数据结构, 其源码在 runtime/sema.go#L446:

1
2
3
4
5
6
7
8
9
type notifyList struct {
wait uint32
notify uint32

// List of parked waiters.
lock mutex
head *sudog
tail *sudog
}

以上代码中,notifyList 包含两类属性:

  1. waitnotify。这两个都是ticket值,每次调 Wait 时,ticket 都会递增,作为 goroutine 本次 Wait 的唯一标识,便于下次恢复。 wait 表示下次 sync.Cond Wait 的 ticket 值,notify 表示下次要唤醒的 goroutine 的 ticket 的值。这两个值都只增不减的。利用 wait 和 notify 可以实现 goroutine FIFO式的唤醒,具体见下文。
  2. headtail。等待在这个 sync.Cond 上的 goroutine 链表,如下图所示:

sync.Cond notifyList 结构

Wait 操作

我们先分析下当调用 sync.CondWait 函数时,底层做了哪些事情。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
func (c *Cond) Wait() {
c.checker.check()
// 获取ticket
t := runtime_notifyListAdd(&c.notify)
// 注意这里,必须先解锁,因为 runtime_notifyListWait 要切走 goroutine
// 所以这里要解锁,要不然其他 goroutine 没法获取到锁了
c.L.Unlock()
// 将当前 goroutine 加入到 notifyList 里面,然后切走 goroutine
runtime_notifyListWait(&c.notify, t)
// 这里已经唤醒了,因此需要再度锁上
c.L.Lock()
}

Wait 函数虽然短短几行代码,但里面蕴含了很多重要的逻辑。整个逻辑可以拆分为 4 步:

第一步:调用 runtime_notifyListAdd 获取 ticket。ticket 是一次 Wait 操作的唯一标识,可以用来防止重复唤醒以及保证 FIFO 式的唤醒。
它的生成也非常简单,其实就是对 notifyListwait 属性进行原子自增。其实现如下:

1
2
3
func notifyListAdd(l *notifyList) uint32 {
return atomic.Xadd(&l.wait, 1) - 1
}

第二步:c.L.Unlock() 先把用户传进来的 locker 解锁。因为在 runtime_notifyListWait 中会调用 gopark 切走 goroutine。因此在切走之前,必须先把 Locker 解锁了。要不然其他 goroutine 获取不到这个锁,将会造成死锁问题。

第三步:runtime_notifyListWait 将当前 goroutine 加入到 notifyList 里面,然后切走goroutine。下面是 notifyListWait 精简后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func notifyListWait(l *notifyList, t uint32) {
lock(&l.lock)

...

s := acquireSudog()
s.g = getg()
s.ticket = t

if l.tail == nil {
l.head = s
} else {
l.tail.next = s
}
l.tail = s

// go park 切走 goroutine
goparkunlock(&l.lock, waitReasonSyncCondWait, traceEvGoBlockCond, 3)

// 注意:这个时候,goroutine 已经切回来了, 释放 sudog
releaseSudog(s)
}

从以上代码可以看出,notifyListWait 的逻辑并不复杂,主要将当前 goroutine 追加到 notifyList 链表最后以及调用 gopark 切走 goroutine。

第四步:goroutine 被唤醒。如果其他 goroutine 调用了 Signal 或者 Broadcast 唤醒了该 goroutine。那么将进入到最后一步:c.L.Lock()。此时将会重新把用户传的 Locker 上锁。

以上就是 sync.Cond 的 Wait 过程,可以简单用下图表示:

sync.Cond wait 过程

Signal:唤醒最早 Wait 的 goroutine

正如最开始的例子中展示的,在 producer 的 goroutine 里面调用 Signal 函数将会唤醒正在 Wait 的 goroutine。而且这里需要注意的是,Signal 只会唤醒一个 goroutine,且该 goroutine 是最早 Wait 的。

我们接下来看下,Signal 是如何唤醒 goroutine 以及如何实现 FIFO 式的唤醒。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func (c *Cond) Signal() {
runtime_notifyListNotifyOne(&c.notify)
}

func notifyListNotifyOne(l *notifyList) {
// 如果二者相等,说明没有需要唤醒的 goroutine
if atomic.Load(&l.wait) == atomic.Load(&l.notify) {
return
}

lock(&l.lock)

t := l.notify
if t == atomic.Load(&l.wait) {
unlock(&l.lock)
return
}

// Update the next notify ticket number.
atomic.Store(&l.notify, t+1)

for p, s := (*sudog)(nil), l.head; s != nil; p, s = s, s.next {
if s.ticket == t {
n := s.next
if p != nil {
p.next = n
} else {
l.head = n
}
if n == nil {
l.tail = p
}
unlock(&l.lock)
s.next = nil

// 唤醒 goroutine
readyWithTime(s, 4)
return
}
}
unlock(&l.lock)
}

我们上面讲 Wait 实现的时候讲到,每次 Wait 的时候,都会同时生成一个 ticket,这个 ticket 作为此次 Wait 的唯一标识。ticket 是由 notifyList.wait 原子递增而来,因此 notifyList.wait 也同时代表当前最大的 ticket。

那么,每次唤醒的时候,也会对应一个 notify 属性。例如当前 notify 属性等于 1,则去逐个检查 notifyList 链表中 元素,找到 ticket 等于 1 的 goroutine 并唤醒,同时将 notify 属性进行原子递增。

那么问题来了,我们知道 sync.Cond 的底层 notifyList 是一个链表结构,我们为何不直接取链表最头部唤醒呢?为什么会有一个 ticket 机制?

这是因为 notifyList 会有乱序的可能。从我们上面 Wait 的过程可以看出,获取 ticket 和加入 notifyList,是两个独立的行为,中间会把锁释放掉。而当多个 goroutine 同时进行时,中间会产生进行并发操作,那么有可能后获取 ticket 的 goroutine,先插入到 notifyList 里面, 这就会造成 notifyList 轻微的乱序。Golang 的官方解释如下:

Because g’s queue separately from taking numbers, there may be minor reorderings in the list.

因此,这种 逐个匹配 ticket 的方式 ,即使在 notifyList 乱序的情况下,也能取到最先 Wait 的 goroutine。

这里有个问题是,对于这种方法我们需要逐个遍历 notifyList, 理论上来说,这是个 O(n) 的线性时间复杂度。Golang 也对这里做了解释:其实大部分场景下只用比较一两次之后就会很快停止,因此不用太担心性能问题。

sync.Cond 的惯用法及使用注意事项

sync.Cond 在使用时还是有一些需要注意的地方,否则使用不当将造成代码错误。

  1. sync.Cond不能拷贝,否则将会造成panic("sync.Cond is copied")错误
  2. Wait 的调用一定要放在 Lock 和 UnLock 中间,否则将会造成 panic("sync: unlock of unlocked mutex") 错误。代码如下:
1
2
3
4
5
6
c.L.Lock()
for !condition() {
c.Wait()
}
... make use of condition ...
c.L.Unlock()
  1. Wait 调用的条件检查一定要放在 for 循环中,代码如上。这是因为当 Boardcast 唤醒时,有可能其他 goroutine 先于当前 goroutine 唤醒并抢到锁,导致轮到当前 goroutine 抢到锁的时候,条件又不再满足了。因此,需要将条件检查放在 for 循环中。
  2. Signal 和 Boardcast 两个唤醒操作不需要加锁。

个人博客及公众号常用工具

作者 cyhone
2020年3月8日 16:58

本文整理和记录下自己在运营 个人博客 以及公众号时常使用到的一些工具。主要包含以下方面:

  1. 中英文空格的自动排版
  2. 微信公众号如何使用 markdown 发布
  3. 绘图工具
  4. 图片压缩工具
  5. 如何测试网站的打开速度以及针对性优化

中英文排版及相关工具

在中英文排版最重要的就是中英文之间的空格,Github 有一个热门仓库《中文文案排版指北》(sparanoid/chinese-copywriting-guidelines), 详细的说明了不加空格的严重性:

有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。

所以就有了自动排版加空格的工具 pangu.js 的诞生。

pangu.js 有两种使用方法:

  1. 直接在 HTML 页面中引入 pangu.js,可以实现自动排版。
  2. 使用 vscode-pangu 插件,markdown 编写完成后,手动调用插件排下版。

我个人更喜欢用第二种方式,这样就不用在页面中再单独引入一个 js 文件,尽可能的保持页面的精简。当然前提是用vscode打开和编写markdown。

markdown 发布到公众号

我的博客 cyhone.com 一直是用 hexo 搭建的,平常也更习惯用 markdown 写文章。但是微信公众号并不支持 markdown,所以刚开始一直在找 markdown 转微信公众号的方式。

尝试了好几款推荐度比较高的方法,这里推荐下 mdnice.com。个人觉得用起来非常舒服,不仅支持多款 markdown 主题以及代码主题, 更重要的是功能维护和更新都非常及时。

md-nice

绘图工具

插图是一个博客非常重要的组成部分,好的绘图可以帮助文章把问题解释的更加清楚。我自己也在探索和实践中,目前更习惯用的是 draw.ioprocesson

这里也推荐一篇 《技术文章配图指南》一文。
文章中作者对比了各类绘图工具的优劣,更重要的是给出了绘图的一些建议,例如图片内容展示,配色和字号等方面。

图片压缩:Tinypng

上文讲到绘图是博客非常重要的组成部分。但是图片如果过多,则会影响页面的加载速度,给文章的观感和用户的流量都不是很好。

这里推荐下 tinypng 这个在线图片压缩工具。下图中的图片是我截屏的图片,压缩率往往可以达到 70% 左右。

tinypng

Tinypng 采用的是有损压缩算法,选择性减少图片中的一些肉眼几乎分别不出来的颜色点,起到压缩图片的作用。

网站打开速度优化

在我们自己维护和运营博客的时候,往往遇到打开速度比较慢的情况,而一时间不知道该如何下手优化。

这时候可以使用 Google 家的 Pagespeed Insights。其地址在: developers.google.com/speed/pagespeed/insights/

把网址输进去,Pagespeed Insights就可以给你的网页打开速度进行打分,并给出有价值的优化建议。
pagespeed
pagespeed

这个工具更适合个人网站进行针对性优化~

总结

好的工具帮助提升效率,节约更多的时间。本文会不定时更新,分享自己遇到的好工具~

❌
❌