iOS面试备战-多线程

image

iOS面试中多线程绝对是最重要的知识点之一,它在日常开发中会被广泛使用,而且多线程是有很多区分度很高的题目可供考察的。这篇文章会梳理下多线程和GCD相关的概念和几个典型问题。因为GCD相关的API用OC看着更直管一些,所以这期实例就都用OC语言书写。

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:130595548,不管你是大牛还是小白都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

概念篇

在面对一些我们常见的概念时,我们常有种这个东西我熟,就认为自己理解了,其实这种程度是不够的。当我们可以清晰准确的向别人描述一个东西,并能理解其官方定义的每个用语的含义,才算是我们熟悉理解了它。所以这里单独抽一节讲下多线程中的概念。

进程,线程,任务,队列

进程:资源分配的最小单位。在iOS中一个应用的启动就是开启了一个进程。 线程:CPU调度的最小单位。一个进程里会有多个线程。 大家可以思考下,进程和线程为什么是从资源分配和CPU调度层面进行定义的。

任务:每次执行的一段代码,比如下载一张图片,触发一个网络请求。 队列:队列是用来组织任务的,一个队列包含多个任务。

GCD

GCD(Grand Central Dispatch)是异步执行任务的技术之一。开发者只需要定义想执行的任务并追加到适当的Dispatch Queue中,GCD就能生成必要的线程执行该任务。这里的线程管理是由系统处理的,我们不必关心线程的创建销毁,这大大方便了我们的开发效率。也可以说GCD是一种简化线程操作的多线程使用技术方案。

安卓没有跟GCD完全相同的一套技术方案的,虽然它可以处理GCD实现的一系列效果。

串行,并行,并发

GCD的使用都是通过调度队列(Dispatch Queue)的形式进行的,调度队列有以下 几种形式:

串行(serial):多任务中某时刻只能有一个任务被运行;

并行(parallel):相对于串行,某时刻有多个任务同时被执行,需要多核能力;

并发(concurrent):引入时间片和抢占之后才有了并发的说法,某个时间片只有一个任务在执行,执行完时间片后进行资源抢占,到下一个任务去执行,即“微观串行,宏观并发”,所以这种情况下只有一个空闲的某核,多核空闲就又可以实现并行运行了;

我们常用的调度队列有以下几种:

// 串行队列
dispatch_queue_t serialQueue = dispatch_queue_create("com.gcd.serialQueue", DISPATCH_QUEUE_SERIAL);
// 并发队列
dispatch_queue_t concurrentQueue = dispatch_queue_create("com.gcd.concurrentQueue", DISPATCH_QUEUE_CONCURRENT);
// 全局并发队列
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 主队列
let mainQueue = DispatchQueue.main

注意GCD创建的是并发队列而不是并行队列。但这里的并发队列是一个相对宽泛的定义,它包含并行的概念,GCD作为一个智能的中心调度系统会根据系统情况判断当前能否使用多核能力分摊多个任务,如果满足的话此时就是在并行的执行队列中的任务。

同步,异步

同步:函数会阻塞当前线程直到任务完成返回才能进行其它操作;

异步:在任务执行完成之前先将函数值返回,不会阻塞当前线程;

串行、并发和同步、异步相互结合能否开启新线程

串行队列 并发队列 主队列
同步 不开启新线程 不开启新线程 不开启新线程
异步 开启新线程 开启新线程 不开启新线程

主线程和主队列

主线程是一个线程,主队列是指主线程上的任务组织形式。

主队列只会在主线程执行,但主线程上执行的不一定就是主队列,还有可能是别的同步队列。因为前说过,同步操作不会开辟新的线程,所以当你自定义一个同步的串行或者并行队列时都是还在主线程执行。

判断当前是否是主线程:

BOOL isMainThread = [NSThread isMainThread];

判断当前是否在主队列上:

static void *mainQueueKey = "mainQueueKey";
dispatch_queue_set_specific(dispatch_get_main_queue(), mainQueueKey, &mainQueueKey, NULL);
BOOL isMainQueue = dispatch_get_specific(mainQueueKey));

