多进程、多线程与多处理器计算平台的性能问题

目录

前言

现代服务器系统,大多采用多线程、多进程与多处理器计算平台的组合,本篇主要研究三者间的关系、存在的性能问题及解决方案。

进程与线程

关于进程与线程的描述最经典莫过于「进程是资源分配的基本单位,线程是 CPU 调度的基本单位」

在早期面向进程设计的计算机系统中,进程不仅拥有并管理着诸如 CPU、RAM、文件描述符与信号处理之类的计算机软硬件平台资源,同时也作为程序的基本执行实体。而在现代面向线程设计的计算机系统中,线程取代了进程作为程序的基本执行实体,进程的定位则更趋于逻辑层面,作为资源和线程的容器,一个进程可以拥有多个且至少拥有一个线程作为它的指令执行体。

基本执行单元从进程到线程的演化,是为了解决创建进程(分配资源)、销毁进程(回收资源)、进程间通信(使用外部共享资源)以及进程间切换(上下文数据内存地址空间的转移)等操作的高耗低效问题。简单来说就是进程太 “重(拥有太多资源)” 了,导致了程序的并发性能差。

线程被包含在进程内部,相对于进程的 “重”,线程则追求极致的轻量,除了一些在处理器上执行所必需的资源(e.g. 程序计数器、寄存器和栈)外,不独占任何额外资源,而是线程间共享同一进程的所有资源。所以,无论是创建线程(基本没有资源创建)、销毁线程(基本没有资源回收)、线程间的通信(使用进程内部共享内存地址空间,一个线程生成的数据可以立即用于其他所有线程,线程间的交互可以在不涉及操作系统的情况下完成)或切换(同样得益于共享内存资源)的消耗成本都得到了优化。

需要注意的是,这里并非表示线程一定优于进程,上文只是分别描述了两者的特点而已。线程或进程的最佳应用实践需要建立在应用场景与二者特性是否适配的基础之上,后文中我们会尝试讨论这个问题。

进程与线程的比较

项目 进程 线程
定义 系统资源分配与调度的基本单位 处理器调度的基本单位
优点 独占操作系统与计算机资源,尤其是独占内存地址空间,所以多进程场景中的单个进程异常不会让整个应用程序崩溃 轻量基本不独占资源,所以线程的创建、销毁、通信及切换的成本都更低,更有利于在多线程场景中提高程序的并发性能
缺点 独占资源多,所以进程的创建、销毁、通信及切换的成本都比较高 没有隔离出私有内存,所以单个线程的崩溃可能会导致整个应用程序退出

Linux 的内核态与用户态

我们常说的 Linux 严格来说指代的是 Linux Kernel,泛指使用或裁剪标准 Linux Kernel 并在此基础之上实现各种应用程序解决方案的操作系统发行版本(e.g. RHEL、SUSE 和 Ubuntu)。一个完整的 Linux 操作系统体系架构通常由下列几个核心层级组成:

  • Applications:在操作系统上安装并运行的用户态应用程序
  • Shell:支持编程的命令行解析器
  • Libs:操作系统标准库函数
  • System Calls:暴露给用户态的内核态系统调用接口
  • Kernel:操作系统的核心,真正对接硬件平台的软件程序

这里写图片描述

Linux Kernel 实现了进程管理器、内存管理器、文件系统、设备驱动以及网络管理组件来负责对接、管理计算机硬件平台并通过系统调用(System Calls)为上层应用程序暴露硬件资源以提供程序运行环境。以系统调用为边界将 Linux 操作系统的体系架构分为用户态和内核态(包括系统调用)。

这里写图片描述

系统调用是操作系统的最小功能单元,具有原子性,并且可以根据不同的应用场景进行扩展和裁剪,Linux 操作系统提供了大约 240-260 个不同的系统调用功能单元。

什么是用户态和内核态?

操作系统的用户态和内核态实际上对应了 CPU 指令集中的非特权指令和特权指令的执行状态,CPU 划分了不同的执行级别来执行具有相应特权的指令,例如:Intel x86 CPU 具有四种不同的执行级别 [RING0, RING1, RING2, RING3],Linux 操作系统只使用了其中的 RING0 和 RING3 分别表示内核态与用户态。处于 RING3 状态的用户态代码不能直接访问处于 RING0 的内核态代码的地址空间(包括代码和数据)。

这里写图片描述

我们知道有些 CPU 特权指令的操作实际是比较危险的,比如:写入系统配置文件、杀掉其他用户的进程或重启系统。所以在操作系统的设计中,为了保障操作系统的稳定性,尤其是在多用户环境中的可靠性,操作系统根据 CPU 的指令类型来抽象并实现了用户态和内核态两种代码运行模式,两种运行模式之间的切换也成为模式切换。用户态的代码被限制了可以执行的操作以及可以访问的资源范围,而内核态的代码则可以执行任何操作并且没有资源使用上的限制。

所以,为什么要划分核心态和用户态?简单来说:

  • 禁止用户程序和底层硬件平台直接交互
  • 禁止用户程序直接访问任意内存地址空间

Linux 进程拥有 4GB 内存地址空间,其中 3-4G 部分是内核态的地址空间,存放了整个内核的代码,所有内核模块以及内核所维护的数据。运行在 RING3 的用户程序代码可以通过系统调用主动访问 RING0 的内核代码来实现从用户态带内核态的切换。当进程陷入内核态时,被执行的内核代码会直接使用进程的内核栈资源。

例如:用户运行一个程序,该程序创建的进程开始运行在用户态,如果程序要执行诸如文件操作,网络数据发送操作等内核态操作的话,就必须通过系统调用中的 Write,Send 等功能单元完成,根本是通过调用内核代码完成的。此时,运行该进程的处理器会从 RING3 切换到 RING0 级别,然后进入 3-4GB 内核地址空间中完成内核代码的执行。执行完成后,处理器再从 RING0 切换回 RING3,进程也回到用户态。

这里写图片描述

用户程序除了通过系统调用主动触发模式切换之外,还可能会被动的进行。总的来说模式切换有两种触发手段:

  • (软中断)系统调用:这时用户态进程要传递很多变量或参数值给内核,内核态运行时也要保存用户进程的一些寄存器值和变量等等。所谓的「进程上下文」,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值以及运行环境等。

  • (硬中断)外围设备中断:硬件可以通过触发中断信号令内核调用中断处理程序从而进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的「中断上下文」,就是硬件传递过来的这些参数和内核需要保存的当前被中断执行的进程环境。

由此可见,处理器总处于以下状态中的一种:

  1. 运行进程上下文的内核态,内核代表进程运行在内核地址空间
  2. 运行中断上下文的内核态,内核代表硬件运行在内核地址空间
  3. 用户态,运行在用户地址空间

内核线程,用户线程与轻量级进程

Linux 操作系统针对内核态与用户态分别实现了内核线程和用户线程两种线程模型,区分的标准为线程的调度者是在内核态还是在用户态。实际上内核调度的对象是内核线程,用户线程不由内核直接调度,但用户线程最终会映射到内核线程上。多线程通过将内核级资源和用户级资源分离来提供灵活性。

