您的位置:首页 > 其它

用人话讲内核2 - 同步

2014-08-23 06:04 162 查看
讲内核的过程中,也解释一下一些比较重要的概念。想想自己当年学的时候学的也是非常费劲。

同步(synchronization)是并发编程中很重要的概念。涉及到critical section,race condition,lock,mutex, semaphore,condition variable等等概念,不可谓不复杂。为了理解清楚这些,请各位看官听我慢慢道来。

1. 什么叫同步?为什么这是一个问题?

“同步”从字面上看什么都看不出来。。。但是同步问题却是普遍存在的。比如一个任务实现“两个人交替拍手”。实现两个人交替拍手实际就是一个同步问题。但是实现它并不如看起来那么简单,尤其是两个人相隔万里,很难通信的时候。

同步需要基于信息的传递或者共享。如果两个人能相互看见或者相互听见,那么交替拍手就容易。如果两个人相隔万里,就得基于某种通信手段或者共享某个媒介。比如共享电话线,大家能听到对方的拍手声就好办了。或者共享夜空,往天上放信号弹,交替拍手交替放信号弹。或者发短信,我拍一次,发条“已拍”短信,对方再来拍。这个看起来是不是有一点点信号量的意思?

这就是同步问题,其实是协同的意思。多个个体按照预先约定好的规则协同作业。

2. 为什么计算机程序需要同步?

交替拍手的确是一个同步问题,但貌似显得不是很重要。但是在现实世界里有些同步问题是至关重要的。

比如抢火车票,最后一张票,十个人同时下单,这票归谁?假如抢到票的人订完不付钱,票还得返回去。所以必须保证票的归属是互斥的,即任何时间只能归属于一人。而且在第一个人抢到单到付完钱期间,都要保证其他人拿不到票。这就是一个互斥问题。

另一个问题,假设庆丰包子每小时生产一笼包子,大家来消费。那么当包子滞销,库存满的时候就应该停止蒸包子;当客户买完的时候,就应该开始排队。这就是一个生产者消费者问题。

这些问题看起来都很直观,但为什么到了计算机里就那么烦呢。因为程序间共享的信息很有限,主要依靠内存。就像天下漆黑一片,伸手不见五指,大家谁也看不见谁,谁也听不到谁,唯一能做的是在夜幕上用灯光打字(共享内存),让天下人看到。在这种环境下,票怎么卖?包子怎么抢??

3. 策略,算法,原语

这是本文要说的重点,这是我们应该如何看待同步问题。先放下锁,信号量,条件变量等等等,让我们用 策略,算法,原语的方法来分析问题。

同步策略,就是一个同步问题的解决方案,是一个想法,是目的。

原语,是我们的工具,是手段。

算法则是利用工具实现方案的方法。

三者的关系是,为一个问题确定一种解决方案,选择原语,开发算法。

让我们从同步策略看起。最重要的同步策略就一个:互斥。一个数据任何时刻只能被一个个体访问。实现这个策略可用的原语实际是随意的,可以用锁,可以用mutex,可以用信号量等等。

锁也有很多种,spinlock, ticket spinlock,read write lock。这些锁从互斥的角度来讲都是一样的,区别在于ticket lock有FIFO得特性,所以保证公平性。读写锁则是在互斥的基础上,加入了对并发读的优化,这实际不是严格意义的互斥,因为同时有多个读者,但只要不更新,多个读者是不影响正确性的。所以在计算机的互斥中,主要关注写操作。当然,买票那个例子除外,一个人进入订票流程,别人看也不行。

如果选用mutex,其实mutex和锁用起来是差不多的。这时候需要考虑的就是互斥以外的因素,比如性能。锁是忙等的,也就是当锁被持有时,申请者会执行Pause指令,等于啥也不干,不断把CPU的计算能力白白浪费掉。所以如果等的比较久的话,推荐用mutex。Mutex就是一个会挂起的锁,申请者申请失败后,会挂起自己,把CPU让给别人,等到mutex被释放的时候再被唤醒。这样就用挂起和唤醒的代价换取CPU时间给别人用。计算机的世界到处是tradeoff (权衡),并不是挂起就一定好。因为如果挂起和唤醒的代价太大,而等的时间很短,还不如忙等呢。

如果用信号量做互斥也是可以的,比如binary semaphore。这就有点杀鸡用牛刀的意思了,所以一般大家不这么干。

