iOS进阶 -- 多线程原理

前言

在平时的开发过程中,我们经常会用到多线程解决问题。比如,我们在网路请求或者其他耗时操作时,一般都会使用多线程来异步执行。但是多线程到底是什么呢?使用多线程是为了解决什么问题的呢?多线程是真正的并发吗?本篇我们就来探索下这些问题,本篇主要探究多线程的原理,对于具体的使用留待以后探索。

一、线程与进程

  • 进程:进程是只在系统中能够独立运行并作为资源分配的基本单位,它是由一组机器指令、数据和堆栈等组成的,是一个能独立运行的实体。
  • 线程:线程是系统中独立运行和独立调度的基本单位,线程比进程更小,基本不拥有系统资源。

像上面这样的进程和线程的定义,我们已经听过很多个版本了,但是对于我们真正理解线程和进程的帮助却并不大。为什么线程不拥有资源,而进程拥有资源?两者在运行时有很多的相似性,为何在进程之后又要引入线程呢?下面我们就一起来探索下。

1.1 操作系统简介

在探索线程和进程之前,我们先来了解下操作系统。操作系统是一个连接用户与计算机硬件系统的接口,通过操作系统,用户可以更方便、快捷、安全可靠的操纵计算机硬件和运行自己的程序。其作用可以参照下图:

Xnip2021-08-07_23-23-19.png

操作系统的类型有很多,但是一般来说其目标包括 有效性方便性可扩充性开放性。其中最为重要的就是 有效性方便性

  • 有效性包含两个方面
    • 提高系统资源利用率,例如CPU、I/O设备等,使得这些资源保持忙碌的状态,避免空闲闲置。
    • 提高系统的吞吐量,通过合理地组织计算机工作流程,进一步改善资源利用率,缩短程序运行周期。
  • 方便性主要是指使用户可以更加方便的使用计算机系统,例如最开始需要用0和1的机器代码,到使用一些封装好的命令,再到后来的使用图形用户界面来操作,是的计算机变得易学易用。

为了实现这些目标,操作系统经历了人工操作方式 -> 单道批处理系统 -> 多道批处理系统的发展过程。其实还有分时系统实时系统的发展,但是与进程的引入关联不大,因此这里就不介绍了。

  • 人工操作方式:即最开始的卡带、纸片式的计算机,此时需要人工进行装带、卸带的操作,资源的利用率以及程序的执行效率都较为低下。
  • 单道批处理系统:随着计算机技术的发展,为了提高资源利用率,人们发展出了脱机输入/输出方式单道批处理系统即是该方式的应用,一批作业以脱机方式输入到磁带中,并在系统中配备监督程序,由监督程序控制作业的执行。其处理过程如下:

Xnip2021-08-08_09-30-39.png

单道批处理系统可以将一批作业放入磁带中,由监视程序控制将作业依次调入内存,然后由计算机依次执行。当一个作业执行完毕后,再调下一个作业执行,直到所有作业执行完毕。由于自始自终都只保持一道作业,所以这种系统被称为单道批处理系统。举个例子,程序运行情况如下:

Xnip2021-08-08_10-32-12.png

  • 多道批处理系统:单道批处理系统解决人工装卸带的问题,但是一次只能执行一道作业,如果该作业运行时需要I/O操作,那么CPU就空闲了,同样的CPU操作时,I/O也是空闲的,这就造成了资源的空闲,而且由于I/O设备的低速性,CPU的利用率显著降低,后续的作业就会存在无谓的等待。于是就产生了多道处理批处理系统,来提高资源的利用率。

下面以 程序A、程序B、程序C、程序D 的运行情况展示下这一过程:

Xnip2021-08-08_10-22-31.png

从图中可以看出,在程序A进行 I/O请求 时,程序C就可以开始执行了,并且在CPU的利用上也有很大的提高,两个或者多个程序可以同一时间段内利用CPU进行并发执行。

以上简单介绍了操作系统的发展历程,其实无论是多道批处理系统,还是实时、分时系统,都具有几个特征,并发、共享、虚拟和异步,其中并发是最为重要的特征,其他三个特征均以并发为前提。下面先简单介绍下这几个特征:

  • 并发性是指两个或多个事件在同一时间段内发生,需要区分的另一个概念是 并行,并行是指两个或多个事件在同一时刻发生。在多道批处理系统中,并发指在一段时间内宏观上有多个程序在同时运行。
  • 共享性是指系统中的资源可供内存中多个并发执行的进程(线程)共同使用。相应的这种资源被称为共享资源,共享的方式又分为互斥共享方式同时访问方式
  • 虚拟性,是指通过某种技术把一个物理实体变为若干个逻辑上的对应物。前者是真实存在的,后者是虚的,让用户感觉存在而已,例如虚拟内存。
  • 异步性是因为并发执行的过程中,各个进程并不是一气呵成的执行完毕,而且有的进程侧重于CPU计算,有的侧重于I/O,很可能出现先进入内存的作业后执行完成,后进入的内存先执行完毕,这就是操作系统的异步性。

1.2 进程与线程的由来

通过多道批处理系统的引入,可以发现确实提高了CPU等资源的利用率,但是如果分开看每一个程序的执行过程,程序的执行依然是顺序执行,即CPU执行时,I/O不能执行,而在I/O请求时,CPU也处于空闲。如果程序都能并发执行,则可以大大提高资源的利用率。

为了解决这样的问题就引入了进程。在引入进程后,将CPU计算程序I/O程序各自建立一个进程,这两个进程可以并发的执行。因而可以将 CPU 和 I/O设备同时开动起来,实现并行工作。除此之外,还可以将各个程序分别建立进程,建立进程后,各个程序也可以实现并发执行,也可以提高资源的利用率与系统吞吐量。

总结下来,引入进程的作用有两点:

  • 1、可以为不同的程序单独建立进程,以实现程序的并发执行
  • 2、对于同一个程序,可以分别建立CPU计算和I/O程序的进程,以达到CPU计算和I/O的并发,减少CPU的空闲。

进程与程序的区别:

  • 结构特征:程序是指一组计算机能够识别的指令,不能独立运行,程序想要独立运行需要为之配置进程控制块PCB(Progress Control Block);而进程是包含程序段、程序相关数据段以及进程控制块(PCB)的一个能够独立运行的实体

  • 动态性:程序是一组有序指令的集合,存放于某种介质上,例如磁盘,本身不具有动态性;进程实质上是进程实体的一次执行过程。

  • 并发性:多个进程实体可以同存于内存中,在同一段时间内可以同时运行。而程序不能独立运行,更谈不上并发运行

  • 独立性:进程实体是一个能独立运行、独立分配资源和独立接受调度的基本单位;而程序未建立PCB不能独立运行。

  • 异步性:进程按各自独立的、不可预知的速度运行推进;程序不能独立运行。

由上所述,我们可以说进程是程序的一次执行,是一个程序及其数据在处理机上执行时所发生的活动。在这一活动过程中,进程会被分配所必须的资源,并会接受系统的调度。由此我们也就可以理解本篇开始时的说法 -- 进程是系统进行资源分配和调度的基本单位

引入进程的目的是为了使多个程序可以并发执行,提高资源利用率和系统吞吐量,并且进程拥有独立的资源。由此也引发一个问题,系统在操作进程时,会引起较大的时空开销,比如:

  • 创建进程要为其分配必需的内存等资源以及建立相应的PCB
  • 撤销进程时需要先将其资源回收并撤销PCB
  • 在切换进程时则需要保存当前进程的CPU环境,并设置新进程的CPU环境

为了减少这种时空消耗,人们想到了是否可以将进程的这两种属性分隔开处理,即作为资源分配的基本单位,但不会频繁地调度和切换,而作为调度和切换的基本单位,又不拥有资源。因此,人们引入了线程的概念。

线程作为程序运行和系统调度的基本单位,在创建、撤销以及切换时的系统开销远小于进程,通常一个进程可以包含多个线程,线程与进程的区别有以下几点:

  • 系统调度:进程的调度会引起进程的切换,开销较大;同一进程的线程在切换时,不会引起进程切换开销较小,但是由一个进程的线程切换到另一个进程的线程时,同样会引起进程的切换
  • 并发性:进程和线程都具有并发性。但是引入线程可以更大的提升并发性。例如一个系统中只有一个文件处理进程,如果没有引入线程,当该进程在被阻塞时,其它需要使用文件处理的进程就需要等待,而引入线程后,可以在文件进程中存在多个线程,即便正在运行的线程阻塞了,也可以有其它线程继续提供服务,从而提高了文件服务的质量和系统吞吐量。
  • 拥有资源:进程拥有自己的资源;线程一般不拥有资源,但是可以访问所属进程的资源,属于同一进程的线程共享该进程的资源。
  • 系统开销:进程的创建、撤销及切换都需要进行资源的分配或回收以及PCB的创建或回收。而线程则不需要。所以就系统开销来看,进程远高于线程。

1.3 进程与线程的状态

进程和线程都有三种基本状态,即就绪、执行和阻塞。这三种状态的解释分别如下:

  • 就绪:当进程已经分配到除CPU以外的资源,只要再得到CPU,便可立即执行,这一状态被称为就绪状态。在这一状态的进程形成一个队列,被称为就绪队列。
  • 运行:进程已经获取到CPU,程序正在执行的状态被称为运行状态。
  • 阻塞:正在执行的进程由于某些原因暂时无法继续执行,便放弃CPU,处于暂停状态,即进程受到阻塞了,这一状态被称为阻塞状态。