内核线程

内核线程(KLT,Kernel Level Thread)又称守护进程,由内核负责调度管理。内核线程占用的资源很少,只有内核栈和线程切换时用于保存寄存器数据的的空间。从调度层面来看,内核线程与进程的调度算法比较相似,调度开销也相差不大。但内核线程最大的好处可以其可以通过系统调度器将多个内核线程隐射到不同的处理器核心上,能够更加充分的享受多处理器平台的好处。除此之外,内核线程的创建和销毁开销也是要比进程更少的。举例一些内核线程的特点:

  • 内核线程是调度到处理器执行的基本单位,一个内核线程的阻塞不会影响其他内核线程
  • 内核会在内核地址空间为每个内核线程都创建线程控制块(TCB),内核根据 TCB 来感知内核线程
  • 内核线程可以在全系统内进行资源竞争
  • 内核线程间的数据同步效率要比同一进程中线程数据同步的效率低一些

内核线程的优点

  • 在多处理器系统中,内核能够调度同一进程内的多个内核线程并行的在多个处理器上执行
  • 如果一个内核线程被阻塞,内核可以调度同一个进程中的另一个内核线程到处理器

内核线程的缺点:内核线程切换的速度已经内核线程间通信的效率较差

轻量级进程

轻量级进程(LWP,Light Weight Process)是一个建立在内核之上并由内核支持的用户线程,一个进程内可以包含多个 LWPs,每个 LWP 又会关联至一个特定的内核线程。因此 LWP 是一个独立的线程调度单元,即使有一个 LWP 被阻塞也不会影响到进程中其他 LWP 的执行。在 Linux 操作系统采用的用户线程与内核线程「一对一」映射模型中,LWP 就是用户线程。

LWP 的实现造成了一些局限性:

  • LWP 进行系统调用时需要进行模式切换,所以比单纯的系统切换开销更大
  • LWP 与内核线程一一对应会消耗内核线程的栈资源(内核地址空间),所以系统不能支持大量的 LWP

这里写图片描述

用户线程

用户线程(ULT,User Level Thread)是在用户态中通过线程库创建的线程,用户线程的创建、调度、销毁和通信都在用户空间完成。内核不会感知到用户线程,内核也不会直接对用户线程进行调度,内核的调度对象依旧是用户进程本身。下面列举一些特性:

  • 内核不会为用户线程分配资源,用户线程只在同一进程内竞争资源。
  • 用户线程切换由用户程序控制,无需内核干涉,所以没有模式切换的消耗。
  • 因为用户线程不被内核感知所以内核也无法将用户线程单独调度到不同的处理器上。用户态中内核只会将进程作为处理器调度的基本单位,同一进程内的多个线程只会在运行进程的处理器上进行线程切换。
  • 用户线程不具有独自的线程上下文,因此同一时刻同一进程只能有一个用户线程在运行

用户线程的优点:用户线程切换不进行模式切换,切换开销小,速度快

用户线程的缺点

  • 不能享受多处理器系统的好处同一进程
  • 一个用户线程的阻塞将导致整个用户进程内所有用户线程阻塞
  • 用户态处理器时间片分配是以用户进程为基本单位的,所以每个线程执行的时间也会相对更少了

这里写图片描述

轻量级进程与用户线程的区别

LWP 虽然本质上属于用户线程,但 LWP 线程库是建立在内核之上的,其许多操作都要需要进行系统调用,切换开销大,因而并发效率不高;而用户线程则是完全建立在用户空间的线程库,不需要内核参与,因此用户线程切换是即快又低耗的。

为什么 Linux 使用的是 LWP 而不是用户线程?
之前我们提到过 Linux 操作系统中的 LWP 就是 Linux 的用户线程。虽然用户线程即快又低耗,这是舍弃了并发性换来的结果,没有办法充分发挥多处理器系统的价值。对定位于服务器端操作系统的 Linux 而言,并没有采用纯粹的用户线程实现,而是使用 LWP 作为用户线程的替身。所以就 Linux 操作系统而言,用户线程就是 LWP 这句话并不为错。

用户线程与轻量级进程的混合模式

混合模式下的用户线程依旧由建立在用户空间中的用户线程库实现,所以用户线程不会像内核线程一般消耗系统内存地址资源,用户线程可以建议任意多的数量。混合模式的特点在于会使用 LWP 作为影用户线程和内核线程之间的桥梁,多个用户线程对应一个 LWP,一个 LWP 又会映射到一个内核线程中。

这里写图片描述

这样的关联关系使得用户线程可以利用 LWP 绑定的内核线程作为内核调度单元的特性来实现同一进程中的某个用户线程被阻塞时并不会使其他用户线程也被阻塞。简单来说,混合模式下的用户线程即保留了完全的用户态特性,又解决了内核对用户线程无感导致的并发性问题。

需要注意的是,用户线程和内核线程间插入了 LWP 中间层,其调度的复杂度和调度的开销成正比提升,执行性能受到削弱。混合模式是一种折中的方案。

用户线程和内核线程的区别

  • 运行模式

    • 用户线程完全运行在用户态
    • 内核线程运行在内核态
  • 内核支持

    • 操作系统内核对用户线程不感知、不调度、不分配资源,所以理论上可以创建任意多的用户线程
    • 内核通过 TCB 来感知内核线程,内核线程占用内核栈资源,所以不能运行太多的内核线程
  • 内核调度

    • 内核线程是内核的调度实体
    • 用户线程所属的进程是内核的调度实体
  • 处理器分配

    • 内核将一个进程调度到一个处理器,进程内的用户线程共享使用该处理器,用户线程不能充分利用多处理器系统
    • 内核会将多个内核线程同时调度到不同的处理器上,内核线程可以充分利用多处理器系统
  • 系统调用中断

    • 用户线程执行系统调用时,会导致其所属进程被中断
    • 内核线程执行系统调用时,只导致该线程被中断

线程的实现模型

一对一模型

每个用户线程都映射或绑定到一个内核线程,一旦用户线程终止则内核线程也一同被销毁。Linux 操作系统采用的 LWP 就是一对一模型。

这里写图片描述

如上图,进程内每个用户线程都可以通过映射到不同的内核线程。

缺点:内核线程数量有限,线程切换会同时涉及到上下文切换和模式切换,开销较大。

多对一模型

多个用户线程映射到一个内核线程,纯粹的用户线程就是多对一模型。

这里写图片描述

如上图,进程内同一时刻只能有一个用户线程被映射到内核线程。

优点:用户线程切换完全在用户态完成,不涉及模式切换。而且同一进程内的线程切换只需要进行寄存器切换,所以速度很快。

缺点:一个用户线程阻塞,同一进程内的所有线程都被阻塞。

多对多模型

多个用户线程可以映射到少数但不止一个内核线程,是上述两种模型的综合实现,用户线程和 LWP 的混合模式就是多对多模型。

这里写图片描述

