
1. 先看一个并发Bug原子操作解决的到底是什么问题很多Go开发者在接触并发时第一个遇到的就是多goroutine同时读修同一个变量时结果不符合预期。我曾经在一个统计服务里见过一个典型的例子1000个goroutine同时对同一个int64变量做1000次自增期望值是1000000实际跑出来经常是九十几万还每次都不一样。当时的排查过程也很经典先怀疑机器问题、再怀疑调度问题最后用go run -race一跑立刻定位到变量竞争。那个瞬间我意识到很多人的第一反应是加锁但有些高频且简单的内存操作其实有比锁更轻量的解法这就是sync-atomic包。atomic包提供的是原子操作它不同于互斥锁不需要让线程进入休眠等待而是直接在CPU层面保证某个内存单元的读-改-写操作是整体完成的。换句话说原子操作天生就是为单一变量、高频调用、逻辑极短这类场景设计的。它能解决计数器、开关状态、配置热更新、无锁数据结构等一大批并发问题适合正在排查并发数据竞态、或者想优化高并发路径性能的工程师参考。理解atomic包的关键不只是会调几个函数而是搞清楚它和锁的分工边界以及在Go内存模型里占的位置。先说清楚一个概念goroutine之间共享变量时如果至少有一个goroutine在写而其他goroutine不通过同步机制读这就是数据竞态。Go的race detector能抓出大多数竞态但能不能修得优雅取决于你对同步机制的理解深度。下面我从原子操作的底层原理讲起再逐个API拆解使用方式最后用实际的可运行代码把整个方案串起来。2. 原子操作背后的硬件逻辑CPU如何保证不可分割2.1 从一条汇编指令说起在x86指令集里INC DWORD PTR [rax]这条指令看起来是把内存单元加1但在多核CPU上如果没有额外保障这条指令在两个核心同时执行时可能会互相覆盖结果。硬件解决这个问题靠的是两类机制一是为关键指令加上LOCK前缀二是依赖缓存一致性协议。带LOCK前缀的指令例如LOCK INC或LOCK XADD会锁住总线或锁住对应缓存行直到这条读改写指令完全结束其他核心无法对该内存地址发起读写。Go的sync/atomic在amd64平台上最终编译出来用的就是这类指令。比如atomic.AddInt64在x86上通常对应LOCK XADDQ一次性完成读旧值-加新值-写回的完整流程。因为指令本身不可打断所以多个核心并发执行时操作系统调度器不需要介入goroutine不会被迫让出CPU。2.2 缓存一致性协议与可见性现代CPU为了避免每次读写内存都走总线会给每个核心配置多级缓存。缓存行的数据同步依赖MESI等一致性协议。一个变量的值被某个核心修改后其他核心的缓存行会从Modified变为Invalid下一次读取被迫重新从更高级缓存或主存加载。原子操作恰好在硬件层保证了两点当前核心的修改能够尽快对其他核心可见且读改写过程不会被并发交织。我在之前接触过的一个项目里需要理解为什么Load一个int64不需要锁也能保证读到某个时刻的完整值。原因就是对于对齐的机器字长数据CPU在硬件层面已经保证单次读或写的原子性。原子包要解决的更多是读改写组合操作和内存顺序语义而不只是单个Load/Store本身。这也是为什么atomic包里的函数虽然都叫原子但语义层次并不相同Load/Store是最基本的Add是读改写CASCompareAndSwap是条件写它们各自适配不同的场景。2.3 和互斥锁的本质差别互斥锁保护的是一段代码临界区进入锁后其他goroutine必须阻塞等待。这个等待过程可能涉及goroutine的挂起、唤醒、上下文切换竞争激烈时开销相当可观。原子操作保护的则是一个内存单元的读改写不需要阻塞拿CPU指令硬扛。你可以这样理解锁是会议室进门要排队、用完了要交钥匙原子操作是自动售货机投币、取货、找零一步到位谁来了都能立刻操作不会因为有人占着机器就让你在门口等。因此判断该用锁还是原子操作核心指标是这段逻辑是否只是对单个变量做很小的操作如果是优先考虑原子操作如果涉及多个变量组合、跨多个步骤保持某种业务不变量锁通常是更合理的选择强上原子操作只会制造出更难排查的逻辑错误。后面第5节会专门展开这个边界。3. sync-atomic API逐组拆解与可运行示例3.1 为什么只支持这些类型没有intatomic包提供的函数命名非常规整比如AddInt32、AddInt64、AddUint32、AddUint64、AddUintptrCAS系列也对应这些类型另外还有unsafe.Pointer系列。细心的读者会问怎么没有AddInt原因是Go的int宽度是平台相关的32位平台上4字节64位平台上8字节。如果API设计成AddInt底层指令选择会很模糊。所以官方只提供固定宽度的类型保证语义一致。如果你确实需要对int做原子操作可以转为int64或uintptr也可以用Go 1.19之后引入的泛型原子类型atomic.Int64内部封装好了这些宽度的处理。另外传统函数式API要求你传指针例如atomic.AddInt64(counter, 1)。这背后的原因很直白原子操作的语义是对一块内存地址发起CPU指令必须把变量的地址交给函数函数才能对该内存位置发起LOCK指令。如果按值传函数操作的是副本那就失去了原子性。3.2 Add并发计数器的正确打开方式把多个goroutine的累加结果稳定下来是atomic包最基础也是最高频的使用场景。看这段对比代码// 错误示范普通累加存在数据竞态 var counter int64 var wg sync.WaitGroup for i : 0; i 1000; i { wg.Add(1) go func() { defer wg.Done() for j : 0; j 1000; j { counter } }() } wg.Wait() fmt.Println(counter) // 输出不稳定经常小于1000000 // 正确示范atomic.AddInt64 var counter int64 var wg sync.WaitGroup for i : 0; i 1000; i { wg.Add(1) go func() { defer wg.Done() for j : 0; j 1000; j { atomic.AddInt64(counter, 1) } }() } wg.Wait() fmt.Println(counter) // 恒为1000000用atomic.AddInt64时虽然1000个goroutine同时执行累加但每次自增在硬件层面都是不可分割的某个核心发起LOCK XADDQ后其他核心对该内存单元的读改写必须排队。这个排队不涉及操作系统调度等待时间极短所以整体性能依然远高于同规模下的锁方案。值得注意的一个细节是原子操作不保证读到的值一定是最新的。但Add这类指令的特殊之处在于它以硬件缓存一致性协议为基础写操作完成后其他核心最终能感知到最新值。这个最终可见的时间窗口极短在大多数业务场景中足够用。如果你需要更严格的内存序保证就涉及Go内存模型和内存屏障这一点我会在第6节讨论排查工具时再提到。3.3 CAS无锁更新的核心武器CompareAndSwap是所有无锁算法的基础它的执行流程可以理解为一行伪代码if *addr old { *addr new return true } return false关键在于这个比较-写入在硬件层面是不可分割的。x86上的LOCK CMPXCHG指令一次完成比较和条件写入。CAS最常见的使用模式是自旋重试先读取当前值计算出新值再尝试CAS如果CAS失败说明在这期间有别的goroutine修改了变量那就以最新值重新计算再次尝试。看一个无锁递增的例子func atomicIncrement(addr *int64) int64 { for { old : atomic.LoadInt64(addr) new : old 1 if atomic.CompareAndSwapInt64(addr, old, new) { return new } } }每次进入循环都重新读取最新值只有CAS成功才退出。如果并发非常高可能出现多个goroutine反复失败的活锁但概率较低。这个模式比直接Add多了一个读取-计算-条件写入的分步过程适合无法用Add一步完成的场景比如往一个无锁队列里插入节点。我在实现一个并发状态机时用过这个模式状态值0代表空闲1代表处理中2代表完成。多个工作goroutine都想把状态从0改成1代码可以这样写var state int64 // 0空闲, 1处理中, 2完成 func tryAcquire() bool { return atomic.CompareAndSwapInt64(state, 0, 1) } func release() { atomic.StoreInt64(state, 0) }这个场景如果用互斥锁会略显笨重因为锁的临界区只包含一次状态切换锁的获取释放开销反而占了较大比例。CAS把状态切换压缩成一条硬件指令并用返回值直接表达谁抢到、谁没抢到语义非常直白。3.4 Load/Store与Swap配置热更新和值交换Store用于原子写Load用于原子读它们是CAS的基石。如果你有一份配置在多goroutine之间共享且更新频率不高、读取频率很高用atomic.Value或带原子类型的变量很合适。这里演示基础用法var requestTimeout atomic.Int64 requestTimeout.Store(3000) // 毫秒 timeout : requestTimeout.Load() // 读到的值一定是某次Store写入的完整值Swap则是不带条件的值交换返回旧值。它适合拿旧换新并保留旧值用于日志或回滚的场景例如灰度开关的原子翻转old : atomic.SwapInt64(featureFlag, 1) // old是切换前的值可用于日志或统计对比一下Store只是写入新值不关心旧值Swap整体完成读旧-写新CAS则多了一层条件判断。三者对应不同抽象层次日常用得最多的是Load/Store和CASSwap在性能剖析、无锁队列的tail更新等场景中也会出现。3.5 atomic.Value任意类型的安全读写如果某个变量是结构体、切片、map或指针没法用AddInt64那一套函数处理这时候atomic.Value就派上用场。它的核心约束有三个第一次Store后后续Store的类型必须一致不能Store nil底层要求值不能是包含变量的结构体这种不可复制类型实际go vet也会提醒。实际项目中最常见的用法是用原子整体替换一个配置快照。比如客户端需要读取一份不断更新的配置读路径非常频繁写路径频率很低type Config struct { Name string QPS int64 } var cfg atomic.Value // 实际存储的是Config // 初始化 cfg.Store(Config{Name: default, QPS: 100}) // 读路径 func GetConfig() Config { v, ok : cfg.Load().(Config) if !ok { return Config{} } return v } // 写路径整体替换修改变量不会影响正在读的goroutine func UpdateConfig(c Config) { cfg.Store(c) }注意这里的关键设计更新配置时是构造一个新结构体然后整体Store。正在读取的goroutine要么读到旧结构体要么读到新结构体绝不会读到被修改了一半的结构体。这就是用原子替换指针/值来代替对结构体字段逐个加锁的精髓。atomic.Value底层在Go 1.x的实现里有一个ifaceWords结构实际是利用unsafe.Pointer的原子读改写实现的同时用内存屏障维护可见性。你不需要深究内部实现但一定要记住类型一致性约束如果第一次Store的是Config之后就不能再Store其他类型否则运行时会panic。3.6 Go 1.19之后的原子类型封装Go 1.19开始sync/atomic加入了通用类型别名atomic.Int32、atomic.Int64、atomic.Bool、atomic.Uintptr等以及atomic.Pointer[T]。这极大改善了使用体验不再需要到处传counter而且内部为64位字段对齐做了处理减少了在32位平台上因字段对齐问题导致的panic风险。前面的代码示例里我刻意混用了传统函数和封装类型实际项目中如果是新写的代码我建议优先用atomic.Int64这类封装类型代码可读性明显更好var total atomic.Int64 total.Add(10) n : total.Load() var ptr atomic.Pointer[Config] cfg : Config{Name: test} ptr.Store(cfg) got : ptr.Load()atomic.Bool更是好用到让人怀疑为什么这么晚才推出过去想表达一个并发安全的布尔开关要么用int32配合Load/Store要么写atomic.Value存bool现在一个atomic.Bool就搞定了。状态开关场景我会在下一个部分详细讲。4. 使用场景分析哪些地方适合上原子操作4.1 高频计数与监控指标在线服务里接口调用量、错误次数、延迟分桶统计这些指标几乎都适合用原子计数器。比如用atomic.Int64维护每分钟请求总数另一个atomic.Int64维护错误数Prometheus之类的监控系统周期性读取。这类场景的特点是操作极其简单自增、频率可以非常高锁的上下文切换开销会被放大很多倍而原子自增几乎可以忽略不计。还有一种常见做法是批量上报多个goroutine通过Add不停累加后台定时用Swap(old, 0)一次性取走计数器并清零。Swap的返回值就是这一段时间内的总增量且取走后计数器归零天然实现了一个并发安全且不丢失数据的累计窗口。4.2 开关、阶段状态与一次性初始化服务启用/禁用、特性开关、任务运行阶段running/done/closed这类只有一个变量、取值有限、更新不频繁的场景很适合原子操作。例如在后台任务里希望优雅关闭时只被触发一次var stopped atomic.Bool func Close() { if stopped.CompareAndSwap(false, true) { // 只有第一次调用才会执行后面的清理逻辑 shutdown() } }对比sync.OnceOnce的语义是只执行一次函数而CAS布尔开关的语义是只有第一个竞争者成功返回值可以立即用来决定后续动作。两者有重叠但CAS给了你更细的控制权且不强制你把全部逻辑包进一个闭包函数。用Once可以用atomic.Bool同样可以具体看代码风格。4.3 无锁数据结构的基础构建CAS是构建无锁栈、无锁队列的基石。典型思路是维护一个头指针插入节点用CAS操作头指针不断循环重试type LIFONode struct { next unsafe.Pointer // 实际是 *LIFONode value int } // 伪代码核心循环 for { old : atomic.LoadPointer(head) node.next old if atomic.CompareAndSwapPointer(head, old, unsafe.Pointer(node)) { break } }这个思路能work是因为插入操作只需要修改head这一个共享变量。真正的工业级无锁队列还要解决内存回收和ABA问题那个复杂度会陡增。在Go里我们通常可以选择不主动回收节点或者结合垃圾回收器让旧节点被自动回收简化很多问题。我个人不推荐在业务代码里轻易实现复杂的无锁队列除非你能写出严格的正确性证明否则用channel或sync.Mutex包一层在外层做控制往往更容易维护。但理解这个CAS循环模式是深入理解atomic包的重要一步。4.4 不适合使用原子操作的典型场景原子操作不是万能钥匙。以下场景我会直接选锁而不是硬套原子操作需要同时更新多个相关字段。例如账户余额和交易版本号必须同时变化拆成两个原子变量去改一定会出现中间状态被其他goroutine观测到。正确做法是把这些字段封装进一个结构体用atomic.Pointer[Struct]整体替换或者干脆加锁。如果结构体很大整体替换成本高加锁保护多个字段的一致性仍然是更合理的选择。临界区有复杂的读后写逻辑或包含不可重试的副作用。CAS自旋失败后要重算新值如果每次重算都有额外的开销且重试概率高那么用锁把整段逻辑包起来通常更高效实现也更简单。需要阻塞等待某个条件满足。例如生产者等待队列有空位这种场景用channel或条件变量比自旋CAS现实得多。原子操作没有等待语义它只是快速失败和重试。需要跨多个goroutine维持复杂的业务不变量。任何要求一旦看到某个状态则其他关联状态必然已经变更完毕的需求单独用多个原子变量都很难正确实现。这种时候用锁保护一块临界区或用atomic.Value整体替换状态快照才是稳的。我在实际评审代码时判断标准很简单如果这段代码改成加锁后锁的临界区只有两三行那我倾向于用原子替换如果临界区超过十行或包含多个变量锁通常是更稳妥的选择。性能问题应当用profiler数据来驱动而不是凭感觉把锁全换成原子操作后者经常制造出更隐蔽的并发缺陷。5. 实操实现一个可动态调参的并发限流器为了把前面拆开的API串起来我设计了一个典型的综合场景一个并发安全的QPS限流器支持运行时动态调整每秒限制、支持启用/禁用并且携带一份可整体替换的扩展配置快照。这个例子同时用到了atomic.Int64、atomic.Bool、atomic.Value和CAS。5.1 需求拆解与方案设计限流器的核心逻辑是每个请求到达时判断当前时间窗口1秒内已放行的请求数是否达到上限。如果没达到放行并将计数加1达到上限拒绝。为了让这个组件更贴近真实线上环境我加了三个附加需求每秒上限可以在运行期调整且调整后立即生效可以临时禁用限流例如灰度放量或故障逃生扩展配置比如来源标识、突发量burst需要整体替换保证读方看不到半更新状态。设计上窗口计数用atomic.Int64窗口开始时间用atomic.Int64存储秒级时间戳启用状态用atomic.Bool扩展配置用atomic.Value存储一个结构体快照。窗口切换时需要保证只有一个goroutine负责重置时间戳和计数我用CAS来实现这个抢占逻辑。5.2 完整代码实现package main import ( fmt sync sync/atomic time ) type ExtraConfig struct { Source string Burst int64 } type RateLimiter struct { limit atomic.Int64 // 每秒允许的请求数 count atomic.Int64 // 当前窗口内已放行的请求数 windowStart atomic.Int64 // 当前窗口开始时间Unix秒 enabled atomic.Bool // 是否启用限流 extra atomic.Value // 扩展配置快照存放 ExtraConfig } func NewRateLimiter(limit int64) *RateLimiter { rl : RateLimiter{} rl.limit.Store(limit) rl.windowStart.Store(time.Now().Unix()) rl.extra.Store(ExtraConfig{Source: local, Burst: 10}) return rl } func (rl *RateLimiter) allow(now int64) bool { if !rl.enabled.Load() { return true } // 判断是否需要切换到新窗口 for { start : rl.windowStart.Load() if now start { break } // 尝试抢占窗口切换权CAS成功才去重置计数 if rl.windowStart.CompareAndSwap(start, now) { rl.count.Store(0) break } // 抢占失败说明其他 goroutine 已经完成了切换重新判断 } // 计数自增并判断是否超限 c : rl.count.Add(1) if c rl.limit.Load() { rl.count.Add(-1) // 超限回退保持计数不膨胀 return false } return true } func (rl *RateLimiter) Allow() bool { return rl.allow(time.Now().Unix()) } func (rl *RateLimiter) SetLimit(n int64) { rl.limit.Store(n) } func (rl *RateLimiter) Enable() { rl.enabled.Store(true) } func (rl *RateLimiter) Disable() { rl.enabled.Store(false) } func (rl *RateLimiter) SetExtra(e ExtraConfig) { rl.extra.Store(e) } func (rl *RateLimiter) GetExtra() ExtraConfig { v, ok : rl.extra.Load().(ExtraConfig) if !ok { return ExtraConfig{} } return v } func main() { rl : NewRateLimiter(5) var wg sync.WaitGroup start : time.Now() for i : 0; i 20; i { wg.Add(1) go func(i int) { defer wg.Done() if rl.Allow() { fmt.Printf(goroutine %d allowed\n, i) } else { fmt.Printf(goroutine %d denied\n, i) } }(i) } wg.Wait() fmt.Println(elapsed:, time.Since(start)) }这段代码里的细节值得展开第一窗口切换用CAS保证只有一个goroutine执行count.Store(0)。假如不用CAS多个goroutine同时发现now windowStart各自执行Store(0)就可能出现旧窗口的请求在计数值被清零后又被当成新窗口请求放行限流效果会被突破。CAS把更新时间戳和重置计数绑定为一个不可分割的抢占动作。第二超限后执行Add(-1)回退。这一步不是严格必要的但能避免重复的拒绝请求把计数器撑大。在极端并发下可能仍有少量误差因为回退和下一次Add之间会有竞争窗口但对于大多数限流场景工作在接受范围内。第三extra整体替换的设计保证读取方拿到的一定是完整的ExtraConfig不会出现Source是新的但Burst还是旧的这种情况。我一直强调原子操作维护多个变量的一致性很困难这里windowStart和count是两个变量之所以能协调是因为窗口切换的动作被CAS固化为谁能改时间戳谁才能重置计数这一规则。这种多变量协调需要从设计上就保证只有一个入口否则很容易出现竞态。5.3 基准测试与性能验证只看代码正确性还不够我习惯用Go自带的testing包跑基准测试验证性能。这里做一个直接对比同一台机器上互斥锁保护的自增和原子自增的并发开销差别func BenchmarkMutexCounter(b *testing.B) { var mu sync.Mutex var c int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { mu.Lock() c mu.Unlock() } }) } func BenchmarkAtomicCounter(b *testing.B) { var c atomic.Int64 b.RunParallel(func(pb *testing.PB) { for pb.Next() { c.Add(1) } }) }在普通多核服务器上这个benchmark的结果往往是原子自增比锁快一个数量级甚至更多。但要注意这个差距主要来自锁竞争时的goroutine调度开销而不是锁本身多慢。如果把临界区做长一点比如执行几千次计算再解锁差距会迅速缩小甚至锁方案由于实现简单而更有优势。所以性能测试一定要针对自己的真实场景跑不要拿这个数字去套所有并发代码。顺带提一下Go的go tool pprof可以分析CPU和内存profile定位锁竞争和原子操作热点都在哪里。原子操作虽然开销低但如果量级达到千万级别仍然会出现在CPU profile里。这时候你可以考虑更细粒度的分桶计数比如每个P一个本地计数器定期合并进一步降低跨核缓存同步代价。这个优化属于后话绝大部分场景不需要做到这一步。6. 常见问题与避坑实录6.1 64位字段对齐32位平台上的隐藏炸弹atomic.AddInt64等函数要求传入的地址在64位对齐边界上否则在32位平台上会panic。Go官方文档明确写了这个约束。最典型的踩坑场景是把一个int64字段放在结构体的中间前面有一个int32字段在32位机器上这个int64可能没有对齐到8字节。早期版本的解决方案是把int64字段放到结构体第一个字段位置。但更省心的做法是用atomic.Int64这类封装类型它在内部通过包含一个_ [0]uint64之类的辅助字段或额外首字段来保证对齐。从Go 1.19开始凡是能改用atomic.Int64的地方我都建议直接改既规避对齐问题代码也更简洁。6.2 复制原子类型go vet一抓一个准atomic.Int64、atomic.Bool这些类型内部包含不可复制的字段比如noCopy标记如果把它按值复制到另一个变量go vet会报错。实际项目中最常见的错误是把包含原子类型的结构体直接作为返回值返回或者放到map里拷来拷去。排查办法很简单在CI里加入go vet ./...它会直接提示copies lock value看到这个提示就说明某个原子类型被复制了。修复方式是把共享的原子类型定义为指针字段或统一用指针传递包含它的结构体。6.3 ABA问题CAS不是万能的CAS自旋最著名的陷阱是ABA问题某个值从A变为B再变回ACAS无法感知中间发生了变动会误以为没有被修改过。在无锁栈/队列里如果节点被反复push、pop、再pushhead指针的数值可能恰好回到原值但指向的节点对象已经被回收或复用继续用CAS自旋就会产生错误。Go的垃圾回收器让ABA问题在节点不回收的设计下会好一些但只要涉及节点的push/pop循环复用依然有风险。解决思路通常是增加版本号字段打包到一个结构体里用CAS整体比较或者不主动复用节点让旧节点被GC回收。业务代码里如果真走到需要处理ABA问题的复杂度我会停下来重新审视设计是否真的需要无锁多数时候把锁放在外层逻辑里得到的可维护性收益远大于那点性能提升。6.4 一半原子一半普通读写race照样爆有一个非常隐蔽的问题同一个变量在某个goroutine里用atomic.LoadInt64读在另一个goroutine里却用普通赋值写。race detector通常能抓到这个因为Go的race工具会追踪所有内存访问只要普通写和原子读同时发生就会报告竞态。遗憾的是有些开发者只把读的那一侧换成原子操作忘记写侧也要同步结果线上偶发读到奇怪值且难以复现。正确的纪律是一旦决定某个变量走原子操作那么所有读写该变量的路径都必须走atomic包不能留普通读取或普通赋值。这一点我在代码评审时会逐行检查因为它太容易漏了。6.5 排查工具组合vet、race和pprof的使用时机做并发相关的代码我的固定流程是三件套写完先跑go vet ./...检查明显的复制/对齐问题再跑go test -race ./...让race detector抓数据竞争最后用go test -bench . -cpuprofile cpu.out做性能验证再用go tool pprof分析热点。这三步分别对应正确性、竞态、性能三个维度。很多奇怪的偶发Bug用-race跑一遍立刻现形很多为什么用锁这么慢的困惑用pprof看一眼锁等待曲线就能明白。原子操作写起来简单但排查工具链不能省这是我踩过多次坑之后养成的习惯。7. 我日常使用sync-atomic的几条习惯这篇文章写到最后我分享几个自己沉淀下来的使用习惯。第一个习惯是能用锁写清楚的代码先用锁写。把业务逻辑验证正确之后再根据热点profile决定是否有必要换成原子操作。不要一上来就追求无锁无锁代码的正确性成本通常比性能收益更显著。第二个习惯是所有设计里只要出现多个原子变量必须协同变化的念头就停下来回到锁或结构体整体替换。原子变量适合的是单个变量、极短操作强行让多个原子变量协同最终会制造出最难排查的那类问题。第三个习惯是代码里尽量用atomic.Int64这类封装类型只在底层或极端性能敏感的代码里使用旧式函数。sync-atomic包在Go标准库里看起来很小但它连接了CPU指令、内存模型、并发设计三个层次。掌握它并不难难的是在真实业务里判断何时不用它。希望这篇文章能帮你把这条路走得顺畅一些。