热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

正在等待缓存锁:无法获得锁_Go:关于锁的1234

正在等待缓存锁:无法获得锁_Go:关于锁的1234,Go语言社区,Golang程序员人脉社



1d7f6d3c4eca96fa05475c5114b14eb4.png


在上一篇《踩坑记:Go服务灵异panic》里我们提到了 mutex 和 atomic ,感觉意犹未尽,这篇再展开一点。



- 锁 -


前面我们讲过好多面试题了,其实锁也很适合用来做套题,比如可以这么切入:sync.Mutex 是悲观锁还是乐观锁?


有些候选人不了解它们的区别,回答靠猜,缺乏逻辑以至于我都记不住。虽然这只是一个概念性的知识,但是却很能反映候选人的工作经验,比如读多写少的并发场景,乐观锁可以减少加锁冲突带来的开销


当然大多数人还是知道的,于是可以继续问:你有了解过锁是怎么实现的吗?


很多人都能想到:维护一个初值为 false 的变量,当一个线程加锁成功的时候,将它置为 true ,就可以保证其他线程无法再获取。


逻辑是没错,但真正的问题是:两个线程同时检查,发现它的值都是 false ,如何保证只有一个线程会把它置为 true 呢?


这样的提问让不少候选人意识到,自己其实并没有真正理解锁。



- 原子操作 -


学过操作系统原理的同学应该都知道,靠的是原子操作(atomic operations)。


那么具体是什么原子操作呢?


在早期只有单核的系统上只需要关闭中断就可以保证原子地执行一段代码 —— 但这通常效率较低,且还存在些问题,例如因为 bug 或恶意代码导致未能正常开启中断,系统就会锁死;而对于多核系统,通常也无法做到在多个核心上同时关闭中断。


因此 CPU 引入了硬件支持的原子操作,例如 x86 体系下的 LOCK 信号(在汇编里给指令加上 LOCK 前缀),通过锁定总线,禁止其他 CPU 对内存的操作来保证原子性。但这样的锁粒度太粗,其他无关的内存操作也会被阻塞,大幅降低系统性能,而随着核数逐渐增加该问题会愈发显著 —— 要知道现在连家用 CPU 都有16核了。


因此 Intel 在 Pentium 486 开始引入了用于保证缓存一致性的 MESI 协议,通过锁定对应的 cache line,使得其他 core 无法修改指定内存,从而实现了原子操作(缓存锁)。这里不展开了,对细节感兴趣的话,详见参考资料《原子操作是如何实现的》[1]。



- CAS -


针对前面问的“什么原子操作”,大多数候选人的回答是 CAS (compare-and-swap),也有人会提到 test-and-set 等其他操作,原理都一样,就是用前述机制实现的。


下面这段 Go 代码展示了 CAS 的逻辑:


func CompareAndSwap(p *int, oldValue int, newValue int) bool {  if *p != oldValue {    return false  } *p = newValue  return true}

请注意:这不是 CAS 的实现,如前所述,真正的 CAS 是硬件级别的指令支持的,最早出现在 1970 年 IBM 的 System 370 上,在 x86 上则是 80486 开始新增的 CMPXCHG 这个指令。


注:在多核系统上 CMPXCHG 也需要使用 LOCK 前缀,但是如果对应内存已经在 cache 里,就不用发出 LOCK 信号锁定总线,而是使用缓存锁。


由于不用锁定总线,这样的原子操作指令不会限制其余 CPU core 操作非锁定内存,因此对系统整体的吞吐量影响不大。这一点对于当今核数越来越多的系统来说尤为重要。


由于原子操作指令仍然需要在 CPU 之间传递消息用于对 cache line 的锁定,其性能仍有一定损耗,具体来说大概就相当于一个未命中 cache 的 Load Memory 指令[2]。


基于 CAS 我们可以用实现很多实用的原子操作,例如原子加法:


func atomicAdd(p *int32, incr int32) int32 { for {   oldValue := *p newValue := oldValue + incr    if atomic.CompareAndSwapInt32(p, oldValue, newValue) {     return newValue    }  }}

看,这就是一个典型的使用乐观锁的实现了:先做加法,如果更新失败,就换个姿势再来一次。


注:Go 语言 atomic.AddInt32 的实现是直接使用汇编 LOCK XADDL 完成的,不是基于 CAS 和循环。



- 自旋锁 -


回到锁的问题上,基于 CAS 我们可以很容易实现一个锁:


type spinLock int32func (p *spinLock) Lock() {  for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) { }}func (p *spinLock) Unlock() { atomic.StoreInt32((*int32)(p), 0)}

这就是经典的自旋锁[3] —— 通过反复检测锁变量是否可用来完成加锁。在加锁过程中 CPU 是在忙等待,因此仅适用于阻塞较短时间的场合;其优势在于避免了线程切换的开销


注:spinlock 是 Linux 内核中主要的两种锁之一(另一种是Mutex),感兴趣的同学可以去看看内核源码里的实现,具体位于 include/asm/spinlock.h (吐槽:内核源码真是难读)。