如上图,一个进程中的多个用户线程可以映射到少数但不止一个内核线程。

优点:用户线程的数量依旧没有限制,并且在多处理器系统上会有一定的性能提升。

缺点:性能提升的幅度不及一对一模型

混合线程模型

混合线程模型实现是用户线程和内核线程的交叉,用户线程由运行时库调度器管理,内核线程由操作系统调度器管理,库和系统同时参与线程的调度。

进程拥有自己的内核线程池,进程内准备好执行的用户线程由运行时库分派并标记为 “可用用户线程”,操作系统选择可用用户线程并将它映射到进程内核线程池中的 “可用内核线程”。多个用户线程可以分配给相同的内核线程。

进程与线程调度

Linux 操作系统的用户态依旧延续了进程和线程的概念及描述,从编程的角度来看,我们依旧可以通过进程库或线程库来实现用户态进程或线程的创建与调度。但 Linux 的内核态并没有特别区分进程和线程,线程被视为了一个与其他进程共享某些资源的特殊 “进程”。无论是使用 fork() 来创建进程,还是使用 pthread_create() 创建线程,最终都调用了 do_dork() 来完成 task_struct 结构体的复制。为了方便描述和理解,下文中不时会使用「任务」来作为进程和线程的抽象。

task_struct “任务描述符” 或称 “进程描述符”,包含了单个进程在运行期间所有必要的信息(e.g. PGID 标识了进程组,TGID 标识了线程组,PID 标识了进程或线程),是内核调度的关键。

这里写图片描述

进程的生命周期

每个进程都有自己的生命周期,比如创建、执行、终止、删除等。

当用户程序创建一个新进程的时候,父进程会发出一个 fork() 系统调用,然后父进程得到一个新建子进程的 “进程描述符”,并设置一个新的 PID 以及将自己的相关属性复制给子进程。此时父子进程会共享相同的地址空间,直至 exec() 系统调用需要将新的代码复制到子进程的地址空间时,才会为子进程分配新的物理页,子进程再 exec 属于自己的程序代码。这种延迟的数据复制操作称为写时复制(COW,Copy On Write),应用 COW 技术有效避免了不必要的数据复制开销,因为将父进程整个地址空间完全复制给子进程是非常低效且无谓的操作。当子进程执行为程序代码之后,通过 exit() 系统调用终止子进程,系统回收子进程资源并将其状态置为僵尸进程。直至父进程通过 wait() 系统调用得到子进程以及终止了,父进程才会彻底释放子进程的所有数据结构和 “进程描述符”。

这里写图片描述

Linux 的线程

上文中我们提到 Linux 的线程实现采用的是一对一模型,作为进程中的执行单元,能够与同一进程中的其他线程并行在不同的处理器中运行。因为线程共享同一进程的资源,比如内存、地址空间、打开的文件等资源,所以需要用户程序实现互斥、锁、序列化等机制来保证共享数据的一致性和数据同步。从性能的角度来看,线程创建的开销要比进程创建更小,因为创建线程时不需要进行资源复制。

这里写图片描述

LinuxThreads 自 Kernel 2.0 以来成为了 Linux 默认的用户空间线程库,用户线程、LWP、内核线程三者间保持着 1:1:1 的对应关系。前文我们提到内核对 LWP(用户线程)的调度和对进程的调度是类似的,所以在 Linux 中,内核对进程和线程的调度管理并没有十分明确的区分,在调度算法上也有着相似的特征。关于进程调度的细节我们在后文继续讨论。

LinuxThreads 使用一个专门的 “管理线程” 来处理所有线程的管理工作,当进程调用 pthread_create() 创建出第一个线程时会先创建并启动 “管理线程”。后续进程再调用 pthread_create() 创建用户线程时,管理线程通过调用 clone() 来创建用户线程并记录 LWP ID 和子线程 ID 的映射关系。用户线程本质是管理线程的子线程。

进程的优先级

Linux 操作系统也被称为「多任务实时操作系统」,支持多种优先级、调度策略和抢占方式。在用户态,Linux 不会直接调度线程,因为内核对线程是无感知的,所以我们能够看见并使用的大多数操作都是针对进程而言。

以是否具有实时性特征,可以将进程分为实时进程和非实时进程。所谓的实时性就是要求最小的中断延时和任务切换延时,即进程能够不被阻塞或少被阻塞,能够快速的完成响应。实时性在工业领域具有广泛的应用场景和严格要求,对于实时性的需求,Linux 常用的调度算法,无论是 O1 还是 CFS 都难以实现。

所以在设计 Linux 内核的时候,干脆将进程的优先级从逻辑上划分为了实时进程优先级和非实时进程优先级两个平面。优先级在 Linux 内核的对象就是一个数字,由宏 MAX_PRIO 来记录:

  • 实时进程优先级:具有 100 个级别对应 MAX_PRIO 的 [0, 99]
  • 非实时进程优先级:具有 40 个级别对应 MAX_PRIO 的 [100, 139]

内核通过优先级来确定 CPU 处理的顺序,从 MAX_PRIO 的范围可见,内核调度进程时始终以实时进程为最优先,并且不能被抢占。如果存在已准备的实时进程则优先执行,直到实时进程结束或主动让出 CPU 后,内核才会考虑调度非实时进程。

从操作层面来看,Linux 将实时优先级和非实时优先级分别映射成为了静态优先级和动态优先级。静态优先级设定后是不能够被修改的,较高的静态优先级会拥有更长的时间片。相反,动态优先级是可以被调整的。这是一个合理的设计,毕竟实时代表着最高执行力。

这里写图片描述

Linux 引入 Nice Level 来改变进程的动态优先级,nice 值的范围是 [-20, 19] 对应 MAX_PRIO 的 [100, 139],默认值为 0,值越小优先级越高。实际上 nice 值是通过公式 PRI(new) = PRI(old) + nice 来决定进程优先级的,静态优先级虽然不能被调整,但却可以通过动态优先级的 nice 值来影响。所以有时候可能你会发现虽然 PA 的 PRI(old) 值比 PB 小,但 PB 却被优先执行了。

需要注意的是,普通用户能否调整的 nice 范围是 [0, 19],并且只能调高不能调低。root 用户才能随意调整 nice 值。

调整进程的动态优先级

设定进程优先级对改善 Linux 多任务环境中的程序执行性能非常有用。

查看进程资源使用信息

root@devstack-all-in:~# ps aux
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root          1  0.1  0.0  78088  9188 ?        Ss   04:26   0:03 /sbin/init maybe-ubiquity
...
stack      2152  0.1  0.8 304004 138160 ?       S    04:27   0:04 nova-apiuWSGI worker 1
stack      2153  0.1  0.8 304004 138212 ?       S    04:27   0:04 nova-apiuWSGI worker 2
...

这里写图片描述

查看进程优先级信息

