作者 | 张璇、张凯
审校 | 重楼
一、背景与意义
在系统应用开发中,随着应用程序复杂度的增加,多线程编程成为了提升用户体验和应用性能的关键技术之一,线程同步占据着举足轻重的地位。鉴于iOS应用程序普遍涉及多任务处理和并发操作,确保线程安全成为一项至关重要的任务。Objective-C语言为此提供了一种高效且可靠的同步机制,即@synchronized关键字。这一机制通过标记特定代码段为临界区,确保了同一时刻仅有一个线程能够执行该代码块,从而有效保护了数据的一致性和完整性。
此外,@synchronized关键字在多线程环境中还具备递归锁定的能力。这意味着同一线程可以多次获取同一把锁,而不会导致死锁的产生,极大地提升了递归调用的安全性和可行性。
二、关键技术
在iOS多线程环境中,@synchronized关键字是实现线程同步和递归锁定的关键技术。其内部实现原理基于Objective-C的运行时(Runtime)和底层的锁机制。
首先,@synchronized关键字在编译时会被转换成Objective-C运行时库中的一个函数调用,即objc_sync_enter和objc_sync_exit。这两个函数分别用于获取和释放锁。当线程尝试进入@synchronized代码块时,objc_sync_enter函数会被调用,并尝试获取与给定对象相关联的锁。如果锁已经被其他线程持有,则当前线程将被阻塞,直到锁被释放。如果锁成功获取,则线程可以安全地执行@synchronized代码块中的代码。
其次,@synchronized关键字的递归锁定能力是通过在运行时维护一个线程到锁计数器的映射来实现的。当同一线程多次进入同一个@synchronized代码块时,锁计数器会递增,而不是重新获取锁。这样,即使线程多次进入临界区,也不会导致死锁。当线程离开@synchronized代码块时,锁计数器会递减,直到计数器归零,此时锁才会被释放,允许其他线程进入临界区。
2.1 @synchronized的源码入口
首先,通过一个demo来了解下@synchronized的具体结构:
图1 demo Objective-C语言版本示例
在xcode中,可以通过clang -rewrit-objc命令,将上述的demo代码重写为C++代码,这里将关键代码提取如下:
图2 demo C++语言版本示例
显然,从给定信息中可以明确,_sync_exit()函数触发了_SYNC_EXIT的构造过程,而 ~_SYNC_EXIT是_SYNC_EXIT的析构函数。从根本上讲,@synchronized指令的底层实现依赖于对objc_sync_enter和objc_sync_exit这两个函数的调用。接下来,我们将深入解析 objc_sync_enter 和 objc_sync_exit的具体实现方式。
2.2、objc_sync_enter 和 objc_sync_exit函数实现
objc_sync_enter和objc_sync_exit是 Objective-C 运行时库中用于处理同步锁的关键函数。这两个函数通过底层的锁机制(如互斥锁或自旋锁)来确保在同一时刻,只有一个线程能够执行特定的代码块。下面,我们将详细解析这两个函数的实现方式及其背后的原理。
2.2.1 objc_sync_enter 函数的实现
objc_sync_enter函数的主要作用是尝试获取与给定对象相关联的锁。如果锁已被其他线程持有,则当前线程将被阻塞,直到锁被释放。这个函数的实现通常包括以下几个步骤:
1. 计算锁对象的哈希值:首先,根据传入的对象(通常是作为@synchronized语句中锁的唯一标识符),计算出一个哈希值。这个哈希值用于在内部的数据结构中快速定位到对应的锁对象。
2.查找或创建锁对象:在内部的数据结构中(如哈希表或链表),根据计算出的哈希值查找是否存在对应的锁对象。如果不存在,则创建一个新的锁对象并插入到数据结构中。这个锁对象可能是一个封装了互斥锁或自旋锁等底层同步机制的结构体。
3. 尝试获取锁:使用找到的锁对象,尝试获取锁。这通常涉及到底层同步机制的调用,如调用互斥锁的lock方法或自旋锁的相关操作。如果锁已被其他线程持有,则当前线程将被阻塞。
4.记录线程与锁的关系:为了支持递归锁定,Objective-C 运行时还需要记录哪些线程已经持有了哪些锁,以及持有锁的次数。这通常是通过一个线程到锁计数器的映射来实现的。
图3 objc_sync_enter函数源码
2.2.2 objc_sync_exit 函数的实现
objc_sync_exit函数的主要作用是释放之前通过objc_sync_enter获取的锁。这个函数的实现通常包括以下几个步骤:
1. 计算锁对象的哈希值:与objc_sync_enter相同,首先根据传入的对象计算哈希值,以找到对应的锁对象。
2. 查找锁对象:在内部的数据结构中查找对应的锁对象。
3. 释放锁:使用找到的锁对象,调用其释放锁的方法(如互斥锁的unlock方法)。这将允许其他被阻塞的线程进入临界区。
4. 更新线程与锁的关系:如果当前线程是最后一次释放该锁(即锁计数器减至零),则从线程到锁计数器的映射中移除该线程的记录。这确保了递归锁定的正确性。
图4 objc_sync_exit函数源码
2.3 总结
从深入探索objc_sync_enter和objc_sync_exit两个函数的源代码中,我们可以清晰地看到这两个函数在同步机制中的核心作用。它们通过巧妙地利用互斥锁data->mutex,确保了线程安全地进入和退出同步块。这种机制在并发编程中至关重要,能够防止多个线程同时访问共享资源,从而避免了数据竞争和不一致性的风险。
进一步地,我们发现data这个变量实际上是指向一个SyncData类型实例对象的指针。在这里,SyncData扮演了至关重要的角色,它封装了与同步操作相关的所有必要信息,包括互斥锁、同步状态等。通过精心设计的SyncData结构体,我们能够更加灵活地管理和控制同步资源的访问,确保程序在不同线程之间的协调运行。
与此同时,id2data这一组件也引起了我们的关注。从名字上推测,它似乎是一个用于将某种标识符(如对象ID)映射到对应SyncData实例的函数或方法。在并发编程中,这样的映射关系对于快速定位和管理同步资源至关重要。通过id2data,我们可以根据传入的obj(可能是某个对象的唯一标识符)快速地查找到对应的data(即该对象的同步数据)。这种映射机制大大提高了同步操作的效率和准确性,为程序的并发执行提供了强有力的支持。
为了更深入地理解SyncData结构体和id2data的具体实现,我们将在接下来的第三小节中详细探讨SyncData的结构和成员变量,以及它在同步机制中的具体应用。同时,在第四小节中,我们将解析id2data的实现细节,包括它如何接收输入参数、进行映射查找以及返回对应的SyncData实例。通过对这些核心组件的深入理解,我们将能够更好地掌握Objective-C的同步机制,并在实际开发中灵活运用。
三、SyncData结构体介绍与解析
首先,让我们对SyncData结构体的实现进行了解:
图5 SyncData基本结构体
从这段源码中可以看到SyncData的基本结构,本质上是存放了一个传入对象obj的单向链表、一把递归锁、以及使用的线程数量。可以从下面的这段源码中看一下这把递归锁。这把递归锁本质上是基于os_unfair_lock的封装。这里补充下:os_unfair_lock是iOS中的一把互斥锁。在之前的版本中recursive_mutex_t是由pthread_mutex_t来进行封装的,因此这里只需要将其理解成一把互斥锁即可。
图6 recursive_mutex_t基本结构
四、id2data的底层实现的分析与研究
在正式深入分析id2data的详尽细节之际,为了构建一个更为明晰的认知框架,本文将首先对所涉及的数据结构进行一次宏观层面的梳理与整体性阐述。此举旨在为后续针对id2data的深入探讨奠定坚实的背景知识基础,确保各位读者能够依托这一稳固的基石,更加精准地把握相关概念与逻辑脉络。
4.1 SyncCache的底层实现逻辑
图7 SyncCache基本结构
可以明显地观察到,SyncCache容器中存储的是SyncData类型的数据。SyncData这个结构体在文章的第三部分已经进行了详细的说明,因此在这里就不再重复解释了。实际上,从这个细节上我们可以推测出,SyncCache似乎是一个专门设计用来存储含有SyncData的SyncCacheItem对象的缓存机制。从这个角度来看,SyncCache的功能和用途变得非常明显,它就是一个用来加快数据访问速度的临时存储区域,当需要访问数据时,可以直接从SyncCache中获取,从而提高程序的运行效率。同时,这也体现了设计者对于数据存储和读取效率的重视,通过引入缓存机制,使得频繁访问的数据能够快速获取,从而提升整体的性能表现。
4.2 Fast Cache(快速缓存)
图8 快速存储内部存储结构
这里的快速缓存,其实和SyncCache在本质上是非常相似的,它们都是一种用于提升数据读取速度的缓存机制。二者的主要区别在于,SyncCache是将数据存储在一个列表中,而快速缓存则只是存储了单个的SyncCacheItem。每个Item都通过两个关键的Key来获取其对应的data和lockCount。这种设计使得快速缓存能够更快地读取数据,因为它不需要遍历整个列表,而是直接通过Key来获取数据。同时,这也使得快速缓存的存储空间更加节省,因为它不需要为一个列表分配大量的内存空间。
4.3 sDataLists的底层实现逻辑
sDataLists是一个全局性的静态变量,意味着在整个应用程序中,它只能存在一个实例。在这个全局变量中,包含了一个名为SyncList的特殊列表。这个SyncList列表在程序中具有独特的地位,它负责管理和同步所有需要共享和更新状态的数据。由于它是静态的,所以无论在程序的哪个部分,只要需要访问sDataLists,都能直接通过它的名称来引用它,而不需要先创建一个局部变量。这种设计使得数据的管理和同步变得更加高效和便捷。
图9 sDataLists存储结构
图10 SyncList基本结构
4.4 StripedMap的设计与实现
在深入剖析sDataLists的源代码架构时,我们可明确辨识出sDataLists本质上遵循哈希表的数据结构设计。这一精心策划的架构背后,蕴含着深远的目的与周详的考量。哈希表作为一种高效的数据组织方式,其核心优势在于显著提升数据检索的速度与效率。然而,在当前的实现框架中,哈希表所承载的功能与角色远超于此单一范畴。
若我们摒弃采用StripedMap的策略,系统将不得不依赖单一的全局SyncList实例来统筹所有对象的锁定与解锁流程。此设计方案虽简洁,却潜藏着显著的性能瓶颈。具体而言,每当有任一对象尝试访问或修改该哈希表时,其操作将被迫暂停,直至其他所有对象完成其解锁操作,方可继续执行。此类串行化的处理方式,无疑将大幅度加剧内存的占用情况,对系统整体性能构成不利影响。
StripedMap的引入,为现存问题提供了有效的解决方案。其核心价值体现在对单一的SyncList实施分片处理,这一机制确保了多个对象能够并行且独立地操作不同的SyncList实例。通过StripedMap预先配置并管理一定数量的SyncList,并在实际调用时采取均衡分配的策略,系统得以显著提升其并发处理能力。具体而言,每个对象在执行加锁操作时,均能够自主选择一个独立的SyncList进行操作,从而成功规避了全局锁可能引发的性能瓶颈问题。
4.5 TLS(Thread Local Storage)
TLS就是线程局部存储,是操作系统为线程单独提供的私有空间,能存储只属于当前线程的一些数据。
TLS,即线程局部存储,是操作系统提供的一种机制,为每个线程单独分配一块私有内存空间,用于存储只属于该线程的数据。由于线程是操作系统进行任务调度和资源分配的最小单位,因此TLS可以确保每个线程拥有独立的存储空间,避免了线程之间的数据干扰和冲突,提高了程序的并发性和稳定性。
在多线程程序中,每个线程可能会访问共享资源,如全局变量或共享内存区域,这会导致数据竞争和竞态条件,从而影响程序的正确性和可靠性。为了解决这个问题,开发者通常需要使用同步机制,如互斥锁、信号量等,来保护共享资源的访问。但是这些同步机制会带来额外的开销,降低程序的性能。而使用TLS,可以将一些需要频繁访问且不涉及共享资源的数据存储在当前线程的私有空间中,避免了同步机制的使用,提高了程序的运行效率和性能。
TLS的使用需要开发者在编写程序时进行适当的声明和初始化,以确保每个线程都能够正确地访问其私有空间中的数据。同时,由于TLS是操作系统提供的一种机制,其具体实现和接口可能会因操作系统的不同而有所差异,因此开发者需要根据具体的操作系统和编译器环境进行相应的适配和调整。
4.6 id2Data的实现解析与研究
在探讨我们当前的议题,现转入id2Data的具体实现细节。鉴于源码存在一定程度的冗余性,以下将依据4.6.1至4.6.4小节进行逐一解析。首先,系统将依据传入的obj参数定位并检索锁及链表的起始节点。
图11 id2Data基本结构之入参
4.6.1 FastCache快速查找
在每个线程的线程局部存储(TLS)中,都会部署一个FastCache机制。该机制的实现过程主要包括以下几个严谨且有序的步骤:
首先,系统会检查是否存在SyncData数据。若存在,则立即将fastCacheOccupied标志位设置为YES,以明确指示快速缓存中已存有有效数据。
紧接着,系统将执行一项关键性检查:验证当前的SyncData对象是否与当前操作所针对的目标对象obj完全一致。若二者相符,则表明在快速缓存中已找到与目标对象相匹配的锁数据。
在确认找到匹配锁数据后,系统将读取当前锁的lockCount值,并依据当前传入的操作类型(如加锁或解锁)来执行相应的操作。操作完成后,系统将更新后的lockCount值重新存储回TLS中,以便在后续的查找操作中能够迅速定位。
此FastCache机制的核心优势在于,它通过在各线程的本地存储中预先缓存锁数据,有效提升了加锁与解锁操作的执行效率。此举不仅避免了频繁的全局范围查找与更新操作,还显著降低了锁资源的争用情况,从而实现了对并发性能的显著提升。
图12 id2Data基本结构之FastCache
4.6.2 SyncCache缓存遍历查找
在FastCache快速查找机制未能成功定位目标时,程序将触发fetch_cache函数的执行,以访问当前线程的缓存数据,并继续执行查找操作。值得注意的是,SyncCache本质上被设计为存储SyncData元素的数组结构,这一过程实质上是遍历数组以查找与当前操作对象obj相匹配的项。一旦找到匹配项,程序将根据传入的操作类型执行相应的加锁或解锁操作,并同步更新lockCount的值。若lockCount递减至0,则视为该线程已完成对该锁的使用,随即从线程缓存中移除该锁实例。
为确保上述操作在多线程并发环境下的正确性和一致性,避免潜在的冲突问题,本机制采用OSAtomicDecrement32Barrier函数来原子性地减少result结构体中threadCount的值。此举旨在确保在减少计数器的过程中,不会被其他线程的加锁操作所干扰,从而维护了系统状态的稳定与准确。
图13 id2Data基本结构之SyncCache
4.6.3 sDataLists查找
若快速缓存与常规缓存均未检索到目标项,则此线程首次执行@synchronized操作。在此情境下,系统转而于sDataLists中检索相应的SyncData对象。首先,通过执行lock操作对全局链表施以加锁,此举旨在防止多个线程并行创建同一对象的新锁,确保线程安全。随后,遍历全局链表,以查找与当前对象相匹配的SyncData实例。
若遍历过程中成功定位到匹配的SyncData,则将该实例赋值予result变量,并利用OSAtomicDecrement32Barrier原子操作递增result->threadCount的值,此举旨在防范与并发的释放操作发生潜在冲突,确保数据一致性与线程安全。完成上述步骤后,该SyncData的相关信息即可在TLS(线程局部存储)中被后续访问。若系统检测到存在未使用的SyncData实例,则优先考虑复用此类资源,以优化资源利用效率,减少不必要的对象创建与销毁开销。
图14 id2Data基本结构之sDataLists
4.6.4 新建SyncData
若sDataLists中未能寻获所需数据,则需自行构建SyncData并将其缓存至线程缓存,以备后续查询之需。审视id2data的锁获取流程,其采用了一种类似于三级缓存的机制,即依次从快速缓存、常规缓存至哈希表进行检索。此设计旨在高效管理多线程环境中的锁资源,确保线程能迅速获得锁以执行加锁与解锁操作,从而提升系统效率。面对sDataLists检索无果之情境,应采取相应策略,即创建SyncData的新实例,并将其妥善地纳入缓存体系之中,以保障数据的完整性与系统的稳定运行。
图15 id2Data基本结构之SyncData创建
图16 id2Data基本结构之SyncData添加缓存
总结
图17 id2Data三级缓存机制
通过本文上述自上而下的分析可以看出,@synchronized通过id2Data的三级缓存机制及时快速为传入的对象obj拿到锁,并清楚的记录着这些锁的lockCount及使用情况,确保在同一时间只有一个线程能够执行,从而达到防止数据竞争和保证线程安全的目的。可以总结出以下几点:
- @synchronized根据传入的对象,为每个线程构建一把递归锁,同时记录每个线程加锁的次数。基于此,对每条线程用不同的递归锁进行加锁和解锁的操作,从而达到多线程递归调用的目的。
- @synchronized内部是基于os_unfair_lock封装的递归互斥锁,@synchronized在内部创建锁的时候为了保证唯一性,使用spinlock_t来保证线程安全。
- @synchronized使用了快速缓存,线程缓存,全局链表方式来使得线程可以更加快速的拿到锁,提升效率。
- 另外需要额外注意的是@synchronized使用时如果传入nil,不能完成加锁,使用时应避免。
总之,在iOS开发过程中,@synchronized提供了一种便捷的线程同步机制,使得开发者能够轻松实现线程同步。尤其是在处理递归锁的情况下,使用@synchronized能够有效地避免死锁的发生,大大提高了代码的安全性和可靠性。这一特性对于开发者来说非常重要,因为它不仅确保了线程之间有序访问共享资源,还降低了复杂度,使得开发者能够更加专注于业务逻辑的实现。最后,本文探索iOS实现线程同步机制的过程,旨在同步系统开发领域为开发者提供具有参考价值的引导。
作者介绍
张璇,中国农业银行股份有限公司研发中心软件研发工程师,熟悉iOS开发,具备SpringBoot及React相关开发经验,具备扎实的计算机基础知识储备。
张凯,中国农业银行股份有限公司研发中心软件研发工程师,擅长SpringBoot+Vue全栈式开发,热爱编程和学习前沿技术信息。