Menu Close

什么是进程?操作系统如何让程序运行起来

你编译了一个 C 程序,然后在两个终端中分别运行它。两次运行使用的是同一个可执行文件,却各自拥有不同的身份、独立的变量,以及不同的执行进度。

操作系统如何把它们区分开?为什么其中一个程序等待输入时,另一个仍然可以继续运行?

理解这些问题,需要从操作系统的一个基本概念开始:进程

进程把保存在文件中的程序指令,与计算机正在执行的工作联系起来。理解进程,也能帮助我们理解内存保护、CPU 调度、系统调用和线程。

本文将结合一个简单的 C 程序,观察这些机制如何实际工作。你只需要具备基本的 C 语言知识,认识变量、函数和输入输出操作,就可以跟着完成实验。

什么是进程?操作系统如何让程序运行起来

1. 程序与进程不是一回事

程序是一组描述如何完成某项任务的指令。

编写 C 程序时,这些指令最初以源代码的形式存在。经过编译和链接,我们得到一个可执行文件,其中包含机器指令,以及启动程序所需要的其他信息。

当可执行文件保存在硬盘或固态硬盘上时,它并没有执行任务,只是一份存储起来的信息。

进程是程序的一次执行实例,以及支持这次执行所需要的内存、资源和执行状态。

可以把程序理解为一份菜谱,而进程是按照这份菜谱进行的一次做饭活动。做饭需要食材、操作空间,还需要记住当前进行到了哪一步。

同一份菜谱可以供多个人分别使用。同样,同一个可执行文件也可以产生多个进程

每个进程可以接收不同的输入、保存不同的数据,并在不同的时间结束。

另外,进程处于活动状态,并不意味着它每时每刻都在使用 CPU。它可能正在等待用户输入,也可能已经准备好执行,只是在等待操作系统分配 CPU 时间。

2. 一个应用程序可以使用多个进程

应用程序是为用户提供某种功能的软件,例如浏览器、文本编辑器或音乐播放器。

进程则是操作系统管理程序执行、资源和保护边界的重要单位。

这两个概念有关联,但一个应用程序不一定只对应一个进程

简单的应用程序可能主要在一个进程中完成工作。复杂的应用程序则可以把任务分配给多个相互配合的进程

例如,浏览器可以使用不同的进程处理网页内容、图形任务和其他服务。这样有助于隔离故障,并限制部分组件能够访问的资源。

不过,一个浏览器标签页不一定恰好对应一个进程,具体关系取决于浏览器的设计和网页正在执行的工作。

同样,打开一个新窗口,也不一定会创建新进程。已有进程可能负责管理这个窗口。

还有一些进程根本没有可见窗口。它们在后台处理打印、文件同步或网络服务等任务。

因此,屏幕上有多少个窗口,并不能说明系统中有多少个进程

3. 一个可以实际观察的 C 程序

下面用一个简单的 C 程序,观察进程的身份、变量地址和输入行为。

这个示例面向 Linux 环境。除了标准 C 输入输出函数,它还使用了 getpid(),用于获取当前进程进程标识符。

getpid() 属于 POSIX 接口,不是标准 C 函数。POSIX 是一组操作系统接口标准,Linux 和其他类 Unix 系统支持其中的许多接口。


程序首先定义整数变量 value,并将其初始化为 10。接着,它打印当前进程的 PID、变量的地址和初始值,然后等待用户输入一个新整数。

表达式 &value 用于获取变量地址。printf() 中的 %p 用于打印指针值,而 (void *)&value 将指针转换为 %p 所要求的类型。

fflush(stdout) 用于刷新标准输出,把尚未输出的数据从 C 库的缓冲区提交出去,避免程序已经开始等待输入,而提示文字仍然留在缓冲区中。

程序还检查了 scanf() 的返回值,确认它是否成功转换了一个整数。本实验请输入 25、80 这样的较小整数;这里的输入处理只是教学示例,并不是完整的通用输入验证方案。

将代码保存为 process_demo.c。在安装了 GCC 的 Linux 环境中,使用下面的命令编译:


运行生成的可执行文件:


先不要输入数字,让程序停在输入提示处。再打开另一个终端,进入同一目录,再次运行这个可执行文件。

现在,你已经从同一个程序启动了两个进程

4. 不同的进程 ID,独立的变量

两个终端都会显示一个 PID。PID 是 Process Identifier 的缩写,也就是进程标识符。

