引言
在前面的博客中,我们已经讨论了锁在多线程编程中的重要性,主要是为了解决多线程同时访问同一片共享数据时发生的竞争条件(race conditions),导致数据不一致和崩溃问题。
并且介绍了一个在Objective-C中,最常见也最简单的锁机制——属性修饰符atomic,它能够有效地防止资源竞争,保障数据安全。我们深入讨论了它的应用场景以及局限性,原子属性通常作用于单个变量,而对于多个数据或者数组时并不起作用。那么这个时候我们就需要引入另一个在Objective-C中的强大同步工具——@synchronized。
@synchronized简介
作用
@synchronized的主要作用是确保某个代码在同一时刻只被一个线程执行,从而避免多个线程同时访问共享资源时发生数据竞争或不一致问题。
举例来说,如果有多个线程同时读取和修改一个共享变量,可能会导致数据损坏或程序崩溃。通过@synchronized,我们可以将对共享资源的访问限制为一次只有一个线程可以操作。
@synchronized为开发者提供了一种简洁且易用的同步机制。相比于手动管理锁(比如NSLock),@synchronized通过一行代码就可以实现锁定和解锁操作,即使在代码块抛出异常或出现错误时,也会确保锁被正常释放,避免了手动管理的繁琐和容易出错的地方,这种自动管理机制为代码健壮性提供了保障,简化了编程过程,减少了出错的可能性,对于初学者来说非常直观并且方便使用。
使用场景
@synchronized在许多常见的多线程编程场景中都非常有用,尤其是需要保护共享资源或临近区的代码时,比如并发读写共享数据、对数据完整完整性要求较高的应用场景:
- 多线程访问同一数据库或文件系统。
- 并发处理网络请求中的共享缓存或配置数据。
- 管理计数器、日志记录等共享状态。
注意事项
尽管@synchronized可以有效解决线程安全问题,但过度使用或不当使用可能会引发性能瓶颈。同步操作会阻塞其他线程的执行,尤其是在高并发场景下,因此开发者应该谨慎选择使用场景。它在中小规模的应用程序中表现得很好,但在需要高性能和大并发量时,可能需要考虑更轻量的同步机制(如GCD的信号量,NSLock)。
@synchronized使用
基本用法
@synchronized的使用很简单,它的锁是基于对象的,只有在锁定的对象上才能进行同步。使用时将指定对象放到括号内即可。
@synchronized(object) {
// 临界区代码
}
我们需要注意括号内的对象不能为空,也不能随便创建一个对象就用,因为它的生命周期不确定。通常我们会使用self,self的生命周期比较长,会让这更简单方便。它的存储和释放都在同一个链表内进行操作。
保证数据安全
我们同样使用上一个博客中的案例,这次我们仍然使用nonatomic来修饰address属性,然后通过GCD创建多个并发异步任务来打印这个属性的值,我们已经知道这大概率会导致崩溃,但这次我们让代码在@synchronized同步代码块中执行。
@property (nonatomic, strong) NSString * address;
//MARK: @synchronized
- (void)synchronizedTest {
for (int i = 0; i < 1000; i++) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
@synchronized (self) {
self.address = [NSString stringWithFormat:@"河北省唐山市路北区%d", i];
NSLog(@"%@", self.address);
}
});
}
}
执行该方法后,我们可以发现即使是使用nonatomic修饰符修饰的属性,也顺利执行完了所有循环。因为@synchronized确保了在代码块中的所有代码在同一时刻只能被一个线程执行。
保持数据一致
在上一篇博客中我们列举了这样一个案例,有一个HPUser实体类,类中有两个属性firstName和lastName,然后服务类可以对其进行数据更新,虽然我们使用属性修饰符atomic保证了数据安全,但是仍然存在一些问题,当有两个不同的线程更新用户时,名称分别为Bob Taylor和Alice Darji,可能会出现Alice Taylor或者Bob Darji的情况。因为atomic并不能保证复合数据的一致性。
这个时候我们使用@synchronized将更新用户信息的方法放在代码块中进行执行,用户所有的相关状态都在同一个事物中批量更新。
@implementation HPUpdaterService
- (void)updateUser:(HPUser *)user properties:(NSDictionary *)properties{
@synchronized(user){//取得针对user对象的锁。一切相关的修改都会被一同处理,而不会发生竞争状态
NSString * fn = [properties objectForKey:@“firstName”];
if(fn != nil){
user.firstName = fn;
}
NSString * ln = [properties objectForKey:@“lastName”];
if(fn != nil){
user.lastName = ln;
}
}
}
@end
这样不管有多少条线程来更新用户信息,在同一个时刻都有且只有一条线程能够进入代码块执行更新代码。这就保证了用户信息的一致性。
另外在本例中,我们通过user对象来获取锁,保证了当user对象不同时,该方法仍然能够高并发地执行,这既实现了高并发,又设置了警戒以防止数据冲突。
可以看到获取锁的对象是良好定义临界区的关键。作为经验法则,可以选择状态会被访问和修改的对象作为信号量的引用。
可变数组的操作
在多线程环境中操作可变数组NSMutableArray,多线程会同时往数组中添加或删除元素。如果不进行同步操作,可能会导致数组状态不一致甚至是崩溃,即使是使用atomic修饰的数组也保证不了在多线程中添加和删除元素的安全。
未使用@synchronized的多线程操作数组
我们首先来创建一个共享数组的管理类,并添加两个方法用于添加元素和移除元素。
#import "PHArrayManager.h"
@interface PHArrayManager ()
/// 共享数组
@property(nonatomic,strong)NSMutableArray * sharedArray;
@end
@implementation PHArrayManager
- (instancetype)init
{
self = [super init];
if (self) {
self.sharedArray = [NSMutableArray array];
}
return self;
}
//MARK: 添加元素
- (void)addObject:(id)object {
[self.sharedArray addObject:object];
NSLog(@"添加元素成功:%@",object);
}
//MARK: 删除元素
- (void)removeObject:(id)object {
[self.sharedArray removeObject:object];
NSLog(@"删除元素成功:%@",object);
}
@end
然后在多线程环境下使用它来添加元素,我们仍然采用了GCD的方式获取一个全局并发队列来创建一个多线程的场景。
//MARK: 操作共享数组
- (void)arrayTest {
PHArrayManager * arrayManager = [[PHArrayManager alloc] init];
for (int i = 0; i < 100; i++) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[arrayManager addObject:[NSString stringWithFormat:@"河北省唐山市路北区%d", i]];
});
}
}
执行后会发现,大概率会发生程序崩溃。
另外如果多线程读取的话,还可能会发生数组越界的情况。
使用@synchronized的多线程操作数组
接下来我们使用@synchronized将对共享数组的操作都放到代码块中来执行。
//MARK: 添加元素
- (void)addObject:(id)object {
@synchronized (self.sharedArray) {
[self.sharedArray addObject:object];
NSLog(@"添加元素成功:%@",object);
}
}
//MARK: 删除元素
- (void)removeObject:(id)object {
@synchronized (self.sharedArray) {
[self.sharedArray removeObject:object];
NSLog(@"删除元素成功:%@",object);
}
}
在执行同样的操作,我们发现所有的代码都可以顺利执行。
@synchronized源码
我们执行clang之后会发现其实@synchronized就两句代码:
objc_sync_enter(_sync_obj);
objc_sync_exit(_sync_obj);
objc_sync_enter方法的源码如下:
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;
}
从源码中我们可以看得出锁对象是不能传递nil的。
objc_sync_exit()方法源码如下:
// 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;
}
其中有一个SyncData,它的结构如下:
typedef struct alignas(CacheLineSize) SyncData {
struct SyncData* nextData;
DisguisedPtr<objc_object> object;
int32_t threadCount; // number of THREADS using this block
recursive_mutex_t mutex;
} SyncData;
可以看到SyncData是一个结构体,并且是一个单向链表。
recursive_mutex_t事实上是一把递归锁,但是只能在单线程中使用。
但是由于有threadCount的存在,synchronized不仅是一把递归锁,还可以在多线程中使用。
结语
在多线程编程中,确保数据的完整性和线程安全始终是一个重要的挑战。通过本篇博客我们可以看到如果不采取适当的同步措施,多个线程同时访问和修改共享资源可能导致崩溃和数据不一致。而@synchronized为我们提供了一种简洁而有效的解决方案,自动处理锁定和解锁,避免了手动管理锁的繁琐。
虽然@synchronized不是性能最高的同步工具,但在代码简洁性和易用性方面,它无疑是初学者和中小型项目中的练好选择。在并发编程中,了解并选择适当的同步机制能够极大提高应用程序的可靠性和稳定性。希望本文能够帮助你更好地理解@synchronized及其在多线程环境中的应用。