"> "> 操作系统-进程与线程 | Yufei Luo's Blog

操作系统-进程与线程

进程

定义

进程(process)就是操作系统为正在运行的程序提供的抽象,一个进程就对应一个正在运行的程序。一般来说,进程是处于运行期的程序和相关资源(内存、寄存器和持久存储设备)的总称。在计算机系统中,进程是资源分配的最小单位。

一个进程需要具备如下这些要素:

  • 拥有一段可执行程序代码
  • 在系统内存(内核)区域中,拥有一段该进程专用的系统堆栈空间
  • 在系统中有自己的进程描述符(task_struct),用于描述这个进程的相关信息
  • 有一段进程专属的内存空间,其它进程不能使用

值得一提的是,在Linux系统中,如果缺少最后一条,则相当于是线程。

进程API

所有的操作系统都需要以某种形式提供如下进程的API:

  • 创建:操作系统必须包含一些创建新进程的方法。
  • 销毁:系统提供一个强制销毁进程的接口。
  • 等待:有时等待进程停止运行是有用的,因此提供了等待接口。
  • 其他控制:包括进程的暂停与恢复等。
  • 状态:获取有关进程的状态信息。

fork()系统调用

操作系统提供的fork()函数被用于创建新的进程。在一个进程中调用fork()函数时,新创建的进程几乎与调用进程完全一样,对于操作系统来说,此时看起来有两个完全一样的程序在运行,并且都从fork()系统调用中返回。新创建的进程叫子进程(child),原来的进程被叫做父进程(parent)。

对于子进程来说,它拥有自己的地址空间(私有内存)、寄存器、程序计数器等。子进程不会从main()函数开始执行,而是直接从fork()系统调用中返回,并执行接下来的代码。需要注意的是,父进程与子进程从fork()函数返回的值是不同的,父进程获得的返回值是新创建子进程的PID(Process identifier,进程描述符),而子进程获得的返回值是0。

子进程被创建之后,它与父进程的执行顺序是不确定的。CPU调度程序决定了某个时刻哪个进程会被执行。

wait()系统调用

操作系统提供的wait()函数用于进程的延迟执行。当父进程调用wait()函数时,它会延迟自己的执行直到子进程执行完毕。当子进程执行完毕之后,wait()返回父进程。

exec()系统调用

exec()函数的输入参数为可执行程序的名称以及需要的参数,在调用这一函数之后,exec()便会从可执行程序中加载代码和静态数据,并用它覆写自己的代码段以及静态数据,堆、栈及其他内存空间也会被重新初始化。之后,操作系统便执行该程序,将程序执行时需要的参数通过exec()函数的参数传递给它。对exec()的成功调用永远不会返回。

因此,exec()函数并未创建新进程,而是直接将当前运行的程序替换为不同的运行程序。也就是说,在执行完exec()之后,几乎就像exec()执行之前的程序从来没有运行过一样。

讨论:fork()与exec()分离的原因

将fork()与exec()分离的做法在构建UNIX shell的时候非常有用。UNIX shell是一个用户程序,它显示一个提示符等待用户输入。可以向它输入一个命令(即一个可执行程序的名称以及其需要的参数),shell会在文件系统中找到这个可执行程序,调用fork()创建新进程,并调用exec()的某个变体来执行这个可执行程序,调用wait()等待该命令完成。子进程执行结束之后,shell从wait()返回并输出一个提示符,等待输入下一条命令。将二者分开的做法给了shell在fork之后exec之前运行代码的机会。这些代码可以在运行新程序之前改变环境,从而实现一些特殊的功能。例:

  • shell通过在调用exec()之前关闭标准输出并打开文件,实现输出的重定向。
  • UNIX管道是通过将进程的输出链接到一个内核管道上(队列),同时将另一个进程的输入也链接到这条管道上。这样可以将命令串联在一起共同完成某项任务。

其他API

UNIX中还有其他许多与进程交互的方式。比如kill()系统调用可以向进程发出信号,整个信号子系统提供了一套丰富的向进程传递外部事件的途径,包括接受与执行这些信号。

进程的生命周期

进程创建

导致进程创建的原因通常有:

  • 当启动操作系统时,通常会在操作系统中创建很多进程,有些进程用于与用户的交互,有些进程作为守护进程工作在后台
  • 正在运行的程序执行了创建进程的系统调用
  • 用户请求创建一个新的进程
  • 用于完成新的批处理作业

操作系统创建一个新进程的步骤如下:

  1. 给新进程分配一个唯一的进程标识符,在主进程表中增加一个新的表项
  2. 给进程分配资源:
    1. 首先要将代码和所有静态数据(如初始化变量)加载到内存中。程序最初以某种可执行格式被存放于磁盘当中,因此需要操作系统从磁盘中读取这些数据并放在内存中的某处。在现代操作系统中,仅在程序执行期间加载需要被加载的代码和数据片段。
    2. 在此之后,操作系统会为程序的运行时栈(runtime stack)分配内存。在C语言程序中,程序使用栈来存放局部变量、函数参数和返回地址。操作系统也可能会使用参数来初始化栈,对应于C语言中main()函数的参数argcargv
    3. 操作系统可能也会为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据,使用malloc()函数申请得到的空间就分布在堆上,之后可以通过调用free()函数来释放掉这一部分空间。
  3. 初始化进程信息:包括进程标识符、处理器状态信息、程序计数器、进程状态、文件描述符等
  4. 将新进程插入到进程队列,等待被调度运行

通过上述步骤,操作系统为程序执行完成了所有的准备工作。接下来要启动程序时,从程序的入口处运行(即main()函数),OS便将CPU的控制权转移到这一进程,从而程序开始执行。

进程状态

