线程间的数据共享

当在线程间存在数据共享,分为两种情况:

  • 如果共享数据是只读的,一般不会出现什么问题,所有的线程都将获得同样的数据
  • 如果共享数据可写,那么如果某个或多个线程要对该共享数据进行修改,那么通常会出现数据不一致的问题。

这就是在写多线程程序时经常发生的条件竞争现象,简直阔怕。


互斥量的概念

为了保护线程间的共享数据,最简单的办法就是使用互斥量来避免条件竞争。

这样,被互斥量保护起来的部分就称为临界区,只能允许一个线程访问,其它线程试图访问被互斥量保护起来的共享数据时,就要进行等待。

  • 具体地,访问共享数据时加锁,当共享数据访问结束时解锁。这样其它线程就能够去抢占,保证了共享数据对所有线程都是可见的。

互斥量应用

考虑下面的简单例子:
2.1 线程间数据共享与互斥量
这里,我们在主线程和子线程中分别打印。可以看到,输出结果是无序的。这是因为 cout 是全局的对象,主线程和子线程会发生竞争。

所以,我们使用互斥对象来同步共享资源的访问,作出下面的改进:
2.1 线程间数据共享与互斥量
首先,使用互斥量我们要包含头文件 #include<mutex>,定义一个全局的互斥量

  • std::mutex mtx;

然后,我们使用 lock() 和 unlock() 将全局对象 cout 保护起来,当一个线程A访问 cout 时加锁,此时若有另一个线程B再想访问 cout,那么就要等待。当共享数据访问结束时,线程A调用 unlock() 解锁,避免资源占用。此时线程A 和线程B再去公平竞争。

当然,上面的例子虽然简单,但存在一定的缺点。就是我们在每个共享对象访问时,都要去调用,如果你忘记了,很可能会出现死锁的情况。

这只是一方面,还有另外一种情况,就是我们在 cout 以后可能还有一部分代码,这部分代码如果抛出了异常,那么 unlock 将不会被执行,资源将永久被占用
2.1 线程间数据共享与互斥量

出于上面提到的原因,C++标准库为互斥量提供了一个 RAII 的模板类 std::lock_guard

std::lock_guard

std::lock_guard 类的对象使用互斥量来进行初始化,并加锁。在其结束时会调用析构函数,在析构函数中解锁。这样,就避免了我们频繁地去 lock 和 unlock。

因此,上面的例子,我们可以改为:
2.1 线程间数据共享与互斥量
在上面的代码中,我们用全局的互斥量对象 mtx 去初始化了 std::lock_guard<std::mutex> 模板类的对象。于是使得两个线程访问 cout 是互斥的,不会出现两个线程同时访问 cout 的情况,实现了有序输出。

这样,一是避免了频繁去 lock 和 unlock,二是即使在抛出异常时也能够自动解锁。解决了前面提到的问题。

还有就是,考虑下面的例子:
2.1 线程间数据共享与互斥量
在这个例子中,doWork() 函数中的代码,也将被保护起来,也就是说,我们上锁的粒度太大了,本该结束保护的时候却没有解锁。这该怎么办呢,方法就是添加作用域即可
2.1 线程间数据共享与互斥量

互斥量的设计准则

上面的例子中,我们使用的是全局对象cout,基于这一点,只要其它线程没有使用 sharedPrint,那么就还是可以在不加锁的情况下访问 cout 的。
2.1 线程间数据共享与互斥量
根据面向对象的原则,我们通常会将互斥量和需要保护的数据放在一个类中,把它们联系在一起
2.1 线程间数据共享与互斥量
这样,就能够在全局范围内保护我们的 cout。

值得注意的是,互斥量和它所保护的数据设置为private的访问权限。我们不能在public作用域下对外提供返回所保护数据的指针或引用,这是可以访问并修改被保护的数据的,这样就很有可能会破坏保护的效果。就像下面这样,被保护的数据 m_o 都可能被外部或者是 fun 函数任意修改。
2.1 线程间数据共享与互斥量
所以,对待 Guard 类的接口,我们要谨慎设计,避免破坏了 mutex 对共享数据的保护效果。


互斥量的缺点

互斥量也并不都是好的,例如:

  • 代码的顺序要确保编排正确
  • 容易造成死锁
  • 加锁解锁占用资源

相关文章: