DelphiXE10.2.3实现线程安全访问数据和对象(二)——如何理解原子自旋锁

     基于《DelphiXE10.2.3实现线程安全访问数据和对象(一)——Delphi原子操作函数介绍》中的原子操作函数,我们再来逐渐理解和实现一个原子自旋锁。

     “原子自旋锁”中的“自旋”其实就是一个读或写数据过程中的一个while循环,只不过while的条件是用原子操作函数来判断条件是否成立,不成立会一直循环自旋直到条件成立为止,看上去似乎会很消耗cpu资源,但其实我们可以灵活处理条件不成立时的等待方法,我的应用场景中,需要条件不成立就一直等待条件成立,各位可根据自身需要进行改造。

一、原子自旋锁要解决什么问题?

   如果只是想解决多线程读写公共数值变量,则根本不需要所谓的原子自旋锁,直接用TInterlocked.Increment(a);或TInterlocked.Decrement(a);来修改数值,并用TInterlocked.Read(a);或b:=AtomicExchange(a,0,0);读取即可,是最简单高效的方法。

   而我们通常更需要多线程安全读写一个公共对象、结构指针的内容,类似线程安全的hash表、对象池、链表这些包含多种复杂数据的对象或结构指针,这时可能需要一个线程修改或读取这个对象的多个数据值,在这个过程中不能被其他线程打断,以免读写到脏数据,之前的方法大多是使用线程互斥锁、信号量等方式,能够达到线程安全的目的,但由于是线程阻塞式,一个线程在读写时,其他线程全部等待,直到之前的线程读写完毕退出锁后,才能再进入一个线程去读写,这就是一个队列的概念,就算门外想进门的人再多,也只有等到门内的人出去才能让离门最近的一个人进去,这种低效率的方式就成为一个系统的瓶颈。

   后来有人提出新的概念,就是将读和写分开,由于读取时不会去修改数据(也只有自己把控了),所以完全没有必要堵塞所有的读取过程,只有当修改数据时,才让所有线程等待写入完毕,这个思想在一定程度和一定应用场景下确实能够较高的提高效率,但本人找到的这种多读单写锁文章,还是大多使用互斥锁的方式实现写时堵塞所有线程,经过测试,效率会在这一瞬间降低太多,而如果应用场景中读写频率相对均衡的情况下,那基本就没有任何优势了。

  而原子自旋锁并不试图解决读写线程堵塞问题(在目前计算机工作原理下,也没人能够解决吧),而只是利用原子操作性隔离线程读写操作,当线程需要修改数据时,阻塞线程的时间越短越好,通常一个原子操作只消耗几个CPU时间片,相比互斥锁的效率提高太多了。

二、理解一个简单链表在多线程下为什么会出错。

1、一个最简单的单链表结构大约是这个样子的:

    PListItem = ^TListItem;
    TListItem = record
      Value: Pointer;//保存的值,类型可以不限制
      Next: PListItem;//下一个链接指针
    end;

   加入一个新链表时大约是这样的:

var

    FListHead:PListItem;

procedure Add(var Value:Pointer);

var

  ListItem:PListItem; 

begin

      new(ListItem);

      ListItem^.Value:=Value;

      if FListHead<>nil then 

       //如果链表头不为空,则将原链表头放入新链表的下一个链表中(这样讲解最简单)

        ListItem^.Next:=FListHead;     

   //然后将原链表头替换为新链表

   FListHead:=ListItem;

end;

这段代码看上去就只有两句:

ListItem^.Next:=ListHead;

FListHead:=ListItem;

     但Delphi编译器将这两句Pascal代码编译成机器码时,会生成多条汇编语句,每条汇编语句在多线程情况下,并不是在一个线程中顺序执行完毕后才让另一个线程继续执行,而是A线程执行一条汇编语句后,都有可能被另外的B、C、D..........线程中断,就会出现A线程刚把ListItem.Next:=ListHead;执行完成(只是举例,实际还会是更多的汇编语句),B线程中断了A线程,并且也完成了ListItem.Next:=ListHead;,那问题就来了,不管AB两个线程谁执行ListHead:=ListItem;语句,整个链表关系都已经乱了!真是可怕!

     就算是简单的这一句: if ListHead<>nil then ,都会在多线程下出现A和B线程判断时不为空,但刚进入下面的代码前,C线程取走了数据,造成ListHead为空,而A和B线程就会存入错误的数据。

  所以这时通常就要用线程互斥锁,让A线程完整执行ListItem.Next:=ListHead;和ListHead:=ListItem;两条语句后才交给B线程继续执行,就不会出现脏数据问题,那原子自旋锁又是怎么实现这种线程互斥的呢?

三、原子自旋锁的原理

procedure Add(var Value:Pointer);

var

  ListItem:PListItem; 

begin

  new(ListItem);

  ListItem^.Value:=Value;

  while true do//条件不成立,就一直循环

  begin

    //先将链表头存入新链表的下一个位置

    ListItem^.Next := FListHead;

    //然后用原子操作比较替换函数来判断当前链表头是否还是之前存入新链表的Next值,如果发生了改变,就循环再取数,再判断,直到条件成立时将链表头替换为新链表为止

    if TInterlocked.CompareExchange(Pointer(FListHead), ListItem,
      ListItem^.Next) <> ListItem^.Next then
      continue;
    exit;
  end;

end;

     这里的代码是最精简的代码,但确实可以保证正确的链表建立过程,实际用于生产的代码还需要考虑很多因素,主要包括对链表中保存的Value读写和While循环等待时如何避免CPU大量占用等问题,下一篇接着讲解。

QQ群:DELPHI 开发者群:733975324


猜你喜欢

转载自blog.csdn.net/u011784006/article/details/80494669