Linux 多线程 ”一写多读” 模式下的无锁设计

  • 缘起
  • 双buffer “无锁” 设计
  • 指针的切换
    • ptr 竞争条件的解决
    • 指针访问丢失
  • 延伸
  • 结语

缘起

在linux多线程环境下对同一变量进行读写时,经常会遇到读写的原子性问题,即会出现竞争条件。为了解决多个线程对同一变量访问时的竞争条件问题,操作系统层面提供了锁、信号量、条件变量等几种线程同步机制。如果对变量的每次访问都使用上述机制,由于系统调用会陷入内核空间,需要频繁的进行上下文切换,这就导致了程序的时间开销比较大。

自然的,我们就想到,在多线程环境中,在某些情况下是否能减少甚至避免使用系统调用?答案是肯定的。

如果对多线程下的变量访问进行分析,可以看到,线程对变量的访问可以分为以下几类:

  • 一个线程写,另一个线程读,简称一写一读
  • 多个线程写,一个线程读,简称多写一读
  • 一个线程写,多个线程读,简称一写多读。
  • 多个线程写,多个线程读,简称多写多读。

在linux 系统中,多个线程同时读一个变量是不需要同步的,而多个线程同时写一个变量或一个线程写而其他线程读某个变量,是需要同步的,可以总结为:”多读不互斥,而读写和多写互斥“。

由于多个线程对同一变量的读不需要同步,因而一写多读和一写一读并无本质区别,进而可以把多线程下对变量访问依据是否需要同步而合并成如下三类:

  • 一写多读
  • 多写一读
  • 多写多读

解决上面所有的互斥,都可以使用系统调用。上面已经提到,在某些情况下我们是可以避免使用代价高昂的系统调用的。而“一写多读”就是这些特殊情况中的一种。

双buffer “无锁” 设计

使用系统调用进行同步的主要问题在于频繁切换上下文耗时较长,而后台系统的处理速度又是除正确性之外最为关键的指标。为提高系统的运行速度,我们可以使用用其他系统资源来换取时间的办法,从而避免使用锁之类系统调用。在这些方法中,最常见的就是用空间换取时间。

针对一写多读的情况,可以使用”双 buffer“ 及共享指针机制来实现对同一变量高效访问,同时又能保证不会出现竞争条件。这一实现的技术关键点在于以下两个方面:

  • 双 buffer 的备份机制,避免了同时读写同一变量。双buffer 就是指对于通常要被多个线程访问的变量,再额外定义一个备份变量。由于是一写多读,写线程只向备份变量中写入,而所有的读线程只需要访问主变量本身即可。当写进程对备份变量的写操作完成后,会触发主变量指针和备份变量指针的互换操作,即指针切换,从而将原变量和备份变量的身份进行互换,达到数据更新的目的。
  • 共享指针 shared_ptr,由于其记录了对变量的引用次数,因而可以避免指针切换时的“访问丢失”问题。

为了便于理解,本文使用 C++ 中的 map 类型变量作为示意,当然,本文的方法可以推广到一写多读模式下任意数据类型的更新中。使用双 buffer 的示意图如下:

Linux 多线程 ”一写多读” 模式下的无锁设计_第1张图片

注意ptr 和 bak_ptr 都是整个map 的指针,上面蓝色箭头表示通过两个指针访问 map 中的元素,ptr 和bak_ptr 本身并不指向元素。

在系统启动时,把两个智能指针分别初始化为一个主map 和一个备份 map。之后把全部数据更新到主map中开始对外提供服务。当外部需要读取数据时(多读),全部通过主map 的智能指针 ptr 来实现。而数据的更新全部通过备份map 的指针bak_ptr 来实现。由此可以看出,由于使用了两个map,即双buffer,使得数据的读和写进行了分离,互不影响,不会出现竞争条件,避免了锁的使用。

指针的切换

由于读写分离,双buffer机制下的数据读写不会出现竞争条件。在备份map 中数据更新完成时,必然需要一种方式,使得新数据能被使用到。这里需要做的就是把主map和备份map 的共享指针指向的内容互换,即ptr 和bak_ptr 指向的内容互换。指针切换如下图所示:

Linux 多线程 ”一写多读” 模式下的无锁设计_第2张图片

那么,在指针互换时,会出现什么问题呢?

在指针的切换过程中,会出现如下两个问题:

  • 由于对主map 的读是多线程的读,会出现多线程同使用主map 共享指针ptr 的情形,而互换指针时,需要对主map 的指针进行写操作,那么对同一指针 ptr 的读和写的竞争条件如何解决?

  • 在准备互换ptr 和 bak_ptr 指向的内容时,如果某个读线程正在使用 ptr 访问主map,直接互换就可能出现读线程再通过ptr获取数据时访问失效的问题,严重的情况下会访问到无效内存导致程序崩溃。这一问题本文简称为”指针访问丢失“问题,类似于常规指针中出现的野指针或悬垂指针的问题。

ptr 竞争条件的解决

