所谓缓存,本质上是同一块数据在不同空间的存储。这样的例子很多:
※ CPU和内存之间的高速缓存;
※ 同时映射到两个进程地址空间的数据,典型的是共享内存;
※ 同时位于数据库和内存中的同一个业务数据,如O-R映射;
※ 位于两台服务器的数据,典型的例子是集群全局配置信息。
一、访问缓存可能发生的简单问题:
1、在同一个存储空间中,不同进程/线程间的共享数据一致性问题。
例如,假设有一个表示姓名的数据结构被两个线程A、B共享:
struct name {
char* first;
char* last;
}
假设线程A要修改姓名,步骤如下:
第一步,修改name.first
第二步,修改name.last
如果线程B在第一步和第二步之间读取name,那么它将获得的是不匹配的“姓”和“名”。
2、在不同存储空间中,数据的一致性问题。
仍然以上面的数据为例,这次假设同样的数据在内存和数据库中各有一份。如果线程A要修改姓名,步骤如下:
第一步:修改内存中的name数据;
第二步:将数据保存在数据库中(修改数据库中的name数据)。
同样,如果在第一步和第二步之间,B先从数据库中读取了name,再与内存中的name比较,就会发现数据不一致问题。
虽然上述两个问题看起来都是由于不同存储空间(内存的不同区域、内存和数据库)造成的数据一致性。其实,这两个问题本质是不同的,问题1是两份有关联的数据间的一致性问题;问题2是同一份数据在不同存储空间中的一致性问题。
二、解决思路
1、Copy-on-Write方法
对于被多个线程共享的数据来说,如果一个线程要修改数据,步骤如下:
第一步:将要求改的数据D复制一份,作为线程私有数据D’;
第二步:对D’进行所需的修改;
第三步:查看D从第一步到现在是否未被被其他线程修改过。如果是,则用D’替换D;否则重新从第一步开始。
第三步其实是一个CAS过程(Compare and Set),要求是一个原子操作。上述过程常用循环来模拟:
while (true) {
D’ = copy(D);
modify(D’);
if CAS( !modified(D) , D’)
break;
}
2、加锁方法
对于被多个线程共享的数据来说,如果一个线程要修改数据,步骤如下:
第一步:对要修改的数据D加排他锁(X锁),阻止其他线程访问;
第二步:修改数据D;
第三步:解除数据D上的锁。
方法1正是所谓无锁结构(lock-free),相比方法2并发要性能高一些,但是复杂性也相对高一些。而且,方法1通常也可用锁机制来模拟,只是加锁时间短暂一些,通常被称为spin-lock(旋转锁)。
上述两个方法的根本,有其共同之处,就是要保证:在共享数据被修改完成以前,不能被其他线程访问。
三、稍微复杂一些的缓存管理问题
1、层次结构的数据一致性问题。
例如下面的数据结构中,结构name是结构person的成员:
struct name {
char* first;
char* last;
}
stuct person {
int id;
struct name full_name;
}
如果我们要通过加锁的方式更新person的数据,就要考虑是否对person.full_name也加锁。
2、写缓存的时机
对于需要在两种存储介质中存储的某种信息,必须考虑何时同步这两种存储中的数据。例如,内存和数据库中的数据同步;内存和磁盘之间的数据同步;CPU和内存之间的数据同步。
3、缓冲的透明性
这就首先要考虑下面两个问题。
1)冲机制的引入,与没有缓冲相比,是否需要对外暴露更多的接口?
考虑为保证一致性而引入锁机制,对编程接口的影响。最直观的方案,就是要求程序员必须显式的进行“加锁/解锁”操作:
lock(d);
*d = new_value;
unlock(d);
另一种方案,就是对外不暴露锁机制,由框架内部完成:
change(data* d, data new_value) {
lock(d);
*d = new_value;
unlock(d);
}
虽然后一种方案看起来先进一些,但其实对于比较复杂的数据结构来说,要求实现被修改数据实现深拷贝(deep copy)接口。
2)是不是每次修改数据,都要同步数据?
对于一个较复杂的数据结构来说,我们可能常常一次只修改其中的一部分数据。例如:
name.first = “Tom”;
/* 进行其他操作 */
/* … */
name.last = “Smith”;
有时,如果修改每一部分数据都进行同步操作,分散写入数据,不但步骤比较繁琐,而且效率不如集中写入高。如果让缓冲管理机制来决定何时写入数据,可能即方便又高效。但是,这样做也有新的问题,一方面实现起来比较复杂,另一方面,如果系统崩溃,可能造成缓冲中为写入的数据丢失。
这就引出了其他机制,回写、直写、先写日志和检查点等各种新的概念和技术。