C++中原子变量的线程安全初始化

5
考虑以下的C++11代码,其中实例化类B并被多个线程使用。 因为B修改了共享向量,所以我必须在B的构造函数和成员函数foo中锁定对它的访问。 为了初始化成员变量id,我使用一个计数器,这是一个原子变量,因为我从多个线程访问它。
struct A {
  A(size_t id, std::string const& sig) : id{id}, signature{sig} {}
private:
  size_t id;
  std::string signature;
};
namespace N {
  std::atomic<size_t> counter{0};
  typedef std::vector<A> As;
  std::vector<As> sharedResource;
  std::mutex barrier;

  struct B {
    B() : id(++counter) {
      std::lock_guard<std::mutex> lock(barrier);
      sharedResource.push_back(As{});
      sharedResource[id].push_back(A("B()", id));
    }
    void foo() {
      std::lock_guard<std::mutex> lock(barrier);
      sharedResource[id].push_back(A("foo()", id));
    }
  private:
    const size_t id;
  };
}

很不幸,这段代码存在竞态条件,并且不像这样工作(有时ctor和foo()不使用相同的id)。如果我将id的初始化移动到由互斥锁保护的ctor体中,它就可以工作:

struct B {
  B() {
    std::lock_guard<std::mutex> lock(barrier);
    id = ++counter; // counter does not have to be an atomic variable and id cannot be const anymore
    sharedResource.push_back(As{});
    sharedResource[id].push_back(A("B()", id));
  }
};

您能帮助我理解为什么后面的例子起作用吗(是因为它没有使用相同的互斥量吗?)?在B的初始化程序列表中安全地初始化id,而不必在构造函数主体中锁定它,是否有方法?我的要求是 id 必须是const 并且id 的初始化必须在初始化程序列表中进行。


4
您能否发布实际导致问题的代码。您提供的代码没有意义(至少在没有定义A的情况下)。例如,您不能仅仅访问sharedResource[id],而未实际执行任何操作来调整sharedResource的大小以包含id + 1个元素。除非A包含一个名为push_back的成员函数,否则该代码甚至不应编译。 - James Kanze
@JamesKanze 为什么 A 需要一个 push_back 成员函数?我只看到使用了一个 (const char*,size_t) 构造函数和一个移动/复制构造函数。 OP:如果可能,请将其制作成 SSCCE - je4d
2
@je4d:sharedResource 是一个 std::vector<A>,所以 sharedResource[id] 返回一个 A&,因此 sharedResource[id].push_back(...) 调用的是 A::push_back - ildjarn
1
@ildjarn 是的,我扫了一眼,认为因为正在推入一个 A,所以它不会被推入到另一个 A 中,因为这对于 push_back 的传统语义来说没有多少意义。我猜测代码并不是 OP 实际想要编写的。 - je4d
使用push_back或通过索引访问。两者一起没有任何意义。 - selalerer
增加了更多的上下文,使示例自包含。 - michael
3个回答

3
首先,这份代码中仍存在一个基本逻辑问题。你使用 ++counter 作为 id。考虑在单线程中创建第一个 B 的情况。此时 B 将会有 id == 1;在 sharedResource.push_back 之后,sharedResource.size() 将会等于 1,而且唯一合法的访问索引将是 0
此外,代码中明显存在竞争条件。即使你纠正了上述问题(用 counter++ 初始化 id),假设当前 countersharedResource.size() 都是 0,你刚刚初始化完毕。线程一进入 B 的构造函数,增加了 counter,所以:
counter == 1
sharedResource.size() == 0

然后,它被第二个线程中断(在获取互斥锁之前),该线程也增加了counter的值(变成2),并使用其先前的值(1)作为id。然而,在第二个线程中的push_back之后,我们只有sharedResource.size() == 1,唯一合法的索引是0。

实际上,我会避免使用两个分离的变量(countersharedResource.size()),这两个变量应该具有相同的值。从经验来看,两个应该相同的东西却不会是相同的。唯一可以使用冗余信息的时候是用于控制,例如某个时刻,您会有一个类似于assert( id == sharedResource.size() )的断言或类似的内容。我会使用以下内容:

B::B()
{
    std::lock_guard<std::mutex> lock( barrier );
    id = sharedResource.size();
    sharedResource.push_back( As() );
    //  ...
}

或者,如果您想将id设置为常量:

struct B
{
    static int getNewId()
    {
        std::lock_guard<std::mutex> lock( barrier );
        int results = sharedResource.size();
        sharedResource.push_back( As() );
        return results;
    }

    B::B() : id( getNewId() )
    {
        std::lock_guard<std::mutex> lock( barrier );
        //  ...
    }
};

(请注意,这需要两次获取互斥锁。或者,您可以将完整的更新“sharedResource”所需的其他信息传递给“getNewId()”,并让其完成整个工作。)

1

当一个对象正在初始化时,它应该由单个线程拥有。然后,在完成初始化后,它变为共享。

如果存在线程安全的初始化,这意味着确保在初始化之前对象不会变得可访问其他线程。

当然,我们可以讨论原子变量的线程安全赋值。赋值与初始化不同。


在他的例子中,被初始化的对象(类型为B)只能从单个线程(我想)访问。他的问题是该对象的构造函数使用了全局资源。 - James Kanze

0

您正在子构造函数列表中初始化向量。这不是真正的原子操作。因此,在多线程系统中,您可能会同时受到两个线程的影响。这将更改id是什么。欢迎来到线程安全101!

将初始化移动到由锁包围的构造函数中,使得只有一个线程可以访问和设置向量。

修复此问题的另一种方法是将其移动到单例模式中。但是,每次获取对象时都要付出锁的代价。

现在您可以进入诸如双重检查锁定之类的东西 :)

http://en.wikipedia.org/wiki/Double-checked_locking


双重检查锁定是一个明显的反模式;它极其难以正确实现,而且从来不是必需的。在这种情况下,如果他将sharedResource设置为单例,静态的instance函数可以获取锁并返回一个std::shared_ptr,其“析构”对象释放该锁。(这应该是多线程环境中可变单例的标准模式。) - James Kanze

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接