阅读 952

iOS概念攻坚之路(四):多线程

前言

我们现在所使用的操作系统模式是 多任务(Multi-tasking)系统,操作系统接管所有的硬件资源,而且本身运行在一个受硬件保护的级别。所有的应用程序都是以 进程(Progress) 的方式运行在比操作系统权限更低的级别。每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU 由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到 CPU,但是,如果运行时间超过了一定的事件,操作系统会暂停该进程,将 CPU 资源分配给其他等待运行的进程。这种 CPU 的分配方式即所谓的 抢占式(Preemptive),操作系统可以强制剥夺 CPU 资源并且分配给它认为目前最需要的进程,如果操作系统分配给每个进程的时间都很短,即 CPU 在多个进程间快速的切换,从而造成了很多进程在同时运行的假象。目前几乎所有现代的操作系统都是采用这种方式。

计算机发展早期,一个 CPU 只能运行一个程序,当执行 I/O 操作,比如读取磁盘数据时,CPU 就会处于空闲状态,这显然是一种浪费。后来人们迅速发明了 多道程序(Multiprogramming),当某个程序无需使用 CPU 时,监控程序就把另外的正在等待 CPU 资源的程序启动,不过它没有一个优先级的概念,就算某些任务急需 CPU,也很可能需要等待很长的时间。经过改进后,人们又发明了 分时系统(Time-Share System),每个程序运行一段时间后都主动让出 CPU 给其他程序,使得一段时间内每个程序都有机会运行一小段时间。不过这种系统的问题在于,如果一个程序在进行一个很耗时的操作,一直霸占 CPU,那么操作系统也是没有办法的,比如一个程序进入了一个 while(1) 的死循环,那么整个系统都会停止。再进一步的发展,就是我们上面提到的多任务系统了。

如此发展的目的是,尽可能最大限度的利用 CPU,到这里,出现了一个进程的概念,而线程与进程,有着脱不开的关系。

什么是进程

维基百科:

进程(Process),是计算机中 已运行程序 的实体。进程曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的 UNIX,Linux2.4 及更早的版本)中,进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统,Linux 2.6 及更新版本)中,进程本身不是基本运行单位,而是 线程的容器。程序本身只是 指令,数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实现。

用户下达运行程序的命令后,就会产生进程。同一程序可产生多个进程(一对多关系),以允许同时有多位用户运行同一程序,却不会相互冲突。

进程需要一些资源才能完成工作,如 CPU 使用时间、存储器、文件及 I/O 设备,且为依序逐一进行,也就是每个 CPU 核心任何时间内仅能运行一项进程。

百度百科:

进程(Process) 是计算机中的 程序关于某数据集合上的一次运行活动,是系统进行 资源分配 和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

进程是一个具有独立功能的程序关于某数据集合的一次运行活动。它可以申请和拥有系统资源,是一个 动态 的概念,是一个活动的实体。它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示。

进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动进程调用的指令和本地变量。第二,进程是一个「执行中的程序」。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。

所以,进程是程序的一个实体,是执行中的程序,而程序是指令、数据及其组织形式的描述。程序不能单独执行,只有将程序加载到内存中,系统为它分配资源后才能够执行,这种 执行的程序 称之为进程。所以进程是一个动态的概念,与程序的区别在于,程序是指令的集合,是进程运行的静态描述文本,而进程则是程序在系统上执行的动态活动。

可以这么理解,我们写的 APP,是一个程序,我们装到手机上,此时它还不是进程,当我们点击它,系统为它分配资源,运行,此时它可以被称为进程。

另外两者都提到,在现代的面向程序设计的计算机结构中,进程是线程的容器。在如今的操作系统中,线程才是最小的调度单位,而进程,是资源分配的最小单位,一个进程包含一个或多个线程。

来看看进程的内容:

  • 那个程序的可执行机器代码的一个在存储器的映象。
  • 分配到别的存储器(通常是一个虚拟的一个存储器区域)。存储器的内容包括可执行代码、特定于进程的数据(输入、输出)、调用堆栈、堆栈(用于保存运行时运输中途产生的数据)。
  • 分配给该进程的资源和操作系统描述符,诸如文件描述符(UNIX术语)或文件句柄(Windows)、数据源和数据终端。
  • 安全特性,诸如进程拥有者和进程的权限集(可以容许的操作)。
  • 处理器状态(中文),诸如寄存器内容、物理存储器定址等。当进程正在运行时,状态通常存储在寄存器,其他情况在存储器。

什么是线程

内核线程、轻量级进程、用户线程

前面提到,进程是线程的容器,而在现代的多数操作系统中,线程是调度的最小单位。我们来看看线程的定义:

维基百科:

线程(Thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的 实际运作单位。一条线程指的是 进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在 Unix System V 及 SunOS 中也被称为轻量级进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。

这边提到了几个概念:内核线程,轻量级进程,用户线程,我们分别来看看它们的具体概念:

内核线程

内核线程就是内核的分身,一个分身可以处理一件特定事情。这在处理异步事件如异步 IO 时特别有用。内核线程的使用是廉价的,唯一使用的资源就是内核栈和上下文切换时保存寄存器的空间。支持多线程的内核叫做多线程内核(Multi-Threads kernel)。

内核线程只运行在内核态,不受用户态上下文的拖累。

轻量级进程

轻量级进程(LWP)是一种由 内核支持的用户线程。它是基于内核线程的高度抽象,因此只有先支持内核线程,才能有 LWP。每一个进程有一个或多个 LWP,每个 LWP 由一个内核线程支持。这种模型被称为一对一模型。在这种实现的操作系统中,LWP 就是用户线程。

由于每个 LWP 都与一个特定的内核线程相关联,因此每个 LWP 都是一个独立的线程调度单元。即使有一个 LWP 在系统调用中阻塞,也不会影响整个进程的执行。

轻量级进程的局限性:

  • 大多数 LWP 的操作,如建立、析构以及同步,都需要进行系统调用,系统调用的代价相对较高(需要在用户态和内核态中切换)
  • 每个 LWP 都需要有一个内核线程支持,因此 LWP 要消耗内核资源(内核线程的栈空间)。因此一个系统不能支持大量的 LWP。(图片的 P 指进程)

将之称之为轻量级进程的原因可能是:在内核线程的支持下,LWP 是独立的调度单元,就像普通的进程一样。所以 LWP 的最大特点还是每个 LWP 都有一个内核线程支持。

用户线程

LWP 虽然本质上属于用户线程,但 LWP 线程库是建立在内核之上的,LWP 的许多操作都要进行系统调用,因此效率不高。而这里的用户线程指的是 完全建立在用户空间的线程库,用户线程的建立、同步、销毁、调度完全在用户空间完成,不需要内核的帮助,因此这种线程的操作是及其快速且低消耗的。

上图是最初的一个用户线程模型,从中可以看出,进程中包含线程,用户线程在用户空间中实现,内核并没有直接对用户线程进行调度,内核的调度对象和传统进程一样,还是进程本身,内核并不知道用户线程的存在。用户线程之间的调度由在用户控件的线程库实现。

这是多对一模型,其缺点在于一个用户线程如果阻塞在系统调用中,则整个进程都将会阻塞。

加强版的用户线程 —— 用户线程+LWP

这种模型是所谓的多对多模型。用户线程库还是完全建立在用户空间中,因此用户线程的操作还是很廉价,因此可以建立任意多需要的用户线程。操作系统提供了 LWP 作为用户线程和内核线程之间的桥梁。LWP 还是和前面提到的一样,具有内核线程支持,是内核的调度单元,并且用户线程的系统调用要通过 LWP,因此进程中某个用户线程的阻塞不会影响整个进程的执行。用户线程库将建立的用户线程关联到 LWP 上,LWP 与用户线程的数量不一定一致。当内核调度到某个 LWP 上时,此时与该 LWP 关联的用户线程就被执行。

很多文献中认为轻量级进程就是线程,实际上这种说法并不完全正确,只有在用户线程完全由轻量级进程构成时,才可以说轻量级进程就是线程。

更多有关内核线程、轻量级进程、用户线程三种线程的概念,可以看看 这篇文章

在现代操作系统中,不再将进程当做操作的基本单元了,而是把线程当做基本单元,一个进程中可以存在多个线程。一个进程内的所有线程都共享虚拟内存空间。进程这个概念依然以一个或多个线程的 容器 的形式保存下来。进程往往都是多线程的,当一个进程只是单线程时,进程和线程两个属于可以互换使用。

线程的结构、访问权限

一个标准的线程由线程 ID、当前指令指针(PC)、寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号),下面是线程和进程的一个关系结构图:

线程可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址,不过这种情况很少见),在实际运用中,线程也拥有自己的私有存储空间,包括以下方面:

  • 栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是私有的资源)
  • TLS(Thread Local Storage,线程局部存储)。TLS 是某些操作系统为线程单独提供的私有空间,通常只具有很有限的容量
  • 寄存器(包括 PC 寄存器),寄存器是执行流的基本数据,因此为线程私有

线程与进程的数据是否私有如下表:

线程私有 进程之间共享(进程所有)
局部变量 全局变量
函数的参数 堆上的数据
TLS 函数的静态变量
程序代码,任何线程都有权利读取并执行任何代码
打开的文件,A 线程打开的文件可以由 B 线程读写

