C++ 缺少常量修饰符引发的 Bug

2020.07.02
SF-Zhou

时运不济,命途多舛,最近 Bug 有点多。大概是这样的:

#include <cstring>
#include <memory>
#include <vector>

class A {
 public:
  void Update(const std::shared_ptr<std::vector<std::string>> &value) {
    paths_ = value;
  }

  bool InPaths(const std::string &input) const {
    auto paths = paths_.load();
    for (auto &path : paths) {
      if (input.length() < path.length()) {
        continue;
      }
      int same = strncmp(&input[0], &path[0], path.length());
      if (same == 0 &&
          (input.length() == path.length() || input[path.length()] == '/')) {
        return true;
      }
    }
    return false;
  }

 private:
  std::atomic_shared_ptr<std::vector<std::string>> paths_;
}

这里会判断输入的 input 是不是在 paths_ 表示的路径下面。代码在多线程环境下执行,paths 为原子共享指针(参考 folly 实现),运行过程中会调用 Update 动态更新。在 gcc 4.8.2 C++11 环境下会 core dump。

在循环 for (auto &path : paths) 中加上常量修饰符 const 可以修复该 Bug。有兴趣可以再看看代码,想想是哪里的问题,下面将公布答案。

Bug 分析

path.length() 本身是 const 的方法,path 是否是 const 并不受影响,所以只剩下 &path[0] 比较可疑了。查看对应的 std::string 源码,会发现 operator[]operator[] const 多调用一个 _M_leak 方法,最后会执行 _M_mutate

template <typename _CharT, typename _Traits, typename _Alloc>
void basic_string<_CharT, _Traits, _Alloc>::_M_mutate(size_type __pos,
                                                      size_type __len1,
                                                      size_type __len2) {
  const size_type __old_size = this->size();
  const size_type __new_size = __old_size + __len2 - __len1;
  const size_type __how_much = __old_size - __pos - __len1;

  if (__new_size > this->capacity() || _M_rep()->_M_is_shared()) {
    // Must reallocate.
    const allocator_type __a = get_allocator();
    _Rep *__r = _Rep::_S_create(__new_size, this->capacity(), __a);

    if (__pos)
      _M_copy(__r->_M_refdata(), _M_data(), __pos);
    if (__how_much)
      _M_copy(__r->_M_refdata() + __pos + __len2, _M_data() + __pos + __len1,
              __how_much);

    _M_rep()->_M_dispose(__a);
    _M_data(__r->_M_refdata());
  } else if (__how_much && __len1 != __len2) {
    // Work in-place.
    _M_move(_M_data() + __pos + __len2, _M_data() + __pos + __len1, __how_much);
  }
  _M_rep()->_M_set_length_and_sharable(__new_size);
}

嗯会有修改操作。查询得知 gcc 4.8 的 std::string 是 copy-on-write 的,在修改时才会去执行复制,多线程环境下就挂了。按照 C++11 的规范已经不允许这种 COW 了,gcc 5 以上的版本就不会存在这样的问题,但 gcc 4.8 没有完全按照标准规范实现。

验证 COW 也很简单,点击在线执行

#include <cassert>
#include <iostream>
#include <string>

int main() {
  std::string a = "Hello World";
  std::string b = a;                    // does not do a deep copy
  *const_cast<char *>(b.data()) = 'W';  // modify string a in fact

  puts(a == b ? "copy on write" : "C++11 std::string");
  return 0;
}

复现 COW 导致的 core dump:

#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <string>
#include <thread>
#include <vector>

int main() {
  std::string a = "hello world";
  std::string b = a;
  std::atomic<bool> f{false};

  constexpr uint32_t N = 8;
  std::vector<std::thread> threads;
  for (uint32_t i = 0; i < N; ++i) {
    threads.emplace_back([&] {
      while (f.load() == false) {
      }

      auto p = &b[0];  // `b.data()` is ok
      printf("%p\n", (void *)p);
    });
  }

  std::this_thread::sleep_for(std::chrono::milliseconds(10));
  f = true;

  for (auto &thread : threads) {
    thread.join();
  }
}

除了增加 const,也可以将 &path[0] 替换为 path.data(),后者也是 const 方法。C++ 标准库中 const 方法通常是线程安全的,能加上 const 就都加上吧。

References

  1. "Legality of COW std::string implementation in C++11", Stack Overflow