std::get<C++11多线程库~线程间共享数据>(10):使用互斥量保护共享数据(3)
1 #ifndef LOCATE_INTERFACES_RACE_CONDITION 2 #define LOCATE_INTERFACES_RACE_CONDITION 3 4 /* 5 * 话题1:使用互斥量保护共享数据 6 * 7 * 接下来学习第三个小话题:定位接口间的条件竞争 8 * 9 * 使用互斥量或者别的机制保护了共享数据,就不必再为条件竞争所担忧了吗? 10 * 答案是:并非如此! 11 * 12 * 下面来看一个例子, 对 std::stack 栈的使用。 13 */ 14 15 #include <deque> 16 #include <stack> 17 18 std::stack<int> s; 19 void func(std::stack<int> &s){ 20 if(! s.empty()){ //步骤1 21 int const value = s.top(); //步骤2 22 s.pop(); //步骤3 23 } 24 } 25 26 /* 27 * 观察上面的函数 func(), 如果 func() 作为两个线程的入口函数,分别为 线程A 和 线程B。 28 * 假如某一时刻,s 中仅剩余1个元素,此时,线程A执行“步骤1”, 线程B执行“步骤3”,线程A执行“步骤2”。 29 * 线程A判断了有元素 ,结果在取元素之前,却被其他线程 pop()掉了,而线程A并不知道,继续 top() 取栈顶元素。 30 * 31 * 问题出现了,接口间发生了条件竞争。 32 * 33 * !这是一个经典的条件竞争,使用互斥量对栈内部数据进行保护,但依旧不能阻止条件竞争的发生,这就是接口固有的问题。 34 * 35 * !这个问题不仅仅出现在基于互斥量实现的接口中, 在无锁实现的接口中, 条件竞争依旧会发生。这是接口的问题,与保护共享数据的实现方式无关。 36 * 37 * 38 * 怎么解决呢?问题发生在接口设计上,所以解决的方法也就是改变接口设计。 39 */ 40 41 /* 42 * 这里引用一段讲述,不感兴趣,可以不用阅读 43 * 这就需要接口设计上有较大的改动,提议之一就是使用同一互斥量来保护top()和pop()。 44 * Tom Cargill[1]指出当一个对象的拷贝构造函数在栈中抛出一个异常,这样的处理方式就会有问题。 45 * 在Herb Sutter[2]看来,这个问题可以从“异常安全”的角度完美解决,不过潜在的条件竞争,可能会组成一些新的组合。 46 * 47 * 说一些大家没有意识到的问题:假设有一个stack<vector<int>>,vector是一个动态容器,当你拷贝一个vetcor,标准库会从堆上分配很多内存来完成这次拷贝。 48 * 当这个系统处在重度负荷,或有严重的资源限制的情况下,这种内存分配就会失败,所以vector的拷贝构造函数可能会抛出一个std::bad_alloc异常。 49 * 当vector中存有大量元素时,这种情况发生的可能性更大。当pop()函数返回“弹出值”时(也就是从栈中将这个值移除), 50 * 会有一个潜在的问题:这个值被返回到调用函数的时候,栈才被改变;但当拷贝数据的时候,调用函数抛出一个异常会怎么样? 51 * 如果事情真的发生了,要弹出的数据将会丢失;它的确从栈上移出了,但是拷贝失败了!std::stack的设计人员将这个操作分为两部分:先获取顶部元素(top()), 52 * 然后从栈中移除(pop())。这样,在不能安全的将元素拷贝出去的情况下,栈中的这个数据还依旧存在,没有丢失。当问题是堆空间不足,应用可能会释放一些内存, 53 * 然后再进行尝试。 54 * 55 * 不幸的是,这样的分割却制造了本想避免或消除的条件竞争。幸运的是,我们还有的别的选项,但是使用这些选项是要付出代价的。 56 * 57 * 选项1: 传入一个引用 58 * 第一个选项是将变量的引用作为参数,传入pop()函数中获取想要的“弹出值”: 59 * std::vector<int> result; 60 * some_stack.pop(result); 61 * 大多数情况下,这种方式还不错,但缺点很明显:需要构造出一个栈中类型的实例,用于接收目标值。 62 * 对于一些类型,这样做是不现实的,因为临时构造一个实例,从时间和资源的角度上来看,都是不划算。对于其他的类型,这样也不总能行得通, 63 * 因为构造函数需要的一些参数,在这个阶段的代码不一定可用。最后,需要可赋值的存储类型,这是一个重大限制:即使支持移动构造,甚至是拷贝构造(从而允许返回一个值), 64 * 很多用户自定义类型可能都不支持赋值操作。 65 * 66 * 选项2:无异常抛出的拷贝构造函数或移动构造函数 67 * 对于有返回值的pop()函数来说,只有“异常安全”方面的担忧(当返回值时可以抛出一个异常)。 68 * 很多类型都有拷贝构造函数,它们不会抛出异常,并且随着新标准中对“右值引用”的支持(详见附录A,A.1节),很多类型都将会有一个移动构造函数, 69 * 即使他们和拷贝构造函数做着相同的事情,它也不会抛出异常。一个有用的选项可以限制对线程安全的栈的使用,并且能让栈安全的返回所需的值,而不会抛出异常。 70 * 71 * 虽然安全,但非可靠。尽管能在编译时可使用std::is_nothrow_copy_constructible和std::is_nothrow_move_constructible类型特征, 72 * 让拷贝或移动构造函数不抛出异常,但是这种方式的局限性太强。用户自定义的类型中,会有不抛出异常的拷贝构造函数或移动构造函数的类型, 73 * 那些有抛出异常的拷贝构造函数,但没有移动构造函数的类型往往更多(这种情况会随着人们习惯于C++11中的右值引用而有所改变)。 74 * 如果这些类型不能被存储在线程安全的栈中,那将是多么的不幸。 75 * 76 * 选项3:返回指向弹出值的指针 77 * 第三个选择是返回一个指向弹出元素的指针,而不是直接返回值。指针的优势是自由拷贝,并且不会产生异常,这样你就能避免Cargill提到的异常问题了。 78 * 缺点就是返回一个指针需要对对象的内存分配进行管理,对于简单数据类型(比如:int),内存管理的开销要远大于直接返回值。 79 * 对于选择这个方案的接口,使用std::shared_ptr是个不错的选择;不仅能避免内存泄露(因为当对象中指针销毁时,对象也会被销毁), 80 * 而且标准库能够完全控制内存分配方案,也就不需要new和delete操作。这种优化是很重要的:因为堆栈中的每个对象,都需要用new进行独立的内存分配, 81 * 相较于非线程安全版本,这个方案的开销相当大。 82 * 83 * 选项4:“选项1 + 选项2”或 “选项1 + 选项3” 84 * 对于通用的代码来说,灵活性不应忽视。当你已经选择了选项2或3时,再去选择1也是很容易的。这些选项提供给用户,让用户自己选择对于他们自己来说最合适,最经济的方案。 85 */ 86 87 88 /* 89 * 针对上面对 std::stack<int> s 访问的 func() 存在的问题, 可以通过实现为一个没有接口间条件竞争的堆栈类。 90 * 比如:采用 “选项1 + 选项3” 91 * 92 * 93 * 94 */ 95 #include<exception> 96 #include<memory> 97 #include<mutex> 98 #include<stack> 99 struct empty_stack: std::exception 100 { 101 const char* what()const throw(){ 102 return"empty stack!"; 103 } 104 }; 105 template<typename T> 106 class threadsafe_stack 107 { 108 private: 109 std::stack<T> data; 110 mutable std::mutex m; 111 public: 112 threadsafe_stack() 113 : data(std::stack<T>()){} 114 threadsafe_stack(const threadsafe_stack& other) 115 { 116 std::lock_guard<std::mutex>lock(other.m); 117 data = other.data;// 1 在构造函数体中的执行拷贝 118 } 119 threadsafe_stack&operator=(const threadsafe_stack&)=delete; 120 void push(T new_value) 121 { 122 std::lock_guard<std::mutex>lock(m); 123 data.push(new_value); 124 } 125 std::shared_ptr<T> pop() 126 { 127 std::lock_guard<std::mutex>lock(m); 128 if(data.empty())throw empty_stack();// 在调用pop前,检查栈是否为空 129 std::shared_ptr<T>const res(std::make_shared<T>(data.top()));// 在修改堆栈前,分配出返回值 130 data.pop(); 131 return res; 132 } 133 void pop(T& value) 134 { 135 std::lock_guard<std::mutex>lock(m); 136 if(data.empty())throw empty_stack(); 137 value=data.top(); 138 data.pop(); 139 } 140 bool empty()const 141 { 142 std::lock_guard<std::mutex>lock(m); 143 return data.empty(); 144 } 145 }; 146 class Locate_Interfaces_Race_Condition 147 { 148 public: 149 Locate_Interfaces_Race_Condition(); 150 }; 151 152 #endif // LOCATE_INTERFACES_RACE_CONDITION
上边的例子可以看出定位接口间的条件竞争是可以通过封装多个接口的组合,并应用 std::shared_ptr 或者 引用的小技巧,就可以避免定位接口间的条件竞争了。