蓝天

多核环境下的内存屏障指令


周老师那个 session 正好排在我的前面。同一间会议室,而且内容我也颇有兴趣。也就顺理成章的听了。讲的东西其实满不错的,唯一的抱怨是太像在大学里授课,互动少了点。会场气氛远不如后来 Andrei 讲 Lock-Free Data Structures 那么精彩。

周老师讲的这块内容,正巧我前几年写多线程安全的内存分配器时碰到过,有点研究。加上前几年对 Intel 的东西颇有兴趣,便有了发言的冲动 :) 。当时的会场上下文环境正好是有个朋友提问说:实际上,InterlockedIncrement 的调用是多余的。(事后交换名片得知,提问的这个哥们是来至 google 的程序员)

如果换在几年前,我是赞同这个哥们的观点的。记得 04 年左右,我们公司内部的 maillist 上曾经有类似的讨论。即,在 32 位系统上,写一个 dword 本身就是原子的,如果 cpu 可以保证程序逻辑上的执行次序(program ordering),那么简单的利用写操作就可以替代锁操作。我们在操作完一大片内存数据后,只需要在最后更改关键的标记字,那么不需要加锁也可以保证安全。(还有一个隐含的前提是数据必须被 32bit 对齐)

btw, 当天晚上 Andrei 讲 Lock-Free Data Structures 时向大家提的问题:那个 hazard list 为什么要用单向链表实现?大约也是这个意思,因为链表指针可以被原子的修改而无需加锁。

单核时代它是对的,因为单核 CPU 要求读写操作 self-consistent 。多嘴两句解释一下,现代 CPU 工作时的指令执行次序(process ordering)是可以不同于程序编制的次序(program ordering)的,即乱序执行技术。这个技术可以极大的提高流水线的工作效率。单核 CPU 保证读写操作的 self-consistent 意味着,等到真正读入操作数据时,数据符合 program ordering 上的正确性。

可问题也出在这里。随着多核的发展,为了提高每个核上的流水线效率,多核环境不再保证其安全。在每一个核上,cpu 内部工作时指令都可能被乱序执行。那么逻辑次序上后写入内存的数据未必真的最后写入。核与核之间作为一个整体看的话,却不保证 self-consistent 了。

也就是说,如果你不做多余的防护措施,在一个核上写入一批数据后,如果你期望最后写一个标记数据表示前面的数据都已经准备好;然后从另一个核上依靠判断这个标记位来判定一切数据就绪。这个策略并不可靠。标记位可能被先写入,但其它数据尚未真正写入内存。

解决的方法是,在标记位被写入前,强迫 CPU 串行化。InterlockedIncrement 和它的兄弟们可以提供这种安全性。翻译成 Intel 指令,会发现它在汇编指令前加了 lock 前缀。也就是在这些读写内存的指令在发起时,cpu 会向总线发出一个 lock# 的信号,阻塞住其它内存访问请求。

但这么做未免效率太低。这种影响总线的指令会随着核越来越多而变的越来越低效。可以想象,任意一个核上发起 lock 几乎会让所有的核都短暂的停止工作(除非完全不访问内存?)。我们今天只有两个或四个核,性能影响微乎其微。但是等到机器拥有 32,64 甚至更多核时,就可能相当严重了。

ps. 上面这个说法也不全然正确。因为既然内存锁在多线程编程中运用的非常广泛,自然在芯片设计上是要做优化的。在 Pentium Pro 以后,当被访问内存处于 cache 中时,lock# 信号不会被发到总线上,取而代之的是锁住 cache 。这样代价会小的多,但是在某些情况下依旧昂贵。(当多个核 cache 住同一块内存时会受影响)

轻量一点的方案是执行一条 CPUID 指令,它也可以保证前面的操作被串行化。到了Pentium III ,Intel 在 IA32 指令集中增加了 SFENCE 指令用来提供更细的控制粒度以更少的代价解决这个问题。在指令序列中插入 SFENCE 可以保证在此指令之前的写操作全部完成(非写操作的指令依旧允许乱序执行)。这样我们在另一个核里读相同内存时,几乎不会出错。

在这里我用了“几乎”,是因为诸如访问单项链表,判断标志数据的编程逻辑,对内存的读操作都是上下文相关的。我们可以断言执行次序不会被乱序执行影响。构造一个可能因为乱序读内存而有出错隐患的合适例子不太容易。但是从 Pentium 4 开始,严格上讲,我们需要用 LFENCE 指令(Pentium 3 没有提供也不需要这条指令)配合使用。它可以保证在此之前的 program ordering 上的读内存操作已经完成(否则逻辑结果可能因为多核间同步 cache 等原因而受到影响)。另外有 MFENCE 指令粒度粗一点,可以同时保证读写内存操作都已完成。


本来不打算翻资料,凭印象写的。写完这篇 blog 审了一遍,还是担心留下重大错误。就又一次翻阅了 2005 年在 Intel 网站上免费索取的 IA-32 Intel Architecture Software Developer's Manual Volume 3 的 Chapter 7 :Multiple-processor management 。

