Archive for the ‘Featured’ Category
Linear Page Table: 更方便地访问页表
Linear page table又叫virtual page table,是一种方便虚拟机监控器(VMM)/操作系统(OS)/应用程序访问页表的技巧。Xen、64位Linux内核、JOS操作系统中都用到了这种技巧。这里以x86_32的虚拟内存管理为例,简单介绍一下它的实现和使用,如有错误敬请指出。
一般情况下,如果OS需要访问某个页表,需要将它映射到自己的虚拟空间中,然后再访问。这样带来两个问题,一是访问比较繁琐,需要临时的页映射;二是对于Exokernel这种fork等行为都是在用户态程序实现的系统,可能会增加一下安全上的问题。因为用户程序在fork的时候需要访问自己的页表,而这时候除非操作系统提供另一些权限控制更精确的系统调用,否则就很难让不可信的应用程序访问自己的页表且不做有害的改动。
Linear page table很好的解决了这两个问题。它的实现很简单,只需要在页目录中增加一项VPT,和一般的页目录项不同的是,这个VPT指向的是页目录本身。
这样带来了什么好处呢?借用一下MIT 6.828课件上的图片来更好的说明这个问题

增加了VPT后,通常的物理地址->虚拟地址的转换还是没变。和之前唯一的不同在于虚拟地址的页目录索引号(PDX)为之前设置的VPT的时候。
举个例子来说,假如现在要访问的虚拟地址是(VPT << 22) | (VPT << 12),即这里的PDX和PTX都等于VPT的时候,整个转换过程是怎么样的呢(假设TLB miss的情况)?首先根据cr3中的物理地址,硬件开始查找页目录中的第VPT项,然后根据这一项中的物理地址,找到了下一级“页表”。注意这时候硬件以为自己得到的页表地址,实际上访问的还是页目录本身。同样,在这个“页表”中找到第VPT项指出去的最终页,得到了最终页的物理地址。因为PTX还是等于VPT,所以最后得到的物理地址还是页目录的。
也就是说,通常的页表访问的顺序是 CR3->页目录->页表->最终页,现在访问这个特殊地址的过程则成了 CR3->页目录->页目录->页目录,通过VPT这一项在页目录上绕了两圈后返回。
接下来,再来看看如何通过这个机制来访问某个页表,假如现在要访问第i个页目录项指向的页表上的第j项,那么我们应该构造这样一个特殊地址:
(VPT << 22) | (i << 12) | (j * 4)
即PDX=VPT, PTX=i, offset=j*4。通过这个地址就能得到需要的页表项了,另外由于(i << 12) | (j * 4) = (i * 1024 + j) * 4,定义vpn为虚拟页的编号,vpn = i * 1024 + j,则这个地址可以转换为
(VPT << 22) + vpn * 4
在JOS中,就是把vpt定义为一个uint32_t的数组,然后vpt[vpn]就是第vpn个虚拟页的页表项了。前面提到的另一个问题,如果要让用户以只读权限访问页表,又应该怎么做呢?很简单,在页目录中为用户设置另一个只读项,指向页目录自己就行了。
Singleton模式与双检测锁定(DCL)
转载请注明 作者 ZelluX http://techblog.iamzellux.com
看OOP教材时,提到了一个双检测锁定(Double-Checked Lock, DCL)的问题,但是书上没有多介绍,只是说这是一个和底层内存机制有关的漏洞。查阅了下相关资料,对这个问题大致有了点了解。
从头开始说吧。
在多线程的情况下Singleton模式会遇到不少问题,一个简单的例子
class Singleton {
private static Singleton instance = null;
public static Singleton instance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
假设这样一个场景,有两个线程调用Singleton.instance(),首先线程一判断instance是否等于null,判断完后一瞬间虚拟机把线程二调度为运行线程,线程二再次判断instance是否为null,然后创建一个Singleton实例,线程二的时间片用完后,线程一被唤醒,接下来它执行的代码依然是instance = new Singleton();
两次调用返回了不同的对象,出现问题了。
最简单的方法自然是在类被载入时就初始化这个对象:private static Singleton instance = new Singleton();
JLS(Java Language Specification)中规定了一个类只会被初始化一次,所以这样做肯定是没问题的。
但是如果要实现延迟初始化(Lazy initialization),比如这个实例初始化时的参数要在运行期才能确定,应该怎么做呢?
依然有最简单的方法:使用synchronized关键字修饰初始化方法:
public synchronized static Singleton instance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
这里有一个性能问题:多个线程同时访问这个方法时,会因为同步而导致每次只有一个线程运行,影响程序性能。而事实上初始化完毕后只需要简单的返回instance的引用就行了。
DCL是一个“看似”有效的解决方法,先把对应代码放上来吧:
class Singleton {
private static Singleton instance = null ;
public static Singleton instance() {
if (instance == null ) {
synchronized (this) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
用JavaWorld上对应文章的标题来评论这种做法就是smart, but broken。来看原因:
Java编译器为了提高程序性能会进行指令调度,CPU在执行指令时同样出于性能会乱序执行(至少现在用的大多数通用处理器都是out-of-order的),另外cache的存在也会改变数据回写内存时的顺序[2]。JMM(Java Memory Model, 见[1])指出所有的这些优化都是允许的,只要运行结果和严格按顺序执行所得的结果一样即可。
Java假设每个线程都跑在自己的处理器上,享有自己的内存,和共享的主存交互。注意即使在单核上这种模型也是有意义的,考虑到cache和寄存器会保存部分临时变量。理论上每个线程修改自己的内存后,必须立即更新对应的主存内容。但是Java设计师们认为这种约束会影响程序性能,他们试着创造了一套让程序跑得更快、但又保证线程之间的交互与预期一致的内存模型。
synchronized关键字便是其中一把利器。事实上,synchronized块的实现和Linux中的信号量(semaphore)还是有区别的,前者过程中锁的获得和释放都会都会引发一次Memory Barrier来强制线程本地内存和主存之间的同步。通过这个机制,Java中的同步机制保证了synchronized块中指令的原子性(atomic)。
好了,回过头来看DCL问题。看起来访问一个未同步的instance字段不会产生什么问题,我们再次来假设一个场景:
线程一进入同步块,执行instance = new Singleton(); 线程二刚开始执行getResource();
按照顺序的话,接下来应该执行的步骤是 1) 分配新的Singleton对象的内存 2) 调用Singleton的构造器,初始化成员字段 3) instance被赋为指向新的对象的引用。
前面说过,编译器或处理器都为了提高性能都有可能进行指令的乱序执行,线程一的真正执行步骤可能是1) 分配内存 2) instance指向新对象 3) 初始化新实例。如果线程二在2完成后3执行前被唤醒,它看到了一个不为null的instance,跳出方法体走了,带着一个还没初始化的Singleton对象。
错误发生的一种情形就是这样,关于更详细的编译器指令调度导致的问题,可以参看这个网页 [4]。
[3] 中提供了一个编译器指令调度的证据
instance = new Singleton(); 这条命令在Symantec JIT中被编译成
0206106A mov eax,0F97E78h
0206106F call 01F6B210 ; 分配空间
02061074 mov dword ptr [ebp],eax ; EBP中保存了instance的地址
02061077 mov ecx,dword ptr [eax] ; 解引用,获得新的指针地址
02061079 mov dword ptr [ecx],100h ; 接下来四行是inline后的构造器
0206107F mov dword ptr [ecx+4],200h
02061086 mov dword ptr [ecx+8],400h
0206108D mov dword ptr [ecx+0Ch],0F84030h
可以看到,赋值完成在初始化之前,而这是JLS允许的。
另一种情形是,假设线程一安稳地完成Singleton对象的初始化,退出了同步块,并同步了和本地内存和主存。线程二来了,看到一个非空的引用,拿走。注意线程二没有执行一个Read Barrier,因为它根本就没进后面的同步块。所以很有可能此时它看到的数据是陈旧的。
还有很多人根据已知的几种提出了一个又一个fix的方法,但最终还是出现了更多的问题。可以参阅[3]中的介绍。
[5]中还说明了即使把instance字段声明为volatile还是无法避免错误的原因。
由此可见,安全的Singleton的构造一般只有两种方法,一是在类载入时就创建该实例,二是使用性能较差的synchronized方法。
(by ZelluX http://techblog.iamzellux.com )
参考资料:
[1] Java Language Specification, Second Edition, 第17章介绍了Java中线程和内存交互关系的具体细节。
[2] out-of-order与cache的介绍可以参阅Computer System, A Programmer’s Perspective的第四、五章。
[3] The “Double-Checked Locking is Broken” Declaration, http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[4] Synchronization and the Java Memory Model, http://gee.cs.oswego.edu/dl/cpj/jmm.html
[5] Double-checked locking: Clever, but broken, http://www.javaworld.com/javaworld/jw-02-2001/jw-0209-double.html?page=1
[6] Holub on Patterns, Learning Design Patterns by Looking at Code
《编程之美》一个二进制趣题的讨论
问题很简单,求二进制中1的个数。对于一个字节(8bit)的变量,求其二进制表示中”1″的个数,要求算法的执行效率尽可能的高。 先来看看样章上给出的几个算法:
解法一,每次除二,看是否为奇数,是的话就累计加一,最后这个结果就是二进制表示中1的个数。
解法二,同样用到一个循环,只是里面的操作用位移操作简化了。
int Count(int v)
{
int num = 0;
while (v) {
num += v & 0x01;
v >>= 1;
}
return num;
}
解法三,用到一个巧妙的与操作,v & (v -1 )每次能消去二进制表示中最后一位1,利用这个技巧可以减少一定的循环次数。
解法四,查表法,因为只有数据8bit,直接建一张表,包含各个数中1的个数,然后查表就行。复杂度O(1)。
int countTable[256] = { 0, 1, 1, 2, 1, ..., 7, 7, 8 };
int Count(int v) {
return countTable[v];
}
好了,这就是样章上给出的四种方案,下面谈谈我的看法。 首先是对算法的衡量上,复杂度真的是唯一的标准吗?尤其对于这种数据规模给定,而且很小的情况下,复杂度其实是个比较次要的因素。 查表法的复杂度为O(1),我用解法一,循环八次固定,复杂度也是O(1)。至于数据规模变大,变成32位整型,那查表法自然也不合适了。 其次,我觉得既然是这样一个很小的操作,衡量的尺度也必然要小,CPU时钟周期可以作为一个参考。 解法一里有若干次整数加法,若干次整数除法(一般的编译器都能把它优化成位移),还有几个循环分支判断,几个奇偶性判断(这个比较耗时间,根据CSAPP上的数据,一般一个branch penalty得耗掉14个左右的cycle),加起来大概几十个cycle吧。 再看解法四,查表法看似一次地址计算就能解决,但实际上这里用到一个访存操作,而且第一次访存的时候很有可能那个数组不在cache里,这样一个cache miss导致的后果可能就是耗去几十甚至上百个cycle(因为要访问内存)。所以对于这种“小操作”,这个算法的性能其实是很差的。 这里我再推荐几个解决这个问题的算法,以32位无符号整型为例。
int Count(unsigned x) {
x = x - ((x >> 1) & 0x55555555);
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
x = (x + (x >> 4)) & 0x0F0F0F0F;
x = x + (x >> 8);
x = x + (x >> 16);
return x & 0x0000003F;
}
这里用的是二分法,两两一组相加,之后四个四个一组相加,接着八个八个,最后就得到各位之和了。 还有一个更巧妙的HAKMEM算法
int Count(unsigned x) {
unsigned n;
n = (x >> 1) & 033333333333;
x = x - n;
n = (n >> 1) & 033333333333;
x = x - n;
x = (x + (x >> 3)) & 030707070707;
x = x % 63;
return x;
}
首先是将二进制各位三个一组,求出每组中1的个数,然后相邻两组归并,得到六个一组的1的个数,最后很巧妙的用除63取余得到了结果。 因为26 = 64,也就是说 x0 + x1 * 64 + x2 * 64 * 64 = x0 + x1 + x2 (mod 63),这里的等号表示同余。 这个程序只需要十条左右指令,而且不访存,速度很快。 由此可见,衡量一个算法实际效果不单要看复杂度,还要结合其他情况具体分析。 关于后面的两道扩展问题,问题一是问32位整型如何处理,这个上面已经讲了。 问题二是给定两个整数A和B,问A和B有多少位是不同的。 这个问题其实就是数1问题多了一个步骤,只要先算出A和B的异或结果,然后求这个值中1的个数就行了。