C++ 多线程之互斥量(mutex)详解

  C++ 11中的互斥量,声明在 头文件中,互斥量的使用可以在各种方面,比较常用在对共享数据的读写上,如果有多个线程同时读写一个数据,那么想要保证多线程安全,就必须对共享变量的读写进行保护(上锁),从而保证线程安全。

  互斥量主要有四中类型:

  当然C++14和C++17各增加了一个:

  std::mutex

  构造函数

  mutex();

  mutex(const mutex&) = delete;

  从上面的构造函数可以看出,std::mutex不允许拷贝构造,当然也不允许move,最初构造的mutex对象是处于未锁定状态的,若构造不成功会抛出 。

  析构函数

  ~mutex();

  销毁互斥。若互斥被线程占有,或在占有mutex时线程被终止,则会产生未定义行为。

  lock

  void lock();

  锁定互斥,调用线程将锁住该互斥量。线程调用该函数会发生下面 3 种情况:

  try_lock

  bool try_lock();

  尝试锁住互斥量,立即返回。成功获得锁时返回 true ,否则返回 false。

  如果互斥量被其他线程占有,则当前线程也不会被阻塞。线程调用该函数也会出现下面 3 种情况:

  unlock

  void unlock();

  解锁互斥。互斥量必须为当前执行线程所锁定(以及调用lock),否则行为未定义。

  看下面一个简单的例子实现两个线程竞争全局变量g_num对其进行写操作,然后打印输出:

  #include

  #include // std::chrono

  #include // std::thread

  #include // std::mutex

  int g_num = 0; // 为 g_num_mutex 所保护

  std::mutex g_num_mutex;

  void slow_increment(int id)

  {

  for (int i = 0; i < 3; ++i) {

  g_num_mutex.lock();

  ++g_num;

  std::cout << "th" << id << " => " << g_num << '

  ';

  g_num_mutex.unlock();

  std::this_thread::sleep_for(std::chrono::seconds(1));

  }

  }

  int main()

  {

  std::thread t1(slow_increment, 0);

  std::thread t2(slow_increment, 1);

  t1.join();

  t2.join();

  }

  加了互斥量实现有序的写操作并输出:

  th0 => 1

  th1 => 2

  th0 => 3

  th1 => 4

  th1 => 5

  th0 => 6

  如果不增加mutex包含,可能输出就不是有序的打印1到6,如下:

  std::recursive_mutex

  如上面所说的,如果使用std::mutex,如果一个线程在执行中需要再次获得锁,会出现死锁现象。要避免这种情况下就需要使用递归式互斥量,它不会产生上述的死锁问题,可以理解为同一个线程多次获得锁“仅仅增加锁的计数”,同时,必须要确保unlock和lock的次数相同,其他线程才可能取得这个mutex。它的接口与std::mutex的完全一样,用法也基本相同除了可重入(必须同一线程才可重入,其他线程需等待),看下面的例子:

  #include

  #include

  #include

  class X {

  std::recursive_mutex m;

  std::string shared;

  public:

  void fun1() {

  m.lock();

  shared = "fun1";

  std::cout << "in fun1, shared variable is now " << shared << '

  ';

  m.unlock();

  }

  void fun2() {

  m.lock();

  shared = "fun2";

  std::cout << "in fun2, shared variable is now " << shared << '

  ';

  fun3(); // 递归锁在此处变得有用

  std::cout << "back in fun2, shared variable is " << shared << '

  ';

  m.unlock();

  }

  void fun3() {

  m.lock();

  shared = "fun3";

  std::cout << "in fun3, shared variable is now " << shared << '

  ';

  m.unlock();

  }

  };

  int main()

  {

  X x;

  std::thread t1(&X::fun1, &x);

  std::thread t2(&X::fun2, &x);

  t1.join();

  t2.join();

  }

  在fun2中调用fun3,而fun3中还使用了lock和unlock,只有递归式互斥量才能满足当前情况。

  输出如下:

  in fun1, shared variable is now fun1

  in fun2, shared variable is now fun2

  in fun3, shared variable is now fun3

  back in fun2, shared variable is fun3

  std::time_mutex

  timed_mutex增加了带时限的try_lock。即和。

  try_lock_for尝试锁互斥。阻塞直到超过指定的 或得到锁,取决于何者先到来。成功获得锁时返回 true,否则返回false 。函数原型如下:

  template< class Rep, class Period >

  bool try_lock_for( const std::chrono::duration& timeout_duration );

  若小于或等于,则函数表现同。由于调度或资源争议延迟,此函数可能阻塞长于。

  #include

  #include

  #include

  #include

  #include

  #include

  std::timed_mutex mutex;

  using namespace std::chrono_literals;

  void do_work(int id) {

  std::ostringstream stream;

  for (int i = 0; i < 3; ++i) {

  if (mutex.try_lock_for(100ms)) {

  stream << "success ";

  std::this_thread::sleep_for(100ms);

  mutex.unlock();

  } else {

  stream << "failed ";

  }

  std::this_thread::sleep_for(100ms);

  }

  std::cout << "[" << id << "] " << stream.str() << std::endl;

  }

  int main() {

  // try_lock_for

  std::vector threads;

  for (int i = 0; i < 4; ++i) {

  threads.emplace_back(do_work, i);

  }

  for (auto& t : threads) {

  t.join();

  }

  }

  [3] failed success failed

  [0] success failed success

  [2] failed failed failed

  [1] success success success

  try_lock_until也是尝试锁互斥。阻塞直至抵达指定的或得到锁,取决于何者先到来。成功获得锁时返回 true,否则返回false。

  timeout_time与上面的timeout_duration不一样,timeout_duration表示一段时间,比如1秒,5秒或者10分钟,而timeout_time表示一个时间点,比如说要等到8点30分或10点24分才超时。

  使用倾向于的时钟,这表示时钟调节有影响。从而阻塞的最大时长可能小于但不会大于在调用时的 timeout_time - Clock::now() ,依赖于调整的方向。由于调度或资源争议延迟,函数亦可能阻塞长于抵达之后。同,允许此函数虚假地失败并返回false,即使在 前的某点任何线程都不锁定互斥。函数原型如下:

  template< class Clock, class Duration >

  bool try_lock_until( const std::chrono::time_point& timeout_time);

  看下面的例子:

  #include

  #include

  #include

  #include

  #include

  #include

  std::timed_mutex mutex;

  using namespace std::chrono;

  void do_work() {

  mutex.lock();

  std::cout << "thread 1, sleeping..." << std::endl;

  std::this_thread::sleep_for(std::chrono::seconds(4));

  mutex.unlock();

  }

  void do_work2() {

  auto now = std::chrono::steady_clock::now();

  if (mutex.try_lock_until(now + 5s)) {

  auto end = steady_clock::now();

  std::cout << "try_lock_until success, ";

  std::cout << "time use: " << duration_cast(end-now).count()

  << "ms." << std::endl;

  mutex.unlock();

  } else {

  auto end = steady_clock::now();

  std::cout << "try_lock_until failed, ";

  std::cout << "time use: " << duration_cast(end-now).count()

  << "ms." << std::endl;

  }

  }

  int main() {

  // try_lock_until

  std::thread t1(do_work);

  std::thread t2(do_work2);

  t1.join();

  t2.join();

  }

  获得锁时输出:

  thread 1, sleeping...

  try_lock_until success, time use: 4000ms.

  修改一下,让其超时,输出:

  thread 1, sleeping...

  try_lock_until failed, time use: 5000ms.

  std::recursive_timed_mutex

  以类似std::recursive_mutex的方式,提供排他性递归锁,同线程可以重复获得锁。另外,通过与方法,提供带时限地获得锁,类似。

  std::shared_mutex

  c++ 17 新出的具有独占模式和共享模式的锁。共享模式能够被(这个后面再详细将)占有。

  std::shared_mutex 是读写锁,把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。

  它提供两种访问权限的控制:共享性(shared)和排他性(exclusive)。通过获取排他性访问权限(仅有一个线程能占有互斥),通过获取共享性访问权限(多个线程能共享同一互斥的所有权)。这样的设置对于区分不同线程的读写操作特别有用。

  通常用于多个读线程能同时访问同一资源而不导致数据竞争,但只有一个写线程能访问的情形。比如,有多个线程调用,多个线程都可以获得锁,可以同时读共享数据,如果此时有一个写线程调用 ,则读线程均会等待该写线程调用。对于C++11 没有提供读写锁,可使用 。

  新增加的三个接口:

  void lock_shared();

  bool try_lock_shared();

  void unlock_shared();

  一个简单例子如下:

  #include

  #include // 对于 std::unique_lock

  #include

  #include

  class ThreadSafeCounter {

  public:

  ThreadSafeCounter() = default;

  // 多个线程/读者能同时读计数器的值。

  unsigned int get() const {

  std::shared_lock lock(mutex_);

  return value_;

  }

  // 只有一个线程/写者能增加/写线程的值。

  void increment() {

  std::unique_lock lock(mutex_);

  value_++;

  }

  // 只有一个线程/写者能重置/写线程的值。

  void reset() {

  std::unique_lock lock(mutex_);

  value_ = 0;

  }

  private:

  mutable std::shared_mutex mutex_;

  unsigned int value_ = 0;

  };

  int main() {

  ThreadSafeCounter counter;

  auto increment_and_print = [&counter]() {

  for (int i = 0; i < 3; i++) {

  counter.increment();

  std::cout << std::this_thread::get_id() << ' ' << counter.get() << '

  ';

  // 注意:写入 std::cout 实际上也要由另一互斥同步。省略它以保持示例简洁。

  }

  };

  std::thread thread1(increment_and_print);

  std::thread thread2(increment_and_print);

  thread1.join();

  thread2.join();

  }

  // 解释:下列输出在单核机器上生成。 thread1 开始时,它首次进入循环并调用 increment() ,

  // 随后调用 get() 。然而,在它能打印返回值到 std::cout 前,调度器将 thread1 置于休眠

  // 并唤醒 thread2 ,它显然有足够时间一次运行全部三个循环迭代。再回到 thread1 ,它仍在首个

  // 循环迭代中,它最终打印其局部的计数器副本的值,即 1 到 std::cout ,再运行剩下二个循环。

  // 多核机器上,没有线程被置于休眠,且输出更可能为递增顺序。

  可能的输出:

  139847802500864 1

  139847802500864 2

  139847802500864 3

  139847794108160 4

  139847794108160 5

  139847794108160 6

  std::shared_timed_mutex

  它是从C++14 才提供的限时读写锁:。

  对比新增下面两个接口,其实这两个接口与上面讲到的的和类似。都是限时等待锁。只不过是增加了共享属性。

  template< class Rep, class Period >

  bool try_lock_shared_for( const std::chrono::duration& timeout_duration );

  template< class Clock, class Duration >

  bool try_lock_shared_until( const std::chrono::time_point& timeout_time );

  总结

  由于它们额外的复杂性,读/写锁 , 优于普通锁,的情况比较少见。但是理论上确实存在。

  如果在频繁但短暂的读取操作场景,读/写互斥不会提高性能。它更适合于读取操作频繁且耗时的场景。当读操作只是在内存数据结构中查找时,很可能简单的锁会胜过读/写锁。

  如果读取操作的开销非常大,并且您可以并行处理许多操作,那么在某些时候增加读写比率应该会导致读取/写入器性能优于排他锁的情况。断点在哪里取决于实际工作量。

  另请注意,在持有锁的同时执行耗时的操作通常是一个坏兆头。可能有更好的方法来解决问题,然后使用读/写锁。

  还要注意,在使用mutex时,要时刻注意lock()与unlock()的加锁临界区的范围,不能太大也不能太小,太大了会导致程序运行效率低下,大小了则不能满足我们对程序的控制。并且我们在加锁之后要及时解锁,否则会造成死锁,lock()与unlock()应该是成对出现。

  本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注脚本之家的更多内容!

  您可能感兴趣的文章: