【问题标题】:Writing on shared variable by acquiring mutex in shared mode(instead in exclusive mode)通过在共享模式下获取互斥锁来写入共享变量(而不是在独占模式下)
【发布时间】:2022-01-17 02:43:02
【问题描述】:

使用 std::shared_timed_mutex 的通常模式是让'reader'线程以共享模式获取它,而'writer'线程以独占模式获取它。通过这种方式,读取和写入不能同时发生,因此程序不会出现数据争用/未定义行为。

我想了解如果我更改线程之间的模式是否存在任何问题,即读取器线程在独占模式获取锁后读取共享变量并且写入线程在共享模式获取互斥体后写入共享变量。

#include <iostream>
#include <thread>
#include <random>
#include <chrono>
#include <shared_mutex>

using namespace std::chrono_literals;

std::shared_timed_mutex lck;
int shared_array[5];

void writerFunc(int index);
void readerFunc();

//main thread
int main() {
  std::thread writer_threads[5];
  for(int i=0; i<5; ++i) {
    writer_threads[i] = std::thread(writerFunc,i);
  }

  while(true) {
    std::this_thread::sleep_for(5s);
    readerFunc();
  }


  for(int i=0; i<5; ++i) {
    writer_threads[i].join();
  }

}

//function executed in writer threads.
//Each writer thread will work on it's own index in the global shared array.
void writerFunc(int index) {
  std::random_device rd;
  std::mt19937 mt(rd());
  std::uniform_real_distribution<double> dist(1.0, 42.0);

  while(true) {
    {
      std::shared_lock<std::shared_timed_mutex> sl(lck);

      //Writing random number in shared variable.
      shared_array[index] += dist(mt);
    }

    std::this_thread::sleep_for(100ms);
  }
}

//function executed in reader thread(main).
void readerFunc() {
  std::lock_guard<std::shared_timed_mutex> sl(lck);
  for(int i=0; i<5 ; ++i) {
    std::cout<<"\nshared_array["<<i<<"]--> "<<shared_array[i];
  }
  std::cout<<"\n\n";
}

由于读写线程不能同时访问变量,因此上述程序中不存在数据竞争。 Thread-sanitiser 也没有报告上述程序有任何问题。

我主要对读者线程读取的值有一点疑问。

C++ 标准是否保证,无论底层 CPU 架构如何,

a) 上述程序没有任何 UB?

b) reader 线程只能看到 writer 线程写入的最新值?

********* 其他详细信息 ********

请注意,以上是一个简短的示例程序,我试图在其中复制我的主要项目设计的特定部分。那里的规模要大得多。例如那里的数组大小(不完全是数组,但非常相似)约为 200 万。此外,数据结构不是简单的 int,而是自定义的可序列化结构。

所以想想这样的事情:

custom_serializable_struct shared_variable[2000000];

在我的主程序中,会有 'N' 个写入线程和一个单个读取线程。大多数情况下,编写器线程将正常工作。由于 N 远小于 200 万,因此我在编写器线程中使用单独的同步(200 万个索引中的每一个都有 1 个 std::atomic_flag。这是在获取 shared_timed_mutex 后使用的)(我已经从示例代码的设计,因为我觉得它与我的要求无关)。

就像我上面所说的,大多数时候,编写器线程都可以工作。只有偶尔,阅读器线程才会起作用。

该方案主要有以下要求:

  1. 当读取线程工作时,我必须尽量减少写入线程在互斥体上花费的等待时间。
  2. 我必须确保读取器线程在工作时始终获取写入器线程写入的最新值。

所以基本上这就是我的主程序中发生的事情:

N 个编写器线程:

while (true) {
// 1. Acquire the shared_timed_mutex in shared mode.
// 2. Acquire the std::atomic_flag of the index, i, on which the thread has to work. This is required, as I mentioned, to prevent data race among writer threads.
// 3. Do changes in the custom_serializable_struct shared_variable[i]
}

1 个读者话题:

while(true) {
// 1. long sleep time.
// 2. Acquire the shared_timed_mutex in exclusive mode.
// 3. read the entire 2 million values. Please note that this read is not done 1 by 1 like in a for loop. It's more like memcpy of the entire memory.
}

【问题讨论】:

  • 如果写线程只获得了一个共享锁并写入共享数据,那么你将与任何其他只有一个共享锁并正在读取的线程竞争。 (如果您唯一的另一个线程总是获得排他锁,则没有竞争,但是当一个简单的互斥锁可以做到时,为什么还要首先使用读/写锁,并且不会让代码的人类读者感到困惑?)
  • @NicolBolas 数组的 5 个元素中的每一个都是一个单独的内存位置。没有两个写入器线程会触及相同的内存位置。
  • 互斥锁不仅仅是将线程锁定在临界区之外。他们还建立了memory barriers,其中,在某些架构上,可能不止一种。事实上,我不知道这一点,但是当线程在“共享”模式下获取锁时执行的特定内存屏障指令似乎可能会为将要写入的线程提供不充分的同步 共享变量。同样,对于要读取另一个线程所写内容的线程来说,排他锁可能是错误的。
  • @JeremyFriesner rand()
  • @n.1.8e9-where's-my-sharem。感谢您指出了这一点。我已经尝试修复它。

标签: c++ multithreading c++14


【解决方案1】:

但是为什么?

我想知道在锁定方面改变读者和作者角色的动机是什么!你这样做解决了什么问题?

在之前的评论中,您提到您不希望作家之间发生争用。

查看代码,我还推断数组中每个int 的更新都独立于其他,但读者必须一次看到它们,就好像它们共同拥有一样ONE 含义(排他锁的原因)。你还没有提到这一点 - 所以假设这是不是意图

只有一个读者,但有很多作者,也就是说,它看起来与(一些?)读者多于作者的刻板印象相反。这不应该是主要考虑因素。

应避免传达意料之外的含义和令人惊讶的代码。我同意@Nicol Bolas 并建议另一种方法:

错误的工具 - 改用 std::atomic

std::shared_timed_mutex 的反向使用对于未来的维护者(你自己?)来说是一个惊喜。另外,使用它是误导读者信息的来源,也是这个问题的原因。 我同意@Nicol Bolas 的观点,即 atomic 可以解决这个问题:

std::atomic<int> shared_array[5];

void writerFunc(int index) {
   ///Other code
    while(true) {
        //Writing random number in shared variable.
        shared_array[index].fetch_add(dist(mt));

        std::this_thread::sleep_for(100ms);
    }
}

void readerFunc() {
    for (auto& item : shared_array) {
        std::cout << item;
    }
}

更好的抽象 - 使用 libguarded::shared_guarded

悲伤的根源似乎是您应用std::shared_timed_mutex lck 的级别 - 它控制整个数组,而您希望更好地控制每个元素。

我强烈建议您考虑使用 BSD 2-Clause “Simplified”许可下的 cs_libguardedshared_guarded

libguarded::shared_guarded<int> shared_array[5];  //Nailed it!

void writeFunc(int index) {
    //Other code
    while (true) {
        {
            auto handle = shared_array[index].lock();
            auto& item = *handle;
            item += dist(mt);
        }
        std::this_thread::sleep_for(100ms);
    }
}

void readerFunc() {
    for (auto& array_element : shared_array) {
        auto item = array_element.lock_shared();
        std::cout << *item;
    }
}

以上内容不仅确保了共享数据的正确、不出所料的使用,而且还确保了 const 正确性,因为它不允许在 lock_shared 上写入。这可以用于任何数据类型,而不仅仅是ints - std::atomic 具有的限制。正如@Solomon Slow 所指出的那样,内存障碍可能会导致您使用原始方法无序执行的意外结果——这段代码没有这个问题。 libguarded 还确保对共享数据的访问始终与正确同步 - 不会意外使用共享数据。

仅供参考,shared_guarded 相当于为每个元素使用互斥体(如下所示),只是更简洁、常量正确且防呆

std::shared_timed_mutex lck[5];  //Don't do it by hand, better use libguarded, as above
int shared_array[5];

我强烈建议优先考虑更简洁的实现,而不是任意目标,例如不想拥有很多 mutexes。如果您不想争用,请消除共享,而不是旨在减少互斥锁。 问题是共享而不是互斥体的存在

P.S.:您将问题标记为 C++14,而 libguarded 需要 C++17。据我检查,libguarded::shared_guarded 应该与std::shared_timed_mutex 一起使用。

【讨论】:

  • 我在问题末尾添加了一些设计背后的推理。
  • 我同意当前的设计,特别是当前获取锁的模式的选择,至少可以说是令人惊讶的。然而,重大的设计大修不会很快发生,这就是为什么我更关心当前设计的正确性。目前这才是最重要的。
【解决方案2】:

unlock_sharedexplicitly synchronizes with subsequent lock calls on the same mutex。这将允许读取器读取任何写入器写入的数据。同样,lock_shared 与之前对unlock 的调用同步。因此可以在没有数据竞争的情况下向后使用shared_mutex(注意:rand 不需要是线程安全的)。

但是……你应该吗?

互斥锁的目的是确保数据完整性,不仅在字节级别(即:数据竞争),而且在更高级别。您有 5 个线程写入 5 个不同的位置。但是...数据的含义是什么?这些数据是否完全不同,或者数据的集合是否具有某种需要保留的意义?也就是说,如果一个线程写入一个值,如果另一个线程还没有写入它的值,读取器是否会得到格式错误的信息?

如果这些数据值都是完全独立的,那么就不需要互斥体(至少对于基本类型而言)。你真正在做的只是原子写入。作者可以写信给atomic&lt;T&gt;,读者会读到这些。由于这些值都是不同的,并且它们之间没有任何排序问题,因此您无需阻止 任何 线程写入。您需要做的就是确保个人T 级别的数据完整性。无锁原子将比任何基于互斥锁的解决方案快得多。

但是,如果数据具有某种完整性概念,如果线程组共同创建读取器线程应完整读取的单个值,那么您正在寻找的是is a barrier,而不是互斥锁.此对象允许您查看一组执行代理是否已集体到达某个点。如果有,您可以安全地读取数据。一旦你读完它,就可以安全地释放代理以再次写入它们。

【讨论】:

  • 在我的用例(我试图在共享示例中复制的那个)中,数据值基本上是分开的。但是我不能像你建议的那样使用 std::atomic ,因为共享对象是一个自定义的可序列化数据结构,创建用于通过网络发送。
  • @VishalSharma 如果这些不是简单的对象,只需将互斥锁与每个单独的对象相关联。如果可扩展性是一个问题,那将更好地扩展。如果可伸缩性不是问题,那么额外的内存开销(无论如何可能非常轻微,但仍然......)根本不会重要。代码变得更简单,因为您没有共享互斥锁 - 您甚至可能希望将互斥锁放入对象本身并封装所有访问。
  • @AndrewHenle 我在问题末尾添加了一些设计背后的推理。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-10-10
  • 2012-06-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多