监控对象

监控对象模式会同步并发执行,以确保对象只执行一个方法。并且,还允许对象的方法协同调度执行序列。这种模式也称为线程安全的被动对象模式。

模式要求

多个线程同时访问一个共享对象时,需要满足以下要求:

  1. 并发访问时,需要保护共享对象不受非同步读写操作的影响,以避免数据争用。
  2. 必要的同步是实现的一部分,而不是接口的一部分。
  3. 当线程处理完共享对象时,需要发送一个通知,以便下一个线程可以使用共享对象。这种机制有助于避免死锁,并提高系统的整体性能。
  4. 方法执行后,共享对象的不变量必须保持不变。

客户端(线程)可以访问监控对象的同步方法。因为监控锁在任何时间点上,只能运行一个同步方法。每个监控对象都有一个通知等待客户端的监控条件。

组件

监控对象由四个组件组成。

监控对象 - 图1

  1. 监控对象:支持一个或多个方法。每个客户端必须通过这些方法访问对象,每个方法都必须在客户端线程中运行。
  2. 同步方法:监控对象支持同步方法。任何给定的时间点上,只能执行一个方法。线程安全接口有助于区分接口方法(同步方法)和(监控对象的)实现方法。
  3. 监控锁:每个监控对象有一个监控锁,锁可以确保在任何时间点上,只有一个客户端可以访问监控对象。
  4. 监控条件:允许线程在监控对象上进行调度。当前客户端完成同步方法的调用后,下一个等待的客户端将被唤醒。

虽然监控锁可以确保同步方法的独占访问,但是监控条件可以保证客户端的等待时间最少。实质上,监控锁可以避免数据竞争,条件监控可以避免死锁。

运行时行为

监控对象及其组件之间的交互具有不同的阶段。

  • 当客户端调用监控对象的同步方法时,必须锁定全局监控锁。如果客户端成功访问,将执行同步方法,并在结束时解锁。如果客户端访问不成功,则阻塞客户端,进入等待状态。
  • 当客户端阻塞时,监控对象会在解锁时,对阻塞的客户端发送通知。通常,等待是资源友好的休眠,而不是忙等。
  • 当客户端收到通知时,会锁定监控锁,并执行同步方法。同步方法结束时解锁,并发送监控条件的通知,以通知下一个客户端去执行。

优点和缺点

监控对象的优点和缺点是什么?

  • 优点:
    • 同步方法会完全封装在实现中,所以客户端不知道监控对象会隐式同步。
    • 同步方法将自动调度监控条件的通知/等待机制,其表现类似一个简单的调度器。
  • 缺点:
    • 功能和同步是强耦合的,所以很难改变同步机制。
    • 当同步方法直接或间接调用同一监控对象时,可能会发生死锁。

下面的程序段中定义了一个ThreadSafeQueue。

  1. // monitorObject.cpp
  2. #include <condition_variable>
  3. #include <functional>
  4. #include <queue>
  5. #include <iostream>
  6. #include <mutex>
  7. #include <random>
  8. #include <thread>
  9. template <typename T>
  10. class Monitor {
  11. public:
  12. void lock() const {
  13. monitMutex.lock();
  14. }
  15. void unlock() const {
  16. monitMutex.unlock();
  17. }
  18. void notify_one() const noexcept {
  19. monitCond.notify_one();
  20. }
  21. void wait() const {
  22. std::unique_lock<std::recursive_mutex> monitLock(monitMutex);
  23. monitCond.wait(monitLock);
  24. }
  25. private:
  26. mutable std::recursive_mutex monitMutex;
  27. mutable std::condition_variable_any monitCond;
  28. };
  29. template <typename T>
  30. class ThreadSafeQueue : public Monitor<ThreadSafeQueue<T>> {
  31. public:
  32. void add(T val) {
  33. derived.lock();
  34. myQueue.push(val);
  35. derived.unlock();
  36. derived.notify_one();
  37. }
  38. T get() {
  39. derived.lock();
  40. while (myQueue.empty()) derived.wait();
  41. auto val = myQueue.front();
  42. myQueue.pop();
  43. derived.unlock();
  44. return val;
  45. }
  46. private:
  47. std::queue<T> myQueue;
  48. ThreadSafeQueue<T>& derived = static_cast<ThreadSafeQueue<T>&>(*this);
  49. };
  50. class Dice {
  51. public:
  52. int operator()() { return rand(); }
  53. private:
  54. std::function<int()>rand = std::bind(std::uniform_int_distribution<>(1, 6),
  55. std::default_random_engine());
  56. };
  57. int main() {
  58. std::cout << std::endl;
  59. constexpr auto NUM = 100;
  60. ThreadSafeQueue<int> safeQueue;
  61. auto addLambda = [&safeQueue](int val) {safeQueue.add(val); };
  62. auto getLambda = [&safeQueue] {std::cout << safeQueue.get() << " "
  63. << std::this_thread::get_id() << ";";
  64. };
  65. std::vector<std::thread> addThreads(NUM);
  66. Dice dice;
  67. for (auto& thr : addThreads) thr = std::thread(addLambda, dice());
  68. std::vector<std::thread> getThreads(NUM);
  69. for (auto& thr : getThreads) thr = std::thread(getLambda);
  70. for (auto& thr : addThreads) thr.join();
  71. for (auto& thr : addThreads) thr.join();
  72. std::cout << "\n\n";
  73. }