在 Go 版的实现里还要注意:如果 GOMAXPROCS 被设置成 1 (Go Runtime只会给用户代码分配一个系统线程),会导致上述代码陷入死循环,因此更完善的实现是:


func (p *spinLock) Lock() { for !atomic.CompareAndSwapInt32((*int32)(p), 0, 1) { runtime.Gosched() }}

通过将当前系统线程的使用权暂时归还给 Go Runtime(相当于其他语言的 yield),可以避免前述情况,但这也在一定程度上破坏了自旋锁的语义、使其变得更重了。


值得一提的是,研究人员发现,如果锁冲突比较频繁,在 CAS 失败时使用指数退避算法(Exponential Backoff)往往能得到更好的整体性能[2]。



- Mutex -


实际上 Go 语言没有提供自带的自旋锁实现,我们在代码中用得更多的是 Mutex 。


对比于 Spinlock 的忙等待,如果 Mutex 未获得锁,会释放对 CPU 的占用


上一篇我们在说 Mutex 性能不够好的时候有提到“lock does not scale with the number of the processors”,这里的 lock 指的是用 CPU LOCK信号实现的锁;而通过阅读 Mutex 的源码,我发现实际上 Mutex 底层也是使用原子操作来实现的,所以前述说法不太准确。


Mutex 针对实际应用场景做了许多优化,是一个从轻量级锁逐渐升级到重量级锁的过程,从而平衡了各种场景下的需求和性能。


具体来说有这么几项:


  • fastpath:在简单场景下直接使用 CAS 加锁和解锁,缩短执行路径

  • spin:当自旋有意义时(多核、GOMAXPROCS > 1 、尝试不超过4次),优先使用自旋

  • 饥饿公平:当等待超过 1ms 时,进入饥饿模式,新竞争者需要排队

注:对具体实现感兴趣的同学,可以结合参考资料《golang中的锁源码实现:Mutex》[5] 阅读源码。


这里提到的“公平”,指的是先到先得,这意味着每一个竞争者都需要进入等待队列,而这意味着CPU控制权的切换和对应的开销;而非公平锁,指的是在进入等待队列之前先尝试加锁,如果加锁成功,可以减少排队从而提高性能,但代价是队列中的竞争者可能会处于“饥饿”状态。



- sync -


除了 Mutex,Go 在 sync 包里还实现了很多用于解决并发问题的工具,这里简单介绍下:


· RWMutex


读写锁,通过将资源的访问者分成读者和写者,允许多个读者同时访问资源,从而提高共享资源的并发度。适用于读远多于写的场景


· WaitGroup


用于对 goroutine 的并发控制,在主 goroutine 里使用 Add(n) 指定并发数量,并使用 Wait() 等待所有任务都调用 Done() (配合 defer 使用效果更佳)。


· Pool


对象池,用于缓存后续会用到的对象,从而缓解 gc 压力。例如 fmt 包用它来缓存输出缓冲区。


· Once


“单例”:once.Do(f) 保证 f 只会被执行一次。f 被执行后,通过原子操作保证了性能。


· Cond


条件同步:当条件不满足时(通常是等待一个任务执行完成),goroutine调用 Wait() 等待通知;另一个 goroutine 完成任务后,调用 Signal() 或 Broadcast() 通知在等待的 goroutine。


· Map


支持并发的 map:通过 Load、Store、LoadOrStore、Delete、Range 等方法提供线程安全的 map 数据结构。


· atomic


提供 Add、CAS、Load、Store、Swap 等对基础数据类型的原子操作,以及 atomic.Value 来支持其他类型的 Load、Store 原子操作。



- 收尾 -


哎呀,这篇写得干巴巴的,连一个表情包都没有(忍住)。


最后照例小结一下:


  • 锁是基于原子操作实现的,而原子操作是需要硬件支持的

  • 基于 MESI 协议的缓存锁可以提高锁的性能

  • 比较常用的原子操作是 CAS

  • Go 没有官方自旋锁,我们通常用 sync.Mutex 

  • sync 包里还有很多解决并发问题的实用工具

下次考虑结合一些具体案例来讲讲,可能更有意思一点儿,为了避免错过,记得关注↓↓↓


285b47d6b7af616a593c7666880505be.png



推荐阅读:


  • 程序员面试指北:面试官视角

  • 踩坑记:go服务内存暴涨

  • TCP:学得越多越不懂

  • UTF-8:一些好像没什么用的冷知识

  • [译] C程序员该知道的内存知识 (1)


参考资料:


1.  原子操作是如何实现的?


https://zhuanlan.zhihu.com/p/33445834


2. Compare-and-swap:


https://en.wikipedia.org/wiki/Compare-and-swap


3. 自旋锁


https://zh.wikipedia.org/wiki/%E8%87%AA%E6%97%8B%E9%94%81


4. 聊聊CPU的LOCK指令


https://albk.tech/%E8%81%8A%E8%81%8ACPU%E7%9A%84LOCK%E6%8C%87%E4%BB%A4.html


5. golang中的锁源码实现:Mutex


http://legendtkl.com/2016/10/23/golang-mutex/