线程调度与优先级

不管是多处理器还是单处理器,我们看到的线程似乎总是 “并发” 执行的,实际情况是,只有当线程数量小于或等于处理器的数量时(并且操作系统支持多处理器),线程的并发才是真正的并发(也就是并行),不同的线程运行在不同的处理器上,彼此之间互不相干。对于线程数量大于处理器数量的情况,至少有一个处理器会运行多个线程,此时的并发只是一种模拟出来的状态:操作系统会让这些多线程程序轮流执行,每次仅执行以小段时间(通常是几十到几百毫秒),这样每个线程 “看起来” 在同时执行。

这样的一个不断在处理器上切换不同线程的行为称之为 线程调度(Thread Schedule),在线程调度中,线程通常拥有至少三种状态,分别是:

  • 运行(Running):此时线程正在执行
  • 就绪(Ready):此时线程可以立刻执行,但 CPU 已经被占用
  • 等待(Waiting):此时线程正在等待某一事件(通常是 I/O 或同步)发生,无法执行

来看一下线程的生命周期:

处于运行中线程拥有一段可以执行的时间,这段时间称为时间片(Time Slice),当时间片用尽的时候,该线程进入就绪状态。如果在时间片用尽之前线程就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。

线程调度自多任务操作系统问世以来就不断被提出不同的方案和算法。现在主流的调度方式尽管各不相同,但都带有 优先级调度(Priority Schedule)轮转法(Round Robin) 的痕迹。所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法,这决定了线程之间是交错运行的。而优先级调度则决定了线程按照什么顺序轮流执行。在具有优先级调度的系统中,线程都拥有各自的 线程优先级(Thread Priority)。具有高优先级的线程会更早的执行,而低优先级的线程常常要等到系统中已经没有高优先级的可执行的线程存在时才能够执行。

系统会根据不同线程的表现自动调整优先级,以使得调度更有效率。例如通常情况下,频繁的进入等待状态(进入等待状态,会放弃之后仍然课占用的时间份额,也就是我们说的线程休眠,不会占用 CPU 资源)的线程(例如处理 I/O 的线程)比频繁进行大量计算,以至于每次都要把时间片全部用尽的线程要受欢迎的多。一般把频繁等待的线程称之为 IO 密集型线程(IO Bound Thread),而把很少等待的线程称为 CPU 密集型线程(CPU Bound Thread)。IO 密集型线程总是比 CPU 密集型线程容易得到优先级的提升。

在优先级调度下,存在一种 饿死(Starvation) 的现象,一个线程被饿死,是说它的优先级较低,在它执行之前,总是有高优先级的线程要执行,因此这个低优先级线程始终无法执行。当一个 CPU 密集型线程获得较高的优先级时,许多低优先级的线程就很可能饿死。而一个高优先级的 IO 密集型线程由于大部分时间都处于等待状态,因此相对不容易造成其他线程饿死。为了避免饿死现象,调度系统常常会逐步提升那些等待了过长时间的得不到执行的线程的优先级。在这样的手段下,一个线程只要等待足够多的事件,其优先级一定会提高到足够让它执行的程度。

总结一下,在优先级调度的环境下,线程的优先级改变一般有三种方式:

  • 用户指定优先级
  • 根据进入等待状态的频繁程度提升或降低优先级
  • 长时间得不到执行而被提升优先级

线程安全

多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其他的线程改变,因此多线程程序在并发时数据的一致性变得非常重要。

为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们要将各个线程对同一个数据访问 同步(Synchronization)。所谓同步,即指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。

原子操作:单指令的操作,无论如何,单条指令的执行都不会被打断。

同步的最常见方法是使用 锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图 获取(Acquire) 锁,并在访问结束之后 释放(Release) 锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。

二元信号量(Binary Semaphore) 是最简单的锁,它只有两种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。

对于允许多个线程并发访问的资源,多元信号量简称 信号量(Semaphore),它是一个很好的选择。一个初始值为 N 的信号量允许 N 个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:

  1. 将信号量的值减 1
  2. 如果信号量的值小于 0,则进入等待状态,否则继续执行

访问完资源之后,线程释放信号量,进行如下操作:

  1. 将信号量的值加 1
  2. 如果信号量的值小于 1,唤醒一个等待中的线程

互斥量(Mutex) 和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程去释放互斥量是无效的。

临界区(Cirtical Section) 是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅限于本进程,其他的进程无法获取该锁。除此之外。临界区具有和互斥量相同的性质。

读写锁(Read-Write Lock) 致力于一种更加特定的场合的同步。对于一段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步的手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁有两种获取方式,共享的(Shared)独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁被分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应的,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为可以总结为下表:

读写锁状态 以共享方式获取 以独占方式获取
自由 成功 成功
共享 成功 等待
独占 等待 等待

条件变量(Condition Variable) 作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

锁与线程同步,会单独再开一篇文章来说,锁的概念都是一样的,只是实现的手段不一样。

线程和进程的由来

我在看 进程和线程的一个简单解释 这篇文章的时候,看到一个回答,用来说明进程和线程的由来比较合适:

  1. 在单核计算机里,有一个资源是无法被多个应用程序并行使用的:CPU。

没有操作系统的情况下,一个程序一直独占着全部 CPU。

如果要有两个任务来共享一个 CPU,程序员就需要仔细地为程序安排好运行计划 —— 某时刻 CPU 由程序 A 来独享,下一时刻 CPU 由程序 B 来独享。

而这种安排计划后来成为 OS 的核心组件,被单独命名为 Scheduler(调度器)。它关心的只是怎样把单个 CPU 的运行拆分成一段一段的 “运行片”,轮流分给不同的程序去使用,而在宏观上,因为分配切换的速度极快,就制造出多线程并行在一个 CPU 上的假象。

  1. 在单核计算机里,有一个资源可以被多个程序共用,然而会引出麻烦:内存。

在一个只有调度器,没有内存管理组件的操作系统上,程序员需要手工为每个程序安排运行的空间 —— 程序 A 使用武力地址 0x00-0xff,程序 B 使用物理地址 0x100-0x1ff,等等。

然而这样做有个很大的问题:每个程序都要协调商量好怎样使用同一个内存上的不同空间,软件系统和硬件系统千差万别,使这种定制方案没有可行性。

为了解决这个麻烦,计算机引入了「虚拟内存」的概念,从三方面入手来做:

  • 硬件上,CPU 增加了一个专门的模块叫 MMU,负责转换虚拟地址和物理地址
  • 操作系统上,操作系统增加了另一个核心组件:「Memory Management」,即内存管理模块,它管理物理内存、虚拟内存相关的一系列事务。
  • 应用程序上,发明了一个叫做「进程」的模型,每个进程都用 完全一样 的虚拟地址空间,然后经由操作系统和硬件 MMU 协作,映射到不同的物理地址空间上。不同的「进程」都有各自独立的物理内存空间,不用一些特殊手段,是无法访问别的进程的物理内存的。
  1. 现在,不同的应用程序,可以不关心底层的物理内存分配,也不关心 CPU 的协调共享了。然而还有一个问题存在:有一些程序,想要共享 CPU,并且还要共享同样的物理内存,这时候,一个叫「线程」的模型就出现了,它们被包裹在进程里面,在调度器的管理下共享 CPU,拥有同样的虚拟空间地址,同时也共享同一个物理地址空间,然而,它们无法越过包裹自己的进程,去访问另外一个进程的物理地址空间。

为什么要使用多线程

  • 某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行。多线程执行可以有效利用等待的时间,典型的例子就是等待网络响应。
  • 某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线程负责计算。
  • 程序逻辑本身就要求并发操作,例如一个多端下载软件。
  • 多 CPU 或多核计算机,本身就具有同时执行多个线程的能力,因此单线程程序无法全面的发挥计算机的全部计算能力。
  • 相对于多进程引用,多线程在数据共享方面效率要高很多。

总结

其实我们最主要是两个问题:什么是进程和什么是线程。

什么是进程?

进程是计算机中已运行程序的主体,在之前的分时系统中,是系统的基本运作单位。不过在如今的面向线程设计的系统中,进程是线程的容器。它的概念主要有两点:第一,进程是一个实体,每一个进程都有它自己的地址空间。第二,进程是一个执行中的程序,程序是一个没有生命的实体,只有处理器赋予程序生命(如点击运行),它才能成为一个活动的实体,也就是进程。

进程是系统进行资源分配的最小单位。

什么是线程?

线程,有时候被称为轻量级进程,它包含在进程之中,是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的执行流。线程共享进程的所有数据并且拥有自己私有的存储空间。线程又分内核线程和用户线程。

线程是系统进行调度的最小单位。

当我们要运行一个程序,系统为我们分配资源,然后运行,此时称为进程,然而真正运行的不是进程,而是进程内的某个执行流,也就是线程,一个进程至少有一条线程。

另外还有线程相关的一些知识点,比如线程的访问权限、结构、生命周期、安全等。

关于线程和进程的话题经久不息,如果文中有理解错误的地方,欢迎大家指出。

参考文章

iOS 多线程全套

内核线程、轻量级进程、用户线程三种线程概念解惑(线程≠轻量级进程)

进程和线程的一个简单解释

关注下面的标签,发现更多相似文章
评论