C++11:线程库与线程安全问题
目录
线程库是操作系统提供的,Linux提供的pthread库,在C++11之前,涉及到多线程问题,都是和平台相关的,比如 Windows 和 Linux 下各有自己的接口,这使得代码的可移植性比较差。C++11中最重要的特性就是对线程进行支持了,使得C++在并行编程时不需要依赖第三方库,而且在原子操作中还引入了原子类的概念。
1.thread类
C++手册文档链接:thread。 要使用标准库中的线程,必须包含< thread >头文件。C++手册链接那么是如何判断平台调用不同的库呢,那就是使用条件编译,下面是创建线程的方式
#ifdef _WIN32
CreateThread()
#else
pthread_create()
#endif
| 函数名 | 功能 |
| thread() | 构造一个线程对象,没有关联任何线程函数,即没有启动任何线程 |
| thread(fn, args1, args2, ...) | 构造一个线程对象,并关联线程函数fn,args1,args2,...为线程函数的参数 |
| get_id() | 获取线程id |
| joinable() | 线程是否还在执行,joinable代表的是一个正在执行中的线程。 |
| join() | 该函数调用后会阻塞住主线程,当该线程结束后,主线程继续执行 |
| detach() | 在创建线程对象后马上调用,用于把被创建线程与线程对象分离开,分离的线程变为后台线程,创建的线程的"死活"就与主线程无关 |
| swap | 交换两个线程 |
thread 类的构造

