单独列一篇整理进程、线程、多线程以及iOS开发中的多线程解决方案
进程与多线程
进程
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,每个进程之间是独立的,每个进程均运行在其专用的且受保护的内存,是操作系统结构的基础。
进程相对来说是更动态化一些的概念,进程是操作系统分配资源的最小单位,程序是一段写好的静态二进制代码。一个程序在操作系统中运行时,可能会拥有一个或多个进程,当一个程序只有一个进程时,这个进程就是程序本身。
进程是程序运行时的一个状态。在系统允许的情况下(非技术原因),程序可以在计算机系统中单独运行多次,分别是多个不同的进程,他们之间的状态各不相同。
进程拥有自己的地址空间,并且不同于线程,进程可以向系统申请资源。在系统中,存在PCB(进程控制块)来管理进程,在PCB中记录了进程的相关信息以及运行数据,如程序计数器、进程状态、CPU暂存器、IO状态等。
- 程序计数器:接着要执行的指令地址
- 进程状态:可以是new、ready、running、waiting或blocked等
- CPU寄存器:如累加器、变址寄存器、堆栈指针以及一般用途寄存器、状况代码等,主要用途在于中断时暂时存储资料,以便稍后继续利用;其数量及类别因计算机体系结构有所差异。进程调度中CPU切换进程时,就要将进程的状态例如在寄存器中的值存储起来。在进程未占用处理器时,进程的上下文是存储在进程的私有堆栈中的。
- CPU排班法:标示进程优先级
- 存储器管理:如标签页表等
- 输入输出状态:配置进程使用I/O设备,如磁带机
进程的PCB在系统中可以有几种组织方式(存放方式)
- 线性表方式:不论进程的状态如何,将所有的 PCB 连续地存放在内存的系统区。这种方式适用于系统中进程数目不多的情况。
- 索引表方式:该方式是线性表方式的改进,系统按照进程的状态分别建立就绪索引表、阻塞索引表等。
- 链接表方式:系统按照进程的状态将进程的 PCB 组成队列,从而形成就绪队列、阻塞队列、运行队列等。
进程之间可以进行通信,通常一个程序根据规模的大小可以拥有1个或多个进程,进程间的通信就显得尤为重要(IPC Inter-Process Communication)。IPC为资源互相隔离的进程提供了通信手段,常见的IPC实现方式有以下几种
- 无名管道:只允许父子进程通信,缓存区开辟在内核区上,自带互斥机制,同一时间只能单向传输
- 有名管道:可以允许没有亲缘关系的进程通信,缓存区在文件系统上,在创建时有一个路径与它绑定,只要能访问这个的进程就能进行IPC通信。即,需要交换的信息被写在一个文件系统中的特定位置,需要交换则向该块内存读出或写入
- 信号:信号是IPC通信中一种有限制的方式,它是一种异步的通知机制,相当于一个中断,由内核调解并分发给对应进程。此时如果信号被发送给了一个进程,操作系统将会中断进程中的所有非原子操作,如果进程有定义该信号处理的函数则执行对应函数,没有则执行默认操作。常见的有终端中的
control + c
操作会发送一个终止信号,kill()
信号也会发送一个终止信号给进程。 - 消息队列:信息列表是由信息组成的链表,存储在内核中,由信息标示符标示。它也是一种异步的通信方式,进程可以将需要交流的信息写入到消息队列中而不用一直等待别的进程将消息取出。和信号相比,消息队列可以传达更多的信息。和管道相比,消息队列可以提供有格式的信息。由于其异步的特点,接收者必须轮询消息队列才能获得最新的信息,这也是其中的一个缺点
- 共享内存:映射一段能被其他进程访问的内存,实现共享该块内存的目的。具体过程是首先在物理内存中申请分配一块共享内存 ,分配成功后需要共享内存的进程将该块内存的物理地址映射到自己的地址空间中 ,即将共享内存的物理地址与地址空间的虚拟地址进行绑定(页表,MMU),在这之后进程就能访问到共享内存。在访问结束后,进程需要与该块共享内存解绑 。另外,使用共享内存时需要注意的是内存的写入需要自己实现同步机制 ,一般在使用共享内存通信时会与信号量配合使用,实现同步的访问。
- 信号量
- 套接字
线程
线程可以说是一种轻量级的进程,但他不像进程一样拥有资源,线程只拥有所属进程的一点运行所必要的资源,它可以与所属进程的其他线程共享进程所持有的资源。在一个进程中可以同时拥有多个线程,但一个线程只对应一个进程。
通常在一个进程中可以包含多个线程,它们可以利用进程所拥有的资源,在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位,由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小的多,能更高效的提高系统内多个程序间并发执行的程度。也就是说线程间的切换开销比进程间的切换开销要小得多
操作系统不会向线程分配资源,线程也不可以申请资源。同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。这是线程与进程的一大差别,核心区别是线程与进程的控制和隔离粒度不同。我们把多个进程考虑成一个个的沙盒,而线程就是沙盒中的一个个处理事务的工具,显然线程的控制粒度更细一些。
在某些情况下,线程也需要线程间的通信,因为线程自身的调用栈和寄存器环境也是私有的,其他线程无法获取到。多个线程只是共享着进程的资源和地址空间。
相对于进程的PCB,线程的状态由线程控制块Thread Control Block TCB描述。TCB包括以下信息:
- 线程状态。
- 当线程不运行时,被保存的现场资源。
- 一组执行堆栈。
- 存放每个线程的局部变量主存区。
- 访问同一个进程中的主存和其它资源。
进程和线程的关系
- 进程是操作系统分配和管理资源的最小单位,线程是进程中执行运算的最小单位,也是CPU进行计算的最小单位。线程可以说是一种轻量级的进程,但他不像进程一样拥有资源,线程只拥有所属进程的一点运行所必要的资源,它可以与所属进程的其他线程共享进程所持有的资源。在一个进程中可以同时拥有多个线程,但一个线程只对应一个进程。
- 区别:1.进程拥有资源,线程只访问资源 2.线程的销毁切换代价低,进程的销毁切换代价高 3.线程作为调度和分配的基本单位,进程作为资源分配的基本单位
多线程
多线程(multithreading),是指从软件或者硬件上实现多个线程并发执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个线程,进而提升整体处理性能。具有这种能力的系统包括对称多处理机、多核心处理器以及芯片级多处理或同时多线程处理器。在一个程序中,这些独立运行的程序片段叫作 “线程”(Thread),利用它编程的概念就叫作“多线程处理”。
实现多线程是采用一种并发执行机制。简单地说就是把一个处理器划分为若干个短的时间片,每个时间片依次轮流地执行处理各个应用程序,由于一个时间片很短,相对于一个应用程序来说,就好像是处理器在为自己单独服务一样,从而达到多个应用程序在同时进行的效果。多线程就是把操作系统中的这种并发执行机制原理运用在一个程序中,把一个程序划分为若干个子任务,多个子任务并发执行,每一个任务就是一个线程。这就是多线程程序。
然而在多线程开发中,也存在着一些缺点。
- 过多的线程:如果程序中存在过多的线程,那么线程之间切换的开销就变得可观了起来,CPU为了在线程间进行切换可能会消耗非常多的时间和资源
- 数据竞赛:在多线程开发中最常见的问题就是数据竞赛。当多个线程同时访问或者修改同一个资源或变量,显然会产生某个进程读取到的资源出错的情况,在执行过程中产生了脏数据导致了错误。多个线程的操作中通常需要一些同步机制来保证程序的正确执行。
多线程解决方案
上文中提到了多线程的数据竞赛问题,在实际的开发中已经有了许多的解决方案,最常见的就是各种锁。
同时,为了进行多线程编程,我们也需要一些开辟后台线程的能力。在iOS开发中,我们常见的唱作线程的工具一般有pthread、NSThread、GCD几种。
pthread是一个跨平台、基于C语言的多线程开发库;NSThread是基于Objective-C语言的对pthread的上层封装,需要开发者自己管理线程的生命周期,在日常开发中最常用到的可能是[NSThread currentThread];
😂
GCD是Apple开发的基于C语言的多线程开发库,他提出了较新的队列和派发概念,不需要由开发者管理线程的生命周期。
GCD(Grand Central Dispatch)
Dispatch Objects
Dispatch Objects(调度对象)有多种类型,包括dispatch_queue_t
、dispatch_group_t
和 dispatch_source_t
这些通过查阅源码可以看到其实是GCD使用宏创建出来的别名,实际上Dispatch Objects在Objective-C(ARC)环境下都是遵循了对应协议的NSObject
对象。
在Objective-C(ARC)中,不需要我们手动管理Dispatch Objects的内存,在调度完毕后他们会被ARC自动释放。
dispatch_queue_t
在GCD中,采用了一种比较简单直观的并发模型,其采用队列和派发的概念来进行并发执行任务的抽象。其中队列的结构就是dispatch_queue_t
.
GCD的队列中主要分为两种,分别是串行队列和并发队列。
- 串行队列:被派发到串行队列中的任务会以FIFO的原则被同步调用。如果是同步sync派发,则会在主线程上同步执行任务;如果是异步派发,则有可能(不一定会,由线程池调控)会开辟一条新的线程同步执行队列中的任务,这里不同于并发队列,在串行队列的异步派发情况下,虽然任务会在子线程被执行,但任务都是在同一条子线程执行的,而并发队列的异步派发下任务可能在不同的子线程执行。。
- 并发队列:被派发到并发队列中的可以被并发调用,在将任务异步派发到并发队列上执行的时候,任务的执行顺序并没有保证,并且任务可能会在各自的线程中被执行。然而,如果将任务同步派发到并发队列上,无论是手动创建的并发队列亦或是全局并发队列,任务都会在主队列上同步执行。
- 主队列:主队列是一个特殊的串行队列。
- 全局队列:全局队列是一个特殊的并发队列,栅栏函数在全局队列上并不起作用。
派发机制 - dispatch_sync
dispatch_sync
函数是GCD中的同步派发函数,与dispatch_async
一起构成GCD的任务派发机制。dispatch_sync
在参数中包含了派发的队列以及提交的任务block。在GCD中,任务以block的形式定义,在底层任务都是一个个的dispatch_block_t
。
顾名思义,dispatch_sync
提供的是同步派发机制,在派发时会阻塞当前线程,并等待block中的任务执行完毕后再进行返回,所以容易产生死锁。
然而,这里的同步指的是派发操作的同步,任务的执行机制主要取决于派发的队列。
- 在使用同步派发将任务派发至串行队列时,任务会按照FIFO的原则同步执行,并不开辟子线程,任务在主线程上同步执行。
- 在使用同步派发将任务派发至并发队列时,任务会按照FIFO的原则同步执行,也不开辟子线程,任务在主线程上同步执行。
也就是说,dispatch_sync不具有开辟新线程的能力。
派发机制 - dispatch_async
dispatch_async
函数是GCD中的异步派发函数。dispatch_async
的参数与dispatch_sync
函数一样,也是包含了派发的队列以及提交的任务block。
与dispatch_sync
最大的不同是,dispatch_async
具有开辟子线程的能力,并且由于它是异步派发,所以并不会像同步派发那样阻塞线程,在将任务派发至任务队列后就可以返回,而不必等待任务执行完成。
同样的,异步派发中任务的执行机制也与派发的队列有着较大关系。
- 在使用异步派发将任务派发至串行队列时,任务会按照FIFO的原则同步执行,但会开辟子线程,并且队列中的任务都在该子线程中按照FIFO同步执行。
- 在使用异步派发将任务派发至并发队列时,任务会被并发执行,并开辟不同的子线程,任务与任务之间的执行顺序不被保证且无法复现,任务可能各自在不同的子线程上执行。但出于代价的考虑,当任务较多时,线程池并不会总是给该队列分配与任务数相等的子线程。
- 在使用异步派发将任务派发至主队列时,由于主队列是一个串行队列,并且是一个特殊的串行队列,所以此时并不会开辟新的线程,任务在主线程主队列上同步按序执行。
- 在使用异步派发将任务派发至全局队列时,情况同
异步派发+
派发导致的死锁
由于派发机制的能力,在使用他们时如果不慎很容易产生死锁。
最常见的一种死锁就是在主线程中同步派发任务。
可以看到,程序在这里崩溃了,原因就是在这里产生了死锁。
由于我们调用dispatch_sync
是在主线程中,所以我们这里就相当于向主队列加入了一个任务,该任务是执行dispatch_sync
函数,这里我们称为任务1. 同时,sync派发向主队列添加了一个任务,任务是执行NSLog
语句,这里我们称为任务2.
前面我们提到,sync派发会阻塞线程,需要等待任务执行完毕后才可以返回。在这里,我们的主线程正在执行任务1,任务1的内容是sync派发,所以dispatch_sync
函数此时阻塞了主线程,等待dispatch_sync
的任务2执行完成。
而dispatch_sync
函数此时将任务2派发到了主队列,此时主队列正在执行任务1,由于串行队列的FIFO原则,任务2需要等待任务1执行完毕才能开始执行,然而任务1此时却在等待任务2执行完毕才宣布自己执行完毕,因为这里是一个sync派发。所以任务1和任务2互相等待,造成了死锁。解决方案也很简单,将同步派发换成异步派发就可以。
另一种常见的死锁情况就是派发与派发之间的嵌套。
向一个串行队列中异步派发一个任务,在任务中再向该队列同步派发一个任务,让我们看看会发生什么。
// log 任务1
可以看到,同样也发生了死锁。原因就是我们向串行队列中异步派发了一个任务1,此时由于async派发不会阻塞线程,所以async函数输出任务1后便返回了。
然而,到任务2这里,由于async函数向队列中派发了任务2,任务2是一个sync派发,sync派发会阻塞线程。sync函数将任务3派发至队列中,但由于sync函数要等待任务执行完毕后才能返回,所以sync函数要求执行任务3,但是队列目前正在执行任务2,按照FIFO原则,需要任务2执行完毕后才能执行任务3,任务2正在准备执行任务3,而任务3却在等待任务2执行完毕来开始自己的执行,所以任务2和任务3发生了互相等待,造成线程死锁。
栅栏函数
- dispatch_barrier_sync
- dispatch_barrier_async
GCD中的栅栏函数可以将队列中的任务分为三组,形象地发挥了栅栏的作用。
在队列中,栅栏函数派发的任务会等待比自己早加入到队列中的任务执行完毕后再开始执行,并保证在自己之后加入队列的任务在自己执行完毕后才能开始执行。
也就是说,栅栏函数将队列中的任务分为了比自己早加入 | 自己 | 比自己晚加入三组任务,并且任务在宏观上严格按照该顺序执行,即早加入->自己->晚加入
。
而在早加入和晚加入两组中,他们内部任务的执行顺序并不被栅栏函数保证。栅栏函数只保证自己在所有比自己早加入的任务执行完后开始执行,不保证他们的执行顺序。比自己晚加入的任务同理。
而dispatch_barrier_sync
和dispatch_barrier_async
两个函数的唯一差别只是sync会阻塞线程。
需要注意的是,在全局队列上,栅栏函数并不生效。
可以看到,任务1理应在task2前执行的,但此时却是task2先执行。
而在手动创建的并发队列上,栅栏函数正常工作。所以全局队列虽然是一个并发队列,但它有着一些特殊性,栅栏函数在全局队列上并不生效。
dispatch_once
dispatch_once
函数可以保证block内的代码只被执行一次,在使用时先创建一个dispatch_once_t
类型的变量。
该函数常用来实现单例。
dispatch_group_t 组函数
在实际开发中,我们有时会需要进行异步操作的同步执行,也就是异步转同步。这一步可以用锁、栅栏函数、信号量实现,也可以用组函数实现。
组函数在使用时先创建一个group,之后可以使用dispatch_group_sync
和dispatch_group_async
向组函数派发任务
同时,也可以使用dispatch_group_enter
和dispatch_group_leave
实现细粒度控制。
最后使用dispatch_group_wait
阻塞线程,等待任务执行完毕。
dispatch_semaphore 信号量
dispatch_semaphore
是在GCD中对于信号量的实现。
信号量机制在操作系统的学习中已经接触过了,对于信号量,只有两个关键的原语操作,分别是signal
与wait
,对应GCD中的dispatch_semaphore_signal()
和dispatch_semaphore_wait()
.
前者会令信号量+1,后者会令信号量-1. 在使用信号量同步时,我们在执行任务前需要先获取一个信号量,若获取不到则会使线程阻塞,也就是说dispatch_semaphore_wait()
在获取不到信号量时是会使线程堵塞的。
初始化信号量时,我们使用dispatch_semaphore_create()
来创建一个信号量,参数传一个整数。