该示例的核心思想是,将监控对象封装在一个类中,这样就可以重用。监控类使用std::recursive_mutex作为监控锁,std::condition_variable_any作为监控条件。与std::condition_variable不同,std::condition_variable_any能够接受递归互斥。这两个成员变量都声明为可变,因此可以在常量方法中使用。监控类提供了监控对象的最小支持接口。

第34 - 55行中的ThreadSafeQueue使用线程安全接口扩展了第53行中的std::queueThreadSafeQueue继承于监控类,并使用父类的方法来支持同步的方法addget。方法addget使用监控锁来保护监控对象,特别是非线程安全的myQueue。当一个新项添加到myQueue时,add会通知等待线程,并且这个通知是线程安全的。当如ThreadSafeQueue这样的模板类,将派生类作为基类的模板参数时,这属于C++的一种习惯性用法,称为CRTP:class ThreadSafeQueue: public Monitor<threadsafequeue<T>>。理解这个习惯的关键是第54行:ThreadSafeQueue<T>& derived = static_cast<threadsafequeue<T>&>(*this),该表达式将this指针向下转换为派生类。监控对象safeQueue第72行使用(第73行和第74行中的)Lambda函数添加一个数字,或从同步的safeQueue中删除一个数字。ThreadSafeQueue本身是一个模板类,可以保存任意类型的值。程序模拟的是100个客户端向safeQueue添加100个介于1 - 6之间的随机数(第78行)的同时,另外100个客户端从safeQueue中删除这100个数字。程序会显示使用的线程的编号和id。

监控对象 - 图2

奇异递归模板模式(CRTP)

奇异递归模板模式,简单地说,CRTP代表C++中的一种习惯用法,在这种用法中,Derived类派生自类模板Base,因此Base作为Derived模板参数。

  1. template<class T>
  2. class Base{
  3. ....
  4. };
  5. class Derived : public Base<Derived>{
  6. ....
  7. };

理解CRTP习惯用法的关键是,实例化方法是惰性的,只有在需要时才实例化方法。CRTP有两个主要的用例。

  • 静态多态性:静态多态性与动态多态性类似,但与使用虚方法的动态多态性相反,方法调用的分派在编译时进行。
  • Mixin: Mixin是设计混合代码类时的一个流行概念。ThreadSafeQueue使用Mixin技术来扩展它的接口。通过从Monitor类派生ThreadSafeQueue,派生类ThreadSafeQueue获得类Monitor的所有方法:ThreadSafeQueue: public Monitor<threadsafequeue<T>>类。

惰性C++:CRTP一文中,有对CRTP习语有更深入地描述。

活动对象和监控对象在几个重要的方面类似,但也有不同。这两种体系结构模式,会同步对共享对象的访问。活动对象的方法在不同线程中执行,而监控对象的方法则在同一线程中执行。活动对象更好地将其方法调用与执行解耦,因此更容易维护。

扩展阅读