【问题标题】:C++ Thread Safe IntegerC++ 线程安全整数
【发布时间】:2010-04-28 12:43:41
【问题描述】:

我目前已经为线程安全的整数创建了一个 C++ 类,它只是私有地存储一个整数,并有一个公共的 get 一组函数,这些函数使用 boost::mutex 来确保一次只能将一个更改应用于整数.

这是最有效的方法吗,我被告知互斥体非常耗费资源?该类被大量使用,非常迅速,因此很可能成为瓶颈......

Googleing C++ 线程安全整数返回对不同架构上整数运算的线程安全性的不清楚的看法和意见。

有人说 32 位架构上的 32 位 int 是安全的,但 32 上的 64 不是由于“对齐”而其他人说它是编译器/操作系统特定的(我不怀疑)。

我在 32 位机器上使用 Ubuntu 9.10,有些机器有双核,因此在某些情况下线程可能会在不同的内核上同时执行,我使用的是 GCC 4.4 的 g++ 编译器。

提前谢谢...

请注意: 我标记为“正确”的答案最适合我的问题 - 但是其他答案中也有一些优秀的观点,它们都值得一读!

【问题讨论】:

标签: c++ multithreading integer thread-safety


【解决方案1】:

有 C++0x 原子库,还有一个正在开发中的使用无锁技术的 Boost.Atomic 库。