root@devstack-all-in:~# ps -le
F S   UID    PID   PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
4 S     0      1      0  0  80   0 - 19522 ep_pol ?        00:00:03 systemd
1 S     0      2      0  0  80   0 -     0 kthrea ?        00:00:00 kthreadd
1 I     0      4      2  0  60 -20 -     0 worker ?        00:00:00 kworker/0:0H
1 I     0      6      2  0  60 -20 -     0 rescue ?        00:00:00 mm_percpu_wq
...
  • UID:进程执行者
  • PID:进程代号
  • PPID:父进程代号
  • PRI:进程优先级,值越小优先级越高
  • NI:进程的 nice 值

查看 nice 不为 0 的非实时进程

[root@localhost ~]# ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm|awk '$4 ~ /-/ &&$5 !~/0/ {print $0}'
   63    63 TS       -   5  14   2  0.0 TS  SN   ksm_scan_threa ksmd
   64    64 TS       -  19   0   2  0.0 TS  SN   khugepaged     khugepaged
12995 12995 TS       -  -4  23   1  0.0 TS  S<sl ep_poll        auditd

nice 指令:执行命令的同时设定 nice 值。e.g.

nice -n -5 service httpd start

renice 指令:修改已经存在的非实时进程的 nice 值。e.g.

[root@localhost ~]# ps -le | grep nova-compute
4 S  1000  9301     1  2  80   0 - 530107 ep_pol ?       00:02:50 nova-compute
[root@localhost ~]# renice -10 9301
9301 (process ID) old priority 0, new priority -10
[root@localhost ~]# ps -le | grep nova-compute
4 S  1000  9301     1  2  70 -10 - 530107 ep_pol ?       00:02:54 nova-compute

设定实时进程优先级

查看系统中所有的实时进程

[root@localhost ~]# ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'
  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
    7     7 FF      99   - 139   0  0.0 FF  S    smpboot_thread migration/0
   10    10 FF      99   - 139   0  0.0 FF  S    smpboot_thread watchdog/0
   11    11 FF      99   - 139   1  0.0 FF  S    smpboot_thread watchdog/1
   12    12 FF      99   - 139   1  0.0 FF  S    smpboot_thread migration/1

chrt 指令可以显示、设定实时进程的静态优先级以及修改实时进程的调度策略。

修改进程的静态优先级chrt -p [1..99] {pid}

[root@localhost ~]# ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'
  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
   27    27 FF      99   - 139   4  0.0 FF  S    smpboot_thread migration/4

[root@localhost ~]# chrt -p 31
pid 31's current scheduling policy: SCHED_FIFO
pid 31's current scheduling priority: 99

[root@localhost ~]# chrt -f -p 50 31

[root@localhost ~]# chrt -p 31
pid 31's current scheduling policy: SCHED_FIFO
pid 31's current scheduling priority: 50

查看进程运行状态及其内核函数名称

[root@localhost ~]# ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:34,nwchan,pcpu,comm
  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN                               WCHAN %CPU COMMAND
    1     1 TS       -   0  19   4  0.0 TS  Ssl  ep_poll                            ffffff  0.0 systemd
    2     2 TS       -   0  19   0  0.0 TS  S    kthreadd                            b1066  0.0 kthreadd
    3     3 TS       -   0  19   0  0.0 TS  S    smpboot_thread_fn                   b905d  0.0 ksoftirqd/0
...
   44    44 TS       -   0  19   7  0.0 TS  R    -                                       -  0.0 kworker/7:0
  • wchan:显示进程处于休眠状态的内核函数名称,如果进程正在运行则为 -,如果进程具有多线程且 ps 指令未显示,则为 *
  • nwchan:显示进程处于休眠状态的内核函数地址,正在运行的任务将在此列中显示短划线 -

进程的调度

在多任务多处理器计算机系统中必须提供一种方法,让多个进程之间尽可能公平地共享处理器等资源,同时还要考虑到不同进程的任务优先级。

Linux 内核实现了调度器来解决这一问题,调度器主要职责是保证处理器都处于忙碌的状态,决定了运行线程的处理器及时间片长度。但需要注意的是,调度器并不负责保证用户程序的执行性能。

调度类型

高级调度(作业调度):根据作业调度算法从外存后备队列将作业调入内存,并分配资源、创建作业相应的进程。作业完成后也做一些善后工作,例如:关闭文件等。

中级调度(平衡调度):涉及进程在内外存之间的交换,当主存资源紧缺时,会将暂不运行的进程从内存调至外存,此时进程处于 “挂起” 状态。当进程又具备了运行条件且主存资源充裕时,再将进程从外存调至内存。中级调度的主要目的是提高内存利用率和系统吞吐量。

低级调度(进程/线程调度):根据调度策略从处理器的就绪队列中选择一个进程或线程让它获取处理器的使用权。

  • 非剥夺式(非抢占式)调度:调度程序一旦把处理器分配给某个进程/线程后,就会一直占用处理器直到执行完成或主动让出时,才会将处理器分配给其他进程/线程。适用于批处理系统。

  • 剥夺式(抢占式)调度:当一个进程/线程使用处理器时,调度策略会根据某种规则将处理器分配给其他进程/线程。适用于分时系统和实时系统。

Linux 的进程/线程调度策略

实时调度策略

  • SCHED_FIFO:先到先服务调度策略,相同优先级的任务先到先服务,高优先级的任务可以抢占低优先级的任务。当前线程占用处理器直到它阻断、退出或被更高的线程抢占为止。

  • SCHED_RR:时间片轮转调度策略,采用时间片,相同优先级的任务当用完时间片后会被放到队列尾部。同样,高优先级的任务可以抢占低优先级的任务。常用于需要以相同优先级运行多个任务的场景。

  • SCHED_DEADLINE:针对突发型计算,且适用于对延迟和完成时间高度敏感的任务。基于Earliest Deadline First (EDF) 调度算法。

非实时调度策略

  • SCHED_NORMAL:普通进程调度策略,通过 CFS 调度器实现。

  • SCHED_BATCH:采用分时策略,根据动态优先级(nice 值)来分配处理器运算资源。适用于非交互的处理器消耗型进程。

  • SCHED_IDLE:优先级最低,在系统空闲时才跑这类进程。适用于系统负载很低的时候。

修改进程的调度策略

chrt 指令支持 6 种调度器策略。e.g.

root@devstack-all-in:~# chrt --help
Show or change the real-time scheduling attributes of a process.

Set policy:
 chrt [options] <priority> <command> [<arg>...]
 chrt [options] --pid <priority> <pid>

Get policy:
 chrt [options] -p <pid>

Policy options:
 -b, --batch          set policy to SCHED_BATCH
 -d, --deadline       set policy to SCHED_DEADLINE
 -f, --fifo           set policy to SCHED_FIFO
 -i, --idle           set policy to SCHED_IDLE
 -o, --other          set policy to SCHED_OTHER
 -r, --rr             set policy to SCHED_RR (default)

设定进程的调度策略