队列与线程的关系

队列是对任务的描述,它可以包含多个任务,这是应用层的一种描述。线程是系统级的调度单位,它是更底层的描述。一个队列(并行队列)的多个任务可能会被分配到多个线程执行。

问题

代码分析

1、分析下面代码的执行逻辑

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    [self syncMainTask];
}

- (void)syncMainTask {
    dispatch_queue_main_t mainQueue = dispatch_get_main_queue();
    dispatch_sync(mainQueue, ^{
        NSLog(@"main queue task");
    });
}

这段代码会输出task1,然后发生死锁,导致crash。

追加问题一:为什么会死锁?死锁就会导致crash?

我们先分析crash的情况,正常死锁应该就是卡死的情况,不应该导致carsh。那为什么会carsh呢,看崩溃信息:

image

是一个EXC_BAD_INSTRUCTION类型的crash,执行了一个出错的命令。

然后看__DISPATCH_WAIT_FOR_QUEUE__的调用栈信息:

image

右侧汇编代码给出了更详细的crash信息:BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread

在当前线程已经拥有的队列中执行dispatch_sync同步操作会导致crash。

libdispatch的源码中我们可以找到该函数的定义:

DISPATCH_NOINLINE
static void
__DISPATCH_WAIT_FOR_QUEUE__(dispatch_sync_context_t dsc, dispatch_queue_t dq) {
    uint64_t dq_state = _dispatch_wait_prepare(dq);
    if (unlikely(_dq_state_drain_locked_by(dq_state, dsc->dsc_waiter))) {
    DISPATCH_CLIENT_CRASH((uintptr_t)dq_state,
            "dispatch_sync called on queue "
            "already owned by current thread");
    }
    /*...*/
}

所以我们知道了,这个carsh是libdispatch内部抛出的,当它检测到可能发生死锁时,就直接触发崩溃,事实上它不能完全判断出所有死锁的情况。

我们分析这里为什么会发生死锁。首先syncMainTask就是在主队列中的,我们在主队列先添加dispatch_sync然后再添加其内部的block。主队列FIFO,只有sync执行完了才会执行内部的block,而此时是一个同步队列,block执行完才会退出sync,所以导致了死锁。

对于死锁的解释我也查了好几篇文章,有些说法其实是经不起推敲的,这个解释是我认为相对合理的。

附一篇参考文章:GCD死锁

引出问题二:什么情况下会发生死锁?

GCD中发生死锁需要满足两个条件:

  • 同步执行串行队列
  • 执行sync的队列和block所在队列为同一个队列

引出问题三:如何避免死锁?这段代码应该如何修改?

根据上面提到的条件,我们可以将任务异步执行,或者换成一个并发队列。另外将block放到一个非主队列里执行也是可以的。

2、分析一下代码执行结果

int a = 0;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
while (a < 2) {
    dispatch_async(queue, ^{
        a++;
    });
}
NSLog(@"a = %d", a);

首先该段代码会编译不过,编译器检测到变量a被block截获,并尝试修改就报以下错误:

Variable is not assignable (missing __block type specifier)。如果我们要在block里对外界变量重新复制,需要添加__block的声明:__block int a = 0;

我们分析这段代码,在开始while之后加入一个异步任务,再之后呢,这个是不确定了,可能是执行a++也可能是因不满足退出条件再次执行加入异步任务,直到满足a<2才会退出while循环。那输出结果也就是不确定了,因为可能在判断跳出循环和输出结果的时候另外的线程又执行了一次a++

再扩展下,如果将那个并发队列改成主队列,执行逻辑还是一样的吗?

首先主队列是不会开启新线程的,主队列上的异步操作执行时机是等别的任务都执行完了,再来执行添加的a++。显然在while循环里,主队列既有任务还未执行完毕,所以就不会执行a++,也就导致while循环不会退出,形成死循环。

其它问题

什么是线程安全,为什么UI操作必须在主线程执行