一个进程可以处于如下的三种状态之一:

  • 运行(running):此时进程正在处理器上运行,意味着它正在执行指令。
  • 就绪(ready):进行已经准备好运行,但是由于某种原因,操作系统不在此时运行它。
  • 阻塞(blocked):一个进程执行了某种操作,直到发生了其他事件时才准备运行。比如一个进程向磁盘发起I/O请求时,它便会被阻塞,其他进程可以使用处理器。

进程终止

导致进程终止的原因有很多,包括:

  • 正常完成:进程完成工作正常终止,自行调用exit()函数,表示它已经结束运行
  • 异常结束:进程在运行时发生了某些异常事件,使得程序无法继续运行,如栈溢出、超过时限、内存访问越界、算数错误、试图访问受保护的资源、试图执行特权指令等
  • 外界干预:进程应外界的请求而终止运行,例如操作系统干预、父进程请求和父进程终止等

进程终止之后,会返回状态值到父进程,所有进程资源,包括物理和虚拟内存、打开文件和I/O缓冲区等将会由操作系统释放掉。

进程间通信

进程间的通信方式包括:

  • 管道及有名管道:管道可用于父子进程之间的通信;有名管道除了具有管道所具有的功能之外,还允许无亲缘关系进程之间的通信
  • 信号:信号是在软件层次上对中断机制的模拟,用于通知进程有某事件发生
  • 消息队列:它是消息的链接表,具有写权限的进程可以按照一定规则向消息队列中添加新的信息;而具有读权限的进程可以从消息队列中读取信息
  • 共享内存:多个进程可以访问同一块内存空间,这种方式需要依靠一些同步操作来保证数据读写的正确性,如互斥锁、信号量等
  • 信号量:用于同步和互斥的手段
  • 套接字:更为一般的进程间通信机制,而且可以用于网络中不同机器的进程间通信

Linux的特殊进程

在Linux系统下,有一些比较特殊的进程:

  • 特权进程:用户ID为0的进程,即root用户的进程。通常由内核施加的权限限制对这类进程无效。
  • init进程:操作系统引导时,内核会创建一个名为init的特殊进程,即”所有进程之父“。系统的所有进程都是通过它与它的后代进程创建。这一进程的进程号为1,且总以root权限运行,任何用户(包括root)都不能杀死该进程。
  • 守护进程:具有特殊用途的线程,通常在系统引导时启动,且在系统关闭前一直运行。它会一直在后台运行,且无控制终端供其读取或写入数据。

线程

概念

线程(thread)是操作系统能够进行CPU运算调度的最小单位,它被包含在进程之中,是进程的实际运作单位。对于一个进程来说,它可以有一个或多个线程,多线程的程序拥有多个执行点,同时它们共享地址空间。在进程的地址空间中,每个线程都会有一块自己的栈空间。因此也可以这样理解,多线程中的每个线程类似于独立的进程,只是这些线程共享地址空间,从而能够访问相同的数据。

下图为一个包含了4个线程的进程所对应的虚拟内存示意图:

img

线程之间的上下文切换类似于进程之间的上下文切换,每个线程都有它自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID, TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。但是线程的切换比起进程的切换更加轻量化一些,因此也会比进程的切换快得多。

备注—一个进程与线程的简单例子:

在计算机中,我们打开QQ,便相当于打开了一个进程,如果打开多个QQ并使用不同的账号登录,这对应的是多个不同的进程;而在登录的其中一个QQ账号中,打开一个聊天窗口便对应于一个线程,一个账号中可以打开多个聊天窗口便是多线程。

线程的主要优点在于,协同线程之间的数据共享更加容易,而且某些算法通过多线程来实现比多进程更加自然。此外,多线程应用也能够充分利用多处理器的并行处理能力。

线程API

线程创建

创建一个线程用到的函数为:

1
2
3
4
5
6
#include<pthread.h>
int pthread_create(pthread_t* thread, //指向pthread_t结构类型的指针,用于和线程的交互
const pthread_attr_t* attr, //指定该线程可能据有的任何属性
void* (*start_routine)(void*), //说明这个线程在哪一个函数中运行
void* arg //传递给线程开始执行的函数的参数,如果不需要参数则传入NULL即可
);

线程完成

如果要等待线程完成,则需要调用函数pthread_join()。这一函数接受两个参数,第一个是pthread_t类型,用于指定要等待的线程;第二个参数是一个指向void的指针,指向希望得到的返回值,如果不需要返回值则传入NULL即可。

需要注意的是,必须非常小心地从线程中返回值,尤其是不要返回一个指针并让它指向线程调用栈上分配的东西。

Linux线程

在Linux内核中,其实没有线程的概念,它把所有的线程当成标准的进程来实现。也就是说,线程仅仅是一个与其它进程共享某些资源的进程。

Linux内核用task_struct这种数据结构代表传统意义上的进程、线程,而不是为二者分别定义数据结构。如果一个进程有多个线程,就用多个task_struct来表示,其中每一个有各自的pid字段,但是有相同的tgid字段,且其中的某些数据区域共享。而在用户态所看到的pid字段其实是内核态的tgid字段。

线程通信

线程之间可以通过共享的全局变量进行通信,借助于线程API提供的条件变量和互斥机制,同一进程所属的线程之间可以相互通信并同步行为。此外,利用进程间通信的方式,线程之间也可以彼此通信。

参考

  1. Operating Systems: Three Easy Pieces
  2. 操作系统:精髓与设计原理
  3. Linux/UNIX系统编程手册
  4. 【linux】19个面试常见的进程和线程问题 - 知乎 (zhihu.com)
  5. 进程和线程之间有什么根本性的区别? - 知乎 (zhihu.com)
  6. Linux内核初探:进程与线程 - 知乎 (zhihu.com)