C++多线程常见的数据竞争模式及示例分析

C++多线程常见的数据竞争模式及示例分析_第1张图片

一、简单竞争
最简单的数据竞争是最常见的一种:两个线程在没有任何同步的情况下访问一个内置类型的变量。很多时候,这种竞争是良性的(代码统计一些允许不精确的统计信息)。

int var;

void Thread1() {  // 在一个线程中运行。
  var++;
}
void Thread2() {  // 在另一个线程中运行。
  var++;
}

但有时这种竞争是极其有害的(例如,如果var是在计算你的钱的数量时)。

二、线程不友好的引用计数
这是对整数的一种竞争变体。非常有害。可能导致偶尔的内存泄漏或重复释放。

// Ref()和Unref()可能从多个线程被调用。
// 最后一次Unref()会销毁对象。
class RefCountedObject {
...
 public:
  void Ref() {
    ref_++;  // 错误!
  }
  void Unref() {
    if (--ref_ == 0)  // 错误!需要使用原子递减!
      delete this;
  }
 private:
  int ref_;
};

三、对复杂对象的竞争
另一种常见的竞争发生在两个线程在没有同步的情况下访问一个非线程安全的复杂对象(例如一个 STL 容器)时。这些几乎总是危险的。

std::map<int,int> m;

void Thread1() {
  m[123] = 1;
}

void Thread2() {
  m[345] = 0;
}

四、通知
考虑以下代码:

bool done = false;

void Thread1() {
  while (!done) {
    do_something_useful_in_a_loop_1();
  } 
  do_thread1_cleanup();
}

void Thread2() {
  do_something_useful_2();
  done = true;
  do_thread2_cleanup();
}

这两个线程之间的同步是通过一个布尔变量done完成的。这是一种错误的同步两个线程的方式。

在 x86 架构上,最大的问题是编译时的优化。

do_something_useful_2()的部分代码可能被编译器移动到“done = true”下面。
do_thread2_cleanup()的部分代码可能被编译器移动到“done = true”上面。
如果do_something_useful_in_a_loop_1()不修改“done”,编译器可能会将Thread1重写为以下方式:

  if (!done) {
    while(true) {
      do_something_useful_in_a_loop_1();
    } 
  }
  do_thread1_cleanup();

这样Thread1将永远不会退出。

在除 x86 以外的架构上,缓存效应或乱序指令执行可能会导致其他微妙的问题。

大多数竞争检测器会检测到这种竞争。
此外,大多数动态竞争检测器会报告在本应与此布尔值同步的内存访问上的数据竞争(即在do_something_useful_2()do_thread1_cleanup()之间)。

要修复这种竞争,你需要使用编译器和/或内存屏障(如果你不是专家——只需使用锁)。强烈建议将这种同步包装到一个单独的类中(即ExitNotification)。

五、不同步地发布对象
一个线程用一个新值初始化一个对象指针(最初为NULL),另一个线程循环直到该对象指针变为非NULL。如果没有适当的同步,编译器可能会对这样的代码进行非常令人惊讶的转换,这将导致(偶尔的)失败。除此之外,在某些架构上,这种竞争可能会由于缓存相关的效应而导致失败。

MyType* obj = NULL;

void Thread1() {
  obj = new MyType();
}

void Thread2() {
  while(obj == NULL)
    yield();
  obj->DoSomething();
}

六、不同步地初始化对象
这可能会导致例如内存泄漏(对象被构造了两次)。

static MyObj *obj = NULL;

void InitObj() { 
  if (!obj) 
    obj = new MyObj(); 
}

void Thread1() {
  InitObj();
}

void Thread2() {
  InitObj();
}

七、在写操作期间持有读锁
在持有读锁的情况下进行更新。

void Thread1() {
  mu.ReaderLock();
  var++;
  mu.ReaderUnlock();
}

void Thread2() {
  mu.ReaderLock();
  var++;
  mu.ReaderUnlock();
}

八、对位域的竞争
下面的代码乍一看似乎是正确的。但是如果xstruct { int a:4, b:4; },我们就有一个错误。

void Thread1() {
  x.a++;
}

void Thread2() {
  x.b++;
}

九、双重检查锁定
所谓的双重检查锁定是众所周知的反模式,但我们仍然偶尔会发现它。

bool inited = false;
void Init() {
  // 可能被多个线程调用。
  if (!inited) {
    mu.Lock();
    if (!inited) {
      //.. 初始化一些东西
    }
    inited = true;
    mu.Unlock();
  }
}

十、销毁期间的竞争
有时对象在栈上创建,传递给另一个线程,然后在不等待第二个线程完成其工作的情况下被销毁。

void Thread1() {
  SomeType object;
  ExecuteCallbackInThread2(
    SomeCallback, &object);
...
  // “object”在离开其作用域时被销毁。
}

十一、对虚函数表指针的数据竞争
一些背景知识:假设你有:

struct A {
  virtual ~A() {
    F();
  }
  virtual void F() {
    printf("In A");
  }
};
struct B : public A {
  virtual void F() {
    printf("In B");
  }
};

C++规定当一个B类型的对象被销毁时,你会看到“In A”被打印出来。更具体地说,在A::~A()期间,所有的虚函数都解析为在A(或其基类)中定义的方法,而不是B中的重写方法。为了实现这一点,gcc和其他编译器在B::~B()的开头将虚函数表指针(指向虚函数表的指针)重新赋值为指向B::vtable,然后在A::~A()的开头再次将其重新赋值为指向A::vtable

现在来看竞争:类A有一个函数Done()、一个虚函数F()和一个虚析构函数。析构函数等待由Done()生成的事件。还有一个类B,它继承自A并覆盖了A::F()

#include 
#include 

class A {
 public:
  A() : done_(false) {}
  virtual void F() { printf("A::F\n"); }
  void Done() {
    std::unique_lock<std::mutex> lk(m_);
    done_ = true;
    cv_.notify_one();
  }
  virtual ~A() {
    std::unique_lock<std::mutex> lk(m_);
    cv_.wait(lk, [this] {return done_;});
  }
 private:
  std::mutex m_;
  std::condition_variable cv_;
  bool done_;
};

class B : public A {
 public:
  virtual void F() { printf("B::F\n"); }
  virtual ~B() {}
};

int main() {
  A *a = new B;
  std::thread t1([a] {a->F(); a->Done();});
  std::thread t2([a] {delete a;});
  t1.join(); t2.join();
} 

创建了一个静态类型为A、动态类型为B的对象a。一个线程执行a->F(),然后向第二个线程发出信号。第二个线程调用delete a(即B::~B),然后调用A::~A,而A::~A反过来又等待来自第一个线程的信号。析构函数A::~A覆盖了虚函数表指针(指向虚函数表的指针)为A::vptr。因此,如果第一个线程在第二个线程开始执行A::~A之后执行a->F(),那么将调用A::F而不是B::F

考虑如果类在构造函数/析构函数中有虚方法,则不要在构造函数/析构函数中使用任何同步。而是使用Start()Join()方法。

如果不继承在构造函数/析构函数中进行同步的类,这种数据竞争是良性的。
ThreadSanitizer可以区分虚函数表指针上的良性和有害竞争。
此外,考虑密封你的类以避免未来出现问题。

十二、构造期间对虚函数表指针的数据竞争
这是虚函数表指针竞争的另一种变体。考虑以下代码:

class Base {
  Base() {
   global_mutex.Lock();
   global_list.push_back(this);
   global_mutex.Unlock();
   // 点(A),见下文
  }
  virtual void Execute() = 0;
...
};

class Derived : Base {
  Derived() {
    name_ =...;
  }
  virtual void Execute() {
    // 使用 name_;
  }
  string name_;
...
};

Mutex global_mutex;
vector<Base*> global_list;

// 由后台线程执行。
void ForEach() {
  global_mutex.Lock();
  for (size_t i = 0; i < global_list.size(); i++)
    global_list[i]->Execute()
  global_mutex.Unlock();
}

乍一看,似乎global_listglobal_mutex正确同步。但是添加到global_list中的对象是部分构造的,但却可供其他线程使用。例如,如果线程在点(A)被抢占,那么ForEach()将触发纯虚函数调用。

十三、对free的竞争
有时程序的一个线程可能会访问堆内存,而另一个线程正在释放相同的内存。

int *array;
void Thread1() {
  array[10]++;
}

void Thread2() {
  free(array);
}

AddressSanitizer也可以检测到这种错误。

十四、退出期间的竞争
如果一个程序在其他线程仍在运行时调用exit(),静态对象可能会被一个线程销毁,同时被另一个线程使用。(结论:除非你知道自己在做什么,否则不要使用静态非 POD 对象)

#include "pthread.h"
#include 

static std::map<int, int> my_map;

void *MyThread(void *) {  // 在一个单独的线程中运行。
  int i = 0;
  while(1) {
    my_map[(i++) % 1000]++;
  }
  return NULL;
}

int main() {
  pthread_t t;
  pthread_create(&t, 0, MyThread, 0);
  return 0;
  // 调用 exit(),my_map 被销毁
}

十五、对互斥锁的数据竞争
对互斥锁的加锁和解锁操作是同步的,但互斥锁仍然可能受到不安全的发布或竞争销毁的影响。考虑以下示例:

class Foo {
  Mutex m_;
...

 public:
  // 异步完成通知。
  void OnDone(Callback *c) {
    MutexLock l(&m_);
    // 处理完成
    //...
    // 调度用户完成回调
    ThreadPool::Schedule(c);
  }
...
};

void UserCompletionCallback(Foo *f) {
  delete f;  // 不再需要它了
  // 通知另一个线程完成
  //...
}

十六、对文件描述符的数据竞争
文件描述符在内部是完全同步的,但用户代码中的竞争可能导致在错误的文件描述符上发出读/写操作。考虑以下示例:

int fd = open(...);

// 线程 1。
write(fd,...);

// 线程 2。
close(fd);

如果线程 1 和线程 2 相互竞争,并且文件描述符编号在关闭后立即被重用,那么写入操作可能会针对错误的文件或套接字进行。这可能会导致例如将敏感数据泄露到不可信的网络连接中。

你可能感兴趣的:(c++,开发语言,多线程,数据竞争)