C++库开发之道:实践和原则(一)https://developer.aliyun.com/article/1464313
第三部分:内存管理和性能优化(Memory Management and Performance Optimization)
在C++编程中,内存管理是一项至关重要的任务。对于库开发者来说,更是如此。库的用户通常期望库可以在内存使用上达到高效且安全,因此,对内存管理的深入理解和精细控制对于库的性能、稳定性和可用性来说都是非常重要的。
3.1 C++内存管理基础(Basics of C++ Memory Management)
在C++中,内存管理通常涉及到动态内存分配和释放。这是通过
new
和delete
操作符来实现的。同时,C++还提供了new[]
和delete[]
操作符,用于分配和释放数组。内存管理概述(Overview of Memory Management)
内存管理的基本原则是:谁申请,谁释放。当你通过
new
操作符申请内存时,你需要负责在适当的时候通过delete
操作符释放这个内存。如果不这样做,就会导致内存泄漏,即已分配的内存没有被释放,从而减少了可用内存。在库设计中,内存管理需要特别的注意,因为库通常被用在各种各样的环境和应用中。如果库的内存管理不良,可能会导致使用库的应用程序出现问题,例如内存泄漏或内存过度使用。
使用合适的数据结构(Using Appropriate Data Structures)
数据结构的选择对内存使用也有很大的影响。例如,如果你需要存储大量的元素,并且经常需要检索,那么使用哈希表(如
std::unordered_map
)可能比使用列表(如std::list
)更加有效,因为哈希表的查找速度通常更快。另一方面,如果你需要存储的元素数量很小,那么使用数组(如
std::array
)可能会比使用动态大小的容器(如std::vector
)更加节省内存,因为数组的大小是固定的,而动态大小的容器通常会预分配更多的内存以便于未来的扩展。避免内存泄漏(Avoiding Memory Leaks)
内存泄漏是一种常见的编程错误,特别是在手动管理内存的语言(如C++)中。内存泄漏发生在你申请了内存,但是忘记了释放它。这会导致你的程序消耗越来越多的内存,直到系统没有更多的内存可供分配。
为了避免内存泄漏,你需要确保每次通过
new
分配的内存都在适当的时机delete
。下面的代码片段是垃圾收集器在C++中的一个简单实现。GCPtr类是负责自动垃圾回收的智能指针。它保存所有GCPtr对象的列表,并定期检查任何可以删除的对象。当GCPtr对象超出范围或不再使用时,它将被删除。
#include <iostream> #include <list> #include <typeinfo> template <class T, int size = 0> class GCPtr { private: static std::list<GCPtr> gclist; T* addr; bool isArray; unsigned arraySize; typename std::list<GCPtr>::iterator sit; public: GCPtr(T* t = nullptr) { static int firstTime = 1; if (firstTime > 0) { atexit(cleanup); firstTime = 0; } addr = t; isArray = size > 0; arraySize = isArray ? size : 0; gclist.push_front(*this); sit = gclist.begin(); } ~GCPtr() { gclist.remove(*this); } static bool collect() { bool memfreed = false; typename std::list<GCPtr>::iterator it; do { for (it = gclist.begin(); it != gclist.end(); it++) { if (it->refcount() == 0) { gclist.remove(*it); if (it->isArray) { delete[] it->addr; } else { delete it->addr; } memfreed = true; break; } } } while (it != gclist.end()); return memfreed; } static void cleanup() { if (!gclist.empty()) { std::cout << "Cleaning up garbage:\n"; } while (collect()) ; } // Overload assignment of pointer to GCPtr T* operator=(T* t) { addr = t; gclist.push_front(*this); sit = gclist.begin(); return t; } // Overload dereferencing operator T& operator*() { return *addr; } // Overload member access operator T* operator->() { return addr; } // Return reference count for this specific GCPtr int refcount() { int count = 0; typename std::list<GCPtr>::iterator it; for (it = gclist.begin(); it != gclist.end(); it++) { if (it->addr == this->addr) { count++; } } return count; } // Overload array subscript operator T& operator[](int i) { return addr[i]; } // Equality and inequality bool operator==(GCPtr& rv) { return (rv.addr == this->addr); } bool operator!=(GCPtr& rv) { return (rv.addr != this->addr); } }; template <class T, int size> std::list<GCPtr<T, size>> GCPtr<T, size>::gclist;
这个垃圾收集器是通过维护所有
GCPtr
实例的列表,并删除任何引用计数为零的实例来工作的。这是一种非常基础的垃圾收集形式,可能在复杂的软件系统中表现不佳。这里实现的垃圾收集器不处理循环引用,这是引用计数垃圾收集器常见的问题。它也没有实现“标记和清扫”算法,这是一个更复杂但在处理循环引用时更有效的垃圾收集策略。这段代码只是一个简单示例,用于说明如何在 C++ 中实现垃圾收集。在实际项目中,可能需要根据具体的需求和场景,实现更复杂、更有效的垃圾收集机制。
智能指针在C++中是一种非常重要的内存管理工具,能自动管理内存的生命周期,有助于防止内存泄露。例如,std::unique_ptr和std::shared_ptr是两种常用的智能指针。当std::unique_ptr离开作用域时,它所指向的对象会自动被删除。而std::shared_ptr则通过引用计数来共享所有权,当最后一个std::shared_ptr离开作用域时,所指向的对象也会被自动删除。
然而,智能指针并不能解决所有的内存管理问题。特别是在处理复杂的数据结构,如图或树时,可能会出现循环引用的问题。在这种情况下,即使对象已经不再需要,std::shared_ptr也无法正确地释放内存,因为每个对象都至少有一个其他对象持有其引用。这就是为什么在某些情况下,可能需要实现自己的垃圾收集器或使用第三方垃圾收集库。
选择何种内存管理策略取决于你的具体需求。在许多情况下,使用智能指针可能就足够了。但在一些特定的场景或需求下,可能需要使用更高级的内存管理技术,如自定义分配器,内存池,或垃圾收集。
自定义分配器是用户定义的类,它控制如何为容器类(如std::vector,std::list和std::map)分配和回收内存。通过创建自定义分配器,你可以针对特定用例(如大数据集,实时系统,或内存有限的嵌入式系统)优化内存管理。
内存池(又名对象池或资源池)是一种内存管理技术,其中预先分配的内存池用于高效地创建和销毁对象。内存池通过复用内存块来降低动态内存分配的开销,从而提高性能并减少内存碎片化。
垃圾收集是一种自动的内存管理技术,可以识别并回收不再使用的对象所占用的内存。虽然C++没有像Java或C#那样内置的垃圾收集器,但你可以实现一个垃圾收集器,或使用提供垃圾收集功能的第三方库,如Boehm-Demers-Weiser垃圾收集器。
在C++中使用垃圾收集器可以简化内存管理,并降低内存泄露和悬空指针的风险。然而,使用垃圾收集器可能会带来一些性能开销,因此可能不适合对性能要求严格或实时约束的应用程序。
3.2 高效的内存管理(Efficient Memory Management)
一旦我们了解了内存管理的基础知识,就需要开始考虑如何使内存管理更加高效。这包括理解和应用内存分配策略,使用智能指针,以及利用内存池和自定义分配器。
内存分配策略(Memory Allocation Strategies)
在C++中,我们通常有两种方式来分配内存:静态分配和动态分配。静态分配的内存大小在编译时就已经确定,而动态分配的内存大小则在运行时确定。使用哪种方式取决于你的具体需求。
避免频繁的小块内存分配可以提高性能。频繁的小块内存分配会导致内存碎片,使得可用的连续内存减少,同时还会增加内存分配的开销。为了避免这个问题,我们可以使用内存池或者固定大小的内存块。
使用智能指针(Using Smart Pointers)
智能指针是C++11引入的一个重要特性,它们可以自动管理动态分配的内存。使用智能指针可以帮助我们避免内存泄漏,减少错误,同时还可以使代码更易于理解和维护。
std::unique_ptr
是一种独占所有权的智能指针,它确保在任何时刻,都只有一个unique_ptr
拥有内存的所有权。当unique_ptr
离开其作用范围时,它会自动释放它所拥有的内存。
std::shared_ptr
则是一种共享所有权的智能指针,多个shared_ptr
可以共享同一块内存的所有权。shared_ptr
使用引用计数来跟踪有多少个智能指针共享同一块内存。当最后一个shared_ptr
离开其作用范围时,它会自动释放它所共享的内存。内存池和自定义分配器(Memory Pools and Custom Allocators)
内存池是一种预先分配一块大内存,然后在需要时从中分配小块内存的
策略。内存池可以减少内存分配的开销,减少内存碎片,提高内存分配的速度。
自定义分配器是一种允许我们控制内存分配行为的机制。通过自定义分配器,我们可以实现如内存池等高级内存管理策略。例如,我们可以创建一个分配器,它从一个预先分配的大块内存中分配内存,当大块内存用完时,再分配一个新的大块内存。
在选择是否使用内存池或自定义分配器时,你需要考虑你的具体需求。如果你的程序需要频繁分配和释放小块内存,那么使用内存池或自定义分配器可能会提高性能。然而,如果你的程序的内存分配模式并不规则,或者分配的内存块大小各不相同,那么使用内存池或自定义分配器可能不会带来太大的好处,甚至可能会降低性能。
总的来说,高效的内存管理需要深入理解和考虑你的程序的特性和需求。通过理解内存分配策略,使用智能指针,以及利用内存池和自定义分配器,我们可以创建出既能有效利用内存,又能保持高性能的C++代码。
代码示例(Code example)
在下面的示例中,我将定义一个基础的
MemoryAllocator
类,然后通过策略模式创建三种不同的内存分配策略:StandardAllocator
,SmartAllocator
和MemoryPoolAllocator
。
#include <memory> #include <iostream> // 基础内存分配器类(Base Memory Allocator Class) class MemoryAllocator { public: virtual void* allocate(size_t size) = 0; // 分配内存(Allocate Memory) virtual void deallocate(void* ptr) = 0; // 释放内存(Deallocate Memory) }; // 标准内存分配器类(Standard Memory Allocator Class) class StandardAllocator : public MemoryAllocator { public: void* allocate(size_t size) override { return malloc(size); // 使用标准 malloc 函数分配内存(Use standard malloc function to allocate memory) } void deallocate(void* ptr) override { free(ptr); // 使用标准 free 函数释放内存(Use standard free function to deallocate memory) } }; // 智能指针内存分配器类(Smart Pointer Memory Allocator Class) class SmartAllocator : public MemoryAllocator { public: void* allocate(size_t size) override { std::shared_ptr<char> ptr(new char[size], std::default_delete<char[]>()); // 使用 shared_ptr 管理内存(Use shared_ptr to manage memory) return ptr.get(); } void deallocate(void* ptr) override { // shared_ptr 自动管理内存,无需手动释放(shared_ptr automatically manages memory, no need to manually deallocate) } }; // 内存池内存分配器类(Memory Pool Allocator Class) class MemoryPoolAllocator : public MemoryAllocator { // 这里只是一个简单的示例,实际的内存池实现会更复杂(This is just a simple example, actual memory pool implementation would be more complex) public: void* allocate(size_t size) override { // 预先分配大块内存,然后从中分配小块内存(Pre-allocate a large block of memory, then allocate small blocks from it) return nullptr; } void deallocate(void* ptr) override { // 将内存块返回到内存池(Return the memory block back to the memory pool) } }; int main() { // 使用策略模式选择合适的内存分配器(Use Strategy Pattern to choose appropriate memory allocator) MemoryAllocator* allocator = new StandardAllocator(); // 可替换为 SmartAllocator 或 MemoryPoolAllocator(Can replace with SmartAllocator or MemoryPoolAllocator) void* memory = allocator->allocate(1024); // 使用内存(Use the memory) allocator->deallocate(memory); delete allocator; return 0; }
这个例子展示了如何使用策略模式来根据不同的需求选择不同的内存分配策略。请注意,这只是一个示例,实际的内存池实现会更复杂,包括如何从内存池中分配和释放内存,如何管理内存池的大小等。另外,智能指针分配器的实现也是简化的,实际使用时,你可能需要根据具体的需求来管理和使用智能指针。
3.3 性能优化策略(Performance Optimization Strategies)
当我们讨论性能优化时,我们通常关注的是使代码运行得更快或者更有效率。这可以通过降低CPU使用,减少内存使用,减少I/O操作,或者更好地利用硬件特性来实现。以下将讨论一些性能优化的策略。
性能测试和分析(Performance Testing and Analysis)
在开始优化前,我们需要明确我们的目标,并且了解代码的性能瓶颈在哪里。性能测试和分析是这个过程的关键。我们需要对代码进行基准测试,比如使用google benchmark等工具,以了解在不同条件下代码的性能如何。
代码分析工具(比如perf, gprof或Intel VTune等)可以帮助我们识别出CPU使用最高的部分,或者是哪些部分的内存使用效率低下。这些信息会指导我们进行后续的优化。
CPU缓存优化(CPU Cache Optimization)
现代的CPU有多级缓存,这些缓存能够大大减少从内存中获取数据的延迟。然而,如果我们的代码不能有效利用这些缓存,那么性能可能会受到显著影响。
为了更好地利用CPU缓存,我们需要尽可能地让相关的数据保持在一起,这样可以提高缓存命中率。这就需要我们在设计数据结构和算法时,考虑数据的局部性原理。另外,了解并避免假共享(False Sharing)也是非常重要的。
并行和并发优化(Parallel and Concurrent Optimization)
多核和多线程的硬件现在已经非常普遍,我们可以利用这些硬件特性来提高我们代码的性能。对于CPU密集型的任务,我们可以使用并行计算来加速代码的执行。对于I/O密集型的任务,我们可以使用并发来避免CPU在等待I/O操作完成时闲置。
使用并行和并发需要我们对代码进行重构,使其可以在多个线程或进程中执行。这需要我们理解并遵循线程安全的原则,比如使用锁来保护共享数据,或者使用无锁数据结构和算法。
同时,我们也需要了解并行和并发也会带来一些开销,比如线程的创建和销毁,上下文切换,或者锁的竞争等。所以我们需要仔细权衡,找到最适合我们的并行和并发策略。
下面的示例代码演示了如何对一个简单的线性搜索算法进行优化。代码包含三个版本的搜索函数,分别展示了性能测试和分析,CPU缓存优化,以及并行优化。
#include <vector> #include <random> #include <algorithm> #include <chrono> #include <iostream> #include <execution> // 使用 std::chrono 库进行性能测试 // Use std::chrono for performance testing auto start = std::chrono::high_resolution_clock::now(); // 简单的线性搜索 // Simple linear search int linear_search(const std::vector<int>& data, int value) { for (size_t i = 0; i < data.size(); ++i) { if (data[i] == value) { return i; } } return -1; } // 对线性搜索进行优化,使用迭代器 // Optimized linear search using iterators for better cache utilization int linear_search_optimized(const std::vector<int>& data, int value) { for (auto it = data.begin(); it != data.end(); ++it) { if (*it == value) { return std::distance(data.begin(), it); } } return -1; } // 并行化线性搜索,使用 C++17 的 Parallel STL // Parallelized linear search using C++17's Parallel STL int linear_search_parallel(const std::vector<int>& data, int value) { auto it = std::find(std::execution::par, data.begin(), data.end(), value); if (it != data.end()) { return std::distance(data.begin(), it); } return -1; } int main() { // 创建一个大型数据集 // Create a large data set std::vector<int> data(1e7); std::iota(data.begin(), data.end(), 0); // 打乱数据集 // Shuffle the data set std::random_device rd; std::mt19937 g(rd()); std::shuffle(data.begin(), data.end(), g); // 需要搜索的值 // The value to search for int value = 123456; // 测试三种搜索函数的性能 // Test the performance of the three search functions auto start = std::chrono::high_resolution_clock::now(); int idx = linear_search(data, value); auto end = std::chrono::high_resolution_clock::now(); std::chrono::duration<double> diff = end - start; std::cout << "linear_search: " << diff.count() << " s\n"; start = std::chrono::high_resolution_clock::now(); idx = linear_search_optimized(data, value); end = std::chrono::high_resolution_clock::now(); diff = end - start; std::cout << "linear_search_optimized: " << diff.count() << " s\n"; start = std::chrono::high_resolution_clock::now(); idx = linear_search_parallel(data, value); end = std::chrono::high_resolution_clock::now(); diff = end - start; std::cout << "linear_search_parallel: " << diff.count() << " s\n"; return 0; }
在这个例子中,我们首先使用
std::chrono
库进行性能测试,以了解每个函数的运行时间。然后我们通过使用迭代器来提高代码的缓存利用率,从而优化线性搜索。最后,我们使用C++17的并行STL来并行化线性搜索,以提高性能。
第四部分:线程安全和并发编程(Thread Safety and Concurrent Programming)
这部分将讨论C++库设计中的线程安全和并发编程。我们将从线程安全的基础知识开始讲起,然后深入到设计线程安全的类和函数,最后探讨并发编程的高级话题。
4.1 线程安全的基础(Basics of Thread Safety)
线程安全是多线程编程中一个至关重要的概念。一个线程安全的代码意味着它可以同时被多个线程访问,而不会出现错误或不可预见的行为。
4.1.1 线程安全概述(Overview of Thread Safety)
在理解线程安全之前,我们需要先明白并发(Concurrency)和并行(Parallelism)的概念。并发是指两个或更多的任务可以在重叠的时间段内启动、运行和完成,但并不意味着它们一定会同时运行。并行则是指两个或更多的任务真正地同时运行,这通常需要硬件的支持,比如多核处理器。
当我们说一个代码是线程安全的,我们通常是指这段代码是并发安全的。也就是说,不论在什么时候,有多少线程在访问这段代码,它都可以正确地执行并产生预期的结果。
4.1.2 使用互斥锁(Using Mutexes)
互斥锁(Mutex)是实现线程安全的一种基本工具。互斥锁可以保证在任何时刻,只有一个线程可以访问被保护的代码区域,即临界区。在C++中,我们可以使用
std::mutex
类来创建互斥锁。然后在需要保护的代码区域前后分别调用lock
和unlock
方法。然而,直接使用
lock
和unlock
可能会导致一些问题。比如在lock
和unlock
之间发生异常,可能会导致互斥锁永久性地被锁住,从而引发死锁。因此,更推荐的做法是使用std::lock_guard
或std::unique_lock
,它们会在构造时自动上锁,在析构时自动解锁,从而避免了因异常导致的死锁。4.1.3 使用条件变量和其他同步工具(Using Condition Variables and Other Synchronization Tools)
除了互斥锁外,还有一些其他的同步工具可以用来保证线程安全,比如条件变量、信号量、读写锁等。这些工具各有各的特点和使用场景。
条件变量
std::condition_variable
可以让一个或多个线程等待某个条件成立。一般和互斥锁一起使用,当条件不满足时,线程会释放互斥锁并阻塞,等待其他线程通知它条件已经成立。
信号量用于限制同时访问某个资源的线程数量。C++20中引入了
std::counting_semaphore
,可以很方便地实现信号量。读写锁用于保护可读写的共享数据,当数据被多个线程读取时不需要加锁,但当数据被修改时需要全局加锁。C++14中引入了
std::shared_timed_mutex
,C++17中进一步引入了std::shared_mutex
来实现读写锁。以上只是同步工具的一部分,选择哪种工具取决于具体的使用场景和需求。但无论选择哪种工具,关键是要正确地使用它们,避免出现死锁、饥饿、竞态条件等问题。
4.1.4 示例:线程安全的C++代码
以下是一个简单的示例,展示了如何在C++中使用互斥锁、条件变量等工具实现线程安全的代码。我们将创建一个线程安全的队列,它可以被多个线程同时访问和修改。
#include <iostream> #include <queue> #include <thread> #include <mutex> #include <condition_variable> // Thread-safe Queue // 线程安全的队列 class SafeQueue { private: std::queue<int> data_queue; // Data Queue 数据队列 std::mutex mtx; // Mutex 互斥锁 std::condition_variable cond; // Condition Variable 条件变量 public: // Push Element // 插入元素 void push(int val) { std::lock_guard<std::mutex> lock(mtx); // Automatically lock and unlock 自动加锁和解锁 data_queue.push(val); cond.notify_one(); // Notify waiting thread 通知等待的线程 } // Pop Element // 弹出元素 int pop() { std::unique_lock<std::mutex> lock(mtx); // Manually lock and unlock 手动加锁和解锁 // Wait until queue is not empty // 等待直到队列非空 cond.wait(lock, [this]{ return !data_queue.empty(); }); int result = data_queue.front(); data_queue.pop(); return result; } }; int main() { SafeQueue sq; // Producer Thread // 生产者线程 std::thread producer([&](){ for (int i = 0; i < 10; ++i) { sq.push(i); std::cout << "Produced: " << i << std::endl; } }); // Consumer Thread // 消费者线程 std::thread consumer([&](){ for (int i = 0; i < 10; ++i) { int val = sq.pop(); std::cout << "Consumed: " << val << std::endl; } }); producer.join(); consumer.join(); return 0; }
在这个示例中,我们首先定义了一个线程安全的队列
SafeQueue
,它内部使用一个std::queue
来存储数据,使用一个std::mutex
来保证互斥访问,使用一个std::condition_variable
来阻塞和唤醒线程。在
push
方法中,我们使用std::lock_guard
自动管理锁的生命周期。然后将元素插入队列,最后调用notify_one
唤醒一个等待的线程。在
pop
方法中,我们使用std::unique_lock
手动管理锁的生命周期。然后调用wait
方法等待队列非空,这里使用了一个lambda函数[this]{ return !data_queue.empty(); }
作为等待条件。当队列非空时,从队列中取出一个元素并返回。在
main
函数中,我们创建了一个生产者线程和一个消费者线程,分别调用push
和pop
方法。这两个线程可以同时访问和修改队列,而不需要担心线程安全问题。4.2 设计线程安全的类和函数(Designing Thread-Safe Classes and Functions)
设计线程安全的类和函数是多线程编程的一个重要环节。下面我们将探讨线程安全设计的一些关键原则和技术。
4.2.1 线程安全的设计原则(Design Principles for Thread Safety)
设计线程安全的类和函数首先要遵循的原则是:尽量减少共享数据。共享数据是导致线程不安全的主要原因。如果可以避免在多个线程间共享数据,那么就可以避免很多线程安全问题。有时候,我们可以使用线程局部存储(Thread-Local Storage, TLS)来达到这个目的。
其次,如果必须共享数据,就应该确保对数据的访问是原子的,或者说是在互斥条件下进行的。可以使用互斥锁、读写锁等同步工具来保证这一点。
最后,应该尽量让接口易于正确使用,难以误用。比如,可以通过封装来隐藏线程同步的细节,让用户无需关心线程同步的问题。
4.2.2 线程局部存储(Thread-Local Storage)
线程局部存储是一种可以让每个线程拥有一份数据副本的机制。每个线程都可以读写自己的数据副本,而不影响其他线程。这样就可以避免了在多个线程间共享数据,从而避免了线程安全问题。
C++11引入了
thread_local
关键字来声明线程局部变量。每个线程都有一份自己的thread_local
变量,每个线程对其的修改都不会影响其他线程。4.2.3 使用原子操作(Using Atomic Operations)
原子操作是指一次不可中断的操作,即在执行过程中不会被其他线程干扰。C++11引入了
std::atomic
模板类来支持原子操作。std::atomic
可以用于基本类型,如int
、float
、pointer
等,也可以用于自定义类型。原子操作可以保证在多线程环境下对数据的修改是线程安全的,但它不能替代锁。因为锁可以保护一段代码区域,而原子操作只能保护一个操作。在需要保护一段代码区域时,还是应该使用锁。
4.2.4 示例:线程安全的类设计
下面的代码示例展示了如何结合上述原则和技术设计一个线程安全的类。
#include <thread> #include <mutex> #include <atomic> // 线程安全的计数器类 class ThreadSafeCounter { private: std::mutex mutex_; // 互斥锁用于保护数据 int count_; // 计数器 std::atomic<int> atomic_count_; // 原子计数器 public: ThreadSafeCounter() : count_(0), atomic_count_(0) {} // 使用互斥锁保护的计数器增加函数 void increase() { std::lock_guard<std::mutex> lock(mutex_); ++count_; // 锁自动在函数退出时释放,保证了异常安全性 } // 使用原子操作的计数器增加函数 void atomic_increase() { ++atomic_count_; // 无需使用互斥锁,原子操作保证了线程安全 } int get_count() const { return count_; } int get_atomic_count() const { return atomic_count_; } }; int main() { ThreadSafeCounter counter; // 启动多个线程并发增加计数器 std::thread t1([&]() { for(int i = 0; i < 10000; ++i) counter.increase(); }); std::thread t2([&]() { for(int i = 0; i < 10000; ++i) counter.increase(); }); t1.join(); t2.join(); // 此时,get_count()的返回值应该是20000,因为increase()是线程安全的 // 启动多个线程并发增加原子计数器 std::thread t3([&]() { for(int i = 0; i < 10000; ++i) counter.atomic_increase(); }); std::thread t4([&]() { for(int i = 0; i < 10000; ++i) counter.atomic_increase(); }); t3.join(); t4.join(); // 此时,get_atomic_count()的返回值应该是20000,因为atomic_increase()是线程安全的 return 0; }
在这个代码中,我们定义了一个
ThreadSafeCounter
类,其中有两个计数器:一个使用互斥锁保护,一个使用原子操作。这个类有两个增加计数器的函数:increase()
和atomic_increase()
,分别使用互斥锁和原子操作来保证线程安全。在main()
函数中,我们启动多个线程并发增加计数器,因为我们的设计是线程安全的,所以无论何时调用get_count()
和get_atomic_count()
,它们的返回值都应该是正确的。这个例子展示了如何结合使用互斥锁和原子操作来设计线程安全的类。但这只是线程安全设计的一个小例子,实际情况可能会更复杂。在设计线程安全的类和函数时,我们还需要考虑其他因素,如锁的粒度、锁的层次、死锁问题、性能问题等。
4.3 并发编程的高级话题(Advanced Topics in Concurrent Programming)
在本节中,我们将深入探讨并发编程的一些高级话题,如锁的粒度、死锁预防与检测,以及使用并发编程库。
4.3.1 锁的粒度和性能影响(Lock Granularity and Performance Impact)
锁的粒度是指锁保护的资源或代码区域的大小。粗粒度锁可以保护较大的资源或代码区域,但可能导致线程竞争和性能下降;细粒度锁则保护较小的资源或代码区域,竞争减少,性能有所提高。然而,细粒度锁可能会增加锁管理的复杂性,导致死锁等问题。
因此,在设计并发代码时,应该根据实际需求权衡锁的粒度。通常,可以从粗粒度锁开始,逐步优化为细粒度锁,以提高性能。
4.3.2 死锁的预防和检测(Deadlock Prevention and Detection)
死锁是指两个或多个线程互相等待对方释放资源的情况,导致整个系统无法继续执行。预防和检测死锁是并发编程中的一个重要问题。以下是一些预防死锁的策略:
- 锁定顺序:为锁定资源设定固定的顺序,遵循这个顺序来锁定资源,可以避免死锁。
- 锁定超时:设置锁定超时时间,当超过这个时间后,线程放弃等待锁。这样可以避免线程永久地等待锁,但可能导致系统性能下降。
- 锁分解:将一个大的锁分解为多个小的锁。这样可以减少锁竞争,从而降低死锁的可能性。
检测死锁通常需要对程序的执行进行分析,以发现潜在的死锁问题。有些调试器和分析工具可以帮助开发者检测死锁。
4.3.3 使用并发编程库(Using Concurrency Libraries)
C++标准库提供了一些基本的并发编程工具,如线程、互斥锁、条件变量等。然而,在实际开发中,我们可能需要更高级的并发编程工具。以下是一些常见的并发编程库:
- Intel Threading Building Blocks(TBB):一个用C++编写的并发编程库,提供了一套丰富的
并发数据结构和算法。
- Microsoft Parallel Patterns Library(PPL):一个用C++编写的并发编程库,提供了一套易于使用的并发编程模型。
- Boost.Asio:一个用C++编写的异步I/O库,提供了一套高效的异步I/O模型。
在选择并发编程库时,应该根据项目的需求和团队的技术栈进行选择。
4.3.4 示例:应用并发编程高级话题(Example: Applying Advanced Topics in Concurrent Programming)
在这个示例中,我们将看到如何在实践中应用我们在本节中讨论的并发编程的高级话题。特别地,我们会看到锁的粒度如何影响性能,如何预防死锁,以及如何使用并发编程库。
#include <thread> #include <mutex> #include <iostream> #include <vector> // A shared resource that multiple threads may need to access // 共享资源,多个线程可能需要访问 std::vector<int> shared_resource; // A fine-grained mutex to protect shared_resource // 一个细粒度的互斥锁来保护 shared_resource std::mutex resource_mutex; void worker(int id) { for(int i = 0; i < 10; ++i) { std::unique_lock<std::mutex> lock(resource_mutex); // Lock the mutex shared_resource.push_back(i); std::cout << "Thread " << id << " added " << i << " to the shared resource\n"; lock.unlock(); // Unlock the mutex // Simulate some work that doesn't involve the shared resource // 模拟一些不涉及共享资源的工作 std::this_thread::sleep_for(std::chrono::milliseconds(100)); } } int main() { std::thread t1(worker, 1); std::thread t2(worker, 2); // Join the threads to wait for their completion // 合并线程,等待它们完成 t1.join(); t2.join(); // Verify that there are no deadlocks by ensuring that all items were added // 通过确保所有项都已添加,验证没有死锁 std::cout << "Total items in the shared resource: " << shared_resource.size() << "\n"; return 0; }
在这个示例中,我们创建了两个线程,每个线程都尝试向
shared_resource
中添加元素。我们使用std::mutex
来保护shared_resource
,以避免数据竞争。当线程需要访问shared_resource
时,它会锁定互斥锁;当它完成操作后,它会解锁互斥锁。注意,我们使用
std::unique_lock
来管理互斥锁的锁定和解锁。这是一个RAII风格的类,当std::unique_lock
的实例被销毁时,它会自动解锁互斥锁。这样可以确保即使在异常情况下,互斥锁也能被正确地解锁。我们还使用了
std::this_thread::sleep_for
函数来模拟一些不涉及shared_resource
的工作。这段时间内,线程不需要锁定互斥锁,其他线程可以访问shared_resource
。在
main
函数中,我们使用std::thread::join
函数来等待线程完成。这可以确保在程序结束之前,所有的线程都已完成其工作。这个示例演示了如何在实践中应用并发编程的高级话题。然而,实际的并发编程可能会涉及到更复杂的情况和更多的挑战,例如处理更复杂的同步问题,优化并发性能,以及使用更高级的并发编程库。
C++库开发之道:实践和原则(三)https://developer.aliyun.com/article/1464315