这两个同时存在的进程具有不同的 PID。即使它们运行的是同一个可执行文件,操作系统也可以通过这些标识符区分它们。

现在,在第一个终端输入 25。

第一个进程会修改自己的变量,打印结果,然后退出。第二个进程仍然停留在自己的输入提示处。

接着,在第二个终端输入 80。第二个进程会打印 80,然后退出。

源代码中只有一处 value 的声明,但每个进程都拥有自己的变量实例。修改其中一个,不会改变另一个。

当两个进程都在等待输入时,还可以在第三个终端中执行下面的命令。请将示例中的 PID 替换为程序实际打印的 PID:


ps 命令用于查看进程信息。这里显示的是进程标识符、状态信息和命令名称。

等待终端输入的进程,通常会显示以 S 开头的状态,表示可中断睡眠,也就是正在等待某个事件,并且可以响应某些信号。后面还可能附带表示其他属性的字符。

PID 标识的是某个具体进程,而不是永久绑定在可执行文件上的编号。再次运行程序时,新进程通常会获得不同的 PID。旧进程结束后,其 PID 也可能在以后被系统重新使用。

5. 每个进程都有自己的内存视图

观察两个进程打印的变量地址,你可能会发现它们不同。

现代操作系统通常会随机调整进程部分内存区域的位置。这种机制称为地址空间布局随机化,英文缩写为 ASLR。

但如果两个进程打印出的地址恰好相同,是否意味着它们正在访问同一个变量?

不是。相同的虚拟地址,并不能证明两个进程共享同一块内存。

虚拟地址需要在所属进程的虚拟地址空间中解释。地址空间可以理解为该执行环境中能够使用的一组内存地址。

操作系统与处理器硬件共同完成虚拟地址到物理内存位置的转换。参与这一转换的硬件称为内存管理单元,也就是 MMU。

两个进程使用相同的虚拟地址,最终仍然可以访问不同的物理内存。

这就像两栋楼里都有 201 号房间。房间号码相同,但由于所在的楼不同,它们并不是同一个位置。

类似地,解释一个内存地址时,还必须知道它属于哪个内存环境。

这套机制也支持内存保护。普通应用程序不能仅仅猜出一个地址,就随意读取或修改另一个进程的私有内存。

当然,进程也可以通过操作系统提供的机制,有意识地共享某些内存区域。操作系统还可能让多个进程共享只读的程序代码。

因此,独立地址空间并不意味着所有内容都必须在物理内存中复制一份,而是意味着每个进程拥有受控制的内存视图。

6. 一个进程还包含什么?

一个进程需要的不只是程序指令和一个变量。

它的内存可以包含程序代码、全局变量、堆,以及线程使用的栈。

全局变量在程序运行期间持续存在。堆用于支持动态内存分配,例如通过 malloc() 请求的内存。

栈用于记录尚未返回的函数调用及相关信息。每个线程都有自己的栈。在本例中,局部变量 value 通常会占用初始线程栈中的一部分空间。

不过,这些是常见的实现方式。C 语言本身并不要求每个变量必须放在某个固定的内存区域中,编译器也可以根据规则进行优化。

操作系统还会跟踪与进程有关的其他资源和属性,例如打开的文件、安全凭据,以及当前工作目录。

安全凭据帮助系统判断进程能够访问哪些资源。当前工作目录则是解释相对文件路径时的起点。

Linux 中,进程通常通过文件描述符引用打开的文件或其他输入输出资源。文件描述符是一个小整数,用于标识进程持有的某个资源引用。

正常从终端启动的程序,通常使用文件描述符 0、1、2,分别对应标准输入、标准输出和标准错误输出。

操作系统会维护内部记录来管理这些信息。教材通常使用“进程控制块”这个名称,英文缩写为 PCB。实际操作系统可能将相关信息分散保存在多个相互关联的数据结构中。

7. 一个进程是如何启动的?

当你在终端中输入 ./process_demo 时,Shell 会解释这条命令。

Shell 是接收命令并安排执行的程序。

对于普通的外部命令,Shell 通常会安排另一个进程执行指定程序。Unix 和 Linux 中具体如何创建进程,我们将在后续课程中展开。这里先关注程序启动前需要准备哪些条件。

系统需要建立程序的内存映射,使可执行代码能够被访问,并准备启动信息和初始执行环境。

内存映射描述虚拟内存区域与底层内存或其他后备存储之间的关系。

启动信息可以包括命令行参数和环境变量。命令行参数是启动程序时传入的值,例如需要打开的文件名。环境变量则是程序可以读取的一组命名设置。