推荐阅读
  • 深入解析CAS机制:全面替代传统锁的底层原理与应用
    本文深入探讨了CAS(Compare-and-Swap)机制,分析了其作为传统锁的替代方案在并发控制中的优势与原理。CAS通过原子操作确保数据的一致性,避免了传统锁带来的性能瓶颈和死锁问题。文章详细解析了CAS的工作机制,并结合实际应用场景,展示了其在高并发环境下的高效性和可靠性。 ... [详细]
  • HTTP(HyperTextTransferProtocol)是超文本传输协议的缩写,它用于传送www方式的数据。HTTP协议采用了请求响应模型。客服端向服务器发送一 ... [详细]
  • 原子操作是指在执行过程中不会被中断的操作。本文将探讨Java是如何通过不同的技术手段实现原子操作的,包括CPU层面的总线加锁和缓存行加锁,以及Java层面的锁机制和CAS操作。 ... [详细]
  • 字符串学习时间:1.5W(“W”周,下同)知识点checkliststrlen()函数的返回值是什么类型的?字 ... [详细]
  • 网站访问全流程解析
    本文详细介绍了从用户在浏览器中输入一个域名(如www.yy.com)到页面完全展示的整个过程,包括DNS解析、TCP连接、请求响应等多个步骤。 ... [详细]
  • 在多线程并发环境中,普通变量的操作往往是线程不安全的。本文通过一个简单的例子,展示了如何使用 AtomicInteger 类及其核心的 CAS 无锁算法来保证线程安全。 ... [详细]
  • window下的python安装插件,Go语言社区,Golang程序员人脉社 ... [详细]
  • B站服务器故障影响豆瓣评分?别担心,阿里巴巴架构师分享预防策略与技术方案
    13日晚上,在视频观看高峰时段,B站出现了服务器故障,引发网友在各大平台上的广泛吐槽。这一事件导致了连锁反应,大量用户纷纷涌入A站、豆瓣和晋江等平台,给这些网站带来了突如其来的流量压力。为了防止类似问题的发生,阿里巴巴架构师分享了一系列预防策略和技术方案,包括负载均衡、弹性伸缩和容灾备份等措施,以确保系统的稳定性和可靠性。 ... [详细]
  • Java Socket 关键参数详解与优化建议
    Java Socket 的 API 虽然被广泛使用,但其关键参数的用途却鲜为人知。本文详细解析了 Java Socket 中的重要参数,如 backlog 参数,它用于控制服务器等待连接请求的队列长度。此外,还探讨了其他参数如 SO_TIMEOUT、SO_REUSEADDR 等的配置方法及其对性能的影响,并提供了优化建议,帮助开发者提升网络通信的稳定性和效率。 ... [详细]
  • 在Linux系统中,网络配置是至关重要的任务之一。本文详细解析了Firewalld和Netfilter机制,并探讨了iptables的应用。通过使用`ip addr show`命令来查看网卡IP地址(需要安装`iproute`包),当网卡未分配IP地址或处于关闭状态时,可以通过`ip link set`命令进行配置和激活。此外,文章还介绍了如何利用Firewalld和iptables实现网络流量控制和安全策略管理,为系统管理员提供了实用的操作指南。 ... [详细]
  • 在当今的软件开发领域,分布式技术已成为程序员不可或缺的核心技能之一,尤其在面试中更是考察的重点。无论是小微企业还是大型企业,掌握分布式技术对于提升工作效率和解决实际问题都至关重要。本周的Java架构师实战训练营中,我们深入探讨了Kafka这一高效的分布式消息系统,它不仅支持发布订阅模式,还能在高并发场景下保持高性能和高可靠性。通过实际案例和代码演练,学员们对Kafka的应用有了更加深刻的理解。 ... [详细]
  • C++ 开发实战:实用技巧与经验分享
    C++ 开发实战:实用技巧与经验分享 ... [详细]
  • 本文深入解析了Java 8并发编程中的`AtomicInteger`类,详细探讨了其源码实现和应用场景。`AtomicInteger`通过硬件级别的原子操作,确保了整型变量在多线程环境下的安全性和高效性,避免了传统加锁方式带来的性能开销。文章不仅剖析了`AtomicInteger`的内部机制,还结合实际案例展示了其在并发编程中的优势和使用技巧。 ... [详细]
  • Python 数据可视化实战指南
    本文详细介绍如何使用 Python 进行数据可视化,涵盖从环境搭建到具体实例的全过程。 ... [详细]
  • 利用REM实现移动端布局的高效适配技巧
    在移动设备上实现高效布局适配时,使用rem单位已成为一种流行且有效的技术。本文将分享过去一年中使用rem进行布局适配的经验和心得。rem作为一种相对单位,能够根据根元素的字体大小动态调整,从而确保不同屏幕尺寸下的布局一致性。通过合理设置根元素的字体大小,开发者可以轻松实现响应式设计,提高用户体验。此外,文章还将探讨一些常见的问题和解决方案,帮助开发者更好地掌握这一技术。 ... [详细]
author-avatar
SIX2FOUR
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有