内容


Linux下写者优先的读写锁的设计

为 Tomcat 构建一个安全的笼子

Comments

一、本文的目的

在linux下有两种实现数据互斥的基本机制,包括了semaphore(信号量),spinlock(自旋锁)。这里要说的读写锁(read write lock)是自旋锁的一个变种,与一般的自旋锁的区别是,自旋锁一次只能有一个进程进入临界区,而对读写锁而言,如果进程是读的话,那就可以有多个进程同时进入临界区,而如果是写的话,则只有一个可以。

就现在的linux内核源代码的发行版本而言,已经实现了读写锁的一个类型,就是读者优先的读写锁。(在这个设计中,作为读的请求可以更容易的进入临界区,而写的请求的请求往往容易受阻,这个我在后面会分析),而我要设计的读写锁,则是以写进程为优先的考虑的对象,如果有写的请求发出,则它会在被允许的最快时间内得到响应。这样的好处是在一个由很多客户端以读的权限访问的服务器(如一般的公众服务器),如果管理员对服务器的某些内容或配置进行修改的话,那它的及时性就有可能无法满足。这有时是不可以被接受的。

二、linux现有的读写锁状况

我先来分析现在linux内核源代码中的读写锁的实现方式,这样就可以很容易的理解后面的写者优先的读写锁的设计。

先介绍一个数据结构,这是在读写锁起到重要作用。

(注:下面所有的内核源代码均来自linux 2.4.17,如果与你的现有的内核源代码不同,请你作一些相应的改变就可以了,原理部分没有变化)

typedef struct {
	volatile unsigned int lock;
#if SPINLOCK_DEBUG
	unsigned magic;
#endif
} rwlock_t;

这里的magic是用于调试的,而lock就是允许可以加的读锁数。

这个代码在linux/include/asm-i386/spinlock.h中定义了read_lock和write_lock

static inline void read_lock(rwlock_t *rw)
{
#if SPINLOCK_DEBUG
	if (rw->magic != RWLOCK_MAGIC)
		BUG();
#endif
	__build_read_lock(rw, "__read_lock_failed");
}
static inline void write_lock(rwlock_t *rw)
{
#if SPINLOCK_DEBUG
	if (rw->magic != RWLOCK_MAGIC)
		BUG();
#endif
	__build_write_lock(rw, "__write_lock_failed");
}

注意这里有两个参数,一个是rw就是允许的读锁数,而后面一个参数是如果加锁失败的话,处理失败的函数。

在这里真正调用的对write_lock是__build_write_lock_const或__build_write_lock_ptr,对read_lock中调用的是__build_read_lock_const或__build_read_lock_ptr,这里的取决因素是调用参数的操作指令寻址方式。我们这里只看const类

     #define __build_write_lock_const(rw, helper) \
	asm volatile(LOCK "subl $" RW_LOCK_BIAS_STR ",(%0)\n\t" \
     "jnz 2f\n" \
     "1:\n" \
     ".section .text.lock,\"ax\"\n" \
     "2:\tpushl %%eax\n\t" \
     "leal %0,%%eax\n\t" \
     "call " helper "\n\t" \
     "popl %%eax\n\t" \
     "jmp 1b\n" \
     ".previous" \
     :"=m" (*(volatile int *)rw) : : "memory")

这里的RW_LOCK_BIAS_STR就是 "0x01000000",取这个值的原因是这个值足够大,可以使满足读的请求足够多。

在".section .text.lock,\"ax\"\n" \
".previous" \
中的内容是把这一段的代码汇编到一个叫.text.lock的节中,并且这个节的属性是可重定位和可执行的,这样在代码的执行过程中,因为不同的节会被加载到不同的页面中,所以如果在前面不出现jmp,就在1:处结束了。而call的是在前面的write_lock中所写入的__write_lock_failed,这个函数在arch/asm-i386/kernel/semaphore.c中定义

	.align	
.globl	__write_lock_failed
__write_lock_failed:
	" LOCK "addl	$" RW_LOCK_BIAS_STR ",(%eax)
1:	rep; nop
	cmpl	$" RW_LOCK_BIAS_STR ",(%eax)
	jne	1b
	" LOCK "subl	$" RW_LOCK_BIAS_STR ",(%eax)
	jnz	__write_lock_failed
	ret

这里的LOCK是一个在SMP体系结构中锁住总线,不让其它的CPU访问内存。在这里先" LOCK "addl $" RW_LOCK_BIAS_STR ",(%eax)是为了防止在后面的自旋等待中,不会让后面的读请求受阻,要不然的话,就会出现死锁,这是致命的。而

	1:	rep; nop
	cmpl	$" RW_LOCK_BIAS_STR ",(%eax)
	jne	1b

就是不断的检查锁是否可以得到,得不到就会nop,这种方法可以在不改变lock的值的情况下实现等待,这就不用LOCK,这样的锁住总线了。最后如果得到锁就

" LOCK "subl	$" RW_LOCK_BIAS_STR ",(%eax)

这样就实现了write_lock的功能。

对读锁也是类似

     #define __build_read_lock_const(rw, helper)   \
	asm volatile(LOCK "subl $1,%0\n\t" \
     "js 2f\n" \
     "1:\n" \
     ".section .text.lock,\"ax\"\n" \
     "2:\tpushl %%eax\n\t" \
     "leal %0,%%eax\n\t" \
     "call " helper "\n\t" \
     "popl %%eax\n\t" \
     "jmp 1b\n" \
     ".previous" \
     :"=m" (*(volatile int *)rw) : : "memory")

但这里要注意一点,就是对read_lock而言,只要减1并且只要这个值不为负的话,就可以得到锁了,但rw.lock的值在被初始化的时候就被赋值成了0x01000000,这个值足够大,而要减的很小,所以要获得读锁是很容易的,从理论上说比得到写锁要容易0x01000000倍,这就是我前面说现在在linux内部实现的读写锁是读者优先的。而这个数也让我们容易理解在要获得写锁时,要对这个lock值 减去0x01000000,就是如果有一个读或者写请求在临界区内的话,第二个写请求就无法得到写锁了。

而如果得不到读锁,所要跳的是在read_lock所指明的__read_lock_failed

	.align	4
.globl	__read_lock_failed
__read_lock_failed:
	lock ; incl	(%eax)
1:	rep; nop
	cmpl	$1,(%eax)
	js	1b
	lock ; decl	(%eax)
	js	__read_lock_failed
	ret

这里的道理与前面的__write_lock_failed中的道理是相似的。

三、写者优先的读写锁的实现

那既然要实现以写者为优先的读写锁,很自然,我们就想到了在读的请求发生时,不先去试图获得读锁,而是去检查有没有写的请求正在等待,如果有写的请求正在等待,则读的请求必须先处于等待状态。让写的请求完成之后,发现已经没有写的请求在等待了,才去试图获得读的锁。

这里我们先来设计rwlock_t这个数据结构,

typedef struct {
	volatile unsigned int lock;
#if WLOCK_PRIORITY
  volatile unsigned int wlock_waiting;
#endif
#if SPINLOCK_DEBUG
	unsigned magic;
#endif
} rwlock_t;

这里所增加的这个wlock_waiting就是作为检测是否有写的请求在等待的标志数。如果这个数不等于0则说明已经有写的请求在等待。它的负值的大小决定了写请求等待的个数。

这里我们先修改__build_write_lock_const中

         #define __build_write_lock_const(rw, wlock_wait,helper,helper1) \
  asm volatile(
 "cmpl $0,(%1)\n\t" \
"jnz 3f\n" \
"1:\t" LOCK "subl $" RW_LOCK_BIAS_STR ",(%0)\n\t" \
         "jnz 4f\n" \
         "2:\n" \
         ".section .text.lock,\"ax\"\n" \
"3:\tpushl %%ebx\n\t" \
"leal %1,%%ebx\n\t" \
"call " helper1 "\n\t" \
"popl %%ebx\n\t" \
"jmp 1b\n" \
"4:\tpushl %%eax\n\t" \
            pushl %%ebx\n\t" \
         "leal %0,%%eax\n\t" \
            "leal %1,%%ebx\n\t" \
         "call " helper "\n\t" \
         "popl %%ebx\n\t" \
"popl %%eax\n\t" \
         "jmp 2b\n" \
         ".previous" \
         :"=m" (*(volatile int *)rw) ,"=m" (*(volatile int *)wlock_waiting)  : : "memory")

这里的新增加的wlock_waiting是表示前面所定义的wlock_waiting的地址,这个是地址,而不是值本身,它的原因是指令寻址的方式决定的,为了保证指令的操作在直接对内存,而不是把内存中的数据load到寄存器中,再进行处理后放回到内存中,如果不是这样的话,就有可能使这个变量的在寄存器内的处理时被其它的cpu在它们的寄存器中被改变。而helper1则是知道已经有了写请求在等待得到锁,而跳转的处理地址。这里我取的名字是__read_lock_failed_wlock_wait

	.align	4
.globl	__read_lock_failed_wlock_wait
__read_lock_failed_wlock_wait:
	1:	rep; nop
	cmpl	$0,(%ebx)
	jnz	1b
	js	__read_lock_failed_wlock_wait
	ret

这里就是不断的检查wlock_waiting的数值是否为0, 如果不是0就要执行空转指令。

而helper就是取前面的__read_lock_failed的名字,但有一点的变化。

	.align	4
.globl	__read_lock_failed
__read_lock_failed:
	lock ; incl	(%eax)
1:	rep; nop
	cmpl	$0,(%ebx)
jnz 1b
	rep; nop
	cmpl	$1,(%eax)
	js	1b
	lock ; decl	(%eax)
	js	__read_lock_failed
	ret

这里的还是要不断的检查是否有写请求在等待,因为如果不是这样的话,在前面的指令的跳转过程中就有可能有写的请求到来,而我们是要严格的执行写者优先的读写规则。如果在试图得到读锁过程中失败,也要跳转到检查写请求的地址,也是这个原因。

对于写锁的获得也要修改。

	#define __build_write_lock_const(rw,wlock_wait, helper) \
	asm volatile(LOCK "subl $1,(%1)\n\t" \
LOCK "subl $" RW_LOCK_BIAS_STR ",(%0)\n\t" \
	"jnz 2f\n" \
	"1:\n"LOCK "addl $1,(%1)\n\t" \
	".section .text.lock,\"ax\"\n" \
	"2:\tpushl %%eax\n\t" \
	"leal %0,%%eax\n\t" \
	"call " helper "\n\t" \
	"popl %%eax\n\t" \
	"jmp 1b\n" \
	".previous" \
	:"=m" (*(volatile int *)rw) ,"=m" (*(volatile int *)wlock_waiting) : : "memory")

这里与在__build_read_lock_const中的原理类似,但有一点不同,就是一旦要试图取得写锁的时候,要先对wlock_waiting这个数行减1,如果得到就要加1,这是为了让同样的读的请求可以知道已经有写的请求在等待获得读写锁了。而这里应用了addl 与subl而不是incl 与decl是因为后两个指令只能保证在24个bits的情况下有效,而前两个指令可以对32个bits情况下有效,而采用先减再加的策略,而不是先加后减,是因为如果一减,那原来是0就变成了0xffffffff,这样所有的位都是1对于硬件的可靠性加强。而对于helper与前面的读者优先的读写锁是相同的。

上面只是给出了在修改读写锁使其是写优先的最主要的内容,其实如果真要实现必须修改十处以上,这里由于篇幅的关系,我把修改好的代码在提供在这里下载:

代码下载

四、小结

这里对linux内核中的读写锁进行了写者优先的修改,这种修改从代码的内容上看比读者优先要增加了进行的成本,特别是如果有一个写请求在临界区外面等待,那可能会有很多的读请求在__read_lock_failed_wlock_wait中进行空转。但如果考虑到写请求与读请求的发生概率可能是1:100甚至更小,而且对系统刷新要求的高标准。那么这一点的损失是值得的,尤其是对路由器,或是实时要求很高的信息发布平台上(如证券)就应该如果。这在代码上是"大巧若拙"。而如果把这个代码在一般的PC机平台上应用,可能这样只会是"弄巧成拙"了。从这当中看出开放源代码的精神之所在,让用户实现自己最佳的配置与功能。


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=20858
ArticleTitle=Linux下写者优先的读写锁的设计
publish-date=05102003