Unix/Linux编程:程序与进程

进程

什么是进程

什么是程序?

  • 计算机实际上可以做的事情实质上非常简单,比如计算两个数的和,再比如在内存中寻找到某个地址等等。这些最基础的计算机动作被称为指令 (instruction)。
  • 所谓的程序(program),就是这样一系列指令的所构成的集合
  • 通过程序,我们可以让计算机完成复杂的操作。程序大多数时候被存储为可执行的文件。这样一个可执行文件就像是一个菜谱,计算机可以按照菜谱作出可口的饭菜。

那么,程序和进程(process)的区别又是什么呢?

  • 进程是程序的一个具体实现

    • 只有食谱没什么用,我们总要按照食谱的指点真正一步步实行,才能做出菜肴。
    • 进程是执行程序的过程,类似于按照食谱,真正去做菜的过程。
    • 同一个程序可以执行多次,每次都可以在内存中开辟独立的空间来装载,从而产生多个进程。不同的进程还可以拥有各自独立的IO接口。
    • 操作系统的一个重要功能就是为进程提供方便,比如说为进程分配内存空间,管理进程的相关信息等等,就好像是为我们准备好了一个精美的厨房。
  • 进程是由内核定义的抽象的实体,并为该实体分配用以执行程序的各项系统资源。从内核角度来看:

    • 进程由用户内存空间以及一系列内核数据结构组成
      • 用户内存空间包含了程序代码以及代码所用到的变量
      • 内核数据结构用于维护进行状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号(IDs)、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息
      • 执行程序时,内核会将程序代码载入虚拟内存,为程序变量分配空间,建立内核记账(bookkeeping)数据结构,以记录与进程有个的各种信息(比如,进程 ID、用户 ID、组 ID 以及终止状态等)。
    • 在内核看来,进程是一个实体,内核必须在它们之间共享各种计算机资源
      • 对于像内存这样的受限资源来说,内核一开始会为进程分配一定数量的资源,并且在进程的生命周期内,统筹该进程和整个系统对资源的需求,对这一分配进程调整
      • 程序终止时,内核会释放所有此类资源,供其他进程中心使用
      • 其他资源(比如CPU、网络带宽等)都属于可再生资源,但必须在所有进程间平等共享
  • 进程就是正在执行的程序实例。我们可以使用ps命令来查询正在运行的进程:

    • 比如$ps -eo pid,comm,cmd: (-e表示列出全部进程,-o pid,comm,cmd表示我们需要PID,COMMAND,CMD信息)
    • 每一行代表了一个进程。每一行又分为三列。
      • 第一列PID(process IDentity)是一个整数,每一个进程都有一个唯一的PID来代表自己的身份
      • 第二列COMMAND是这个进程的简称。
      • 第三列CMD是进程所对应的程序以及运行时所带的参数。
        • (第三列有一些由中括号[]括起来的。它们是内核的一部分功能,被打扮成进程的样子以方便操作系统管理。我们不必考虑它们。)
$ ps -eo pid,comm,cmd
   PID COMMAND         CMD
     1 systemd         /usr/lib/systemd/systemd --switched-root --system --deserialize 
     2 kthreadd        [kthreadd]
     4 kworker/0:0H    [kworker/0:0H]
     6 ksoftirqd/0     [ksoftirqd/0]
     7 migration/0     [migration/0]

进程的ID

  • 每一个进程都有一个唯一的整数型进程标识符PID
  • 每一个进程都有一个父进程标识符PPID属性,用以标识请求内核创建自己的进程
  • 系统管理器授权每个进程使用一个给定的UID。每个被启动的进程都有一个启动该进程的用户UID。子进程和父进程拥有一样的UID。没有可以是某个组的成员,每个组也有一个GID标识

进程的地址空间

  • 进程本质上是一个正在执行的程序。与每个进程相关的是进程的地址空间(也叫做core image)这是从某个最小值的存储位置(通常是)到某个最大值存储位置的列表。在这个地址空间中,进程可以进行读写

  • 该地址空间中存放有可执行程序、程序的数据以及程序的堆栈。与每个进程相关的还有资源集,通常包括寄存器(含有程序计数器和堆栈指针)、打开文件的清单、突出的报警、有关进程清单,以及运行该程序所需要的所有其他信息

进程的时间

进程涉及两种类型的时间

  • 真实时间:指的是在进程的生命期内(所经历的时间或时钟时间),以某个标准时间点(日历时间)或固定时间点(通常是进程的启动时间)为起点测量得出的时间。在UNIX 系统上,日历时间是以国际协调时间(简称 UTC)1970 年 1 月 1 日凌晨为起始点,按秒测量得出的时间,再进行时区调整(定义时区的基准点为穿过英格兰格林威治的经线)2。这一日期与 UNIX 系统的生日很接近,也被称为纪元(Epoch)。
  • 进程时间:也叫做CPU时间,指的是进程自启动以来,所占用的CPU时间总量。可进一步将CPU时间划分为系统CPU时间和用户CPU时间。前者是指在内核模式中,执行代码所花费的时间(比如,执行系统调用,或代表进程执行其他的内核服务)。后者是指在用户模式中,执行代码所花费的时间(比如,执行常规的程序代码)

time 命令会显示出真实时间、系统 CPU 时间,以及为执行管道中的多个进程而花费的用户 CPU 时间

进程表

  • 一个进程暂时被挂起之后,在随后的某个时间里,该进程再次启动时的状态必须与先前暂停时完全相同,这就意味着在挂起时该进程的所有信息都要保存下来,这样在进程重新启动之后,所执行的读调用才能读到正确的数据。
  • 在很多操作系统中,与一个进程有关的所有信息,除了该进程自身地址空间的内容以外,存放在操作系统中的一张表中,称为进程表
  • 进程表是数组或链表结构,当前存在的每个进程都要占用其中一项。
  • 所以,一个(挂起的)进程包括:进程的地址空间以及对应的进程表项

进程的优先级

  • 由于进程是独立执行的,因此它们执行的速度有快慢。

    • 特定情况下,进程有时会因为执行某些操作被“阻塞”或者“挂起”。
    • 为了避免正在等待的进程占用CPU,操作系统会阻塞该进程而运行其他进行继续执行。
    • 当等待的事情来了,就将进程“恢复”执行。
  • 有句话叫做“所有的进程并发执行”,但这种说法过于简单化。

    • 实际上,程序员会给每个进程分配一个“优先级”。
    • 操作系统给各个进程分配CPU使用权时参照各进程的优先级。其分配策略为:CPU对没有阻塞的进程赋予最高优先级。如果多个进程具有同样的优先级,那么CPU将在这几个进程之间快速切换。

alarm signal

  • 有时,需要向一个正在运行的进程传送信息,而该进程并没有等待接收信息。比如,一个进程通过网络向另一台机器上的进程发送信息进程通信。为了保证一条消息或者消息的应答不会丢失,发送者要求它所在的操作系统在指定的n秒之后给一个通知,这样如果对方尚未收到确认消息就可以进行重发。在设定该定时器之后,程序就可以做其他工作
  • 在指定秒数流逝之后,操作系统向该进程发送一个alarm gignal。此信号引起该进程暂时挂起,无论该进程正在做什么,系统将其寄存器的值保存到堆栈,并开始运行一个特别的信号处理过程,比如重发可能丢失的消息。这些信号是软件模拟的硬件中断,除了定时器到期之后,该信号可以由各种原因产生。很多由硬件进程出来的陷阱,比如执行了非法指令或者使用了无效地址等,也别转换成信号并交给这个进程




过滤器

从stdin读取输入,加以转换,再将转换后的数据输出到stdout,常常将拥有上诉行为的程序称为过滤器,比如cat、grep、tr、sort、wc、sed、awk等

C/C++编程:命令行参数解析

创建进程和执行程序

  • 实际上,当计算机开机的时候,内核(kernel)只建立了一个systemd进程。Linux内核并不提供直接建立新进程的系统调用。剩下的所有进程都是init进程通过fork机制建立的
    • fork是一个系统调用。每一个进程都可以使用fork来创建一个新子进程。调用fork()的进程被称为父进程,新创建的进程被称为子进程。
    • 新的进程要通过老的进程复制自身得到,这就是fork。
      • 进程存活于内存中。每个进程都在内存中分配有属于自己的一片空间 (address space)。当进程fork的时候,Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。
      • 内核通过对父进程的复制来创建子进程。子进程从父进程处创建数据段、栈段以及堆段的副本后,可以修改这些内容,不会影响父进程的“原版”内容。(在内存中被标记为只读的程序文本段则由父子进程共享)
      • 然后,子进程要么去执行与父进程共享代码段中的另一组不同函数,或者,使用系统调用execve()去加载病执行一个全新程序(更常见)。execve()会销毁现有的文本段、数据段、栈段以及堆段,并根据新程序的代码,创建新段来共享他们。
    • 一个进程除了有一个PID之外,还会有一个PPID(parent PID)来存储的父进程PID。如果我们循着PPID不断向上追溯的话,总会发现其源头是systemd进程。所以说,所有的进程也构成一个以systemd为根的树状结构。

