为什么在这个反汇编的 std::string dtor 中有一个锁定的 xadd 指令?

Why is there a locked xadd instruction in this disassambled std::string dtor?

我有一个非常简单的代码:

#include <string>
#include <iostream>

int main() {
    std::string s("abc");
    std::cout << s;
}

然后,我编译了它:

g++ -Wall test_string.cpp -o test_string -std=c++17 -O3 -g3 -ggdb3

然后反编译出来,最有意思的一段是:

00000000004009a0 <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10>:
  4009a0:       48 81 ff a0 11 60 00    cmp    rdi,0x6011a0
  4009a7:       75 01                   jne    4009aa <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0xa>
  4009a9:       c3                      ret    
  4009aa:       b8 00 00 00 00          mov    eax,0x0
  4009af:       48 85 c0                test   rax,rax
  4009b2:       74 11                   je     4009c5 <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0x25>
  4009b4:       83 c8 ff                or     eax,0xffffffff
  4009b7:       f0 0f c1 47 10          lock xadd DWORD PTR [rdi+0x10],eax
  4009bc:       85 c0                   test   eax,eax
  4009be:       7f e9                   jg     4009a9 <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0x9>
  4009c0:       e9 cb fd ff ff          jmp    400790 <_ZdlPv@plt>
  4009c5:       8b 47 10                mov    eax,DWORD PTR [rdi+0x10]
  4009c8:       8d 50 ff                lea    edx,[rax-0x1]
  4009cb:       89 57 10                mov    DWORD PTR [rdi+0x10],edx
  4009ce:       eb ec                   jmp    4009bc <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0x1c>

为什么_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10(即std::basic_string<char, std::char_traits<char>, std::allocator<char> >::_Rep::_M_dispose(std::allocator<char> const&) [clone .isra.10])是一个以xadd为前缀的锁?

后续问题是如何避免它?

它看起来像与 copy on write 字符串关联的代码。锁定指令递减引用计数,然后仅当包含实际字符串数据的可能共享缓冲区的引用计数为零时才调用 operator delete(即,它不是共享的:没有其他字符串对象引用它)。

由于libstdc++是开源的,我们可以通过查看源码来确认这一点!

您反汇编的函数,_ZNSs4_Rep10_M_disposeERKSaIcE 反汇编1std::basic_string<char>::_Rep::_M_dispose(std::allocator<char> const&)。这是 gcc-4.x 时代的 libstdc++ 的 corresponding source2:

    void
    _M_dispose(const _Alloc& __a)
    {
#if _GLIBCXX_FULLY_DYNAMIC_STRING == 0
      if (__builtin_expect(this != &_S_empty_rep(), false))
#endif
        {
          // Be race-detector-friendly.  For more info see bits/c++config.
          _GLIBCXX_SYNCHRONIZATION_HAPPENS_BEFORE(&this->_M_refcount);
          if (__gnu_cxx::__exchange_and_add_dispatch(&this->_M_refcount,
                             -1) <= 0)
        {
          _GLIBCXX_SYNCHRONIZATION_HAPPENS_AFTER(&this->_M_refcount);
          _M_destroy(__a);
        }
        }
    }  // XXX MT

鉴于此,我们可以注释您提供的程序集,将每条指令映射回 C++ 源代码:

00000000004009a0 <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10>:

  # the next two lines implement the check:
  # if (__builtin_expect(this != &_S_empty_rep(), false))
  # which is an empty string optimization. The S_empty_rep singleton
  # is at address 0x6011a0 and if the current buffer points to that
  # we are done (execute the ret)
  4009a0: cmp    rdi,0x6011a0
  4009a7: jne    4009aa <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0xa>
  4009a9: ret

  # now we are in the implementation of
  # __gnu_cxx::__exchange_and_add_dispatch(&this->_M_refcount, -1)
  # which dispatches either to an atomic version of the add function
  # or the non-atomic version, depending on the value of `eax` which
  # is always directly set to zero, so the non-atomic version is 
  # *always called* (see details below)
  4009aa: mov    eax,0x0
  4009af: test   rax,rax
  4009b2: je     4009c5 <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0x25>

  # this is the atomic version of the decrement you were concerned about
  # but we never execute this code because the test above always jumps
  # to 4009c5 (the non-atomic version)
  4009b4: or     eax,0xffffffff
  4009b7: lock xadd DWORD PTR [rdi+0x10],eax
  4009bc: test   eax,eax
  # check if the result of the xadd was zero, if not skip the delete
  4009be: jg     4009a9 <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0x9>
  # the delete call
  4009c0: jmp    400790 <_ZdlPv@plt> # tailcall

  # the non-atomic version starts here, this is the code that is 
  # always executed
  4009c5: mov    eax,DWORD PTR [rdi+0x10]
  4009c8: lea    edx,[rax-0x1]
  4009cb: mov    DWORD PTR [rdi+0x10],edx
  # this jumps up to the test eax,eax check which calls operator delete
  # if the refcount was zero
  4009ce: jmp    4009bc <_ZNSs4_Rep10_M_disposeERKSaIcE.isra.10+0x1c>

要注意的是,您所关注的 lock xadd 代码 从未执行过 。有一个 mov eax, 0 后跟一个 test rax, rax; je - 这个测试总是成功并且跳转总是发生,因为 rax 总是零。

这里发生的事情是 __gnu_cxx::__atomic_add_dispatch 以检查进程是否 绝对 单线程的方式实现。如果它绝对是单线程的,它不会为 __atomic_add_dispatch 之类的东西使用昂贵的原子指令 - 它只是使用常规的非原子加法。它通过检查 pthreads 函数的地址来做到这一点,__pthread_key_create - 如果这是零,则 pthread 库还没有被 link 编辑,因此该进程肯定是单线程的.在您的情况下,此 pthread 函数的地址在 link 时被解析为 0(您的编译命令行上没有 -lpthread),这就是 mov eax, 0x0 来自。在 link 时间,优化此知识为时已晚,因此残留的原子增量代码仍然存在但永远不会执行。此机制在 .

中有更详细的描述

执行的代码是函数的最后一部分,从 4009c5 开始。此代码 减少引用计数,但以非原子方式。在这两个选项之间做出决定的检查可能基于进程是否是多线程的,例如,-lpthread 是否已被 linked。无论出于何种原因,此检查在 __exchange_and_add_dispatch 内以一种方式实现,以防止编译器实际删除分支的原子部分,即使在构建过程中的某个时刻知道它永远不会被采用的事实过程(毕竟,硬编码 mov eax, 0 以某种方式到达那里)。

A follow-up question is how I can avoid it?

好吧,您已经避开了 lock add 部分,所以如果那是您所关心的,那您就可以开始了。不过,你还是有顾虑的:

写时复制 std::string 实现 are not standards compliant due to changes made in C++11,所以问题仍然存在,为什么即使在指定 -std=c++17.

时,您也会得到这种 COW 字符串行为

问题很可能与发行版有关:CentOS 7 默认使用 < 5 的古老 gcc 版本,它仍然使用不兼容的 COW 字符串。但是,您提到您正在使用 gcc 8.2.1,默认情况下在使用非 COW 字符串的正常安装中。似乎如果你使用 RHEL "devtools" 方法安装 8.2.1,你将得到一个新的 gcc,它仍然使用旧的 ABI 和 links 来对抗旧系统 libstdc++。

要确认这一点,您可能需要 check the value of _GLIBCXX_USE_CXX11_ABI macro in your test program, and also your libstdc++ version (the version information here 可能有用)。

您可以通过使用 OS 而不是 CentOS 来避免,它不使用古老的 gcc 和 glibc 版本。如果出于某种原因需要坚持使用 CentOS,则必须研究是否有支持的方法在该发行版上使用较新的 libstdc++ 版本。您还可以考虑使用容器化技术来构建独立于本地主机库版本的可执行文件。


1 你可以这样分解它:echo '_ZNSs4_Rep10_M_disposeERKSaIcE' | c++filt.

2 我正在使用 gcc-4 时代的源代码,因为我猜这就是你最终在 CentOS 7.

中使用的源代码