【问题标题】:Is this C++ implementation for an Atomic float safe?这个用于原子浮点的 C++ 实现安全吗?
【发布时间】:2010-09-19 12:31:25
【问题描述】:

编辑:这里的代码仍然有一些错误,它可以在性能部门做得更好,但我没有尝试修复这个问题,为了记录,我把问题交给了英特尔讨论组并获得了很多很好的反馈,如果一切顺利,Atomic float 的完善版本将包含在英特尔线程构建模块的近期版本中

好的,这是一个棘手的问题,我想要一个原子浮点数,不是为了超快的图形性能,而是作为类的数据成员常规使用。而且我不想为在这些类上使用锁付出代价,因为它没有为我的需求提供额外的好处。

现在使用英特尔的 tbb 和我见过的其他原子库,支持整数类型,但不支持浮点。所以我继续实施了一个,它有效......但我不确定它是否真的有效,或者我很幸运它有效。

这里有人知道这不是某种形式的线程异端吗?

typedef unsigned int uint_32;

  struct AtomicFloat
  {
    private:
    tbb::atomic<uint_32> atomic_value_;

    public:
    template<memory_semantics M>
    float fetch_and_store( float value ) 
    {
        const uint_32 value_ = atomic_value_.tbb::atomic<uint_32>::fetch_and_store<M>((uint_32&)value);
        return reinterpret_cast<const float&>(value_);
    }

    float fetch_and_store( float value ) 
    {
        const uint_32 value_ = atomic_value_.tbb::atomic<uint_32>::fetch_and_store((uint_32&)value);
        return reinterpret_cast<const float&>(value_);
    }

    template<memory_semantics M>
    float compare_and_swap( float value, float comparand ) 
    {
        const uint_32 value_ = atomic_value_.tbb::atomic<uint_32>::compare_and_swap<M>((uint_32&)value,(uint_32&)compare);
        return reinterpret_cast<const float&>(value_);
    }

    float compare_and_swap(float value, float compare)
    {
        const uint_32 value_ = atomic_value_.tbb::atomic<uint_32>::compare_and_swap((uint_32&)value,(uint_32&)compare);
        return reinterpret_cast<const float&>(value_);
    }

    operator float() const volatile // volatile qualifier here for backwards compatibility 
    {
        const uint_32 value_ = atomic_value_;
        return reinterpret_cast<const float&>(value_);
    }

    float operator=(float value)
    {
        const uint_32 value_ = atomic_value_.tbb::atomic<uint_32>::operator =((uint_32&)value);
        return reinterpret_cast<const float&>(value_);
    }

    float operator+=(float value)
    {
        volatile float old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<float&>(atomic_value_);
            new_value_ = old_value_ + value;
        } while(compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_);
    }

    float operator*=(float value)
    {
        volatile float old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<float&>(atomic_value_);
            new_value_ = old_value_ * value;
        } while(compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_);
    }

    float operator/=(float value)
    {
        volatile float old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<float&>(atomic_value_);
            new_value_ = old_value_ / value;
        } while(compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_);
    }

    float operator-=(float value)
    {
        return this->operator+=(-value);
    }

    float operator++() 
    {
        return this->operator+=(1);
    }

    float operator--() 
    {
        return this->operator+=(-1);
    }

    float fetch_and_add( float addend ) 
    {
        return this->operator+=(-addend);
    }

    float fetch_and_increment() 
    {
        return this->operator+=(1);
    }

    float fetch_and_decrement() 
    {
        return this->operator+=(-1);
    }
   };

谢谢!

编辑:按照 Greg Rogers 的建议将 size_t 更改为 uint32_t,这样更便携

编辑:为整个事物添加了列表,并进行了一些修复。

更多编辑: 性能方面,使用锁定浮点数进行 5.000.000 += 在我的机器上使用 100 个线程的操作需要 3.6 秒,而我的原子浮点数即使是愚蠢的 do-while 也需要 0.2 秒做同样的工作。因此,> 30 倍的性能提升意味着它是值得的,(这就是问题所在)如果它是正确的。

更多编辑: 正如 Awgn 指出的那样,我的 fetch_and_xxxx 部分都错了。修复了这个问题并删除了我不确定的部分 API(模板化内存模型)。并在运算符+=方面实现了其他操作,避免代码重复

添加:添加了运算符 *= 和运算符 /=,因为没有它们,浮点数就不是浮点数。感谢 Peterchen 的评论,注意到这一点

