C++11标准化内存模型的含义及影响 技术背景 在C++98/C++03规范中,抽象机器本质上是单线程的。因此,无法编写相对于该规范“完全可移植”的多线程C++代码。该规范甚至没有提及内存加载和存储的原子性,也没有说明加载和存储可能发生的顺序,更不用说互斥锁之类的东西了。而在实际中,开发者可以为特定的具体系统(如pthreads或Windows)编写多线程代码,但C++98/C++03没有标准的多线程编程方法。
实现步骤 1. 了解C++11抽象机器与内存模型 C++11的抽象机器从设计上就是多线程的,并且有一个定义良好的内存模型,即规定了编译器在访问内存时可以做什么和不可以做什么。
2. 对比不同情况下的代码实现 非原子变量的并发访问 1 2 3 4 5 6 7 8 9 10 int x, y; x = 17 ; y = 37 ; std::cout << y << " " ; std::cout << x << std::endl;
在C++98/C++03中,由于标准未考虑“线程”的概念,这个问题本身毫无意义;在C++11中,由于加载和存储通常不是原子的,结果是未定义行为。
原子变量的并发访问 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #include <iostream> #include <atomic> #include <thread> std::atomic<int > x, y;void thread1 () { x.store (17 ); y.store (37 ); }void thread2 () { std::cout << y.load () << " " ; std::cout << x.load () << std::endl; }int main () { std::thread t1 (thread1) ; std::thread t2 (thread2) ; t1. join (); t2. join (); return 0 ; }
在C++11中,使用std::atomic
类型,行为是定义好的。线程2可能输出0 0
(如果它在线程1之前运行)、37 17
(如果它在线程1之后运行)或0 17
(如果它在线程1给x赋值之后但给y赋值之前运行),但不能输出37 0
,因为C++11中原子加载/存储的默认模式是强制顺序一致性。
放松顺序的原子操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #include <iostream> #include <atomic> #include <thread> std::atomic<int > x, y;void thread1 () { x.store (17 , std::memory_order_relaxed); y.store (37 , std::memory_order_relaxed); }void thread2 () { std::cout << y.load (std::memory_order_relaxed) << " " ; std::cout << x.load (std::memory_order_relaxed) << std::endl; }int main () { std::thread t1 (thread1) ; std::thread t2 (thread2) ; t1. join (); t2. join (); return 0 ; }
如果算法可以容忍无序的加载和存储,即只需要原子性而不需要顺序性,可以使用std::memory_order_relaxed
,这样在现代CPU上可能会更快。
特定顺序的原子操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #include <iostream> #include <atomic> #include <thread> std::atomic<int > x, y;void thread1 () { x.store (17 , std::memory_order_release); y.store (37 , std::memory_order_release); }void thread2 () { std::cout << y.load (std::memory_order_acquire) << " " ; std::cout << x.load (std::memory_order_acquire) << std::endl; }int main () { std::thread t1 (thread1) ; std::thread t2 (thread2) ; t1. join (); t2. join (); return 0 ; }
如果只需要保持特定的加载和存储顺序,可以使用std::memory_order_release
和std::memory_order_acquire
,这样可以以最小的开销实现有序的加载和存储。
核心代码 序列锁实现示例(存在问题的代码) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #include <atomic> std::atomic<uint64_t > seq; int data1, data2; template <typename T>T reader () { int r1, r2; unsigned seq0, seq1; while (true ) { seq0 = seq; r1 = data1; r2 = data2; seq1 = seq; if (seq0 == seq1 && !(seq0 & 1 )) break ; } return T (); }void writer (int new_data1, int new_data2) { unsigned seq0 = seq; while (true ) { if ((!(seq0 & 1 )) && seq.compare_exchange_weak (seq0, seq0 + 1 )) break ; } data1 = new_data1; data2 = new_data2; seq = seq0 + 2 ; }
问题分析与解决方案 在上述序列锁实现中,data1
和data2
需要是std::atomic
类型,否则会存在数据竞争问题。并且不能仅仅将它们设为std::atomic
并使用std::memory_order_relaxed
访问,因为reader()
中对seq
的读取只有获取语义,编译器可能会对操作进行重排序,从而破坏锁的实现。论文中给出的一种较好的解决方案是在第二次读取seqlock
之前使用std::atomic_thread_fence
和std::memory_order_relaxed
。
最佳实践 1. 使用互斥锁 如果只是简单地保护数据,使用互斥锁是一个不错的选择,因为互斥锁一直提供了足够的顺序和可见性保证。
2. 合理使用原子操作 在需要高性能的并发场景中,可以使用原子操作,但要根据具体需求选择合适的内存顺序。
3. 参考专业资料 对于复杂的并发编程,如序列锁的实现,建议参考专业的技术报告和资料,避免自己实现时出现错误。
常见问题 1. 数据竞争问题 在多线程程序中,如果多个线程同时访问同一内存位置,并且至少有一个线程进行写操作,就会发生数据竞争。可以使用原子类型或互斥锁来避免数据竞争。
2. 内存顺序选择不当 如果选择的内存顺序不恰当,可能会导致程序出现意外的行为。需要根据具体的算法和性能需求来选择合适的内存顺序。
3. 序列锁实现错误 在实现序列锁时,很容易出现错误,如未将受保护的数据设为原子类型、未正确处理内存顺序等。在实现序列锁时,要仔细考虑内存模型的要求,并参考专业的实现方案。