【问题标题】:Is it legal for the compiler to assume that a static variable will not be modified by another thread?编译器假设静态变量不会被另一个线程修改是否合法?
【发布时间】:2021-12-26 23:54:26
【问题描述】:

编译器 (gcc) 只是假设静态变量永远不会被其他线程触及,即使优化级别最低。我试图读取从另一个线程写入的值,但 gcc 只是认为该值从未改变。是否读取由另一个线程未定义行为修改的静态变量的值?

我特别询问编译器所做的假设。不是关于当程序没有正确处理线程同步时会发生什么。


为了向未来的读者澄清,只有选定的答案才能清楚地回答我在标题中写的问题。它并没有解决我遇到的实际问题,但这就是我所问的。不过,我想澄清一下实际问题是什么,以及我是如何最终理解编译器在做什么的。

给定一个静态全局变量n

static int n;

我将n 放入一个循环中以制造一个错误的自旋锁。

while (!n); doSth();

除非nvolatile_Atomic,否则编译器将简单地假设n 的值不会在循环内改变。

然后我注意到依赖于信号处理程序的代码部分按预期工作。

n = 0; //added for explanation
sigset_t s;
sigemptyset(&s);
sigaddset(&s, SIGUSR1);
sigwait(&s, (int *)&_);
if (n) doSth(); //the compiler still checks the value of `n`

我一开始以为sigwait 有什么特别之处,但事实并非如此。有了这个更简单的例子,

n = 0;
putchar(0);
if (n) doSth();

编译器仍然不能假定n 的值是0,因为putchar 修改n 的值可能会产生副作用,因为n 是一个全局变量。

当然,任何理智的编译器都会对此进行优化。

n = 0;
if (n) doSth();

毕竟,有了一个不错的信号处理程序,一切都可以正常工作。

【问题讨论】:

  • @sj95216 这仅适用于“两者都不会在另一个之前发生”的数据竞争条件。如果从另一个线程修改后读取清楚了值怎么办?
  • 一般情况下,如果变量不是volatile,不是原子的,也不是被内存屏障保护的,优化器会像单线程一样优化对它的访问。
  • 在大多数情况下,它甚至不是编译器的假设,而是 cpu 本身的假设,因为内存中的值是通过多级缓存缓冲区缓存的。编译器关心的只是volatile,它不能替代原子操作等。
  • 我不认为静态变量的规则(re:多线程)与一般变量的规则有什么不同。
  • @xiver77 "happens before" 没有通俗的意思。它是 5.1.2.4.18 中定义的技术术语。特别是“发生在之前”是指在同一线程中之前排序或通过原子/互斥体/栅栏以某种方式同步。

标签: c multithreading language-lawyer undefined-behavior


【解决方案1】:

注意:这个答案是指问题的revision 3。同时,问题已更改,因此此答案不再直接对应问题。

根据§5.1.2.4 ¶25 and ¶4 of the ISO C11 standard,如果至少有一个线程正在写入该内存位置,两个不同的线程以无序方式使用非原子操作访问同一内存位置会导致undefined behavior

因此,编译器可以假定没有其他线程会更改非原子非易失性变量,除非线程以某种方式同步。

如果使用线程同步(例如互斥锁),则不再允许编译器假定变量没有被另一个线程修改,除非使用了允许编译器继续生成的memory order这个假设。

在您的问题中,您声明您正在尝试使用“信号”对线程进行排序。但是,在 ISO C 中,“信号”不能用于线程同步。根据§7.14.1.1 ¶7 of the ISO C11 standard,在多线程程序中使用函数signal会导致未定义的行为。

如果您的意思是使用函数cnd_signal 发出条件变量的信号,那么可以,条件变量(也使用互斥锁)可用于正确的线程同步。

如果您指的是特定于平台的功能,那么我无法对此发表评论,因为您没有在问题中指定任何特定平台。

【讨论】:

  • 这仅适用于“两者都不会先于另一个发生”的数据竞争条件。如果从另一个线程修改后读取清楚了值怎么办?
  • @xiver77 是什么让你认为它是“明显之后”?只有原子和互斥体才能做到这一点。
  • a non-atomic variable, unless the threads are synchronized. 绝对错误
  • @o11c 请看看我的编辑。
  • @0__:据我所知,您引用的我的句子是正确的。请您详细说明您认为该句子中的错误之处?
【解决方案2】:

对于那些不阅读和 DV 的人。这个答案不相关 IPC 只回答了第一个问题。对于简短的 SO 答案,IPC 过于广泛和复杂。我不写关于竞争条件、原子性或连贯性的文章。

编译器 (gcc) 只是假设 静态变量永远不会被其他线程触及,即使 最低优化级别。

标准中的 5.1.2.4.4 读取“如果其中一个修改了内存位置而另一个表达式评估为或 修改相同的内存位置。”

您提出了两个不同的问题。第一个是关于副作用的。第二个关于IPC机制。

我只会回答第一个,因为第二个太宽泛,无法在这里回答。

编译器假设对象(变量)只有在更改它们的代码位于正常程序执行路径中时才能更改。

如果不是,则假定这些对象不会被更改。

但是C有一个特殊的关键字volatile。它通知编译器 volatile 对象容易产生副作用——即它可以被正常程序执行路径之外的东西改变。编译器每次使用都会从对象存储位置读取,每次修改都会写入对象存储位置。

例子:

unsigned counter1;
volatile unsigned counter2;

int interruptHandler1(void)
{
    counter1++;
}

void foo(void)
{
    while(1)
        if(counter1 > 100) printf("Larger!!!!");
}

int interruptHandler2(void)
{
    counter2++;
}

