许式伟的架构课
许式伟
七牛云 CEO
84945 人已学习
新⼈⾸单¥68
登录后,你可以任选4讲全文学习
课程目录
已完结/共 89 讲
许式伟的架构课
15
15
1.0x
00:00/00:00
登录|注册

12 | 进程内协同:同步、互斥与通讯

写操作阻止一切,不管读操作还是写操作
读操作不阻止读操作,阻止写操作
具体用法
类型安全的管道
用法
执行体间的通讯机制
用法
设计精巧又强大
更通用的同步原语
等待它们一起做完
分配给n个执行体并行去做
把一个大任务分解为n个小任务
读写锁
互斥体的使用范式
避免多个执行体同时操作一组数据产生竞争
用于多个执行体之间的互斥访问
channel
管道
条件变量
等待组
互斥体/锁
与操作系统无关
CPU提供的能力
执行体的通讯
执行体的同步
执行体的互斥
原子操作
进程内协同

该思维导图由 AI 生成,仅供参考

你好,我是七牛云许式伟。
上一讲开始我们进入了多任务的世界,我们详细介绍了三类执行体:进程、线程和协程,并且介绍了每一种执行体的特点。
既然启动了多个执行体,它们就需要相互协同,今天我们先讨论进程内的执行体协同。
考虑到进程内的执行体有两类:用户态的协程(以 Go 语言的 goroutine 为代表)、操作系统的线程,我们对这两类执行体的协同机制做个概要。如下:
让我们逐一详细分析一下它们。

原子操作

首先让我们看一下原子操作。需要注意的是,原子操作是 CPU 提供的能力,与操作系统无关。这里列上只是为了让你能够看到进程内通讯的全貌。
顾名思义,原子操作的每一个操作都是原子的,不会中途被人打断,这个原子性是 CPU 保证的,与执行体的种类无关,无论 goroutine 还是操作系统线程都适用。
从语义上来说,原子操作可以用互斥体来实现,只不过原子操作要快得多。
例如:
var val int32
...
newval = atomic.AddInt32(&val, delta)
等价于:
var val int32
var mutex sync.Mutex
...
mutex.Lock()
val += delta
newval = val
mutex.Unlock()

执行体的互斥

互斥体也叫锁。锁用于多个执行体之间的互斥访问,避免多个执行体同时操作一组数据产生竞争。其使用界面上大概是这样的:
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
锁的使用范式比较简单:在操作需要互斥的数据前,先调用 Lock,操作完成后就调用 Unlock。但总是存在一些不求甚解的人,对锁存在各种误解。
确认放弃笔记?
放弃后所记笔记将不保留。
新功能上线,你的历史笔记已初始化为私密笔记,是否一键批量公开?
批量公开的笔记不会为你同步至部落
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
  • 深入了解
  • 翻译
    • 英语
    • 中文简体
    • 中文繁体
    • 法语
    • 德语
    • 日语
    • 韩语
    • 俄语
    • 西班牙语
    • 阿拉伯语
  • 解释
  • 总结

本文深入探讨了进程内协同的相关内容,主要包括原子操作和执行体的互斥。首先介绍了原子操作的特性,强调了其不会被中断的原子性,以及与互斥体的关系。接着详细讨论了执行体的互斥,包括互斥体的使用范式和对异常安全的代码的要求。文章还提到了锁的问题所在,包括不易控制和锁粒度的问题,并介绍了在“读多写少”情况下使用读写锁的优化性能的方法。此外,还介绍了同步的一个最常见的场景,即“等待组”的使用方式,以及条件变量的更通用的同步原语。文章还涉及了执行体间的通讯机制,包括管道和类型安全的管道——channel的使用。总的来说,本文通过对原子操作和执行体的互斥进行详细解释,帮助读者更好地理解进程内协同的相关概念和技术特点。

仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《许式伟的架构课》
新⼈⾸单¥68
立即购买
登录 后留言