root@devstack-all-in:~# chrt -f 10 bash
root@devstack-all-in:~# chrt -p $$
pid 6344's current scheduling policy: SCHED_FIFO
pid 6344's current scheduling priority: 10
  • SCHED_FIFO:先到先服务调度策略。一旦处于可执行状态就会一直执行,直到它自己阻塞或者释放 CPU。只能被优先级更高的进程抢占,一般用于延时要求较短的进程,被赋予较高的优先级。
[root@localhost ~]# chrt -r 10 bash
[root@localhost ~]# chrt -p $$
pid 13360's current scheduling policy: SCHED_RR
pid 13360's current scheduling priority: 10

[root@localhost ~]# ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,policy,stat,wchan:14,comm |awk '$4 !~ /-/{print $0}'
  PID   TID CLS RTPRIO  NI PRI PSR %CPU POL STAT WCHAN          COMMAND
13360 13360 RR      10   -  50   7  0.0 RR  S    do_wait        bash
  • SCHED_RR:时间片轮转调度策略。进程执行直到时间片用完或者自己阻塞和释放CPU。只能被优先级更高的进程抢占,一般用于延时要求稍长的进程,被赋予较低的优先级。

上下文切换

你可能会认为使用多线程的系统一定会比使用单线程的系统执行速度快,其实未必,多处理器系统中的多线程同样会带来各种各样的性能开销问题,例如线程竞争(e.g. 竞争 I/O 设备,竞争锁)、例如频繁的线程上下文切换(Context Switch)。这些都是调度器无法处理的,应用多线程需要付出的代价。

如果当前进程(当前正在处理器上运行的进程)因为时间片用完、阻塞或被抢占,调度器会将处理器的使用权交给另一个进程,这个过程就叫做进程切换

在处理器执行进程任务期间,当前进程的信息被存储在处理器的寄存器和高速缓存(Cache)中,当前进程被加载到寄存器的数据集被称为上下文(Context)

一个进程的上下文可以分为以下三个部分

  • 用户级上下文: 正文、数据、用户堆栈以及共享存储区
  • 寄存器上下文: 通用寄存器、程序寄存器、处理器状态寄存器、栈指针
  • 系统级上下文: 进程描述符 task_struct、内存管理信息(mm_struct/vm_area_struct/pgd/pte)、内核堆栈

在进程切换过程中,会先当前进程的上下文会被存储在一个特定的区域(进程描述符或内核堆栈区域),然后把下一个要运行的进程的上下文恢复到寄存器。这个过程就是上下文切换

这里写图片描述

根据不同的应用程序会存在不同级别的进程上下文,但大致上每一次进程的上下文切换都会伴随着刷新处理器的寄存器和高速缓存以便释放空间给下一个进程,这样的动作势必会导致处理器性能的下降。如果切换的不是进程而是线程的话,因为线程的上下文不包括内存地址空间、MMU 等,所以线程的上下文切换只需要切换必要性奥寄存器即可,效率会比进程快上不少。

但需要注意的是,如果是同一进程内的多线程切换的开销,正如上面所说会比较低。但如果切换的对象是来自两个不同进程内的线程的话,就会发生由线程切换导致进程切换的情况。

运行模式切换

上文我们提到过,Linux 的线程模式是一个用户线程会映射到一个内核线程。又因为线程切换是只能在内核态进行,所以用户线程切换不仅仅会存在上下文切换,还存在线程的运行模式切换,即从用户态与内核态之间的切换。模式切换同样会对线程的执行性能造成影响,不过与上下文切换相比会更容易些,因为模式切换最主要的任务只是切换线程寄存器的上下文。

这里写图片描述

线程切换的性能消耗

直接开销:由线程切换本身引起的开销。

  • 上下文切换:线程执行现场(task_struct 结构体、寄存器、程序计数器、线程栈等)的保留和载入。

  • 运行模式切换:线程切换只能在内核态完成,如果当前线程处于用户态,则必然需要先将线程从用户态切换为内核态。

  • 调度器:调度器负责线程状态的管理与调度,如果存在优先级调度,则还需要维护线程的优先级队列。当线程切换比较频繁,那么调度器的负载成本也会比较大。

间接开销:是直接开销的副作用。包括在多核 Cache 之间的共享数据

  • 高速缓存缺失:新旧线程切换,如果二者访问的地址空间不接近,则会引起缓存缺失(缓存命中率低,还要花费额外的时间来不断刷新)。具体影响范围取决于计算机系统的实现,处理器体系结构和用户程序的代码实现。如果系统的缓存较大,则能减缓缓存缺失的影响,如果二者访问的地址空间比较接近,也能够降低缓存缺失的概率。

  • 多核缓存共享数据同步:同一进程的不同线程在多个处理器上运行,如果这些线程间存在共享数据,同时这些数据又存在缓存中。那么当另一个线程在新的处理器上运行时,就需要同步其他处理器的缓存数据到新处理器缓存中。

如何减少上下文切换?

  • 如果是让步式上下文切换,线程会主动释放处理器。可通过减少锁竞争来避免上下文切换。
  • 如果是抢占式上下文切换,线程会因用尽时间片而放弃处理器或被其他优先级更高的线程抢占处理器。可通过适当减少线程数来避免上下文切换。

使用 vmstat 指令查看当前系统的上下文切换情况

root@devstack:~# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 4  1      0 4505784 313592 7224876    0    0     0    23    1    2  2  1 94  3  0
  • r:CPU 运行队列的长度和正在运行的线程数
  • b:正在阻塞的进程数
  • swpd:虚拟内存已使用的大小,如果大于 0,表示机器的物理内存不足了。如果不是程序内存泄露的原因,那么就应该升级内存或者把耗内存的任务迁移到其他机器上了
  • si:每秒从磁盘读入虚拟内存的大小,如果大于 0,表示物理内存不足或存在内存泄露,应该杀掉或迁移耗内存大的进程
  • so:每秒虚拟内存写入磁盘的大小,如果大于 0,同上
  • bi:块设备每秒接收的块数量,这里的块设备是指系统上所有的磁盘和其他块设备,默认块大小是 1024Byte
  • bo:块设备每秒发送的块数量,例如读取文件时,bo 就会大于 0。bi 和 bo 一般都要接近 0,不然就是 I/O 过于频繁,需要调整
  • in:每秒 CPU 中断的次数,包括时间中断
  • cs:每秒上下文切换的次数,这个值要越小越好,太大了,要考虑减少线程或者进程的数目。上下文切换次数过多表示 CPU 的大部分时间都浪费在上下文切换了而不是在执行任务
  • st:CPU 在虚拟化环境上在其他租户上的开销

查看进程使用 CPU 的统计信息

root@devstack:~# pidstat -p 12285
Linux 4.4.0-91-generic (devstack)   07/15/2018  _x86_64_    (8 CPU)

02:53:02 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:53:02 PM     0     12285    0.00    0.00    0.00    0.00     5  python
  • PID:进程标识
  • %usr:进程在用户态运行所占 CPU 的时间比率
  • %system:进程在内核态运行所占 CPU 的时间比率
  • %CPU:进程运行所占 CPU 的时间比率
  • CPU:进程在哪个核上运行
  • Command:创建进程对应的命令

