编译器假定静态变量不会被另一个线程修改是否合法?

Is it legal for the compiler to assume that a static variable will not be modified by another thread?

我有点惊讶编译器 (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();

毕竟,有了一个好的信号处理程序,一切都运行良好。

注意:此答案指的是问题的revision 3。同时,题目也做了改动,所以这个答案不再直接对应题目。

根据 §5.1.2.4 ¶25 and ¶4 of the ISO C11 standard, two different threads accessing the same memory location using non-atomic operations in an unordered fashion causes undefined behavior,如果至少有一个线程正在写入该内存位置。

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

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

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

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

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

对于那些不阅读和 DV 的人。此答案与 IPC 无关,仅回答提出的第一个问题。 IPC 对于简短的 SO 回答来说过于宽泛和复杂。我不写竞争条件、原子性或一致性。

I'm a bit surprised that the compiler (gcc) is simply assuming that a static variable will never be touched by other threads even with the lowest optimization level.

5.1.2.4.4 in the standard reads "Two expression evaluations conflict if one of them modifies a memory location and the other one reads or modifies the same memory location."

你问了两个截然不同的问题。第一个是关于副作用。第二个关于IPC机制。

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

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

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

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

示例:

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 对象将在 任何 访问永久存储位置读取:

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

That only applies to data race conditions when "neither happens before the other". What if the value is read clearly after a modification from another thread?

“发生在之前”是一个有点棘手的概念。如果语言标准说“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);

用户 17732522 在评论中提到的一个“发生在之前”的规则是,在任何单个线程中,一切都“发生”在 程序顺序中。 也就是说,因为 global_var = new_value;writer(...) 函数的源代码中出现在 pthread_mutex_unlock(&mutex); 之前,所以赋值必须在对 writer(...).[=23= 的任何一次调用中“发生在”解锁之前]

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

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

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