如果可执行文件依赖共享库,相关启动软件还需要安排这些库可供程序使用。共享库包含可重复使用的代码,例如常见的输入输出功能。

随后,程序通过启动代码开始执行,并最终调用 main()

因此,操作系统不是直接跳进你编写的 C 源代码,而是与编译后的程序及其启动机制配合工作。

它也不一定要立即把整个可执行文件复制到物理内存中。许多系统会在程序需要访问相应部分时,再将这些内容调入内存。

8. 运行、就绪与等待

我们的程序会花一部分时间等待键盘输入。

在这段时间里,进程仍然存在。它的内存和资源引用继续保留,操作系统也保存着让它继续执行所需的信息。

但是,当输入尚未到达时,执行读取操作的线程通常会阻塞。

阻塞意味着执行暂时无法继续,必须等待某个事件发生。

理解这一行为,可以从三个基本状态开始:

  • 运行: CPU 正在执行它的指令。
  • 就绪: 已经具备继续执行的条件,但还在等待 CPU 时间。
  • 等待,也称阻塞: 必须等待某个事件发生,才能继续执行。

本例只有一个线程,因此可以用这些状态简单描述它的执行情况。

当线程等待输入时,操作系统可以安排其他任务运行,不需要持续执行我们的程序来检查是否有人输入了数字。

输入到达后,线程可以进入就绪状态。但它仍然需要被调度到 CPU 上,才能真正继续执行。

如果一个进程包含多个线程,不同线程可以处于不同状态。例如,一个线程等待输入,另一个线程继续进行计算。

因此,给整个进程标注一个状态,并不总能完整描述其内部所有活动。

9. 多个进程如何共享 CPU?

计算机中可以执行的线程数量,可能超过 CPU 同时能够执行的数量。

操作系统通过调度器决定下一个运行哪个就绪线程。

先考虑一个简化例子:系统只有一个 CPU 核心,并且该核心一次执行一个线程。CPU 核心是能够执行指令的处理单元。

一个线程运行一段时间后,可能因为等待事件而阻塞,也可能执行结束,或者被操作系统暂停,让其他就绪线程运行。

操作系统能够暂停正在运行的线程,并安排其他线程执行,这种机制称为抢占式调度。

它有助于防止普通的计算密集型应用程序长期独占处理器。

通过调度,多个任务可以在相互重叠的一段时间内持续取得进展。这叫作并发。

如果系统拥有多个 CPU 核心,不同线程还可以在同一时刻真正执行。这叫作并行。

并发强调多个任务在同一段时间内交替或共同推进;并行强调多个任务在同一时刻执行。

CPU 时间也不一定平均分配。线程优先级、工作负载特点和调度策略都会影响分配结果。

正在等待输入的线程,不会仅仅因为它仍然存在,就需要获得一次执行机会。调度器主要从已经准备好执行的工作中进行选择。

10. 上下文切换时发生了什么?

操作系统CPU 上正在执行的线程换成另一个线程时,就会发生上下文切换。

上下文是暂停执行后,能够在以后恢复工作所需要的一组执行信息。

其中包括 CPU 寄存器的值,以及接下来应该从哪个指令位置继续执行等信息。寄存器是处理器内部容量较小、访问速度很快的存储位置。

系统保存即将暂停的线程所需的状态,再恢复即将运行的线程的状态。

如果两个线程属于不同进程,系统还需要按要求切换当前使用的内存环境。

上下文切换通常不会复制整个进程的内存。其他线程运行期间,原进程的内存仍然保留。

因此,我们的 C 程序可以暂停一段时间,等到再次运行时,仍然保有原来的数据,并从正确的位置继续执行。

上下文切换也有成本。保存和恢复状态需要时间,切换工作负载还可能影响处理器缓存的使用效率。缓存保存近期使用的指令和数据,帮助 CPU 更快地访问它们。

另外,上下文切换与用户态、内核态之间的切换不是一回事。

用户态限制应用程序的权限;内核态允许操作系统核心执行具有更高权限的操作。

一个线程可以进入内核态请求服务,然后返回用户态,整个过程中并不一定切换到另一个线程。

11. 进程如何使用操作系统服务?

我们的 C 程序既执行普通计算,也执行输入输出操作。

修改变量属于普通程序执行,并不会因为一个数值发生变化,就自动需要一次系统调用。

但是,读取终端输入和向终端输出内容,需要使用操作系统管理的服务。