当指针切换时,单线程对 bak_ptr 的写操作已经完成,因而对其可以随便读写。但由于多个读线程可能还在使用ptr,切换指针时对 ptr 的读写就要十分的小心。为了避免对 ptr 的读写出现竞争条件,本文使用了自旋锁来对ptr 的读写进行同步。使用自旋锁的原因有两个:

  • 只在指针切换时使用锁,而不是在读写两个map 时使用锁,因而锁的使用频率会非常的低,由此导致的上下文切换的代价是可接受的。
  • 由于指针切换时 ptr 处于的情形是一写多读,指针互换准备对 ptr 进行写操作时,要获取锁的等待时间并不长,并不会有长时间的锁等待出现,因而可以使用代价更小的自旋锁,而不是使用代价更高的读写锁。

指针访问丢失

上面已经介绍了指针访问丢失的情形,即在两个指针切换时,多个读线程可能正在使用ptr。为了避免出现读线程会读取到无效数据,本文使用的方法是利用共享指针的引用计数来实现指针的延迟互换。

解决ptr 的竞争条件和指针访问丢失问题后,就可以安全的使用双buffer 方案了。

最终的代码如下,其中 map_ptr_ 就是主map 指针,bak_ptr_ 是备份map 的指针:

class UpdateData {
  public:
    UpdateData():flag_(0) {
    }

    void PeriodTask();
    void SetFlag(int i) {
      flag_ = i;
    }
  private:
    shared_ptr<map> map_ptr_;
    SpinLock map_rwspinlock_;
    shared_ptr<map> bak_map_ptr_;
    int flag_;

    shared_ptr<map> GetMainMapPtr(); 
    void SetMainMapPtr(shared_ptr<map> new_map_ptr);
    void SwitchMapPtr();
    void PeriodTask();
    void GetData(shared_ptr<map> ptr) {
      ptr["abc"] = "def";
      ...
    }
};


// 获取主map 指针
shared_ptr<map> UpdateData::GetMainMapPtr() {
  Lock(map_rwspinlock_); // 加自旋锁,避免对 ptr 访问出现竞争条件
  return map_ptr_;  // 主map 指针
}

// 设置主map 指针
void UpdateData::SetMainMapPtr(shared_ptr<map> new_map_ptr) {
  Lock(map_rwspinlock_);  // 加自旋锁,避免对 ptr 访问出现竞争条件
  map_ptr_ = new_map_ptr;
}

// 真正的切换指针
void UpdateData::SwitchMapPtr() {
  shared_ptr<map> old_map_ptr = GetMainMapPtr();
  SetMainMapPtr(bak_ptr_);  // 这里新数据已经可以被使用了

  // 用引用次数来解决访问丢失问题
  while (!old_map_ptr.unique() {
    ::usleep(10000);  // 指针延迟互换
  }
  bak_map_ptr_ = old_map_ptr;
  bak_map_ptr_->clear();
}


// 定时任务
void UpdateData::PeriodTask() {
    while(flag) {
      ::sleep(300); // 每5分钟更新一次数据
      GetData(bak_ptr_); // 新数据写到备份 map 中
      SwitchMapPtr();
    }
}   

需要注意的是,SwitchMapPtr 中调用 SetMainMapPtr(bak_ptr_) 之后,即使程序一直处在while 循环中,再有新的线程通过 map_ptr_ 来访问主map 的数据时,使用的已经是新的数据了。while 循环是为了解决指针访问丢失问题。当引用次数为1时,即 unique 为真时,表示已经没有读线程再使用旧的 map 了,只剩下SwitchMapPtr 中old_map_ptr 这一个引用了,这时可以安全的释放旧的map,并把它清空当作备份map继续进行数据的更新操作。

从上面可以看出,通过使用双buffer和共享指针,避免了在一写多读模式中对数据的读写频繁加锁,实现了”无锁“ 的设计。

延伸

即然双buffer可以很好的用于一写多读模式,那么对于”多写一读“或”多写多读“模式,是否也可以引入双buffer 模式呢?

在含有多线程写同一变量的情形下下,其实是不太适合使用双buffer 方案的。主要原因是:

  • 多写的情形下,需要在 bak_map 的多个写操作之间通过锁来同步,虽然避免了对读写互斥情形的加锁,但是多线程写时通常对数据的实时性要求较高,如果使用双buffer,所有新数据必须要等到指针切换时才能被使用,很可能达不到实时性要求。
  • 多线程写时若用双buffer,则在指针切换时也需要给bak_map 加锁,并且也要用类似于上面的while 循环来保证没有线程在执行写入操作时才能进行指针切换,而且此时也要等待多读的完成才能进行切换,这时就会出现对 bak_map 的锁定时间过长,在数据更新频繁的情况下是不合适的。

因而,在多写的模式下,还是优先用读写锁等操作系统提供的同步机制。

结语

双buffer 方案在多线程环境下能较好的解决 “一写多读” 时的数据更新问题,特别是适用于数据需要定期更新,且一次更新数据量较大的情形。而这种情形在后台开发中十分常见。

对技术的不断探索与思考往往会有新的收获,我们误闯入的世界,也往往能带给我们不一样的精彩。

我思故我在。


我就是我,疾驰中的企鹅。

我就是我,不一样的焰火。

Linux 多线程 ”一写多读” 模式下的无锁设计_第3张图片

你可能感兴趣的:(linux-系统调用,技术与心得,后台开发)