> ## Content Index
> Fetch the complete content index at: https://huizhou92.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Go 高性能编程 EP9: 两个有用的 Golang 无锁编程技巧
- URL: https://huizhou92.com/go-gao-xing-neng-bian-cheng-ep9-liang-ge-you-yong-de-golang-wu-suo-bian-cheng-ji-qiao/
- Published: 2024-10-19T04:03:18.000Z
- Updated: 2026-09-06T11:40:16.000Z
- Author: huizhou92
- Tags: tech, go, golang, #lang-zh-cn

对于无锁编程来讲，无锁只是一个表象，编程者设法组织 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` 的潜在可能性的。

```go
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`也可以减轻小内存频繁分配的压力。

### 应用

```go
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`模式将会是一个非常好的消费模式。

```go
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 模式，适当地复制少许状态以便解耦竞态条件。