作者:@阿亮joy.
专栏:《吃透西嘎嘎》
座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
在 C++11 之前,涉及到多线程问题,都是和平台相关的,比如 Windows 和 Linux 下各有自己的接口,这使得代码的可移植性比较差。C++11 中最重要的特性就是对线程进行支持了,使得 C++ 在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。要使用标准库中的线程,必须包含 < thread > 头文件。C++11中线程类
注:当创建一个线程对象后,没有提供线程函数,该对象实际没有对应任何线程。thread 类是防拷贝的,不允许拷贝构造以及赋值,但是可以移动构造和移动赋值,即将一个线程对象关联线程的状态转移给其他线程对象,转移期间不影响线程的执行。
可以通过 jionable 函数判断线程是否是有效的,如果是以下任意情况,则线程无效。
get_id 函数使用示例:
yield 函数的介绍和使用示例
#include
#include
using namespace std;
void Print(int n)
{
cout << "ThreadID:" << this_thread::get_id() << endl;
for (int i = 0; i < n; ++i)
cout << i << " ";
cout << endl;
}
int main()
{
thread t1(Print, 10);
t1.join();
thread t2(Print, 10);
t2.join();
return 0;
}
除了用函数(函数指针)来构造线程,还可以使用 lambda 表达式,仿函数等来构造线程。
#include
#include
#include
using namespace std;
int main()
{
int x = 0;
int n = 10;
int m = 0; // 线程个数
cin >> m;
vector<thread> threads;
threads.resize(m);
for (int i = 0; i < m; ++i)
{
// 移动赋值给vector中的线程对象
threads[i] = thread([&]() {
cout << "ThreadID: " << this_thread::get_id() << endl;
for (int i = 0; i < n; ++i)
{
++x;
cout << i << " ";
}
cout << endl;
});
threads[i].join();
}
cout << "x: " << x << endl;
return 0;
}
并发和并行都是指多个任务同时执行的情况,但是它们的含义有所不同。
并发是指在同一个时间段内,多个任务交替地执行,这些任务可以在同一台计算机上运行,也可以在不同的计算机上运行,彼此之间通过网络或其他方式进行通信和同步。并发常常用来提高系统的吞吐量和响应性,以及实现资源共享和负载均衡等功能。
而并行是指在同一个时间点上,多个任务同时执行,这些任务通常在多个处理器或多个计算机上运行,每个任务分配给不同的处理器或计算机进行处理。并行常常用来加速计算和处理速度,提高系统的性能。
C++11 中引入了一个新的 mutex 类,它是一个互斥量,用于实现线程之间的同步和数据保护。mutex 类的基本用法是在多个线程之间锁定共享资源以防止竞争条件。在一个线程获得了 mutex 的锁之后,其他线程尝试获取这个锁将会被阻塞,直到锁被释放。
在 C++11 中,mutex 类定义在头文件 中,主要有两个常用的成员函数:
#include
#include
#include
std::mutex mtx;
void Print(const std::string& msg)
{
std::lock_guard<std::mutex> lock(mtx);
std::cout << msg << std::endl;
}
int main()
{
std::thread t1(Print, "Hello from thread 1");
std::thread t2(Print, "Hello from thread 2");
t1.join();
t2.join();
return 0;
}
在这个示例中,我们创建了两个线程 t1 和 t2,它们都调用了 Print 函数来输出一条消息。由于 Print 函数需要访问共享的输出流 std::cout,我们在函数中使用了 std::lock_guardstd::mutex 来获取锁,保护输出流不会被多个线程同时访问。这里的 mtx 是我们定义的全局互斥量。
当一个线程获取了锁之后,其他线程将会被阻塞,直到锁被释放。在这个示例中,t1 和 t2 会依次输出各自的消息,因为它们会依次获取 mtx 的锁,然后执行 Print 函数,最后释放锁。
需要注意的是,如果一个线程获取了锁之后没有及时释放,那么其他线程将会被永久阻塞,程序可能会陷入死锁状态。因此,我们应该始终确保在使用 mutex 类时,尽可能快地获取和释放锁,避免阻塞其他线程的执行。
关于 lock_guard
lock_guard 是一种用于管理互斥锁的 RAII(Resource Acquisition Is Initialization)类。它可以保证在作用域结束时自动释放互斥锁,以避免忘记手动释放锁所导致的问题。
使用 lock_guard 类可以避免手动管理互斥锁的问题,可以提高程序的可读性和可维护性。在创建 lock_guard 对象时,需要传入一个互斥锁对象,这个互斥锁对象会被 lock_guard 类包装,当 lock_guard 对象被销毁时,它会自动调用互斥锁对象的 unlock 函数,释放互斥锁。
lock_guard 类的使用非常简单,只需要在需要使用互斥锁的代码块中创建一个 lock_guard 对象即可,不需要手动加锁和解锁。当程序流程离开这个代码块时,lock_guard 对象会自动释放互斥锁。由于 lock_guard 对象的生命周期是由编译器控制的,因此无论代码流程中出现何种异常情况,lock_guard 对象都会在作用域结束时被自动销毁,从而保证了程序的正确性。
lock_guard 的模拟实现
template <class Lock>
class LockGuard
{
public:
LockGuard(Lock& lock)
: _lock(lock)
{
_lock.lock();
}
~LockGuard()
{
_lock.unlock();
}
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
Lock& _lock;
};
如何将互斥锁作为执行例程的参数
因为互斥锁是不支持拷贝构造的,所以就不能使用传值的方式来传递互斥锁。那么我们可以采用指针或引用的方式来传递。以指针方式来传递互斥锁比较简单,我们来学习一下如果通过引用来传递互斥锁。
为何通过引用来传递互斥锁
线程的执行函数的参数尽管使用引用来修饰,也是值传递的方式,那怎么样才能实现真正的引用传递方式呢?见下图所示:
在 C++11 线程库中,线程执行例程的参数可以是引用也可以是拷贝,具体取决于用户如何传递参数。如果将参数作为值传递,则会进行拷贝构造,而如果将参数作为引用传递,则不会进行拷贝构造。
如果需要真正实现传递引用,可以使用 std::ref 函数将引用类型的参数包装成 std::reference_wrapper 类型的对象,然后将这个对象作为参数传递给线程的执行例程。 std::reference_wrapper 是一个模板类,它提供了一种引用的包装方式,可以像普通对象一样进行拷贝和赋值,同时可以通过调用其 get 函数获取其包装的引用。
如果有一个函数需要传递一个引用类型的参数,可以使用 ref 函数将这个引用包装成 reference_wrapper 类型的对象,并将其作为参数传递给线程的执行例程。
注:线程构造函数的参数包并不是直接传给执行例程的,而是先用参数包的参数去构造线程,然后再将这些参数传递给线程的执行例程。互斥锁没有被识别成引用传递的问题是出现在构造线程时参数包传递的过程。线程函数的参数是以值拷贝的方式拷贝到线程栈空间中的,因此:即使线程参数为引用类型,在线程中修改后也不能修改外部实参,因为其实际引用的是线程栈中的拷贝,而不是外部实参。
std::recursive_mutex
其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock,除此之外,recursive_mutex 的特性和 mutex 大致相同。
std::timed_mutex
比 std::mutex 多了两个成员函数,try_lock_for 和 try_lock_until。
unique_lock
与 lock_guard 类似,unique_lock 类模板也是采用 RAII 的方式对锁进行了封装,并且也是以独占所有权的方式管理 Mutex 对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化 unique_lock 的对象时,自动调用构造函数上锁,unique_lock 对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。
与 lock_guard 不同的是,unique_lock更加的灵活,提供了更多的成员函数:
int main()
{
int x = 0;
int n = 10000;
int m = 0; // 线程个数
cin >> m;
vector<thread> threads;
threads.resize(m);
for (int i = 0; i < m; ++i)
{
// 移动赋值给vector中的线程对象
threads[i] = thread([&]() {
for (int i = 0; i < n; ++i)
{
++x;
}
});
}
for (auto& thread : threads)
{
thread.join();
}
cout << "x: " << x << endl;
return 0;
}
在多线程场景下,多个线程可能会同时访问共享资源,如变量、内存区域、文件等。如果多个线程同时修改同一个共享资源,就会导致数据竞争问题,从而导致程序出现不可预料的错误,如程序崩溃、死锁等。因此,在多线程场景中,需要对共享资源进行加锁保护,以避免数据竞争问题的出现。
上面的代码可以进行加锁保护,加锁的粒度一般是越小越好。对于一些简单的程序,如只有加加操作的,可能锁的释放和线程的上下文切换有可能是更大的消耗。
C++11 引入了 atomic 类,它是一种特殊的数据类型,用于在多线程程序中保证数据的原子性操作。原子性操作是指操作不可被中断或分割,要么全部执行成功,要么全部执行失败,不会出现部分成功或者失败的情况。原子操作通常是在单条指令中完成的,因此在多线程环境下可以避免数据竞争和其他线程安全问题。
atomic 类的主要作用是提供一组原子操作函数来操作其内部封装的数据类型,这些原子操作函数包括:
通过使用这些原子操作函数,可以在多线程环境下对共享变量进行安全的读取和修改操作,避免了数据竞争和其他线程安全问题的出现。例如,可以使用atomic
来创建一个原子变量,然后使用 load 和 store 来进行安全的读写操作。
需要注意的是,atomic 类只能用于特定的数据类型,如int、long 等,而不能用于自定义类型。此外,原子操作的开销比普通的非原子操作要大,因此在性能敏感的场景下需要谨慎使用。
注意:原子类型通常属于资源型数据,多个线程只能访问单个原子类型的拷贝,因此在 C++11 中,原子类型只能从其模板参数中进行构造,不允许原子类型进行拷贝构造、移动构造以及 operator= 等,为了防止意外,标准库已经将 atmoic 模板类中的拷贝构造、移动构造、赋值运算符重载默认删除掉了。
CAS(Compare-and-Swap)是一种原子操作,用于在多线程编程中实现无锁算法。无锁编程是一种并发编程技术,它不使用互斥锁和信号量等同步机制,而是使用原子操作和其他技术来保证数据的一致性和正确性。
CAS 操作是一种原子性的读取-修改-写入操作,它通常有三个参数:操作的内存地址、期望值和新值。它的执行流程如下:
CAS 操作的原子性保证了在多线程环境下,同一时间只有一个线程可以成功地执行该操作,从而避免了数据竞争和其他线程安全问题。因此,CAS 操作通常被用于实现无锁算法,如无锁队列、无锁哈希表、无锁栈等。
无锁编程是一种比较高级的编程技术,它可以提高程序的并发性和性能,同时避免了锁冲突和死锁等问题。但是无锁编程也有一定的局限性,例如在处理复杂的共享数据结构时,可能需要使用比较复杂的算法来实现无锁操作,并且需要注意数据一致性和正确性等问题。因此,在实际编程中需要仔细评估使用无锁编程的可行性和适用范围。
Linux 系统 CAS 操作的 API
在 Linux 中,CAS操作可以使用 GCC 内置函数__sync_val_compare_and_swap 来实现,其语法如下:
type __sync_val_compare_and_swap(type* ptr, type oldval, type newval);
该函数的作用是比较 ptr 和 oldval 的值,如果相等,则将 ptr 的值设置为 newval,返回 ptr 原本的值;否则不进行操作,返回 ptr 当前的值。
需要注意的是,GCC 内置函数__sync_val_compare_and_swap 只能用于支持 CAS 操作的平台,如 x86 和 ARM 等。在一些不支持 CAS 操作的平台上,可能需要使用其他方式来实现 CAS 操作。
下面是一个使用 __sync_val_compare_and_swap 函数实现 CAS 操作的示例,用于保证某个整型变量在多线程环境下的原子性操作:
#include
#include
volatile int counter = 0;
void* increment(void*)
{
for (int i = 0; i < 100000; ++i)
{
int old_value, new_value;
do
{
old_value = counter;
new_value = old_value + 1;
} while (!__sync_val_compare_and_swap(&counter, old_value, new_value));
}
return nullptr;
}
int main()
{
const int num_threads = 10;
pthread_t threads[num_threads];
for (int i = 0; i < num_threads; ++i)
{
pthread_create(&threads[i], nullptr, increment, nullptr);
}
for (int i = 0; i < num_threads; ++i)
{
pthread_join(threads[i], nullptr);
}
std::cout << "Counter: " << counter << std::endl;
return 0;
}
该示例中,使用 pthread 库创建了 10 个线程来对 counter 进行增加操作。在 increment 函数中,使用 do-while 循环进行 CAS 操作,直到成功为止。在每次循环中,首先使用 old_value 保存当前的 counter 值,然后计算 new_value 为 old_value 加 1。接着,使用 __sync_val_compare_and_swap 函数进行 CAS 操作,如果 counter 的值等于 old_value,则将 counter 的值设置为 new_value,返回 true;否则不进行操作,返回 false。直到 CAS 操作返回 true为止,表示当前线程成功地对 counter 进行了原子性操作。
需要注意的是,__sync_val_compare_and_swap 函数只能保证单个变量的原子性操作,不能保证多个变量之间的操作的原子性。
C++11 中的 condition_variable 是用于线程同步的一种机制,它能够协调多个线程之间的操作,以便它们能够有效地进行通信和同步。condition_variable 通常与互斥锁一起使用,用于实现生产者-消费者模型、读者-写者模型等线程间同步的场景。
condition_variable 提供了两个主要的操作:wait 和notify_one 或 notify_all。
wait 操作会使当前线程阻塞,并释放关联的互斥锁,直到另外一个线程调用了 notify_one 或 notify_all 方法,通知该线程可以继续执行了。
notify_one 操作会唤醒一个正在等待的线程,而notify_all 操作会唤醒所有正在等待的线程。
C++11中的condition_variable是用于线程同步的一种机制,它能够协调多个线程之间的操作,以便它们能够有效地进行通信和同步。condition_variable通常与互斥锁一起使用,用于实现生产者-消费者模型、读者-写者模型等线程间同步的场景。
condition_variable提供了两个主要的操作:wait()和notify_one()或notify_all()。
wait()操作会使当前线程阻塞,并释放关联的互斥锁,直到另外一个线程调用了notify_one()或notify_all()方法,通知该线程可以继续执行了。
notify_one()操作会唤醒一个正在等待的线程,而notify_all()操作会唤醒所有正在等待的线程。
使用 condition_variable 的步骤通常如下:
一个线程打印奇数,另一个线程打印偶数
#include
#include
#include
#include
using namespace std;
int main()
{
int i = 0;
int n = 100;
mutex mtx;
condition_variable cv;
bool ready = true;
// 线程1打印奇数
thread t1([&] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
cv.wait(lock, [&ready]() { return !ready; });
cout << "t1(" << this_thread::get_id() << ")" << " : " << i << endl;
++i;
ready = true;
cv.notify_one();
}
});
// 线程2打印偶数
thread t2([&] {
while (i < n)
{
unique_lock<mutex> lock(mtx);
cv.wait(lock, [&ready]() { return ready; });
cout << "t2(" << this_thread::get_id() << ")" << " : " << i << endl;
++i;
ready = false;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
判断谁先运行的关键是看 wait 时的可执行对象的返回值是否为 true,如果为 true,那么就是这个线程先运行。
在 C++ 中,智能指针是一种指针类,它通过使用 RAII(资源获取即初始化)技术来自动管理内存资源。智能指针可以自动分配和释放内存,并且可以防止内存泄漏和悬挂指针等问题。
C++ 中有四种智能指针,分别为 auto_ptr、unique_ptr、shared_ptr 和 weak_ptr。其中 auto_ptr 是 C++98 引入的智能指针,其余三个均是 C++11 引入的智能指针。
#include
#include
int main()
{
std::auto_ptr<int> p1(new int(10));
std::cout << *p1 << std::endl; // 输出 10
std::auto_ptr<int> p2(p1); // 所有权转移,p1 现在为空指针
std::cout << p1.get() << std::endl; // 输出 0
std::cout << *p2 << std::endl; // 输出 10
std::auto_ptr<int> p3;
p3 = p2; // 所有权转移,p2 现在为空指针
std::cout << p2.get() << std::endl; // 输出 0
std::cout << *p3 << std::endl; // 输出 10
return 0; // 所有的 auto_ptr 对象在此处销毁,释放内存
}
在《智能指针》这篇博客中模拟实现的 shared_ptr 不是线程安全的,原因就是未对引用计数进行加锁保护,那我们现在来模拟实现一下线程安全的 shared_ptr。
#include
#include
#include
#include
#include
using namespace std;
namespace Joy
{
template <class T>
class shared_ptr
{
public:
shared_ptr()
: _ptr(nullptr)
, _pRefCount(nullptr)
, _pMutex(nullptr)
{}
shared_ptr(T* ptr)
: _ptr(ptr)
, _pRefCount(new int(1))
, _pMutex(new mutex)
{}
shared_ptr(const shared_ptr<T>& other)
: _ptr(other._ptr)
, _pRefCount(other._pRefCount)
, _pMutex(other._pMutex)
{
AddRef();
}
void AddRef()
{
_pMutex->lock();
++(*_pRefCount);
_pMutex->unlock();
}
void Release()
{
// 当前智能指针没有指向任何资源
if (_ptr == nullptr)
return;
// 通过flag来判断是否需要释放释放锁资源
bool flag = false;
_pMutex->lock();
if (--(*_pRefCount) == 0 && _ptr)
{
cout << "delete " << _ptr << endl;
delete _ptr;
delete _pRefCount;
flag = true;
}
_pMutex->unlock();
if (flag) delete _pMutex;
}
~shared_ptr()
{
Release();
}
shared_ptr<T>& operator==(const shared_ptr<T>& other)
{
// 不是自己给自己赋值才执行下面的流程
if (_ptr != other._ptr)
{
Release();
_ptr = other._ptr;
_pRefCount = other._pRefCount;
_pMutex = other._pMutex;
AddRef();
}
return *this;
}
int use_count() const
{
return *_pRefCount;
}
T& operator*() const
{
return *_ptr;
}
T* operator->() const
{
return _ptr;
}
T* get() const
{
return _ptr;
}
private:
T* _ptr;
int* _pRefCount;
mutex* _pMutex;
};
}
int Func(int n)
{
cout << n << endl;
return n;
}
int main()
{
int m = 0;
cin >> m;
vector<thread> threads(m);
int n = 10000;
Joy::shared_ptr<double> sp1(new double(7.28));
Joy::shared_ptr<double> sp2(sp1);
for (auto& t : threads)
{
t = thread([&](){
for (size_t i = 0; i < n; ++i)
{
Joy::shared_ptr<double> sp(sp1);
}
});
}
for (auto& t : threads)
t.join();
// 测试智能指针没有指向任何资源的情况
{
Joy::shared_ptr<int> sp3;
}
cout << sp1.use_count() << endl;
return 0;
}
在 C++11 标准中,智能指针(如unique_ptr、shared_ptr和weak_ptr)的引用计数是原子操作,因此在多线程环境下使用智能指针管理资源是线程安全的。多个线程可以同时访问和修改智能指针对象,而不会导致数据竞争和同步问题。
但是,智能指针管理的资源本身并不一定是线程安全的。如果多个线程同时访问和修改同一块内存或同一资源,仍然会发生竞争条件和数据竞争。因此,在使用智能指针管理资源时,需要特别注意多线程访问同一块内存或资源的情况。
如果需要在多线程环境下访问和修改资源,可以使用互斥锁等同步机制来保护资源的访问,以避免数据竞争和同步问题。
饿汉模式和懒汉模式都是单例模式的实现方式,用于保证一个类只有一个实例对象。如何保证一个类只有一个实例对象呢?不允许随便创建对象,删除拷贝构造函数和赋值运算符重载以及提供一个获取唯一实例对象的接口。
饿汉模式是指在程序启动时或者类被加载时,就创建单例对象。饿汉模式的实现方式是在类定义中直接创建静态的单例对象,如下所示:
class Singleton
{
public:
static Singleton* GetInstance()
{
return &_instance;
}
private:
// 构造函数私有
Singleton() {};
// 防拷贝
Singleton(const Singleton&) = delete;
Singleton& operatot(const Singleton&) = delete;
private:
static Singleton _instance;
};
Singleton Singleton::_instance;
在程序启动时或者类被加载时,就会创建 Singleton 类的静态实例对象 _instance。
饿汉模式的优点:
懒汉模式是指在需要创建单例对象时才创建。懒汉模式的实现方式是在 GetInstance 方法中判断单例对象是否已经创建,如果没有就创建一个新的单例对象,如下所示:
class Singleton
{
public:
static Singleton* GetInstance()
{
// 保证对象创建好后,不需要申请锁和释放锁
// 直接返回单例对象的指针
if (_pInstance)
{
unique_lock<mutex> lock(_mtx);
// 加锁保护和if判断保证第一次
// 创建对象是线程安全的
if (_pInstance == nullptr)
{
_pInstance = new Singleton();
}
}
return _pInstance;
}
private:
// 构造函数私有
Singleton() {};
// 防拷贝
Singleton(const Singleton&) = delete;
Singleton& operatot(const Singleton&) = delete;
private:
static Singleton* _pInstance;
static mutex _mtx;
};
Singleton* Singleton::_pInstance = nullptr;
mutex Singleton::_mtx;
除了使用双检查加上锁保护的方式来实现懒汉模式,下面的代码也能实现懒汉模式。注:下面的代码只能在支持 C++11 的编译器里实现懒汉模式。
class Singleton
{
public:
static Singleton* GetInstance()
{
static Singleton instance;
return &instance;
}
private:
// 构造函数私有
Singleton() {};
// 防拷贝
Singleton(const Singleton&) = delete;
Singleton& operatot(const Singleton&) = delete;
};
为什么上面的代码只在支持 C++11 的编译器里才能实现懒汉模式。
C++ 中局部静态对象的初始化在 C++11 之前是不线程安全的,因为其初始化不是原子操作,可能会出现竞态条件。在多线程场景下,不同线程在不同时间可能同时访问同一个静态变量,如果没有加锁保护,则有可能导致对象被多次初始化,从而破坏程序的正常运行。
C++11 引入了线程安全的局部静态变量初始化方式,即通过使用 std::call_once 函数和 std::once_flag 类型实现。std::call_once 函数可以保证在多线程环境下只初始化一次静态变量,而 std::once_flag 则是用于标记初始化是否完成的标志。
#include
#include
class Foo
{
public:
void doSomething()
{
std::call_once(initFlag_, &Foo::init);
std::cout << "do something..." << std::endl;
}
static Foo& getInstance()
{
std::call_once(initFlag_, &Foo::init);
return *instance_;
}
private:
static void init()
{
instance_ = new Foo();
}
static Foo* instance_;
static std::once_flag initFlag_;
};
Foo* Foo::instance_ = nullptr;
std::once_flag Foo::initFlag_;
int main()
{
Foo::getInstance().doSomething();
return 0;
}
在上面的示例代码中,Foo 类中的 init 函数通过 new 操作符创建一个 Foo 对象并赋值给 instance_ 指针。在 doSomething和 getInstance 函数中,通过 std::call once 函数保证在多线程环境下只初始化一次静态变量。
需要注意的是,在使用 std::call_once 函数时,需要提供一个静态的函数指针,该函数用于初始化静态变量,而且该函数需要是 static 类型的,因为非 static 类型的函数中无法访问静态成员变量。
懒汉模式的优点:
懒汉模式的缺点:
本篇博客主要讲解了多线程相关的类 thread、mutex、atomic 和 condition_variable、线程安全的智能指针和单例模式等。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!❣️