void bar(void)
{
    while(1)
        if(counter2 > 100) printf("Larger!!!!");
}

输出代码:

interruptHandler1:
        add     DWORD PTR counter1[rip], 1
        ret
.LC0:
        .string "Larger!!!!"
foo:
        cmp     DWORD PTR counter1[rip], 100
        ja      .L12
.L11:
        jmp     .L11
.L12:
        push    rax
.L4:
        xor     eax, eax
        mov     edi, OFFSET FLAT:.LC0
        call    printf
        cmp     DWORD PTR counter1[rip], 100
        ja      .L4
.L8:
        jmp     .L8
interruptHandler2:
        mov     eax, DWORD PTR counter2[rip]
        add     eax, 1
        mov     DWORD PTR counter2[rip], eax
        ret
bar:
.L20:
        mov     eax, DWORD PTR counter2[rip]
        cmp     eax, 100
        jbe     .L20
        sub     rsp, 8
.L19:
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        call    printf
.L15:
        mov     eax, DWORD PTR counter2[rip]
        cmp     eax, 100
        jbe     .L15
        jmp     .L19
counter2:
        .zero   4
counter1:
        .zero   4

volatile 对象将在 any 从永久存储位置访问时被读取:

int foo1(void)
{
    return counter1 + counter1 + counter1 + counter1;
}

int bar1(void)
{
    return counter2 + counter2 + counter2 + counter2;
}
foo1:
        mov     eax, DWORD PTR counter1[rip]
        sal     eax, 2
        ret
bar1:
        mov     eax, DWORD PTR counter2[rip]
        mov     esi, DWORD PTR counter2[rip]
        mov     ecx, DWORD PTR counter2[rip]
        mov     edx, DWORD PTR counter2[rip]
        add     eax, esi
        add     eax, ecx
        add     eax, edx
        ret

并保存每次修改:

void foo2(void)
{
    counter1++;
    counter1++;
    counter1++;
    counter1++;
}

void bar2(void)
{
    counter2++;
    counter2++;
    counter2++;
    counter2++;
}
foo2:
        add     DWORD PTR counter1[rip], 4
        ret
bar2:
        mov     eax, DWORD PTR counter2[rip]
        add     eax, 1
        mov     DWORD PTR counter2[rip], eax
        mov     eax, DWORD PTR counter2[rip]
        add     eax, 1
        mov     DWORD PTR counter2[rip], eax
        mov     eax, DWORD PTR counter2[rip]
        add     eax, 1
        mov     DWORD PTR counter2[rip], eax
        mov     eax, DWORD PTR counter2[rip]
        add     eax, 1
        mov     DWORD PTR counter2[rip], eax
        ret

【讨论】:

  • 即使使用volatile,程序也存在数据竞争,因此行为未定义。
  • 在使用同步原语时替代 volatile:将变量的地址传递给 在不同的翻译单元中的无操作函数。使用现代工具链,不同的 .c 文件将不够好。我绝对确定的方法是编写三行汇编,但将它放在不同的共享库中也可以。 (由于链接时代码生成,仅链接到不同的静态库将不再有效,但可以通过编译器选项小心完成。`)
  • @EmployedRussian 请看看我的编辑。
  • @Joshua 它与 volatile 完全不同。传递地址表明可能涉及副作用。要重现 volatile 行为,您必须在每次访问之前和每次修改之后调用此函数。这没有太多意义。尝试在我的回答中复制示例 2 和 3 中的行为。
  • 不过你应该说清楚:volatile 只能用于信号处理程序或使用内存映射 I/O 的特殊硬件。
【解决方案3】:

这仅适用于“两者均未发生”时的数据竞争条件。如果在另一个线程修改后读取清楚了值怎么办?

“发生在之前”是一个有点棘手的概念。如果语言标准说“A 发生在 B 之前”,这并不意味着 A 总是保证实时地发生在 B 之前。只有当我们将其理解为transitive relationship时,其含义才会变得清晰:如果按照标准,A“发生在”B之前,B“发生在”C之前;那么我们可以推断出A“发生在”C之前。

但是,A 实际上会实时发生在 C 之前吗?

让我们想象两个线程。其中一个更新受互斥锁保护的共享变量:

void writer(...) {
    mytype_t new_value = create_new_value(...);

    pthread_mutex_lock(&mutex);
    global_var = new_value;
    pthread_mutex_unlock(&mutex);

另一个线程访问同一个变量:

void reader(...) {
    mytype_t local_copy;

    pthread_mutex_lock(&mutex);
    local_copy = global_var;
    pthread_mutex_unlock(&mutex);

    do_something_with(local_copy);

user17732522 在评论中提到的一个“发生之前”规则是,在任何单个线程中,所有事情都按程序顺序“发生”。也就是说,因为出现了global_var = new_value;writer(...) 函数的源代码中pthread_mutex_unlock(&mutex); 之前的分配必须“发生在”任何一次调用writer(...) 的解锁之前。

另一条规则说,在一个线程中解锁互斥锁“发生在”其他线程锁定同一个互斥锁之前。

根据这些规则,我们可以推断*IF*某个线程A调用writer(...)并在其他线程B进入reader(...)之前锁定了互斥锁,然后当线程B最终获得互斥锁时并读取global_var,它将读取线程A写入的值。

但这是一个很大的“*IF*!” 我在这个例子中展示的任何东西实际上都不能保证线程 A 实际上在线程 B 调用之前调用 writer() reader()。如果您想确保线程确实以任何特定的实时顺序调用这些函数,则必须添加一些更高级别的线程间通信。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-07-07
    • 2017-10-05
    • 1970-01-01
    • 1970-01-01
    • 2018-08-12
    • 2018-09-02
    相关资源
    最近更新 更多