Swift并发安全与序列号生成:六种实现方案从锁到Actor
2026/9/9 18:54:36 网站建设 项目流程

做后端的时候,我遇到过一件挺丢人的事:线上日志系统的messageId突然出现了重复,下游做幂等的同事跑来问我是不是数据写重了。排查到最后,根因就是一行看起来人畜无害的代码——一个全局计数器在并发环境下被多个线程同时自增。从那时候起,我就把Swift并发安全序列号管理这两件事牢牢绑在了一起。

Swift的项目里,序列号管理出现频率远比想象中高:订单号、日志ID、数据库主键、推送消息流水、文件上传编号,甚至打点事件ID,全都是一个模式:一个计数器,在并发环境下安全地自增,并且返回不重复、不丢号、可预期的值。听起来简单,但你一旦在并发模型上偷懒,它就会用线上重复数据、偶发崩溃和诡异死锁来给你上课。

这篇文章我不打算写成教科书式的入门教程,而是基于我自己从GCD时代一路踩到Actor时代的实际经历,把Swift里的并发安全手段、序列号生成的六种实现、序列号格式设计、容量与回拨问题、以及压测和踩坑记录都放出来。不管你是刚接触Swift并发,还是已经在生产环境里被data race坑过,都能在这里找到可以直接抄的代码和判断标准。

1. 一个线上崩溃:序列号重复的头号元凶

1.1 复现现场:三行代码引发的重复订单号

先说一个最经典的错误写法。早期我做 iOS 客户端日志采集时,需要一个自增的本地消息ID,当时的实现长这样:

final class SequenceGenerator { private var counter: Int64 = 0 func next() -> Int64 { counter += 1 return counter } }

放到单线程里,这段代码优雅得很,每次调用返回 1、2、3…… 顺滑得让人忘记它有多脆弱。后来为了优化启动速度,日志采集从主线程挪到了后台并发队列,同一时刻多个线程同时调next(),线上数据里就开始出现重复ID,偶尔还伴随类似这样的崩溃:

Simultaneous accesses to 0x600003f4xxxxx, but modification requires exclusive access.

这个崩溃信息来自 Swift 的内存排他性检查,通俗讲就是:两个线程同时想改同一个变量,一个还在读,另一个已经在写,直接被运行时抓住了。

1.2 数据竞争的本质:计数器不是原子的

为什么一个counter += 1都会出事?因为你在源码里看到的一行,在 CPU 层面其实是三步操作:

  1. 从内存把counter的值读到寄存器;
  2. 在寄存器里加 1;
  3. 把新值写回内存。

假设两个线程同时执行这三步,完全可能发生这样的交错:线程 A 读到 0,线程 B 也读到 0,A 写回 1,B 也写回 1。最终计数器是 1,但你调用了两次next(),返回的却是 1 和 1。更糟的情况是返回值直接覆盖,导致后续所有ID乱序、丢失、甚至出现之前未出现过的跳跃。

这件事的专业名词叫数据竞争(data race):多个线程并发访问同一块内存,至少有一个访问是写操作,并且没有同步机制保护。Swift 的内存安全模型能保护你避免野指针和越界访问,但它并不保证并发写同一个变量的安全性——这层保护需要你交给并发原语去实现。

1.3 用Thread Sanitizer把问题暴露在崩溃之前

这种偶发性问题,光靠看日志很难复现。我的习惯是无论如何先开Thread Sanitizer(TSan)跑一轮。

开启路径很简单:Xcode 里选 Edit Scheme -> Run -> Diagnostics,勾上 Thread Sanitizer。然后跑一个用并发队列频繁调用next()的压力测试,TSan 会直接定位到产生数据竞争的那一行代码。它会在运行时记录每次内存访问,检测到无同步保护的并发访问就报错,还能给出当前线程的完整调用栈。

需要提醒的是:TSan 有性能开销,跑大任务会明显变慢,但它能帮你把无数线上事故提前消化在本地。我的习惯是每次涉及共享可变状态的改动,都开着 TSan 跑一遍并发单测再合代码。

2. Swift并发安全工具箱:从锁、队列到Actor的演进

2.1 GCD时代的三件套:串行队列、barrier、同步读取

Swift 5.5 引入 async/await 和 Actor 之前,GCD 几乎是唯一的并发安全手段。那会儿我处理序列号,常用三件套。

第一件是串行队列。DispatchQueue 保证同一个串行队列里的任务一个接一个执行,天然互斥。把计数器操作全部派发到串行队列里,就不会有竞争问题:

