• Golang 的锁


    前言

    go 语言的锁, 一说大家都知道, 一个是互斥锁Mutex, 一个是读写互斥锁RWMutex, 用起来很简单, 但是要想在技术上更进一步, 还是需要了解其原理

    基础知识

    进程同步

    既然是锁, 就意味着在加锁之后, 其他goroutine获取锁, 就需要等待, 这里就需要了解操作系统的进程同步机制

    进程同步其实是控制临界区内的权限, 当一个进程进入临界区时, 其他想进入临界区的进程只能等待, 也就是说, 临界区是互斥的, 而临界区内的资源, 则是临界资源

    访问临界区有几种情况:

    • 空闲让进: 当没有进程在临界区时, 代表临界区是空闲的, 此时一个进程可以立即进入临界区, 访问临界资源
    • 忙等: 当已经有进程在临界区内时, 代表临界区有人正在访问, 所以其他想要进入临界区的进程必须等待
    • 有限等待: 当进程等待访问临界区时, 设置一定的超时时间, 避免一直等待
    • 让权: 当进程访问临界区发现需要等待时, 立刻释放资源, 避免夯住

    信号量

    那么在一个进程结束后, 怎么通知其他进程进入呢? 这就需要信号量

    信号量分为四种类型:

    • 整形信号量
    • 记录型信号量
    • AND 型信号量
    • 信号量集

    以整形信号量为例, 核心是设置一个用于表示资源数量的整形 A, 这个 A 有两种操作,

    一个是减小(wait), 一个是增大(signal)

    在进入资源区时进行wait操作, 在退出时进行signal操作

    通过对信号量的大小对比, 可以获取当前等待进程和正在运行的进程数量与当前的状态

    自旋

    在进程不能进入临界区时, 会进行等待, 但是并不是立刻就进入等待了, 而是先不停的去侦测这个锁是否有被释放掉, 这个过程被称作自旋, 这也是锁的一种, 在自旋过程中, 如果发现临界区空置了, 就立刻进入临界区.

    自旋其实就是隔一段时间访问一次临界区查看是否空闲, 所以自旋过程中, 这个进程并不是等待的

    自旋的存在目的是为了更加的高效, 因为有可能在等待时间很短, 自旋可以让进程不必切换状态

    而缺点是因为进程不是等待, 还在运作, 会导致资源损耗, 所以在等待时, 先使用自旋进行获取锁, 在几次之后依旧获取不到, 则变为等待状态

    自旋的问题

    如果每一个新来的进程都在获取不到锁时, 进行回旋, 就会出现一个问题, 比如之前有3个进程在自旋后依旧没有获取到锁, 进入到了等待状态, 此时一个新的进程来了, 他在获取不到锁时, 进入了自旋状态, 而此时占用锁的进程退出了, 那么这一个新的进程就会最先获取到锁, 类似于插队的问题, 这样显然是不公平的, 为了解决这个问题, 一般是为锁增加饥饿状态, 在饥饿状态下, 不允许进程进入自旋, 直接等待

    互斥锁

    在了解基本的知识后, 我们来套入到 go 语言中

    互斥锁在被占用后, 其他协程完全无法访问, 不可读更不可写

    // 互斥锁, 占用后不可读也不可写
    var lock sync.Mutex
    lock.Lock()  // 加锁
    lock.Unlock()  // 解锁
    

    结构

    sync.Mutex的数据结构如下

    type Mutex struct {
    	state int32  // 当前互斥锁的状态
    	sema  uint32  // 锁的信号量
    }
    

    state

    state记录了四个状态. 分别是:

    • waiter_num(29bit): 当前等待抢占这个锁的goroutine数量
    • starving(1bit): 当前锁是否处于饥饿状态(0:无/1:有)
    • woken(1bit): 当前锁是否有goroutine已经被唤醒(0:无/1:有)
    • locked(1bit): 当前锁是否被goroutine持有(0:无/1:有)

    sema

    sema 记录了信号量, 当锁被一个goroutine释放时, 会释放这个信号量, 唤醒之前抢锁的正在阻塞的goroutine来获取锁

    运行流程

    正常模式

    在正常模式下, 等待的协程按照先入先出的方式排列, 当一个协程被信号唤醒后, 这个协程并不是直接获取到锁, 而是和刚刚到达的协程一起竞争锁的所有权.

    新到的协程有一个优势, 就是他因为刚到, 现在还在 CPU 上运行, 而唤醒的协程, 刚从等待状态准备启动, 而且新到的协程有可能不止一个, 所以这个被唤醒的协程很大概率抢不到锁.

    为了解决这个问题, 被唤醒的这个协程会被放到等待队列的第一位. 由他来第一个获取锁, 如果等待的协程超过1ms 内没有获取到锁, 此时会把这个锁设置为饥饿模式

    饥饿模式

    在饥饿模式下, 解锁的协程会将锁的所有权直接交给等待队列第一位的协程. 并且新的协程到达也不会进行自旋来获取锁, 而是直接加入等待队列的队尾.

    而等待队列中的协程获取到锁的时候, 会查看

    • 自己是否是等待队列的最后一个协程
    • 自己的等待时间是否小于1ms

    如果有任意一个满足要求, 则将锁修改为正常模式

    锁在初始时, 为正常模式, 正常模式下效率更高, 因为在释放锁的一瞬间新的协程可以迅速获取锁, 而饥饿模式则在有协程等待1ms 之后运行, 可以让等待的协程优先获取到锁

    读写锁

    读写互斥锁可以添加两种锁, 读锁和写锁, 在读锁上锁时其他协程可读不可写, 写锁上锁时其他协程不可写不可读

    // 读写互斥锁
    var rwlock sync.RWMutex
    rwlock.RLock()  // 读锁, 此时其他协程不可写, 不可读
    rwlock.RUnlock()
    
    rwlock.Lock()  // 写锁, 此时其他协程无法写, 可以读
    rwlock.Unlock() 
    

    结构

    type RWMutex struct {
      	w           Mutex  // 写锁(互斥)
    	writerSem   uint32 // 写锁信号量
    	readerSem   uint32 // 读锁信号量
    	readerCount int32  // 读锁计数器
    	readerWait  int32  // 等待读锁释放的协程数
    }
    

    读写锁中等级最高的锁还是写锁, 当加上写锁后其他协程无论是读还是写都会阻塞, 所以写锁也是互斥的

    运行流程

    这里就分为读锁和写锁了, 当用户上写锁, 其实就是上了个互斥锁, 此时所有新的协程都会阻塞, 类似于上面的互斥锁, 这里不过多的赘述

    而加读锁, 实际上就是一个数字, readerCount自增, 此时如果加写锁, 会判断读锁计数器是否为0, 为0则上写锁(互斥), 如果加读锁, 就是readerCount自增

    如果写锁释放了, 此时有一些协程请求加写锁, 一些请求加读锁, 会优先将锁分配给写锁

    如果读锁释放了, 此时有一些协程请求加写锁, 会等待readerCount为0时加写锁

  • 相关阅读:
    敏捷个人2011.7月份第一次线下活动报道:迷茫、游戏和故事中的敏捷个人.
    敏捷开发:60分钟掌握敏捷估计和规划
    敏捷之旅北京2011.11月份活动报道:让敏捷落地
    敏捷个人2011.6月份线下活动:拖延、知道力分享
    答TOGAF企业架构的一些问题
    活动推荐:Agile Tour 2011北京站“让敏捷落地”
    敏捷个人2011.5月份线下活动主题一:培养好习惯
    第二届清华大学项目管理精英训练营【敏捷个人】分享
    产品管理:产品的三种驱动类型技术、销售和市场驱动
    敏捷个人线上线下活动PPT及照片做成的视频共享
  • 原文地址:https://www.cnblogs.com/chnmig/p/16744166.html
Copyright © 2020-2023  润新知