该系列文章系个人读书笔记及总结性内容,任何组织和个人不得转载进行商业活动!
第17章 并发编程
在计算科学中,并发编程(concurrent processing)是指同时执行多个逻辑控制流(软件中实现的);
计算机系统中,并发处理可以在硬件层到应用层的多个层级中实现;
从程序员的角度看,在应用层使用并发处理,可以开发出以并行方式(in parallel)执行多个操作的应用程序;
这些操作包括回应异步事件、访问I/O设备、提供网络服务以及进行并行运算等;
OC提供了各种语言扩展、API和操作系统服务,通过这些特性可以高效且安全的进行并发编程;
本章会介绍这种技术和多个示例程序;
对着一章的内容,真是又爱又恨,哎,希望这次不再失望;
17.1 并发编程的基本原则
并发编程:
领域广阔,先来了解下一些基本术语和各种并发编程的设计概念及优点;
并发处理与顺序处理的区别:
程序执行过程:
顺序处理:按照先后顺序处理逻辑控制流(即逐个处理);
任务1——>任务2——>任务3
并发处理:同时执行多个任务;
任务1——>
——> 任务3
任务2——>
这里需要说明一下 并行计算(parallel computing)和并发计算(concurrent computing)的差异;
在设计时希望利用并发机制的程序是否会并行执行多任务,取决于运行程序的计算机系统;
从广义上,并行计算与硬件有关,并发计算与设计有关;
并行计算是指:多个软件同时执行多个操作或任务;
执行并行计算(即并行处理)的能力直接取决于计算机硬件;对于如今流行的多核使得计算机可以同时执行多条指令;
并发计算是指:一个软件被设计和实现来同时执行多个操作或任务;
要发挥并发处理的优势,就必须以相应的方式设计和实现软件,并在能够支持并行处理的硬件上运行它;
此外还需了解 并发编程 与 异步编程的差异;
并发处理是指同时处理多个逻辑控制流;
异步处理是一种对方法和函数进行异步(非阻塞)调用操作的高效机制;
调用方法之后,在该方法被执行时,调用程序仍然可以继续其处理过程;
这种方式抽象化了基础实现机制,可以提高程序响应性、系统吞吐量等性能指标;
可以通过多种方式来实现异步处理,也可以通过并发编程API和服务来实现;
17.1.1 并发处理的优势
并发处理的优势:
1)增加应用程序吞吐量:
吞吐量指一段时间内应用程序能够完成的任务数;并发处理会比顺序处理完成更多任务;
2)提高系统利用率:
更集中、高效地利用系统资源;
3)提高应用程序的整体响应性:
等待的任务,并不影响其他并发任务的继续运行,减少应用程序整体闲置时间,提高响应性;
4)更好地与问题领域契合:
如对某些任务进行建模时(科学,数学,人工智能),可以创建为同时处理的任务集,更自然,更优;
17.1.2 实现并发处理
使用并发处理的方式:
1)分布式处理:
在这种并发处理方式中,多个任务会被分发给多台通过网络相连的计算机执行,这些计算机通过消息传递来相互通信;
2)并行处理:
在这种并发处理方式中,通常由多核CPU和可编程GPU进行大量并行计算;
利用了并行计算提高性能,交付计算密集型算法可以实现的功能;
3)多进程:
在这种并发处理方式中,多个任务会被分发给一台计算机中的多个进程;每个进程都拥有由操作系统管理的独立资源和地址空间;
4)多线程:
在这种并发处理方式中,多个任务会与多个线程对应,这些线程会被配置为以并发方式执行;
因为这些线程是在单个进程中被执行,所以他们会共享资源(如地址空间和内存等);
线程、进程和任务:
线程是指可以独立执行的指令序列;有时也称为轻量级进程,而且多个线程可能会共享一个地址空间;
进程是指正在运行的、拥有独立地址空间和系统资源的计算机程序;进程可能会含有多个线程,这些线程可以顺序执行、以并发的方式执行,或者混合使用;
任务也称为作业的逻辑单元;任务可以由线程或进程执行;
本章后续介绍的并发编程机制都是以多线程方式为基础,下面接着介绍;
17.2 并行处理带来的挑战
主要难点体现在:
进行同步操作和在并发执行的控制线程之间(即逻辑控制流)共享信息;
在控制不同线程中的相关操作时需要实现同步,而在线程之间进行通信就必须实现信息共享;
此外,由于要同时执行控制流的多个线程,整个程序的执行顺序就会不确定,得到的结果也可能不同;
多线程和他们之间潜在的交互也会增加程序的复杂性;
处理机制:
常用的两种是 共享内存 和 消息传递;
共享内存编程模式会实现共享状态,即多个线程都可以访问某些程序数据;
当程序中的多个线程共同使用某个地址空间,共享内存就成了信息共享方式的自然之选,即快速又高效;
17.3 共享数据
共享内存模式需要一种机制来协调多个线程共用的数据;
通常使用同步机制来实现这一目的,如使用锁和判定条件;(终于到了正题了)
锁:
锁是一种控制多线程间数据访问和资源共享的机制;线程获得共享资源的锁,对该资源执行操作,接着释放这个锁,然后其他线程才能访问该资源;
条件变量:
条件变量是一种同步机制,它使线程一直处于等待状态直到指定条件出现,条件变量通常是使用锁实现的;
锁带来的问题:
锁是一种常见的控制机制,使用它可以控制多线程对共享数据的访问;
锁实施了一种互斥策略,从而避免受保护的数据和资源被多个线程同时访问;
遗憾的是,使用锁协调对共享数据的访问时,很可能引发 死锁、活锁和资源匮乏问题,这些问题都会导致程序中断;
死锁:是指两个或多个线程相互阻塞的情况,每个线程都在等待其他线程释放锁,导致所有线程都一直处于等待状态;
死锁示例:循环等待
@1——>线程1——>@2
| |
对象1 对象2
| |
@4<——线程2<——@3
@1:(被锁)保持
@2:请求(阻塞)
@3:(被锁)保持
@4:请求(阻塞)
活锁:是指一个线程因为要回应其他一个或多个线程,而导致自身无法继续执行的情况;
活锁的线程没有被阻塞,它将所有的时间都用于回应其他线程,以恢复正常操作;
资源匮乏:是指线程无法正常访问共享资源的情况,其原因是共享资源被其他资源占用;
当一个或多个线程占用共享资源的时间过长时,就会引发这种问题;
实际上,也可以将活锁视为资源匮乏的一种形式;
防止出现死锁的方式:
1)实现获取锁的总次序:
确保进程按照固定次序获取和释放锁;这种方式需要掌握线程代码的知识,而且可能无法应用于第三方软件;
2)防止出现保持和等待条件:
使线程一次原子获取所有锁;这可以确保在任何时候每个线程都拥有一个锁,从而使程序获得全局预防锁;
这种处理方式消除了出现保持和等待情况的可能性;但会降低并发处理的效率,也需要掌握线程代码的知识;
3)提供优先权:
使用提供试锁(trylock)或类似机制的锁,如果可以,获取锁;如果不行,返回一个合适的结果;
这种方式会增加出现活锁的可能性,而且需要掌握代码如何使用锁的知识;
4)设置等待超时:
使用提供超时功能的锁,防止出现无限等待的情况;
17.4 消息传递
在消息传递模式中,模型状态不是共享的,线程通过交换信息进行通信;
这种处理方式使线程能够通过交换消息进行同步和通信;
消息传递避免了互斥问题,并自然地与多核、多处理器系统契合;
使用消息传递既可以执行同步通信,也可以执行异步通信;
在进行同步消息传递时,发送者和接收者会直接连接;消息传递操作完成后,发送者和接收者会断开连接;
异步消息传递通过队列传输消息;
示例:使用队列传递消息
发送消息 接收消息
线程1——————> 队列 ——————>线程2
如示例中所示:
消息不是在线程之间直接传递,而是通过消息队列进行交换;
因此,发送者和接收者并不会配对,发送者将消息发送给队列后也无需断开连接;
使用异步消息传递可以实现并发编程;
下一节,我们会接触到几个使用异步消息传递的框架;
17.5 在OC中实现并发编程
目前为止,我们已经介绍了一些与并行编程有关的关键问题;接下来看看如何实现OC的并发编程;
内容从语言特性到API与系统服务:
1)语言特性:
OC语言有多个支持并发编程的特性;
使用@synchronized指令可以在OC代码中创建锁;
使用atomic属性限定符可以对OC属性进行线程安全的访问;
2)消息传递:
Foundation框架中的NSObject类含有多个用于向其他线程发送消息的方法;
这些方法会将目标线程运行循环中的消息添加到队列中,而且能够通过同步或异步方式执行;
3)线程:
Foundation框架提供了直接创建和管理线程的整套API;其中还包括用于对多线程共享数据进行同步访问的Foundation框架的API集;
4)操作队列:
这是基于OC的消息传递机制,他通过异步设计方法实现并发编程;
5)分派队列:
这些是基于C语言的一系列语言特性和运行时服务,用于通过异步和并发方式执行任务;
17.6 语言特性
@synchronized指令:
提供了OC代码中创建锁的简单机制,使并发线程能够同步访问共享状态;
示例:
NSObject * uniqueObj = [NSObject new];
@synchronized(uniqueObj){
//关键部分-被指令保护的代码
}
@synchronized指令后带有一个放在圆括号中的唯一标识符,以及放在花括号中的受保护代码块;
唯一标识符:是一个用于区分受保护代码块的对象;
如果有多个线程尝试使用相同的唯一标识符访问这个关键部分,那么这些线程中的某一个线程会先得到锁,而其他线程会阻塞,直到得到所的线程完成对这个关键部分的操作为止;
值得注意的是:
@synchronized语句块会隐式地向受保护代码中添加一个异常处理程序;
该异常程序会在程序抛出异常时自动释放锁;
因此,要使用@synchronized指令,就必须在OC代码中启用异常处理;
OC语言提供了另一种用于对属性进行原子访问的特性;
原子属性限定符是一种OC语言特性,专门用于提供对属性的原子访问;
即使其访问方法被多个线程同时调用,它也能发挥作用;
atonic(原子):
是指不论属性是否被以并发方式访问,属性的访问方法永远都会设置/获取完成(一致的)值;
示例:
@property (atonic,readwrite) NSString * greeting;
默认,OC中属性都是原子的,因此无需专门在属性声明语句中使用atonic关键字;
注意:原子属性限定符为属性提供了原子访问方式,但没有提供线程安全性;
@property (atonic,readwrite) Person * person;
例如:Person类的属性firstName和lastName,都是原子的,但是Person的对象并不具备线程安全性;
原因在于,person没有以并发方式访问其各个组件的机制(即firstName和lastName属性有可能被单独修改);
使用@synchronized指令和同步基元可以解决该问题,稍后会介绍这方面的内容;
17.7 消息传递
Foundation框架的NSObject类含有许多方法,这些方法使用消息传递模式,通过线程调用对象中的方法;
该线程可以是现存的次要线程,也可以是应用主线程;
1)performSelector:onThread:withObject:waitUntilDone:
2)performSelector:onThread:withObject:waitUntilDone:modes:
3)performSelectorOnMainThread:withObject:waitUntilDone:
4)performSelectorOnMainThread:withObject:waitUntilDone:modes:
NSObject类中的每个方法都会为接收对象中将被线程调用的方法设置选择器;
该方法SEL也叫做线程入口点例程;
选择器消息会在线程的运行循环中排队,而该方法会作为运行循环中的标准处理过程被线程执行;
使用这些消息传递方法,可以设置以同步或异步方式调用线程;
同步调用方式会阻塞当前线程,直到该方法执行完为止;(waitUntilDone = YES/NO,表示同步/异步执行)
在创建线程时,可以配置它的部分运行环境(如栈的大小、本地线程存储空间、线程优先权等);
通过使用下列功能实现线程入口点例程(适当配置线程环境也非常重要):
1)自动释放池:
应该在线程入口点例程的开头创建自动释放池,并在其末尾移除自动释放池;
2)异常处理程序:
如果应用程序捕捉并处理异常,就应该将入口点例程配置为捕捉任何可能出现的异常;(第14章介绍了异常处理机制,我们找时间在介绍)
3)运行循环:
要以动态方式处理线程处理请求,可在线程入口点例程中设置运行循环;
示例:
#import <Foundation/Foundation.h>
@interface C17ConcurrentProcessor : NSObject
@property (readonly,assign) BOOL isLoaded;
-(void)downloadTask;
@end
#import "C17ConcurrentProcessor.h"
@interface C17ConcurrentProcessor ()
@property (assign) BOOL isLoaded;
@end
@implementation C17ConcurrentProcessor
-(void)downloadTask{
@autoreleasepool{
NSURL * url = [NSURL URLWithString:@"http://www.apress.com"];
NSString * str = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:nil];
NSLog(@"URL Contents :%@",str);;
self.isLoaded = YES;
}
}
@end
C17ConcurrentProcessor * concurrentProcessor = [C17ConcurrentProcessor new];
[concurrentProcessor performSelectorOnMainThread:@selector(downloadTask) withObject:nil waitUntilDone:NO];
log比较长(网页内容)就不打印了;
NSObject类中的performSelectorOnMainThread:方法通常用于:
从次要线程对象向主线程对象返回值(如状态,计算结果等);
这样就在主,次线程之间实现同通信;
对于只应通过主线程使用的对象(如UIKit中的某些对象),这个API尤为重要;
17.8 线程
线程是指在某个进程环境中执行的逻辑控制流;
OS X和iOS为线程的创建、管理和执行提供了直接的支持;
在应用层,Foundation框架提供了许多用于创建和管理线程的API;
以及用于在并发线程之间同步共享数据访问的API集合;
17.8.1 NSObject线程
使用NSObject的performSelectorInBackground:withObject:方法,可以隐式地创建和启动用于执行对象中方法的新线程;
该线程会作为后台次要线程立刻启动,当前线程会立刻返回;
这个方法提供了一种使用新后台线程执行对象中方法的简单机制;
该线程实例是隐式创建的,因此无需直接使用API;
应根据需要,通过自动释放池、异常处理器和运行循环在方法(线程的入口点例程)中配置该线程的环境;
17.8.2 NSThread
(我们仍然使用示例类C17ConcurrentProcessor)
NSThread类提供了用于通过显示创建和管理线程的API;
该类含有很多方法,使用可以创建和初始化NSThread对象(附到其他对象方法中)、启动和停止线程、配置线程和查询线程及其执行环境;
下面是NSThread类中用于创建和初始化线程的API:
1)detachNewThreadSelector:toTarget:withObject:
创建并启用新线程;
它的输入参数是用作线程入口点的选择器和新线程中选择器的目标;
示例:
[NSThread detachNewThreadSelector:@selector(downloadTask) toTarget:concurrentProcessor withObject:nil];
这个方法创建新线程并调用接收者的入口点例程(即将该方法与它的选择器对应起来);
和NSObject的performSelectorInBackground:withObject:功能上等价;
2)initWithTarget:selector:object:
创建线程但不会启动线程;
当已初始化的线程开始执行接收者的入口点例程时,NSThread类中的启动实例方法就会被调用;
示例:
NSThread * processThread = [[NSThread alloc] initWithTarget:concurrentProcessor selector:@selector(downloadTask) object:nil];
[processThread setThreadPriority:0.5];
[processThread start];
该方法设置了选择器、目标接收者的实例和可用作入口点例程参数的对象;
初始化线程,在调用start方法之前,可以使用它配置线程;
NSThread类的API可以配置线程、确定线程的执行状态和查询线程的环境;
这可以是你能够设置线程的优先权、栈大小和线程字典;
检索当前线程和调用栈信息;
暂停线程以及执行一些其他操作;
示例:
[NSThread sleepForTimeInterval:5.0];//将当前线程暂停5s
17.8.3 线程同步
使用线程实现并发编程,OC提供了多种机制来管理共享状态和实现线程间同步;
尤其是,Foundation框架中含有的一系列锁和条件变量API,它们以面向对象的方式实现了这些机制;
1.锁
Foundation框架提供的NSLock、NSRecursiveLock、NSConditionLock和NSDistributedLock:
使用它们可以实现各种用于控制同步访问共享状态操作的锁;
锁用来保护关键部分(即用于访问共享数据或资源的代码部分),这些代码不允许由多个线程以及并发方式执行;
NSLock类:
为并发编程提供了一种基本的互斥锁;
遵循NSLocking协议,因此会实现获取和释放锁的lock和unlock方法;
之前提供的@synchronized基元,是一种OC语言特性,可以媲美NSLock类的互斥锁;
区别在于:
1)@synchronized指令隐式创建锁,NSLock类的API直接创建锁;
2)@synchronized指令会隐式地为关键部分提供异常处理程序,NSLock没有这一功能;
NSLock * computeLock = [NSLock new];
[computeLock lock];
//关键部分
[computeLock unlock];
NSDistributedLock类:
定义了一个可由多台主机上的多个应用程序使用的锁;
使用该锁可以控制对共享资源的访问操作;
NSDistributedLock实例没有互斥策略,而是在锁处于忙碌状态时发送报告,由使用锁的代码根据锁的状态适当地执行操作;
示例:用一个文件的路径(/hello.lck)创建分布锁(你可能会将该文件作为锁定系统对象)
(OS X系统中使用)
//C17 test code
NSDistributedLock * filelock = [NSDistributedLock lockWithPath:@"/hello.lck"];
//访问资源
//解除对资源的锁定
[filelock unlock];
NSDistributedLock类并没有遵循NSLocking协议,这个锁还是使用文件系统实现的,所以他必须显示释放;
如果某个应用在拥有分布式锁的情况下终止运行,那么其他客户端必须使用NSDistributedLock类的breaklock方法才能解除这种锁定情况;
NSConditionLock类:
定义了一种只有在特定条件下才能被获取和释放的锁;
这个条件是由你定义的整数值;
条件锁通常用于确保任务以指定的顺序执行;
示例:程序在指定条件出现的时候获取一个锁
NSConditionLock * dataLock = [[NSConditionLock alloc] initWithCondition:NO];
//获取锁(缓冲区中没有数据)
[dataLock lock];
//将数据添加到缓冲区
//根据条件解锁(数据位于缓冲区中)
[dataLock unlockWithCondition:YES];
NSRecursiveLock类:
定义了一种在不引起死锁的情况下,可以被同一个线程获取多次的锁;
这个锁可以记录他被获取的次数,而在释放该锁前,必须用相应的调用语句进行平衡以解锁对象;
2.条件
条件变量是一种锁,用于同步操作的执行顺序;
尝试获取/等待条件的线程会一直阻塞,直到另一个线程显示地向该条件发送信号(signal)为止;
实际上,条件变量允许线程根据实际的数据值进行同步;
(不是很理解,我们接着看)
Foundation框架的NSCondition类实现了一种条件变量;
下面是使用条件对象的逻辑:
1)锁定条件对象并检查其相应的布尔条件表达式;
2)若YES,执行相关任务,之后跳转到步骤4;
3)若NO,就使用条件对象的wait或waitUntilDate:方法阻塞线程,然后重新检查条件值(即跳转到步骤2);
4)使用条件对象的signal或broadcast方法再次向条件对象发送信号,或者更改条件表达式的值;
5)解除对条件对象的锁定;
示例:一个入口点例程模板,使用NSCondition对象访问和处理数据(生产者-消费者)
-(void)consumerTask{
@autoreleasepool{
//获取条件锁并测试布尔条件
[self.condition lock];
while (!self.dataAvalible) {
[self.condition wait];
}
//数据处于可访问状态后,对数据进行处理
//...
NSLog(@"Deal data!");
//完成处理数据的操作,更新判断值和发送信号的条件
self.dataAvalible = NO;
[self.condition unlock];
}
}
-(void)producterTask{
@autoreleasepool{
//获取条件锁并测试布尔条件
[self.condition lock];
while (self.dataAvalible) {
[self.condition wait];
}
//检索需要处理的数据
//...
NSLog(@"Deal check!");
//完成检索数据的操作,更新判断值和发送信号的条件
self.dataAvalible = YES;
[self.condition unlock];
}
}
[self producterTask];
[self consumerTask];
log:
2018-01-02 17:21:01.801690+0800 精通Objective-C[78283:13896319] Deal check!
2018-01-02 17:21:01.801870+0800 精通Objective-C[78283:13896319] Deal data!
条件变量提供了一种高效的机制,使用该机制可以控制对共享数据的访问操作,还可以同步对这些数据的处理操作;
可以看出,条件变量就是一种锁;
17.9 使用线程实现并发处理
到目前为止,已经介绍了线程和同步的知识,接下来我们介绍使用线程和这些同步机制创建一个执行并发处理的示例;
我们将新定义的类命名为ConcurrentProcessor;(请区别于之前的C17ConcurrentProcessor)
(Code ConcurrentProcessor.h ConcurrentProcessor.m)
#import <Foundation/Foundation.h>
@interface ConcurrentProcessor : NSObject
@property (readwrite) BOOL isFinished;
@property (readonly) NSInteger computeResult;
-(void)computeTask:(id)data;
@end
#import "ConcurrentProcessor.h"
@interface ConcurrentProcessor()//类扩展 为computeResult属性重启写入操作
@property (readwrite) NSInteger computeResult;
@end
@implementation ConcurrentProcessor{
//线程管理和同步的私有实例变量
NSString * computeID; //@synchronize指令锁定的唯一对象
NSUInteger computeTasks;//并行计算任务的计数
NSLock * computeLock; //锁对象
}
-(instancetype)init{
if (self = [super init]) {
_isFinished = NO;
_computeResult = 0;
computeLock = [NSLock new];
computeID = @"1";
computeTasks = 0;
}
return self;
}
-(void)computeTask:(id)data{
NSAssert([data isKindOfClass:[NSNumber class]], @"Not an NSNumber instance");
NSUInteger computations = [data unsignedIntegerValue];
@autoreleasepool{
@try{
//获取锁并增加活动任务的计数
if ([[NSThread currentThread] isCancelled]) {
return;
}
@synchronized(computeID){
computeTasks++;
}
//获取锁并执行关键代码部分中的计算操作
[computeLock lock];
if ([[NSThread currentThread] isCancelled]) {
[computeLock unlock];
return;
}
NSLog(@"Performing computations %ld",computations);
for (int ii = 0; ii < computations; ii++) {
self.computeResult = self.computeResult + 1;
}
[computeLock unlock];
//模拟额外的处理时间(在关键部分之外的其他操作)
[NSThread sleepForTimeInterval:1.0];
//减少活动任务数,如果数量为0,则更新标志位
@synchronized(computeID){
computeTasks--;
if (!computeTasks) {
self.isFinished = YES;
}
}
}
@catch(NSException * ex){
}
}
}
@end
ConcurrentProcessor * processer = [[ConcurrentProcessor alloc] init];
[processer performSelectorInBackground:@selector(computeTask:) withObject:@5];
[processer performSelectorInBackground:@selector(computeTask:) withObject:@10];
[processer performSelectorInBackground:@selector(computeTask:) withObject:@20];
while (!processer.isFinished) {
}
NSLog(@"%ld",(long)processer.computeResult);
log:
2018-01-02 18:05:39.088456+0800 精通Objective-C[78879:13937931] Performing computations 10
2018-01-02 18:05:39.088582+0800 精通Objective-C[78879:13937930] Performing computations 5
2018-01-02 18:05:39.088759+0800 精通Objective-C[78879:13937932] Performing computations 20
2018-01-02 18:05:40.162764+0800 精通Objective-C[78879:13937824] 35
分析:
-(void)computeTask:(id)data;方法中;
首先,该方法被封装在自动释放池和try-catch异常语句中;
这是为了确保该方法的线程不会泄露对象;并且处理任何抛出的异常(每个线程都负责处理自身的异常);
因为这个方法可以由多个线程并发执行,并且能够访问和更新共享数据,所以必须同步对这一数据的访问;
使用@synchronized可以控制对computeTasks变量的访问,从而允许每次一个线程更新其内容;
其次,该方法中执行计算过程的代码,只是简单根据参数类增computeResult的值;
这段代码必须在关键部分执行,以实施对共享数据的同步访问;
该方法以减少执行他的线程数作为结束,如果线程数为0,则isFinished为YES;
这个逻辑也是在同步语句块(@synchronized)中实现,以确保该方法一次只能由一个线程访问;
测试代码中:
使用了三个后台线程,操作了processer对象;当他们都执行完,计算结果打印输出;
该示例展示了如何使用多线程实现并发编程;它还体现了一些线程管理和并发处理的复杂性;
下一节介绍另一种并发编程方式——操作和操作队列;