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

2,456 阅读26分钟

前言

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

CPU.jpeg

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

其实都是尽可能最大限度的利用 CPU。

目前的多任务系统中,出现了一个进程的概念,而线程与进程,有着脱不开的关系。

进程

进程是计算机中正在执行的一个程序实例。它是操作系统对一个正在运行的应用程序的一种抽象。

每个进程都有它自己的内存空间、数据栈、程序计数器和其他的系统资源。操作系统通过分配一些时间片来轮流调度进程,使它们看起来像是同时运行的。

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

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

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

一个进程通常包含以下内容:

  1. 程序代码

    1. 进程运行的程序代码,可以是机器码、字节码或其他形式的可执行文件
  2. 数据区域

    1. 存储进程运行时需要的数据、变量和常量等信息
  3. 堆区

    1. 动态分配内存的区域,存储进程运行时动态创建的数据结构
  4. 栈区

    1. 存储进程运行时的函数调用和返回等信息,也用于存储局部变量和参数等信息
  5. 寄存器

    1. 进程运行时使用的寄存器,包括程序计数器、堆栈指针,通用寄存器等
  6. 环境变量

    1. 包括系统路径,用户目录,语言设置等信息
  7. 系统资源

    1. 进程使用的系统资源,包括文件句柄、网络连接、进程间通信等
  8. 线程

    1. 进程可能包含一个或多个线程,每个线程负责进程的一部分工作

进程是一个完整的运行环境,包含了程序代码、数据、系统资源和运行时信息等。

进程.png

线程

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

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

线程是进程中的一个独立执行流程,它是 CPU 调度和执行的基本单位。

一个进程可以包含多个线程,每个线程都有自己的程序计数器、栈空间、寄存器等状态。在> 同一个进程中的多个线程可以共享进程的内存空间、数据和其他资源,它们之间的通信和同步也比进程间通信更为高效。

关于线程有三个概念需要提一下,分别是:

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

内核线程

内核线程嘛,就是操作系统内核创建和管理的线程。

内核线程不受用户程序控制。内核线程通常用于执行操作系统内核的任务,如设备驱动程序、系统服务等,这些任务需要在内核空间中执行,无法由用户程序直接访问。

内核线程通常由操作系统内核自行创建和销毁,其创建和销毁的过程不需要用户程序的干预。内核线程也可以进行调度和同步,与用户空间的线程类似,它们可以使用同步机制(如信号量、互斥锁等)来控制对共享资源的访问。

内核线程通常与进程紧密相关,每个进程至少有一个内核线程与之对应,用于执行进程相关的任务,如进程调度、信号处理等。内核线程也可以独立存在,执行一些系统级的任务,如定时器处理、中断处理等。

相比于用户线程,内核线程具有更高的执行优先级和更低的上下文切换开销,因为内核线程的调度和同步都是由操作系统内核负责的,不需要额外的用户空间线程库。但是,由于内核线程的创建和销毁由操作系统内核负责,因此其创建和销毁的开销可能会比用户线程大。

轻量级进程 LWP

轻量级进程(Lightweight Process,LWP)是一种介于进程和线程之间的概念,它是在操作系统内核级别实现的线程模型。与传统的进程模型相比,轻量级进程创建和销毁的开销更小,可以更加高效的实现并发和并行执行。

轻量级进程与内核线程的几点区别:

  1. 实现方式不同

    1. 轻量级进程在进程内部由操作系统内核实现
    2. 内核线程是由操作系统内核直接创建和管理
  2. 调度方式不同

    1. 轻量级进程调度由操作系统内核负责,因此其调度算法与进程调度类似
    2. 内核线程的调度也由操作系统内核负责,但其调度算法通常比进程调度更简单,因为内核线程的调度粒度更细
  3. 资源占用情况不同

    1. 轻量级进程需要占用进程资源,如内存、文件句柄等
    2. 内核线程只需要占用内核资源,如内核栈、CPU 时间片等
    3. 因此,在创建大量并发执行的任务时,轻量级进程的资源消耗可能比内核线程更大
  4. 上下文切换开销不同

    1. 轻量级进程的上下文切换需要保存和恢复更多的状态信息,如进程控制块、虚拟内存等,因此上下文切换开销比内核线程更大
    2. 内核线程的上下文切换只需要保存和恢复内核栈和 CPU 寄存器等少量状态信息,因此上下文切换开销比轻量级进程更小
  5. 功能和用途不同

    1. 轻量级进程主要用于实现多线程编程,可以共享进程资源
    2. 内核线程主要用于实现操作系统内核的功能,如设备驱动程序、中断处理等,无法被用户程序直接访问

用户线程

用户线程指的是 完全建立在用户空间的线程库,用户线程的建立、同步、销毁、调度完全在用户空间完成,不需要内核的帮助,因此这种线程的操作是及其快速且低消耗的。

用户线程通常由线程库实现,线程库提供了一组用户空间的 API,用于创建、启动和管理用户线程。线程库通常包括线程调度器、同步机制和线程池等组件,用于管理用户线程的生命周期和状态。

与内核线程相比,用户线程的创建和销毁开销较小,因为它们不需要操作系统内核的干预,也不需要占用内核资源。用户线程还可以共享进程资源,可以更高效的实现并发编程。

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

用户线程存在一些问题,例如由于用户线程的调度和同步都是由线程库实现的,因此在多核 CPU 上可能无法充分利用 CPU 资源。另外,如果一个用户线程被阻塞或者在执行长时间的计算任务时,可能会导致整个进程被阻塞,影响系统的响应性能。

这种模型是所谓的多对多模型。用户线程库还是完全建立在用户空间中,因此用户线程的操作还是很廉价,因此可以建立任意多需要的用户线程。操作系统提供了 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),当时间片用尽的时候,该线程进入就绪状态。如果在时间片用尽之前线程就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。

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

线程调度自多任务操作系统问世以来就不断被提出不同的方案和算法。现在主流的调度方式尽管各不相同,但都带有 优先级调度轮转法 的痕迹。

  • 轮转法

    • 让各个线程轮流执行一小段时间
    • 这决定了线程之间是交错运行的
  • 优先级调度

    • 决定了线程按照什么顺序轮流执行
    • 高优先级的线程会更早的执行
    • 低优先级的线程通常要等到系统中已经没有高优先级的可执行线程存在时才能够执行

系统会根据不同线程的表现自动调整优先级,以使得调度更有效率。例如通常情况下,频繁的进入等待状态的线程比频繁进行大量计算,以至于每次都要把时间片全部用尽的线程要受欢迎的多。

线程进入等待状态,会放弃之后仍然可占用的时间份额,也就是我们说的线程休眠,不会占用 CPU 的资源。

一般把频繁等待的线程称为 IO 密集型线程,而把很少等待的线程称为 CPU 密集型线程。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) 作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

锁与线程同步在 iOS 中的使用,可以看一下这篇 iOS概念攻坚之路(五):线程同步方案

线程和进程的由来

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

  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 多线程全套

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

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