读书人

讨论上lazy-loading和线程安全

发布时间: 2013-01-01 14:04:19 作者: rapoo

讨论下lazy-loading和线程安全
大家一定听说过lazy-loading(延迟加载),简单来说,延迟加载就是只有当需要某资源的时候才去加载,以减少不必要的系统开销。如下面的代码所示,仅在访问Members属性的时候创建列表(而不是在constructor内创建)


type
TPerson = class
end;

TMembers = TObjectList<TPerson>;

TGroup = class
private
fMembers: TMembers;
function GetMembers: TMembers;
public
destructor Destroy; override;
//...
property Members: TMembers read GetMembers;
end;

//...

destructor TGroup.Destroy;
begin
fMembers.Free;
inherited Destroy;
end;

function TGroup.GetMembers: TMembers;
begin
if fMembers = nil then
begin
fMembers := TMembers.Create;
end;
Result := fMembers;
end;



我们现在来讨论一下,如果要保证上面的这段代码是线程安全的,有哪些方法,以及各种方法有什么利弊。我先把自己见过的方法列举一下,欢迎大家讨论、补充:

第一种,完全通过临界区来保证该方法是线程安全的,如下列代码所示:



// fCriticalSection: SyncObjs.TCriticalSection;

constructor TGroup.Create;
begin
inherited Create;
fCriticalSection := TCriticalSection.Create;
end;

destructor TGroup.Destroy;
begin
fMembers.Free;
fCriticalSection.Free;
inherited Destroy;
end;

function TGroup.GetMembers: TMembers;
begin
fCriticalSection.Enter;
try
if fMembers = nil then
begin
fMembers := TMembers.Create;
end;
Result := fMembers;
finally
fCriticalSection.Leave;
end;
end;


呵呵,这就是我刚接触多线程的时候用的方法(类似java里面的synchronized方法)。应该说,这种方法最简单,最容易理解,当然也是成本最高的。(其实所谓的线程安全,实际上是对共享资源的保护,并不需要完全同步方法,以免浪费系统资源。)

第二种方法,即所谓的double checked locking,常见于c#和java,示例代码如下,大家可以比较下:



function TGroup.GetMembers: TMembers;
begin
if fMembers = nil then // 先判断fMembers是否为nil,若为nil才进入临界区
begin
fCriticalSection.Enter;
try
if fMembers = nil then // 再次判断fMembers(这就是double-checked locking的由来)
begin
fMembers := TMembers.Create;
end;
finally
fCriticalSection.Leave;
end;
end;
Result := fMembers;
end;


能想出这种方法的人绝对聪明。但这里必须指出,这段代码有个隐藏的问题。什么问题呢?(在C#里面必须把变量声明为volatile才可以用double-checked locking。)查了下MSDN的帮助下才算弄明白了一点。(推荐阅读MSDN: Synchronization and Multiprocessor Issues)

原因在于*处理器在读、写内存的时候会使用缓存以便优化性能*。假设有两个线程分别在两个处理器上执行这段代码,线程A进入了临界区并把创建好的实例赋值给(缓存中的)fMembers后退出了临界区。接下来处理器B上运行的线程B进入了临界区,当它判断fMembers的时候,由于缓存中的fMembers还没有更新,于是又创建了一次。杯具啊。。。

那怎么解决呢?System里面有一个MemoryBarrier函数可以保证CPU指令直接对内存操作。




function TGroup.GetMembers: TMembers;
begin
if fMembers = nil then
begin
fCriticalSection.Enter;
try
MemoryBarrier;
if fMembers = nil then
begin
fMembers := TMembers.Create;
end;
finally
fCriticalSection.Leave;
end;
end;
Result := fMembers;
end;



btw. 是不是第一种方法也需要加MemoryBarrier?

第三种方法,也是VCL里面使用的比较多的,即先判断fMembers是否为nil,若为nil则创建一个局部实例,再使用InterlockedCompareExchangePointer执行一个比较和交换指针的原子操作,交换失败则销毁局部实例。代码如下:


function TGroup.GetMembers: TMembers;
var
list: TMembers;
begin
if fMembers = nil then
begin
list := TMembers.Create;
if InterlockedCompareExchangePointer(fMembers, list, nil) <> nil then
begin
list.Free;
end;
end;
Result := fMembers;
end;




还有第四种方法,使用读写锁,尤其适用于读操作较多而写操作较少的情景:


// fMembersSync: SysUtils.IReadWriteSync;

constructor TGroup.Create;
begin
inherited Create;
fMembersSync := TMREWSync.Create; // or TMultiReadExclusiveWriteSynchronizer.Create;
end;

destructor TGroup.Destroy;
begin
fMembers.Free;
inherited Destroy;
end;

function TGroup.FindMember(const name: string): TPerson;
var
person: TPerson;
begin
fMembersSync.BeginRead; // 查找成员是“读操作”
try
for person in Members do
begin
if SameText(person.Name, name) then
begin
Result := person;
Break;
end;
end;
finally
fMembersSync.EndRead;
end;
end;

procedure TGroup.AddMember(person: TPerson);
begin
fMembersSync.BeginWrite; // 添加成员属于“写操作”
try
Members.Add(person);
finally
fMembersSync.EndWrite;
end;
end;

// 这里用读写锁应该没有意义,是不是要用上面的某种方法来保证线程安全???
function TGroup.GetMembers: TMembers;
begin

end;




呵呵,我就抛砖引玉到这儿了,下面欢迎大家热烈讨论,我会把讨论的结果总结后放到博客上。

如有任何错误,请一定指出:)
[解决办法]
这个贴子不错
[解决办法]
学习
[解决办法]
楼主最好把测试数据列上,同样的代码,不同的同步方法


临界区 成本最高的 ,这句话不认同, 临界区比通过消息,事件等的效率都高,
[解决办法]
倾向使用第4种
[解决办法]
支持下,用过第二种
[解决办法]

引用:
引用:
上面二种在高并发的情况下,上面的应该好过下面这个吧


感觉上是这样的(高并发的情况下,第二种比第三种要好一点)。不过因为两种方法都先判断fMembers是否为nil,一般也很难体现出来。


如果在双核的情况下,高并发,第三种应该是更好。

MSDN上的说法我想应该是很少有这种并发需求,才说的那段话吧。

读书人网 >.NET

热点推荐