如下,我们查询当前shell下的进程:

$  ps -o pid,ppid,cmd
   PID   PPID CMD
  9574   9566 bash
 10168   9574 ps -o pid,ppid,cmd

还可以用$pstree命令来显示整个进程树:

$ pstree
systemd─┬─ModemManager───2*[{ModemManager}]
        ├─NetworkManager───2*[{NetworkManager}]
        ├─VGAuthService
        ├─2*[abrt-watch-log]
        ├─abrtd
        ├─accounts-daemon───2*[{accounts-daemon}]
        ├─alsactl
        ├─at-spi-bus-laun─┬─dbus-daemon───{dbus-daemon}
        │                 └─3*[{at-spi-bus-laun}]
        ├─at-spi2-registr───2*[{at-spi2-registr}]
        ├─atd
        ├─auditd─┬─audispd─┬─sedispatch
        │        │         └─{audispd}
        │        └─{auditd}
        ├─avahi-daemon───avahi-daemon

fork通常作为一个函数被调用。这个函数会有两次返回,将子进程的PID返回给父进程,0返回给子进程。实际上,子进程总可以查询自己的PPID来知道自己的父进程是谁,这样,一对父进程和子进程就可以随时查询对方。

通常在调用fork函数之后,程序会设计一个if选择结构。

  • 当PID等于0时,说明该进程为子进程,那么让它执行某些指令,比如说使用exec库函数(library function)读取另一个程序文件,并在当前的进程空间执行 (这实际上是我们使用fork的一大目的: 为某一程序创建进程);
  • 而当PID为一个正整数时,说明为父进程,则执行另外一些指令。

由此,就可以在子进程建立之后,让它执行与父进程不同的功能。

进程终止和终止状态

可以使用下面两种方法之一来终止一个进程:

  • 进程可以使用_exit()系统调用(或者相关的exit()库函数),请求退出。
  • 向进程传递信号,将其“杀死”。

无论以何种方式退出,进程都会生成“终止状态”,一个非负小整数,可供父进程的wait()系统调用检测。

  • 在调用_exit()的情况下,进程会指明自己的终止状态。
  • 若由信号来“杀死”进程,则会根据导致进程“死亡”的信号类型来设置进程的终止状态。

根据惯例,终止状态为 0 表示进程“功成身退”,非 0 则表示有错误发生。大多数 shell 会将前一执行程序的终止状态保存于 shell 变量$?中

也就是说:

  • 当子进程终结时,它会通知父进程,并清空自己所占据的内存,并在内核里留下自己的退出信息(exit code,如果顺利运行,为0;如果有错误或异常状况,为>0的整数)。在这个信息里,会解释该进程为什么退出。
  • 父进程在得知子进程终结时,有责任对该子进程使用wait系统调用。这个wait函数能从内核中取出子进程的退出信息,并清空该信息在内核中所占据的空间。
  • 但是,如果父进程早于子进程终结,子进程就会成为一个孤儿(orphand)进程。孤儿进程会被过继给init进程,init进程也就成了该进程的父进程。init进程负责该子进程终结时调用wait函数。
  • 当然,一个糟糕的程序也完全可能造成子进程的退出信息滞留在内核中的状况(父进程不对子进程调用wait函数),这样的情况下,子进程成为僵尸(zombie)进程。当大量僵尸进程积累时,内存空间会被挤占

尽管在UNIX中,进程与线程是有联系但不同的两个东西,但在Linux中,线程只是一种特殊的进程。多个线程之间可以共享内存空间和IO接口。所以,进程是Linux程序的唯一的实现方式。

总结:
fork()/execve()过程中,假设子进程结束时父进程仍存在,而父进程fork()之前既没安装SIGCHLD信号处理函数调用waitpid()等待子进程结束,又没有显式忽略该信号,则子进程成为僵死进程,无法正常结束,此时即使是root身份kill -9也不能杀死僵死进程。补救办法是杀死僵尸进程的父进程(僵死进程的父进程必然存在),僵死进程成为”孤儿进程”,过继给1号进程init,init始终会负责清理僵死进程。

