多进程、多线程与多处理器计算平台的性能问题
2018-07-19 16:54 云物互联 阅读(1904) 评论(0) 编辑 收藏 举报目录
前言
现代服务器系统,大多采用多线程、多进程与多处理器计算平台的组合,本篇主要研究三者间的关系、存在的性能问题及解决方案。
进程与线程
关于进程与线程的描述最经典莫过于「进程是资源分配的基本单位,线程是 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 部分是内核态的地址空间,存放了整个内核的代码,所有内核模块以及内核所维护的数据。随便多说一句,这就是所谓的操作系统副本,无论是 SMP 还是 NUMA 实现的都是「单操作系统与数据库系统副本」,而 MPP 海量并行处理体系结构实现的是多操作系统与数据库系统副本,但 MPP 一般只常见于大型机。运行在 RING3 的用户程序代码可以通过系统调用主动访问 RING0 的内核代码来实现从用户态带内核态的切换。当进程陷入内核态时,被执行的内核代码会直接使用进程的内核栈资源。
例如:用户运行一个程序,该程序创建的进程开始运行在用户态,如果程序要执行诸如文件操作,网络数据发送操作等内核态操作的话,就必须通过系统调用中的 Write,Send 等功能单元完成,根本是通过调用内核代码完成的。此时,运行该进程的处理器会从 RING3 切换到 RING0 级别,然后进入 3-4GB 内核地址空间中完成内核代码的执行。执行完成后,处理器再从 RING0 切换回 RING3,进程也回到用户态。
用户程序除了通过系统调用主动触发模式切换之外,还可能会被动的进行。总的来说模式切换有两种触发手段:
(软中断)系统调用:这时用户态进程要传递很多变量或参数值给内核,内核态运行时也要保存用户进程的一些寄存器值和变量等等。所谓的「进程上下文」,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值以及运行环境等。
(硬中断)外围设备中断:硬件可以通过触发中断信号令内核调用中断处理程序从而进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的「中断上下文」,就是硬件传递过来的这些参数和内核需要保存的当前被中断执行的进程环境。
由此可见,处理器总处于以下状态中的一种:
- 运行进程上下文的内核态,内核代表进程运行在内核地址空间
- 运行中断上下文的内核态,内核代表硬件运行在内核地址空间
- 用户态,运行在用户地址空间
内核线程,用户线程与轻量级进程
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. 网卡、磁盘控制器、鼠键、串行适配卡等)和 CPU 通信的方式,让 CPU 能够及时掌握硬件设备发生的事件,并视乎于中断类型决定是否放下当前任务,尽快处理紧急的硬件设备事件(e.g. 以太网数据帧到达,键盘输入)。
对于计算机系统而言,中断的本质是一个电信号,称为中断请求信号 IRQ。操作系统为每个硬件设备分配一个 IRQ Number,以此来区分发出中断的设备类型。IRQ Number 又会映射到内核中断服务路由列表(ISR)中的一个服务清单,操作系统就是通过这样的方式,将硬件设备和具体需要在处理器上运行的驱动程序服务关联起来。
硬件中断是内核调度优先级最高的任务类型之一,所以硬件中断通常都伴随着任务切换,将当前任务切换为中断上下文。显然,大量的中断处理,尤其是硬件中断处理会非常消耗 CPU 资源。并且在多处理器计算平台中,每个处理器都有机会处理硬件中断,所以将中断处理绑定到指定的处理器上,可以有效提高系统性能。详细内容我们在后面继续讨论。
上下文切换
你可能会认为使用多线程的系统一定会比使用单线程的系统执行速度快,其实未必,多处理器系统中的多线程同样会带来各种各样的性能开销问题,例如线程竞争(e.g. 竞争 I/O 设备,竞争锁)、例如频繁的线程上下文切换(Context Switch)。这些都是调度器无法处理的,应用多线程需要付出的代价。
如果当前进程(当前正在处理器上运行的进程)因为时间片用完、阻塞或被抢占,调度器会将处理器的使用权交给另一个进程,这个过程就叫做进程切换。
在处理器执行进程任务期间,当前进程的信息被存储在处理器的寄存器和高速缓存(Cache)中,当前进程被加载到寄存器的数据集被称为上下文(Context)。
一个进程的上下文可以分为以下三个部分:
- 用户级上下文: 正文、数据、用户堆栈以及共享存储区
- 寄存器上下文: 通用寄存器、程序寄存器、处理器状态寄存器、栈指针
- 系统级上下文: 进程描述符 task_struct、内存管理信息(mm_struct/vm_area_struct/pgd/pte)、内核堆栈
在进程切换过程中,会先当前进程的上下文会被存储在一个特定的区域(进程描述符或内核堆栈区域),然后把下一个要运行的进程的上下文恢复到寄存器。这个过程就是上下文切换。
根据不同的应用程序会存在不同级别的进程上下文,但大致上每一次进程的上下文切换都会伴随着刷新处理器的寄存器和高速缓存以便释放空间给下一个进程,这样的动作势必会导致处理器性能的下降。如果切换的不是进程而是线程的话,因为线程的上下文不包括内存地址空间、MMU 等,所以线程的上下文切换只需要切换必要性奥寄存器即可,效率会比进程快上不少。
但需要注意的是,如果是同一进程内的多线程切换的开销,正如上面所说会比较低。但如果切换的对象是来自两个不同进程内的线程的话,就会发生由线程切换导致进程切换的情况。
运行模式切换
上文我们提到过,Linux 的线程模式是一个用户线程会映射到一个内核线程。又因为线程切换是只能在内核态进行,所以用户线程切换不仅仅会存在上下文切换,还存在线程的运行模式切换,即从用户态与内核态之间的切换。模式切换同样会对线程的执行性能造成影响,不过与上下文切换相比会更容易些,因为模式切换最主要的任务只是切换线程寄存器的上下文。
线程切换的性能消耗
直接开销:由线程切换本身引起的开销。
上下文切换:线程执行现场(task_struct 结构体、寄存器、程序计数器、线程栈等)的保留和载入。
运行模式切换:线程切换只能在内核态完成,如果当前线程处于用户态,则必然需要先将线程从用户态切换为内核态。
调度器负载:调度器负责线程状态的管理与调度,如果存在优先级调度,则还需要维护线程的优先级队列。当线程切换比较频繁,那么调度器的负载成本也会比较大。
间接开销:是直接开销的副作用。包括在多核 Cache 之间的共享数据
高速缓存缺失(Cache Missing):新旧线程切换,如果二者访问的地址空间不接近,则会引起缓存缺失(缓存命中率低,还要花费额外的时间来不断刷新)。具体影响范围取决于计算机系统的实现,处理器体系结构和用户程序的代码实现。如果系统的缓存较大,则能减缓缓存缺失的影响,如果二者访问的地址空间比较接近,也能够降低缓存缺失的概率。
多核缓存共享数据同步:同一进程的不同线程在多个处理器上运行,如果这些线程间存在共享数据,同时这些数据又存在缓存中。那么当另一个线程在新的处理器上运行时,就需要同步其他处理器的缓存数据到新处理器缓存中。
如何减少上下文切换?
- 如果是让步式上下文切换,线程会主动释放处理器。可通过减少锁竞争来避免上下文切换。
- 如果是抢占式上下文切换,线程会因用尽时间片而放弃处理器或被其他优先级更高的线程抢占处理器。可通过适当减少线程数来避免上下文切换。
使用 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 迁移。例如调度器的负载均衡机制。
多处理器计算平台是一个复杂的系统,有多复杂?现在服务器计算平台可能会集合了 SMP、NUMA、多核、超线程等技术。在这样复杂的系统,调度器要解决的一个首要问题就是如何让处理器保持忙碌,使得负载均衡。Scheduling Domain 就是负载均衡机制就是针对混合多处理器技术场景下的 Linux 的调度器实现。
Scheduling Domain 其实就是将具有相同属性的 CPUs 组成集合,并且跟据 Multicore-processors、Hyper-threading、SMP、NUMA 这样的多处理器技术划分成不同的级别。不同级别间通过指针链接在一起,从而形成一种的树状的关系。
针对 Scheduling domain,从叶节点往上遍历,直到所有的 domain 中的负载都是平衡的。当然对不同的 domain 会有不同的策略识别负载是否为平衡,以及不同的调度策略。
通过这样的方式,能够针对不同的处理器类型、多处理器实现技术进行针对性的负载均衡算法,从而在各个 domain 都得到负载优化,以此来实现整体 CPUs 的负载优化。
实际上多处理器之间的 Load Balance 同样是有代价的,比如对处于两个不同处理器上运行的进程进行负载平衡的话,将会使得处理器 Cache 失效,造成效率下降。还要考虑的一个问题就是功耗的问题,一个处理器中的两个逻辑处理器各执行一个进程,会比两个处理器各执行一个进程更节省功耗。
显然,调度器的负载均衡机制和进程亲和性原则是相悖的。虽然 Scheduling domain 自身也具有软 CPU 亲和特性,但正如在上文中我们反复提到过的,调度器的核心任务是负责保证每个 CPU 都是忙碌的,但并不负责用户程序的执行性能。所以 Linux 一方面提供了设定负载均衡触发阈值的接口,另一方面 Linux 内核也提供了将进程绑定到指定 CPU 的硬 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 亲和性(CPU Affinity),又称 CPU 关联性,是一种调度属性(Scheduler Property),指定进程要在特定的 CPU 上尽量长时间地运行而不被迁移到其他 CPU 上的倾向性。Linux 内核调度器原生支持「软 CPU 亲和性」,调度器会积极的让进程在同一个 CPU 上运行。内核 2.6 版本引入了可供编程的「硬 CPU 亲和性」,意味着用户程序可以显式地指定进程在哪个(或哪些)处理器上运行。
进程描述符 task_struct 的 cpus_allowed 表示 CPU 亲和位掩码(BitMask)由 n 位组成,对应系统中的 n 个 Processor。最低位表示第一个 Processor,最高位表示最后一个 Processor,进程的 CPU 亲和特性会传递给子线程。通过为进程的 cpus_allowed 掩码位置 1 来指定进程的 Processors 亲和,当有多个掩码位被置 1 时表示运行进程在多个 Processor 间迁移,缺省为全部位置 1。
设定进程或线程的 CPU 亲和性
在多处理器计算平台中,尤其在 NUMA 架构体系中,设置 CPU 亲合能够有效提高 CPU Cache 的命中率,减少远程内存访问损耗,以获得更高的性能。Linux 内核提供了 API 接口,可以让用户查看和修改进程/线程的 CPI 亲和。通常的以下 2 种场景我们需要考虑手动设定 CPU 亲和:
- 计算量巨大(e.g. 人工智能,图形计算,超算)
- 进程任务对延时非常敏感(e.g. 运营商级别的 NFV,能支持千兆万兆网卡数据采集的 DPDK)
查看进程运行在哪个 Processor 上:
root@devstack:~# ps -eo pid,args,psr
PID COMMAND PSR
...
19153 python test.py 3
...
- pid:进程标识
- args:进程执行时传入的命令行参数
- psr:运行进程的 Processor ID
查看线程的 TID:上文我们提到过,Linux 的用户线程本质是 LWP,所以从操作层面上看到的 TID 其实就是用户线程真实的 PID。
注意,TID 并非编程接口 pthread_getaffinity_np(pthread_t thread, ...)
中的 pthread_t。
# test.py 是在终端运行的一个线程脚本
root@devstack:~# ps -efL | grep test
root 19153 18464 19153 0 2 03:47 pts/0 00:00:00 python test.py
root 19153 18464 19319 0 2 03:54 pts/0 00:00:00 python test.py
root 19323 18549 19323 0 1 03:54 pts/1 00:00:00 grep --color=auto test
root@devstack:~# ps -To 'pid,lwp,psr,cmd' -p 19153
PID LWP PSR CMD
19153 19153 3 python test.py
19153 19329 7 python test.py
# 一个线程脚本生成了两个 LWP,一个是主进程,一个是子进程(子线程)
C 语言调用接口设定 CPU 亲和
进程:
int sched_getaffinity(pid_t pid, size_t cpusetsize,cpu_set_t *mask); //查看 pid 绑定的 cpu。
int sched_setaffinity(pid_t pid, size_t cpusetsize,cpu_set_t *mask); //设定 pid 绑定的 cpu,
线程:
int pthread_setaffinity_np(pthread_t thread, size_t cpusetsize, onst cpu_set_t *cpuset); //查看 thread 绑定的 cpu。
int pthread_getaffinity_np(pthread_t thread, size_t cpusetsize, cpu_set_t *cpuset); //设定 thread 绑定的 cpu。
Linux 指令设定 CPU 亲和
taskset 指令用于根据 PID 显示、设定运行进程的 CPU 亲和性,还可以在启动进程的同时设定 CPU 亲和性,将指定的进程与指定的 CPU(或一组 CPU)绑定起来。但 taskset 指令不能保证内存分配,在 NUMA 结构体系的计算平台中可以使用 numactl 指令来完成本地内存分配。
为运行的进程设定 CPU 亲和:
taskset -p <mask> pid
在操作层面,CPU 亲和性使用十六进制的位掩码来表示。e.g. 0x00000001 代表 Processor0,0x00000003 代表 Processor3 和 Processor1。
为启动的进程设定 CPU 亲和:
taskset mask -- <program>
# e.g.
root@devstack:~# taskset -c 0 python test.py
root@devstack:~# ps -eo pid,args,psr | grep test
20277 python test.py 0
Python 语言设定进程的 CPU 亲和
Python 程序可以用个 cpu_affinity 库来实现设定进程的 CPU 亲和。cpu_affinity 提供了 3 个 method:
- 通过 PID 获取进程
_get_handle_for_pid(pid, ro=True)
- 通过 PID 获取进程的 CPU 亲和位掩码
get_process_affinity_mask(pid)
- 绑定 PID 的进程到指定的 CPU
set_process_affinity_mask(pid, cpu)
设定 NUMA 结构体系中的 CPU 亲和策略
首先,我们重申一些概念,线程是处理器调度运行的基本单位,进程是资源分配的基本单位;Linux 将所有可提供运算能力的单元统一抽象为 Processor;
numactl 指令可以用于设定进程的 CPU 亲和策略和 NUMA 节点内存分配策略。设定的策略会对进程及其所有子进程生效。
numactl 指令实际上是通过 /sys 文件系统来决定系统拓扑的,/sys 文件系统包含有关 CPU、内存和外设是如何通过 NUMA 互联的信息。特别是 /sys/devices/system/cpu 目录中包含有关 CPU 是如何互联的信息。/sys/devices/system/node 目录包含有关系统中 NUMA 节点以及节点间的相对距离信息。NUMA 系统中的处理器和内存条间的相对距离越大,处理器访问内存的速度就越慢。所以应该为性能敏感的应用程序设定从最接近运行程序进程的 NUMA 节点最接近的内存条分配内存,简单来说,让进程使用 NUMA 节点的本地内存。
除此之外,多线程应用程序在 NUMA 系统中还存在一个问题值得被关注。我们知道 CPU 的 Core 是直接访问 L1(或 L2、L3 视 CPU 模具设计而定)Cache 的。如果多线程运行在 NUMA 节点的多核 CPU 上,线程间可能不不断在同一个 CPU 的不同 Core 中切换并轮流使用 Core 的 Cache。假如现在 Core 的 L1 Cache 全部是 T1 的数据,那么当线程切换为 T2 时,就很可能会将 L1 Cache 的数据刷新为 T2 的数据。当再次线程切换为 T1 时,T1 又会重新把 L1 Cache 的数据刷新为 T1 的数据。周而复始,线程间不断的在破坏对方在 Core 上的缓存数据,每一次线程切换,当前线程都需要花费宝贵时间片从内存从加载数据到 Cache。这就是由「缓存交叉访问」导致的「缓存贬值」问题。
(Core 独占 L3)
所以,对于性能敏感的多线程应用程序,建议直接将线程绑定到一个或少数 Core 上,以此来充分发挥 Cache 的价值。最起码也应该将线程绑定到一个 NUMA 节点上,让线程在多个层级(L1、L2 以及 L3 Cache)间共享缓存。
(Core 共享 L3)
需要注意的是,根据每个 CPU 布局设计的不同存在不同的微观调整方案,但大体的原则是一致的:「线程绑定粒度越小,线程执行性能越高」。
使用 numastat 指令来查看 NUMA 节点的内存分配统计数据及分配情况:
Per-node numastat info (in MBs):
Node 0 Total
------ -----
Numa_Hit 25036 25036
Numa_Miss 0 0
Numa_Foreign 0 0
Interleave_Hit 115 115
Local_Node 25036 25036
Other_Node 0 0
- numa_hit:成功分配到本地内存访问的内存大小
- numa_miss:把内存分配到另一个节点的内存大小
- numa_foreign:另一个节点访问该节点本地内存的大小
- interleave_hit:成功分配到该节点的尝试交错策略数
- local_node:该节点上的进程成功分配本地内存的大小
- other_node:该节点上的进程从其他节点上分配远程内存的大小
显然,低 numa_miss 和 numa_foreign 值表示最佳 CPU 性能。反正,则需要考虑进行亲和性调整。
使用 numactl 指令可以将进程绑定到指定的 NUMA 节点,也可以将某个线程绑定到 NUMA 节点的指定 Cores 上。
--show
:显示 NUMA 节点的进程策略
root@devstack:~# numactl --show
policy: default
preferred node: current
physcpubind: 0 1 2 3 4 5 6 7
cpubind: 0
nodebind: 0
membind: 0
--hardware
:显示系统中可用的 NUMA 节点清单
root@devstack:~# numactl --hardware
available: 1 nodes (0)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 16046 MB
node 0 free: 11061 MB
node distances:
node 0
0: 10
--membind
:限定从指定 NUMA 节点分配内存,如果这些节点中的内存不足则分配失败。
numactl --membind=<nodes> <program>
# nodes: NUMA 节点 ID,使用逗号隔开
# program: 应用程序
# e.g.
numactl --membind=0 python test.py
--cpunodebind
:把进程及其子进程绑定到指定的 NUMA 节点
numactl --cpunodebind=nodes program
--physcpubind
:将进程绑定到某个核心(Processor)上
numactl --physcpubind=<cpu> <program>
# cpu: /proc/cpuinfo 中的的 processor ID
# program: 应用程序
--localalloc
:永久性严格指定只能在当前 NUMA 节点分配本地内存
numactl --localalloc python test.py
--preferred
:在可能的情况下弹性从指定的 NUMA 几点上分配内存
numactl --preferred=<node>
# node: NUMA 节点 ID
# e.g.
numactl --preferred=0 python test.py
设定 SMP IRQ Affinity
中断处理也具有亲和性调度属性 smp_affinity,用于指定运行 IRP 对应的 ISR 的 CPU。smp_affinity 是一个十六进制位掩码,保存在 /proc/irq/IRQ_NUMBER/smp_affinity 文件中,可以以 root 用户权限查看并修改。
查看系统的中断处理状态:/proc/interrupts 文件列出了每个 I/O 设备对应到每个 Processor 上的中断次数、类型,以及接收中断的驱动程序列表。
[root@control01 ~]# cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3 CPU4 CPU5 CPU6 CPU7
0: 104 0 0 0 0 0 0 0 IO-APIC-edge timer
...
56: 10810 0 0 0 0 0 0 4060240 PCI-MSI-edge vmw_pvscsi
...
57: 20342 30923 33789 25533 46977 64504 78884 70780 PCI-MSI-edge ens192-rxtx-0
58: 5162 6797 10395 11019 17324 19285 21365 24282 PCI-MSI-edge ens192-rxtx-1
59: 14154 21925 28098 25550 48416 46239 59652 67793 PCI-MSI-edge ens192-rxtx-2
60: 429 836 924 1155 1917 2304 2227 3131 PCI-MSI-edge ens192-rxtx-3
61: 6667 16537 23922 20526 34824 38050 48226 43089 PCI-MSI-edge ens192-rxtx-4
62: 218 617 1149 1235 2559 2551 2098 3710 PCI-MSI-edge ens192-rxtx-5
63: 36892 78273 78853 27126 78559 55956 68976 107141 PCI-MSI-edge ens192-rxtx-6
64: 3027 6627 7506 6994 12081 13225 14441 19625 PCI-MSI-edge ens192-rxtx-7
65: 0 0 0 0 0 0 0 0 PCI-MSI-edge ens192-event-8
...
上面截选了计时器 timer、VMware PVSCSI 驱动程序以及 ens192 网卡设备驱动程序在每个 Processor 上的中断处理数据。可以看见,ens192 在 8 个 Processor 上都有中断处理,这是 IRQ 负载均衡器 irqbalance 在发生着作用,但是这种均衡在某些高性能应用场景中或许并不是一个好的现象。例如在 DPDK 场景中,应该将网卡设备绑定到指定的 CPU 核上,加快采集处理速度;又例如在数据库服务器场景中,应该把磁盘控制器驱动绑到一个 CPU 核上、加快数据库操作响应速度。
修改指定 IRQ Number 的 smp_affinity:
[root@control01 ~]# service irqbalance stop
[root@control01 ~]# cat /proc/irq/57/smp_affinity
04
[root@control01 ~]# echo 01 > /proc/irq/57/smp_affinity
[root@control01 ~]# cat /proc/irq/57/smp_affinity
01
手动指定 IRQ 亲和性首先需要关闭 irqbalance。但需要注意的是,在大部分场景中,irqbalance 提供的中断分配优化都是可以起到积极作用的。irqbalance 会自动收集系统数据来分析出使用模式,并依据系统负载状况将工作状态调整为以下两种模式:
- Performance mode:irqbalance 会将中断尽可能均匀地分发给各个 CPU 的 Core,以充分提升性能。
- Power-save mode:irqbalance 会将中断处理集中到第一个 CPU,保证其它空闲 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 业务系统而言,分布式架构(解耦 CPU 密集型业务和 I/O 密集型业务并分别部署到不同的服务器上进行调优)是一个不错选择。
Python 的线程安全问题
GIL 解决的问题本质就是 Python 多线程的线程安全问题(thread-safe)。从上文中我们了解到,同一进程的多个线程间存在数据共享。为了避免内存可见性的并发安全问题,编程语言大多会提供用户可控的数据的保护机制,也就是线程同步功能。使用线程同步功能,可以控制程序流以及安全访问共享数据,从而并发执行多个线程。常见的同步模型大致有以下四种:
- 互斥锁:仅允许每次使用一个线程来执行特定代码块或者访问特定的共享数据。
- 读写锁:允许对受保护的共享数据进行并发读取和独占写入(多读单写)。要修改共享数据,线程必须首先获取互斥写锁。只有释放所有的读锁之后,才允许使用互斥写锁。
- 条件变量:一直阻塞线程,直到特定的条件为真。
- 计数信号量:通常用来协调对共享数据的访问。使用计数,可以限制访问某个信号的线程数量。达到计数阈值时,信号被阻塞,直至线程执行接收,计数减少为止。
为了线程安全,Python 提供了下列 3 种常见的实现:
- 原子性操作
- 线程库锁(e.g. threading.Lock)
- GIL
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 模块来编译出上述代码的字节码,最关键的字节码指令为:
LOAD_GLOBAL
:将全局变量 lst 的数据 load 到堆栈LOAD_ATTR
:将 sort 的实现 load 到堆栈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
代码编译后的字节码指令:
- 将全局变量 n 的值 load 到堆栈
- 将常数 1 的值 load 到堆栈
- 在堆栈顶部将两个数值相加
- 将相加结果存储回全局变量 n 的地址
- 将常数 0(None) 的值 load 到堆栈
- 从堆栈顶部返回常数 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 之美。