【讨论】:

    【解决方案2】:

    它不是特定于编译器和操作系统的,而是特定于体系结构的。编译器和操作系统加入其中是因为它们是您使用的工具,但它们不是设置真正规则的工具。这就是 C++ 标准不会触及这个问题的原因。

    我一生中从未听说过 64 位整数写入,它可以分成两个 32 位写入,在中途被中断。 (是的,这是邀请其他人发布反例。)具体来说,我从未听说过 CPU 的加载/存储单元允许中断未对齐的写入。中断源必须等待整个未对齐的访问完成。

    要拥有一个可中断的加载/存储单元,它的状态必须保存到堆栈中……而加载/存储单元将 CPU 的其余状态保存到堆栈中。如果加载/存储单元是可中断的,这将非常复杂,并且容易出错......您将获得的只是响应中断的延迟减少一个周期 ,充其量是在几十个周期内测量的。完全不值得。

    早在 1997 年,我和一位同事编写了一个用于多处理系统的 C++ 队列模板。 (每个处理器都有自己的操作系统运行,以及自己的本地内存,因此这些队列仅用于处理器之间共享的内存。)我们想出了一种方法,通过单个整数写入使队列更改状态,并将此写入视为原子操作。此外,我们要求队列的每一端(即读取或写入索引)由一个且仅一个处理器拥有。十三年后,代码仍然运行良好,我们甚至有一个可以处理多个阅读器的版本。

    不过,如果您想将 64 位整数写入视为原子,请将字段对齐到 64 位边界。为什么要担心?

    编辑:对于您在评论中提到的情况,我需要更多信息才能确定,所以让我举一个例子,说明无需专门的同步代码即可实现。

    假设你有 N 个作者和一个读者。您希望作者能够向读者发送事件信号。事件本身没有数据;你只需要一个事件计数,真的。

    为共享内存声明一个结构,在所有写入者和读取者之间共享:

    #include <stdint.h>
    struct FlagTable
    {   uint32_t flag[NWriters];
    };
    

    (将其设为类或模板或您认为合适的任何内容。)

    需要告诉每个写入者它的索引并给它一个指向该表的指针:

    class Writer
    {public:
        Writer(FlagTable* flags_, size_t index_): flags(flags_), index(index_) {}
        void SignalEvent(uint32_t eventCount = 1);
    private:
        FlagTable* flags;
        size_t index;
    }
    

    当作者想要发出一个(或多个)事件的信号时,它会更新它的标志:

    void Writer::SignalEvent(uint32_t eventCount)
    {   // Effectively atomic: only one writer modifies this value, and
        // the state changes when the incremented value is written out.
        flags->flag[index] += eventCount;
    }
    

    阅读器保留它所看到的所有标志值的本地副本:

    class Reader
    {public:
        Reader(FlagTable* flags_): flags(flags_)
        {   for(size_t i = 0; i < NWriters; ++i)
                seenFlags[i] = flags->flag[i];
        }
        bool AnyEvents(void);
        uint32_t CountEvents(int writerIndex);
    private:
        FlagTable* flags;
        uint32_t seenFlags[NWriters];
    }
    

    要找出是否发生了任何事件,它只查找更改的值:

    bool Reader::AnyEvents(void)
    {   for(size_t i = 0; i < NWriters; ++i)
            if(seenFlags[i] != flags->flag[i])
                return true;
        return false;
    }
    

    如果发生了什么事,我们可以检查每个来源并获取事件计数:

    uint32_t Reader::CountEvents(int writerIndex)
    {   // Only read a flag once per function call.  If you read it twice,
        // it may change between reads and then funny stuff happens.
        uint32_t newFlag = flags->flag[i];
        // Our local copy, though, we can mess with all we want since there
        // is only one reader.
        uint32_t oldFlag = seenFlags[i];
        // Next line atomically changes Reader state, marking the events as counted.
        seenFlags[i] = newFlag;
        return newFlag - oldFlag;
    }
    

    现在这一切的大问题是什么?它是非阻塞的,也就是说你不能让 Reader 休眠,直到 Writer 写东西。 Reader 必须在等待AnyEvents() 返回true 的自旋循环中做出选择,这可以最大限度地减少延迟,或者它可以每次都休眠一点,这可以节省 CPU,但可能会堆积很多事件。所以有总比没有好,但不是万能的。

    使用实际的同步原语,只需要用互斥锁和条件变量包装这段代码以使其正确阻塞:Reader 会休眠直到有事可做。由于您对标志使用了原子操作,因此您实际上可以将互斥锁锁定的时间保持在最低限度:编写器只需将互斥锁锁定足够长的时间即可发送条件,而无需设置标志,而读取器只需在调用AnyEvents() 之前等待条件(基本上,它就像上面的睡眠循环案例,但使用等待条件而不是睡眠调用)。

    【讨论】:

    • 我目前使用的是 32 位整数,但您能否详细说明“将字段对齐到 64 位边界”的含义。不知道这是否阐明了我的需求,但是:有多个线程写入(递增)和一个阅读器。
    • 如果你有一个 64 位整数,那么你想将它对齐到一个 64 位边界,即可以被sizeof(uint64_t) 整除的字节地址(或者你想要对齐的任何东西) .有关详细信息,请参阅en.wikipedia.org/wiki/Data_structure_alignment
    • Sweet... 我扩大了我的答案并在没有解释的情况下被否决了。
    • 有了更多细节,这更容易理解和实施 - 谢谢!
    • 我知道这个答案很旧,但它是错误的(并且在搜索结果中显示很高)。如果有多个 CPU,则无需中断指令即可发生问题。如果两个单独的 CPU 启动 SignalEvent,它们可能都加载、计算、然后都存储,从而导致一个标志被覆盖。即使在单个现代 CPU 上,指令也可以重新排序并阻止算法正常工作,即使它们被正确编码。 Memory Fences 或 Atomic Compare and Swaps 是使此类简单算法发挥作用的现代工具。
    【解决方案3】:

    C++ 没有真正的原子整数实现,大多数常用库也没有。

    请考虑这样一个事实,即即使存在上述实现,它也必须依赖某种互斥锁 - 因为您无法保证跨所有架构的原子操作。

    【讨论】:

      【解决方案4】:

      当您使用 GCC 时,根据您想要对整数执行的操作,您可能会选择 GCC's atomic builtins

      这些可能比互斥锁快一点,但在某些情况下仍然比“正常”操作慢很多。

      【讨论】:

        【解决方案5】:

        正如其他人已经提到的,对于完整的通用同步,非常需要传统的同步工具。但是,对于某些特殊情况,可以利用硬件优化。具体来说,大多数现代 CPU 支持整数的原子递增和递减。 GLib 库对此有很好的跨平台支持。本质上,该库为这些操作包装了 CPU 和编译器特定的汇编代码,并在它们不可用的地方默认使用互斥保护。它当然不是很通用,但如果您只对维护计数器感兴趣,这可能就足够了。

        【讨论】:

          【解决方案6】:
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2023-04-07
          • 2015-08-11
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多