LWN: Lockless编程模式 - 更多的read-modify-write例子!

阿里巴巴时序数据库——Timon 背景 能见度延迟:新数据在流系统中聚合时,会有一定延迟; 读-修改-写:需要一个 ReadModifyWrite 操作来读取和更新外部存储上以前聚合的值。 大规模长期事件分析:场景为大规模海量数据接入通道。 ![jpg](https://img-blog.csdnimg.cn/img_convert/44c761b39adccfddd6881e7cdfdd0343.png#clientId=ufec597a7-0703-4&from=paste&height=493&id=u0. 阅读详情

关注了就能看到更多这么棒的文章哦~

Lockless patterns: more read-modify-write operations

March 19, 2021
This article was contributed by Paolo Bonzini
Lockless patterns
DeepL assisted translation
https://lwn.net/Articles/849237/

系列文章之一:LWN:介绍lockless算法!

系列文章之二:LWN: lockless编程模式——relaxed acess和partial memory barrier

系列文章之三:LWN: lockless编程模式 - full memory barrier!

系列文章之四:LWN: Lockless编程模式 - 介绍compare-and-swap!

本系列中上周关于 lockless 的文章(LWN: Lockless编程模式 - 介绍compare-and-swap!)初步介绍了 compare-and-swap(CAS)操作。CAS 是一个强大的工具,可用来实现许多 lockless 原语 API。下一步,我们来看看可以基于 campare-and-swap 实现出来的其他原子性的 read-modify-write 操作。

基于 CAS 的原语通常都是操作 int 值的。Linux 内核使用了 atomic_t,这是一个 struct 类型,其中封装了 int,因此 load 和 store 都是显式标记出来的。例如,如果 x 是一个 atomic_t,就不可以用 x++ 这种写法。相反,必须写 atomic_inc(&x); 。所有对 atomic_t 的操作都是以 "atomic_"开头的。

Linux 针对 atomic_t 大概有 30 个 read-modify-write 的操作,其中 atomic_cmpxchg() 这种形式的 compare-and-swap 操作是最核心的,因为它可以用来实现其他所有操作。例如,下面这是一个 "atomic increment(原子操作自增)":

/* atomic_read() is like READ_ONCE(), but for Linux's atomic_t.  */
old = atomic_read(&x);
do {
    expected = old;
    old = atomic_cmpxchg(&x, expected, expected + 1);
} while (old != expected);

你可能也发现了,它与我们之前介绍过的 "add to list" 操作很类似。此外,在完成时,old 就包含了已经递增过的值,于是上述代码实际上就相当于 Linux 中 atomic_fetch_inc() 这个宏。

不同的指令集中所提供的 read-modify-write 指令数量差异很大。有的指令集中只包括 compare-and-swap;x86 则提供了更多的指令,但其中只有三条(CMPXCHG、XCHG 和 "交换和添加 "指令 XADD)会返回内存位置的原有值。而且,哪怕是最全面的指令集,也必然会出现一些处理器无法处理好的情况。这时,compare-and-swap 就可以派上用场了。例如,我们可以为 "返回两个数值中的最大值" 的这个操作来定义一个使用了 read-modify-write 方式实现的版本:

/* Store max(x, new) into x.  */
old = atomic_read(&x);
do {
    expected = old;
    if (old > new)
        break;
    old = atomic_cmpxchg(&x, expected, new);
} while (old != expected);

或者实现一个判断只有当一个值是非零时才会递增:

old = atomic_read(&x);
do {
    expected = old;
    if (old == 0)
        break;
    old = atomic_cmpxchg(&x, expected, expected + 1);
} while (old != expected);

像后面这个例子代码,对于实现 lock-free fast path 就有用。这种 lockless 编程模式可以让程序只在极少数情况下使用锁,从而大部分时候都可以避免产生锁竞争。一个典型的使用场景就是 reference couting(引用计数)。

Lock-free reference counting

假设有一个很典型的、跟引用计数相关的数据结构,我们称之为 struct gadget。每个 gadget 都有一个父节点,并持有一个对其父节点的 reference(引用),从而确保父节点本身不会被释放。下面是 get_gadget()和 put_gadget()最简单的实现,这两个函数分别用来增加和减小 gadget 的引用计数:

void get_gadget(struct gadget *g) {
    mutex_lock(&gadgets_lock);
    g->refcnt++;
    mutex_unlock(&gadgets_lock);
}

void put_gadget(struct gadget *g) {
    mutex_lock(&gadgets_lock);
    if (g->refcnt-- == 1) {
        mutex_unlock(&gadgets_lock);
        put_gadget(g->parent);
        kfree(g);
        return;
    }
    mutex_unlock(&gadgets_lock);
}

然而,这样的实现效率太低了。在某个 thread 调用 get_gadget() 来再次获取一个对 gadget g 的引用之时,它肯定早就有了一个对 g 的引用。并且在 get_gadget()返回之前,这个引用都不会消失。因此,任何对 put_gadget()的并发调用都不会走到 kfree() 这个分支。我们可以用下面的方法,就可以去除这里的 lock 了:

void get_gadget(struct gadget *g) {
    /*
     * Unlike atomic_fetch_dec(), this increment is atomic but has
     * no acquire or release semantics.  This is true of all Linux
     * atomic operations that do not return a value.
     */
    atomic_inc(&g->refcnt);
}

void put_gadget(struct gadget *g) {
    /*
     * Like atomic_cmpxchg(), this has both acquire and release semantics.
     */
    if (atomic_fetch_dec(&g->refcnt) > 1)
        return;

    put_gadget(g->parent);
    kfree(g);
}

Linux 提供了 kref_get()和 kref_put() 来实现了这个抽象概念。在上面的代码中,有两点值得注意:

  • put_gadget()并没有使用 atomic_fetch_dec_release(),递减引用计数的操作既有 acquire 语义又有 release 语义。就像在 lock-free list example 中一样,这会在所有的对 put_gadget()的调用中都传递了 “happen before” 的关系,因此当通过 g->parent 来访问其他 gadget 时,就可以使用普通的 load 操作了。

  • 唯一的同步点(synchronization point)是在 put_gadget() 里面。get_gadget()确实也要求它的 increment 递增操作是原子操作,但是它不需要有 acquire 的语义。这是因为调用它的线程一定已经具有了对 g 的引用。每当线程最初拿到这个引用的时候(例如在线程创建时),它就已经有了 acquire 和 release 的语义。在这之后,线程就可以独立运行,直到它需要交还这个引用并(可能会)释放这个 gadget 时,才会再次有 acquire 和 release 的语义。

现在,假设每个 gadget 中还存储了一个 sub-gadgets 的 list。那么在销毁自己之前,gadget 需要先将自己从其父节点的 sub-gadgets list 中删除:

void put_gadget(struct gadget *g) {
    if (atomic_fetch_dec(&g->refcnt) > 1)
        return;

    mutex_lock(&g->parent->gadgets_lock);
    list_del(&g->node);
    mutex_unlock(&g->parent->gadgets_lock);
    put_gadget(g->parent);
    kfree(g);
}

然而,跟往常一样,上面的代码只处理了一部分问题。gadget 拥有一个指向它的父节点的引用;如果父节点对它的每一个子节点也有一个引用的话,就会产生一个引用计数循环(reference-count cycle)并产生内存泄漏。因此,这个 list 必须只能包含指向子节点的指针,而不能具备对子节点的引用;也就是有时候人们对这种情况会称为 list 对子节点有一个弱引用(weak reference)。线程可以自由地访问 list 并对其中的 gadget 进行操作,但它们不能调用 get_gadget(),因为它们还没有引用。这意味着,线程执行时可能会碰到子节点出人意料地消失了的情况。

最简单的解决方法就是只在锁保护下才对弱引用进行操作。如果 put_gadget()无法拿到锁,它就无法从 list 中删除 gadget,这样 list 中的 gadget 就不会消失。然而,这样的限制太强了。我们可以改为修改一下 get_gadget() 的调用规则。以下是一个可行的替代方法:

  • 和之前一样,一个已经有强引用(strong reference)的线程可以通过 get_gadget() 来再获得一个引用。

  • 线程可以通过调用 get_gadget() 来将一个弱引用升级为强引用,但是这个过程中需要持有一个保护 list 不被修改的锁。

get_gadget()的实现仍然跟之前一样很简单,但 put_gadget() 的实现必须更加小心:它必须在将引用计数从 1 递减到 0 之前获取锁,直到从 list 中将自己删除后才释放锁。这样一来,list 的访问者就永远不会看到有对于引用计数为 0 的 gadget 的弱引用了。然而,只要引用计数大于 1,put_gadget() 就可以采用 lockless 的方式进行了。下面是用 compare-and-swap 的实现:

void put_gadget(struct gadget *g) {
    for (;;) {
        int old = atomic_read(&g->refcnt);
        if (old > 1) {
            if (atomic_cmpxchg(&g->refcnt, old, old - 1) == old)
                return;
        } else {
            /* old was 1, fence off accesses to weak references!  */
            mutex_lock(&g->parent->gadgets_lock);
            if (atomic_cmpxchg(&g->refcnt, 1, 0) == 1)
                break;

            /*
             * Somebody snuck in and upgraded a weak reference before the
             * mutex_lock().  Try again.
             */
            mutex_unlock(&g->parent->gadgets_lock);
        }
    }

    list_del(&g->node);
    mutex_unlock(&g->parent->gadgets_lock);
    put_gadget(g->parent);
    kfree(g);

建议反复阅读代码,可以看到每次调用 put_gadget() 都会引起一次正确的 compare-and-swap 操作。

这是 Linux 中的常见模式(common pattern),kref_put_mutex()和 kref_put_lock() 这两个函数对开发者的实现会很有帮助。和 llist.h 一样,我们强烈鼓励你不要自己开发自己的版本,而是尽量使用库函数(library functions)。有的读者可能会注意到并没有 kref_put_rwsem() 这个函数,如果有一个 struct rw_semaphore 用来保护子节点的 list,那么这个函数就会很方便。可以作为练习来自己尝试实现这个函数。

有时没有锁在保护数据的访问和销毁(the access and the destruction)。这种情况通常是弱引用由一个单独的子系统(a separate subsystem)所持有,比如 debugfs inode 的 i_private 字段这种情况。在这种情况下可以利用 refcount_inc_not_zero()和 kref_get_unless_zero(),在 kvm_debugfs_open() 中就能看到前一个函数的使用:

/*
 * The debugfs files still hold a reference to the kvm struct at the
 * time kvm_destroy_vm is called.  The files are removed, and the
 * reference disappears, before kvm_destroy_vm frees the kvm struct.
 *
 * To avoid a race between the opening and the removal of the debugfs
 * files, return -ENOENT if kvm_destroy_vm is in progress.
 */
if (!refcount_inc_not_zero(&stat_data->kvm->users_count))
    return -ENOENT;

与文章开头的实现(如果不为零就递增)相比,refcount_inc_not_zero()为了实现溢出保护(overflow protection)而变得更加复杂了,这进一步说明了要尽量使用现有的高级原语(higher-level primitives)重要性。

Conclusions

在下一篇文章中会讲到我们整个系列中的一些没有涵盖到的细节以及简化说明,但基本上本文就结束了我们对无锁编程模式的介绍了。除了 Linux 中非常复杂的部分, 比如 scheduler 或者 read-copy-update(RCU)之外,这些同步原语和模式(these synchronization primitives and patterns)应该涵盖了你会遇到的几乎所有的 lockless 代码。我写这些文章的目的是帮助你理解基本思想以及那些高级 API 是如何封装了这些思想的,这样以后哪怕碰到略有不同的情况,你也能正确使用它们。我希望它们既能作为学习材料,也能作为参考资料。

[作者感谢 Jon Corbet、Laszlo Ersek 和 Stefan Hajnoczi 帮助校对了这些文稿]。

全文完

参考资料:


LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~

Android USB(type-c)耳机调试分析(十) 简介:优快云博客专家,专注Android/Linux系统,分享多mic语音方案、音视频、编解码等技术,与大家一起成长!】🚀】🚀人生格言:人生从来没有捷径,只有行动才是治疗恐惧和懒惰的唯一良药.​更多原创,欢迎关注:Android系统攻城狮​1.前言本篇目的:理解Android USB(type-c)耳机调试分析。 阅读详情

相关推荐

linux内核协议栈 之 TCP-TSQ

TCP Small Queues的目的是限制每个TCP连接在Qdisc和device队列中的skb数量,以达到降低RTT(Round-Trip Time)和避免 BufferBloat 的目的。

1473

Linux内核(linux-5.2.9)--内核对象(对象的引用计数)

一个分配好的内存对象可能会在多种场景中被多次传递并使用,在这种情况下,为了能够正确的使用内存对象,引入了“引用计数”功能。 防止内存泄漏:确保已分配的对象最终都会被正确释放; 防止访问已释放的内存:确保不会使用已经被释放的对象。 数据结构 kref 为任何内存数据结构添加引用计数提供了一种简单但有效的方法。Kref相关的定义如下: struct kre...

其疾如风 其徐如林 侵略如火 不动如山 3897

麒麟KylinV10系统yum源更换全攻略:从官方源到第三方仓库的保姆级教程

本文详细介绍了麒麟KylinV10系统yum源的更换与优化方法,包括官方源配置、第三方仓库集成及RPM包管理技巧。通过实战案例和命令示例,帮助用户快速解决软件依赖问题,提升系统管理效率。特别适合需要在国产操作系统上进行开发的工程师参考。

weixin_30752699的博客 647

读-改-写操作

读-改-写”(Read-Modify-Write,简称RMW)是一种常见的操作模式,它通常用于需要更新数据的场景。这个模式包含三个基本步骤:1.读(Read):首先读取当前的数据。2.改(Modify):根据需要对数据进行修改。3.写(Write):将修改后的数据写回原来的位置。

优快云_PBB的博客 3576

2022李宏毅作业hw1—新冠阳性人员数量预测。

从非常基础的内容开始,主要是我的水平也很基础。

YI_SHU_JIA的博客 1万+

项目回顾一:《高速多端口共享缓存模块》

本帖子仅为了回顾一下一年前自己动手完成的第一个数字ic设计项目的历程,为复试做一下准备,完成的这个sramc模块花了整整四个月的时间,从0到12个模块的代码产出以及模块的验证,虽然没有拿奖,也没有进入复赛,设计过程中使用的技术手段和算法也不太成熟,但这这是一个值得记录的过程!

weixin_60261760的博客 1254

Rust for Linux 源码导读 | Ref 引用计数容器 原创

引子 2022 年,我们很可能会看到 Linux 内核中的实验性 Rust 编程语言支持成为主流。2021.12.6 早上发出了更新的补丁,介绍了在内核中处理 Rust 的初始支持和基础设施。 这次更新的内容包括: 升级到了最新 Stable 编译器和 Rust 2021 edition 。因此可以摆脱了 const_fn_transmute,const_panic、const_unreachable_unchecked、core_panic 和try_reserve 这几个之前未稳定的特性。 自定义 co

CRMEB小程序商城的博客 1127

【AAOS】【源码分析】用户管理(五)-- MUMD介绍

MUMD(多用户多屏显示)是Android Automotive OS为车载多乘员场景设计的功能,支持不同座位用户独立操作各自屏幕。

码农的历程 1510

Java多线程编程(2)- 线程安全

1 竞态 竞态指计算结果的正确性依赖相对时间顺序和线程的交错,通俗的说就是计算结果与时间有关,对于一个同样的输入,有时候结果正确,有时候结果不正确。竞态不一定会导致结果错误,只是说有这种导致结果出错的可能性。竞态往往伴随着读取脏数据问题,或是丢失更新问题。 2 竞态的模式及竞态产生的条件 竞态有两种模式: read-modify-write(读-改-写) 即读取一个共享变量的值(read),然后根据值做一些计算(modify),最后更新该变量的值(write)。例如...

数智技术 1054

使用buildroot系统的qmake编译QT出现Unknown module(s) in QT: xxxx 的错误解决方法

可以看到,我这里使用的是 qt5base-5.15.2 目录下的qmake,这个qmake只包含了基础的qt模块,而额外添加的qt模块没有包含进去,需要使用。可以看到我这里是有的,如果你跟我一样,模块存在,但是还是报找不到对应模块的错误的话,大概率是跟我一样,用错qmake了,看第二点。在包含 qt5base 目录下查看,是否有对应模块的目录,如果没有的话,需要通过make menuconfig 添加一下。目录下的qmake才可以。

T541132的博客 2489

(ZOS-API)Matlab控制ZEMAX软件调整光学系统某镜面位姿

所用软件的版本:MatlabR2022a, Zemax OpticStudio2024R1交互扩展功能,Zemax和可以同时打开,操作会比较方便在ZEMAX软件中,调整次镜位置通常有两种方法:1.在次镜前后各添加一个坐标间断面,并修改其“偏心X”“偏心Y”等参数来调整次镜的位姿;2.或者直接在次镜属性选项中设置相关数值。这两种方法对Zernike系数的采集结果无显著影响。

ecilifurt的博客 911

Java内存模型

多线程编程的问题 ​ 在前面阅读了Thread类的源码,总结了线程的创建、停止、生命周期,以及多线程编程的异常处理,那么接下来就要总结多线程编程下的安全问题。 ​ 事实上,并不是所用的情况用多线程就一定比单线程快。多线程如果使用不当,不仅会带来严重的安全性问题,也会造成性能问题。 ​ 在JVM内存结构中,我们知道栈、程序计数器、局部变量这些是线程私有的,但是进程范围内的资源,同一个进程内的线程都会共享进程内的地址空间,那么它们会共享堆上分配的对象。当多个线程并发运行时,它们就可能会访问或者修改其他线程正在使

你知道的越多,你不知道的越多 3097

从AT指令到万物互联:移远EG800K-CN模块的协议栈深度解析与实践指南

本文深度解析移远EG800K-CN 4G模块的协议栈架构与AT指令体系,涵盖MQTT云端集成、多协议配置优化及工业级故障排除指南。通过实战示例展示如何利用该模块构建稳定可靠的物联网连接方案,实现从设备到云端的万物互联。

jupyter5notebook的博客 361

Linux内核同步机制之原子操作

本文转自http://www.wowotech.net/linux_kenrel/atomic.html,在作者原文基础上(黑体),加入自己的阅读理解(红色字体),形成学习笔记并记录于此。蜗窝科技,如果你是一位 Linux 驱动开发工程师,那么强烈建议把它加到你的收藏夹,干货实在是太多。 一、源由 我们的程序逻辑经常遇到这样的操作序列: 1、读一个位于 memory 中的变量的值到寄存器中 2、修改该变量的值(也就是修改寄存器中的值) 3、将寄存器中的数值写回 mem...

不捡风筝的玖伍贰柒 887

linux系统下XDMA驱动的安装与测试问题总结

先看两个常见的问题。一个是驱动安装问题,一个是驱动测试错误;error 512 能识别PCIE的设备,但是The kernel modle installed correctly, but no devices were recognized.

whm128的博客 3798

STM32F407中文手册

STM32f407中文手册 本参考手册面向应用开发人员,提供有关使用 STM32F405xx/07xx、STM32F415xx/17xx、 STM32F42xxx 和 STM32F43xxx 微控制器存储器与外设的完整信息。STM32F405xx/07xx、STM32F415xx/17xx、STM32F42xxx 和 STM32F43xxx 构成一个微 控制器系列,各产品具有不同的存储器大小、封装和外设。有关订购信息以及器件的机械与电气特性,请参见数据手册。有关 ARM Cortex™-M4F 内核的信息,请参见《Cortex™-M4F 技术参考手册》。

ARM 学习笔记之7: The Read-Modify-Write operation

The read-modify-write operation ensures that you modify only the specific bits in a system register that you want to change. Individual bits in a system register control different system functionality...

LDWJ2016的博客 2723

Devexpress16.2.6破解工具

Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具

关于一致性,你该知道的事儿(下)

上篇文章讲了一些关于较为底层的一致性内容,这篇文章上升一个层次,讨论讨论应用层面的一些一致性内容。底层给我们提供了实现数据逻辑一致性的一些保证,但是由于上层应用多种多样,需求也各不相同,有些系统宁愿吞吐量降低也不允许不一致的情况发生,而有的系统则可以接受一定程度的不一致,但是需要保证一定的高可用和高并发(因此这些系统可能采没有实现事务功能的数据库系统)。因此,要实现整个应用的一致性,上层系统还需要注意很多东西。下面是几点引起应用不一致的几个常见场景。

xiaopan的博客 1727

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

OV9734 DATASHEET,完整规格书

OV9734完整规格书,包括电气参数,尺寸规格,寄存器配置表等

Java内存模型JMM 高并发原子性可见性有序性简介 多线程中篇(十)

Java内存模型JMM 高并发原子性可见性有序性简介 多线程中篇(十) JVM运行时内存结构回顾 在JVM相关的介绍中,有说到JAVA运行时的内存结构,简单回顾下 整体结构如下图所示,大致分为五大块 而对于方法区中的数据,是属于所有线程共享的数据结构 而对于虚拟机栈中数据结构,则是线程独有的,被保存在线程私有的内存空间中,所以这部分...

noteless的博客 476

Compare-and-Swap 和 Read-Modify-Write

原子的read-modify-write操作——简称RMW——要比原子的load/store更复杂。RMW允许读一块共享数据的同时还原地修改它的值。以fetch_add为例,它首先读取一个共享变量的当前值,对这个值做加法,再将新的值写回去——这3步是原子完成的。你可以用锁来完成同样的操作,但这就不是无锁的了。而RMW操作则天生就被设计为是无锁的。RMW操作可以受益于任何CPU的无锁指令,如ARMv7的ldrex/strex。

Kiris_king的博客 959

线程的安全性问题

Java 对线程的支持是一把双刃剑。多线程虽然拥有提高多核处理器能力、便于编程建模等优点,但也存在一些列风险,如安全性问题、活跃性问题、性能问题。本文将分析多线程带来的安全性问题。 一、竞态条件 1.什么是竞态 多线程编程中经常遇到的一个问题就是对同样的输入,程序的输出有时候是正确的,有时候是错误的。这种一个计算结果的正确性与时间有关的现象就被称为竞态(Race Condition)。 竞态往往伴...

hncboy的博客 578

check then act and read modify write

翻译自stackoverflow: 问题: 例1:check then act if(vector.contains(e))//check { vector.remove(e) } f there are 2 threads can access, thread1 suspends after check vector contains e, and e does in vecto

north hunter 1512

多线程问题(四)

文章目录多线程遇到问题(四)竞态条件竞争数据缓存问题同步synchronized 与 volatile区别 多线程遇到问题(四) 竞态条件 竞争数据 缓存问题 竞态条件 当计算机正确性取决于相对时间或者调度器所控制的线程交叉时,就会产生竞态条件。 check-then-act if(a==10){ rerurn a/2; } 如果线程1在执行a==10后,同时线程2修改a值,最终结果就...

qq_36485886的博客 194

MemoryBIST

通常会要求设计特定的测试算法以定位特定memory defects,这些defects使用现有的算法难以检测到。为了有效测试memory,可以需要以特定的顺序在同一个memory上应用多个算法。一些情况下,可能会选择应用几个算法来诊断memory defects,否则难以识别这些defects。

weixin_44954910的博客 1502

Linux内核API手册——简略版

Kernel API 寻址 页表与页 TLB NODE、CPU与寄存器 寄存器操作 CPU特性 PerCPU变量操作 CPU位掩码 NODE操作 内核同步 内核抢占 原子操作 位锁 内存屏障 自旋锁 位图操作 顺序锁 信号量 PerCPU 读写信号量 完成变量 中断操作 工作队列 等待队列 内存管理 物理页管理 非整页内存管理 非连续内存管理 时间管理 节拍软定时器...

CrazyHeroZK的专栏 1万+

Linux内核设计模式–Reference Counts, kcref

http://www.douban.com/group/topic/7814532/设计模式最早来源于建筑学,后被计算机科学引用。简单来说,一个设计模式描述了某类设计问题,并且针对此类问题给出了一个被实践证明有效的解决方案。Linux内核的开发中也遇到过很多设计问题,并且针对这些问题,内核开发者给出了很好的通用解决方案,只不过长久以来没有人很好的归纳和文档化这些设计模式,导致不是每

LYJ个人博客,记录技术、八卦、生活,分享各种心得,期待与朋友们一同进步 2878
上一篇: LWN:COW以及它最近引出的一系列问题 - 第一部分
下一篇: LWN:不请自来的物联网!
LinuxNews搬运工
博客等级 码龄7年 438粉丝 20原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值