多处理器计算平台中的多任务并行与调度

多任务分为硬件支持的多任务与软件支撑的多任务。早期计算机系统中的多任务并行执行更多是由软件来实现的,采用多进程或多线程模型来设计用户程序。单处理器计算平台同一时间只能运行一个任务,但操作系统可以在很小的时间间隔内通过快速的切换多个不同的任务,来给用户造成一种多个任务同时执行的假象,但其本质仍是并发而非并行执行。这样的程序运行机制被称为软件支撑的多任务系统。

随着硬件的发展,推出了多处理器计算平台,现在的服务器系统大多是此类型,同时支持软硬两种方式的多任务并行处理。程序的多个进程或进程内的多个线程可以同时使用多个不同的处理器来执行各自的任务,如果任务的数目不超过处理器的数目,则操作系统通常会确保每个任务都独占一个处理器,以此来提高程序的并发和降低延时。这样的程序运行机制被称为硬件支撑的多任务系统。

这里写图片描述

多处理器计算平台的并发程序设计中,大致会引来两个问题,一个是内存可见性,一个是 Cache 一致性流量。内存可见性属于并发安全的问题,Cache 一致性流量引起的是性能上的问题。

  • 内存可见性:在单处理器或单线程场景中不会发生该问题。在一个单线程环境中,为一个变量写入值,然后在没有干涉的情况下读取这个变量,得到的值始终会是最新的值。但如果读、写不在同一个线程中进行时,情况是不可预料的。同理,Core1 和 Core2 可能会同时把主存中某个位置的值加载到自己的一级缓存中,而 Core1 修改了自己一级缓存中的值后,却不更新主存中的值,这样对于 Core2 来讲,永远看不到 Core1 对值的修改,导致共享数据不一致。

  • Cache 一致性问题:例如在 SMP 体系结构中,Core1 和 Core2 同时加载了主存中的值到自己的一级缓存中,Core1 修改值后,会通过总线让 Core2 中的值失效,Core2 发现自己存的值失效后,会再通过总线从主存中得到新值,以此来保证数据一致性。总线的通信带宽是固定的,通过总线来进行各 CPU 的一级缓存数据同步,那么总线带宽就会成瓶颈。这会影响到 CPU 的性能,需要通过减小数据同步竞争来减少一致性维护流量。

在 SMP 体系结构中,各 CPU 可以共享一个全局的 run_queue(可执行队列)也可以拥有一个私有的 run_queue,一般每个 CPU 对应了一个 run_queue的情况较多。如果进程处于 TASK_RUNNING(可执行状态),则该进程会被加入且仅会被加入到其中一个 run_queue 中,以便让调度器从 run_queue 中调入 CPU。CPU 和可执行队列一一对应的好处在于维护了进程与 CPU 的亲和性。由于处理器 Cache 的原因,通常进程在某个 CPU 上开始执行后,就不会轻易切换到另一个 CPU 上运行。让一个持续处于 TASK_RUNNING 的进程总是趋向于在同一个 CPU 上运行,这样有利于提高进程数据的缓存命中率,提高运行效率。否则会带来额外的缓存开销。操作系统会积极的维护进程亲和性以提高执行效率,但有些情况下仍然会发生被动的 CPU 迁移。例如调度器的负载均衡机制。

多处理器计算平台是一个复杂的系统,在这样复杂的系统,调度器要解决的一个首要问题就是如何让处理器保持忙碌,使得负载均衡。实际上多处理器之间的 Load Balance 同样是有代价的,比如对处于两个不同处理器上运行的进程进行负载平衡的话,将会使得处理器 Cache 失效,造成效率下降。还要考虑的一个问题就是功耗的问题,一个处理器中的两个逻辑处理器各执行一个进程,显然比两个处理器各执行一个进程更节省功耗。Linux 的调度器实现是 Scheduling Domain。

Scheduling Domain 其实就是将具有相同属性的 CPUs 组成集合,并且跟据 Multicore-processors、Hyper-threading、SMP、NUMA 这样的多处理器技术划分成不同的级别。不同级别间通过指针链接在一起,从而形成一种的树状的关系。

这里写图片描述

针对 Scheduling domain,从叶节点往上遍历,直到所有的 domain 中的负载都是平衡的。当然对不同的 domain 会有不同的策略识别负载是否为平衡,以及不同的调度策略。通过这样的方式,能够针对不同的处理器类型、多处理器实现技术进行针对性的负载均衡算法,从而在各个 domain 都得到负载优化,以此来实现整体 CPUs 的负载优化。

显然,调度器的负载均衡机制和进程亲和性原则是相悖的。正如在上文中我们反复提到过的,只会负责保证每个 CPU 都是忙碌的,但调度器不负责用户程序的执行性能。所以 Linux 一方面提供了设定负载均衡触发阈值的接口;另一方面,Linux 内核提供了将进程绑定到指定 CPU 的系统调用,可以明确指定该进程不允许发生 CPU 迁移。在 NUMA 体系结构中,内存管理器还会配合调度器做其相应的策略调整。当一个进程绑定到指定的 CPU 之后,进程申请的内存资源就都要从其相应的 NUMA 节点分配。

多处理器实现与多任务性能的影响

从微观的角度来看,不同的多处理器技术实现会对多任务的并行性能产生不同的影响。

多核处理器(Multicore-processors 或 CMP,Chip-level Multi Processor),每个处理器由多个核心构成,一般拥有独立的 L1 Cache,也可能拥有独立的 L2 Cache。与多处理器(Multi-processors)不同,多核处理器的多个核心在同一个物理芯片上,所以线程间交换数据的效率会更高,功耗也更少。

超线程(Hyper-threading 或 SMT),每个核心都通过超线程技术实现多个硬件线程(Thread),或称为 Virtual CPU,或称为逻辑处理器。这些硬件线程之间几乎所有的东西都会共享。包括 L1 Cache,甚至是逻辑运算单元(ALU)以及功耗(Power)。一般的,如果处理器 Cache 不命中,则处理器需要等待较长的时间从内存中读取数据,这段时间内的逻辑运算核心就会被挂起(Memory Stall),造成性能损耗。超线程的出现就是为了解决这个问题。与上层调度的思路类似,当逻辑运算核心在等待 I/O,那么这时就可以启动另一个隶属于同一个核心的硬件级线程来运行其他任务的指令,防止逻辑运算核心空闲。Linux 操作系统将每个硬件线程都抽象为独立的逻辑处理器,例如,一个双核 2 线程的处理器,从 Linux 操作系统看到的其实是 4 个 Processor 资源。

超线程具有粗粒度和精粒度两种超线程切换的方式,前者在 CPU 出现较大的空闲(e.g. Memory Stall)时才做进行切换,并且切换时由于要重新填充 Pipeline 多以会给性能带来额外开销;后者的控制则更加精细,切换逻辑也更加复杂,但效果较好。超线程技术极致的压榨了处理器的并发能力,提供了吞吐量。但同时也会因为多个线程在同一个物理核心中竞争,导致线程的处理时延变长。

