Go 高性能编程 EP9: 两个有用的 Golang 无锁编程技巧

 

对于无锁编程来讲,无锁只是一个表象,编程者设法组织 data+processing,重点在于如何消除 dataracing。而消除 dataracing 而言,传统的并发编程逻辑是采用关键区、排他性锁、读写锁等方式来保护 data 不会因为 processing 而导致错误读或错误写,那么对于无锁编程来说,则是通过消除 data 的共享性,或者消除并发操作 data 等方式来解决问题。
所以最典型的无锁编程技法包含两个技巧:

  1. structure
  2. bumper loop
    其他的方法都是相似思路的具体衍生。

这里的“其他的方法”,是指完全不使用锁定或 CAS 的算法设计方案。本文中讨论的是如何设计数据结构及其处理器(算法)来防止加锁,甚至于连 CAS 也去除。

至于在共享的data上强行消除竞争读写问题的其他无锁方案,例如RingBuffer之类的工具类库,则是完全依赖于具体的 data 实体并在具体的 CPU 上进行设计,基本上是一种很受限的方法,不具备高层次抽象层面的通用性。尽管其具体的设计思路有迹可循,但却不是最佳的选择:一个优秀的 RingBuffer,它的致命之处就在于在单核心的CPU上,或者单颗 vCPU 的 vHost 上可能是无法降级工作的,或者即使能工作也是低效的或者高消费的。所以本文讨论的是更通用的,在任何高级语言(无论其线程、协程支持度如何)于任何 Targets 上都可以通行的无锁编程技法。

Structure copy

Structure copy是一种经典的无锁编程技巧。它的核心思想在于将易变数据交给一个 structure 管理,通常可能起名为 Entry,然后在这个 structure 上展开数据处理过程。如此,由于数据处理过程只会操作其所关联到的 structure,因此这一组数据本身就是非共享的,也就不需要考虑 dataracing 的潜在可能性的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type Worker struct {
  closed int32 // avoid duplicated release rsrc in Close()
  entries []*Entry
}

type Entry struct {
  Count int
  Data []any
}

func (w *Worker) New(data ...any) *Entry {
  e:=&Entry{Data:data}
  w.entries = append(w.entries, e)
  return e
}

func (e *Entry) Run() {
  // processing e.data
}

假设初始化数据 data []any 被提供给 Worker,基于此产生一个 Entry,然后通过 Entry.Run 来处理初始化数据,产生结果,那么上面的示例代码就避免了数据包(Count,Data)的共享问题,因为这个数据包是排他性独立的。

有时候我们的数据包很难拆解,此时可以以衍生技法来实现拆解。具体的思路是设法进行分治。即数据包可以按照推进程度重新设计为 step1,step2,等等,每一步骤中的小数据包的计算结果依次提交给下一步骤。又或者数据包可以拆解为若干个小计项目,最后将多个小计结果合并计算(Map-Reduce?)。

无论采取哪种拆解思路,总的思路不变,即单个 processing 所操作的 data 是排他性独立的一个副本,从而从根本上去除共享加锁解锁的需求。

问题

问题在于太多的小块内存(Entry struct)可能是不好的。一方面它可能带来额外的内存消耗(如果正在处理一个巨大的链表、数组之类,你不太可能总是制造它们的副本),另一方面,太多频繁的小块内存的分发和析构也会产生难以接受的开销。有时候,强制这样的小块内存在栈上分配(如果语言或者编译器支持)是解决后一问题的良药。不过更多的情况下、或许这的确就是不可行的。
当然,带有 GC 的语言这方面的压力会小一点。 其次,使用一个sync.Pool也可以减轻小内存频繁分配的压力。

应用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type Logger struct {  
    handler Handler // for structured logging  
}
type Handler interface {  
	 Handle(context.Context, Record) error
}

func (l *Logger) Debug(msg string, args ...any) {  
    l.log(context.Background(), LevelDebug, msg, args...)  
}

func (l *Logger) log(ctx context.Context, level Level, msg string, args ...any) {  
    。。。。
    r := NewRecord(time.Now(), level, msg, pc)  
    。。。。
    _ = l.Handler().Handle(ctx, r)  
}

slog 中,每条日志都会被包装成一个NewRecord ,避免了数据竞争。
Entry 模式可以说是到处都有在用,用途也不一定限于为了无锁。然而凡是运用到该模式的地方,自动获得了无锁的收益,这其实是很划算的。

Bumper Loop

Bumper Loop是一种设计模式

Bumper Loop 最经典的例子是Redis, Redis 使用多线程网络模型,但是在事务处理上使用单线程。

它的核心思想是将并行事件强行序列化,然后再一一分发给具体处理程序。于是对于这些具体处理程序来讲,共享数据总是排他性的:因为所有处理程序在同一时间下只会运行一个。如果具体处理程序总是飞快地完成,没有阻塞的忧虑,那么Bumper Loop模式将会是一个非常好的消费模式。

1
2
3
4
5
6
7
8
func (w *Worker) runLoop() {
  for {
    switch{
      case ev:=<-fileWatcherCh:
        onFileChanged(ev)
    }
  }
}

对于低频事件的分发和简化并在此同时去除加锁需求、提升性能来说,循环泵模式是一时之选。在诸如 TCP/IP 服务器的 incoming data 处理上通常Bumper Loop 是最佳选择:

对新进连接请求采取 Entry 模式制作一个独立的处理器 connectionProcessor,该处理器中以循环泵模式接受输入数据,识别输入数据的模式为规约命令,然后分发给具体的规约命令处理器。

问题

Bumper Loop 的问题在于,一个阻塞会破坏整个循环,一个过慢的处理会带来不可知的下一事件的处理延迟,高频率的事件会在分发点上阻塞、堆积,甚至是丢失。尽管 Bumper Loop 似乎很明显地有串行化的效率劣势,但它仍被广泛地用于 TCP/IP server/client 编程中。

Structure Copy 与 Bumper Loop 结合

Bumper Loop 的问题之一是不太适合高频事件场景,一般来说事件频率在80ms以上时才比较好用。这种尺度的隐喻是说单次事件处理平均耗时应该小于 80ms。
在不那么严格的场景中(例如非金融高频交易),有时候可以在耗时的事件处理器中启动一个 gorountine 去异步地慢慢处理,而主体的 bumpper loop 则继续去分发后继事件。这时候异步 go routine 又可以用到 Entry 模式,适当地复制少许状态以便解耦竞态条件。

Licensed under CC BY-NC-SA 4.0
最后更新于 Oct 19, 2024 12:03 CST
使用 Hugo 构建
主题 StackJimmy 设计