它是否安全,我可以从call_once之前检查布尔变量中受益吗

Is it safe and can I get benefit from checking a bool variable before call_once

提问人:mian 提问时间:11/17/2023 更新时间:11/17/2023 访问量:143

问:

检查之前的 std::call_once 是否安全? 它是否减少了同步开销? 这是我的代码:initialized

struct Data {
    std::vector<int> data;
    std::once_flag flag;
    bool initialized = false;
};

int run(Data& d, int l, int r) {
    if (!d.initialized) {
        std::call_once(d.flag, [&d]() {
            d.data.resize(5);
            std::generate(d.data.begin(), d.data.end(), [g = std::mt19937(std::random_device{}())]() mutable {
                return g() % 10;
            });
            d.initialized = true;
        });
    }

    // read only ops:
    assert(d.data.size() == 5);
    return std::accumulate(d.data.cbegin() + l, d.data.cbegin() + r, 0);
}

void func(Data& d, int l, int r) {
    int res = 0;
    for (int i = l; i < r; i++) {
        res += run(d, i, r);
    }
    std::cout << l << ',' << r << ':' << res << '\n';
}

int main() {
    Data data;
    std::thread t1(func, std::ref(data), 1, 2);
    std::thread t2(func, std::ref(data), 0, 3);
    std::thread t3(func, std::ref(data), 1, 4);
    t1.join();
    t2.join();
    t3.join();
}
C++ 多线程

评论

0赞 abc 11/17/2023
它是线程安全的,但在我看来它没有价值,因为call_once很可能会内联并基本上为您进行检查,那么为什么要再做一次呢?静态在后台使用相同的机制,并保证线程安全,仅初始化一次。
2赞 Passer By 11/17/2023
@MosheRabaev 不,它不是线程安全的,是什么让你有这个想法?有一场数据竞赛。initialized
0赞 Solomon Slow 11/17/2023
您不是第一个尝试优化“仅一次”初始化例程的人,您正在尝试的内容在过去已经尝试了足够频繁,以至于为自己赢得了名声。阅读双重检查锁定的故事。
1赞 Solomon Slow 11/17/2023
P.S.,你是在假设你正在使用的编写者没有做得那么好的情况下运作的。根据我的经验,尝试改进主流软件开发工具链附带的库代码通常是浪费精力。在这个开源软件的时代,如果它还没有达到应有的水平,那么很可能已经有人在编写下一个版本中的修复程序。(OTOH,如果我错了,那么你可以提交错误报告。甚至可以贡献你自己的修复程序。)std::call_once
0赞 abc 11/18/2023
布尔值是 1 个字节,因此不存在对齐问题,所有写入都将是不可分割的和原子的,读取变量的另一个线程要么读取 false,要么最终读取 true,因此实际上在所有现代架构上,这都不是问题。虽然断言可能会触发,但这与数据竞争无关。

答:

4赞 user17732522 11/17/2023 #1

一个线程可能正在读入,而另一个线程正在执行写入 in 的调用一次函数。这是一场数据竞赛,因此会导致未定义的行为。d.initializedif (!d.initialized)d.initializedd.initialized = true;

您无法添加外部检查来验证初始化。重点是以线程安全的方式提供这一点。如果您手动正确地实施了此类检查,您将复制其功能。call_once

在您的特定示例中,最好在启动线程之前直接在 main 中执行 call-once 函数。无论哪种方式,所有线程都需要等待实际执行一次调用函数的线程。

评论

0赞 mian 11/17/2023
如果向量初始化不影响指令重新排序,则一个线程可能会使断言失败?
2赞 user17732522 11/17/2023
@mian我不明白你的意思。如果没有断言,则保证不会失败。使用该程序具有未定义的行为,任何事情都可能发生。d.initializedd.initialized
0赞 mian 11/17/2023
我的意思是,是否有可能一个线程在实际初始化向量之前运行,而另一个线程可能读取 d.initialized 为 true,但发现向量的大小不正确。d.initialized = true;
1赞 Sebastian Redl 11/17/2023
@mian 程序具有未定义的行为。一个线程可以读取为 ,然后继续执行函数的其余部分,跳过 .d.initializedfalsecall_once
2赞 user17732522 11/17/2023
@mian 未定义的行为意味着您对任何行为都无法保证。因此,任何“可能”问题的答案都是“是”。它也可能崩溃或只是完全跳过代码或其他任何内容。
-1赞 kcbsbo 11/17/2023 #2

我不认为这是不安全的,但这不是标准的。至少你应该使用mo_relaxed来保证原子性。

以下是仅使用 call_once+once_flag 生成的汇编代码(在 x86-64 gcc -O3 环境中)。

push    rbp
push    rbx
sub     rsp, 24
mov     rbp, QWORD PTR std::__once_callable@gottpoff[rip]
mov     rbx, QWORD PTR std::__once_call@gottpoff[rip]
lea     rax, [rsp+7]
mov     QWORD PTR [rsp+8], rax
lea     rax, [rsp+8]
mov     QWORD PTR fs:[rbp+0], rax
mov     eax, OFFSET FLAT:_ZL28__gthrw___pthread_key_createPjPFvPvE
mov     QWORD PTR fs:[rbx], OFFSET FLAT:_ZZNSt9once_flag18_Prepare_executionC4IZSt9call_onceIZ14simple_do_oncevEUlvE_JEEvRS_OT_DpOT0_EUlvE_EERS5_ENUlvE_4_FUNEv
test    rax, rax
je      .L13
mov     esi, OFFSET FLAT:__once_proxy
mov     edi, OFFSET FLAT:flag1
call    __gthrw_pthread_once(int*, void (*)())
test    eax, eax
jne     .L10
mov     QWORD PTR fs:[rbp+0], 0
mov     QWORD PTR fs:[rbx], 0
add     rsp, 24
pop     rbx
pop     rbp
ret

经过检查,我注意到缺少类似于测试和返回的优化逻辑。这个结果有些出乎意料。在其他环境中生成代码可能会产生不同的结果。GCC 可能由于其稀有性而没有优化这种情况。针对您的第二个问题,添加的 bool 变量确实可以在没有争用的场景中减轻开销

或者,考虑使用静态本地对象来满足您的要求。如上一篇文章所述,使用函数进行静态初始化可确保线程安全和初始化安全,同时保持高度优化。GCC 采用一种巧妙的技术来实现静态初始化。如果您有兴趣,可以进一步调查详细信息。我不会在这里详细说明这一点。

评论

0赞 user17732522 11/18/2023
即使这还不是 UB,我很确定任何体面的实现都会在进入慢速路径之前进行快速原子检查,例如参见 glibc 的 github.com/bminor/glibc/blob/master/nptl/pthread_once.c#L139(例如 libstdc++ 的 )。正如你所看到的,检查也至少需要是获取,存储是发布。否则,它们不会在调用一次函数中的计算上建立任何内存排序。编译器可以自由地对它们进行重新排序。std::call_oncestd::call_once
0赞 user17732522 11/18/2023
在所示的程序集中,快速路径逻辑隐藏在 后面。它在链接时链接到不同的实现,具体取决于应用程序是作为单线程还是多线程链接。__gthrw_pthread_once