线程安全与锁

UIKit和Foundation的线程安全

在UIKit和Foundation中,有很多操作都是非线程安全的,比如多线程修改可变容器的内容可能会引发问题,必须在主线程设置UIImageView的image属性,苹果在Thread-Safe Class Design一文提到

It’s a conscious design decision from Apple’s side to not have UIKit be thread-safe. Making it thread-safe wouldn’t buy you much in terms of performance; it would in fact make many things slower. And the fact that UIKit is tied to the main thread makes it very easy to write concurrent programs and use UIKit. All you have to do is make sure that calls into UIKit are always made on the main thread.

大意是只在主线程使用UIKit将会使并发控制变得简单,并且让UIKit变得线程安全将会带来很多性能开销,因此,苹果有意识不让UIKit线程安全,并让我们在主线程对UIKit进行调用。

锁按照等抢锁失败时处理方式的不同分为自旋锁和互斥锁。

自旋锁(spinlock)

自旋锁只要没有锁上,就不断重试,可以理解成线程在while循环做抢锁操作。显然,如果别的线程长期持有该锁,那么你这个线程就一直在 while while while 地检查是否能够加锁,浪费 CPU 做无用功。

while (抢锁(lock) == 没抢到) {
}
互斥锁(mutex)

互斥锁也叫互斥量,它在抢锁失败时,线程会进入休眠状态,优点就是节省CPU资源,缺点就是休眠唤醒会消耗一点时间。

互斥锁相对自旋锁,更适合于耗时的任务,比如文件IO,大量循环操作等;也适合于临界区竞争激烈的情况,线程很难抢到锁。互斥锁可以避免while循环长期暂用CPU资源,而自旋锁抢锁响应更快,更适合于抢锁等待时间短的轻量级任务,可以避免休眠与唤醒的性能消耗和延迟。

锁按照在同一线程中是否可以对某个锁重入,分为递归锁和非递归锁。

递归锁(recursive mutex)

递归锁也叫可重入锁,允许被同一线程多次进行重复加锁,而不会引起死锁。递归锁相对非递归锁增加了性能开销,尽量避免使用。

锁按照多个处于抢锁等待的线程是否需要排队分为公平锁和非公平锁。

公平锁(Fair)

加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得 。
优:线程按照顺序获取锁,不会出现饿死现象(注:饿死现象是指一个线程的CPU执行时间都被其他线程占用,导致得不到CPU执行)。
缺:整体吞吐效率相对非公平锁要低,等待队列中除一个线程以外的所有线程都会阻塞,CPU唤醒线程的开销比非公平锁要大。

非公平锁(Nonfair)

加锁时不考虑排队等待问题,哪个线程先抢到先加锁。
优:可以减少唤起线程上下文切换的消耗,整体吞吐量比公平锁高。
缺:在高并发环境下可能造成线程优先级反转和饿死现象。

读写锁(多读单写策略)

对数据或文件的多个线程同时读操作不需要加锁,因为读操作不涉及到数据源的修改,不会引发线程安全问题。
读操作和写操作之间需要加锁。举个例子,数据源是一张苹果图片,A线程将其改成一张香蕉图片,在A线程写入图片操作执行一半时候,B线程从数据源读取图片数据,则此时读到的图片资源是无效的,既不是苹果也不是香蕉。
写与写操作之间同理需要加锁。

优先级反转(Priority Inversion)

优先级反转是一种不希望发生的任务调度状态。在该种状态下,一个高优先级任务间接被一个低优先级任务所先占,使得两个任务的相对优先级被倒置。 这往往出现在一个高优先级任务等待访问一个被低优先级任务正在使用的临界资源,从而阻塞了高优先级任务。
如下图,在步骤3中,TaskA由于其所需要的某个资源被低优先级的任务TaskC占用了,所以高优先级TaskA就被阻塞了。若此时加入中优先级的TaskB,由于比TaskC的优先级高,则TaskB优先执行,导致最终TaskA等待时间更长。

优先级反转

优先级反转的解决方式有优先级天花板和优先级继承。

优先级天花板(priority ceiling)

优先级天花板是当任务申请某资源时, 把该任务的优先级提升到可访问这个资源的所有任务中的最高优先级, 这个优先级称为该资源的优先级天花板。这种方法简单易行, 不必进行复杂的判断, 不管任务是否阻塞了高优先级任务的运行, 只要任务访问共享资源都会提升任务的优先级。

