一、防止恶性条件竞争
1.数据间的共享问题
竞争状态:多线程同时读写共享数据
临界区:读写共享数据的代码片段,对于共享数据同时只能有一个线程访问
防止恶性条件竞争:
- 上锁 采取保护措施包装数据结构,确保不变量被破坏时,中间状态只对执行改动的线程可见
无锁编程
修改数据结构的设计及其不变量,由一连串不可拆分的改动完成数据变更,每个改动都维持不变量不被破坏。
将修改数据结构当作事务(transaction)来处理
2.mutex互斥锁
1)mutex互斥锁
mutex mtxt1;
int shared_data = 100;
void use_lock() {
while (true) {
mtxt1.lock();
shared_data++;
cout << this_thread::get_id() << " , " << shared_data<<endl;
this_thread::sleep_for(chrono::seconds(1));
mtxt1.unlock();
this_thread::sleep_for(chrono::milliseconds(10)); //防止恶性循环抢占资源,睡眠10ms给cpu留出充足时间释放时间片
}
}
void Text1() {
thread t1(use_lock);
t1.detach();
thread t2([]() { //隐式转换
while (true) {
mtxt1.lock();
shared_data--;
cout << this_thread::get_id() << " , " << shared_data << endl;
this_thread::sleep_for(chrono::seconds(1));
mtxt1.unlock();
this_thread::sleep_for(chrono::milliseconds(10));
}
});
t2.detach();
}
int main() {
Text1();
this_thread::sleep_for(chrono::seconds(10));
return 0;
}
//结果可以看到两个线程对于共享数据的访问是独占的,谁先把shared_data锁上,谁就访问,单位时间片只有一个线程访问并输出日志。
1)坑(死锁)示例
mutex mux;
void Fun(int i) {
for (;;) {
mux.lock();
cout << this_thread::get_id() << " 进入 ";
cout << i << endl;
this_thread::sleep_for(chrono::seconds(1)); //休眠目的是给主线程让出一点时间,继续创建其他线程,
//但此时这样会有一个坑,有可能主线程创建的子线程创建出来后抢占不到资源
mux.unlock();
}
}
int main() {
for (int i = 1; i <= 3; i++) {
thread th(Fun,i);
th.detach();
this_thread::sleep_for(chrono::seconds(1)); //防止主线程退出过快
}
return 0;
}
//可能的结果是某个线程一直抢占资源,不退出
//原因:拿第一个线程举例,1在临界区后,2、3可能现在都在mux.lock()处阻塞,但当1执行完
//mux.unlock()时,(可能操作系统立即释放临界区资源与无限循环的时间先后)因为是for循环,1自己又申请了lock(),其他线程就还是在阻塞中
在unlock后面睡眠1ms,给操作系统多留出一点时间释放临界区资源,可以尽量避免死锁
②避免长时间死锁
1.try_lock()
try_lock
是一种非阻塞的锁获取方式。如果锁当前不可用(即已被其他线程持有),它将立即返回,而不会等待
2.timed_mutex.try_lock_for() 超时锁
也是一种非阻塞的锁获取方式,如果当前锁不可用,等待一个指定的时间段后,再返回,如果在这个时间段内锁可用,则获取锁;若超时仍未获取到锁,返回 false
。
mutex mux;
void Fun(int i) {
for (;;) {
mux.lock();
cout << this_thread::get_id() << " 进入 ";
cout << i << endl;
this_thread::sleep_for(chrono::seconds(1));
mux.unlock();
this_thread::sleep_for(1ms); //留出1ms,给操作系统释放临界资源时间,
}
}
timed_mutex tmu;
void Text_tmu(int i) {
for (;;) {
if (!tmu.try_lock_for(chrono::milliseconds(500))) { //
cout << i << "超时" << endl;
continue;
}
cout << i << "抢占到资源" << endl;
this_thread::sleep_for(chrono::seconds(1)); //睡眠一段时间,缓慢向屏幕输出
tmu.unlock();
this_thread::sleep_for(1ms);
}
}
int main() {
for (int i = 1; i <= 3; i++) {
thread th(Text_tmu, i);
th.detach();
}
this_thread::sleep_for(chrono::seconds(1));
getchar();
for (int i = 1; i <= 3; i++) {
cout << "。。。。。。。。。。。。。。。。。" << endl;
thread th(Fun,i);
th.detach();
}
this_thread::sleep_for(chrono::seconds(5));
return 0;
}
3.递归锁(可重入)recursive_mutex和递归超时锁recursive_timed_mutex用于业务组合
递归锁 (
recursive_mutex
)定义与特性:
recursive_mutex
是一种特殊类型的互斥锁,允许同一线程多次获取该锁而不导致死锁。- 每当一个线程成功地获得锁时,内部计数器会增加,当它释放锁时,计数器会减少。只有当计数器降到零时,锁才被完全释放。
递归超时锁则是在递归锁基础上加上了时间段
3.lock_guard的使用
锁的管控(守卫),lock_guard
在作用域结束时自动调用其析构函数解锁,这么做的一个好处是简化了一些特殊情况从函数中返回的写法,比如异常或者条件不满足时,函数内部直接return,锁也会自动解开。
void Text1() {
thread t1(use_lock);
t1.detach();
thread t2([]() {
while (true) {
{
lock_guard<mutex>lock(mtxt1); //创建时就自动加锁了
shared_data--;
cout << this_thread::get_id() << " , " << shared_data << endl;
this_thread::sleep_for(chrono::seconds(1));
} //出了这个局部作用域后就自动解锁
this_thread::sleep_for(chrono::milliseconds(10));
}
});
t2.detach();
}
4.如何保证数据安全
1)常见错误1
意外地向外传递引用,指向受保护共享数据
class some_data
{
int a;
std::string b;
public:
void do_something();
};
class data_wrapper
{
private:
some_data data;
std::mutex m;
public:
template<typename Function>
void process_data(Function func)
{
std::lock_guard<std::mutex> l(m);
func(data); ⇽--- ①向使用者提供的函数传递受保护共享数据
}
};
some_data* unprotected; //指向了受保护的变量
void malicious_function(some_data& protected_data)
{
unprotected=&protected_data;
}
data_wrapper x;
void foo()
{
x.process_data(malicious_function); ⇽--- ②传入恶意函数
unprotected->do_something(); ⇽--- ③以无保护方式访问本应受保护的共享数据
}
2)常见错误2
当我们对一份共享数据进行操作时,会为其上锁,在锁内操作是安全的,但是返回值抛给外边使用,就存在不安全性,因为此时已经出了作用域。以stack为例,虽然empty和 pop
都在各自的作用域内加锁,但在两个线程之间相互交错执行时,栈的状态可能会发生瞬间变化,导致判空不准确,尤其是栈中只有一个元素这种情况,会引发崩溃
template<typename T>
class threadsafe_stack1 {
public:
threadsafe_stack1(){}
threadsafe_stack1(const threadsafe_stack1& other) {
std::lock_guard<mutex> lock(other.m); //对要copy过来的stack上锁
data = other.data;
}
threadsafe_stack1& operator=(const threadsafe_stack1& other) = delete;//不允许拷贝赋值,因为拷贝赋值会将锁copy过来
void push(T new_value) {
std::lock_guard<mutex>lock(m);
data.push(std::move(new_value)); //move减少一次拷贝构造
}
//problem
T pop() {
std::lock_guard<mutex>lock(m);
auto element = data.top(); //取出栈顶元素
data.pop();
return element;
}
//危险
bool empty() const {
std::lock_guard<mutex> lock(m);
return data.empty();
}
private:
std::stack<T> data;
mutable std::mutex m; //可变参数,在对于栈的判空读操作函数中,我们可以用const函数,但在多线程中需要对这部分数据读进行加锁,加锁则是改变const成员函数
//所以需要对于锁这个实例是可变的,这样才能在常量成员函数中加锁
};
void text_threadsafe_stack1() {
threadsafe_stack1<int> safe_stack;
safe_stack.push(1);
std::thread t1([&safe_stack]() {
if (!safe_stack.empty()) {
std::cout << "t1线程判断stack不为空" << std::endl;
this_thread::sleep_for(chrono::seconds(1)); //模拟处理业务逻辑
safe_stack.pop();
std::cout << "t1出栈" << std::endl;
}
});
std::thread t2([&safe_stack]() {
if (!safe_stack.empty()) {
std::cout << "t2线程判断stack不为空" << std::endl;
this_thread::sleep_for(chrono::seconds(1));
safe_stack.pop();
std::cout << "t2出栈" << std::endl;
}
});
t1.join();
t2.join();
}
int main() {
//Text1();
text_threadsafe_stack1();
return 0;
}
两个线程可能同时判断不为空,同时pop最后一个元素,此时就会引发崩溃。归根结底就是判空时返回值使用时机造成的问题,因为返回值在没有锁的情况下,可能会延迟使用,所以会造成一些问题。
解决这个问题我们可以抛异常,比如定义一个空栈的异常
自定义异常
struct empty_stack :std::exception {
const char* what() const noexcept override {
return "empty stack";
}
};
修改后的pop
T pop() {
std::lock_guard<mutex>lock(m);
if (data.empty()) {
throw empty_stack();
}
auto element = data.top();
data.pop();
return element;
}
text程序
void text_threadsafe_stack() {
threadsafe_stack<int> safe_stack;
safe_stack.push(1);
std::thread t1([&safe_stack]() {
try {
if (!safe_stack.empty()) {
std::cout << "t1线程判断stack不为空" << std::endl;
this_thread::sleep_for(chrono::seconds(1));
safe_stack.pop();
std::cout << "t1出栈" << std::endl;
}
}
catch (const std::exception& e) {
std::cout <<"t1抛出异常 "<< e.what();
std::cout << "\n";
}
});
std::thread t2([&safe_stack]() {
try {
if (!safe_stack.empty()) {
std::cout << "t2线程判断stack不为空" << std::endl;
this_thread::sleep_for(chrono::seconds(1));
safe_stack.pop();
std::cout << "t2出栈" << std::endl;
}
}
catch (const std::exception& e) {
std::cout <<"t2抛出异常: " << e.what();
std::cout << "\n";
}
});
t1.join();
t2.join();
}
测试运行结果:
这里只改了一个问题隐患,现在这个pop
函数仍存在问题,如果T是一个很大的数据类型,在返回时会做一个拷贝,因为数据过大,可能内存不够用溢出,造成拷贝时无效,但数据已经被pop掉了,这就会造成想要返回的数据丢失,可能会造成程序崩溃。
-
一个函数返回一个对象时,可能会以拷贝构造传递这个对象: 返回一个对象会导致调用拷贝构造函数来创建返回值的新副本。例如,你的
pop()
函数返回T
类型的对象,在返回过程中,可能会导致拷贝构造的调用。 -
T pop() {
std::lock_guard<std::mutex> lock(m);
if (data.empty())
{ throw empty_stack(); }
T element = data.top(); // 这里可能会触发拷贝构造
data.pop();
return element; // 这一步也可能会触发拷贝构造
}
如何避免拷贝
为了避免这种情况,可以考虑以下几种方法:
1.使用移动语义:采用 std::move 来显式地移动对象,而不是拷贝它,
这是 C++11 引入的一种机制。这样就能有效地避免拷贝。
T pop() {
std::lock_guard<std::mutex> lock(m);
if (data.empty()) {
throw empty_stack();
}
T element = std::move(data.top()); // 使用移动,避免拷贝
data.pop();
return element; // 返回移动对象
}
2.返回 std::shared_ptr<T> 或 std::unique_ptr<T>:这样可以避免直接拷贝对象,
同时确保对象的生命周期管理更安全。
std::shared_ptr<T> pop() {
std::lock_guard<std::mutex> lock(m);
if (data.empty()) {
throw empty_stack();
}
auto element = std::make_shared<T>(std::move(data.top())); // 返回指向对象的智能指针
data.pop();
return element;
}
比如T
是一个vector<int>
类型,那么在pop
函数内部element
就是vector<int>
类型,开始element
存储了一些int值,程序没问题,函数执行pop操作, 假设此时程序内存暴增,导致当程序使用的内存足够大时,可用的有效空间不够, 函数返回element
时,当我们尝试为它的副本分配内存时,系统可能没有足够的可用内存,这会导致 std::bad_alloc
异常。就会就会存在vector
做拷贝赋值时造成失败。即使我们捕获异常,释放部分空间但也会导致栈元素已经出栈,数据丢失了。这其实是内存管理不当造成的,但是C++ 并发编程一书中给出了优化方案。
第一种方案 引用:
void pop(T& value)
{
std::lock_guard<std::mutex> lock(m);
if (data.empty()) throw empty_stack();
value = data.top();
data.pop();
}
第二种方案 使用智能指针:
//智能指针
std::shared_ptr<T> pop() {
std::lock_guard<mutex>lock(m);
if (data.empty()) {
throw empty_stack();
}
std::shared_ptr<T> const res(std::make_shared<T>(data.top()));
data.pop();
return res;
}