核对后,我想大概意思应该写清楚了,如果有小 bug 还请行家多多包涵。真有兴趣把这点东西搞清楚的朋友,莫信我的一家之言,查 Intel 的手册吧,它写的更加清楚和权威。btw ,不要问我该去哪找,去问 google 。

TrackBack

如果你想引用这篇文章,请复制下面的链接发送引用通告(GBK)
http://blog.codingnow.com/mt/mt-tb.cgi/317

Comments

只要设计良好,死锁的情况是可以避免的。的确,使用操作系统的多线程同步原语可能会出现死锁的情况,的确,使用 lock-free 在执行效率上更加有优势。但是我要说的是,从目前的情况来看,使用 lock-free 进行编程出现错误的可能性,绝对要比使用同步原语出现死锁的可能性更大,而且我更加认为 lock-free 的代码的可维护性是不会令人满意的。lock-free 目前很不成熟,几乎是不可能用到工程上的。Cloud 自称其资源加载模块运用了该技术,但是就算成功了,这样的代码谁能看懂呢,谁又愿意去维护呢?而操作系统提供的同步原语则是很成熟,并且能够为大多数人接受的。正是因为操作系统对多任务的支持,才简化了我们编程的难度。以目前的情况来看,操作系统的多任务支持和同步原语已经足够满足我们的需求(即便是做游戏开发)。如果一项技术或工具在实现或使用时太过复杂,我想富多人会更加倾向于使用相对简单的,但同样可以解决问题的方法,即便其效率会差一点。而我一直都认为,一个系统越复杂,其出错的可能性就越大,一项技术也是如此。

另外说一下,Windows系统下似乎没有实时线程、内核线程的的概念,Windows中所谓的实时其实是相对的概念,也就是优先级的概念,因为 windows 本身就不具备实时系统的特性。用户模式和内核模式的不同在于访问权限,而与优先权没有太大关系,Windows NT 采用的是微内核架构,即便是驱动程序绝大部分也都是运行在用户模式下。驱动中处理中断的方法和简单,一个事件与中断相关联,一个线程执行一个 while 循环,在循环中 WaitforSingleObject 等待中断事件,如果等到说明中断发生,则进行中断处理。线程阻塞时是不会占用处理器资源的,而所谓的死锁,也不会占用处理器资源,只是死锁的线程永远都处于阻塞状态,而永远都不会有执行的机会。正常下,如果线程中没有死循环和同步原语,线程是可以很快的跑到自己的终点。如果线程有较长的代码执行,他才会占用大量的处理器资源。

如果担心自己写的几个线程中,由于某个线程被提升到实时的高优先级关系,而长期霸占 cpu ,导致其它部分的逻辑无法工作。那么就应该使用用户态线程。

在这个问题上,并不是内核线程和用户线程那个优先级高,对系统的控制力强的问题。

区别在于,内核不知道用户逻辑到底在干什么,它的调度方式对用户逻辑是无关。

而把线程调度放在用户态做,可以依赖用户的逻辑来工作。

把多个用户线程塞在一个内核线程里,就无所谓这个内核线程是不是实时的了,也就是让所有用户线程共享同一个内核级别上的线程优先级,而用户自己的代码调度自己的线程,可以控制适的将一条线上的逻辑释放出 CPU 交给另一线的逻辑去工作。

至于 lock-free ,本质上是提供一个不会死锁的并行方案。另外提供了一条思路加快并行工作的效率。它的出现并非是克服 lock 的性能瓶颈的(IMHO)。

在FreeBSD中,内核线程是分为几类的,例如ithread,可以用于中断处理线程,一般用于软中断,比如在第三层协议栈的处理上,就采用了软中断线程的方式。在网络严重拥挤的情况下,比如用smartbits打压的时候,系统也会出现假死的情况,但这也不是死锁,只是没有足够的时间片用于响应键盘、控制台的IO了。因此内核的进程调度是有足够的强制权的。
在要求实时性高的场合,最好的方式就是尽量少用锁,哪怕是多几个“立交桥”。mp下的锁如同红绿灯,计算小没有关系,计算大就成了瓶颈了。这种情况下就需要很多的经验和技巧了,比如批量处理或将必加锁的时间段相对延长。
而且个人觉得lock-free并不会对MP带来革命性的进步,而且随着MP数的增长,lock-free也会成为瓶颈,除非某一天“局部性原理”被突破或者被绕过去了。
To Cloud:内核如果都没法调度,就不要指望用户层会调度好,用户层的优先级太低了。

补充一点关于 Windows 的材料,Windows NT 并不提供严格意义上的实时操作系统设施。

参见:Real-Time Systems and Microsoft Windows NT

http://msdn2.microsoft.com/en-us/library/ms810433.aspx

楼下的提交评论可否改一下不留名的习惯?我不太想改设置完全禁止匿名评论。

