最早的计算机就像一个新手服务员,只有在接收到每一条指令时才会开始执行。当用户输入指令时,计算机会执行这条指令,然后等待下一条指令。如果用户在思考或者犹豫时,计算机就会乖乖地等待,效率实在是有点低,因为计算机有很多时间是闲着的。
批处理操作系统
后来,批处理操作系统出现了,它就像一个升级版的服务员,能够一次性接收一系列指令,并按顺序执行。这种系统允许用户将要执行的程序写入一盘磁带中,然后让计算机读取这些指令并执行,同时将结果写入另一盘磁带上。
虽然批处理操作系统在一定程度上提高了计算机的运行效率,但由于它仍然是串行处理的,内存中一次只能运行一个程序。后续的程序必须等到前一个程序完全执行完毕后才能开始执行。如果前一个程序因为I/O操作、网络请求等原因被阻塞,整个批处理的运行效率就会受到影响。因此,批处理操作系统的效率依然有限。
进程
随着科技的发展,人们对计算机性能的要求越来越高,现有的批处理操作系统已无法满足这些需求。批处理操作系统的瓶颈在于一次只能运行一个程序。
为了解决批处理操作系统的瓶颈,科学家们开发出了多任务操作系统,在多任务操作系统的诞生过程中,他们提出了一个新的概念——进程。
进程就是正在运行的程序。比如,当你启动一个 Java 程序时,实际上是启动了一个 Java 虚拟机进程。换句话说,一个正在运行的 Java 程序就是一个 Java 虚拟机进程。这个概念使得计算机能够同时运行多个程序,从而大大提高了计算机的效率和性能。
操作系统可以同时运行多个进程,比如 Chrome 浏览器、微信 等,它们之间互不干扰。每个进程都会保存自身的运行状态,操作系统能够高效地管理和切换这些进程。
为实现这一点,CPU 使用时间片轮转调度算法来运行进程。这个算法的基本原理是:CPU 为每个进程分配一个固定的时间段,称为时间片。进程在运行时如果分配给它的时间片用完了,该进程会被暂停,然后操作系统将 CPU 分配给另一个进程,这个过程称为上下文切换。如果在时间片用完之前,进程被阻塞,CPU 会立即进行切换,无需等待时间片用完。
举个例子,在一个多任务系统中,如果有三个进程A、B和C,假设时间片的大小为50毫秒。每个进程将轮流使用CPU,每次最多50毫秒。操作系统会按照以下顺序执行:
- 进程A运行50毫秒。
- 进程B运行50毫秒。
- 进程C运行50毫秒。
- 回到进程A,继续执行下一个时间片。
当一个进程被暂停时,操作系统会保存该进程的当前状态(进程控制块PCB)。下次切换回来时,操作系统会根据之前保存的状态恢复进程,继续执行。
虽然从宏观上看,操作系统似乎能够在同一时间段内执行多个任务,实际上,对于单核 CPU 来说,在任意时刻,只会有一个进程占用着 CPU 资源,这称之为并发,与并发这个词还有一个相似的概念叫做并行。
并发和并行
在计算机中,并发意味着多个任务在同一时间段内看似同时执行,但实际上它们是通过快速切换来共享 CPU 时间的。虽然每个任务并不是真正同时进行的,但如果切换得够快,给用户的感觉就像是多个任务同时在运行。
并发就像一个人(单核 CPU)在同时处理多件事情。想象你是一个服务员,负责照顾几张桌子上的客人。你可以走到第一桌,记下点的饮料,然后走到第二桌,记下点的主菜,再走到第三桌,记下甜点。虽然你在同一时刻只能服务一桌客人,但通过快速地在几桌之间切换,看起来好像你在同时为每桌提供服务。这就是并发。
并行意味着多个任务在不同的处理器(或 CPU 核心)上同时执行。因为每个任务都有自己独立的计算资源,所以它们是真正意义上地同时进行。
并行就像有多个服务员(多核 CPU)同时在不同的桌子上服务客人。每个服务员负责一张或几张桌子,他们可以真正同时接单、上菜、收盘子。这样,每张桌子都能独立得到服务,而不需要等待其他桌子的服务结束。这就是并行。
线程
尽管进程的引入显著提升了操作系统的性能,但随着时间的推移,人们希望单个进程能够处理更多的任务。如果一个进程中的多个子任务只能逐一执行,还是有很大的缺陷。
举个例子,当你使用浏览器浏览网页时,看到一个感兴趣的文件想要下载,如果在下载文件的过程中,浏览器无法继续加载其他网页,这显然是一个不好的用户体验。
为了解决这个问题,人们又引入了线程的概念。线程是进程中的一个执行单元,每个线程可以独立地完成一个子任务。这样,一个进程可以包含多个线程,如果需要,多个线程可以并发地执行。
例如,下面的 Java 代码展示了如何在主线程中启动两个新线程,每个线程负责执行不同的任务,一个线程负责打印 “hello world”,另一个线程负责打印 “I love you”:
class SayHelloThread extends Thread {
public void run() {
System.out.println("Hello world");
}
}
class SayLoveThread extends Thread {
public void run() {
System.out.println("I love you");
}
}
public class MultiThreadJavaApp {
public static void main(String[] args) throws InterruptedException {
SayHelloThread sayHelloThread = new SayHelloThread();
SayLoveThread sayLoveThread = new SayLoveThread();
sayHelloThread.start();
sayLoveThread.start();
//main thread sleep
Thread.sleep(5000);
}
}
//输出:
hello world
I love you
有趣的是,上面的程序每次运行时,输出的结果可能并不相同。这是因为 sayHelloThread 不一定总是能先获得执行的机会,具体哪个线程先执行,取决于操作系统的调度算法。
引入线程后,任务管理变得更加灵活和高效。当你在浏览器中下载文件时,可以由一个专门负责下载的线程来处理这个任务,而用户继续浏览网页时,浏览器则会运行另一个负责页面加载的线程。通过时间片轮转调度,操作系统可以在这些线程之间快速切换,从而让用户感觉下载和浏览网页的操作是在同时进行的。
进程和线程之间的区别
进程和线程的引入极大地提升了操作系统的性能,但它们之间到底有什么区别呢?
1. 资源占用
进程是操作系统分配资源的基本单位,包含程序执行的一个实例,包括代码、数据和系统资源(例如内存、文件、设备等)。每个进程都有独立的内存空间和系统资源,互不干扰。
线程是CPU调度的基本单位,多个线程共享同一个进程的内存空间和系统资源,但是每个线程拥有独立的栈、寄存器和程序计数器
2. 数据交换
进程之间是相互独立的,每个进程有自己的地址空间和系统资源。因此,进程之间的数据交换必须通过进程间通信(IPC)机制来实现,例如管道、消息队列、共享内存等,这种方式比较复杂。
线程是同一进程内的不同执行路径,共享同一个进程的内存空间和系统资源,因此线程之间的数据交换更加简单和快捷。
3. 开销
进程由于有独立的内存空间和系统资源,创建和销毁进程需要较大的开销,包括分配内存、加载程序、保存和恢复上下文信息等操作。
线程共享进程的内存空间和系统资源,创建和销毁线程的开销要小得多,仅需要保存和恢复少量的上下文信息,因此线程的切换成本较低。
4. 并发性
进程是独立的执行单元,拥有各自的调度算法,在并发执行时更加稳定可靠,因为一个进程的问题通常不会直接影响其他进程。
线程由于共享同一进程的资源,调度和同步相对复杂,需要仔细处理共享数据的并发访问,避免出现数据不一致或竞争条件等问题。这也是我们在后续文章中会深入讨论的一个关键点。
基于上面的区别,我们可以看到,在一个进程内实现多个任务的并发,最合适的方法是使用多线程,而不是多进程。不过,需要特别注意处理好并发逻辑,防止线程间的数据竞争和同步问题。
然而,这并不意味着多线程一定比多进程更优。在 Linux 系统中,创建进程的开销相对较小,因此 Linux 系统鼓励更多地使用多进程。但是,多进程的一个常见问题是进程间通信比较复杂和不方便,因此在 Linux 中,学习的重点之一就是掌握各种进程间通信(IPC)的方法。
简单来说,进程的引入使得操作系统可以实现多个程序的并发运行,而线程的引入则使得一个进程内部可以并发执行多个任务。
上下文切换
上下文切换(有时称为进程切换或任务切换)是指 CPU 从一个进程(或线程)切换到另一个进程(或线程)的过程。这里的“上下文”指的是 CPU 在某个时间点的所有状态信息,包括寄存器和程序计数器的内容。
寄存器是 CPU 内部少量的高速存储器,用于保存和访问运算过程中的中间值,从而提高计算速度。
程序计数器是一个专用的寄存器,用来指示 CPU 当前正在执行的指令(或将要执行的下一条指令)在指令序列中的位置。它存储的值可以是正在执行的指令的位置,也可以是下一条即将执行的指令位置,这取决于具体的系统实现。
CPU 通过为每个线程分配一定的时间片来实现多线程机制,并使用时间片轮转调度算法来执行任务。当一个线程执行完一个时间片后,CPU 就会切换到下一个线程。在切换前,CPU 会保存当前线程的状态信息,以便在将来切换回这个线程时能够恢复到之前的状态。
这个过程,也就是从保存当前任务状态到重新加载另一个任务状态的过程,就称为上下文切换。
假设当前线程 A 的时间片用完,需要切换到线程 B,操作系统会进行以下步骤:
- 暂停线程 A,将其在 CPU 中的状态(如寄存器、程序计数器等)保存到内存中。
- 从内存中加载线程 B 的上下文信息,并将这些信息恢复到 CPU 的寄存器中,以便执行线程 B。
- CPU根据程序计数器(此时保存的是将要执行的线程B的指令位置)的指示执行线程B的代码。
上下文切换通常是一个计算密集型操作,需要消耗大量的 CPU 时间。因此,线程数量增加并不总是能提高性能。如何减少系统中的上下文切换次数,是提升多线程性能的一个关键问题,我们将在后续的文章中进一步探讨。