第一个创建一个空线程不启动。explicit 阻止“上下文隐式转换”,不仅不支持单参构造函数的隐式类型转换,它的核心作用恰恰是禁止这种转换。单参构造函数有两种,一种是只有一个参数,一种是从第一个参数开始后面都有缺省值;
c语言创建线程:
int pthread_create(pthread_t *tidp, const pthread_attr_t *attr,
void *(*start_rtn)(void*), void* arg);
上图中第二个构造函数创建线程:
- 第一个参数 template < > && 是万能引用,传入可执行对象,1.函数指针,2.仿函数,3.lambda,4.包装器。
- 然后第二个参数是可变参数包,之后会将参数包里的内容传入第一个参数中
C++11 提供了 thread 和 this_thread:
std::thread:是一个类。你可以定义这个类的对象(如t1,t2),每个对象都关联着一个系统底层的执行线程。
std::this_thread:是一个命名空间(namespace)。里面只包含静态/全局函数,你不能创建它的对象,所有功能都是针对“此时此刻运行代码的线程”的。
用法:
std::thread:管理“别人”即目标线程。通过t.join()等待别人结束,通过t.detach()让别人独立运行,通过t.get_id()查询别人的线程号。
std::this_thread:管理“自己”。它提供 3 个静态成员函数,都是让当前线程“自我牺牲”或“自我认知”的:
std::this_thread::get_id():获取当前线程自己的 ID。
std::this_thread::sleep_for():让当前线程自己睡一会儿(阻塞)。
std::this_thread::sleep_until():让当前线程睡到某个时间点。
std::this_thread::yield():当前线程主动礼让 CPU(提示调度器先执行其他线程)。比如 trylock 没有成功,可以将CPU让出去。
代码实例:
#include <iostream>
using namespace std;
#include <thread>
void Print(size_t n, size_t j)
{
for (size_t i = n; i < j; i++)
{
//线程内获取id
cout << this_thread::get_id() << ":" << i << endl;
}
}
int main()
{
thread t1;
thread t2(Print, 100, 1000);
thread t3(Print, 100, 1000);
cout << "线程1:" << t1.get_id() << endl;
cout << "线程2:" << t2.get_id() << endl;
cout << "线程3:" << t3.get_id() << endl;
//默认构造的对象是 非可加入(non-joinable) 的
if (t1.joinable()) {
t1.join();
}
t2.join();//阻塞等待新线程
t3.join();
return 0;
}
线程之间不支持拷贝构造,赋值,因为数据资源需要独立。但是支持移动构造 ,移动赋值。移动之后就开始运行了,需要等待就使用 join。
//线程对象支持移动构造,移动赋值,不支持拷贝构造,拷贝赋值
int main()
{
size_t n;
cin >> n;
//C++11中线程是对象,所以可以放入容器
vector<thread> vthd(n);//创建了n个空的线程,没有启动
for (auto& thd : vthd)
{
thd = thread(Print, 1, 100);
}
for (auto& thd : vthd)
{
thd.join();
}
thread t1(Print, 1, 100);
thread t2(move(t1));
//t1.join();接管后t1.joinable() = false;
t2.join();
return 0;
}
执行 move(t1) 后: t2 接管了 t1 内部的线程句柄,t2.joinable() 变为 true。t1 被“掏空”,恢复为默认构造状态,t1.joinable() 变为 false,不用再 t1.join()。这里也可以用 swap 实现。
传入lambda函数,当然下面代码对 x++ 是有线程安全问题的。cout 也会有多线程抢夺它的使用权时,执行顺序极度不明确。
int main()
{
int n1;
int n2;
cin >> n1 >> n2;
//捕获的n1,传入的n2,用的线程的第二种构造函数
thread t1([n1](int n2) {
for (int i = n1; i < n2; i++)
{
cout << this_thread::get_id() << ":" << i << endl;
}
}, n2);
t1.join();
size_t x = 0;
thread t2([n1,&x]() {
for (int i = 0; i < n1; i++)
{
x++;
cout << this_thread::get_id() << ":" << i << endl;
}
});
thread t3([n2, &x]() {
for (int i = 0; i < n2; i++)
{
//n2++;//不加mutable(只针对不带引用) 不能对捕获的值修改
x++;
cout << this_thread::get_id() << ":" << i << endl;
}
});
t2.join();
t3.join();
cout << x << endl;
return 0;
}
如何控制线程安全呢,我们就需要用到锁。
2.mutex类
C++手册文档链接:mutex。锁也不支持拷贝,不支持传值,只支持传入引用。
| lock() | 上锁:锁住互斥量 |
| unlock() | 解锁:释放对互斥量的所有权 |
| try_lock() | 尝试锁住互斥量,如果互斥量被其他线程占有,则当前线程也不会被阻塞,返回 true 或者 false |
线程控制,通过捕获锁的引用传入lambda函数。
int main()
{
int n1;
int n2;
cin >> n1 >> n2;
mutex mtx;//锁
size_t x = 0;
thread t1([n1,&x,&mtx]() {
for (int i = 0; i < n1; i++)
{
mtx.lock();
x++;
mtx.unlock();
}
});
thread t2([n2, &x,&mtx]() {
for (int i = 0; i < n2; i++)
{
mtx.lock();
x++;
mtx.unlock();
}
});
t1.join();
t2.join();
cout << x << endl;
return 0;
}
注意:通过参数传锁(引用)需要加 std::ref()
C++ 标准规定:为了把参数传递给新线程函数,thread 的构造函数必须先把这些参数复制一份存起来(存到线程的内部存储中),而 std::mutex 不允许拷贝。std::ref() 并不复制 mtx,它生成了一个“引用包装器”(std::reference_wrapper<mutex>)临时对象。
-
这个包装器非常轻量,内部只保存了一个 mtx 的指针。当然也可以传入指针
-
关键点:
std::reference_wrapper是允许拷贝的(拷贝它,相当于拷贝了内部的那个指针)。
即使是引用传入一个变量,想要内部修改,我们也要使用 std::ref(),因为 thread 创建时中间会产生一个中间变量,我们再引用的是中间变量,而不是最开始的变量。
结论:线程函数的形参是 T& (左值引用),并且你想让线程修改外部的这个变量,我们就需要使用 std::ref(),会生成一个可拷贝的“引用包装器”,在线程内部自动还原为引用。
void Print(size_t n,mutex &mtx,int& rx)
{
for (size_t i = 0; i < n; i++)
{
//线程内获取id
mtx.lock();
cout << this_thread::get_id() << ":" << i << endl;
rx++;
mtx.unlock();
}
}
int main()
{
mutex mtx;
int x = 0;
thread t2(Print, 10, ref(mtx), ref(x));
thread t3(Print, 15, ref(mtx), ref(x));
t2.join();//阻塞等待新线程
t3.join();
cout << x << endl;//25
return 0;
}
举个例子:bar 就是 foo