进程的创建:

  • 系统调用fork()通过复制一个与调用进程(父进程)几乎完全一样的拷贝来创建一个新进程(子进程)。系统调用vfork()是一种更为高效的fork()版本,不过因为其语义独特----vfork()产生的子进程将使用父进程内存,直到其调用exec()或者退出;与此同时,将会挂起(suspended)父进程,所以应该尽量避免使用

  • 调用fork()之后,不应对父、子进程获得调度以使用CPU的先后顺序有所依赖。对执行顺序做出假设的程序易于产生所谓"竞争条件"错误。由于此类错误的产生依赖于诸如系统负载之类的外部因素,故而其发现和调试将非常困难

  • 如同fork(),Linux特有的clone()系统调用也会创建一个新进程,但其对父子间的共享属性有更为精确的控制。该系统调用主要用于线程库的实现

  • fork()创建的子进程会从其父进程处继承(有时是共享)某些进程属性的副本,而对其他进程属性则不作继承。比如,子进程继承了父进程文件描述符和信号处置的副本,但并不进程父进程的间隔定时器、记录锁或者是挂起信号集合。相应地,进程执行 exec()时,某些进程属性保持不变,而会将其他属性重置为缺省值。例如,进程 ID 保持不变,文件描述符保持打开(除非设置了执行时关闭标志),间隔定时器得以保存,挂起信号依然挂起,不过会将对已处理信号的处置重置为默认设置,同时与共享内存段“脱钩”

进程的终止:

  • 进程的终止分为正常和异常两种。异常终止可能是由于某些信号引起的,其中一些信号还可能导致进程产生一个核心转储文件
  • 正常的终止可以通过调用_exit()完成,更多的情况下,则是使用_exit()的上层函数 exit()完成。_exit()和 exit()都需要一个整型参数,其低 8 位定义了进程的终止状态。依照惯例,状态 0用来表示进程成功完成,非 0 则表示异常退出。
  • 不管进程正常终止与否,内核都会执行多个清理步骤。调用exec()正常终止一个进程,将会引发经由 atexit()和 on_exit()注册的退出处理程序(执行顺序与注册顺序相反),同时刷新stdio缓冲区
  • 当打开进程记账功能时,内核会在系统中每一进程终止时将其账单记录写入一个文件。该记录包含进程使用资源的统计数据

监控子进程

  • 使用wait()和waitpid()以及其他相关函数,父进程可以得到其终止或者停止子进程的状态。该状态表明子进程是正常终止(带有表示成功或失败的退出状态),还是异常终止,因收到某个信号而停止,还是因收到到 SIGCONT 信号而恢复执行
  • 如果子进程的父进程终止,那么子进程将变为孤儿进程,并为进程ID为1的init进程接管
  • 子进程终止后会变为僵尸进程,仅当其父进程调用wait()族函数获取子进程退出状态时,才能将其从系统中删除。在设计长时间运行的程序,比如shell程序以及守护进程时,应该总是捕获其所创建子进程的状态,因为系统无法杀死僵尸进程,而未处理的僵尸进程最终将塞满内核进程表
  • 捕获终止子进程的一般方法是为信号SIGCHLD设置信号处理程序。当子进程终止时(或者子进程因信号而停止时),其父进程会收到SIGCHLD信号。还有一种一致性稍差的处理方法,进程可以选择将对SIGCHLD信号的处置置为忽略(SIG_IGN),这时将立即丢弃终止子进程的状态(因此其父进程从此也无法获取到这些信息),子进程也不会成为僵尸进程。

进程的用户和组标识符

每个进程都有一组与之相关的用户ID(UID)和组ID(GID),如下所示

  • 真实用户ID和组ID:用了标识进程所属的用户和组。新进程从其父进程处继承这些ID。登录shell则会从系统密码文件的相应字符中获取其真实用户ID和组ID
  • 有效用户ID和组ID:进程在访问受保护资源(比如文件和进程间通信对象)时,会使用这两个ID(结合补充组ID)来确定访问权限。一般情况下,进程的有效ID和相应的真实ID值相同。改变进程的有效ID实为一种机制,可使进程具有其他用户或组的权限
  • 补充组ID:用来标识进程所属的额外组。新进程从其父进程处继承补充组ID。登录shell则从系统组文件中获取其补充取ID

特权进程

  • 特权进程是指有效用户ID为0(超级用户)的进程。通常由内核所施加的权限限制对此进程无效
  • 非特权进程是指有效用户ID不为0(其他用户)的进程。必须遵守由内核所强加的权限规则

如何创建特权进程,有两种方法

  • 由root用户创建的进程
  • 利用set-user-ID机制。该机制允许某进程的有效用户ID等同于该进程所执行程序文件的用户ID

能力

从内核2.2开始,Linux把传统上赋予超级用户的权限划分为一组相互独立的单元(称之为“能力”)。

  • 每次特权操作都与特定的能力有关,仅当进程具有特定能力时,才能执行相应操作。
  • 赋予某进程部分能力,使得其既能执行某些特权级能力,又能防止其执行其他特权级能力
  • 能力的命名以CAP_为前缀,比如CAP_KILL