优先级继承(priority inheritance)

优先级继承是当TaskA 申请共享资源时, 如果共享资源正在被TaskC 使用,通过比较TaskC 与自身的优先级,如发现任务C 的优先级小于自身的优先级, 则将TaskC的优先级提升到自身的优先级, 任务C 释放资源 后,再恢复TaskC 的原优先级。这种方法只在占有资源的低优先级任务阻塞了高优先级任务时才动态的改变任务的优先级,如果过程较复杂, 则需要进行判断。

iOS中锁方案:

OSSpinLock

自旋锁。需要导入头文件<libkern/OSAtomic.h>,其中libkern是苹果XNU内核中提供的一个C++内核库,libkern指 kernel library(内核库)。(源码地址:https://opensource.apple.com/source/xnu/xnu-7195.81.3/libkern/libkern/OSAtomic.h.auto.html
OSSpinLock会引发优先级反转问题(和前面的优先级反转例子略有不同)。(https://blog.ibireme.com/2016/01/16/spinlock_is_unsafe_in_ios/?utm_source=tuicool
高优先级因为自旋操作处于忙等状态从而占用大量 CPU,此时低优先级线程无法与高优先级线程争夺 CPU 时间,从而导致任务迟迟完不成、无法释放 lock。
最终的结论就是,除非开发者能保证访问锁的线程全部都处于同一优先级,否则 iOS 系统中所有类型的自旋锁都不能再使用了。
相对自旋锁的线程处于忙等状态占用大量 CPU,互斥锁中高优先级的线程会进入休眠状态,避免了高优先级线程一直抢占着CPU资源。
OSSpinLock 提供OSSpinLockTry 尝试加锁函数,即加锁失败不会忙等,直接返回false。

os_unfair_lock

os_unfair_lock从字面理解是个非公平锁,iOS10开始支持。与OSSpinLock不同的是,os_unfair_lock尝试获取的线程也会进入休眠,解锁时由内核唤醒。使用时需要导入头文件<os/lock.h>。(源码地址:https://opensource.apple.com/source/libplatform/libplatform-254.80.2/include/os/lock.h.auto.html
通过实时反汇编分析,os_unfair_lock默认是互斥锁,但从源码看出os_unfair_lock也可以通过方法os_unfair_lock_lock_with_options_inline设置OS_UNFAIR_LOCK_ADAPTIVE_SPIN可选参数,将其设置为自旋锁。

os_unfair_recursive_lock

os_unfair_recursive_lock是非公平递归锁,iOS12开始支持,但目前是私有,不对开发者公开。(源码地址:https://opensource.apple.com/source/libplatform/libplatform-254.80.2/private/os/lock_private.h.auto.html

pthread系列(POSIX Threads)

pthread是POSIX的线程标准,定义了创建和操纵线程的一套API。pthread支持跨平台,在unix,linux,windows,ios等系统下都支持。
注:POSIX指可移植操作系统接口(Portable Operating System Interface),是IEEE为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称。
源码地址:https://opensource.apple.com/source/libpthread/

pthread_mutex_t

pthread_mutex_t是互斥锁,可以在初始化时指定为递归锁,普通锁(非递归)或错误检查锁。
错误检查锁比普通锁效率稍微慢点,文档关于错误检查锁的说明,如果同一个线程请求同一个锁,则返回EDEADLK

If the mutex type is PTHREAD_MUTEX_ERRORCHECK, then error checking shall be provided. If a thread attempts to relock a mutex that it has already locked, an error shall be returned.

pthread_cond_t

pthread_cond_t指条件变量,需要配合pthread_mutex_t使用。
pthread_cond_wait会对mutex解锁,同时线程陷入陷入休眠,等待被激活(signal),若被激活同时会自动对mutex加锁。pthread_cond_signal会激活cond,但最多只会激活一个线程。若要对所有休眠cond进行激活,可使用pthread_cond_broadcast函数。
pthread_cond_wait必须放在pthread_mutex_lockpthread_mutex_unlock之间,pthread_cond_signal即可以放在pthread_mutex_lockpthread_mutex_unlock之间,也可以放在pthread_mutex_lockpthread_mutex_unlock之后。
pthread_cond_t常用于实现多个线程临界区部分代码存在的的执行依赖的关系(A线程代码执行一半需要进入休眠,等待B线程任务完成,A线程再执行剩下代码),在比如可以解决生产者消费者问题的 生产者在缓冲区满时等待消费,消费者在缓冲区空时等待生产。

NSLock,NSRecursiveLock

NSLockNSRecursiveLockFoudation框架中分别对pthread的普通互斥锁和递归锁的OC面向对象封装。使用更加方便,不需要写销毁代码,但因为封装性能略有下降。
GNUStep中可以看出NSLock内部实现使用pthread_mutex_t的错误检查锁。NSRecursiveLock内部使用pthread_mutex_t的递归锁。

/* Use an error-checking lock. This is marginally slower, but lets us throw exceptions when incorrect locking occurs.*/

NSLock源码

NSRecursiveLock源码
NSCondition

NSCondition是对pthread_mutex_tpthread_cond_t的合并封装,使用更加简便。
NSCondition常用于实现多个线程临界区部分代码存在的的执行依赖的关系(A线程代码执行一半需要进入休眠,等待B线程任务完成,A线程再执行剩下代码),如生产者消费者问题。

NSConditionLock(条件锁)

NSConditionLock是对NSCondition的进一步封装,可以设置具体的条件值。
lockWhenCondition: 1方法调用代表当条件值为1才尝试加锁,否则处于休眠等待。
unlockWithCondition: 1方法调用代表先将条件值设为1,然后进行解锁。
NSConditionLock常用于实现设置多个线程之间的执行顺序,比如A线程先于B线程执行。

dispatch_queue 串行队列

dispatch_queue 串行队列类似锁,也可以实现线程同步的效果。

dispatch_semaphore(信号量)

dispatch_semaphore_wait 函数会在信号量的值<=0时,进入休眠,等到值>0激活。当值>0时,会使信号量值-1,并继续执行后面代码。dispatch_semaphore_signal 使信号量值+1。
dispatch_semaphore常用于实现控制线程最大并发数量。

@synchronized

@synchronized是对os_unfair_recursive_lock(旧版本是pthread_mutex_t递归锁)递归锁的封装。@synchronized的传入对象作为参数,用于内部HashMap关联锁的key。
通过反汇编调试可以看到,@synchronized最终调用了 objc_sync_enterobjc_sync_exit@synchronized分别作为代码块的开始和结束。

@synchronized

可以在Objective-C源码中找到这两个函数。

// Begin synchronizing on 'obj'. 
// Allocates recursive mutex associated with 'obj' if needed.
// Returns OBJC_SYNC_SUCCESS once lock is acquired.  
int objc_sync_enter(id obj)
{
    int result = OBJC_SYNC_SUCCESS;
    if (obj) {
        SyncData* data = id2data(obj, ACQUIRE);
        ASSERT(data);
        data->mutex.lock();
    } else {
        // @synchronized(nil) does nothing
        if (DebugNilSync) {
            _objc_inform("NIL SYNC DEBUG: @synchronized(nil); set a breakpoint on objc_sync_nil to debug");
        }
        objc_sync_nil();
    }
    return result;
}
// End synchronizing on 'obj'. 
// Returns OBJC_SYNC_SUCCESS or OBJC_SYNC_NOT_OWNING_THREAD_ERROR
int objc_sync_exit(id obj)
{
    int result = OBJC_SYNC_SUCCESS;
    if (obj) {
        SyncData* data = id2data(obj, RELEASE); 
        if (!data) {
            result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
        } else {
            bool okay = data->mutex.tryUnlock();
            if (!okay) {
                result = OBJC_SYNC_NOT_OWNING_THREAD_ERROR;
            }
        }
    } else {
        // @synchronized(nil) does nothing
    }
    return result;
}

通过源码追溯,@synchronized最终使用的锁是recursive_mutex_tt对象,内部是os_unfair_recursive_lock非公平递归锁。

class recursive_mutex_tt : nocopy_t {
    os_unfair_recursive_lock mLock;
}

通过下面代码可以看到锁最终会存储在静态全局哈希表sDataLists中,key是@synchronized作为参数传入的对象(准确说是对象所在内存地址)。

// Use multiple parallel lists to decrease contention among unrelated objects.
#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
#define LIST_FOR_OBJ(obj) sDataLists[obj].data
static StripedMap<SyncList> sDataLists;

@synchronized封装比较复杂,而且使用时需要在全局哈希表中查找锁,性能比较差,不推荐使用。

atomic

atomic修饰的属性,可以保证对属性的set和get方法都是原子性操作。
objc源码可以看出,若设置atomic修饰,自动生成的set方法会赋值时进行加锁slotlock,赋值结束进行解锁。在get方法中使用的和set是同一把锁(PropertyLocks[slot]),锁存放在全局哈希表PropertyLocks中。

StripedMap<spinlock_t> PropertyLocks;
/// 属性自动生成的get方法
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);
}
/// 属性自动生成的set方法
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使用的是spinlock_t锁,从名字可以看出是自旋锁。源码追踪到spinlock_t是mutex_tt类,内部使用os_unfair_lock非公平锁,并在加锁时对其进行设置OS_UNFAIR_LOCK_ADAPTIVE_SPIN,将其设置为自旋锁。

class mutex_tt : nocopy_t {
    os_unfair_lock mLock;
    void lock() {
        lockdebug_mutex_lock(this);

        // <rdar://problem/50384154>
        uint32_t opts = OS_UNFAIR_LOCK_DATA_SYNCHRONIZATION | OS_UNFAIR_LOCK_ADAPTIVE_SPIN;
        os_unfair_lock_lock_with_options_inline
            (&mLock, (os_unfair_lock_options_t)opts);
    }
}

atomic性能比较差,不推荐使用,有以下几个原因:

  1. atomic使用全局的哈希表存储锁,而不是直接取到锁指针,性能会有一定影响。
  2. atomic修饰的属性对所有读写场景都会加锁,但一般是该属性只有少部分场景需要加锁。
  3. atomic读写用同一把锁,可以防止读和写,写和写,读和读 同时进行,避免线程安全问题。但读与读操作却并不需要加锁,因此不是一个高效的读写锁(多读单写)。
  4. atomic有时并不能实现写与写方法的线程同步。
    如下面代码,atomic可以防止多个set方法同时调用存在的线程安全问题。比如A和B线程未设置atomic执行set,一方面set方法内部有release和retain/copy,多线程set调用可能造成对象已经释放被再次调用release导致crash;一方面受到CPU与内存最小交互字节数影响,一般64位CPU为64位。
// A线程执行
self.i = 1;
// B线程执行
self.i = 2;

atomic却无法保证set方法设置的新value需要依赖旧value场景的线程安全。如下,A线程可能在执行int value = self.i + 1;时,切换到B线程,B线程执行完再来执行A线程self.i = value;,最终导致B线程的+1变得无效。
简单来说,atomic只在语句2 加锁,但要保证线程安全,需要对语句1 和语句2 同时加锁。

// A线程执行
self.i ++;
// B线程执行
self.i ++;

内部汇编等同于
// A线程执行
int value = self.i + 1;  // 语句1
self.i = value;    // 语句2
// B线程执行
int value = self.i + 1;
self.i = value;
  1. atomic也无法保证数据容器类型的线程安全,比如NSMutableArrayaddObject方法。
pthread_rwlock高效读写锁

pthread_rwlock提供pthread_rwlock_rdlockpthread_rwlock_wrlock函数分别对读和写加锁。

gcd实现高效读写锁

使用gcd并发队列和 barrier可实现高效读写锁,参考https://www.jianshu.com/p/9e62aa5a5da1

性能对比

os_unfair_lock > OSSpinLock > dispatch_semaphore > pthread_mutex > dispatch_queue串行队列 > NSLock > NSCondition > pthread_mutex递归锁 > NSRecursiveLock > NSConditionLock > @synchronized
值越大性能更好,因为dispatch_semaphore性能比较好,很多人会将初始信号量设为1,用来当锁使用。os_unfair_lock iOS 10才支持,若不需要支持iOS 10以下设备推荐使用。OSSpinLock可能会导致优先级反转,不推荐使用。

可以通过以下宏定义简化dispatch_semaphore的使用。

#define SemaphoreBegin \
static dispatch_semaphore_t semaphore; \
static dispatch_once_t onceToken; \
dispatch_once(&onceToken, ^ { \
    semaphore = dispatch_semaphore_create(1); \
});\
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);

#define SemaphoreEnd \
    dispatch_semaphore_signal(semaphore);

-(void)test {
    SemaphoreBegin
    // 要加锁的代码        
    SemaphoreEnd
}

需要加锁场景

需要加锁场景一般分为三种:

1.多线程访问和修改同一个变量,导致变量值错误。

待更新。。。

2. 线程执行顺序错乱,导致线程同步问题。

待更新。。。

3. 死锁,活锁,饥饿。

待更新。。。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容