递归互斥锁:recursive_mutex 其允许同一个线程对互斥量多次上锁(即递归上锁),来获得对互斥量对象的多层所有权,释放互斥量时需要调用与该锁层次深度相同次数的 unlock(),除此之外,std::recursive_mutex 的特性和 std::mutex 大致相同。例如在递归函数中加锁,mutex 只能申请一次,而 recursive_mutex 可以,不会死锁。
std::timed_mutex:比 std::mutex 多了两个成员函数,try_lock_for(),try_lock_until() 。
- try_lock_for() 接受一个时间范围,表示在这一段时间范围之内线程如果没有获得锁则被阻塞住(与 std::mutex 的 try_lock() 不同,try_lock 如果被调用时没有获得锁则直接返回 false),如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
- try_lock_until() 接受一个时间点作为参数,在指定时间点未到来之前线程如果没有获得锁则被阻塞住,如果在此期间其他线程释放了锁,则该线程可以获得对互斥量的锁,如果超时(即在指定时间内还是没有获得锁),则返回 false。
mtx.try_lock_for(std::chrono::milliseconds(200));
chrone是一个关于时间函数的命名空间。还有一个std::recursive_timed_mutex。
3.LockGuard
如果上锁之后,中间发生抛异常,没有主动释放锁,会导致死锁,我们可以通过实现一个LockGuard 来实现自动 通过析构函数释放锁,RAII。
template<class Lock>
class LockGuard
{
public:
LockGuard(Lock& mtx)
:_mtx(mtx)
{
_mtx.lock();
}
~LockGuard()
{
_mtx.unlock();
}
private:
mutex& _mtx;//成员变量用引用,const ,没有默认构造的自定义类型必须在初始化列表初始化
};
成员变量用引用,const ,没有默认构造的自定义类型必须在初始化列表初始化。因为_mtx不支持拷贝,所以这里必须成员是引用。
void func()//生成随机数
{
srand(time(nullptr));
if (rand() % 3 == 0)
{
throw exception("异常");
}
else
cout << "func()" << endl;
}
int main()
{
int n = 10;
mutex mtx;
thread t1([n, &x, &mtx]() {
try {
for (int i = 0; i < n; i++)
{
//mtx.lock();
LockGuard<mutex> lg(mtx);
func();
//mtx.unlock();
}
}
catch (const exception& e)
{
cout << e.what() << endl;
}
});
t1.join();
cout << x << endl;
return 0;
}

mutex 库中也提供了 lock_guard,只支持构造和析构, 通过上述代码可以看到,lock_guard类模板主要是通过RAII的方式,对其管理的互斥量进行了封装,在需要加锁的地方,只需要用上述介绍的任意互斥体实例化一个lock_guard,调用构造函数成功上锁,出作用域前,lock_guard对象要被销毁,调用析构函数自动解锁,可以有效避免死锁问题。
lock_guard 的缺陷:太单一,用户没有办法对该锁进行控制,只能通过析构函数进行解锁,因此C++11又提供了 unique_lock。

C++手册链接:unique_lock。unique_lock与 lock_gard 类似,unique_lock 类模板也是采用 RAII 的方式对锁进行了封装,并且也是以独占所有权的方式管理 mutex 对象的上锁和解锁操作,即其对象之间不能发生拷贝。在构造(或移动(move)赋值)时,unique_lock 对象需要传递一个 Mutex 对象作为它的参数,新创建的 unique_lock 对象负责传入的 Mutex 对象的上锁和解锁操作。使用以上类型互斥量实例化unique_lock 的对象时,自动调用构造函数上锁,unique_lock 对象销毁时自动调用析构函数解锁,可以很方便的防止死锁问题。与lock_guard不同的是:unique_lock 更加的灵活,提供了更多的成员函数:上锁/解锁操作:lock、try_lock、try_lock_for、try_lock_until和unlock修改操作:移动赋值、交换(swap:与另一个unique_lock对象互换所管理的互斥量所有权)、释放(release:返回它所管理的互斥量对象的指针,并释放所有权)获取属性:owns_lock(返回当前对象是否上了锁)、operator bool()(与owns_lock()的功能相同)、mutex(返回当前unique_lock所管理的互斥量的指针)。
主要不同:
- 可以与 time_mutex 配合使用
- 支持手动解锁加锁
4.条件变量

条件变量的等待函数中就是使用的 unique_lock,因为内部如果条件变量不满足,就会先 unlock,需要主动申请和释放锁,等待别人通知。