编辑:最新版本的代码如下(不过我会留下旧版本以供参考)

  #include <tbb/atomic.h>
  typedef unsigned int      uint_32;
  typedef __TBB_LONG_LONG       uint_64;

  template<typename FLOATING_POINT,typename MEMORY_BLOCK>
  struct atomic_float_
  {
    /*  CRC Card -----------------------------------------------------
    |   Class:          atmomic float template class
    |
    |   Responsability: handle integral atomic memory as it were a float,
    |                   but partially bypassing FPU, SSE/MMX, so it is
    |                   slower than a true float, but faster and smaller
    |                   than a locked float.
    |                       *Warning* If your float usage is thwarted by
    |                   the A-B-A problem this class isn't for you
    |                       *Warning* Atomic specification says we return,
    |                   values not l-values. So  (i = j) = k doesn't work.
    |
    |   Collaborators:  intel's tbb::atomic handles memory atomicity
    ----------------------------------------------------------------*/
    typedef typename atomic_float_<FLOATING_POINT,MEMORY_BLOCK> self_t;

    tbb::atomic<MEMORY_BLOCK> atomic_value_;

    template<memory_semantics M>
    FLOATING_POINT fetch_and_store( FLOATING_POINT value ) 
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::fetch_and_store<M>((MEMORY_BLOCK&)value);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    FLOATING_POINT fetch_and_store( FLOATING_POINT value ) 
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::fetch_and_store((MEMORY_BLOCK&)value);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    template<memory_semantics M>
    FLOATING_POINT compare_and_swap( FLOATING_POINT value, FLOATING_POINT comparand ) 
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::compare_and_swap<M>((MEMORY_BLOCK&)value,(MEMORY_BLOCK&)compare);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    FLOATING_POINT compare_and_swap(FLOATING_POINT value, FLOATING_POINT compare)
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::compare_and_swap((MEMORY_BLOCK&)value,(MEMORY_BLOCK&)compare);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    operator FLOATING_POINT() const volatile // volatile qualifier here for backwards compatibility 
    {
        const MEMORY_BLOCK value_ = atomic_value_;
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    //Note: atomic specification says we return the a copy of the base value not an l-value
    FLOATING_POINT operator=(FLOATING_POINT rhs) 
    {
        const MEMORY_BLOCK value_ = atomic_value_.tbb::atomic<MEMORY_BLOCK>::operator =((MEMORY_BLOCK&)rhs);
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    //Note: atomic specification says we return an l-value when operating among atomics
    self_t& operator=(self_t& rhs) 
    {
        const MEMORY_BLOCK value_ = atomic_value_.tbb::atomic<MEMORY_BLOCK>::operator =((MEMORY_BLOCK&)rhs);
        return *this;
    }

    FLOATING_POINT& _internal_reference() const
    {
        return reinterpret_cast<FLOATING_POINT&>(atomic_value_.tbb::atomic<MEMORY_BLOCK>::_internal_reference());
    }

    FLOATING_POINT operator+=(FLOATING_POINT value)
    {
        FLOATING_POINT old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<FLOATING_POINT&>(atomic_value_);
            new_value_ = old_value_ + value;
        //floating point binary representation is not an issue because
        //we are using our self's compare and swap, thus comparing floats and floats
        } while(self_t::compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_); //return resulting value
    }

    FLOATING_POINT operator*=(FLOATING_POINT value)
    {
        FLOATING_POINT old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<FLOATING_POINT&>(atomic_value_);
            new_value_ = old_value_ * value;
        //floating point binary representation is not an issue becaus
        //we are using our self's compare and swap, thus comparing floats and floats
        } while(self_t::compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_); //return resulting value
    }

    FLOATING_POINT operator/=(FLOATING_POINT value)
    {
        FLOATING_POINT old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<FLOATING_POINT&>(atomic_value_);
            new_value_ = old_value_ / value;
        //floating point binary representation is not an issue because
        //we are using our self's compare and swap, thus comparing floats and floats
        } while(self_t::compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_); //return resulting value
    }

    FLOATING_POINT operator-=(FLOATING_POINT value)
    {
        return this->operator+=(-value); //return resulting value
    }

    //Prefix operator
    FLOATING_POINT operator++()
    {
        return this->operator+=(1); //return resulting value
    }

    //Prefix operator
    FLOATING_POINT operator--() 
    {
        return this->operator+=(-1); //return resulting value
    }

    //Postfix operator
    FLOATING_POINT operator++(int)
    {
        const FLOATING_POINT temp = this;
        this->operator+=(1);
        return temp//return resulting value
    }

    //Postfix operator
    FLOATING_POINT operator--(int) 
    {
        const FLOATING_POINT temp = this;
        this->operator+=(1);
        return temp//return resulting value
    }

    FLOATING_POINT fetch_and_add( FLOATING_POINT addend ) 
    {
        const FLOATING_POINT old_value_ = atomic_value_;
        this->operator+=(addend);
        //atomic specification requires returning old value, not new one as in operator x=
        return old_value_; 
    }

    FLOATING_POINT fetch_and_increment() 
    {
        const FLOATING_POINT old_value_ = atomic_value_;
        this->operator+=(+1);
        //atomic specification requires returning old value, not new one as in operator x=
        return old_value_; 
    }

    FLOATING_POINT fetch_and_decrement() 
    {
        const FLOATING_POINT old_value_ = atomic_value_;
        this->operator+=(-1);
        //atomic specification requires returning old value, not new one as in operator x=
        return old_value_; 
    }
  };

  typedef atomic_float_<float,uint_32> AtomicFloat;
  typedef atomic_float_<double,uint_64> AtomicDouble;