三种状态的转换图如下:

Xnip2021-10-01_21-51-02.png

为了管理需要,还存在两种常见的状态,即创建和终止

  • 创建状态:创建状态是指进程创建成功(PCB创建成功),但是还未进入内存,不能被调度,此时为创建状态,一旦进入内存,即为就绪状态
  • 终止状态:当一个进程自然结束,或者出现了无法克服的错误,或者被系统及其它有终止权力的进程所终止时,进程进入终止状态,此时进程无法再执行,但操作系统中依然保留一个记录,直到其它进程完成对终止状态进程的信息提取后,操作系统将删除该进程。

五状态的转换图如下:

Xnip2021-10-01_22-12-22.png

二、资源抢夺

在上一小节中,探索了进程和线程的由来及状态,我们可以发现,除了不拥有资源外,线程在很多时候与传统的进程有很大的相似性。在后续的探索中,我们主要以线程为主。

线程的异步性使得资源利用率和系统吞吐量都有很大程度的提高,但是在异步执行过程中,不同线程的执行速度是不一样的,所以就带了对于临界资源的资源抢夺问题,比如我们经常会看到的买票抢票的问题,就是一个经典的资源抢夺问题。下面通过伪代码展示一下:

db.ticketCount = 5; // 余票剩余5张
ticket.count = db.ticket; // 看到的当前票量

乘客A的购票步骤,在线程 A
A.buyTicket(1);
ticket.count = ticket.count - 1;
db.ticketCount = ticket.count; // 写入数据库中的剩余票量

乘客B的购票步骤,在线程 B
B.buyTicket(1);
ticket.count = ticket.count - 1;
db.ticketCount = ticket.count; // 写入数据库中的剩余票量
复制代码

上述的步骤如果分别单独执行,结果均为4,是没有问题的;即使是一起执行,如果是顺序的,例如先A后B,或者先B后A,结果为3,也和我们期待的一致。但是如果是下面这种顺序执行,结果就有了问题:

A.buyTicket(1); // 此时A获取到的count为5
B.buyTicket(1); // 此时B获取到的count也为5
ticket.count = ticket.count - 1; // A中ticket.count-1,结果为4
ticket.count = ticket.count - 1; // B中ticket.count-1,结果为4
db.ticketCount = ticket.count; // A写入数据库count为4
db.ticketCount = ticket.count; // B写入数据库count为4
复制代码

由以上伪代码可以看出,最终写入数据库的count为4,但是实际上经过两次购票,结果应该为3,很显然不是我们期待的结果,这便是多线程的资源抢夺问题。

在上述例子中,两个线程A和B共同修改票的余量count,这便构成了 ABcount 的抢夺,count便为共享资源。为了防止这种资源抢夺导致的数据错乱,我们需要保证 A 和 B 对 count 的访问是互斥的,即 A 在访问 count 时,B不得访问,同理 B 访问 count 时, A也不得访问。此时 count 便成为了临界资源,即诸进程采用互斥访问的资源

三、线程安全

上一小节所描述的资源抢夺,极大的可能会引起一个严重的问题,即线程安全。就如上述例子中所说的一样,多线程环境下,由于并发性的存在,极有可能造成数据的不一致性,而这在实际的开发过程中会引发很严重的后果,例如显示的车票还有余额,但实际上车票已经卖完了,这也就凸显了线程安全的重要性。

为了保证线程安全,就需要保证各个线程对于临界资源的访问时互斥的。如今有两种方式来保证线程的互斥访问,原子指令和线程同步。

  • 原子指令:大多数的系统都会提供一些单指令的原子指令,单条指令的执行不会被打断,所以原子指令的使用非常方便,但是仅适用于比较简单特定的场合,对于复杂的场景原子指令就力不从心了。
  • 线程同步:所谓同步,即一个线程访问资源时,其它线程不能访问该资源,如此将对资源的访问原子化了。

实际上线程同步,是对多个相关的线程在执行次序上进行协调,以使得并发执行时可以有效地共享资源和相互合作。目前实现线程同步的方式主要就是锁。每个线程在访问临界资源前,先尝试获取锁,获取成功就可以访问临界资源;当访问资源结束后,则需要释放锁。在锁被占用时,线程会等待,直到锁重新可用。

总结

本篇主要介绍了多线程的一些基本概念,例如进程与线程、资源抢夺和线程安全等,还有很多概念没有在本篇博客中描述,例如锁的分类、以及死锁的原因等等,这些将在后续篇章中继续探索。对于本篇中的概念,描述不正确的地方,欢迎大家指正。

猜你喜欢

转载自juejin.im/post/7014374502360416269