notify_one 唤醒一个。
wait的作用:让当前线程“睡眠”等待,直到收到唤醒信号,并且在等待的瞬间“原子地”释放掉锁。
交替打印奇数偶数,t1先运行。
t2不在条件变量的等待队列中(等不到锁),发生虚拟唤醒,所以t1只运行一次。
int main()
{
mutex mtx;
condition_variable cv;
int n = 100;
//cin >> n;
int x = 1;
thread t1([&]()
{
for (; x < n;)
{
unique_lock<mutex> lg(mtx);
cv.wait(lg);
cout << this_thread::get_id() << ":" << x << endl;
x++;
cv.notify_one();//t1 noticfy_one的时候,t2还没有wait
}
});
thread t2([&]()
{Sleep(500);
for (; x < n;)
{
unique_lock<mutex> lg(mtx);
cv.wait(lg);
cout << this_thread::get_id() << ":" << x << endl;
x++;
cv.notify_one();
}
});
Sleep(250);//确保能唤醒t1
cv.notify_one();
t1.join();
t2.join();
return 0;
}
正确方式:
int main()
{
mutex mtx;
condition_variable cv;
int n = 100;
int x = 1;
bool flag = false;
// condition_variable + flag 保证t1先运行,锁保证 flag
thread t1([&]()
{
for (int i = 0; i < n/2; i++)
//for (; x < n;)//减少共享数据
{
unique_lock<mutex> lg(mtx);
while(flag)
cv.wait(lg);
cout << this_thread::get_id() << ":" << x << endl;
x++;
flag = true;
cv.notify_one();//t1 noticfy_one的时候,t2还没有wait
}
});
thread t2([&]()
{
for (int i = 0; i < n/2; i++)
//for (; x < n;)
{
unique_lock<mutex> lg(mtx);
while(!flag)
cv.wait(lg);
cout << this_thread::get_id() << ":" << x << endl;
x++;
flag = false;
cv.notify_one();
}
});
t1.join();
t2.join();
return 0;
}
场景1:t1 先启动,flag 是 false,无论得没得到锁,通过 while 判断,只能由 t1 先打印 x,flag 改成 true,然后唤醒 t2。
场景2,t2先启动, flag 是 false,也是通过while,实现 t1 先打印,然后唤醒 t2。就算 t1 t2 两个执行时间距离太长,都唤醒不了对方,但有 flag 在 也可以正确实现。
第二种条件变量的等待:
![]()

第二个参数式可以执行对象,返回true时才会继续往下执行。
5. <atomic>
++i 不是原子操作,寄存器的值没执行完会先保存在 PCB 中,所以两个线程执行最后可能只将一个线程中计算的变量的值保存了下来。

临界区资源时间短是不适合使用互斥锁的,会导致线程频繁的加锁和解锁,上下文切换代价大。
mtx.lock();
x++;
mtx.unlock();
自旋方式:只用 try_lock() 抢占cpu,非常消耗cpu时间,可以使用 this_thread::yield() 让一下cpu。
while(mtx.try_lock());
x++;
mtx.unlock();
while(mtx.try_lock())
this_thread::yield();
x++;
mtx.unlock()
atomic 是一个类模板

核心作用是:在多线程环境下,让你对普通变量的操作(如 ++、--、赋值)变成“不可分割”的原子操作,从而完全替代互斥锁(mutex)来保护单个变量,简单的自定义类型也可以实现一些operator++,operator--。
示例:
atomic<int> x = 1; // 替换原来的 int x = 1;
// 在 t1 和 t2 内部,去掉 unique_lock,直接操作 x
for (int i = 0; i < n/2; i++)
{
cout << this_thread::get_id() << ":" << x << endl; //这行只保证从内存x拿数据不会出错,打印可能还会出现乱码
x++; // 这行现在是原子操作,CPU 保证它不会被两个线程同时穿插执行
}


store 与 load:

既然等号(=)就能操作,为什么还要用它们?
因为默认的等号和隐式转换,强制使用了最强的内存顺序(memory_order_seq_cst,顺序一致性)。这虽然最安全,但在某些极简场景下(比如纯计数器累加),会带来不必要的性能开销(CPU 内存屏障指令变多)。
使用 store 和 load ,你可以传第二个参数 (memory_order)来“降级”内存约束:
| 内存顺序 | 适用场景 | 性能 |
|---|---|---|
memory_order_seq_cst(默认) | 默认,最强同步,绝对安全 | 最慢 |
memory_order_acquire | 用在 load 中,保证后续读操作在本次读之后执行 | 中等 |
memory_order_release | 用在 store 中,保证之前写操作在本次写之前完成 | 中等 |
memory_order_relaxed | 纯累加计数器(无需同步其他变量) | 最快(无内存屏障) |
代码对比示例:
std::atomic<int> counter(0);
// 写法一:默认(最安全,但稍慢)
counter++; // 等同于 fetch_add(1, seq_cst)
int val = counter; // 等同于 load(seq_cst)
// 写法二:使用 store/load 优化(高性能)
counter.store(10, std::memory_order_release); // 写入,搭配释放语义
int val = counter.load(std::memory_order_acquire); // 读取,搭配获取语义
// 写法三:纯计数器,不依赖其他变量(极速)
counter.fetch_add(1, std::memory_order_relaxed); // 累加
CAS (compare and swap),无锁编程, 是原子操作的一种,意思就是看内存 *reg 里的值是不是 oldval ,如果是的话,这对其赋值。
++x 变成原子操作底层大概实现方法,这些步骤是一下执行完的,即CAS是原子的,不会被打断。