【问题讨论】:

  • operator= 返回一个值有点邪恶,因为在内置类型上它计算为一个左值(T&,其中 T 是类型)。对于这些类型,“(i = j) = k”是不寻常但合法的,并将 k 的值分配给 i。
  • 一个好点,并在我最新版本的代码中解决。然而,返回 T 而不是 operator= 的左值是 tbb 中原子值的正确行为。
  • 嘿@RobertGould。非常感谢您实现该功能并在此处分享。我有两个问题:(1)时间仍然有效吗?我的意思是,在我的平台上,当我使用原子版本而不是 std::mutex 时,我无法获得加速,(2)这段代码是否有一些许可证?如果我要借用它并让它在我的项目的标准库中与std::atomic 一起工作,我该怎么办?

标签: c++ thread-safety atomic tbb


【解决方案1】:

我强烈建议不要进行公共继承。我不知道原子实现是什么样的,但我假设它具有将其用作整数类型的重载运算符,这意味着在许多(也许是大多数?)情况下,将使用这些提升而不是你的浮点数。

我看不出有什么不可行的理由,但像你一样,我必须要证明这一点......

注意:您的 operator float() 例程没有加载获取语义,不应该将其标记为 const volatile(或绝对至少是 const)吗?

编辑:如果你要提供 operator--() 你应该提供前缀/后缀形式。

【讨论】:

  • 做作文可能是更好的解决方案。如果实现没问题,我可能应该重构这个类。
  • 完全同意继承-组合。
【解决方案2】:

看起来您的实现假定sizeof(size_t) == sizeof(float)。对于您的目标平台,这是否总是正确的?

我不会说 threading 异端,而是说 casting 异端。 :)

【讨论】:

  • 不一定,但我打算放置一个比较 sizeof(float) == sizeof(size_t) 的静态断言作为编译的保护
  • 仅使用 uint32_t 有什么好处?
  • 看起来您的实现假定 sizeof(uint32_t) == sizeof(float)。对于您的目标平台,这是否总是正确的?这对您的编译器来说总是如此吗?
  • 对于他目前的平台来说可能已经足够了,如果有任何未来的平台,但使用静态断言会让他知道什么时候不是这样,如果他想变得非常聪明,他可能可以为不同的平台做不同的 MACRO 定义。
【解决方案3】:

虽然 uint32_t 的大小可能等同于给定拱门上的 float 的大小,但通过重新解释从一个到另一个的强制转换,您隐含地假设对位的原子递增、递减和所有其他操作在这两种类型上在语义上是等效的,但实际上并非如此。我怀疑它是否按预期工作。

【讨论】:

  • 不,不,我不是,这就是为什么我将实际操作拉到事务同时循环(一种已知的并行模式)中。无论如何,我可以向您保证代码在单个线程中正常工作。它甚至一直在多线程中正常工作。只是不确定这是否是我可以信任的……
  • 我没有太关注运营商。但问题是:您确定 fetch_and_add、fetch_and_increment 等以正确的方式工作吗?
  • 你是对的!自从我测试操作员以来,我实际上并没有考虑太多。 fetch_xxxx 都错了!傻我错过了,他们需要对运营商同样的待遇。
【解决方案4】:

我强烈怀疑您是否在 fetch_and_add 等中获得了正确的值,因为浮点加法与 int 加法不同。

这是我从这些算术中得到的:

1   + 1    =  1.70141e+038  
100 + 1    = -1.46937e-037  
100 + 0.01 =  1.56743e+038  
23  + 42   = -1.31655e-036  

是的,线程安全,但不是您所期望的。

无锁算法(运算符 + 等)应该在原子性方面起作用(尚未检查算法本身..)


其他解决方案: 由于都是加减法,你可以给每个线程一个自己的实例,然后将多个线程的结果相加。

【讨论】:

  • 请注意,我没有这样做。我将整数转换为浮点引用,这意味着它们被正确处理。 old_value_ = reinterpret_cast(*this);新值_ = 旧值_ + 值;
  • 这对于“减少”的解决方案来说很好,但我需要浮点数作为具有长寿命的数据结构(属性)的成员。但是您的评论确实提醒我,没有乘法和除法的浮点数很愚蠢。也会添加这些
  • 修改后的代码看起来好多了! :) 是的,无锁循环对我来说看起来不错,但我还没有做足够的事情来真正判断。
【解决方案5】:

这是在英特尔板上讨论后的代码状态,但仍未彻底验证在所有情况下都能正常工作。

  #include <tbb/atomic.h>
  typedef unsigned int      uint_32;
  typedef __TBB_LONG_LONG       uint_64;

  template<typename FLOATING_POINT,typename MEMORY_BLOCK>
  struct atomic_float_
  {
    /*  CRC Card -----------------------------------------------------
    |   Class:          atmomic float template class
    |
    |   Responsability: handle integral atomic memory as it were a float,
    |                   but partially bypassing FPU, SSE/MMX, so it is
    |                   slower than a true float, but faster and smaller
    |                   than a locked float.
    |                       *Warning* If your float usage is thwarted by
    |                   the A-B-A problem this class isn't for you
    |                       *Warning* Atomic specification says we return,
    |                   values not l-values. So  (i = j) = k doesn't work.
    |
    |   Collaborators:  intel's tbb::atomic handles memory atomicity
    ----------------------------------------------------------------*/
    typedef typename atomic_float_<FLOATING_POINT,MEMORY_BLOCK> self_t;

    tbb::atomic<MEMORY_BLOCK> atomic_value_;

    template<memory_semantics M>
    FLOATING_POINT fetch_and_store( FLOATING_POINT value ) 
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::fetch_and_store<M>((MEMORY_BLOCK&)value);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    FLOATING_POINT fetch_and_store( FLOATING_POINT value ) 
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::fetch_and_store((MEMORY_BLOCK&)value);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    template<memory_semantics M>
    FLOATING_POINT compare_and_swap( FLOATING_POINT value, FLOATING_POINT comparand ) 
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::compare_and_swap<M>((MEMORY_BLOCK&)value,(MEMORY_BLOCK&)compare);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    FLOATING_POINT compare_and_swap(FLOATING_POINT value, FLOATING_POINT compare)
    {
        const MEMORY_BLOCK value_ = 
            atomic_value_.tbb::atomic<MEMORY_BLOCK>::compare_and_swap((MEMORY_BLOCK&)value,(MEMORY_BLOCK&)compare);
        //atomic specification requires returning old value, not new one
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    operator FLOATING_POINT() const volatile // volatile qualifier here for backwards compatibility 
    {
        const MEMORY_BLOCK value_ = atomic_value_;
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    //Note: atomic specification says we return the a copy of the base value not an l-value
    FLOATING_POINT operator=(FLOATING_POINT rhs) 
    {
        const MEMORY_BLOCK value_ = atomic_value_.tbb::atomic<MEMORY_BLOCK>::operator =((MEMORY_BLOCK&)rhs);
        return reinterpret_cast<const FLOATING_POINT&>(value_);
    }

    //Note: atomic specification says we return an l-value when operating among atomics
    self_t& operator=(self_t& rhs) 
    {
        const MEMORY_BLOCK value_ = atomic_value_.tbb::atomic<MEMORY_BLOCK>::operator =((MEMORY_BLOCK&)rhs);
        return *this;
    }

    FLOATING_POINT& _internal_reference() const
    {
        return reinterpret_cast<FLOATING_POINT&>(atomic_value_.tbb::atomic<MEMORY_BLOCK>::_internal_reference());
    }

    FLOATING_POINT operator+=(FLOATING_POINT value)
    {
        FLOATING_POINT old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<FLOATING_POINT&>(atomic_value_);
            new_value_ = old_value_ + value;
        //floating point binary representation is not an issue because
        //we are using our self's compare and swap, thus comparing floats and floats
        } while(self_t::compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_); //return resulting value
    }

    FLOATING_POINT operator*=(FLOATING_POINT value)
    {
        FLOATING_POINT old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<FLOATING_POINT&>(atomic_value_);
            new_value_ = old_value_ * value;
        //floating point binary representation is not an issue becaus
        //we are using our self's compare and swap, thus comparing floats and floats
        } while(self_t::compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_); //return resulting value
    }

    FLOATING_POINT operator/=(FLOATING_POINT value)
    {
        FLOATING_POINT old_value_, new_value_;
        do
        {
            old_value_ = reinterpret_cast<FLOATING_POINT&>(atomic_value_);
            new_value_ = old_value_ / value;
        //floating point binary representation is not an issue because
        //we are using our self's compare and swap, thus comparing floats and floats
        } while(self_t::compare_and_swap(new_value_,old_value_) != old_value_);
        return (new_value_); //return resulting value
    }

    FLOATING_POINT operator-=(FLOATING_POINT value)
    {
        return this->operator+=(-value); //return resulting value
    }

    //Prefix operator
    FLOATING_POINT operator++()
    {
        return this->operator+=(1); //return resulting value
    }

    //Prefix operator
    FLOATING_POINT operator--() 
    {
        return this->operator+=(-1); //return resulting value
    }

    //Postfix operator
    FLOATING_POINT operator++(int)
    {
        const FLOATING_POINT temp = this;
        this->operator+=(1);
        return temp//return resulting value
    }

    //Postfix operator
    FLOATING_POINT operator--(int) 
    {
        const FLOATING_POINT temp = this;
        this->operator+=(1);
        return temp//return resulting value
    }

    FLOATING_POINT fetch_and_add( FLOATING_POINT addend ) 
    {
        const FLOATING_POINT old_value_ = atomic_value_;
        this->operator+=(addend);
        //atomic specification requires returning old value, not new one as in operator x=
        return old_value_; 
    }

    FLOATING_POINT fetch_and_increment() 
    {
        const FLOATING_POINT old_value_ = atomic_value_;
        this->operator+=(+1);
        //atomic specification requires returning old value, not new one as in operator x=
        return old_value_; 
    }

    FLOATING_POINT fetch_and_decrement() 
    {
        const FLOATING_POINT old_value_ = atomic_value_;
        this->operator+=(-1);
        //atomic specification requires returning old value, not new one as in operator x=
        return old_value_; 
    }
  };

  typedef atomic_float_<float,uint_32> AtomicFloat;
  typedef atomic_float_<double,uint_64> AtomicDouble;