江峰的问题是“想以 CAS 方式获得锁”,但是 CAS 本就工作在 lock-free 下况,并非 lock-based 的。根本没有资源被锁住。采用 CAS 的机制也就不会去做 wait-sleep 了。

不管是不是实时线程,全部采用 CAS 机制更新数据,程序都不会有问题。因为要么在处理数据时没有线程切换,CAS 总能成功;要么有线程切换,数据迟早会被正确提交。

至于另一个问题: os 对内核线程的调度。总会有一定机制把低优先级的线程调高,防止饿死。如果担心内核做不到这点(或是强制把线程级设到最高不允许有线程被调整到更高),用用户态线程好了。调度是自己(用户态的库)做的,又有什么好担心的呢?

to rockcarry & 江峰:
你们最初提到的那个不是 priority inverse 的问题。priority inverse 一般来说要有3个进程互相影响,才算。而且这个问题应该不能算死锁,应该算是活锁……

to cloud & 江峰:
现在的 OS 用的基本上都是 multi-queue 的调度算法,高优先级的队列里面还有进程运行,低优先级的队列里面堆满了进程也不会去运行(广义的来说是这样,实际上不排除有的系统对于多个队列也 round robin 的)。所以对于 Linux 2.6 来说,想让低优先级的运行,低优先级就可以运行,不想让它运行,它就永远在实时线程后面。

to Siney:
这里有一个问题,实时类型的线程的优先级是不会变动的。而其他类型线程优先级会随着运行时间的变化受到调度器的调整(一直跑的程序的优先级会降低)。如果你把线程的优先级调整到实时,那么显然其他的线程都不会响应了……

to m:
Windows XP 的内核是 nonpreemptive 的。如果一个线程在 kernel mode 进行某些耗时很常的行为,那么就会造成响应很差,严重一点就是假死……

关于一开始的江峰提到的那个问题,我认为这个属于设计上的问题。当一个线程无法获取到 lock 的时候,应该采取正确的手段来处理这种情况。不停的尝试去获得一个锁显然是有问题的,kernel 这个时候显然应该应该让这个线程去 sleep,等待唤醒。而一般的 mutex 也是这样干的。对于 spinlock 的话,应该 spin 一会儿然后去 sleep,因为 spinlock 为的是减少不必要的上下文切换。假设在 MP 系统上,A已经占有一个 lock,B 申请这个 lock,但 A 很快就可以释放这个 lock 的情况下,B可以不去 sleep,而直接 spin 一会儿等 A 释放 lock,从而避免了上下文切换。另外要注意的一点是,千万不要让一个获得了 lock 的线程带着 lock 去 sleep,这样很危险,很可能造成死锁。

对于linux下的进程调度,个人不认为会允许某一个实时进程始终占据时间这个资源。我对freebsd熟一些。这些开源的系统都会相互借鉴的。对于这些用户层面上的程序,优先级是始终低于内核的响应的。原因在于内核掌控着所有的中断,而这些中断,尤其是硬中断响应级别是非常高的,比如时间、网卡、终端等这些中断响应级别是非常高的。时间片到了,内核进程调度就会根据算法决定下一个进程,进行上下文的切换。
CAS从高级语言这个机器层上来说,就是一个原子操作了,对于CAS不存在死锁。
windows和Linux/freebsd的调度是不同的,所以windows经常会假死掉(尽管从xp后好多了),windows过于放权,导致一些无赖不放手,而且记忆中windows的内核都是通过消息通过各个模块构建起来的,所以不如这些"单核"这么"霸道"。

我以前也以为系统会自己调度线程,不管线程的优先级高低,总会有机会获得cpu时间,但经过我写程序测试发现(windows),高优先级的程序会妨碍低优先级的程序运行,windows的线程的优先级还有个delta量,系统的会根据delta量再次调节线程的优先级,如果低优先级的经过调节后还是低于高优先级的线程(优先级很高的线程),那么低优先级的线程是永远不会执行的.

可以简单的写个测试程序,比如写个控制台程序,死循环打印一个数,如果你把这个程序的优先级调到很高(通过任务管理器),其他进程都不响应了.

如果调到实时状态,连任务管理器都无法打开.

我想,即使这个线程不会被打断一直跑下去,问题也不会出在死锁上。

因为,这个线程获得 expected 到使用 CAS(v,expected,updated) 更新这个变量之间同样不会被打断。CAS 总是会成功的。除非你混杂用 lock-free 和 lock-based 的策略。

btw, 我还是对这种行为深表怀疑,os 的内核线程调度器会允许一个内核线程永远霸占 CPU ?如果真有 os 允许这样的话,使用用户态线程好了。让用户态的线程库自己利用时钟信号做线程切换。