final class QueueGenerator { private let queue = DispatchQueue(label: "com.example.serial.generator") private var counter: Int64 = 0 func next() -> Int64 { queue.sync { counter += 1 return counter } } }

因为queue.sync会阻塞调用线程直到任务执行完,返回值可以直接拿,代码写起来很顺。

第二件是barrier。如果想用并发队列做更多事情,同时又希望写操作被隔离,可以对写操作加.barrier标志:

final class BarrierGenerator { private let queue = DispatchQueue(label: "com.example.concurrent.generator", attributes: .concurrent) private var counter: Int64 = 0 func next() -> Int64 { queue.sync(flags: .barrier) { counter += 1 return counter } } }

barrier 的含义是:队列里在它之前的任务全部执行完,才开始执行这个任务;这个任务执行完,再继续执行后续任务。这就相当于在并发队列里安了一个"关卡",能保证写操作期间没有任何别的任务打扰。

第三件是同步读取。如果读多写少,用串行队列所有操作排队,读性能会受写操作影响。更精细的做法是:写用 barrier,读用普通 sync,不隔离读操作。这样多个读可以并发,写操作仍被隔离。

这套组合拳至今仍能解决绝大多数并发问题,但缺点也很明显:调度队列需要上下文切换,锁竞争激烈时性能并不好看;而且它属于"靠自觉"的机制——你可以把任何代码丢进队列,也可以绕过队列去直接读写counter属性,编译器不会拦你。

2.2 Actor隔离:让编译器当你的并发哨兵

Swift 5.5 之后,Actor 成了并发安全的首选。Actor 的核心思想是:它拥有自己的私有存储,外界无法直接访问 actor 内部的属性;所有对 actor 状态的访问都必须通过await调用它的方法,由 actor 自己排队执行。

举个例子:

actor SequenceGenerator { private var counter: Int64 = 0 func next() -> Int64 { counter += 1 return counter } } let generator = SequenceGenerator() Task { let value = await generator.next() }

外部连generator.counter都读不到,编译器在编译期就强制了隔离规则。这比手动加锁靠谱太多:锁容易漏加、加错顺序、或者忘了放在正确的作用域,而 Actor 是语言层面的保证,只要通过await调用,状态就不可能被并发破坏。

你可能会问:Actor 不就是个语法糖包装的队列吗?本质确实有点像,但它大幅降低了心智负担。不用再关心"哪个方法要加锁、哪个不用",也不用担心不小心改了某个属性导致锁失效。对序列号管理器这种小而频繁的操作,Actor 的模型非常贴合。

2.3 编译原理视角:排他性规则与Sendable如何提前拦截错误

Swift 的并发安全不只是运行时机制,编译器的静态检查越来越强。这也是我近几年比较关注"Swift编译原理"的原因:只有理解了编译器做了什么,才知道很多崩溃为什么在编译期能被拦下来。

先说排他性规则(Exclusivity Enforcement)。Swift 规定,对同一变量的访问不能同时存在"修改"和"读取"。这个规则在单线程下是运行时的安全检查,在并发场景下就是一道静态防火墙。比如你写了一个发送引用类型进 Task 的代码,如果该类型不是 Sendable,Swift 6 会直接报错,不让你编译通过。

再说Sendable。它不是一个运行时协议,而是一个编译期标记,告诉编译器这个类型"可以安全地跨并发域传递"。一个 class 如果内部有可变状态,又没做隔离,默认就不符合 Sendable。只有 actor、不可变值类型、或者被 @unchecked Sendable 标记的类型才能穿过并发边界。

我之前遇到过一个场景:把生成器实例直接传给多个 Task,编译时报错说SequenceGenerator不满足 Sendable 约束。早期的解决方案是给类加final并用锁保护内部状态,再标记@unchecked Sendable。现在更推荐的做法是直接用 actor,因为 actor 本身就是 Sendable 的。编译器逼着你走正确的路,这就是现代 Swift 并发设计最舒服的地方。

3. 序列号生成的六种实现:从加锁到无锁

3.1 手动加锁方案:os_unfair_lock与NSLock

第一种实现是用锁保护计数器。在 iOS/macOS 上,性能最好的是os_unfair_lock,它是一个轻量级自旋锁,比NSLock快,但要求你不能持有它跨函数调用。

import os.lock final class LockedGenerator { private var lock = os_unfair_lock() private var counter: Int64 = 0 func next() -> Int64 { withLock { counter += 1 return counter } } private func withLock<T>(_ body: () -> T) -> T { os_unfair_lock_lock(&lock) defer { os_unfair_lock_unlock(&lock) } return body() } }

