为什么标准不要求std::mutex::~mutex与最新的解锁操作同步

4
struct X
{
    std::mutex m;
    std::string str;
    
    void set(std::string s) 
    {
        auto _ = std::unique_lock(m);
        str = std::move(s);
    }
    
    ~X()
    {
        // auto _ = std::unique_lock(m);
    }
}

有没有标准的部分保证在没有注释行的情况下,~X~string内部永远不会出现竞态条件?
通过具有RELAXED语义的原子变量来管理对象和/或生命周期的独占访问是可能的(即除了生命周期结束的事实之外,不同步任何其他数据)。我们有一个互斥锁来保护对对象的访问,因此使用松散操作似乎可以用于同步独占访问/生命周期和互斥锁来访问数据。
如果标准要求~mutex与最新的unlock同步,并且我们将互斥锁的声明移到受保护数据的下方,那么我们可以使用默认的~X,但如果没有这个要求,我们需要始终有显式的析构函数来锁定保护任何成员的所有互斥锁。

PS为了帮助理解我的问题,使用这个例子https://en.cppreference.com/w/cpp/thread/mutex,有一个评论说在没有锁的情况下访问g_pages是安全的。为什么?标准的哪一部分保证了这一点?两个线程都加入只能保证对映射的多线程访问没有问题,但它不能保证与最新的mutex::unlock操作同步。我运行了许多不同的程序,试图暴露出竞态条件,我非常确定mutexlockunlock时使用了std::atomic_thread_fence,它与必须使用至少一些原子操作的thread.join同步。但问题是标准不要求mutex使用std::atomic_thread_fence,只是碰巧我所知道的所有实现都使用了它。

#include <chrono>
#include <iostream>
#include <map>
#include <mutex>
#include <string>
#include <thread>
 
std::map<std::string, std::string> g_pages;
std::mutex g_pages_mutex;
 
void save_page(const std::string &url)
{
    // simulate a long page fetch
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::string result = "fake content";
 
    std::lock_guard<std::mutex> guard(g_pages_mutex);
    g_pages[url] = result;
}
 
int main() 
{
    std::thread t1(save_page, "http://foo");
    std::thread t2(save_page, "http://bar");
    t1.join();
    t2.join();
 
    // safe to access g_pages without lock now, as the threads are joined
    for (const auto &pair : g_pages)
        std::cout << pair.first << " => " << pair.second << '\n';
}

PPS正如@user17732522所指出的那样,上面的示例由于使用了thread.join,所以是绝对安全的。你可以移除互斥锁和其中一个线程(只使用一个线程),它仍然是安全的。因此,我稍微修改了这个示例来演示问题。请注意,这里的松散内存顺序是重要的。如果我们将其替换为一对acquire/release,那么它将成为完全线程安全的代码,但如果我们假设std::mutex::unlock使用std::atomic_thread_fence,那么使用松散内存顺序也是正确的。
#include <atomic>
#include <chrono>
#include <iostream>
#include <map>
#include <mutex>
#include <string>
#include <thread>

std::map<std::string, std::string> g_pages;
std::mutex g_pages_mutex;
std::atomic_flag g_f1 = {};
std::atomic_flag g_f2 = {};

void save_page(const std::string& url, std::atomic_flag* flag)
{
    // simulate a long page fetch
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::string result = "fake content";

    {
        std::lock_guard<std::mutex> guard(g_pages_mutex);
        g_pages[url] = result;
    }
    flag->clear(std::memory_order_relaxed);
}

int main()
{
    g_f1.test_and_set();
    g_f2.test_and_set();

    std::thread t1(save_page, "http://foo", &g_f1);
    std::thread t2(save_page, "http://bar", &g_f2);

    while (g_f1.test_and_set(std::memory_order_relaxed));
    while (g_f2.test_and_set(std::memory_order_relaxed));

    // whether it safe to access g_pages without lock now, as the threads signaled they are done?
    for (const auto& pair : g_pages)
        std::cout << pair.first << " => " << pair.second << '\n';

    t1.join();
    t2.join();
}

6
如果一个互斥锁被锁定,试图销毁它将导致未定义行为。即使不考虑这一点,当一个线程正在销毁对象时,另一个线程如何能够访问该对象而不引发各种问题也是不清楚的。假设被注释的行被取消注释,如果执行该行后另一个线程立即尝试获取锁,会发生什么? - undefined
有趣。不,我不认为标准能保证那样,那将是完全不合理的。 - undefined
1
最后一个例子有效是因为join与线程的完成同步,线程中的其他所有内容都在完成之前被顺序执行。在那一点上,互斥锁不再相关。 - undefined
@AntonDyachenko答案与我在您的Rust变体问题上评论的相同。您正在使用memory_order_relaxed;仅因为您的标志读取了更新的值,并不意味着您将观察到发生在g_pages上的任何事情,因为您尚未建立g_pages需要在标志循环终止之后被读取。 - undefined
@GManNickG Rust的问题不同,rust中的示例根本不使用互斥锁,因此它是一个明显的竞争条件,具有松散的内存顺序。但是,rust中的问题是这种行为(get_mut)被认为是安全的,但实际上并不是(请记住,将原始指针转换为互斥锁的所有要求都得到满足)。正如我在这里评论过的,并且我在许多平台上多次检查过,使用mutex.lock()/unlock在线程中是足够的,即使对于标志的松散内存顺序,这是因为有屏障存在。这并没有被任何标准部分涵盖,或者我没有找到相关的部分。 - undefined
显示剩余3条评论
1个回答

9
有点道理。标准确实要求你不要销毁生命周期已结束的对象。如果多个线程尝试在同一个对象上运行X::~X(),那你已经陷入了未定义行为的领域。
对于一个线程尝试在同一个对象上执行~X,而另一个线程尝试在该对象上执行x.set的情况,可以提出类似的论点。如果你遇到了这种情况,你已经违反了对x对象的访问规则。由此产生的对x.str的竞争导致了未定义行为。
所以,你根本不应该陷入需要在拥有者X的析构函数中使用锁的情况。如果有多个线程访问同一个x对象,可能需要将其所有权放在线程池之外。

1
@AntonDyachenko "happens before"的定义不包括一个线程向原子变量写入一个值,另一个线程读取该写入的值的情况,除非写入是一个释放写入,读取是一个获取读取。 - undefined
1
另外,请注意这一点:~X { auto _ = std::unique_lock(m); } 实际上没有任何有用的作用。事件的顺序是这样的:首先构造 _,然后销毁它(从而释放锁),然后 X 的成员被销毁,不再受锁的保护。 - undefined
@bitmask "根本没有任何有用的功能"我不同意。在析构函数的主体完成之前,它确实做到了它应该做的事情,但是在它解锁(==主体完成)之后,任何线程尝试访问该对象,就会出现未定义行为。为了明确起见,我看到了很多这样的代码(在析构函数中加锁),并且自己也写过类似的代码,但是它们总是遵循相同的模式 - 在析构函数中,在锁上设置退出标志,并在条件变量上(在同一个锁上)阻塞析构函数,等待运行对象成员的线程检查标志并退出。这是如此常见的模式,我很惊讶你不知道。 - undefined
1
@AntonDyachenko 在写线程中调用std::atomic_thread_fence并不会建立同步关系,除非在读线程中存在一个获取操作(获取操作可以是另一个栅栏,或者是一个原子操作);请参考标准中的[atomics.fences]。我并不感到惊讶,现实世界中存在很多不符合标准但仍然能正常工作的代码。 - undefined
1
@AntonDyachenko 你错了。在你完成销毁对象之前,你的锁就已经失效了。 - undefined
显示剩余11条评论

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