【讨论】:

    【解决方案6】:

    对此进行说明(我想发表评论,但显然不允许新用户发表评论):在引用上使用 reinterpret_cast 会在 gcc 4.1 -O3 中产生不正确的代码。这似乎在 4.4 中得到修复,因为它在那里工作。将 reinterpret_casts 更改为指针,虽然有点丑陋,但在这两种情况下都有效。

    【讨论】:

      【解决方案7】:

      从我对那段代码的阅读来看,我真的会对这样的编译器感到愤怒,因为它会为这个非原子的程序提供程序集。

      【讨论】:

        【解决方案8】:

        让您的编译器生成汇编代码并查看它。如果操作不止一条汇编语言指令,那么它不是原子操作,并且需要锁才能在多处理器系统中正常运行。

        不幸的是,我不确定反之亦然——单指令操作是保证是原子的。我不知道多处理器编程的细节到那个级别。我可以为任何一个结果提出理由。 (如果其他人对此有明确的信息,请随时加入。)

        【讨论】:

        • 单个 ASM 指令应被视为非原子指令,除非另有证明,尤其是在 x86 和其他 CISCy 体系结构上,因为指令被分解为微操作,在其中您可能有上下文切换。像比较和交换这样的原子插入禁用中断来忽略这一点。
        • 单汇编语言指令在多处理器系统中是非原子的,无论是否有任何处理器进行上下文切换。获得原子性的方法是使用专门为其设计的操作,例如比较和交换、锁定或 Dekker 算法。
        • 当然,在多处理器系统中,上下文切换本身是无关紧要的,但是您应该检查线程执行的每一个可能的交错这一事实并不会改变多个线程是否被任意时间复用到一个核心,或时分复用到共享内存中。
        猜你喜欢
        • 2018-10-26
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2013-04-12
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2011-09-28
        相关资源
        最近更新 更多