代码之家  ›  专栏  ›  技术社区  ›  Aman Deep Gautam

当临时不使用但稍后更新时,编译器是否可以优化成员访问

  •  0
  • Aman Deep Gautam  · 技术社区  · 2 年前

    我知道编译器优化可以暴露已经存在的错误(假设编译器优化不会引入错误)。我还发现了一个类似的问题: Can compiler optimization introduce bugs? 并阅读建议的文章: Dangerous Optimizations 这指出了编译器优化的陷阱。

    我担心第二次检查可能会在这段代码中被优化掉;这可能吗?

    class Lock {
      void Acquire(bool is_exclusive);
      // Upgrades shared lock to an exclusive lock, if possible. 
      bool TryUpgrade();
      void Release();
    }
    
    class Foo {
      bool condition_boolean = true; 
      Lock lock;
    
      void f1() {
        lock.Acquire(false);
        if (condition_boolean) {
          if (!lock.TryUpgrade()) {
            lock.Release();
            lock.Acquire(true);
          }
    
          // Recheck the condition as we may have released the lock to 
          // acquire it exclusively.
          if (condition_boolean) {  // Can be optimized away.
            // Do something;
            condition_boolean = false;
          }
        }
    
        lock.Release();
      }
    };
    
    0 回复  |  直到 2 年前
        1
  •  3
  •   Nate Eldredge    2 年前

    假设 Lock 类的编写是正确的,使得它的获取/释放函数包含相应类型的内存屏障,那么不,第二次检查不能被优化掉。

    此类优化包含在C++标准“仿佛规则”[C++20 N4860 intro.abstract p1]中。抽象地说,代码必须完全按照编写的方式执行。但是,如果编译器能够证明,假设程序没有未定义的行为,则转换不会改变程序的可观察行为,那么它就可以应用转换。(或者,如果程序有不止一种有效的执行方式,那么至少它必须确保它仍然以其中一种有效方式运行。)

    在多线程程序的情况下,“假设程序没有未定义的行为”经常发挥作用,应用于数据竞赛。一个典型的例子是:

    bool b;
    
    void foo() {
        int x=0, y=0;
        if (b)
            x=1;
        if (b)
            y=1;
        assert(x == y);
    }
    

    在这里,编译器可以假设 b 不能在两个测试之间更改,并优化第二个测试,从而将代码变成 if (b) { x=1; y=1; } .

    你可能会说,“但难道其他线程不可能修改吗 b 介于两者之间?“啊,好吧,如果是这样的话,你会有一个数据竞赛。数据竞赛规则说,对于对同一个非原子变量的任何两次访问,其中至少有一次是写入,程序必须进行同步,以确保其中一次 以前发生过 另一个[内含子区域p21]。同步通常由互斥体提供,或者由读取早期版本存储所写值的原子变量的获取负载提供。

    在上面的程序中,编译器看不到这样的同步是可能的,因为在的两次读取之间 b ,该代码不包含互斥操作,也不包含任何获取或释放操作。因此,的任何并发写入 b 不可避免地 是一个数据竞赛,这将是UB,所以编译器可以假设它不会发生。(如果违反了编译器的假设,那么可能会发生一些不好的事情——但这不是编译器的错,而是调用UB的错。)


    然而,在您发布的代码中 同步发生的一种方式。当你暂时释放锁时,其他线程可能会占用它,修改 condition_boolean ,然后松开锁。这将是免费的数据竞赛。假设和以前一样 类的正确实现,锁的释放与其他线程的获取同步,这意味着您的第一次读取 condition_boolean 发生在另一个线程写入之前。同样,其他线程的释放与您的重新获取同步,因此其他线程的写入发生在您的第二次读取之前。

    因此,上的所有操作 condition_boolean 完全按照之前发生的事情排序,因此不会发生数据竞争。此外,由于写入 condition_boolean 发生在二读之前,二读 必须 观察新值[interro.races p13]。为了提供这种行为,编译器必须实际执行第二次读取;它无法优化它。


    再一次,所有这些都假设 这堂课写得很正确。如果它只是一个包装 std::shared_mutex 或者类似的东西,那么一切都应该是好的,因为标准定义了 std::mutex 朋友提供适当的获取和发布语义。如果是你自己写的东西 std::atomic s、 那么你就有责任确保你使用了正确的算法和适当的 std::memory_order 以提供解锁和锁定例程之间的同步。如果是你写的 没有 使用 std::原子 ,那么几乎可以肯定的是,这是错误的,它本身就导致了数据竞赛,而且无论你用什么方法,你的程序都会被破坏 condition_boolean 。(不, volatile 不能代替 std::原子 .)


    话虽如此,如果可以的话,编译器可以自由地打破这些规则中的任何一个 证明 它对其他UB免费程序没有任何区别。作为一个简单的例子,可以通过编译器选项指定程序将单线程运行。在这种情况下,编译器可以将所有原子对象降级为普通对象,并删除所有互斥操作和内存屏障。然后它也可以删除的第二个检查 condition_boolean 。但当然,在这种情况下,这样做是完全正确的,因为 condition_boolean 事实上 不能 改变