线程锁漫谈

01

介绍

锁和多线程有密不可分的关系,多线程环境下,如果线程A访问一段被锁保护的代码,线程B访问时,需要等线程A将锁释放,才可以进行访问。

02

类型

这里介绍下主要的几种锁:

  • NSRecursiveLock:递归锁,可以在一个线程中反复获取锁不会造成死锁,这个过程会记录获取锁和释放锁的次数来达到何时释放锁的作用。

  • NSConditionLock:条件锁,用户定义条件,确保一个线程可以获取满足一定条件的锁。因为线程间竞争会涉及到条件锁检测,系统调用上下切换频繁导致耗时是几个锁里最长的。

  • OSSpinLock:自旋锁,不进入内核,减少上下文切换,性能最高。但抢占多时会占用较多cpu,好点多,这时使用pthread_mutex较好。

  • os_unfair_lock:用来替代OSSpinLock的,OSSpinLock存在的一些问题,后面会讲到。

  • pthread_mutex_t:互斥锁,C语言层面的锁,底层api性能高。

  • @synchronized:更加简单,通过代码块的方式加锁。

  • dispatch_semaphore:信号量,GCD中提供的一套API

  • NSLock:互斥锁,上层的高级封装,使用简单,无需关心底层细节处理。

  • pthread_spinlock_t:pthread层级的自旋锁,性能比pthread_mutex_t要高。在等待的时候会进入“自旋”状态,不会进入休眠。但是,iOS系统并不支持,所以用不了。

03

资源抢夺

在开发中往往是多线程的,如果多个线程同时对同一资源进行读写,就可能造成资源抢夺的问题,导致数据计算结果不是我们想要的结果。对于资源抢夺,一般都是通过加锁的方式来解决,或者用调度队列来解决。

下面是一个例子,来模拟资源抢夺,通过sleep来模拟不同线程中的逻辑处理。

self.count = 15;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
    
dispatch_async(queue, ^{
    for (int i = 0; i < 5; i++) {
        [self saleTicket];
    }
});
    
dispatch_async(queue, ^{
    for (int i = 0; i < 5; i++) {
        [self saleTicket];
    }
});
    
dispatch_async(queue, ^{
    for (int i = 0; i < 5; i++) {
        [self saleTicket];
    }
});

- (void)saleTicket {
    NSInteger oldCount = self.count;
    sleep(0.1);
    oldCount--;
    self.count = oldCount;
    NSLog(@"count: %ld, thread: %@", self.count, [NSThread currentThread]);
}

从打印结果可以看出,由于是先获取的值,休眠一段时间后,再进行计算和赋值操作。所以,导致进行计算时,使用的还是旧值,导致旧值的计算结果把其他线程的计算结果覆盖。这是资源抢夺中的一种场景。

count: 14, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 14, thread: <NSThread: 0x600003668840>{number = 6, name = (null)} // 发生了资源抢夺
count: 13, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 12, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 11, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 10, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 9, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 8, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 7, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 6, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 5, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 4, thread: <NSThread: 0x600003655080>{number = 7, name = (null)}
count: 3, thread: <NSThread: 0x600003670f00>{number = 5, name = (null)}
count: 2, thread: <NSThread: 0x600003668840>{number = 6, name = (null)}
count: 2, thread: <NSThread: 0x600003655080>{number = 7, name = (null)} // 发生了资源抢夺

04

锁的分类

这里先从类型的纬度,介绍下有哪些类型的锁。

4.1 自旋锁

自旋锁属于一种盲等的锁,当其他线程执行到已经加锁的代码时,就会进入持续等待,这个等待过程是持续消耗CPU的。

例如下面代码,如果method1中已经执行lock方法,处于上锁的状态。执行到method2lock方法时,所在的线程就会开始执行一个耗时任务,直到method1lock放开锁为止。

- (void)method1 {
    lock(lock);
    // ...
    unlock(lock);
}

- (void)method2 {
    lock(lock);
    // ...
    unlock(lock);
}

但是,自旋锁的效率相对是很高的,因为不用进行线程唤醒的操作,而是让线程直接进入盲等状态,相当于运行了一个while循环。以OSSpinLock为例,自旋锁的实现是一个循环,下面a32a43的部分,实际上是循环执行的。

4.2 互斥锁

互斥锁在进入等待的状态时,会进入休眠状态。

pthread_mutex_lock为例,其本质是调用了syscall进入系统休眠状态,lock后面的代码不再继续执行。直到持有lock的线程调用unlock,会唤醒线程继续执行,线程进行调度的过程中是有一定性能损耗的。

4.3 递归锁

递归锁允许同一个线程,对同一个lock对象重复加锁。

普通锁在执行递归操作时,第二次对同一个lock对象加锁,就会导致死锁。而递归锁则允许出现递归调用的情况,对同一个lock对象递归调用两次也是被允许的。

4.4 选型

目前主流的锁是互斥锁和自旋锁,二者的选型主要看需求场景。自旋锁不涉及线程挂起和唤醒的操作,如果是等待时间比较短的场景,使用自旋锁比较合适。尤其是在多核CPU中,被频繁调用的场景下,很适合用自旋锁。

因为自旋锁比较消耗CPU,如果是比较耗时的任务,用互斥锁可以节省性能。

4.5 优先级反转

CPU执行不同线程的任务,是通过时间片来进行的。实际执行的时候,由系统的时间片调度算法,来调度CPU执行某个线程的任务,执行一段时间后,再去执行另一个线程的任务,这样来实现多线程。

锁会带来优先级反转的问题,即出现高优先级线程等待低优先级线程的执行,甚至导致死锁的问题。

这里用到一张很经典的优先级反转的图,有三条线程,线程A优先级为10,优先级最低,线程B优先级15,线程C优先级20,优先级最高。线程A先获得互斥锁,线程C也要获得互斥锁,随后C进入等待过程。

这时,如果线程B执行任务,就会抢占线程A的时间片,线程A要等线程B执行完,才可以继续执行剩余任务并释放锁。从而,线程B也间接影响了高优先级的线程C的执行,形成了优先级反转。

优先级反转和自旋锁、互斥锁种类并无关系,当不同等级的线程伴随着锁出现时,理论上就会出现优先级反转的问题。在相同优先级的线程中不会出现优先级反转。避免优先级反转的方式也比较简单,就是在低优先级线程获得锁之前,将其设置为高优先级线程,释放锁后再设置回低优先级。

从操作系统的层面,有些系统已经内置了解决方案,在遇到此类优先级反转的情况时,会出让高优先级线程的优先级给让低优先级线程,保证锁内资源执行的正常。

05

都有哪些锁

5.1 OSSpinLock

OSSpinLock的类型是自旋锁,但是由于其已经不能再保证线程安全性,所以苹果不建议使用,这里仅作为了解,苹果推荐使用iOS 10新出来的os_unfair_lock来替代OSSpinLock。如果抛开安全性单说性能,OSSpinLockiOS开发中性能最好的锁。

5.2 os_unfair_lock

os_unfair_lock是一个互斥锁,从iOS 10开始支持。和OSSpinLock的实现机制不同,os_unfair_lock在已经lock的情况下,等待os_unfair_lock的线程并不是循环,而是将线程睡眠,不会占用CPU资源。

os_unfair_lock_t本质上是一个结构体,属于比较底层的os层的锁,所以苹果是建议我们用更高级的封装,例如NSLock或者信号量。初始化os_unfair_lock_t时,需要通过指定的方式OS_UNFAIR_LOCK_INIT进行初始化。

typedef struct os_unfair_lock_s os_unfair_lock;
typedef struct os_unfair_lock_s * os_unfair_lock_t;
typedef struct _os_unfair_lock_s {
 os_ulock_value_t oul_value;
} *_os_unfair_lock_t;

在使用os_unfair_lock时需要注意,加锁和解锁的线程需要时同一个线程,否则会导致崩溃。

extern void os_unfair_lock_lock(os_unfair_lock_t lock);
extern bool os_unfair_lock_trylock(os_unfair_lock_t lock);
extern void os_unfair_lock_unlock(os_unfair_lock_t lock);

5.3 pthread_mutex_t

pthread_mutex_t是一个C语言层面的锁,相对底层,需要自己进行内存管理。pthread_mutex_t可以进行跨平台使用,可以创建两种类型的锁,一个是互斥锁,一个是递归锁,在初始化时指定类型。

下面是互斥锁的初始化,PTHREAD_MUTEX_DEFAULT表示一个互斥锁。如果想创建一个递归锁,则改成PTHREAD_MUTEX_RECURSIVE即可。需要注意的是,在dealloc时需要将pthread_mutex_t销毁。

- (void)dealloc {
    pthread_mutex_destroy(&self->_lock);
}

- (void)viewDidLoad {
    [super viewDidLoad];
    
    pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);
    pthread_mutex_init(&self->_lock, &attr);
    pthread_mutexattr_destroy(&attr);
}

- (void)getUserInfo {
    pthread_mutex_lock(&self->_lock);
    /// 处理业务代码
    pthread_mutex_unlock(&self->_lock);
}

除了常规使用方法外,pthread_mutex_t还可以配合pthread_cond_waitpthread_cond_signal两个函数,实现条件锁,具体和信号量的使用比较像。

5.4 NSLock

NSLock我们平时用的比较多,是高级封装的一个互斥锁,其和NSRecursiveLock等高级封装一样,遵循NSLocking并实现加锁细节。根据汇编源码可以看出,NSLock是对pthread_mutex互斥锁的封装,使用也很简单。

@protocol NSLocking
- (void)lock;
- (void)unlock;
@end

@interface NSLock : NSObject <NSLocking>
@property (nullable, copy) NSString *name;
- (BOOL)tryLock;
- (BOOL)lockBeforeDate:(NSDate *)limit;
@end

需要注意的是,因为是互斥锁,连续在同一个线程中执行lock会导致死锁,如果这个线程是主线程,则会导致界面卡死,并在一定时间后被系统kill掉。如果这个线程是子线程,则会导致后面获得这个锁的线程,一直被卡住。

对于死锁的问题,我们有两种方案解决,一种是换成NSRecursiveLock递归锁,另一种是在可能发生重复的lock的地方,增加tryLock的判断。如果发生重复加锁的情况,则返回NO

NSLock *lock = [[NSLock alloc] init];
// A线程
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [lock lock];
    /// 业务代码
    sleep(2);
    [lock unlock];
});

// B线程
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    sleep(1);
    
    if ([lock tryLock]) {
        [lock lock];
        /// 业务代码
        [lock unlock];
    } else {
        NSLog(@"加锁失败");
    }
});

5.5 NSRecursiveLock

NSRecursiveLock正如其名,是一个递归锁,也就是同一个线程,可以多次获取同一个锁,而不会发生死锁。如果是同一个线程,NSRecursiveLock会记录获取的次数,当此线程的unlocklock次数匹配时,才会释放资源给其他线程。

根据汇编源码可以看出,NSRecursiveLock是一个PTHREAD_MUTEX_RECURSIVE类型的pthread_mutex_t的封装。用下面例子可以验证NSRecursiveLock,其并不会发生死锁。

self.lock = [[NSRecursiveLock alloc] init];
for (NSInteger i = 0; i < 100; i++) {
    [self.lock lock];
    NSLog(@"加锁");
}
    
for (NSInteger i = 0; i < 100; i++) {
    [self.lock unlock];
    NSLog(@"解锁");
}
    
NSLog(@"循环已结束");

5.6 NSCondition

NSCondition是条件锁,是对pthread_mutex_tpthread_cond的封装。由于继承了NSLocking协议,所以其自身已经具备了锁的功能。

在使用场景方面,正如其名,可以通过设置条件决定锁是否进入waiting状态,如果进入waiting状态,则需要其他broadcast或者signal来触发wait的后续代码执行。

self.condition = [[NSCondition alloc] init];
    
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSLog(@"thread:%@", [NSThread currentThread]);
    [self lockMethod];
});
    
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSLog(@"thread:%@", [NSThread currentThread]);
    [self lockMethod];
});
    
sleep(1);

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSLog(@"thread:%@", [NSThread currentThread]);
    [self unlockMethod];
});

- (void)lockMethod {
    [self.condition lock];
    NSLog(@"lockMethod lock");
    
    NSLog(@"lockMethod wait...");
    [self.condition wait];
    NSLog(@"lockMethod wait completion");
    
    [self.condition unlock];
    NSLog(@"lockMethod unlock");
}

- (void)unlockMethod {
    [self.condition lock];
    NSLog(@"unlockMethod lock");
    
    NSLog(@"unlockMethod broadcast");
    [self.condition broadcast];
    
    sleep(1);
    NSLog(@"unlockMethod unlock");
    [self.condition unlock];
}

通过多次测试数据来看,如果只在unlockMethod中执行broadcastsignal,并不会立刻触发wait后的代码执行。因为其除了条件锁之外,本质上还是个互斥锁,需要unlockMethod所在的线程释放锁之后,才会执行到wait后的代码。

thread:<NSThread: 0x600001745200>{number = 6, name = (null)}
thread:<NSThread: 0x600001756540>{number = 3, name = (null)}
lockMethod lock
lockMethod wait...
lockMethod lock
lockMethod wait...
thread:<NSThread: 0x600001750480>{number = 7, name = (null)}
unlockMethod lock
unlockMethod broadcast
unlockMethod unlock
lockMethod wait completion
lockMethod unlock
lockMethod wait completion
lockMethod unlock

5.7 NSConditionLock

NSConditionLock是对NSCondition的进一步封装,NSConditionLock的核心在于,当condition为某个条件值时,才会触发另一个线程的lock代码的执行。

初始化condition时,可以传入一个值,并且可以在加锁和解锁的时候,也传入一个值。lockWhenCondition表示当condition符合传入的值,才执行加锁操作。unlockWithCondition表示,解锁并传入一个condition值,传入的值可能会触发其他的lockWhenCondition

下面是NSConditionLock常用的一些方法,还有其继承的NSLocking相关方法也是常用方法。

- (instancetype)initWithCondition:(NSInteger)condition;
- (void)lockWhenCondition:(NSInteger)condition;
- (void)unlockWithCondition:(NSInteger)condition;

下面示例中创建了三个线程,并且初始化的时候传入的condition0。当执行threadActionC方法时,符合初始化时传入的value,则Clock方法被执行。随后会执行到unlockCondition传入1,执行threadActionBlock内方法,直到执行到threadActionA,执行到unlock则锁被释放。

self.lock = [[NSConditionLock alloc] initWithCondition:0];
    
NSThread *threadA = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionA) object:nil];
[threadA start];
sleep(0.2);
    
NSThread *threadB = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionB) object:nil];
[threadB start];
sleep(0.2);
    
NSThread *threadC = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionC) object:nil];
[threadC start];

- (void)threadActionC {
    NSLog(@"C begin");
    /// 由于Condition初始值是0,此时Condition值为0才能加锁成功,成功后会继续执行后续代码
    [self.lock lockWhenCondition:0];
    NSLog(@"C threadExcute");
    [self.lock unlockWithCondition:1];
}

- (void)threadActionB {
    NSLog(@"B begin");
    /// C设置Condition为1则将B解锁,并执行B的代码及unlock
    [self.lock lockWhenCondition:1];
    NSLog(@"B threadExcute");
    [self.lock unlockWithCondition:2];
}

- (void)threadActionA {
    NSLog(@"A begin");
    /// B设置Condition为1则将A解锁,并执行A的代码及unlock,锁被释放出来
    [self.lock lockWhenCondition:2];
    NSLog(@"A threadExcute");
    [self.lock unlock];
}

通过日志可以看出,NSConditionLock很适合用来控制多条线程的执行,例如需要多条线程串行执行,并根据判断条件控制执行顺序。

A begin
B begin
C begin
C threadExcute
B threadExcute
A threadExcute

5.8 DISPATCH_QUEUE_SERIAL

通过GCD的串行队列,也可以实现线程锁的作用。将资源代码放在串行队列中执行即可,这种方式不会出现多次lock崩溃的问题,安全且灵活。

self.serialQueue = dispatch_queue_create("com.sohu.serialQueue", nil);

NSThread *threadA = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionA) object:nil];
[threadA start];
sleep(0.2);

NSThread *threadB = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionB) object:nil];
[threadB start];
sleep(0.2);

NSThread *threadC = [[NSThread alloc] initWithTarget:self selector:@selector(threadActionC) object:nil];
[threadC start];

- (void)threadActionA {
    NSLog(@"A begin");
    dispatch_async(self.serialQueue, ^{
        NSLog(@"A threadExcute");
    });
}

- (void)threadActionB {
    NSLog(@"B begin");
    dispatch_async(self.serialQueue, ^{
        NSLog(@"B threadExcute");
    });
}

- (void)threadActionC {
    NSLog(@"C begin");
    dispatch_async(self.serialQueue, ^{
        NSLog(@"C threadExcute");
    });
}

5.9 dispatch_semaphore_t

通过GCD的信号量,也可以实现锁的效果,并且信号量还可以进行waitsignal的控制。信号量可以指定并发执行数量,但一般为了防止资源抢夺,不会允许并发执行。实际上,如果不考虑资源抢夺的场景,semaphore设置的value就是并发的数量,只有其为正数才会执行waitsignal之间的代码。

通过下面的Demo演示下如何防止资源抢夺,将semaphore设置为1,这样可以实现同时只有1个线程访问waitsignal中间的资源。信号量在等待释放锁的过程中,线程也会进入休眠,让出CPU的时间片,不会占用资源。

self.semaphore = dispatch_semaphore_create(1);
for (NSInteger i = 0; i < 5; i++) {
    NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(threadAction) object:nil];
    [thread start];
}

- (void)threadAction {
    NSLog(@"semaphore waiting");
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"thread:%@", [NSThread currentThread]);
    sleep(1);
    dispatch_semaphore_signal(self.semaphore);
}

下面是打印结果,semaphore大于等于0会执行waitsignal中间的代码,随后就是等下一个signal信号的到来。

semaphore waiting
semaphore waiting
semaphore waiting
semaphore waiting
semaphore waiting
thread:<NSThread: 0x60000173e040>{number = 7, name = (null)}
thread:<NSThread: 0x60000173de40>{number = 9, name = (null)}
thread:<NSThread: 0x60000173e200>{number = 10, name = (null)}
thread:<NSThread: 0x60000173cec0>{number = 11, name = (null)}
thread:<NSThread: 0x60000173e080>{number = 8, name = (null)}

5.10 @synchronized

@synchronized会以传入的对象为纬度,进行加锁。如果多段代码都用@synchronized修饰,则同一个对象只允许被同时访问一次。但如果同一个类型的对象,是不同的实例,可以直接对类对象进行加锁,以保证对同一个对象加锁。如果不同的对象,会有可能导致@synchronized传入的不同对象,执行时出现“锁失效”的情况。

@synchronized ([self class]) {
    [self threadAction];
}

@synchronized本质上就是语法糖,并没有直接的源码实现,需要用clang@synchronized进行分析,可以看出来@synchronized是由objc_sync_enterobjc_sync_exit实现的,由编译器将objc_sync_enterobjc_sync_exit插入到对应的位置,相关函数的实现可以从runtime中的objc-sync.hobjc-sync.m看到。

从更底层来看,@synchronized本质上是对pthread_mutex_t的高级封装,由于用的是pthread_mutex_t的递归锁,所以@synchronized可以被递归调用。我后续会出一篇文章,详细介绍@synchronized的深层实现,这里不过多展开。

using recursive_mutex_t = recursive_mutex_tt<DEBUG>;
class recursive_mutex_tt : nocopy_t {
    pthread_mutex_t mLock;

  public:
    recursive_mutex_tt() : mLock(PTHREAD_RECURSIVE_MUTEX_INITIALIZER) { }
    void lock() {
        lockdebug_recursive_mutex_lock(this);
        int err = pthread_mutex_lock(&mLock);
        if (err) _objc_fatal("pthread_mutex_lock failed (%d)", err);
    }
}

5.11 atomic

属性的atomic定义在runtime源码的objc-accessors.mm文件中,下面是settergetter的核心实现。从代码实现可以看到,如果是原子性的属性,会加一个spinlock_t自旋锁。

static inlinevoid reallySetProperty(idself, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, boolcopy, bool mutableCopy) {
    if (!atomic) {
        oldValue = *slot;
        *slot = newValue;
    } else {
        spinlock_t& slotlock = PropertyLocks[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;        
        slotlock.unlock();
    }

    objc_release(oldValue);
}

下面是getter的实现,有趣的是,虽然叫spinlock_t,但其并不是自旋锁,而是由os_unfair_lock实现,其本质是一个互斥锁。这是因为spinlock_t原先是通过OSSpinLock实现,但OSSpinLock存在优先级反转的问题,后来将内部实现替换为os_unfair_lock,但并未改spinlock_t的名字。

id objc_getProperty_non_gc(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
    // Retain release world
    id *slot = (id*) ((char*)self + offset);
    if (!atomic) return *slot;
        
    // Atomic retain release world
    spinlock_t& slotlock = PropertyLocks[slot];
    slotlock.lock();
    id value = objc_retain(*slot);
    slotlock.unlock();
    
    // for performance, we (safely) issue the autorelease OUTSIDE of the spinlock.
    return objc_autoreleaseReturnValue(value);
}

需要注意的是,如果将一个属性设置为atomic,这个关键字只是针对于属性的settergetter方法,调用属性的settergetter方法是原子性操作。如果我们调用这个属性的方法,或者通过成员变量的方式直接获取属性,这些操作并不受锁的保护。对于这些会导致资源抢夺的操作,最好在业务层使用lock

由于atomic性能较差,定义的属性如果使用较频繁,并不适合用atomic修饰。否则会出现大量的锁操作,导致性能下降。

06

锁的性能对比

下面是对iOS中锁的性能对比,从排行来看,@synchronized性能是最差的,但是这种锁使用起来遇到的问题也比较少,不会出现重复lock的崩溃,如果执行次数很少,还是可以考虑的。个人认为,根据执行次数来选择锁,执行次数越多则越要考虑性能高的锁,然后在业务层做一些必要的安全处理。

  • OSSpinLock;

  • dispatch_semaphore_t;

  • pthread_mutex_t;

  • DISPATCH_QUEUE_SERIAL;

  • NSLock;

  • NSCondition;

  • os_unfair_lock;

  • pthread_mutex_t(recursive);

  • NSRecursiveLock;

  • NSConditionLock;

  • @synchronized;

07

参考资料

优先级反转那点事儿:

https://zhuanlan.zhihu.com/p/146132061

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值