NUMA 体系结构,我们已经在《OpenStack Nova 高性能虚拟机之 NUMA 架构亲和》 中详细的讨论过了,这里不再赘述。在 NUMA 体系结构中的多线程同样面临着调度器无情的 “负载优化” 使用户程序得不到最大的性能优化。例如:应用程序的线程首先在 NODE_A 内运行,但调度器发现存在可用的 NODE_B 异常空闲。调度器就会把应用程序的一个线程迁移到 NODE_B。但是,此时的线程可能仍然需要访问在 NODE_A 的内存。由于该线程当前在 NODE_B 运行,并且对于此线程来说 NODE_A 的内存已经变成了远程内存,远程内存的访问就要花更长的时间。相较于线程在 NODE_A 内等待可用的处理器,该线程在 NODE_B 访问远程内存可能要更加费时。正如上面提到的,Linux 内核同样会积极保持线程亲和性,保证 Cache 的命中率。该特性在 NUMA 体系结构中,同样有助于提高内存本地性,避免了队列操作的线程同步开销(Mutex,互斥锁)。如果可执行队列是全局并被所有 CPU 共享的,那么这种开销将会影响计算平台架构的扩展性。

如何选择多线程的数量

设定线程池大小的核心原则是「在最大化利用 CPU(高吞吐)的同时尽量减少线程切换和管理的性能耗损(低延时)」。首先可以根据任务的执行时长,将任务划分为长任务与短任务两大类。

对于短任务,显然是要求线程数量尽量少,如果线程太多,那么线程切换和管理的开销就比较大了。如果这些开销的时间比任务本身执行的时间都要长的话,那么多线程反而让系统性能更加低了。要求线程数量尽量少,那多少为少?这取决于实际的业务情况,大体上有一些计算的公式,这里先不展开。

对于长任务,则还要再细分一下类型。我们知道进程的时间片大致可以分为 CPU 执行周期和 I/O 执行周期。一般高 I/O 的进程 CPU 周期就短,长 CPU 周期进程的 I/O 次数就少。所以可以将长任务再细分为:

  • I/O 消耗型(I/O 密集型):任务的大部分时间都在提交 I/O(例如:访问磁盘,访问内存,输入输出)请求或者等待 I/O 请求。

  • 处理器消耗型(CPU 密集型):处理器的大部分时间都在做计算、逻辑判断等运算动作,任务没有太多的 I/O 需求,除非被处理器抢占,否则任务会不停地运行。

两种业务类型本质上是高吞吐和低延时的对立统一。如果想要高吞吐量,那就想办法让 Processor 的数量更多,任务的切换就更频繁;如果想要降低任务的延时,快速响应,那么必然要想办法让任务占用处理器的时间更长,减少任务切换等的无效耗时。对于 Linux 上的线程来说,假设拥有 N 个 Processor,可以按照下列常规原则来设置线程池:

  • CPU 密集型场景,线程池大小设置为 N+1
  • I/O 密集型场景,线程池大小设置为 2N+1

这里多说两句,将超线程的性能问题串联起来。在超线程的帮助下,两个被调度到同一个 Core 下不同 Siblings Thread 的 Worker,由于 Siblings Thread 共享 Cache 和 TLB(Translation Lookaside Buffer,转换检测缓冲区),所以能够有效降低 Workers 线程切换的开销。同时,在某个 Worker 不忙的时候,超线程允许其它的 Worker 先使用物理计算资源,以此来提升 Core 的整体吞吐量,非常适合应用到 I/O 密集型的场景。但由于 Workers 间会争抢 Core 的物理执行资源,导致单个 Worker 执行的延时增加,响应速度就不如当初了。在 CPU 密集型场景中,当发生了超线程竞争,超线程计算能力大概是关闭超线程的 60% 左右(个人数据,仅供参考)。

Python GIL 对线程并发性能的影响

说到这里,不妨继续引入 Python GIL 的问题。

在多处理器时代,程序要想充分的利用计算平台的性能,就必须按照并发方式进行设计。但是很遗憾,对于 Python 程序而言,不管你的服务器拥有多少个处理器,任何时候总是有且只能有一个线程在运行。这就是 GIL 为 Python 带来的最困难的问题。并且目前看来短时间内这个问题是难以得到解决的,以至于 Python 专家们通常会建议你 “不要使用多线程,请使用多进程”。

Python 是解释型语言,程序代码被编译成二进制格式的字节码,然后再由 Python 解释器的主回路 pyeval_evalframeex() 边读取字节码,边逐一执行其中的指令。显然,解释器在程序运行之前对程序本身并不是完全了解的,解释器只知道 Python 既定的规则以及在执行过程中怎样动态的去遵守这些规则。Python 解释器无法像 C/C++ 编译器那般在程序进入到处理器运行之前就已经对程序代码拥有了全局的语义分析和理解能力。作为解释型语言,Python 解释器无法在程序真正运行之前就告诉你,你的多线程代码实现到底有多糟糕(隐含的逻辑错误要到真正运行时才会触发)。

你是否也曾面对过这样的窘境,使用 Python 多线程以后,程序的执行效率反而比使用单线程的时候更低了?即便 Python 多线程没有完成真正的并行,那也应该和串行的单线程差不太多才是啊?实际情况可以比你想象的更加糟糕,Python 的多线程在某些场景中会比单线程的效率下降 45%。这是由于 GIL 的设计缺陷导致的。

Python 社区认为操作系统的调度器已经非常成熟,可以直接使用,所以 Python 的线程实际上是 C 语言的一个 pthread,并交由系统调度器根据调度算法和策略进行调度。同时,为了让各线程能够平均的获得 CPU 时间片,Python 会自己维护一个微代码(字节码指令)执行计数器(Python2:1000 字节码指令,Python3:15 毫秒),达到一定的计数阈值后就会强制当前线程释放 GIL,让其他线程得到进入 CPU 的机会,这意味着 GIL 的释放与获取是伴随着操作系统线程切换一起进行的。

这样的模式在单处理器计算平台中是没有问题的,每触发一次线程切换,当前线程都能够如愿获取 GIL 并执行字节码指令,所以单个处理器始终是忙碌的。但在多处理器计算平台中这样的模式会发生什么呢?GIL 只有一个,给了在 CPU1 的当前线程,就不能给 CPU2 的当前线程,所以 CPU2 的当前线程只能白白浪费 CPU 执行时间(线程只有获取了 GIL 才能执行字节码指令)。而且在多处理器计算平台中还平添了线程切换甚至是进程切换的各种开销,赔了夫人又折兵。

这里写图片描述

  • 绿色:CPU 的有效执行时间
  • 红色:线程因为没拿到 GIL 白白浪费的 CPU 时间

那么,Python 的多线程到底还能不能用?就结果而言,如果业务系统中存在一个 CPU 密集型的任务,那么我会告诉你 “多进程或者协程都是不错的选择”。如果业务系统中全都是 I/O 密集型任务,那么恭喜你,多线程将会起到积极的作用。