超级用户进程(有效用户ID为0)开启了所有能力

init进程

  • 系统引导时,内核会创建一个名为init的特殊进程,即"所有进程之父",该进程的相应程序文件为/sbin/init
  • 系统的所有进程不是有init(使用fork())“亲自”创建的,就是由其后代进程创建
  • init进程的进程号总是1,而且总是以超级用户权限运行。谁(哪怕是超级用户)都不能"杀死"init进程,只有关闭系统才能终止进程。
  • init的主要任务是超级病监控系统运行所需的一系列进程。手册页init(8)中包含了init进程的详细信息

守护进程

守护进程指的是具有特殊用户的进程,系统超级和处理此类进程的方式与其他进程相同。但以下特征是其所独有的

  • “长生不老”。守护进程通常在系统引导时启动,直到进程关闭前,会一直“健在”
  • 守护进程在后台运行,而且无控制终端供其读取或者写入数据

守护进程中的例子有 syslogd(在系统日志中记录消息)和 httpd(利用 HTTP 分发 Web 页面)。

环境列表

每个进程都有一份环境列表,即在进程用户空间内存中维护的一组环境变量。这份列表的每一个元素都由一个名称及其相关值组成

  • 由fork()创建的新进程,会继承父进程的环境副本。这也为父子进程间通信提供了一组机制。
  • 当进程调用exec族函数替换当前正在运行的程序时,新进程要么继承老程序的环境,要么在exec族函数调用的参数中指定新环境并加以接收

在绝大多数shell 中,可使用export 命令来创建环境变量(C shell 使用setenv 命令),如下所示

export MYVAP = "Hleoo world"

C 语言程序可使用外部变量(char **environ)来访问环境,而库函数也允许进程去获取或修改自己环境中的值

资源限制

每个进程都会消耗比如打开文件、内存以及CPU时间之类的资源。使用系统调用setrlimit(),进程可以为自己消耗的各类资源设定一个上限。此类资源限制的每一项都有两个相关值

  • 软限制(soft limit)限制了进程可以消耗的资源总量
  • 硬限制(hard limit)软限制的调整上限。

非特权进程在针对特定资源调整软限制值时,可将其设置为 0 到相应硬限制值之间的任意值,但硬限制值则只能调低,不能调高

由fork()创建的新进程,会继承其父进程对资源限制的设置

使用 ulimit 命令(在 C shell 中为 limit)可调整 shell 的资源限制。shell 为执行命令所创建的子进程会继承上述资源设置。

程序

计算机实际上可以做的事情实质上非常简单,比如计算两个数的和,再比如在内存中寻找到某个地址等等。这些最基础的计算机动作被称为指令 (instruction)。所谓的程序(program),就是这样一系列指令的所构成的集合。通过程序,我们可以让计算机完成复杂的操作。程序大多数时候被存储为可执行的文件。这样一个可执行文件就像是一个菜谱,计算机可以按照菜谱作出可口的饭菜。

  • 程序通常以两种面目示人。
    • 其一为源码形式写成的一系列语句组成,是人类可以阅读的文件文件。
    • 要想执行程序,则需要将源码转换为第二种形式----计算机可以理解的二进制语言指令(这与脚本形成了鲜明对照,脚本是包含命令的文本文件,可以由shell或者其他命令解释器之类的程序直接处理)
  • 一般认为,术语”程序“的上诉两种含义近乎相同,因为经过编译和链接处理,会将源码转换为语义相同的二进制机器码

程序是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,所包括的内容如下所示

  • 二进制格式标志:每个程序文件都包含用于描述可执行文件的元信息。内核(kernel)利用此信息来解释文件中的其他信息。历史上,Unix可执行文件曾有两种广泛使用的格式,分别为最初的 a.out(汇编程序输出)和更加复杂的 COFF(通用对象文件格式)。现在,大多数 UNIX 实现(包括 Linux)采用可执行
    连接格式(ELF),这一文件格式比老版本格式具有更多优点
  • 机器语言指令:对程序算法进行编码
  • 程序入口地址:标识程序开始执行时的起使指令位置
  • 数据:程序文件包含的变量初始值和程序使用的字面常量值
  • 符号表和重定位表:描述程序中函数和变量的位置以及名称。这些表格有多种用途,其中包括调试和运行时的符号解析(动态链接)
  • 共享库和动态链接信息:程序文件所包含的一些字段,列出了程序运行时需要使用的共享库,以及加载共享库的动态链接器的路径名。
  • 其他信息:程序文件还包含许多其他信息,用以描述如何创建进程。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值