线程同步与线程通信
多线程是有趣的事情,它很容易突然出现”错误情况”,这是由于系统的线程调度具有一定的随机性造成的.不过,即使程序偶然出现问题,那么是由于编程不当所引起的.当使用多个线程来访问同一个数据时,很容易”偶然”出现线程安全问题.
线程安全问题
关于线程安全问题,有一个经典的问题:银行取钱的问题.银行取钱的基本流程基本可以分为如下几个步骤.
- 用户输入账户、密码、系统判断用户的账户、密码是否匹配。
- 用户输入取款金额.
- 系统判断账户余额是否大于取款金额.
- 如果余额大于取款金额,则取款成功;如果余额小于曲矿金额,则取款失败.
我们不管检查账户和密码的操作,仅仅模拟了后面3步骤操作.
下面定义一个账户类,该账户类封装了账户编号和账户余额两个属性.
代 码 片 段 |
1 LCAccount.h 2 3 @interface LCAccount : NSObject 4 5 // 封装账户编号、账户余额两个属性 6 7 @property (nonatomic, copy)NSString* accountNO;// 账户编号 8 9 @property (nonatomic, readonly)CGFloat balance;// 账户余额 10 11 - (id)initWithAccountNo:(NSString*)accountNo balance:(CGFloat)balance; 12 13 - (void)draw:(CGFloat)drawAmount; 14 15 @end 16 17 该LCAccount类还需要提供一个draw:方法,该方法用于从该账号中取钱。 18 19 LCAccount.m 20 21 @implementation LCAccount 22 23 - (id)initWithAccountNo:(NSString *)aAccount balance:(CGFloat)aBalance 24 25 { 26 27 self = [super init]; 28 29 if(self) 30 31 { 32 33 _accountNo = aAccount; 34 35 _balance = aBalance; 36 37 } 38 39 return self; 40 41 } 42 43 // 提供了一个draw方法来完成取钱操作 44 45 - (void)draw:(CGFloat)drawAmount 46 47 { 48 49 // 账户余额大于取钱数目 50 51 if(self.balance >= drawAmount) 52 53 { 54 55 // 吐出钞票 56 57 NSLog(@”%@取钱成功!吐出钱票:%g”, [NSThread currentThread].name , drawAmount);// ① 58 59 // [NSThread sleepForTimeInterval:0.001 ]; 60 61 // 修改余额 62 63 _balance = _balance – drawAmount; 64 65 NSLog(@” 余额为:%g”, self.balance); 66 67 } 68 69 else 70 71 { 72 73 NSLog(@”%@取钱失败!余额不足!”, [NSThread currentThread].name);// ② 74 75 } 76 77 } 78 79 - (NSUInteger) hash 80 81 { 82 83 return [self.accountNo hash]; 84 85 } 86 87 - (BOOL)isEqual:(id)anObject 88 89 { 90 91 if(self == anObject) 92 93 return YES; 94 95 if(anObject != nil 96 97 && [anObject class] == [LCAccount class]) 98 99 { 100 101 LCAccount* target = (LCAccount*)anObject; 102 103 return [target.accountNo isEqualToString:self.accountNo]; 104 105 } 106 107 return NO; 108 109 } 110 111 @end 112 113 114 115 LCViewController.m 116 117 @implementation LCViewController 118 119 LCAccount* account; 120 121 - (void)viewDidLoad 122 123 { 124 125 [super viewDidLoad]; 126 127 // 创建一个账号 128 129 account = [[LCAccount alloc] initWithAccountNo:@”321231” balance: 1000.0 ]; 130 131 } 132 133 - (IBAction)draw:(id)sender 134 135 { 136 137 // 创建第1个线程对象 138 139 NSThread* thread1 = [[NSThread alloc] initWithTarget:self 140 141 selector:@selector(drawMethod:) 142 143 object:[NSNumber numberWithInt:800]]; 144 145 // 创建第2个线程对象 146 147 NSThread* thread2 = [[NSThread alloc] initWithTarget:self 148 149 selector:@selector(drawMethod:) 150 151 object:[NSNumber numberWithInt:800]]; 152 153 // 启动两条线程 154 155 [thread1 start]; 156 157 [thread2 start]; 158 159 160 161 } 162 163 - (void)drawMethod:(NSNumber *)drawAmount 164 165 { 166 167 // 直接调用accont对象的draw方法来执行取钱操作 168 169 [account draw:drawAmount.doubleValue]; 170 171 } 172 173 @end |
说 明 |
按照正常的执行逻辑,应该是第1个线程可以取到钱,第2线程显示”余额不足”.但上图所示的运行结果并不是期望的结(不过也有可能看到运行正确的结果),这正是多线程编程突然出现的”偶然”错误----因为线程调度的不确定性. |
使用@synchronized实现同步
为了解决”线程执行体的方法不具备同步安全性”的问题,Objective—C的多线程支持引入了同步,使用同步的通用方法就是@synchronized修饰代码块,被@synchornized修饰的代码块可简称为同步代码块.
同步代码块的语法如下:
@synchronized(obj)
{
…
// 此处的代码就是同步代码块
}
上面语法格式中,@synchronized后面括号里的obj就是同步监视器.上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定.
注意:任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对同步监视器的锁定.虽然Objective-C允许使用任何对象作为同步监视器,但想一下同步监视器的目的----阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器.
对于上面的取钱模拟程序,我们应该考虑使用账户(LCAccount对象)作为同步监视器.只要我们把LCAccount类的draw:方法修改如下形式即可.
代 码 片 段 |
1 // 提供一个线程安全的draw方法来完成取钱操作 2 3 - (void) draw:(CGFloat)drawAmount 4 5 { 6 7 // 使用self作为同步监视器,任何线程进入下面的同步代码块之前 8 9 // 必须先获得self账户的锁定----其他线程无法获得锁,也就无法修改它 10 11 // 这种做法符合”加锁 →修改→释放锁”的逻辑 12 13 @synchronized(self) 14 15 { 16 17 // 账户余额大于取钱数目 18 19 if(self.balance >= drawAmount) 20 21 { 22 23 // 吐出钞票 24 25 NSLog(@”%@取钱成功! 吐出钞票:%g”, [NSThread currentThread].name , drawAmount); 26 27 [NSThread sleepForTimeInterval:0.001]; 28 29 // 修改余额 30 31 _balance = _balance – drawAmount; 32 33 NSLog(@” yue为: %g”, self.balance); 34 35 } 36 37 else 38 39 { 40 41 NSLog(@”%@取钱失败!余额不足!”, [NSThread currentThread].name); 42 43 } 44 45 }// 同步代码块结束,该线程释放同步锁 46 47 } |
说明 |
上面程序使用@synchronized将draw:方法的方法体修改成同步代码块,该同步代码块的同步监视器是LCAccount对象本声,这样做法符合”加锁→修改→释放锁”的逻辑,任何线程在修改制定资源之前,首先都要对该资源加锁,在加锁期间其他线程无法修改该资源,当该线程修改完成后,释放对该资源的锁定.通过这种方式就可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区(也称为临界区),所以同一时刻最多只有一个线程处于临界区内,从而保证了线程的安全性. |
特征 |
通过这种方式可以非常方便地实现线程安全的类,线程安全的类具有如下特征. 该类的对象可以被多个线程安全地访问. 每个线程调用该对象的任意方法之后都将得到正确结果. 每个线程调用该对象的任意方法之后,该对象依然保持合理状态. |
减少线程安全的负面 |
|
释放对同步监视器的锁定
任何线程在进入同步代码块之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定。
当前线程的同步代码执行结束,当前线程即释放同步监视器。
当线程在同步代码块中遇到goto、return终止了该代码块、该方法的继续执行时,当前线程将会释放同步监视器。
当线程在同步代码块中出现错误,导致该代码块异常结束时,将会释放同步监视器。
典型地,当程序调用NSThread的sleepXxx方法暂停线程时,线程不会释放同步监视器。
同步琐(NSLock)
Foundation还提供了NSLock,它通过显式定义同步锁对象来实现同步,在这种机制下,同步锁使用NSLock对象充当。
NSLock是控制多个线程对共享资源进行访问的工具,通常锁定提供了对共享资源的独占访问,每次只能有一个线程对NSLock对象加锁,线程开始访问共享资源之前应先获得NSLock对象。
在实现线程安全的控制中,使用该NSLock对象可以显式地加锁、释放锁。通常使用NSLock的代码格式如下:
1 @implementation X 2 3 NSLock *lock; 4 5 - (id)init 6 7 { 8 9 self = [super init]; 10 11 if(self) 12 13 { 14 15 lock = [[NSLock alloc] init]; 16 17 } 18 19 return self; 20 21 } 22 23 // 定义需要保证线程安全的方法 24 25 - (void) m 26 27 { 28 29 [lock lock]; 30 31 // 需要保证线程安全的代码 32 33 // … method body 34 35 [lock unlock]; 36 37 } 38 39 … 40 41 @end
通过使用NSLock对象,我们可以把LCAccount类改为如下形式,它依然是线程安全的.
1 LCAccount.m 2 3 @implementation LCAccount 4 5 NSLock *lock; 6 7 - (id)init 8 9 { 10 11 self = [super init]; 12 13 if(self) 14 15 { 16 17 lock = [[NSLock alloc] init]; 18 19 } 20 21 return self; 22 23 } 24 25 - (id)initWithAccountNo:(NSString*)aAccount balance:(CGFloat)aBalance 26 27 { 28 29 self = [super init]; 30 31 if(self) 32 33 { 34 35 lock = [[NSLock alloc] init]; 36 37 _accountNo = aAccount; 38 39 _balance = aBalance; 40 41 } 42 43 return self; 44 45 } 46 47 // 提供一个线程安全的draw方法来完成取钱操作 48 49 - (void)draw:(CGFloat)drawAmount 50 51 { 52 53 // 显式锁定lock对象 54 55 [lock lock]; 56 57 // 账户余额大于取钱数目 58 59 if(self.balance > = drawAmount) 60 61 { 62 63 // 吐出钞票 64 65 NSLog(@”%@取钱成功!吐出钱票:%g”, [NSThread currentThread].name, drawAmount); 66 67 [NSThread sleepForTimeInterval:0.001]; 68 69 // 修改余额 70 71 _balance = _balance – drawAmount; 72 73 NSLog(@” 余额为:%g”, self.balance); 74 75 } 76 77 else 78 79 { 80 81 NSLog(@”%@取钱失败!余额不足!”, [NSThread currentThread].name); 82 83 } 84 85 // 释放lock的锁定 86 87 [lock unlock]; 88 89 } 90 91 // 省略hash和isEqual:方法 92 93 … 94 95 @end
定义了一个NSLock对象,程序中实现draw:方法时,进入方法开始执行后立即请求对NSLock对象进行加锁,当执行完draw:方法的取钱逻辑之后,程序释放对NSLock对象的锁定.
提示:使用NSLock与使用同步方式有点相似,只是使用NSLock时显式使用NSLock对象作为同步锁,而使用同步代码块时系统显式使用某个对象作为同步监视器,同样都符合”加锁->修改->释放锁”的操作模式,而且使用NSLock对象时每个NSLock对象对应一个LCAccount对象,一样可以保证对于同一个LCAccount对象,同一时刻只能有一个线程进入临界区.
使用NSCondition控制线程通信
当线程在系统内运行时,线程的调度具有一定的透明性,程序通常无法准确控制线程的轮换执行,但我们可以通过一些机制来保证线程协调运行,也就是处理线程之间的通信。
Foundation提供了一个NSCondition类来处理线程通信,NSCondition实现了NSLocking协议,因此也可以调用lock、unlock来实现线程同步。NSCondition可以让那些已经锁定NSCondition对象却无法继续执行的线程释放NSCondition对象,NSCondition对象也可以唤醒其他处于等待状态的线程。
NSCondition类提供了如下3个方法 |
|
- wait: |
该方法导致当前线程一直等待,直到其他线程调用该NSCondition的signal方法或broadcast方法来唤醒该线程。wait方法有一个变体:- (BOOL)waitUntilDate:(NSDate *)limiteout,用于控制等待到指定时间点,如果到了该时间点,该线程将会被自动唤醒。 |
- signal: |
唤醒在此NSCondition对象上等待的单个线程。如果所有线程都在该NSCondition对象上等待,则会选择唤醒其中一个线程,选择是任意性的。只有当前线程放弃对该NSCondition对象的锁定后(使用wait方法),才可以执行被唤醒的线程。 |
- broadcast: |
唤醒在此NSCondition对象上等待的所有线程,只有当前线程放弃对该NSCondition对象的锁定后,才可以执行被唤醒的线程。 |
代 码 片 段 |
/* 本程序中LCACcount使用NSCondition对象来控制同步,并使用NSCondition对象来控制线程的通信。程序通过一个旗标来标识账户中是否已有存款,当旗标为“NO”时,表明账户中没有存款,存款者线程可以向下执行,当存款者把钱存入账户后,将旗标设为“YES”,并调用signal或broadcast方法来唤醒其他线程;当存款者线程进入线程体后,如果旗标为“YES”,就调用wait方法让该线程等待。 当旗标为“YES”时,表明账户中已经存入了钱,则取钱者线程可以向下执行,当取钱者把钱从账户中取出后,将旗标设为“NO”,并调用signal或broadcast方法来唤醒其他线程;当存款者线程进入线程体后,如果旗标为“YES”,就调用wait方法让该线程等待。 上面这种模式可以推而广之,存钱的线程可以称为生产者,而取钱的线程则可以称为消费者,生产者与消费者之间通过NSCondition进行通信,从而实现良好的协调运行。 本程序通过为LACcount类提供draw:和deposit:两个方法,分别对应该账户的取钱、存钱等操作,因为这两个方法可能需要并发修改LCAccount类的balance成员变量,所以这两个方法都使用NSCondition来控制线程同步。除此之外,这两个方法还使用了wait、broadcast来控制线程的通信。 */ 1 LCAccount.m 2 3 @implementation LCAccount 4 5 NSCondition* cond; 6 7 BOOL flag; 8 9 - (id)init 10 11 { 12 13 self = [super init]; 14 15 if(self) 16 17 { 18 19 cond = [[NSCondition alloc] init]; 20 21 } 22 23 return self; 24 25 } 26 27 -(id)initWithAccountNo:(NSString*)aAccount balance:(CGFloat)aBalance 28 29 { 30 31 self = [super init]; 32 33 if(self) 34 35 { 36 37 cond = [[NSCondition alloc] init]; 38 39 _accountNo = aAccount; 40 41 _balance = aBalance; 42 43 } 44 45 return self; 46 47 } 48 49 // 提供一个线程安全的draw方法来完成取钱操作 50 51 - (void)draw:(CGFloat)drawAmount 52 53 { 54 55 // 加锁 56 57 [cond lock]; 58 59 // 如果flag为NO,则表明账户中还没有人存钱进去,取钱方法阻塞 60 61 if(!flag) 62 63 { 64 65 [cond wait]; 66 67 } 68 69 else 70 71 { 72 73 // 执行取钱操作 74 75 NSLog(”%@ 取钱:%g”, [NSThread currentThread].name, drawAmount); 76 77 _balance -= drawAmount; 78 79 NSLog(@”账户余额为:”%g”, self.balance); 80 81 // 将标识账户是否已有存款的旗标设为NO 82 83 flag = NO; 84 85 // 唤醒其他线程 86 87 [cond broadcast]; 88 89 } 90 91 [cond unlock]; 92 93 } 94 95 - (void)deposit:(CGFloat)depositAmount 96 97 { 98 99 [cond lock]; 100 101 // 如果flag为YES,则表明账户中已有人存钱进去了,存钱方法阻塞 102 103 if(flag)// 1 104 105 { 106 107 [cond wait]; 108 109 } 110 111 else 112 113 { 114 115 // 执行存款操作 116 117 NSLog(@”%@ 存款:%g”, [NSThread currentThread].name, depositAmount); 118 119 _balance += depositAmount; 120 121 NSLog(@”账户余额为:%g”, self.balance); 122 123 // 将标识账户是否已有存款的旗标设为YES 124 125 flag = YES; 126 127 // 唤醒其他线程 128 129 [cond broadcast]; 130 131 } 132 133 [cond unlock]; 134 135 } 136 137 // 此处省略了hash和isEqual:方法 138 139 … 140 141 @end |
说 明 |
上面程序中的代码使用了wait和broadcast进行控制,对存款线程而言,当程序进入deposit:方法后,如果flag为”YES”,则表明账户中已有存款,程序调用wait方法阻塞;否则,程序向下执行存款操作,当存款操作执行完成后,系统将flag设为“YES”,然后调用broadcast来唤醒其他被阻塞的线程----如果系统中有存款者线程,存款者线程也会被唤醒,但该存款者线程执行到“1”号代码处时再次进入阻塞状态,只有执行draw:方法的取钱者线程才可以向下执行,同理,取钱者线程的执行流程也是如此。 |
代 码 片 段 |
/* 程序中的存款者线程循环100次重复村矿,而取钱者线程则循环100次重复取钱,存款者线程和取钱者线程分别调用LCAccount对象的deposit:、draw:方法来实现。 */ 1 ViewController.m 2 3 @implementation ViewController 4 5 LCAccount* account; 6 7 - (void)viewDidLoad 8 9 { 10 11 [super viewDidLoad]; 12 13 // 创建一个账号 14 15 account = [[LCAccount alloc] initWithAccountNo:@”321321” balance:1000.0]; 16 17 } 18 19 - (IBAction)depositDraw:(id)sender 20 21 { 22 23 // 创建 启动3个存钱者线程 24 25 [NSThread detachNewThreadSelector:@selector(drawMethod:) toTarget:self withObject:[NSNumber numberWithDouble:800.0]]; 26 27 [NSThread detachNewThreadSelector:@selector(drawMethod:) toTarget:self withObject:[NSNumber numberWithDouble:800.0]]; 28 29 [NSThread detachNewThreadSelector:@selector(drawMethod:) toTarget:self withObject:[NSNumber numberWithDouble:800.0]]; 30 31 // 创建,启动取钱者线程 32 33 [NSThread detachNewThreadSelector:@selector(depositMethod:) toTarget:self withObject:[NSNumber numberWithDouble:800.0]]; 34 35 } 36 37 - (void)drawMethod:(NSNumber*)drawAmount 38 39 { 40 41 [NSThread currentThread].name = @”甲”; 42 43 // 重复100次执行取钱操作 44 45 for(int i = 0;i < 100; i++) 46 47 { 48 49 [account draw:drawAmount.doubleValue]; 50 51 } 52 53 } 54 55 - (void)depositMethod:(NSNumber*) depositAmount 56 57 { 58 59 [NSThread currentThread].name = @”乙”; 60 61 // 重复100次执行存款操作 62 for(int i = 0; i < 100; i++) 63 { 64 [account deposit:depositAmount.doubleValue]; 65 } 66 } 67 @end |