共享数据

共享数据#

避免恶性条件竞争#

  • 最简单的方式是对数据结构采取某种保护机制,比如上锁。
  • 另一种方式是修改数据结构,保证修改后的数据结构能够完成一系列的不可分割的变化,从而保证了不变量的状态,也就是所谓的无锁编程。

互斥量#

c++标准库提供了std::mutex,可以很方便的进行上锁操作。大部分情况下只需要注意不要在逻辑代码中循环上锁就可以了(避免死锁),但也不是真的万事大吉了,如果函数返回的是受保护数据的指针或者引用,那么仍然有无视锁直接修改数据的风险。因为返回的指针和引用可能有访问受保护数据的权限,这就取决于代码写的是否严谨了。可以说,涉及到并发编程时,总是需要更加的小心和谨慎。

参考书中例子给出的一个通过传递引用而导致越过锁直接访问受保护数据的例子。

struct Data {
    int num{ 42 };
    std::string str{"hello world"};

    void doSomething() {
        // ...
    }
};

class DataWrapper {
public:
    template<typename Foo> 
    void processData(Foo func) {
        std::lock_guard<std::mutex> lg(m_mtx);
        func(m_data);   // 本意是通过上锁来保护数据
    }

private:
    std::mutex m_mtx;
    Data m_data;
};

Data* dangerous_ptr;

void malicious_function(Data& data) {
    dangerous_ptr = &data;
}

DataWrapper x;

void dangerousCall {
    x.processData(malicious_function);  // 传递了一个恶意函数
    dangerous_ptr->doSomething();   // 实际上锁完全没有起到保护作用,指针无视了锁
}

dangerousCall函数中,看似数据受到了保护,实际上通过引用和指针,已经将数据的地址传递到了外部,在外面完全可以不通过锁就能操作受保护数据。使用书中的一句话来总结:

比起在没有互斥量保护的区域内存储指针和引用,更危险的行为是,将受保护的数据作为一个运行时参数进行传递。

避免死锁#

  • 避免嵌套锁:如果线程已经获取了一个锁,就尽量别再去获取第二个了。
  • 避免在持有锁时,调用外部代码。
  • 使用固定的顺序来获取锁。
  • 使用层次锁结构。

共享数据初始化的保护方式#

上锁是通用的机制#

有时,你只是想初始化一个共享数据,而这个共享数据的构造代价可能很大,一般会采取一种延迟初始化的方式,即在需要使用时判断一下数据是否已经被初始化了。如果想当然地在判断前加了锁,则会导致线程间不再是并行的,这可能与我们的意图相背,我们本身的意图只是在数据的初始化时保护一下。

std::shared_ptr<Recource> resource_ptr;
std::mutex mtx;

void foo() {
    std::unique_lock<std::mutex> ul(mtx);
    if(!resource_ptr) {
        resource_ptr.reset(new Recource);   // 只是想保护初始化过程,而不是让线程按顺序进行判断,显然是低效的
    }
    resource_ptr->doSomething();
}

“臭名昭著”的双重检查**#

既然上述做法是不太好的,于是有人想出了双重检验的方法。

std::shared_ptr<Recource> resource_ptr;
std::mutex mtx;

void foo() {
    if(!resource_ptr) {
        std::unique_lock<std::mutex> ul(mtx);
        if(!resource_ptr) {
            resource_ptr.reset(new Recource); 
        }
    }
    resource_ptr->doSomething();
}

这样做,保证了线程的并发,而不是按顺序进行判断。在上锁之后,再判断一下是否在上锁阶段共享数据已经被初始化了。看起来不错,但仍然是有潜在的条件竞争问题,原因在于,第一次的读取判断操作没有和reset的写入操作同步,也就是说,可能共享数据在刚刚初始化好,还没来得及同步时,正好另一个线程在执行第一次的判断,发现数据没有被初始化,走了上锁的逻辑。

once_flag和call_once#

为此,c++提供了std::once_flagstd::call_once来处理这种情况,call_once保证只会被执行一次,相比显式地使用互斥量,这种方式能提供更高效的处理方式。

std::shared_ptr<Recource> resource_ptr;
std::once_flag flag;

void init() {
    resource_ptr.reset(new Recource);
}

void foo() {
    std::call_once(flag, init);
    resource_ptr->doSomething();
}

call_once还可以作为一种类成员线程安全的延迟初始化方式。

class X {
public:
    X(const connection_info& detail) 
        : connect_details(detail) 
    {}

    void send_data(const data_packet& dp) {
        std::call_once(init_flag, &X::open_connection, this)
        connection.sendData(dp);
    }

    void recv_data(const data_packet& dp) {
        std::call_once(init_flag, &X::open_connection, this)
        connection.recvData(dp);
    }

private:
    void open_connection() {
        connection = connection_manager.open(connection_details);
    }

private:
    connection_info connection_details;
    connection_handle connection;
    std::once_flag init_flag;
};

只有在第一次发送或接收时才会初始化连接,而且保证了只会初始化一次。

作者:cwtxx

出处:https://www.cnblogs.com/cwtxx/p/18718196

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   cwtxx  阅读(1)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示