线程安全设置

Thread-safe Settings

本文关键字:设置 安全 线程      更新时间:2023-10-16

我正在编写一些可以从我的多线程应用程序中任何地方访问的设置类。我会经常阅读这些设置(因此读取访问应该很快),但它们并不经常写入。

对于原始数据类型,看起来boost::atomic提供了我需要的东西,所以我想出了这样的东西:

class UInt16Setting
{
    private:
        boost::atomic<uint16_t> _Value;
    public:
        uint16_t getValue() const { return _Value.load(boost::memory_order_relaxed); }
        void setValue(uint16_t value) { _Value.store(value, boost::memory_order_relaxed); }
};

问题 1:我不确定内存排序。我认为在我的应用程序中,我并不真正关心内存排序(是吗?我只想确保getValue()始终返回一个未损坏的值(旧值或新值)。那么我的内存排序设置是否正确?

问题 2:是否建议将此方法使用boost::atomic用于此类同步?或者是否有其他结构可以提供更好的读取性能?

我的应用程序中还需要一些更复杂的设置类型,例如std::stringboost::asio::ip::tcp::endpoint列表。我认为所有这些设置值都是不可变的。因此,一旦我使用 setValue() 设置值,值本身(std::string或端点列表本身)就不再更改。因此,我只想确保我得到旧值或新值,而不是一些损坏的状态。

问题3:这种方法适用于boost::atomic<std::string>吗?如果没有,还有什么替代方案?

问题 4:更复杂的设置类型(如端点列表)怎么样? 你会推荐像boost::atomic<boost::shared_ptr<std::vector<boost::asio::ip::tcp::endpoint>>>这样的东西吗?如果没有,还有什么更好的呢?

Q1,如果您在读取原子后不尝试读取任何共享的非原子变量,请更正。内存屏障仅同步对原子操作之间可能发生的非原子变量的访问

Q2 我不知道(但见下文)

Q3 应该可以工作(如果编译)。然而

 atomic<string> 

可能没有锁定

Q4 应该可以工作,但同样,实现不可能是无锁的(实现无锁shared_ptr具有挑战性且需要专利挖掘)。

因此,如果您的配置包含大小超过 1 个机器字的数据(CPU 本机原子通常适用于此),则读者-写入器锁定(正如 Damon 在评论中建议的那样)可能会更简单,甚至更有效

[编辑]但是,

atomic<shared_ptr<TheWholeStructContainigAll> > 

即使无锁也可能有一定的意义:这种方法最大限度地减少了需要多个相干值的读取器的冲突概率,尽管编写器应该在每次更改某些内容时制作整个"参数表"的新副本。

对于问题 1,答案是"取决于,但可能不是"。如果你真的只关心单个值没有乱码,那么是的,这很好,你也不关心内存顺序。
不过,通常情况下,这是一个错误的前提。

对于问题 234,是的,这将起作用,但它可能会对复杂对象(如 string)使用锁定(在内部,对于每次访问,在您不知情的情况下)。通常只有大约一个或两个指针大小的相当小的对象才能以无锁方式原子方式访问/更改。这也取决于您的平台。

一个人是否以原子方式成功更新一个或两个值是一个很大的区别。假设您有值 leftright,它们分隔了任务将在数组中执行某些处理的位置的左右边界。假设它们分别为 50 和 100,然后将它们分别更改为 101 和 150,每个原子。因此,另一个线程拾取从 50 到 101 的变化并开始计算,看到 101> 100,完成并将结果写入文件。之后,再次以原子方式更改输出文件的名称。
一切都是原子的(因此比正常情况贵),但没有一个是有用的。结果仍然是错误的,并且也被写入了错误的文件。
在您的特定情况下,这可能不是问题,但通常是(并且,您的要求将来可能会发生变化)。通常,您真的希望完整的更改集是原子的。

也就是说,如果你有很多或复杂的(

或者,许多和复杂的)更新要做,你可能首先希望对整个配置使用一个大的(读取器-写入器)锁,因为这比获取和释放 20 或 30 个锁或执行 50 或 100 个原子操作更有效。但请注意,在任何情况下,锁定都会严重影响性能。

正如上面的评论中所指出的,我最好从修改配置的一个线程中制作配置的深层副本,并将消费者使用的引用(共享指针)的更新作为正常任务。这种复制-修改-发布方法也与 MVCC 数据库的工作方式有点相似(这些数据库也存在锁定会降低其性能的问题)。

修改副本断言只有读取器访问任何共享状态,因此读取器或单个写入器都不需要同步。阅读和写作很快。交换配置集仅在保证配置集处于完整、一致的状态并且线程保证不执行其他操作时明确定义的点进行,因此不会发生任何类型的丑陋意外。

典型的任务驱动应用程序看起来有点像这样(在类似C++伪代码中):

// consumer/worker thread(s)
for(;;)
{
    task = queue.pop();
    switch(task.code)
    {
        case EXIT:
            return;
        case SET_CONFIG:
            my_conf = task.data;
            break;
        default:
            task.func(task.data, &my_conf); // can read without sync
    }
}

// thread that interacts with user (also producer)
for(;;)
{
    input = get_input();
    if(input.action == QUIT)
    {
        queue.push(task(EXIT, 0, 0));
        for(auto threads : thread)
            thread.join();
        return 0;
    }
    else if(input.action == CHANGE_SETTINGS)
    {
        new_config = new config(config); // copy, readonly operation, no sync
        // assume we have operator[] overloaded
        new_config[...] = ...;           // I own this exclusively, no sync
        task t(SET_CONFIG, 0, shared_ptr<...>(input.data));
        queue.push(t);
    }
    else if(input.action() == ADD_TASK)
    {
        task t(RUN, input.func, input.data);
        queue.push(t);
    }
    ...
}

对于比指针更重要的任何内容,请使用互斥锁。tbb(开源)库支持读取器-写入器突变的概念,它允许多个同时读取器,请参阅文档。