Python 多线程在 I/O 密集型场景中允许真正的并发,是因为一个等待 I/O 的当前线程会在长的或者不确定的一段时间内,可能并没有任何 Python 代码会被执行,那么该线程就会将 GIL 让出给其他处理器上的当前线程使用(一个在 I/O,一个在执行 Python 代码)。这种礼貌行为称为协同式多任务处理,它允许并发。不同的线程在等待不同的事件。

Python 的线程安全问题

GIL 解决的问题本质就是 Python 多线程的线程安全问题(thread-safe)。从上文中我们了解到,同一进程的多个线程间存在数据共享。为了避免内存可见性的并发安全问题,编程语言大多会提供用户可控的数据的保护机制,也就是线程同步功能。使用线程同步功能,可以控制程序流以及安全访问共享数据,从而并发执行多个线程。常见的同步模型大致有以下四种:

  • 互斥锁:仅允许每次使用一个线程来执行特定代码块或者访问特定的共享数据。
  • 读写锁:允许对受保护的共享数据进行并发读取和独占写入(多读单写)。要修改共享数据,线程必须首先获取互斥写锁。只有释放所有的读锁之后,才允许使用互斥写锁。
  • 条件变量:一直阻塞线程,直到特定的条件为真。
  • 计数信号量:通常用来协调对共享数据的访问。使用计数,可以限制访问某个信号的线程数量。达到计数阈值时,信号被阻塞,直至线程执行接收,计数减少为止。

Python 为了线程安全,也提供了 3 种实现:

  • 原子性操作
  • 线程库锁(e.g. threading.Lock)
  • GIL

GIL 自不必说,只有拿到 GIL 的线程才能执行 Python 字节码指令,能够非常有效的保证线程安全。下面再看看另外两种支持方式。

Python 的原子性操作

Python 提供的许多内置函数都是具有原子性的,例如排序函数 sort()

>>> lst = [4, 1, 3, 2]
>>> def foo():
...     lst.sort()
...
>>> import dis
>>> dis.dis(foo)
  2           0 LOAD_GLOBAL              0 (lst)
              3 LOAD_ATTR                1 (sort)
              6 CALL_FUNCTION            0
              9 POP_TOP
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

我们使用 dis 模块来编译出上述代码的字节码,最关键的字节码指令为:

  1. LOAD_GLOBAL:将全局变量 lst 的数据 load 到堆栈
  2. LOAD_ATTR:将 sort 的实现 load 到堆栈
  3. CALL_FUNCTION:调用 sort 对 lst 的数据进行排序

真正执行排序的只有 CALL_FUNCTION 一条指令,所以说该操作具有原子性。

Python 的线程库锁

我们再举个例子看看非原子操作下,怎么保证线程安全。

>>> n = 0
>>> def foo():
...     global n
...     n += 1
...
>>> import dis
>>> dis.dis(foo)
  3           0 LOAD_GLOBAL              0 (n)
              3 LOAD_CONST               1 (1)
              6 INPLACE_ADD
              7 STORE_GLOBAL             0 (n)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

代码编译后的字节码指令:

  1. 将全局变量 n 的值 load 到堆栈
  2. 将常数 1 的值 load 到堆栈
  3. 在堆栈顶部将两个数值相加
  4. 将相加结果存储回全局变量 n 的地址
  5. 将常数 0(None) 的值 load 到堆栈
  6. 从堆栈顶部返回常数 0 给函数调用者

语句 n += 1 被编译成了前 4 个字节码,后两个字节码是 foo 函数的 return 操作,解释器自动添加。

我们在上文提到,Python2 的线程每执行 1000 个字节码就会被动的让出 GIL。现在假如字节码指令 INPLACE_ADD 就是那第 1000 条指令,这时本应该继续执行 STORE_GLOBAL 0 (n) 存储到 n 地址的数据就被驻留在了堆栈中。如果同一时刻,变量 n 被别的处理器当前线程中的代码调用了。那么请问现在的 n 还是 +=1 之后的 n 吗?答案是此时的 n 发生了更新丢失,在两个当前线程中的 n 已经不是同一个 “n” 了。这就是上面我们提到过的内存可见性数据安全问题的又一个佐证。

下面的代码正确输出为 100,但在 Python 多线程多处理器场景中,可能会得到 99 或 98 的结果。

import threading


n = 0
threads = []

def foo():
    global n
    n += 1

for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)

for t in threads:
    t.start()

for t in threads:
    t.join()

print(n)

此时,Python 程序员应该要想到使用 Python 线程库的锁来解决为。

import threading


n = 0
lock = threading.Lock()
threads = []

def foo():
    global n
    with lock:
        n += 1

for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)

for t in threads:
    t.start()

for t in threads:
    t.join()

print(n)

显然,即便 Python 已经存在了 GIL,但依旧要求程序员坚持「始终为共享可变状态的读写上锁」。至于 Python 多线程既然也实现诸如此类的细粒度的锁,为什么还要固执的坚持使用 GIL 这把巨大无比的锁呢?很抱歉,除了引用官方文档,笔者实在不能给出更多的答案了,这是一个令人着迷又深感挫折的问题。

static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary primarily because CPython’s memory management is not thread-safe. (However, since the GIL exists, other Features have grown to depend on the guarantees that it enforces.)

翻译:在 CPython(最常用的 Python 解释器实现)中,全局解释器锁(GIL)是一个全局的互斥锁,它可以防止多线程同时执行 Python 程序的字节码。 这种锁是必要的,主要因为 CPython 的内存管理不是线程安全的。

当然也有人尝试过将 GIL 改废,Greg Stein 在 1999 年提出的 “Free Threading” patch 中移除了 GIL。但结果就是单线程执行性能下降了 40%,同时多线程的性能提升也未能达到线性增长标准。至今为止有许多乐于挑战的开发者们在尝试解决这一难题,甚至发布了多种没有 GIL 的 Python 解释器实现(e.g. JPython、IronPython)。不过很可惜的是,由于这些 “特殊” 解释器不属于 C 语言生态圈,所以没能享受到社区众多优秀 C 语言模块的福利,也就注定无法成为主流,只能在特定的场景中发挥着属于自己的特长。

无论如何,GIL 作为 Python 的文化基因,深远的影响了每一位 Pythoner,但却并不完全是正面的影响。例如:Python 程序员对多线程安全问题的理解与任何 C 或 Java 程序员都是大相径庭的。GIL 和 Python 原子性操作的 “溺爱” 让大多数 Python 程序员产生了 “Python 是原生线程安全的编程语言” 的幻觉,并最终在大规模并发应用场景中屡屡受挫。或许真是应了那一句 “Python 的门很好进,但进了门之后才发现 Python 的殿堂在天上”。

那么 GIL 是万恶之源吗?也不尽然,编程的世界永远是「时间和空间」的权衡,简单优雅才是 Python 之美。

猜你喜欢

转载自blog.csdn.net/Jmilk/article/details/81049623
今日推荐