注意os_unfair_lock是值类型,放进 final class 后地址固定,才能安全使用。如果你用结构体持有锁,复制结构体时锁会被复制,那就有问题了。

另一个常见选择是NSLock,API 和这个类似,但封装更重,性能比os_unfair_lock差一些。对于序列号这种高频小操作,能用 os_unfair_lock 就不用 NSLock。

3.2 串行队列方案:把竞争变成排队

前面已经贴过串行队列的代码,这里不再重复。它和锁的本质区别是:锁是让线程互斥访问,队列是让任务串行执行。队列的好处是 API 清晰,坏处是每次调用都要派发到队列,会有调度开销。在序列号生成这种微秒级操作里,开销占比不算小,但绝大多数业务场景都感知不到。

有一个必须记住的坑:不要在一个已经运行在同一个串行队列里的任务里调用queue.sync,否则会死锁。比如next()里调用了另一个也走queue.sync的方法,当前队列任务还没执行完,同步派发又在等它执行,两者互相等待,整个 app 就卡死了。我早期写过这样的代码,印象深刻。

3.3 并发队列加barrier:读多写少的优化

如果序列号生成器不只是自增,还附带查询当前值、批量生成等功能,读写比例不均匀,可以用并发队列 + barrier 的组合:

final class OptimizedGenerator { private let queue = DispatchQueue(label: "com.example.generator", attributes: .concurrent) private var counter: Int64 = 0 func next() -> Int64 { queue.sync(flags: .barrier) { counter += 1 return counter } } func current() -> Int64 { queue.sync { return counter } } }

current()是普通同步读,多个线程可以并发读,不会互相阻塞;而next()必须经过 barrier 写隔离。这种结构在"读频繁、写很少"的缓存类场景里非常有用,对序列号而言复杂度略高,但如果你的业务需要同时暴露当前值,这个模式值得参考。

3.4 Actor方案:最省心的现代写法

Actor 方案在前面已经展示过,完整形态可以这样:

actor ActorGenerator { private var counter: Int64 = 0 func next() -> Int64 { counter += 1 return counter } func current() -> Int64 { counter } func nextBatch(_ count: Int) -> [Int64] { let start = counter + 1 counter += Int64(count) return Array(start...counter) } }

Actor 内部方法不需要await,因为状态访问本来就在 actor 自己的执行上下文里。外部调用await generator.next(),由 Swift 运行时负责调度。

这个方案我最推荐。代码简洁、编译器保证隔离、不容易踩坑。唯一的成本是 actor 调度导致的微小开销,但非极端场景完全感知不到。

3.5 原子操作方案:Swift Atomics库的正确用法

如果你真的追求极致性能,可以考虑无锁的原子操作。Swift 官方维护的swift-atomics库提供了ManagedAtomic类型,底层映射到 CPU 的原子指令。

import Atomics final class AtomicGenerator { private let storage = ManagedAtomic<Int64>(0) func next() -> Int64 { let oldValue = storage.fetchAdd(1, ordering: .relaxed) return oldValue + 1 } }

fetchAdd(1, ordering:)会原子地完成两件事:读取旧值、把计数器加 1,然后返回旧值。这样整个"读-改-写"在硬件层就是原子的,不需要锁,也不会有队列调度开销。

注意不要用load+wrappingIncrement组合,因为 load 和 increment 之间是两条指令,另外的线程可能插进来,导致两个调用返回相同的值。正确做法就是只用fetchAdd这类一次性原子操作。

wrappingIncrement也不是不能用,但它只做自增,不返回旧值,不适合需要把"本次序列号"返回给调用方的场景。

3.6 UUID/时间戳/持久化方案:什么场景该放弃计数器

还有一个思路是干脆不要计数器。如果只是需要"唯一"而不管"递增",直接用 UUID 最简单:

let uniqueID = UUID().uuidString

UUID 是完全无序的,生成耗时略高,但没有任何并发安全问题。它的缺点是序列号不具备可读性,不能用来做排序,也不适合做数据库主键性能优化。

如果序列号必须在一定程度上可排序,可以用自定义时间戳 + 随机数/序号组合。比如 ULID 方案:前 48 位是毫秒时间戳,后面 80 位是随机数据,既能排序又基本不会碰撞。