TO 云风:
linux2.6中实时进程确实如此。我手头没有权威资料,搜索了一下,找到了这个网页说到了这个:http://pages.cpsc.ucalgary.ca/~madhukar/TA/assignment2.html 当然了,它是否权威难说,但是我确信有这么回事。其中FIFO算法就是协作式的,如果一个采用FIFO调度算法的进程不退出,也没有比它优先级更高的进程,就会一直运行下去。而我们通常情况下的进程都是OTHER这类算法,所以不会有哪个进程永久霸占CPU。里面有这么一段话,这里我copy一下:
A word of caution: Your Linux Shell is using SCHED_OTHER algorithm therefore any Real Time process will take away CPU time from the Shell. This means that Real Time process with an infinite loop in it cannot be stopped! Keep this in mind when experimenting with real time processes.

我认同CAS是原子的,但问题是,在某种情况下,试图用CAS这种方式来保证互斥会导致程序死锁,例如我在四楼所描述的情况。我想知道是我这样的分析是不是对的?如果是对的,那么CAS是怎么用于现实编程的?例如jdk中的concurrency包,我看了一下source code,确实是用了compareAndSwap()。但是我没有往底下看,没功夫。呵呵。

谢谢云风以及各位!

to 江峰:

“至少对于linux2.6,如果系统有且只有一个实时进程,那么只要它的进程状态是runnable,那么就会一直跑。”

我不熟悉 linux 不敢妄言,你的意思是说:如果有个线程是 *实时* 的话,它不主动释放时间片就会一直跑下去?

我对此深表怀疑。那样的话,岂不是退回到了 windows 3.1 时代的协作式多线程模式了?

现在大多数 os 都会在时间片用完后把正在运行的线程切出去啊。

另一方面讲,若真是如此,CAS(v,expected,updated) 根本不会失败。因为当前线程获得 expected 到用 CAS 提交 updated 中间没有人打断。

“有且只有一个实时进程”如何定义?如果执行 while(!CAS(v,expected,updated)); 而不用锁的线程就是 *实时* 的,那么工作在 CAS 下的所有线程就都是 *实时* 的了。

关于 CAS 原子性保证的问题,下面我已讨论过,如果 CPU 不提供 CAS 的原子性,我们只需要每个线程把要提交数据利用写操作的原子性写到自己的一个单向队列中。再由一个独立的仲裁线程去原子的读其它所有线程的提交,并给出仲裁结果即可。