线程安全:当多个线程访问某个方法时,不管你通过怎样的调用方式或者说这些线程如何交替的执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类时线程安全的。

为什么UI操作必须放到主线程:首先UIKit不是线程安全的,多线程访问会导致UI效果不可预期,所以我们不能使用多个线程去处理UI。那既然要单线程处理UI为什么是在主线程呢,这是因为UIApplication作为程序的起点是在主线程初始化的,所以我们后续的UI操作也都要放到主线程处理。

关于这个问题展开讨论可以参阅这篇文章:iOS拾遗——为什么必须在主线程操作UI

开启新的线程有哪些方法

1、NSThread

2、NSOperationQueue

3、GCD

4、NSObject的performSelectorInBackground方法

5、pthread

多线程任务要实现顺序执行有哪些方法

1、dispatch_group

2、dispatch_barrier

3、dispatch_semaphore_t

4、NSOperation的addDependency方法

如何实现一个多读单写的功能?

多读单写的意思就是可以有多个线程同时参与读取数据,但是写数据时不能有读操作的参与切只有一个线程在写数据。

我们写一个示例程序,看下在不做限制的多读多写程序中会发生什么。

// 计数器
self.count = 0;
// 并发队列
self.concurrentQueue = dispatch_get_global_queue(0, 0);
for (int i = 0; i< 10; i++) {
    dispatch_async(self.concurrentQueue, ^{
        [self read];
    });
    dispatch_async(self.concurrentQueue, ^{
        [self write];
    });
}
// 读写操作
- (void)read {
    NSLog(@"read---- %d", self.count);
}

- (void)write {
    self.count += 1;
    NSLog(@"write---- %d", self.count);
}

// 输出内容
2020-07-18 11:47:03.612175+0800 GCD_OC[76121:1709312] read---- 0
2020-07-18 11:47:03.612273+0800 GCD_OC[76121:1709311] read---- 1
2020-07-18 11:47:03.612230+0800 GCD_OC[76121:1709314] write---- 1
2020-07-18 11:47:03.612866+0800 GCD_OC[76121:1709312] write---- 2
2020-07-18 11:47:03.612986+0800 GCD_OC[76121:1709311] write---- 3
2020-07-18 11:47:03.612919+0800 GCD_OC[76121:1709314] read---- 2
2020-07-18 11:47:03.613252+0800 GCD_OC[76121:1709312] read---- 3
2020-07-18 11:47:03.613346+0800 GCD_OC[76121:1709314] write---- 4
2020-07-18 11:47:03.613423+0800 GCD_OC[76121:1709311] read---- 4

每次运行的输出结果都会不一样,根据这个输出内容,我们可以看到在还没有执行到输出write----1的时候,就已经执行了read----1,在write---- 3之后 read的结果却是2。这绝对是我们所不期望的。其实在程序设计中我们是不应该设计出多读多写这种行为,因为这个结果是不可控。

解决方案之一是对读写操作都加上锁做成单独单写,这样是没问题但有些浪费性能,正常写操作确定之后结果就确定了,读的操作可以多线程同时进行,而不需要等别的线程读完它才能读,所以有了多读单写的需求。

解决多读单写常见有两种方案,第一种是使用读写锁pthread_rwlock_t

读写锁具有一些几个特性:

  • 同一时间,只能有一个线程进行写的操作
  • 同一时间,允许有多个线程进行读的操作。
  • 同一时间,不允许既有写的操作,又有读的操作。

这跟我们的多读单写需求完美吻合,也可以说读写锁的设计就是为了实现这一需求的。它的实现方式如下:

// 执行读写操作之前需要定义一个读写锁
@property (nonatomic,assign) pthread_rwlock_t lock;
pthread_rwlock_init(&_lock,NULL);
// 读写操作
- (void)read {
    pthread_rwlock_rdlock(&_lock);
    NSLog(@"read---- %d", self.count);
    pthread_rwlock_unlock(&_lock);
}

