加入收藏 | 设为首页 | 会员中心 | 我要投稿 南京站长网 (https://www.025zz.com.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 服务器 > 搭建环境 > Unix > 正文

UNIX(多线程):28---双buffer “无锁” 设计

发布时间:2022-10-15 22:00:59 所属栏目:Unix 来源:转载
导读: 在linux多线程环境下对同一变量进行读写时,经常会遇到读写的原子性问题,即会出现竞争条件。为了解决多个线程对同一变量访问时的竞争条件问题,操作系统层面提供了锁、信号量、条件变量等

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

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

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

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

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

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

双buffer “无锁” 设计

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

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

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

注意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 指向的内容互换。指针切换如下图所示:

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

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

ptr 竞争条件的解决

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

指针访问丢失

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

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

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

class UpdateData {
  public:
    UpdateData():flag_(0) {
    }
    void PeriodTask();
    void SetFlag(int i) {
      flag_ = i;
    }
  private:
    shared_ptr map_ptr_;
    SpinLock map_rwspinlock_;
    shared_ptr bak_map_ptr_;
    int flag_;
    shared_ptr GetMainMapPtr(); 
    void SetMainMapPtr(shared_ptr new_map_ptr);
    void SwitchMapPtr();
    void PeriodTask();
    void GetData(shared_ptr ptr) {
      ptr["abc"] = "def";
      ...
    }
};
// 获取主map 指针
shared_ptr UpdateData::GetMainMapPtr() {
  Lock(map_rwspinlock_); // 加自旋锁,避免对 ptr 访问出现竞争条件
  return map_ptr_;  // 主map 指针
}
// 设置主map 指针
void UpdateData::SetMainMapPtr(shared_ptr new_map_ptr) {
  Lock(map_rwspinlock_);  // 加自旋锁,避免对 ptr 访问出现竞争条件
  map_ptr_ = new_map_ptr;
}
// 真正的切换指针
void UpdateData::SwitchMapPtr() {
  shared_ptr 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(bakptr) 之后,即使程序一直处在while 循环中,再有新的线程通过 mapptr\ 来访问主map 的数据时,使用的已经是新的数据了。while 循环是为了解决指针访问丢失问题。当引用次数为1时,即 unique 为真时,表示已经没有读线程再使用旧的 map 了,只剩下SwitchMapPtr 中old_map_ptr 这一个引用了,这时可以安全的释放旧的map,并把它清空当作备份map继续进行数据的更新操作。

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

延伸

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

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

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

结语

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

(编辑:南京站长网)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章