不好意思,这两天有道正式版发布,忘记来这里了,呵呵。顺便也做个广告,去看看有道( http://www.yodao.com/ )。

我提出的问题其实是这样的:抛开gc之类的不说,就说Andrei同学那天讲的CAS的基本原理,是这样的:
while(!CAS(v,expected,updated));
其语意是,如果v等于expected,那么就把updated赋值给v。并且整个CAS原语是由CPU保证的。

顺便说一句,我似乎看到有人在讨论单核读写内存是否是原子操作的问题,Andrei已经讲过了,即便读、写、条件判断是原子操作都没有用。他的原话中文翻译是:“如果给你一个能保证原子赋值操作的CPU你会买吗?”。答案是否定的。

回到正题。我开始的提问是说,想以CAS的方式获得lock的进程是一个“实时”进程。那么云风说的时间片用完的说法就可能不存在了,至少对于linux2.6,如果系统有且只有一个实时进程,那么只要它的进程状态是runnable,那么就会一直跑。退一万步讲,即使会有一些时间片交给普通进程A,可想而知进程A什么时候才能把活干完从而release lock。

这其实也是一种死锁,就是把CPU时间片也当作一个资源来看待。

TO rockcarry:我觉得这不能算是准确的“优先级反转”。优先级反转的关键点在于,低优先级进程反而阻塞了高优先级进程,但低优先级进程是可以run的。而这里不是。这里的情况是等锁的进程是“实时”的,低优先级的进程没有CPU时间片也不能continue。最后造成的结果是,两个进程都不能继续走。

另外,我昨天用java的concurrent包做了一下实验,并不能发现CPU100%的情况。而根据以上推测实时进程不停loop应该是会造成cpu100%的。因此,我觉得Andrei同学漏讲了点什么。或者Andrei同学讲的这个东西本身就是有这个问题,只是jdk做了另外的处理,例如自动转变成了lock-based的调用了。

Any idea?

一个资源是这样,但是多个资源就不是了。

比如有两个资源 A 和 B 。大家都是依次处理 A 和 B 。当大家都去抢 A 的时候 lock-free 可以保证先做完的先提交,但传统方法则是谁先请求谁拿到。

如果一个需要很长时间的线程先拿到 A 那么大家都会去等它,而后这个线程要去拿 B ,大家又接着因为 B 去等待。

但是用 lock free 算法,处理 A 快的线程去先完成了,对 B 的处理就被提前了。

当共享资源进一步增加时,lock-free 的效率会更明显。

cowboy的说法正确 呵呵 撤消重做的代价太大了,在对共享资源有更新处理时候, 只要单个线程对资源的处理时间大于两次处理之间的间隔时间,那么在多个线程处理情况下重做总是会发生.

对于n个同步启动的处理相同的线程,即使不考虑优胜线程再次加入竞争, 重做次数也至少是n!-n. 最后一个线程在最理想的情况下完成第一次处理的时间点也是n*t了,与带lock模式相比总体效率无提高,但是总体cpu消耗就大的不可以道理计了

至于说避免死锁也不是lookfree的优势,死锁是可以由流程设计上去避免的,反而我觉得,只要从设计上去提高并行度尽量降低锁内处理,锁结构的运行性能是完全可以稳定估量的,而lockfree结构则在运行态上是不稳定的,存在性能风险.

我认为 Bit Cowboy 的说法过于武断,不知道“这个说法是有实验数据支撑的”有没有具体参考。

Lock-free 有一定的性能损失,这个我在下面 26 楼简单分析过。

但是要说随着核的增加,性能损失会越来越大,我不赞同。

当很多线程同时更新同一资源时,如果是依赖锁还是免锁,由于工作必须串行化,所用的时候都不会超过单线程串行处理。

lock-free 的性能损失在于一个任务完成后,其它任务正在重做上次会被作废掉的事情,导致一小段的空闲时间。

但是随着并发任务增加,总是最快的那个线程正确提交结果。性能损失会随着核的增加而趋于稳定。

另一方面,随着事务的复杂,lock-free 天生总能让同时请求同一资源的多个线程中做的最快的那个先完成。这比传统的锁依赖算法是个很大的改进。很有可能改进总体性能。

这就好比大家排队打水,让桶小的人排在前面,可以使大家总等待时间变短。如果只有打水一件事,总时间不会因为改变次序而改变。但是除了打水,还有排队买饭等等别的事情时,让做的快的人插队先做,却可以让集体的总时间缩短。

ps. 虽然没有死锁问题,但是在极端情况下,lock-free 的算法有可能导致几个工作的慢的线程永远提交不了数据。(比如同时有别的核不间断的高频修改同一资源)这时候就依赖于 os 把高频的线程挂起一小段时间了。

Lock-free算法的可扩展性是很差的。当同时并行执行的线程越多(CPU硬件核心越多)的情况下,性能越差,并且无限接近单线程的性能。这个说法是有实验数据支撑的。原因是因为,Lock-free是一个“乐观锁”,它假设冲突不会发生,而等到真的发生了冲突再去修正(撤销数据提交重做)。而当并行执行的线程越多,它们之间的冲突也是以N^2被增长的。所以大量的CPU时间都被浪费在数据重做上。大家有兴趣可以参考“事务式内存”方面的资料。

Lock-free算法在8核一下的情况下效率还是不错了,超过8核,效率就比较低了。Lock-free的最大价值在于,它不会有死锁,而且,目前状况下性能够用。

如果要用好像Intel 80核那样的CPU,数据上的并行考虑是必须的。

换个标准点的阅读器吧,“新浪点点通" 肯定不标准。

那个链接不是文章链接。而且 atom 文件中已经写明了是 link rel="service.edit" type="application/atom+xml"

这个链接是 atom 协议中用于修改和发布的。普通用户不应该看的见。而且链接直接访问也会因为通不过认证而失败。

ps. 但是还是把缺少的 SHA1 模块装上了。

续上:FEEDS地址:
http://blog.codingnow.com/atom.xml

To Cloud:
很早就发现这个问题了
新浪点点通
如此文在阅读器中文章链接为:
http://blog.codingnow.com/mt/mt-atom.cgi/weblog/blog_id=1/entry_id=317
现在点击提示:

Got an error: Can't locate Digest/SHA1.pm in @INC (@INC contains: /var/www/mt/extlib lib /etc/perl /usr/local/lib/perl/5.8.4 /usr/local/share/perl/5.8.4 /usr/lib/perl5 /usr/share/perl5 /usr/lib/perl/5.8 /usr/share/perl/5.8 /usr/local/lib/site_perl .) at lib/MT/AtomServer.pm line 14.
BEGIN failed--compilation aborted at lib/MT/AtomServer.pm line 14.
Compilation failed in require at (eval 2) line 1.

C 的 expat 模块以及 perl 的XML::Parser 模块没有安装,刚才都去装上了。

为啥一直没有人提呢?我用 MT 确省设置装的 blog 系统,没精力折腾。

另外,哪些出错信息怎么出来的?为啥我用 opera 订阅和 google reader 订阅都没问题?

云风大哥,你BLOG的RSS能不能调一下,订阅后点链接提示:

Got an error: Can't locate XML/Parser.pm in @INC (@INC contains: /var/www/mt/extlib lib /etc/perl /usr/local/lib/perl/5.8.4 /usr/local/share/perl/5.8.4 /usr/lib/perl5 /usr/share/perl5 /usr/lib/perl/5.8 /usr/share/perl/5.8 /usr/local/lib/site_perl .) at /var/www/mt/extlib/XML/XPath/XMLParser.pm line 7.
BEGIN failed--compilation aborted at /var/www/mt/extlib/XML/XPath/XMLParser.pm line 7.
Compilation failed in require at /var/www/mt/extlib/XML/XPath.pm line 13.
BEGIN failed--compilation aborted at /var/www/mt/extlib/XML/XPath.pm line 13.
Compilation failed in require at /var/www/mt/extlib/XML/Atom.pm line 11.
BEGIN failed--compilation aborted at /var/www/mt/extlib/XML/Atom.pm line 24.
Compilation failed in require at lib/MT/AtomServer.pm line 10.
BEGIN failed--compilation aborted at lib/MT/AtomServer.pm line 10.
Compilation failed in require at (eval 2) line 1.


或者干脆将简报内容字数调大些,方便那些通过阅读器订阅你博客的网友阅览。

从字义上讲,无锁是对lock-free的误解。如果没有接触过lock-free,见到无锁的第一印象,是没有涉及到锁的技术。

应用层上,稳定性是高于性能的,但是在内核层上,性能是同等于稳定的。

lock-free 的本质是:大家把事情尽量的按自己的流程做,做错了再重做。不要相互等待,如果 A 和 B 同时依赖一个资源,那么让 A , B 同时去做,然后只取一个结果,让另一个重来一次。它并不提高效率,当 A,B 同时开始时,完成 A 和 B 两项工作的总体时间是 2 * max (A,B) 。

而依赖锁的方法本质上是让 A 事情做完了 B 再继续,需要的时间是 A+B 。至于花在锁本身上的时间是几乎忽略不计的。

lock-free 最终解决的问题是,保证正确的基础上,降低实现的复杂度。它最大的功劳是杜绝了逻辑上的死锁。

所以对于 lock-free 最后的原子操作怎么实现,并不重要。

即使 CAS 以来硬件保证的一个最小粒度的锁来保证原子操作,lock-free 的思考方式也现在以来资源锁这种也不同的。因为这个原子操作永远不会有逻辑上死锁的问题。编程上也可以减少更多的失误。

至于 lock-free 在 C/C++ 层面实现的复杂度,更多的是没有 gc 机制带来的,而不是方法本身。

ps. 昨天贴的一个用单独线程来取代 CAS 中比较交换的原子操作的方法,今天再想想还有一些性能问题。主要是还需要做一些改进让仲裁线程不被阻塞。整个方案实际上是用一个单独线程来保证 CAS 需要的原子操作,但代价反而更大 :(

许多时候在工程上看重得更多的是稳定性,而不是性能,即便是做游戏开发也是如此。如果要我选择,我还是使用操作系统提供的 API 来对临界资源进行保护。在工程上都是使用成熟的技术来保证稳定性和缩短开发周期。

赞同 Cowboy
这样说来软件的架构设计上还是很关键的,在设计上尽量避免多个线程共享数据,尽量避免锁的使用。

lock-free 很好,可惜实现起来太复杂了,而且可移植性不好,因为这个跟处理器和编译器的细节有太大的关系。要真正的应用还是困难。

云风:

Csdn 博客系统有哪些问题,你可以提点意见,我们正打算做新系统。By the way,评论不允许超过250字,是不是因为这个原因你发不出去评论?

不管是操作系统锁、还是CPU硬件锁。锁的目的就是让程序不能并行运行。不管用什么样的锁,只要大量使用都会让多核的应用退回到单核状态。

并行计算的关键不在于锁,而在于在一个系统架构设计的时候就考虑到将数据最大限度的分离开,使得各个线程在运行的时候可以不访问或者很少访问共享的变量或者数据结构才可能真正让程序并行跑起来。

Lock-free不过是对锁的一个改进,但是并不是包治并行程序设计百病的灵丹妙药。

今天想了一下,然后又跟同事讨论了一会儿。如果未来核特别多的话,如果可以拿出一个核,倒是可以只用读写原子操作模拟出 CAS 来。

方法是这样的:

首先看一个更简单的问题:队列操作。如果对一个队列只有一个 thread 有写权限(进入队列),其它 thread 都只有读权限。那么,只需要读写操作原子即可保证安全。

如果有多个 thread 都需要写队列,我们可以这样做:让每个 thread 把数据分别写到自己的队列中,然后用一个单独的队列不断的去搜集所有工作 thread 的私有队列信息,合并起来。

因为合并之后的队列也只有一个 thread 对其写,而其它队列仅仅是读,那么依旧是安全的。

再来看 CAS 。如果每个可能被修改的数据单元为每个 thread 分配一个子单元(这些单元可以用链表串起来),那么每个 thread 修改这个数据单元都互不影响。它们均把老的值和新的值写入。

然后另外开一个额外的 thread 专门做仲裁。仲裁者从各个 thread 写入的数据中挑出合法的版本,放到最终的结果中。之后,每个工作 thread 再检查最终结果是否是自己来决定是否继续尝试。成功后再将结果设置为过期,让仲裁 thread 继续工作。

大概的想法就是这样啊,没有仔细再想下去,我觉得还是可行的。不过现在肯定没有实用价值。

关于InterlockedIncrement是否多余。

在32位x86平台下写一个DWORD确实是原子操作了,不管有多少的核心都一样,这个是Intel自己说的。但是InterlockedIncrement做的是+=1操作,不是简单的赋值操作。+=1怎么实现?是先把原来的值从内存里读出来,然后做加一运算,最后再写回到内存里去,是三步操作完成的。三条CPU指令怎么可能原子操作呢。如何保证在做+1操作的时候别的CPU核没有改变那块内存的值呢?这三条指令别说是多核了,就算是在单核心多线程的情况下都不能保证原子操作。

所以Intel引入了CAS指令,用一条硬件指令来做三个操作。这样可以保证这条指令在操作是时候线程不会被操作系统时间片调度程序打断。

不过,在多核时代,仅仅这样的保证就不够了。因为有并行(不是并发)的线程在执行。所以Intel又提供了lock指令前缀。在CAS指令前面加上lock前缀,可以在执行这条指令的同时调用CPU的Cache一致性模型来保证多个内核看见和操作的是同一块实际内存的值而不是各自Cache里的值(如果多个核心不是共享Cache的话),并且通过锁顶内存地址线的方式(这个是比较老的方法了,不知道Intel的Core架构上有没有改进)强制同一时间只有一个CPU核可以写那块内存,从而保证了多核情况下的线程安全操作。从这里可以看出,“锁”实际上被移到了CPU内部,由硬件高效得来完成。

另一个保证线程安全的前提可能更容易被大家忽略。大家如果用过InterlockedIncrement接口的话应该就会发现,这个接口要求的操作变量必须是被volatile修饰的。volatile这个修饰符的意义在于,告诉编译器,不要把对这个内存块的操作优化成寄存器操作。通常情况加,为了加速变量的操作,编译器会自己把一组的变量操作优化成在CPU内部寄存器里运算,而不是立即写回内存的。但是,我们从前面的分析就可以知道,CPU的lock CAS操作是对内存的线程安全保证,并不能对CPU内部的寄存器里的值做任何保证。本来吗,寄存器就是各个CPU核心私有的,对不对。

所以lock CAS和volatile是两个基本条件确一不可。

看到有人在问学习Lock-free算法的资料,这方面系统的学习资料好像不多。我觉得可以从两个方面,一个是看Intel的汇编指令,不过这个枯燥了一点,也没有什么效率。另一个是看源代码。Windows里的Interlocked系列函数其实已经包含了不少Lock free算法的实现了,不过它最大的问题是不能跨平台,而且也没有公开源代码。不过要看Interlocked系列函数的实现还是可以的,Interlocked系列函数是用户空间的函数,不用切换到内核去执行,所以,其实可以在VC里面通过反汇编看他的汇编实现。如果你就是怕看汇编的话,那么我向你推荐一个好东西,apache项目的APR,apache protable runtime,是apache项目自己做的一个跨平台库,其中包含了很多Lock free算法的实现,而且是跨平台的,而且,最重要的是,提供了源代码。有兴趣的话可以从APR的原子操作开始学习。

看了下 lock-free 和 CAS, 我的确理解错了,抱歉。觉得这个真的很神奇,太不可思议了。
但是目前要应用还是很难啊。

Anonymous正解
既然要同步,就一定要上锁。Lock-free还是要上锁啊,只是影响面缩小了。
那个谁rockcarry,WaitForSingleObject一类的API在这里不适用,大伙是在讨论LOCK-FREE不是WINDOWS自带的各种同步对象别搞错了。。

大伙继续。

看了下,CAS这个操作有点神奇,看来我可能想错了。

再次看了下资料 lock-free 的概念似乎仅仅适用于多核处理器的系统中。网上有许多文章在谈论这个,似乎是在滥用概念。当然,我也是才听说这个,不能确定,还需要继续找资料看看。

CAS 本质上是给对象赋予了一个版本号,而版本号这个东西用一个字长足够了。

就算单个的内存访问是原子操作,实现 lock-free 的库也很困难。因为一个对象内部不可能只用一个内存单元来存放数据。因此,lock-free 的东西,在目前的处理器架构上很难实现。目前仍然需要借助多任务的操作系统。lock-free 只有等待新的处理器架构的出现,

至于大家所提的 lock-free 的东西,目前还只是处于研究的初级阶段,根本谈不上应用,也不是我们搞工程的需要研究的。

单核时,有些操作,比如写内存,本身就是原子的。不需要用 lock 前缀。所以可以构造一些数据结构,完成一些有限的事情,而不用 cmpxchg 这样的操作。

比如只有一个线程在一个单项链表后追加数据,而另外的线程只用来读。那么这样的数据结构就可以不用锁来完成。

我们的资源加载模块就基于这个完成的:

http://blog.codingnow.com/2007/05/mutilthread_preload.html

不知道大家所提的锁到底是个什么东西,据我所知,在 Windows 平台上进行多线程编程时,线程之间同步的方法很多 Event, Mutex, Semaphore, CriticalSection, 等等内核对象,足够满足我们的需求,我们可以根据需要具体选用。加锁是为了在多访问时对临界资源进行保护,这可以通过 Mutex 或者 CriticalSection 来实现,但这仅仅是多任务同步中的一个方面。许多多任务的同步是不需要加锁的,比如说某个线程要等待某个事件的发生(这时需要用到 Event 或 Semaphore)。

在多任务系统之上,所有的多任务操作,都是由操作系统和操作系统提供的 API 来管理和实现,这与具体的机器指令无关。操作系统通过内核对象来实现多任务同步和临界资源的管理,这样的实现,也不需要处理器提供特别的指令支持。

而大家所提的 CPU 的总线的 lock 信号,和相应的 lock 指令其实跟这个关系不大。在单核的系统上,能够与总线和主存打交道的器件,估计也就只有 DMA 控制器和 CPU 了,当 CPU LOCK 了总线以后,其他要使用总线的器件都必须等待总线的释放。因此说,在单核处理器中 LOCK 指令时没有什么用武之地的。但是 LOCK 指令在多核的系统中是极为重要的,而且是必需的,再多核的系统中,访问内存,必须要使用 LOCK 指令来锁住总线,然后处理器再在总线上产生读写的控制信号,所谓总线和控制信号的概念,大家应该都清楚吧。如果不 LOCK 的话,两个 CPU 都在同时往总线上放控制信号,岂不是乱套了,这必然会导致主存的访问出错。

因此说 LOCK 在多核处理器架构的系统上是必需的,然而这个东西似乎与我们通常所说的多任务同步等等问题没有关系。就算有关系,也都是由支持多核的操作系统和编译器去处理掉了,在操作系统之上,我们看到的是一个虚拟机,不用也没有必要去关心底层的实现细节。

单核多线程,不加锁,不能做到同步吧?
所谓的无锁,其实也不是真正的无锁,只不过是将巨锁(相对的),变成了单条指令锁,Andrei讲到的CAS,换成机器指令为 lock cmpxchg。指令前缀lock还是排他的。基于lock-free的库,大家也可以在网上找得到。

江峰所提的似乎是实时系统中优先级反转的问题,在实时系统中,这个问题已经有解决办法,可以参考相关资料。

另外提一点,多任务的系统中,一个任务如果得不到临界资源,操作系统会将该任务变为阻塞状态,该任务不会占用处理器资源,而持有临界资源的任务仍然会继续运行,直到新的任务调度产生。

至于你所说的“不停的 while-loop”我不太明白是什么意思。如果是等待临界资源,操作系统一般都会提供相关的 API, 比如 Win32 API 的 WaitForSingleObject。

Andrei 想说的死锁应该是指:线程对资源的循环依赖。

A 线程依次请求 1,2 资源;B 线程依次请求 2,1 资源。一旦 A 申请到了 1 ,B 申请到了 2 ,大家都在请求下一个资源时就会发生死锁。

to 周伟民,我依稀记得有个类似 ReadBarrier WriteBarrier 的 API 可以干这事。不知道是 Windows 提供的还是啥库提供的。不过用最新版本的 Intel C / gcc / VC 的话,对应的 api 是 _mm_lfence() _mm_sfence()

我这两年都没用 Windows 系统的,Interlocked* 系列的 api 细节不太清楚。

to 江峰,

那样不会死锁的,因为 os 会在每个线程的时间片耗尽时切换出去。

为了防止低优先级的线程锁住资源,而高优先级的线程相互轮占 cpu ,导致死锁,os 通常会按一定的机制自动调整优先级别,让每个线程都有机会活动。

Windows 下的线程调度,推荐阅读:《深入解析 Windows 操作系统第 4 版》6.5 线程调度。

to 江峰 :
不是实时系统的话.任务管理系统会给A分配时间片的吧.

spin-lock 貌似问题多多啊,主要是在cpu资源的分配和多路同写的重复操作上,正如下面tx说的,假如有个THREAD_PRIORITY_HIGHEST的线程与另一个THREAD_PRIORITY_LOWEST甚至THREAD_PRIORITY_IDLE之间存在spinlock的关联,那么很可能死锁,至少效率会无比低下.

云风,你好。我是有道的。我当时问了Andrei一个问题,就是:假设有两个进程A和B,进程A已经获得了lock,并且正在做他的事情。这时候一个实时进程B想要获得这个lock,于是就在不断的while-loop,而进程A却得不到CPU时间片去完成他的事情。于是便发生了死锁。是不是这样?当然我没有想清楚,就被Andrei同学糊弄过去了。呵呵。

麻烦云风兄给推荐一个多核开发的入门书籍

多谢云风讲fence指令的这篇文章,不过还有一个问题没有解决,
那就是InterlockedIncrementAcquire()和InterlockedIncrementRelease()是不是用sfence和lfence指令实现的?
毕竟编程的时候不可能直接去使用这种底层的指令吧?

很艰难的看完了..发现还是没看懂..哎.不是写游戏引擎的,真的是没考虑计算机底层的那么多东西...先干好我的逻辑程序员吧- -!

posted on 2012-07-24 16:16  #蓝天  阅读(714)  评论(0编辑  收藏  举报

导航