其他常见同步策略还有生产者消费者问题。标准做法是选用mutex保证互斥,再加两个信号量,保证满和空得时候block。

4. 其他

4.1 race condition 和 critical section

这两个概念真是把简单的问题复杂化了,实际都是有互斥衍生出来的。为了实现互斥,我们加锁。加锁的那段代码我们叫他critical section。代码写的不好,该互斥的地方没互斥,把数据该乱了,就叫race condition。

关于critical section 需要注意的是,实际分析并发程序的时候是对“数据”加锁,不是对“代码”加锁。我们锁的是共享的数据,所以访问这段共享的数据的代码都要持有锁。这样思考比对“代码”加锁更有效,锁粒度也会更小,你就不会写出入函数拿锁,出函数释放锁这种粗犷的程序了。

4.2 关于spinlock 和 pthread_mutex

spinlock在kernel中比较常用,有两个原因。一、在持锁时间短的时候,忙等比挂起唤醒更加高效。二、在某些内核代码断中,比如linux interrupt processing,是没法挂起的。因为这段中断处理代码有些孤魂野鬼的意思,出了三界外,不在五行中,根本没法挂。

但是在userspace中,一般都用pthread_mutex。 前面说了,spinlock和mutex都可以实现互斥,所以功能上是一样的。区别在于性能。Userspace thread是会被OS挂起的,从而引起了著名的lock holder preemption问题。假设线程间用锁做互斥,当一个持锁线程被OS挂起时,其它线程为了拿锁就会忙等。正常情况下锁等待时间一般是10ns级(因为通常持锁只执行不多的指令),出现lock holder preempption时等待时间会成百上千倍增长。因为一个被挂起的线程通常过几毫秒才会回来释放锁。注意这种挂起不是自愿的,而是OS挂起一个线程把CPU分给了别人。这些等待时间的CPU
cycle都被浪费掉了。所以我们用mutex,虽然等待时间不会变短,但是等待线程会被挂起,CPU资源能被让出来给需要的人。当然,如果你说所有的资源都归我,浪费了无所谓,那你就可以大胆在多线程程序里用spinlock了!

4.3 关于无锁内核

最近一个专门为Java和虚拟化优化的操作系统OSv直接搞出一个无锁内核。为什么?

前面提到kernel经常用spinlock,但是userspace不推荐,因为用户态线程会被内核挂起。现在同样的问题发生在了内核身上:虚拟机里的virtual CPU可能被虚拟机管理器(hypervisor)挂起。于是完全一样的lock holder preemption问题出现了。而且即使现在最新的linux kernel也没完全避免这个问题。

所以,内核也不要用spinlock了,用mutex吧。但是刚才还提到了一些出了三界外,不在五行中的孤魂野鬼。为了让它们也支持用mutex,OSv把它们也都收编进了kernel thread的模型,于是它们也可以被挂起了。

4.4 什么时候选用什么原语

线程间用什么,进程间用什么?简单的说,只要能share,大家都能看到相同的变量,用啥对线程进程没区别。选一个简单的,合适的就好。

4.5 Golang Channel

Google Golang 里边有一个新的同步原语叫做channel。这个原语用来做啥最有效我还不是很熟,但是起码生产者消费者问题就很容易解决,生产者自动往channel里丢数据,channel满了自动block了就。消费者也是直接取,channel空了就block。剩下的就交给编译器吧!

5. 结论

本文重点就像让大家分清同步策略和同步原语。在交替拍手问题中,实现交替同步策略,需要知道两人共享什么信息媒介(原语)。无论是电话,短信,信号弹(原语),我们都能找到合适的方法(算法)来实现我们的策略,你说是不?

6. 后记

《用人话讲内核》系列主要介绍操作系统的概念,作者根据自己多年的学习和理解,不谈实现细节,主要介绍概念和思想。 供初学者参考讨论。有误之处,多多指教。 以后可能会慢慢谈到虚拟化技术,资源管理,云计算等等。东西很多,慢慢聊。

关于作者

欧阳马上毕业,中国科学技术大学学士,美国匹兹堡大学在读博士生,研究方向操作系统,虚拟化。2013,2014年暑假作为实习生在美国VMware, Palo Alto ESX内核组从事CPU调度研发工作。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: