面试遇到多线程的第三天-读写安全

361 阅读5分钟

本文是多线程部分的最后一篇,是对前面加锁只是的更进一步的实践。主要就分享一个知识点,就是标题里说的读写安全,读写安全也是我们开发中遇到比较多的一种场景。

还有一个也属于多线程方面的知识点,这里再补充一下,就是关键词atomic。

atomic

atomic用于保证属性setter、getter的原子性操作,所谓原子性操作,可以理解为不可分割的操作,就是保证方法内部是线程同步的,要把这个方法执行完才能继续后面的操作。

其实也相当于在setter、getter内部增加了线程同步的锁。

可以根据源码去进一步理解这里的加锁操作,我们找到objc源码中的objc-accessors.mm,找到objc_getProperty方法的实现,这里有一个atomic的判断,如果!atomic,就直接return,如果atomic设置为true,则使用spinlock_t加锁

id objc_getProperty(id self, SEL _cmd, ptrdiff_t offset, BOOL atomic) {
    if (offset == 0) {
        return object_getClass(self);
    }

    // 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);
}

spinlock_t的底层实现也是用了上一篇文章中分享过的os_unfair_lock这个锁。

PropertyLocks就是一个map,里面放了属性值和对应的锁。

再看一下setter方法的底层实现,最后是调用了reallySetProperty,同样也有一个对atomic的判断逻辑

static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
    if (offset == 0) {
        object_setClass(self, newValue);
        return;
    }

    id oldValue;
    id *slot = (id*) ((char*)self + offset);

    if (copy) {
        newValue = [newValue copyWithZone:nil];
    } else if (mutableCopy) {
        newValue = [newValue mutableCopyWithZone:nil];
    } else {
        if (*slot == newValue) return;
        newValue = objc_retain(newValue);
    }

    if (!atomic) {
        oldValue = *slot;
        *slot = newValue;
    } else {
        spinlock_t& slotlock = PropertyLocks[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue;        
        slotlock.unlock();
    }

    objc_release(oldValue);
}

那么,既然atomic可以保证setter和getter的线程同步,为什么我们开发中很少使用他呢?其实这里有一下三个原因:

  1. atomic只是保证setter和getter方法内部是线程同步的,但并不能保证使用属性的过程是线程安全的
  2. 使用属性的频率高,频繁加锁解锁很消耗性能
  3. 只有同时对同一个属性进行读写操作才会产生异常,实际开发中这样的场景也并不多

对于上面原因中的第一点可能不太好理解,举个例子

@interface Person : NSObject
@property (atomic, strong) NSMutableArray *array;
@end

对于Person中的array,使用了atomic修饰,保证其setter和getter方法线程同步,就是说只是调用set方法是线程同步的,但如果是多条线程同时对array进行添加,就没办法保证线程安全了,就是第一点原因,不能保证属性的使用过程是线程安全的。

 Person *p = [[Person alloc] init];
 p.array = [NSMutableArray array];
        
//多条线程同时进行添加 就不是线程安全的  
[p.array addObject:@"1"];

读写安全

读写就是常说的IO操作,文件操作,这类操作一般比较耗时,放在子线程中操作的时候更要注意线程安全问题。
先用上一篇文章中分享过的知识点思考一下这个读写安全的问题如何解决呢?

其实就和上一篇文章中说的存取钱问题很类似了,使用加锁把读操作和写操作给锁起来。先用信号量来解决一下读写的问题,也顺便复习一下信号量,信号量的初始值可以用来控制线程并发访问的最大数量,当设置为1的时候,则代表同时只允许一条线程访问资源,保证了线程同步

@interface ViewController ()
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    self.semaphore = dispatch_semaphore_create(1);
    for (int i = 0; i < 5 ; i++) {
        [[[NSThread alloc] initWithTarget:self selector:@selector(read) object:nil] start];

           [[[NSThread alloc] initWithTarget:self selector:@selector(write) object:nil] start];
    }

}

- (void)read {
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"%s",__func__);
    dispatch_semaphore_signal(self.semaphore);
}

- (void)write {
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    NSLog(@"%s",__func__);
    dispatch_semaphore_signal(self.semaphore);
}

这个方案毫无疑问可以解决读写安全的问题,但是也存在一个问题,那就是,不管读写,同一时间只有一条线程在执行,效率很低。

要解决这个问题,首先要再分析分析,读写的这个实际场景,其实我们想要的方案应该是这样的:

  • 同一时间只有一条线程可以写
  • 同一时间可以有多条线程进行读
  • 同一时间不允许既有读又有写

上面的几点要求就是典型的“多读单写”,因为读取是不会对数据造成破坏的,只有同时写才会有线程安全的问题。

多读单写

对于“多读单写”的使用场景,iOS中也有现成的实现方案

  • 读写锁:pthread_rwlock
  • 栅栏: dispatch_barrier_async

pthread_rwlock

pthread_rwlock是专门为读写操作定义的锁,有专门对读操作进行加锁解锁的方法和对写操作加锁解锁的操作。 直接贴一下读写锁的使用:

//读写锁
- (void)viewDidLoad {
    pthread_rwlock_init(&_lock, NULL);

    dispatch_queue_t queue = dispatch_queue_create(0, 0);
    dispatch_async(queue, ^{
        for (int i = 0; i < 10; i++) {
            dispatch_async(queue, ^{
                [self read];
            });
            dispatch_async(queue, ^{
                [self write];
            });
        }
    });
}

- (void)read {
    pthread_rwlock_rdlock(&_lock);
    sleep(1);
    NSLog(@"%s",__func__);

    pthread_rwlock_unlock(&_lock);
}

- (void)write {
    pthread_rwlock_wrlock(&_lock);
    sleep(1);
    NSLog(@"%s",__func__);

    pthread_rwlock_unlock(&_lock);
}

- (void)dealloc
{
    pthread_rwlock_destroy(&_lock);
}

dispatch_barrier_async

栅栏可以想象现实中的栅栏,就是把block中的任务,用栅栏隔离起来,不允许同一个队列中别的线程同时调用。

异步栅栏调用,需要传入一个手动通过dispatch_queue_create创建的并发队列,如果传入的是全局并发队列,或者串行队列,则达不到我们想要的“多读单写”的要求了。

- (void)viewDidLoad {
    self.queue = dispatch_queue_create("rw_queue", DISPATCH_QUEUE_CONCURRENT);
    for (int i = 0; i < 10; i++) {
        [self write];
        [self read];
    }
}
- (void)read {
    dispatch_async(self.queue, ^{
        sleep(1);
        NSLog(@"%s",__func__);
    });
}

- (void)write {
    dispatch_barrier_async(self.queue, ^{
        sleep(1);
        NSLog(@"%s",__func__);
    });
}

对于上面的两种实现“多读单写”的方案,有缘读到本文的小伙伴,可以手敲一遍代码,看看最终的打印结果,还可以在多加一些log,验证一下,读操作是会同时发生的,而读写是不会同时发生的,并且不会同时写。

对于多线程的分享告一段落,总感觉多线程的知识是比较抽象不容易理解的,如果本系列的三篇文章有写的不准确的欢迎评论区留言一起讨论学习。

参考:
面试遇到多线程的第一天-死锁

面试遇到多线程的第二天-安全隐患(锁)