printf()scanf() 是 C 库函数,负责格式化输出、将输入文本转换为整数等工作。

在需要的时候,C 库会通过系统调用,请求内核完成底层操作。

系统调用是应用程序请求操作系统内核服务的一种受控制的入口。

一次库函数调用不一定对应一次系统调用。库可以使用缓冲区,暂时将数据保存在内存中,以便更高效地处理或传输。

例如,scanf() 可能需要执行底层读取操作。如果输入尚未到达,相关线程就可能阻塞,让其他线程获得运行机会。

进程也可以使用操作系统提供的机制相互通信,这称为进程间通信,英文缩写为 IPC。

管道是一种常见机制,可以把一个执行环境产生的数据传递给另一个进程。试着运行下面的命令:


左侧命令输出数字 25 和一个换行符。Shell 将这段输出通过管道连接到右侧程序的标准输入。

这样,我们的程序就会从管道中读取 25,而不需要等待用户手动输入。

其他通信方式包括套接字和共享内存。套接字提供通信端点,可以支持同一台计算机内或网络上的通信;共享内存则允许多个进程访问明确共享的内存区域。

进程隔离并不妨碍合作,而是让合作通过明确的机制进行。

12. 进程与线程有什么不同?

进程提供资源管理和保护环境,线程则在这个环境中执行一连串指令。

我们的示例只有一个线程,但更复杂的程序可以创建多个线程。

同一进程中的线程共享地址空间和许多资源。不过,每个线程仍然有自己的执行状态和栈。

例如,一个应用程序可以用一个线程响应用户操作,用另一个线程完成耗时计算。

由于它们处于同一地址空间中,只要程序作出相应安排,就可以访问同一份数据。

这与我们分别启动的两个进程不同。那两个进程各自拥有自己的 value 变量。

共享数据也会带来协调问题。如果多个线程在缺少适当同步的情况下访问和修改共享数据,结果可能取决于它们执行操作的先后时机,从而产生竞态条件。

同步机制的作用,就是协调这些访问,避免程序依赖不可控的执行时序。

不同进程之间通常具有更强的内存隔离边界,而同一进程中的线程更方便共享数据。

一个线程发生严重的内存错误,可能破坏同一进程中其他线程使用的数据。独立地址空间有助于限制这类破坏跨进程传播,但共享资源和相互依赖仍然可能使故障影响其他进程

使用多个进程还是多个线程,需要根据任务对共享、保护和协调的要求来决定。

13. 一个进程如何结束?

我们的程序成功打印结果后,会从 main() 返回 0。

这会启动 C 程序的正常终止流程,包括相应的标准库清理,随后进程退出。

操作系统回收进程的私有内存,并释放它持有的资源引用,例如打开文件的引用。

如果其他进程仍然使用某项共享资源,这项资源就可以继续存在。

进程还会提供退出状态,让其他软件判断它以什么结果结束。

Linux 中,进程退出后,会暂时保留少量记录,直到父进程收集其退出状态。这种状态称为僵尸进程。它已经不再执行,只是仍然保留着需要被回收的记录。

进程也可能因为错误,或者收到有权限的终止请求而结束。

需要注意的是,资源回收并不意味着未保存的工作一定能够恢复。如果文本编辑器崩溃,释放它占用的内存,并不能自动找回从未保存的文档。

但是,可执行文件仍然保存在存储设备中。再次运行它,就可以启动新的执行实例。

14. 总结

程序是一组存储起来的指令。进程则是程序的一次活动实例,以及支持这次执行所需要的内存、资源和执行状态。

通过一个简单的 C 程序,我们实际观察到了这种区别:在两个终端中运行同一个可执行文件,会得到两个具有不同 PID 的进程,它们各自拥有独立的变量。

每个进程都有自己的虚拟地址空间。修改一个进程中的变量,不会改变另一个进程中的变量。

我们也看到,进程可以继续存在,却不持续占用 CPU。等待输入时,操作系统可以运行其他任务;恢复执行时,程序仍然保有自己的数据和执行状态。

这些简单现象背后,是操作系统对内存保护、线程调度、资源管理和系统调用的共同组织。

进程提供执行环境,线程在其中执行指令。

接下来的问题是:一个正在运行的程序,能不能主动创建另一个进程

下一课,我们将介绍 Linux 中的 fork(),观察父进程与子进程如何产生,以及为什么它们都会从同一次函数调用之后继续执行。

Posted in 操作系统