还有一种必须持久化的场景:应用杀掉重启后,序列号不能从头开始,否则会和历史数据撞号。这时就要把当前计数器的值写入 UserDefaults、文件或数据库。这种方案里,计数器的并发安全只是第一步,持久化的一致性才是大头,后面我会专门说。

3.7 六种方案的选型判断

方案并发安全机制性能级别复杂度适用场景
os_unfair_lock对性能敏感、避免编译器依赖
串行队列队列串行通用,最简单不容易出错
并发队列+barrier栅栏隔离读多写少、附带状态读取
Actor语言隔离默认首选,代码最安全
原子操作CPU原子指令最高高频生成,性能瓶颈明确
UUID/时间戳/持久化无共享可变状态受系统调用影响唯一性优先于递增性

我的选型经验是:从Actor开始,不要过早优化。只有当实测发现 Actor 调度成为瓶颈时,才把生成器换成原子操作或锁。序列号生成这种操作,绝大多数情况下性能瓶颈根本不在生成器本身,而是在网络、磁盘 IO 或数据库写入。

4. 序列号设计:格式、容量、回拨与分布式扩展

4.1 裸计数器到业务流水号:常见的序列号结构

并发安全只是第一步,序列号本身的设计往往更容易踩坑。裸的 Int64 计数器虽然安全,但在业务里可读性太差,无法判断生成时间,也不容易定位问题。实践里更好用的是组合式序列号:

[业务前缀 2位] + [时间戳 10位] + [节点ID 2位] + [自增序号 6位]

比如PO2025012110451201003142,前面的PO表示订单,时间戳一眼看出生成时间,节点ID能看出是哪台机器生成,最后的自增序号保证了同一时刻、同一节点的唯一性。

组合式设计的好处是:不需要全局唯一协调中心,只要时间戳 + 节点ID + 序号三段组合在不同的并发上下文中不同,就能保证不重复。这在多实例部署时特别重要。

4.2 溢出边界:Int64能用多久,Int32为什么不够

序列号用Int64还是Int32,是个容易被忽略的问题。

Int32.max是 2,147,483,647,也就是约 21 亿。如果每秒钟生成 1 万个序列号,21 亿除以 1 万再除以 3600 秒,大约 60 小时就会耗尽。如果你的序列号里还有前缀和固定位数,可用容量更小。

Int64.max是 9,223,372,036,854,775,807,约 922 亿亿。假设每秒生成 100 万条序列号,能撑约 29 万年。对绝大多数业务场景完全不是问题。

所以我的建议很简单:能上 Int64 就上 Int64,不要为了省几个字节用 Int32。另一点要注意的是,如果你用ManagedAtomic<Int64>做原子操作,fetchAdd是 wrapping 的,也就是溢出后会回绕到负数。虽然要满到溢出几乎不可能,但在生成器里加一个防御性判断也没坏处,比如检测到旧值接近Int64.max时抛异常或报警。

4.3 时间戳回拨:系统时钟不可信赖

组合式序列号一旦加入时间戳,就必须面对一个现实:系统时钟不是单调递增的。用户手动改时间、NTP 同步、设备时区变化,都可能导致时间戳倒退。如果序列号用"当前时间"作为前缀,回拨后生成的新序列号可能比之前的小,下游排序就直接乱了。

解决方案有几种。最简单的是维护一个lastTimestamp字段,每次生成时比较当前时间和上次时间,如果当前时间小于上次时间,就用上次时间继续递增序号。更彻底的办法是使用系统单调时钟,比如 Darwin 的clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW),它不受用户改时间影响,适合做性能分析和排序,但遗憾的是它不携带真实世界语义,业务上不好看。

如果序列号必须同时具备时间可读性和单调递增,我一般用方案:时间戳取当前时间,但加一个"回拨补偿"逻辑——检测到回拨时,等待系统时间追上之后再用。这个方案简单粗暴,但在回拨幅度特别大的场景(比如用户手动改回去了好几个小时)不可用,因为等待时间太长。更好的做法是结合 lastTimestamp + 序号自增的兜底机制。

4.4 多实例与分布式:节点ID与雪花算法

单机内存计数器在 App 里没问题,但在服务器多实例部署时就不够了。每个实例各自维护一个计数器,肯定会撞号。这时需要给每个实例分配一个唯一节点 ID,把节点 ID 编入序列号。

著名的雪花算法就是这种思路:64 位整数,高位 41 位是毫秒时间戳,中间 10 位是节点 ID,低位 12 位是同一毫秒内的自增序号。算下来每毫秒单个节点最多生成 4096 个序列号。Swift 实现雪花算法完全是可行的,核心逻辑就是位运算和位移:

struct SnowflakeGenerator { private let nodeID: Int64 private var lastTimestamp: Int64 = -1 private var sequence: Int64 = 0 private let twepoch: Int64 = 1_600_000_000_000 // 自定义起始时间 private mutating func nextID() -> Int64 { var current = currentTimestamp() if current < lastTimestamp { current = lastTimestamp } if current == lastTimestamp { sequence = (sequence + 1) & 4095 if sequence == 0 { while current <= lastTimestamp { current = currentTimestamp() } } } else { sequence = 0 } lastTimestamp = current return ((current - twepoch) << 22) | (nodeID << 12) | sequence } private func currentTimestamp() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } }

注意这里仍然要用锁或 actor 保护lastTimestampsequence的读写,因为雪花算法在并发调用下同样是共享可变状态。把整个生成器设计成 actor 是最干净的封装方式。

4.5 崩溃恢复:序列号落盘与App Group跨进程共享

另一个不能忽略的问题是崩溃恢复。如果 App 在后台生成了序列号,还没写数据库就 crash 了,重启后计数器从 0 开始,就可能和已落库的记录撞号。

最简单的做法是把计数器定期写入 UserDefaults。低频场景下,每次生成后同步落盘:

final class PersistentGenerator { private let defaults = UserDefaults.standard private let key = "currentSequence" private var counter: Int64 init() { counter = defaults.object(forKey: key) as? Int64 ?? 0 } func next() -> Int64 { counter += 1 defaults.set(counter, forKey: key) return counter } }

注意 UserDefaults 不适合高频写入,每写一次全量同步到 plist,几十万次调用会有性能问题。高频场景建议用文件或者 SQLite,配合批量提交。

更复杂的情况是 App 主程序和 Extension(比如 Widget、通知扩展)共享同一个序列号源。UserDefaults(suiteName: "group.xxx")可以跨进程读同一个标准域,但跨进程读写同一块存储本身没有锁保护,两个进程同时自增还是会撞号。正确做法是用文件协调器(NSFileCoordinator)或者通过 App Group 容器里的 SQLite 做事务性增量。说到底,跨进程序列号管理已经不是 Swift 内存并发问题了,而是分布式一致性问题。

5. 压测与踩坑实录:性能、死锁、重入与误用

5.1 用TaskGroup写一个可复现的压测

说再多理论,不如跑一遍数据。我写了一个压测模板,用 Swift Concurrency 的 TaskGroup 同时开多个任务疯狂生成序列号,检查是否重复:

func stressTest(generator: some Any) async throws { let totalCount = 1_000_000 let taskCount = 8 var result = Set<Int64>() let lock = NSLock() await withTaskGroup(of: [Int64].self) { group in for _ in 0..<taskCount { group.addTask { var values: [Int64] = [] for _ in 0..<(totalCount / taskCount) { // 注意这里的 generator 需要是并发安全的类型 // values.append(await generator.next()) } return values } } for await values in group { lock.lock() for value in values { result.insert(value) } lock.unlock() } } assert(result.count == totalCount, "出现重复序列号") }

跑压测之前先把generator换成不同的实现,一个简单又好用的方式是定义一个 Protocol:

protocol SequenceGenerating: Sendable { func next() async -> Int64 }

让 Actor、锁、原子操作各自的实现都遵循这个协议,压测代码就能原样复用。next()用 async 是为了兼容 actor,锁方案里同步方法包一层 async 就能对上约束。

5.2 实测数据:不同方案的吞吐量对比

我在自己的 M1 Mac mini 上跑过一百万次序列号生成压测。不同设备、不同系统版本数值会有差异,但相对关系具有参考价值:

  • os_unfair_lock方案:最快,约在 0.5 秒级别,因为锁几乎完全在用户态完成,没有线程切换。
  • 串行队列方案:明显比锁慢,约 1 到 2 秒,主要开销是 GCD 的任务调度。
  • Actor 方案:和串行队列接近,略慢一点点,但差距通常不到一倍。在 Swift 5.5 之后的版本里,actor 调度做了不少优化,实际使用中观感很好。
  • 原子操作方案:和 os_unfair_lock 相当,甚至更快,因为 CPU 指令直接完成,连锁的获取和释放都省了。

所以结论很清楚:如果生成器每秒被调用几次到几十万次,Actor 的额外开销完全无所谓;只有当每秒调用量在百万级别且已经成为性能热点时,才值得换成原子操作

5.3 踩坑一:Actor重入导致序列号乱序

Actor 的隔离规则有个容易忽略的细节:actor 是可重入的。也就是说,actor 里一个方法执行到await时,会临时挂起,让其他等待中的方法进来执行。如果序列号生成器内部不包含await,其实没有影响;但一旦有人手贱加了await,就可能出问题。

举个例子:

actor BadGenerator { private var counter: Int64 = 0 func next() async -> Int64 { // 这里假如无意中加了异步操作 try? await Task.sleep(nanoseconds: 1_000) counter += 1 return counter } }

两个并发调用next(),第一个方法执行到Task.sleep让出了 actor 控制权,第二个方法进来把 counter 从 0 加到 1,然后返回 1;接着第一个方法继续执行,把 counter 从 1 加到 2,返回 2。这种情况下序列号本身不会重复,但你如果以为"先调用就先返回",结果第一个调用反而拿到 2,顺序就乱了。

我的教训是:序列号生成器里不要放任何await,让它保持纯同步方法。如果确实需要异步扩展(比如生成后写日志),把异步逻辑放到 actor 外部,不要污染生成器本身。

5.4 踩坑二:在Actor里同步等待锁的死锁现场

另一个印象深刻的坑是手写锁和 Actor 混用导致的死锁。我曾经在一个 actor 方法里调用了queue.sync去读另一个用串行队列保护的缓存,结果那个串行队列的某个任务又反过来await了这个 actor 的方法。两边的执行路径互相等待,整个 app 出现长时间的卡死。

死锁的根子是阻塞式同步 API 和异步调度的混用。Actor 那边等队列,队列那边等 actor,谁都不让谁。排查的时候 TSan 对这种锁序问题不一定会报错,反而用 Xcode 的线程调试器把两个线程的调用栈放在一起看,一下就清楚了。

规矩是这样的:在 actor 内部不要使用会长期阻塞线程的同步 API。如果必须读取另一个受保护资源,用异步版本或者直接通过 actor 方法访问,避免互相等待的锁序环。

5.5 踩坑三:@MainActor误用把并发拉回单线程

还有一种容易犯的错误是给序列号生成器打了@MainActor标记。表面上解决了并发冲突,因为所有调用都被派发到主线程执行了,结果高并发场景下主线程被序列号生成任务占满,UI 掉帧卡顿,数据任务反而排队。

@MainActor适合 UI 相关的状态修改,不适合通用的序列号生成。如果你非要让生成器在主线程执行,同时调用方又来自后台任务,等于把并发性能全部浪费在主线线程这一个瓶颈上。这种错误不会导致数据竞争,也不会崩,但性能会肉眼可见地下降。

我现在的原则是:默认用 actor 做序生成器,UI 层需要展示时才把序列号直接取回来用,而不是把生成器本身绑到主线程。

5.6 踩坑四:原子操作的memory ordering没那么玄

用 Swift Atomics 的时候,每个操作都要传一个ordering参数,很多人拿到代码都懵:relaxed、acquiring、releasing、sequentiallyConsistent 到底有什么区别?

简化理解:relaxed只保证这个变量本身的原子性,不保证它和其他变量的顺序关系。序列号生成这种场景,只需要"自增这个计数器时不被打断",relaxed就够了。acquiringreleasing用在发布/订阅模式里——比如先把数据写入几个属性,再通过一个原子的 flag 发布出去,读方看到 flag 为 true 时,也要能看到写入的数据。sequentiallyConsistent是最强的内存序,保证全系统的一致性视角,但性能损失也最高。

对于序列号生成,千万别为了"保险"而用sequentiallyConsistent,那只会引入无谓的开销。先想清楚:你是只关心计数器唯一递增,还是要顺带用它做其他数据的同步?如果是前者,relaxed就是正确答案。

写在最后的一点体会

序列号管理做到最后,其实考验的是你对并发模型边界的判断力。单机内存内,用 Actor 或原子操作都行;跨进程,就得靠持久化和文件/数据库事务;跨服务,就得引入节点 ID、时间戳和分布式算法。每个层级都有各自的安全边界,把这些边界想清楚,代码怎么写都不会出大问题。

最后再分享一个我这两年形成的小习惯:每一段涉及共享状态的新代码,都先问自己三个问题——这个状态会被几个并发域访问?访问频率是多少?状态是否需要跨进程持久化?回答完这三个问题,锁、队列、Actor、原子操作还是持久化方案,答案基本就浮出水面了。顺着这个决策路径走,序列号重复这种坑,基本可以跟你彻底说再见了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询