- (void)write {
    pthread_rwlock_wrlock(&_lock);
    _count += 1;
    NSLog(@"write---- %d", self.count);
    pthread_rwlock_unlock(&_lock);
}
// 输出内容
2020-07-18 12:00:29.363875+0800 GCD_OC[77172:1722472] read---- 0
2020-07-18 12:00:29.363875+0800 GCD_OC[77172:1722471] read---- 0
2020-07-18 12:00:29.364195+0800 GCD_OC[77172:1722469] write---- 1
2020-07-18 12:00:29.364325+0800 GCD_OC[77172:1722472] write---- 2
2020-07-18 12:00:29.364450+0800 GCD_OC[77172:1722470] read---- 2
2020-07-18 12:00:29.364597+0800 GCD_OC[77172:1722471] write---- 3
2020-07-18 12:00:29.366490+0800 GCD_OC[77172:1722469] read---- 3
2020-07-18 12:00:29.366703+0800 GCD_OC[77172:1722472] write---- 4
2020-07-18 12:00:29.366892+0800 GCD_OC[77172:1722489] read---- 4

我们查看输出日志,所以的读操作结果都是最近一次写操作所赋的值,这是符合我们预期的。

还有一种实现多读单写的方案是使用GCD中的栅栏函数dispatch_barrier。栅栏函数的目的就是保证在同一队列中它之前的操作全部执行完毕再执行后面的操作。为了保证写操作的互斥行,我们要对写操作执行「栅栏」:

// 我们定义一个用于读写的并发对列
self.rwQueue = dispatch_queue_create("com.rw.queue", DISPATCH_QUEUE_CONCURRENT);

- (void)read {
    dispatch_sync(self.rwQueue, ^{
        NSLog(@"read---- %d", self.count);
    });
}

- (void)write {
    dispatch_barrier_async(self.rwQueue, ^{
        self.count += 1;
        NSLog(@"write---- %d", self.count);
    });
}

这个输出结果跟读写锁实现是一样的,也是符合预期的。

这里多说几句,这里的读和写分别使用syncasync。读操作要用同步是为了阻塞线程尽快返回结果,不用担心无法实现多读,因为我们使用了并发队列,是可以实现多读的。至于写操作使用异步的栅栏函数,是为了写时不阻塞线程,通过栅栏函数实现单写。如果我们将读写都改成sync或者async,由于栅栏函数的机制是会顺序先读后写。如果反过来,读操作异步,写操作同步也是可以达到多读单写的目的的,但读的时候不立即返回结果,网上有人说只能使用异步方式,防止发生死锁,这个说法其实不对,因为同步队列是不会发生死锁的。

用GCD如何实现一个控制最大并发数且执行任务FIFO的功能?

这个相对简单,通过信号量实现并发数的控制,通过并发队列实现任务的FIFO的执行

int maxConcurrent = 3;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(maxConcurrent);
dispatch_async(queue, ^{
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    // task
    dispatch_semaphore_signal(semaphore);
});

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:130595548,不管你是大牛还是小白都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

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

推荐阅读更多精彩内容

  • 1、进程 1)进程是一个具有一定独立功能的程序关于某次数据集合的一次运行活动,它是操作系统分配资源的基本单...
    Crics阅读 462评论 0 0
  • 多线程技术方案 GCD NSOperation NSThread GCD 同步/异步 和串行/并发 dispatc...
    爱玩游戏的iOS菜鸟阅读 367评论 0 0
  • 计算机多进程发展的背景: 早期计算机工作是单道批处理程序处理,讲写好的作业卡依次放进计算机的批处理程序执行,效率非...
    Gaizka阅读 737评论 0 3
  • 同步串行 先看一个头条的面试真题,下面这段代码有什么问题? 这是一个同步串行的问题,这段代码会造成程序死锁,下面分...
    PetitBread阅读 998评论 0 6
  • 前言: 最近公司项目不怎么忙, 闲暇时间把iOS 在面试中可能会遇到的问题整理了一番, 一部分题目是自己面试遇到...
    Leon_520阅读 6,108评论 2 23