全部留言(67)

  • 最新
  • 精选
  • 笨拙的自由
    希望老师类比java说一下,特别是条件原语那块看得有点懵

    作者回复: 如果用 Java,代码看起来是这样的: class Channel { private final Lock lock = new ReentrantLock(); private Condition cond = lock.newCondition(); private final Queue queue = new Queue(); private int n; public Channel(int cap) { n = cap; } public void push(Object v) { lock.lock(); try { while (queue.size() == n) { cond.await(); } if (queue.size() == 0) { cond.signalAll(); } queue.push(v); } finally { lock.unlock(); } } public Object pop() { lock.lock(); try { while (queue.size() == 0) { cond.await(); } if (queue.size() == n) { cond.signalAll(); } return queue.pop(); } finally { lock.unlock(); } } }

    2019-05-24
    3
    51
  • 立耳
    许老师,下面的这段代码是不是存在问题,以Push为例,应该先执行 c.queue.Push 再进行广播,否则可能通知到其他协程进行Pop,系统调度可能先进行了另外一个协程的c.queue.Pop(), 这个时候还没有入队列。 func (c *Channel) Push(v interface{}) { c.mutex.Lock() defer c.mutex.Unlock() for c.queue.Len() == c.n { // 等待队列不满 c.cond.Wait() } if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们 c.cond.Broadcast() } c.queue.Push(v) } func (c *Channel) Pop() (v interface{}) { c.mutex.Lock() defer c.mutex.Unlock() for c.queue.Len() == 0 { // 等待队列不空 c.cond.Wait() } if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们 c.cond.Broadcast() } return c.queue.Pop() } func (c *Channel) TryPop() (v interface{}, ok bool) { c.mutex.Lock() defer c.mutex.Unlock() if c.queue.Len() == 0 { // 如果队列为空,直接返回 return } if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们 c.cond.Broadcast() } return c.queue.Pop(), true } func (c *Channel) TryPush(v interface{}) (ok bool) { c.mutex.Lock() defer c.mutex.Unlock() if c.queue.Len() == c.n { // 如果队列满,直接返回 return } if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们 c.cond.Broadcast() } c.queue.Push(v) return true }

    作者回复: 代码没有问题的。先 Push 还是先通知都可以,次序可以交换的。因为反正锁还没有释放,这里只是标记一下哪些执行体可以调度,并没有真正发生控制权的转移。而且就算转移了也没问题的,你可以留意下本文贴的条件变量的 Wait 函数实现,它获得控制权后下一句就是 mutex.Lock 去申请锁,而我们这里是 Push 后才调用 mutex.Unlock 释放锁的,所以 Broadcast 和 Push 的次序可以随意交换。

    2019-05-27
    4
    22
  • 蚂蚁内推+v
    协程和线程还是没区别清楚

    作者回复: 一个操作系统调度,一个用户态自己来调度

    2019-05-24
    4
    18
  • Geek_gooy
    老师 我明白了, 比如A线程notify或者signal,被唤醒的线程并不会马上执行,而是需要等待A线程退出同步块或者unlock才会执行。 如果是notifyAll,也同样如此,但是等到唤醒并获得执行权的线程执行结束后,CPU会优先把执行权交给上次唤醒没有得到执行权的某个线程,而不会给阻塞在锁外面等待锁的线程。和调用notify只唤醒一个还是有些许区别的。

    作者回复: 是这样,你可以看我文章中Wait的代码,在唤醒后第一件事情是lock,也就是请求锁,所以只有A线程unlock后,其他被唤醒的线程中的一个会得到锁往下走。

    2019-05-26
    13
  • Geek_gooy
    老师 1、像这种有进有出的是不是应该创建两个condition。大小为满时,避免进的线程,唤醒的可能还是进的线程。大小为零时,出的唤醒的还是出的线程。 2、cond.signal()方法把lock锁释放了吗,如果释放了,后面再unlock是不是没做任何操作。 3、像老师评论中的Java单锁channel举例改为普通的sync,object.wait(),notify是不是效果一样,但性能没lock好。对于两个condition,java对象的notify就不好指定唤醒了。

    作者回复: 1、你是对的,用两个cond性能会更好。但是用一个也是可以正常工作的。 2、cond.signal 不是把锁释放了,是让等待在这个cond上的执行体改变状态(从挂起到可被调度),从而允许调度程序给它执行权。 3、对的

    2019-05-25
    12
  • f抵达
    如果是用户太自己对寄存器进行操作? 对物理器件的操作不都是要经过系统调用么? 难道协程x是用户态的操作系统?

    作者回复: 寄存器不是输入输出设备,操作寄存器不需要经过操作系统,编译器整天和寄存器打交道的。

    2019-05-26
    9
  • Taozi
    总算明白为什么叫条件变量了,拿这里的Channel 实现来说,几个执行体要读写的队列是“变量”,队列的长度是“等待条件”和“唤醒条件”。是这样理解吗?

    作者回复: 对的

    2019-05-25
    8
  • Cordova
    看完代码后发现go做的通信发现并没有什么优势,其他语言做通信也这么干、刷了下评论区提到libevent、才恍然发现标题这节讲的是同步!可能是我太期待许老师讲异步了😂~ 目前python我用异步首选会把异步过程交给libuv。听了老师上节讲到python的协程只是一种编程范式,想到内置的asyncio虽然是做了异步但还是有很较大的性能提升空间这个逻辑也就通了!希望许老师在讲异步的时候能多提一提跨平台异步库他们是怎么实现

    作者回复: 1、go和java的代码只是形似,实质不同,因为go里面的channel是协程的通讯设施,java版本的是线程的通讯设施,大相径庭; 2、我们本节提的同步,和同步io的同步,两个是完全不同含义的同步; 3、我们课程不太会讲资料已经相对多的某个细节,除非这个细节非常关键影响到全局的理解。

    2019-05-28
    2
    7
  • Eternal
    信号量只能实现一个条件,条件变量能实现多了条件,不知理解对不对

    作者回复: 信号量的条件太死板,不可编程。条件变量的条件是任意条件,是可编程的。

    2020-09-11
    6
  • Ender
    还是没太明白条件变量在channel代码里面的意义,所有操作都是先获取锁,在一个操作没完成的情况下其他都不会进到cond.Wait()呀。按理只需要锁就能做到了channel的实现了。

    作者回复: 向 channel.push 一个对象时,要考虑 channel 满了,这时会等待,这就是 cond.Wait 的逻辑

    2019-06-06
    5
收起评论
显示
设置
留言
67
收藏
沉浸
阅读
分享
手机端
快捷键
回顶部