任务管理 —— 进程和线程
介绍
进程和线程
进程和线程是操作系统中最基本的概念:
- 进程是操作系统进行资源分配和调度的基本单位,它拥有独立的地址空间、内存、数据栈以及其他系统资源。
- 线程是进程中的一个执行单元,它共享进程的地址空间和资源,但拥有独立的程序计数器、寄存器和栈。
进程和线程都是操作系统进行程序执行的基本单位,但它们的角色和功能有所不同。进程是操作系统进行资源分配和保护的基本单位,它拥有独立的地址空间和系统资源;而线程是进程中的一个执行单元,它是操作系统进行 CPU 调度的基本单位,共享进程的资源并负责执行程序代码。
进程和线程的概念并非一蹴而就,而是随着计算机技术的发展逐步演变而来的:
- 早期阶段: 单道程序。早期的计算机系统只能运行一个程序,这个程序独占所有的系统资源。这种模式下,没有进程和线程的概念,程序的执行是顺序的,效率低下。为了提高资源利用率,出现了多道程序设计技术,允许多个程序同时驻留在内存中,并由操作系统进行调度。
- 进程的出现:多道程序。为了提高资源利用率,出现了多道程序设计技术,允许多个程序同时驻留在内存中,并由操作系统进行调度。 进程的概念应运而生,每个程序对应一个进程,拥有独立的地址空间和资源。操作系统负责进程的创建、销毁、切换和资源分配,实现了程序的并发执行。
- 线程的出现:轻量级进程。随着图形用户界面 (GUI) 和网络应用的普及,传统的进程模型显得过于笨重,创建和切换进程的开销较大。 线程的概念被提出,作为进程中的一个执行单元,共享进程的地址空间和资源,但拥有独立的程序计数器、寄存器和栈。线程的创建和切换开销比进程小得多,可以更高效地实现并发编程。
- 多线程的普及:多核处理器的出现使得多线程编程成为提升程序性能的关键技术。现代操作系统都提供了完善的线程支持,例如 POSIX 线程 (pthread) 和 Windows 线程。各种编程语言也提供了对多线程编程的支持,例如 Java、Python、C++ 等。
要是暂时无法理解进程和线程,就可以简单的认为:一个程序就是一个进程,一个进程可以有多个线程,这些线程可以共享这个进程的资源,比如内存、文件等。
在 Android 系统中,可以通过 ps
命令查询进程和线程信息:
# 查询所有的进程和线程
adb shell ps -A
ps -T -p <pid>
可以列出指定进程的所有线程。如果一个 ID 是线程,它会在ps -T
的输出中显示为某个进程的子线程。如果 PID 和
TID 相同(如 1234),则该 ID 是进程。如果 PID 和 TID 不同(如 1235 和 1236),则该 ID 是线程。
$ adb shell ps -T -p 1234
USER PID PPID TID VSZ RSS WCHAN ADDR S NAME
u0_a123 1234 456 1234 1.2G 300M ffffffff 00000000 S main
u0_a123 1234 456 1235 1.2G 300M ffffffff 00000000 S Thread-1
u0_a123 1234 456 1236 1.2G 300M ffffffff 00000000 S Thread-2
# 上述字段的含义如下:
# USER:运行该线程的用户。
# PID:进程 ID(Process ID)。
# PPID:父进程的进程 ID(Parent Process ID)。
# TID:线程 ID(Thread ID)。
# VSZ:虚拟内存大小(Virtual Memory Size),单位是 KB。
# RSS:常驻内存大小(Resident Set Size),单位是 KB 或者 MB。
# WCHAN:线程当前等待的内核事件(Wait Channel)。如果线程处于睡眠状态(例如等待 I/O 或锁),
# 这里会显示线程等待的内核函数或事件。
# 如果线程正在运行,则显示 0 或 -。
# ADDR:线程的内核地址(Kernel Address)。
# S:线程的状态(State)
# - R 表示运行中(Running)
# - S 表示睡眠中(Sleeping)
# - D 表示不可中断睡眠(通常是等待 I/O)
# - Z 表示僵尸状态(Zombie),说明线程已终止但未被父进程回收。
# - T 表示停止状态(Stopped)。
# NAME:线程的名称。
内核线程和用户线程
介绍内核线程和用户线程之前,我们先了解下什么是内核空间和用户空间。Linux 操作系统会划分两个独立的运行空间:内核空间和用户空间。 用户的程序在用户空间运行,系统核心程序在内核空间运行。通过将用户程序和系统程序隔离开的目的是防止用户程序错误或者恶意攻击操作系统。
内核空间是操作系统内核运行的环境,是参与操作系统管理的重要组成部分。内核空间的程序具有最高的权限,能够直接访问硬件资源,比如内存、CPU、I/O 等等。
用户空间是应用程序运行的环境,是用户与操作系统交互的重要桥梁。用户空间的程序运行在受限的权限下,无法直接访问硬件资源,必须通过系统调用(System Call)向内核空间请求服务,例如内存分配、文件读写、网络通信等等。
在 Linux 操作系统中,线程根据管理和调度的责任方是谁,可以分成内核线程和用户线程。
内核线程在内核空间运行,由操作系统内核直接管理和调度的线程。内核线程的创建、销毁和切换由内核完成,开销较大。 每个内核线程都拥有独立的线程控制块(TCB),内核可以感知和控制所有内核线程。可以利用多核 CPU 实现真正的并行执行。
用户线程在用户空间运行,由用户空间的线程库进行管理和调度。相比内核线程,用户线程创建、销毁和切换由线程库完成,开销较小。用户线程无法利用调用操作系统的调度算法,所以可能导致某些线程长时间得不到 CPU 调度的情况。
一个物理 CPU 在同一时间最多只能运行一个线程。假设用户空间中的进程 1、进程 2 和进程 3 分别拥有可被调度的 4 个线程、3 个线程和 5 个线程,内核空间拥有 3 个进程,其中 1 个进程的优先级非常高。那么,在一台拥有 8 个 CPU 的设备上,操作系统会根据进程管理的调度算法决定哪些进程会被在 CPU 上执行:
由于设备只有 8 个 CPU 核,同一时间红色的线程表示得到了 CPU 调度的线程,那么剩下没有得到 CPU 调度的线程将继续等待,直到被 CPU 调度。
进程隔离
进程隔离是保护系统免受恶意攻击或错误操作影响的关键机制。它确保每个进程都运行在自己的独立地址空间中,彼此之间互不干扰。即使一个进程崩溃或出现错误,也不会影响其他进程的正常运行。
我们以上述例子中的用户空间的进程 1 和进程 2 举例。在进程创建时,操作系统会给进程 1 和进程 2 单独分配内存空间,原则上进程 1 和进程 2 的代码只能运行在各自的独立沙盒中,彼此互补干扰,各自相互独立。即使进程 1 崩溃或者出现错误 ,也不会影响到进程 2 的正常运行。
进程隔离的实现主要依赖于虚拟内存技术。虚拟内存技术为每个进程分配了一个看似连续且私有的内存空间。操作系统通过内存管理单元(MMU)将虚拟地址转换为物理地址,并检查访问权限。如果一个进程试图访问其他进程的内存空间,MMU 会阻止该操作并触发异常。虚拟内存技术不仅有效地隔离了不同进程数据和代码的影响,还为内存提供了可靠的保护机制,防止进程的错误操作导致操作系统崩溃。
线程的生命周期
线程的生命周期是指线程从创建到终止的整个过程,它描述了线程在其生命周期中可能经历的各种状态以及状态之间的转换关系。线程的生命周期通常包括以下几个阶段:
- 新建 (New):线程对象被创建,但尚未启动。
- 可运行 (Runnable):线程已经启动,并等待 CPU 时间片。
- 运行 (Running):线程正在 CPU 上执行。
- 阻塞 (Blocked):线程因为等待某个资源(例如 I/O 操作、锁等)而暂时停止执行。
- 等待 (Waiting):线程因为调用了
wait()
、join()
等方法而进入等待状态。 - 计时等待 (Timed Waiting):线程因为调用了
sleep()
、wait(timeout)
等方法而进入计时等待状态。 - 终止 (Terminated):线程已经执行完毕,或者被强制终止。
下图详细的描述了一个线程的完整生命周期:
Note
在一些文档中会看到进程还有一个僵尸态(ZOMBIE)。处于僵尸态的线程被称为僵尸线程,僵尸线程表示进程已经死亡,但是尚未被操作系统回收。
Note
Q:为什么我们平时都喜欢说线程的生命周期,而不是说进程的生命周期?
A:这是因为 CPU 实际调度的对象是线程而不是进程,也就是线程是 CPU 的执行对象,所以生命周期指的是 CPU 上运行的线程的生命周期。
优先级
Linux 线程优先级
线程调度
线程调度机制是操作系统的核心功能之一,它负责决定哪个线程可以获得 CPU 资源,以及每个线程可以运行多长时间。线程调度机制的设计直接影响着系统的性能、响应速度和资源利用率。一个好的线程调度机制应该在以下几个方面表现出色:
- 公平性 (Fairness):确保每个线程都能获得公平的 CPU 时间份额,避免某些线程长时间得不到 CPU 资源。使用公平的调度算法,例如完全公平调度器 (CFS),并根据线程的优先级和运行时间动态调整调度顺序。
- 响应速度 (Responsiveness):提高系统的响应速度,使用户操作得到及时处理。优先调度交互式线程,例如 GUI 线程,并减少线程切换的开销。
- 吞吐量 (Throughput):提高系统的吞吐量,使系统能够处理更多的任务。优化调度算法,减少线程切换的开销,并充分利用多核 CPU 的优势。
- 资源利用率 (Resource Utilization):提高 CPU、内存等资源的利用率。根据系统的负载情况动态调整线程的优先级和调度策略,并避免资源浪费。
- 可扩展性 (Scalability):能够支持大量的线程和 CPU 核心。使用高效的调度算法和数据结构,并避免锁竞争等问题。
为了能够实现上述目标的线程调度策略,常见的线程调度算法有如下几种:
- 先来先服务 (FCFS): 按照线程到达的顺序进行调度,简单易实现,但可能导致短任务等待时间过长。
- 短作业优先 (SJF): 优先调度预计运行时间较短的线程,可以缩短平均等待时间,但难以准确预测线程的运行时间。
- 时间片轮转 (RR): 每个线程轮流运行一个固定的时间片,适合交互式系统,但时间片大小的选择会影响系统性能。
- 优先级调度: 根据线程的优先级进行调度,优先级高的线程可以获得更多的 CPU 时间,但可能导致低优先级线程饥饿。
- 多级反馈队列: 结合了时间片轮转和优先级调度的优点,将线程分为多个优先级队列,并根据线程的运行情况动态调整其优先级。
通常,在 Linux 和 Android 系统中会采用更加复杂或者精细化的调度算法,甚至会人为通过配置的方式干预线程的运行。
优先级分类
在 Linux 操作系统中,可以按照线程的优先级将线程分成实时线程(Real-Time Thread)和普通线程(Normal Thread)。
- 实时线程:实时线程是具有高优先级的线程,用于处理需要快速响应和确定性的任务。实时线程可以抢占普通线程,确保关键任务能够及时获得 CPU 资源。
- 普通线程:普通线程是具有较低优先级的线程,用于处理一般的任务。普通线程不能抢占实时线程,必须等待实时线程主动释放 CPU 资源。通常,在用户空间的线程都是普通线程。
实时线程的调度策略一般为先进先出 FIFO(First In First Out) 或者时间片轮转 RR(Round Robin)。普通线程的调度策略一般为完全公平调度 CFS(Completely Fair Scheduler)。完全公平调度通过动态优先级来确保每个进程都能公平地获得CPU时间。普通进程对执行时效的要求相对较低。
优先级
线程优先级是操作系统调度线程的重要依据,它决定了线程获取 CPU 资源的顺序。优先级高的线程会优先获得 CPU 时间,从而提高其执行效率和响应速度。在 Linux 系统中,用户空间的线程优先级会通过计算映射到内核线程优先级,内核线程优先级的范围在 0 到 139,其中:
- 实时线程的优先级范围从 0 到 99。
- 普通线程的优先级范围从 100 到 139。
针对普通线程,Linux 提供了一个 Nice 值的概念。Nice 值的概念最早出现在 Unix 系统中,用于表示进程的“友好程度”(niceness)。 一个进程越“友好”(Nice 值越高),它对 CPU 资源的竞争就越温和,优先级越低;反之,Nice 值越低,进程的优先级越高。
Danger
Nice 值的概念仅用于表示用户空间中的普通进程,实时进程是没有 Nice 值这一说法的。
Nice 值的取值范围在 -20 到 +19。通常情况下,Nice 的默认值为 0。Nice 值越大,进程的优先级越低,意味着获取到 CPU 调度的机会越少。Nice
值越小,进程的优先级则越高,意味着被 CPU 调度的机会越大。一个 Nice 值为 -20 的线程优先级最高,一个 Nice 值为 +19
的进程优先级最低。Nice 值可以通过 ps
命令查询,以查询 Settings 的 Nice 值为例:
$ adb shell ps -A | grep com.android.settings
UID PID PPID C STIME TTY TIME CMD
system 1834 631 0 00:54 ? 00:00:04 com.android.settings
# UID:运行该进程的用户 ID。
# PID:进程 ID。
# PPID:父进程 ID(创建该进程的进程 ID)。
# C:CPU 使用率(百分比)。
# STIME:进程启动时间。
# TTY:与进程关联的终端(如果有)。
# TIME:进程占用 CPU 的总时间。
# CMD:启动进程的命令名称。
$ adb shell ps -l -p 1834
F S UID PID PPID C PRI NI BIT SZ WCHAN TTY TIME CMD
5 S 1000 1834 631 0 29 -10 64 1509890 do_ep+ ? 00:00:04 ndroid.settings
# PRI:进程的优先级(Priority),值越小优先级越高。
# NI:进程的 nice 值,用于调整优先级。
# BIT:进程的位数(32 位或 64 位)。
# SZ:进程占用的内存大小(以页为单位)。
可以看到 com.android.settings 进程号为 1834,Nice 值(NI)是 -10。在 NI 旁边有一个 PRI 字段,这个字段就是进程的优先级(Priority)。PRI 和 NI 的关系如下:
因为 Nice 值的范围是 [-20,+19],可得 PRI
的值在 [0,39]。由于 Linux
规定了实时线程和普通线程的优先级范围,普通线程优先级在 [100,139],所以还需要对 PRI 做一次计算得到静态优先级(Static
Priority):
因为 PRI 的范围是 [0,39],可得静态优先级的取值范围是 [100,139],正好与 Linux 线程优先级的范围一致。因此综上,可得:
也即:
针对用户空间的普通线程可以通过 renice
的方式修改线程的优先级:
# 获取线程的 pid 信息
adb shell "ps -A | grep settings"
system 1829 610 5431532 115052 do_freezer_trap 0 S com.android.setting
# 设置线程的 Nice 值
adb shell "renice -n 19 -p <pid>"
# 查询线程的 Nice 值
adb shell "ps -l -p <pid>"
F S UID PID PPID C PRI NI BIT SZ WCHAN TTY TIME CMD
5 S 1000 1829 610 0 39 -20 64 1357883 do_fr+ ? 00:00:02 ndroid.settings
Warning
Q:为什么修改 NI
后 PRI
没有立即变化?或者存在对应关系不一致的情况?
A:内核会根据系统负载和调度策略动态调整 PRI
,NI
只是影响因素之一,从而导致 NI
值和 PRI
的值存在是不是一一对应的关系。
Note
在 Linux 系统中,父进程通过fork()
系统调用创建的子进程会继承父进程的 Nice 值(即进程的静态优先级)。然而,Nice
值的继承是单向的,父进程后续通过renice
命令或setpriority()
系统调用修改自身的 Nice 值时,子进程的 Nice 值不会随之改变。这是因为每个进程的
Nice 值是独立的,子进程在创建后与父进程在调度优先级上完全解耦,父进程和子进程的优先级调整互不影响。
调度器
不同的调度器对优先级的处理方式不同,在 Linux 内核提供了多种调度器 (类) 来满足不同场景的需求,以下是几种常用的调度器:
调度器 | 名称 | 设计目标 | 实现原理 |
---|---|---|---|
SCHED_DEADLINE | 截止时间调度器 | 确保任务在指定的截止时间之前完成 | 使用 Earliest Deadline First (EDF) 算法,优先调度截止时间最早的任务 |
SCHED_NORMAL | 普通调度器( CFS 调度器的别名,用于调度普通线程 (SCHED_OTHER)。) | 公平地分配 CPU 时间给所有可运行的线程 | 使用虚拟运行时间 (vruntime) 来衡量线程的运行时间,并选择 vruntime 最小的线程运行。 |
SCHED_FIFO | 先进先出调度器(First In First Out) | 确保高优先级线程能够及时获得 CPU 资源 | 按照线程的优先级和到达顺序进行调度,优先级高的线程会一直运行,直到它主动放弃 CPU 或更高优先级的线程出现。 |
SCHED_RR | 轮转调度器(Round Robin) | 在保证高优先级线程响应速度的同时,提高公平性 | 按照线程的优先级和到达顺序进行调度,优先级高的线程会运行一个时间片,然后让给同优先级的其他线程。 |
SCHED_BATCH | 批处理调度器 | 提高批处理任务的吞吐量 | 低批处理任务的优先级,并延长其时间片,以减少上下文切换的开销。 |
SCHED_IDLE | 空闲调度器 | 在系统空闲时运行低优先级任务 | 只有当系统没有其他可运行的任务时,才会调度 SCHED_IDLE 任务。 |
通常情况下,实时线程的调度策略通常为 SCHED_FIFO 或 SCHED_RR,普通线程的调度策略通常为 SCHED_OTHER。在拥有 root 权限的
Android 设备上可以通过 chrt
命令查询线程的调度器:
$ adb shell ps -A | grep com.android.settings
UID PID PPID C STIME TTY TIME CMD
system 1834 631 0 00:54 ? 00:00:04 com.android.settings
$ chrt -p 1834
pid 1834's current scheduling policy: SCHED_OTHER
pid 1834's current scheduling priority: 0
Android 线程优先级
线程模型
Android 是一个基于 Linux 内核的操作系统,因此它的线程优先级和调度机制与 Linux 类似。然而,Android 在其应用框架层对线程管理进行了封装和优化,以适应移动设备的资源限制和应用场景。Android 应用通常由多个线程组成,主要包括:
- 主线程(UI 线程):负责处理用户界面(UI)更新和事件响应。如果主线程被阻塞,会导致应用卡顿甚至 ANR(Application Not Responding)。
- 工作线程(后台线程):用于执行耗时操作(如网络请求、文件读写、数据库操作等)。避免阻塞主线程,提升应用响应速度。
Android 提供了多种线程管理工具,比如带有消息循环的工作线程 HandlerThread、用于管理多个工作线程的 ThreadPoolExecutor、简化后台任务与 UI 更新交互的 AsyncTask 和 Kotlin 轻量级并发框架 Coroutine。
优先级
Android 线程优先级的范围与 Linux 一致,但 Android 提供了更高层次的 API Process.setThreadPriority()
来设置线程优先级:
// 设置当前线程的优先级
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
// 设置指定线程的优先级
Process.setThreadPriority(threadId, Process.THREAD_PRIORITY_BACKGROUND);
在 AOSP 中,定义了一些常用的优先级常量:
常量 | 说明 | nice 值 |
---|---|---|
THREAD_PRIORITY_DEFAULT | 默认优先级,通常用于主线程和普通工作线程。 | 0 |
THREAD_PRIORITY_LOWEST | 最低优先级,适合非常不紧急的后台任务。 | 19 |
THREAD_PRIORITY_BACKGROUND | 后台任务优先级,适合低优先级的后台任务(如日志记录、数据同步)。 | 10 |
THREAD_PRIORITY_FOREGROUND | 前台任务优先级,适合与用户交互相关的任务。 | -2 |
THREAD_PRIORITY_DISPLAY | 显示相关任务的优先级,适合与 UI 渲染相关的任务。 | -4 |
THREAD_PRIORITY_URGENT_DISPLAY | 紧急显示任务的优先级,适合高优先级的 UI 渲染任务。 | -8 |
THREAD_PRIORITY_AUDIO | 音频相关任务的优先级,适合音频播放和处理任务。 | -16 |
THREAD_PRIORITY_URGENT_AUDIO | 紧急音频任务的优先级,适合高优先级的音频任务。 | -19 |
THREAD_PRIORITY_MORE_FAVORABLE | 比当前优先级更优先(nice 值减 1)。 | 当前 nice - 1 |
THREAD_PRIORITY_LESS_FAVORABLE | 比当前优先级更低(nice 值加 1)。 | 当前 nice + 1 |
在实际的性能优化过程中,可以适当地提高对于用户可感知的线程优先级,例如:音频播放线程、动画渲染线程等。同时,也可以降低后台对于用户无感知或者感知较为不明显的线程,例如:数据同步、日志记录等。对于主线程的优先级不建议设置成很低的优先级,因为可能会引起 UI 界面的卡顿。