CAS 下图所示的无锁链表的尾插操作:


p 指向队列的尾端,新节点的创建没有线程安全问题,因为new是安全的,n 是局部变量。当 p->next 为 NULL 时,我们 t1 线程便可以进行尾插返回真,但此时如果另一个线程 t2 也创建好节点要尾插,会发现 p->next 已经被插入不是 NULL,CAS()返回假,会循环等待,只有 t1 执行完下一句 CAS(Q->next,p,n) 后 p->next 又从新变为 NULL t2 才能进行插入。
C++方式实现 x++ 无锁编程
int main()
{
int n1 = 10000;
int n2 = 10000;
atomic<size_t> x = 0;
thread t1([n1, &x]()
{
for (size_t i = 0; i < n1; i++)
{
size_t old;
size_t newval;
do
{
old = x;
newval = old + 1;
} while (!atomic_compare_exchange_weak(&x, &old,newval));
}
});
thread t2([n1, &x]()
{
for (size_t i = 0; i < n1; i++)
{
size_t old;
size_t newval;
do
{
old = x;
newval = old + 1;
} while (!atomic_compare_exchange_weak(&x, &old, newval));
}
});
t1.join();
t2.join();
cout << x << endl;
return 0;
}
6. shared_ptr 实现线程安全:
通过将引用计数设计成原子操作,new 时也要写对正确的类型。引用计数++ ,不是原子的,最后析构次数和构造次数就会对不上。通过atomic可以实现,当然也可以使用锁控制每一处计数操作。
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new atomic<int>(1))//保证原子操作
{
}
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new atomic<int>(1))
, _del(del)//包装器,传入一个函数对象,调用时会调用这个函数对象
{
}
private:
T* _ptr;
//int* _pcount;
atomic<int>* _pcount;//原子操作
function<void(T*)> _del = [](T* ptr) {delete ptr; };//传入指针调用,参数是T*
};
shared_ptr本身是线程安全的,内部实现好了,保护的资源不一定是线程安全的,管不了,需要另外对数据也保护。例如:
void test_shared_ptr()
{
int n1 = 10000;
int n2 = 10000;
mutex mtx;
atomic<size_t> x = 0;
xlh::shared_ptr<double> sp(new double(1));
thread t1([&]()
{
for (size_t i = 0; i < n1; i++)
{
shared_ptr<double> copy(sp);//引用计数++ ,不是原子的,最后析构次数就会对不上。通过atomic可以实现,也可以上锁
{
unique_lock<mutex> lock(mtx);
++(*copy);//对数据也加锁
}
}
});
thread t2([&]()
{
for (size_t i = 0; i < n2; i++)
{
shared_ptr<double> copy(sp);
{
unique_lock<mutex> lock(mtx);//自动锁
++(*copy);
}
}
});
t1.join();
t2.join();
cout << sp.getpcount() << endl;
cout << *sp << endl;
}

getpcount = 1 是因为两个线程都以成功退出,只剩一个指 向的指针。
7. 懒汉模式中的线程安全问题:
需要加锁保护 _inst,也需要在外面再加一层判断,防止每次都申请锁。
static B* GetInstance() //static 才能类外调用,也可以返回引用
{
if (_inst == nullptr)
{
unique_lock<mutex> lg(mtx);//锁是static类型
if (_inst == nullptr)
_inst = new B;
}
return _inst;
}
单例模式最简单的写法:
class Singleton
{
public:
static Singleton& GetInstance()
{
static Singleton inst;//局部的静态对象是在第一次调用时初始化
//c++11 之前这个代码不安全
//之后可以保证局部静态对象的初始化时线程安全的。
return inst;
}
private:
Singleton() { cout << "Singleton" << endl;
_n++;
};
//只有在第一次调用创建对象的时候_n++;线程安全
Singleton(const Singleton& aa) = delete;
Singleton& operator=(const Singleton& aa) = delete;
int _n;
};
c++11 之前这个代码不安全,之后可以保证局部静态对象的初始化时线程安全的。所以c++11之后,懒汉模式都是这么写的。
本篇结束!

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)