Cpp Concurrency In Action(读书笔记5)——基于锁的并发数据结构设计

10 篇文章 0 订阅
9 篇文章 0 订阅

为并发设计的意义何在?

  在并行程序中的数据结构:要么绝对不变,要么能够正确的同步。要能够正确的同步:一种方法是设计独立的互斥量,来锁住需要保护的数据,另外一种方法就是设计一种能够并发访问的数据结构。
  指导思想:设计”线程安全“的数据结构,并减少保护区域,减少序列化操作,提高并发访问的潜力。

数据结构并发设计的指导与建议(指南)

  一要保证访问是安全的,二要能够真正的并发访问。根据第三章(读书笔记2)的描述须:

  • 确保没有线程能够看到,数据结构的“不变量”破坏时的状态。
  • 小心那些会引起条件竞争的接口,提供完整操作的函数,而非操作步骤(将步骤进行合并)。
  • 注意数据结构的行为是否会产生异常,从而确保“不变量”的状态稳定。
  • 将死锁的概率降到最低。使用数据结构时,需要限制锁的范围,且避免嵌套锁的存在。
      第二个方面,保证真正的并发访问。作者提到了几个问题:
  • 锁的范围中的操作,是否允许在锁外执行?
  • 数据结构中不同的区域是否能被不同的互斥量所保护?
  • 所有操作都需要同级互斥量保护吗?
  • 能否对数据结构进行简单的修改,以增加并发访问的概率,且不影响操作语义?
      以上都是围绕指导思想展开。

    基于锁的并发数据结构

      确保访问线程持有锁的时间最短。

    线程安全栈——使用锁

      线程安全栈的类定义(在读书笔记2中已经实现过):

    struct empty_stack : std::exception
    {
      const char* what() const throw()
      {
          return "empty stack.";
      }
    };
    template<typename T>
    class threadsafe_stack
    {
    private:
      std::stack<T> data;
      mutable std::mutex m;
    public:
      threadsafe_stack() {}
      threadsafe_stack(const threadsafe_stack& other)
      {
          std::lock_guard<std::mutex> lock(other.m);
          data = other.data;
      }
      threadsafe_stack& operator=(const threadsafe_stack&) = delete;
      void push(T new_value)
      {
          std::lock_guard<std::mutex> lock(m);
          data.push(std::move(new_value)); // 1
      }
      std::shared_ptr<T> pop()
      {
          std::lock_guard<std::mutex> lock(m);
          if (data.empty()) throw empty_stack(); // 2
          std::shared_ptr<T> const res(
              std::make_shared<T>(std::move(data.top()))); // 3
          data.pop(); // 4
          return res;
      }
      void pop(T& value)
      {
          std::lock_guard<std::mutex> lock(m);
          if (data.empty()) throw empty_stack();
          value = std::move(data.top()); // 5
          data.pop(); // 6
      }
      bool empty() const
      {
          std::lock_guard<std::mutex> lock(m);
          return data.empty();
      }
    };
    

      构造和析构不是线程安全的,所以,用户就要保证在栈对象完成构建前,其他线程无法对其进行访问;并且,一定要保证在栈对象销毁后,所有线程都要停止对其进行访问。当用户持有锁来调用代码的时候,可能会发生死锁。此处,序列化(等待锁)线程影响了程序性能。

    线程安全队列——使用锁和条件变量

      使用条件变量实现的线程安全队列(读书笔记3):

    template<typename T>
    class threadsafe_queue
    {
    private:
      mutable std::mutex mut;
      std::queue<T> data_queue;
      std::condition_variable data_cond;
    public:
      threadsafe_queue(){}
      void push(T new_value)
      {
          std::lock_guard<std::mutex> lk(mut);
          data_queue.push(std::move(data));
          data_cond.notify_one(); // 1
      }
      void wait_and_pop(T& value) // 2
      {
          std::unique_lock<std::mutex> lk(mut);
          data_cond.wait(lk, [this] {return !data_queue.empty();});
          value = std::move(data_queue.front());
          data_queue.pop();
      }
      std::shared_ptr<T> wait_and_pop() // 3
      {
          std::unique_lock<std::mutex> lk(mut);
          data_cond.wait(lk, [this] {return !data_queue.empty();}); // 4
          std::shared_ptr<T> res(
              std::make_shared<T>(std::move(data_queue.front())));
          data_queue.pop();
          return res;
      }
      bool try_pop(T& value)
      {
          std::lock_guard<std::mutex> lk(mut);
          if (data_queue.empty())
              return false;
          value = std::move(data_queue.front());
          data_queue.pop();
          return true;
      }
      std::shared_ptr<T> try_pop()
      {
          std::lock_guard<std::mutex> lk(mut);
          if (data_queue.empty())
              return std::shared_ptr<T>(); // 5
          std::shared_ptr<T> res(
              std::make_shared<T>(std::move(data_queue.front())));
          data_queue.pop();
          return res;
      }
      bool empty() const
      {
          std::lock_guard<std::mutex> lk(mut);
          return data_queue.empty();
      }
    };
    

      #2与#3相对于栈的设计要好很多,它不需要线程密切关注状态(持续调用empty())、对异常敏感。同时,在try_pop()返回结果上与pop()相比,做了改进,返回空指针和false,此时可以让线程做其他事情。如果多个线程等待push,其中一个线程被唤醒却在new时发生异常则其他都会永远沉睡。书中阐述了三种方案,一是改成notify_all()(所有线程唤醒,其中一个获得锁,但是一旦发生异常,将重蹈覆辙),二是异常发生后再次调用notify_one()(让另一个线程尝试完成),三是使用std::shared_ptr实例。书中对第三种方案进行了阐释。
      持有 std::shared_ptr<>实例的线程安全队列:

    template<typename T>
    class threadsafe_queue
    {
    private:
      mutable std::mutex mut;
      std::queue<std::shared_ptr<T> > data_queue;
      std::condition_variable data_cond;
    public:
      threadsafe_queue(){}
      void wait_and_pop(T& value)
      {
          std::unique_lock<std::mutex> lk(mut);
          data_cond.wait(lk, [this] {return !data_queue.empty();});
          value = std::move(*data_queue.front()); // 1
          data_queue.pop();
      }
      bool try_pop(T& value)
      {
          std::lock_guard<std::mutex> lk(mut);
          if (data_queue.empty())
              return false;
          value = std::move(*data_queue.front()); // 2
          data_queue.pop();
          return true;
      }
      std::shared_ptr<T> wait_and_pop()
      {
          std::unique_lock<std::mutex> lk(mut);
          data_cond.wait(lk, [this] {return !data_queue.empty();});
          std::shared_ptr<T> res = data_queue.front(); // 3
          data_queue.pop();
          return res;
      }
      std::shared_ptr<T> try_pop()
      {
          std::lock_guard<std::mutex> lk(mut);
          if (data_queue.empty())
              return std::shared_ptr<T>();
          std::shared_ptr<T> res = data_queue.front(); // 4
          data_queue.pop();
          return res;
      }
      void push(T new_value)
      {
          std::shared_ptr<T> data(
              std::make_shared<T>(std::move(new_value))); // 5
          std::lock_guard<std::mutex> lk(mut);
          data_queue.push(data);
          data_cond.notify_one();
      }
      bool empty() const
      {
          std::lock_guard<std::mutex> lk(mut);
          return data_queue.empty();
      }
    };
    

      上面代码中,采用了存放std::shared_ptr<>实例的方式。此时,push中实例分配完毕,唤醒线程,不会发生内存分配失败(不用构造新的std::shared_ptr<>实例,分配内存),也就不会锁在线程(继续等待push,其他线程沉睡)中。同时,内存分配的方式提升了队列工作效率,在分配内存的时候,可以让其他线程对队列进行操作。

    线程安全队列——使用细粒度锁和条件变量

      上面使用了std::queue<>,使用一个互斥量对它进行保护,限制了并发访问。
      队列实现——单线程版:

    #include <memory>
    template<typename T>
    class queue
    {
    private:
      struct node
      {
          T data;
          std::unique_ptr<node> next;//随处可见C++11的影子
          node(T data_) :
              data(std::move(data_))
          {}
      };
      std::unique_ptr<node> head; // 1
      node* tail; // 2
    public:
      queue(){}
      queue(const queue& other) = delete;
      queue& operator=(const queue& other) = delete;
      std::shared_ptr<T> try_pop()
      {
          if (!head)
          {
              return std::shared_ptr<T>();
          }
          std::shared_ptr<T> const res(
              std::make_shared<T>(std::move(head->data)));
          std::unique_ptr<node> const old_head = std::move(head);
          head = std::move(old_head->next); // 3
          return res;
      }
      void push(T new_value)
      {
          std::unique_ptr<node> p(new node(std::move(new_value)));
          node* const new_tail = p.get();
          if (tail)
          {
              tail->next = std::move(p); // 4
          }
          else
          {
              head = std::move(p); // 5
          }
          tail = new_tail; // 6
      }
    };
    

      多线程下的问题:当只有一个元素时,head=tail,此时try_pop和push上的是同一个锁。
      通过分离数据实现并发:预分配一个虚拟节点(无数据),确保这个节点永远在队列的最后,用来分离头尾指针能访问的节点。
      带有虚拟节点的队列:

    template<typename T>
    class queue
    {
    private:
      struct node
      {
          std::shared_ptr<T> data; // 1,数据指针
          std::unique_ptr<node> next;
      };
      std::unique_ptr<node> head;
      node* tail;
    public:
      queue() :
          head(new node), tail(head.get()) // 2,虚拟节点的创立
      {}
      queue(const queue& other) = delete;
      queue& operator=(const queue& other) = delete;
      std::shared_ptr<T> try_pop()
      {
          if (head.get() == tail) // 3,没有元素
          {
              return std::shared_ptr<T>();
          }
          std::shared_ptr<T> const res(head->data); // 4,取出返回数据指针
          std::unique_ptr<node> old_head = std::move(head);
          head = std::move(old_head->next); // 5
          return res; // 6,返回数据指针
      }
      void push(T new_value)
      {
          std::shared_ptr<T> new_data(
              std::make_shared<T>(std::move(new_value))); // 7,生成数据指针
          std::unique_ptr<node> p(new node); //8,新虚拟节点
          tail->data = new_data; // 9,虚拟节点变为新节点
          node* const new_tail = p.get();
          tail->next = std::move(p);
          tail = new_tail;
      }
    };
    

      这里选择修改虚拟节点的数据指针来push,避免了try_pop和push对同一节点进行操作。
      线程安全队列——细粒度锁版:

    template<typename T>
    class threadsafe_queue
    {
    private:
      struct node
      {
          std::shared_ptr<T> data;
          std::unique_ptr<node> next;
      };
      std::mutex head_mutex;
      std::unique_ptr<node> head;
      std::mutex tail_mutex;
      node* tail;
      node* get_tail()//只需要一会tail,所以包装成函数获取
      {
          std::lock_guard<std::mutex> tail_lock(tail_mutex);
          return tail;
      }
      std::unique_ptr<node> pop_head()//返回唯一指针(独占)
      {
          std::lock_guard<std::mutex> head_lock(head_mutex);
          if (head.get() == get_tail())
          //只需要一会tail,不用担心别的线程之后pop(已经锁了head)
          {
              return nullptr;
          }
          std::unique_ptr<node> old_head = std::move(head);
          head = std::move(old_head->next);
          return old_head;
      }
    public:
      threadsafe_queue() :
          head(new node), tail(head.get())
      {}
      threadsafe_queue(const threadsafe_queue& other) = delete;
      threadsafe_queue& operator=(const threadsafe_queue& other) = delete;
      std::shared_ptr<T> try_pop()//返回数据指针
      {
          std::unique_ptr<node> old_head = pop_head();
          return old_head ? old_head->data : std::shared_ptr<T>();
      }
      void push(T new_value)
      {
          std::shared_ptr<T> new_data(
              std::make_shared<T>(std::move(new_value)));
          std::unique_ptr<node> p(new node);
          node* const new_tail = p.get();
          //尽量减少锁的时长
          std::lock_guard<std::mutex> tail_lock(tail_mutex);
          tail->data = new_data;
          tail->next = std::move(p);
          tail = new_tail;
      }
    };
    

      上面封装了两个函数,极大减少了上锁时间。
      下面的实现有可能会使线程中途卡住(错误实现):

      std::unique_ptr<node> pop_head() // 这是个有缺陷的实现
      {
          node* const old_tail = get_tail(); 
          // 1, 在head_mutex范围外获取旧尾节点的值
          std::lock_guard<std::mutex> head_lock(head_mutex);
          if (head.get() == old_tail) // 2
          {
              return nullptr;
          }
          std::unique_ptr<node> old_head = std::move(head);
          head = std::move(old_head->next); // 3
          return old_head;
      }
    

      可能在获取到旧的尾节点后,无法上锁,甚至此时链表已经发生了改变,此尾节点已不是尾节点。
      下面是等待数据弹出的设计。
      可上锁和等待的线程安全队列:

    //头文件
    template<typename T>
    class threadsafe_queue
    {
    private:
      struct node
      {
          std::shared_ptr<T> data;
          std::unique_ptr<node> next;
      };
      std::mutex head_mutex;
      std::unique_ptr<node> head;
      std::mutex tail_mutex;
      node* tail;
      std::condition_variable data_cond;
    
      node* get_tail()
      {
          std::lock_guard<std::mutex> tail_lock(tail_mutex);
          return tail;
      }
      std::unique_ptr<node> pop_head() // 1
      {
          std::unique_ptr<node> old_head = std::move(head);
          head = std::move(old_head->next);
          return old_head;
      }
      std::unique_lock<std::mutex> wait_for_data() // 2,lambda等待条件变量
      {
          std::unique_lock<std::mutex> head_lock(head_mutex);
          data_cond.wait(head_lock, 
              [&] {return head.get() != get_tail();});
          return std::move(head_lock); // 3,返回锁的实例给调用者
      }
      std::unique_ptr<node> wait_pop_head()
      {
          std::unique_lock<std::mutex> head_lock(wait_for_data()); // 4
          return pop_head();
      }
      std::unique_ptr<node> wait_pop_head(T& value)
      {
          std::unique_lock<std::mutex> head_lock(wait_for_data()); // 5
          value = std::move(*head->data);
          return pop_head();
      }
      std::unique_ptr<node> try_pop_head()
      {
          std::lock_guard<std::mutex> head_lock(head_mutex);
          if (head.get() == get_tail())
          {
              return std::unique_ptr<node>();
          }
          return pop_head();
      }
      std::unique_ptr<node> try_pop_head(T& value)
      {
          std::lock_guard<std::mutex> head_lock(head_mutex);
          if (head.get() == get_tail())
          {
              return std::unique_ptr<node>();
          }
          value = std::move(*head->data);
          return pop_head();
      }
    public:
      threadsafe_queue() :
          head(new node), tail(head.get())
      {}
      threadsafe_queue(const threadsafe_queue& other) = delete;
      threadsafe_queue& operator=(const threadsafe_queue& other) = delete;
      //等待弹出
      std::shared_ptr<T> wait_and_pop()
      {
          std::unique_ptr<node> const old_head = wait_pop_head();
          return old_head->data;
      }
      void wait_and_pop(T& value)
      {
          std::unique_ptr<node> const old_head = wait_pop_head(value);
      }
      //尝试弹出
      std::shared_ptr<T> try_pop()
      {
          std::unique_ptr<node> old_head = try_pop_head();
          return old_head ? old_head->data : std::shared_ptr<T>();
      }
      bool try_pop(T& value)
      {
          std::unique_ptr<node> const old_head = try_pop_head(value);
          return old_head;
      }
      void empty()
      {
          std::lock_guard<std::mutex> head_lock(head_mutex);
          return (head.get() == get_tail());//同类型比较
      }
      void push(T new_value);//压入
    };
    template<typename T>
    void threadsafe_queue<T>::push(T new_value)
    {
      std::shared_ptr<T> new_data(
          std::make_shared<T>(std::move(new_value)));
      std::unique_ptr<node> p(new node);
      {
          std::lock_guard<std::mutex> tail_lock(tail_mutex);
          tail->data = new_data;
          node* const new_tail = p.get();
          tail->next = std::move(p);
          tail = new_tail;
      }
      data_cond.notify_one();//添加通知
    }
    

      这是一个无界(unbounded)队列,线程可以持续向队列中添加数据项,即使没有元素被删除。而有界(bounded)队列一开始就已经确定了最大长度。

    基于锁设计更加复杂的数据结构

      栈和队列接口比较固定,而其他大多数数据结构支持更加多样化的操作。原则上,这将增大并行的可能性,但是也让对数据保护变得更加困难,因为要考虑对所有能访问到的部分。

    编写一个使用锁的线程安全查询表

      查询表或字典是一种类型的值(键值)和另一种类型的值进行关联(映射的方式)。一般情况下,这样的结构允许代码通过键值对相关的数据值进行查询。标准容器的接口不适合多线程进行并发访问,因为这些接口在设计的时候都存在固有的条件竞争,所以这些接口需要砍掉,进行重新修订。并发访问时,std::map<> 接口最大的问题在于——迭代器,而许多容器给定的接口中迭代器经常使用。类似于上面,使用标准容器,一个锁来锁住全部,降低了并发的可能性。
      为细粒度锁设计一个映射结构:选择哈希表(二叉树需要锁住根节点,有序数组要全部锁住,舍弃)。

    #include <functional>//std::hash<>
    #include <list>
    #include <vector>
    #include <mutex>
    #include <boost/thread/shared_mutex.hpp>
    #include <boost/thread.hpp>
    template<typename Key, typename Value, typename Hash = std::hash<Key> >
    class threadsafe_lookup_table
    {
    private:
      class bucket_type
      {
      private:
          typedef std::pair<Key, Value> bucket_value;
          typedef std::list<bucket_value> bucket_data;
          typedef typename bucket_data::iterator bucket_iterator;
          bucket_data data;//list<pair<Key,Value>>,链表中存放键值对
          mutable boost::shared_mutex mutex; // 1,对每一个桶实施保护
          bucket_iterator find_entry_for(Key const& key) const 
          // 2,确定key是否在桶中
          {
              return std::find_if(data.begin(), data.end(),
                  [&](bucket_value const& item)
              {return item.first == key;});
          }
      public:
          Value value_for(Key const& key, Value const& default_value) const
          {
              boost::shared_lock<boost::shared_mutex> lock(mutex); 
              // 3,共享(只读)所有权
              bucket_iterator const found_entry = find_entry_for(key);
              return (found_entry == data.end()) ?
                  default_value : found_entry->second;
          }
          void add_or_update_mapping(Key const& key, Value const& value)
          {
              std::unique_lock<boost::shared_mutex> lock(mutex);
              // 4,唯一(读/写)权
              bucket_iterator const found_entry = find_entry_for(key);
              if (found_entry == data.end())
              {
                  data.push_back(bucket_value(key, value));
              }
              else
              {
                  found_entry->second = value;
              }
          }
          void remove_mapping(Key const& key)
          {
              std::unique_lock<boost::shared_mutex> lock(mutex); 
              // 5,唯一(读/写)权
              bucket_iterator const found_entry = find_entry_for(key);
              if (found_entry != data.end())
              {
                  data.erase(found_entry);
              }
          }
      };
      std::vector<std::unique_ptr<bucket_type> > buckets; // 6,保存桶
      Hash hasher;//哈希函数
      bucket_type& get_bucket(Key const& key) const // 7,可以无锁调用
      {
          //得到索引(哪个桶)
          std::size_t const bucket_index = hasher(key) % buckets.size();
          return *buckets[bucket_index];
      }
    public:
      typedef Key key_type;
      typedef Value mapped_type;
      typedef Hash hash_type;
      //默认桶的数量,任意质数,哈希表在有质数个桶时,工作效率最高。
      threadsafe_lookup_table(
          unsigned num_buckets = 19, Hash const& hasher_ = Hash()) :
          buckets(num_buckets), hasher(hasher_)
          //此处对vector初始化,大小num_buckets
      {
          for (unsigned i = 0;i<num_buckets;++i)
          {
              buckets[i].reset(new bucket_type);
          }
      }
      threadsafe_lookup_table(threadsafe_lookup_table const& other) 
                                                              = delete;
      threadsafe_lookup_table& operator=(
          threadsafe_lookup_table const& other) = delete;
      Value value_for(Key const& key,
          Value const& default_value = Value()) const
      {
          return get_bucket(key).value_for(key, default_value); 
          // 8,可以无锁调用
      }
      void add_or_update_mapping(Key const& key, Value const& value)
      {
          get_bucket(key).add_or_update_mapping(key, value);
          // 9,可以无锁调用
      }
      void remove_mapping(Key const& key)
      {
          get_bucket(key).remove_mapping(key); // 10,可以无锁调用
      }
      std::map<Key, Value> get_map() const;
      //查询表的一个“可有可无”(nice-to-have)的特性,会将选择当前状态的快照
    };
    template<typename Key, typename Value, typename Hash = std::hash<Key> >
    std::map<Key, Value> 
    threadsafe_lookup_table<Key, Value, Hash>::get_map() const
    {
      std::vector<std::unique_lock<boost::shared_mutex> > locks;//锁的数组
      for (unsigned i = 0;i<buckets.size();++i)
      {
          locks.push_back(
              std::unique_lock<boost::shared_mutex>(buckets[i].mutex));
              //上锁后压入
      }
      std::map<Key, Value> res;
      for (unsigned i = 0;i<buckets.size();++i)
      {
          for (bucket_iterator it = buckets[i].data.begin();
              it != buckets[i].data.end();
              ++it)
          {
              res.insert(*it);//存入键值对
          }
      }
      return res;
    }
    

      这个查询表作为一个整体,通过单独的操作,对每一个桶进行锁定,并且通过使用boost::shared_mutex允许读者线程对每一个桶进行并发访问。

    编写一个使用锁的线程安全链表

      链表类型:访问涉及迭代器,在此,需要封装一个函数专门管理迭代器(进行控制和锁定)。
      线程安全链表——支持迭代器:

    template<typename T>
    class threadsafe_list
    {
      struct node // 1
      {
          std::mutex m;
          std::shared_ptr<T> data;
          std::unique_ptr<node> next;
          node() : // 2
              next()
          {}
          node(T const& value) : // 3
              data(std::make_shared<T>(value))
          {}
      };
      node head;//头节点
    public:
      threadsafe_list(){}
      ~threadsafe_list()
      {
          remove_if([](node const&) {return true;});
      }
      threadsafe_list(threadsafe_list const& other) = delete;
      threadsafe_list& operator=(threadsafe_list const& other) = delete;
      void push_front(T const& value)
      {
          std::unique_ptr<node> new_node(new node(value)); 
          // 4,指向新节点的指针
          std::lock_guard<std::mutex> lk(head.m);
          new_node->next = std::move(head.next); 
          // 5,修改指针,unique只能move
          head.next = std::move(new_node); // 6
      }
      //将风险都转移到传入的谓词(函数),交由用户决定处理
      template<typename Function>
      void for_each(Function f) // 7,模板函数出现新的类
      {
          node* current = &head;
          std::unique_lock<std::mutex> lk(head.m); // 8
          while (node* const next = current->next.get()) // 9,遍历
          {
              std::unique_lock<std::mutex> next_lk(next->m); // 10,逐个上锁
              lk.unlock(); // 11,前驱节点解锁
              f(*next->data); // 12,对数据处理
              current = next;
              lk = std::move(next_lk); // 13,将锁移至前驱
          }
      }
      template<typename Predicate>
      std::shared_ptr<T> find_first_if(Predicate p) // 14
      {
          node* current = &head;
          std::unique_lock<std::mutex> lk(head.m);
          while (node* const next = current->next.get())
          {
              std::unique_lock<std::mutex> next_lk(next->m);
              lk.unlock();
              if (p(*next->data)) // 15,判别
              {
                  return next->data; // 16
              }
              current = next;
              lk = std::move(next_lk);
          }
          return std::shared_ptr<T>();
      }
      template<typename Predicate>
      void remove_if(Predicate p) // 17
      {
          node* current = &head;
          std::unique_lock<std::mutex> lk(head.m);
          while (node* const next = current->next.get()) {
              std::unique_lock<std::mutex> next_lk(next->m);
              if (p(*next->data)) // 18
              {
                  std::unique_ptr<node> old_next = 
                              std::move(current->next);
                  //取出前驱节点的后继指针
                  current->next = std::move(next->next);
                  //修改前驱节点的后继指针
                  next_lk.unlock();
              } // 20
              else
              {
                  lk.unlock(); // 21
                  current = next;
                  lk = std::move(next_lk);
              }
          }
      }
    };
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值