Linux干货 | 进程编程基础知识总结

一、进程概述

1、进程概念

程序:磁盘上的可执行文件, 并且只占用磁盘上的空间,是一个静态的概念。

进程(Process):被执行之后的程序叫做进程,不占用磁盘空间,需要消耗系统的内存,CPU资源,每个运行的进程的都对应一个属于自己的虚拟地址空间,这是一个动态的概念。同一个程序可能生成多个进程。

我们所说的程序通常是指可执行程序,它本质上就是一个文件,当我们要运行这个程序的时候,我们会将其加载到内存中:

Linux干货 | 进程编程基础知识总结

图1 进程加载到内存示意图

在加载到内存中之后,操作系统会为该程序建立一个PCB来存储该程序中的信息,PCB与程序的总体就称为进程。

准确的来讲:进程=程序文件内容+与进程相关的数据结构(PCB);即用红色部分圈起来的整体表示一个进程。

操作系统对进程的管理不是直接对程序文件进行操作,而是通过PCB(管理者与被管理者不接触的原则)进行管理,因此PCB中一定存在能找到该程序文件的信息,程序文件包括程序代码和数据。在内存中不可能只存在一个进程,对于不同进程的PCB,操作系统会使用一定的数据结构进行连接,这就是再组织的过程,注意连接的是PCB而不是程序文件。

Linux干货 | 进程编程基础知识总结

图2 PCB链表

2、并发和并行

1)CPU时间片

CPU在某个时间点只能处理一个任务,但当前的操作系统基本都支持多任务的,那么计算机在CPU只有一个的情况下是怎么完成多任务处理的呢?想必解决这个问题的西方人到访过古老的东方,见识到了古代中国救济灾民的手段,即赈灾时会给每个灾民分一点点粮食,不至于饿死但又不能吃饱。想必西方人一定是基于此思路,想出了CPU时间片的方法。当然,这只是个玩笑,不过也有这个可能,谁知道呢。

CPU会给每个进程被分配一个时间片,进程得到这个时间片之后才可以运行,使各个程序从表面上看是同时进行的。在时间片结束时即使进程还在运行,CPU的使用权也将会被收回,该进程将会被中断挂起等待下一个时间片。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换,这样就可以避免CPU资源的浪费。

因此可以得知,在我们使用的计算机中启动的多个程序,从宏观上看是同时运行的,从微观上看由于CPU一次只能处理一个进程,所有它们是轮流执行的,只不过切换速度太快,我们感觉不到罢了,因此CPU的核数越多计算机的处理效率越高。

2)并发和并行

这两个概念都可以笼统的解释为多个进程同时运行,但是他们两个的同时并不是同一个概念。

A. 并发(Concurrency)

并发的同时运行是一个假象,CPU在某一个时间点只能为某一个任务来服务,不可能同时处理多任务,现实中我们看到的同时处理多任务,是广义上的同时处理,这是通过计算机的CPU快速的时间片切换实现的。

计算机在运行过程中,有很多指令会涉及 I/O 操作,而 I/O 操作又是相当耗时的,速度远远低于 CPU,这导致 CPU 经常处于空闲状态,只能等待 I/O 操作完成后才能继续执行后面的指令。为了提高 CPU 利用率,减少等待时间,人们提出了一种 CPU 并发工作的理论。

所谓并发,就是通过一种算法将 CPU 资源合理地分配给多个任务,当一个任务执行 I/O 操作时,CPU 可以转而执行其它的任务,等到 I/O 操作完成以后,或者新的任务遇到 I/O 操作时,CPU 再回到原来的任务继续执行。

下图展示了单核CPU上两个任务并发执行的过程:

Linux干货 | 进程编程基础知识总结

图3:单核 CPU 执行两个任务

虽然 CPU 在同一时刻只能执行一个任务,但是通过将 CPU 的使用权在恰当的时机分配给不同的任务,使得多个任务在视觉上看起来是一起执行的。CPU 的执行速度极快,多任务切换的时间也极短,用户根本感受不到,所以并发执行看起来才跟真的一样。

拓展】操作系统负责将有限的 CPU 资源分配给不同的任务,但是不同操作系统的分配方式不太一样,常见的有:

  • 当检测到正在执行的任务进行 I/O 操作时,就将 CPU 资源分配给其它任务。
  • CPU 时间平均分配给各个任务,每个任务都可以获得 CPU 的使用权。在给定的时间内,即使任务没有执行完成,也要将 CPU 资源分配给其它任务,该任务需要等待下次分配 CPU 使用权后再继续执行。

CPU 资源合理地分配给多个任务共同使用,能有效避免 CPU 被某个任务长期霸占的问题,可以极大地提升 CPU 资源利用率。

B. 并行(Parallelism)

并发是针对单核 CPU 提出的,而并行则是针对多核 CPU 提出的。和单核 CPU 不同,多核 CPU 真正实现了同时执行多个任务,可以在同一时刻同时运行多个进程;并行需要依赖多个硬件资源,单个是无法实现的;并行可以理解为是一个高富帅,出生就有天然的硬件优势,资源多自然办事效率就高。

注意:多核 CPU 内部集成了多个计算核心(Core),每个核心相当于一个简单的 CPU,如果不计较细节,你可以认为给计算机安装了多个独立的 CPU

多核 CPU 的每个核心都可以独立地执行一个任务,而且多个核心之间不会相互干扰。在不同核心上执行的多个任务,是真正地同时运行,这种状态就叫做并行。

例如,同样是执行两个任务,双核 CPU 的工作状态如下图所示:

Linux干货 | 进程编程基础知识总结

图4:双核 CPU 执行两个任务

双核 CPU 执行两个任务时,每个核心各自执行一个任务,和单核 CPU 在两个任务之间不断切换相比,它的执行效率更高。

C. 并发+并行

上面图 4 中,执行任务的数量恰好等于 CPU 核心的数量,是一种理想状态。但是在实际场景中,处于运行状态的任务是非常多的,尤其是电脑和手机,开机就几十个任务,而 CPU 往往只有 4 核、8 核或者 16 核,远低于任务的数量,这个时候就会同时存在并发和并行两种情况:所有核心都要并行工作,并且每个核心还要并发工作。

例如一个双核 CPU 要执行四个任务,它的工作状态如下图所示:

Linux干货 | 进程编程基础知识总结

图5 并发和并行

每个核心并发执行两个任务,两个核心并行的话就能执行四个任务。当然也可以一个核心执行一个任务,另一个核心并发执行三个任务,这跟操作系统的分配方式,以及每个任务的工作状态有关系。

并发针对单核 CPU 而言,它指的是 CPU 交替执行不同任务的能力;并行针对多核 CPU 而言,它指的是多个核心同时执行多个任务的能力。单核 CPU 只能并发,无法并行;换句话说,并行只可能发生在多核 CPU 中。

在多核 CPU 中,并发和并行一般都会同时存在,它们都是提高 CPU 处理任务能力的重要手段。

二、进程的状态

进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。通常情况下分为三态、五态、七态三种状态模型。

1、进程状态模型

1)三态模型

进程状态分为运行态,就绪态,阻塞态(等待态)。

Linux干货 | 进程编程基础知识总结

图6 三态模型

  • 运行(running)态:进程占有处理器正在运行的状态

    进程已获得CPU,其程序正在执行。在单处理机系统中,只有一个进程处于执行状态;在多处理机系统中,则有多个进程处于执行状态。

  • 就绪(ready)态:进程具备运行条件,等待系统分配处理器以便运行的状态

    当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行,进程这时的状态称为就绪状态。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列或运行队列(runqueue)。

    进程想要在CPU上运行起来,就必须加入到就绪队列(或运行队列) 中,这个就绪队列是 CPU 为管理进程而产生的,是内核给 CPU 准备的。进程加入到就绪队列中,是 task_struct 结构体(也就是PCB),并不是让可执行程序去排队;就绪队列里的进程随时随刻都要准本好,让 CPU 随时调度运行,此时在就绪队列里的一个个进程就叫做TASK_RUNNING(可执行状态)。

    注意:可执行状态,包括就绪和正在CPU上执行。只有在该状态的进程才可能在 CPU上运行。多核平台上同一时刻可能有多个进程处于可执行状态,这些进程的 task_struct 结构(进程控制块)被挂入对应CPU的运行队列(或就绪队列)中,一个进程最多只能出现在一个CPU的运行队列中。很多操作系统教科书将正在CPU上执行的进程定义为RUNNING状态,而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在Linux下统一为TASK_RUNNING状态,对应的状态编码数值为0。用 ps 命令或/proc/PID/status 查看进程时,可执行状态的进程显示为R

  • 等待(wait)态:又称阻塞态或睡眠态,指进程不具备运行条件,正在等待某个事件完成的状态

    也称为等待或睡眠状态,一个进程正在等待某一事件发生(例如请求I/O而等待I/O完成等)而暂时停止运行,这时即使把处理器分配给进程也无法运行,故称该进程处于阻塞状态。

    如进程需要访问外部设备(显示屏、磁盘、键盘…),但是发现外部设备正在被其他进程使用,因此只能等待。操作系统对于每个外设会管理专门的结构体且每个结构体都有一个等待队列(wait_queue),这些结构体会对访问外设的进程进行管理,一旦外设已被其他进程占用,则欲访问该外设的当前进程就会加入到等待队列,变成阻塞状态,等待资源。

    注意:进程加入等待队列是将该进程的PCB结构体对象放到队列中,并不是可执行程序本身。

    举个栗子:比如你要找工作了,你需要把你的简历投到公司的邮箱里或者某聘上,而不是把你自己投到邮箱里。公司有自己的简历池,假设有几千份简历,HR 对这些投递的简历进行排序,HR 觉得你的简历不错,在简历池中选出你的简历进行面试,HR 再把你的简历扔给面试官。你的简历上有你的电话、姓名、邮箱…等等,通过这个简历里面的方法可以找到你这个人。这个面试官相当于 CPUHR 就相当于操作系统的调度算法,你的简历就相当于 PCB 结构体 task_struct。面试官拿到的是你的简历,简历上有你的全部数据和属性,通过简历上的属性可以找到你这个人,PCB 结构体 task_struct 也是如此,它有进程的全部数据和属性,通过该进程的属性可以找到该进程所对应的代码和数据等等。

    外设忙完了,发现进程在等待它,此时外设是空闲的,外设就会告诉操作系统,进程可以运行了,然后操作系统就会调用进程,把进程的阻塞状态改为运行状态,然后放入 CPU的运行队列里,进程就等待CPU运行。

解析:引起进程状态转换的可能原因如下: 运行态→等待态:等待使用资源;如等待外设传输;等待人工干预。等待态→就绪态:资源得到满足;如外设传输结束;人工干预完成。运行态→就绪态:运行时间片到;出现有更高优先权进程。就绪态—→运行态:CPU 空闲时选择一个就绪进程。

2)五态模型

进程状态分为新建态、终止态,运行态,就绪态,阻塞态(等待态)。五态模型在三态模型的基础上增加了新建态(new)和终止态(exit)。

Linux干货 | 进程编程基础知识总结

图7 五态模型

  • 新建态:对应于进程被创建时的状态,尚未进入就绪队列

    创建一个进程需要通过两个步骤:

    1. 为新进程分配所需要资源和建立必要的管理信息。

    2. 设置该进程为就绪态,并等待被调度执行。

  • 终止态:指进程完成任务到达正常结束点,或出现无法克服的错误而异常终止,或被操作系统及有终止权的进程所终止时所处的状态

    处于终止态的进程不再被调度执行,下一步将被系统撤销,最终从系统中消失。

    终止一个进程需要两个步骤:

    1. 先等待操作系统或相关的进程进行善后处理(如抽取信息)。

    2. 然后回收占用的资源并被系统删除。

解析:引起进程状态转换的可能原因如下NULL→新建态:执行一个程序,创建一个子进程。新建态→就绪态:当操作系统完成了进程创建的必要操作,并且当前系统的性能和虚拟内存的容量均允许。运行态→终止态:当一个进程到达了自然结束点,或是出现了无法克服的错误,或是被操作系统所终结,或是被其他有终止权的进程所终结。运行态→就绪态:运行时间片到;出现有更高优先权进程。运行态→等待态:等待使用资源;如等待外设传输;等待人工干预。就绪态→终止态:未在状态转换图中显示,但某些操作系统允许父进程终结子进程。等待态→终止态:未在状态转换图中显示,但某些操作系统允许父进程终结子进程。终止态→NULL:完成善后操作。

3)七态模型

进程状态分为挂起就绪态、挂起等待态、新建态、终止态,运行态,就绪态,阻塞态(等待态)。七态模型在五态模型的基础上增加了挂起就绪态(ready suspend)和挂起等待态(blocked suspend)。

Linux干货 | 进程编程基础知识总结

图8 七态模型

三态模型和五态模型都是假设所有进程都在内存中的事实上有序不断的创建进程,当系统资源尤其是内存资源已经不能满足进程运行的要求时,必须把某些进程挂起(suspend),对换到磁盘对换区中,释放它占有的某些资源,暂时不参与低级调度。起到平滑系统操作负荷的目的。

  • 挂起就绪态:进程具备运行条件,但目前在外存中,只有它被对换到内存才能被调度执行。

  • 挂起等待态:表明进程正在等待某一个事件发生且在外存中。

注:引起进程挂起的原因是多样的,可能有

1.终端用户的请求。当终端用户在自己的程序运行期间发现有可疑问题时,希望暂停使自己的程序静止下来。亦即,使正在执行的进程暂停执行;若此时用户进程正处于就绪状态而未执行,则该进程暂不接受调度,以便用户研究其执行情况或对程序进行修改。我们把这种静止状态成为“挂起状态”。
2.父进程的请求。有时父进程希望挂起自己的某个子进程,以便考察和修改子进程,或者协调各子进程间的活动。
3.负荷调节的需要。当实时系统中的工作负荷较重,已可能影响到对实时任务的控制时,可由系统把一些不重要的进程挂起,以保证系统能正常运行。
4.操作系统的需要。操作系统有时希望挂起某些进程,以便检查运行中的资源使用情况或进行记账。
5.对换的需要。为了缓和内存紧张的情况,将内存中处于阻塞状态的进程换至外存上。

解析:引起进程状态转换的可能原因如下:等待态→挂起等待态:操作系统根据当前资源状况和性能要求,可以决定把等待态进程对换出去成为挂起等待态。挂起等待态→挂起就绪态:引起进程等待的事件发生之后,相应的挂起等待态进程将转换为挂起就绪态 挂起就绪态→就绪态:当内存中没有就绪态进程,或者挂起就绪态进程具有比就绪态进程更高的优先级,系统将把挂起就绪态进程转换成就绪态。就绪态→挂起就绪态:操作系统根据当前资源状况和性能要求,也可以决定把就绪态进程对换出去成为挂起就绪态。挂起等待态→等待态:当一个进程等待一个事件时,原则上不需要把它调入内存。但是在下面一种情况下,这一状态变化是可能的。当一个进程退出后,主存已经有了一大块自由空间,而某个挂起等待态进程具有较高的优先级并且操作系统已经得知导致它阻塞的事件即将结束,此时便发生了这一状态变化。运行态→挂起就绪态:当一个具有较高优先级的挂起等待态进程的等待事件结束后,它需要抢占 CPU,而此时主存空间不够,从而可能导致正在运行的进程转化为挂起就绪态。另外处于运行态的进程也可以自己挂起自己。新建态→挂起就绪态:考虑到系统当前资源状况和性能要求,可以决定新建的进程将被对换出去成为挂起就绪态。

挂起进程等同于不在内存中的进程,因此挂起进程将不参与低级调度直到它们被调换进内存。

挂起进程具有如下特征:

  • 该进程不能立即被执行
  • 挂起进程可能会等待一个事件,但所等待的事件是独立于挂起条件的,事件结束并不能导致进程具备执行条件。(等待事件结束后进程变为挂起就绪态)
  • 进程进入挂起状态是由于操作系统、父进程或进程本身阻止它的运行。
  • 结束进程挂起状态的命令只能通过操作系统或父进程发出。

2、Linux系统的进程状态

上述状态模型是广义上来划分的,但是具体的操作系统实现方式可能会略有区别,因本文主要介绍Linux平台下的进程基础,故我们此处主要介绍Linux系统的进程状态分类。Linux系统的进程状态分类主要如下:

  1. 运行状态(R,running):并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。

  2. 睡眠状态(S,sleeping): 意味着进程在等待事件完成,这里的睡眠有时候也叫做可中断睡眠 interruptible sleep

  3. 磁盘休眠状态(D,Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。

  4. 暂停状态(T,stopped):可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可 以通过发送 SIGCONT 信号让进程继续运行。

  5. 死亡状态(X,dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。

  6. 僵尸状态(Z,zombie):是一个比较特殊的状态。当进程退出并且父进程,没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。

  7. 杀死进程的方法:kill -9 进程PIDctrl+c只能杀前台进程,kill命令可以杀后台进程。

  8. Linux内核进程状态源代码:

    static const char * const task_state_array[] = {
     "R (running)",         /* 0 */---运行
     "S (sleeping)",        /* 1 */---浅度睡眠,随时被唤醒,被杀掉
     "D (disk sleep)",      /* 2 */---深度睡眠,不会被杀掉,只有自己主动唤醒才能恢复
     "T (stopped)",         /* 4 */---暂停
     "t (tracing stop)",    /* 8 */---进程被调试的时候,遇到断点所处的状态
     "X (dead)",            /* 16 */---死亡
     "Z (zombie)",          /* 32 */---僵尸
    };

补充

  • 前台进程:可以被 [Ctrl]+c杀掉的进程,命令行在这个终端可以起作用,如下STATS+ 和R+后面的+号就表示前台进程的意思。

    [root@localhost ~]# ps aux | grep ps
    root     1397920  0.0  0.0  47636  3740 pts/0    R+   03:17   0:00 ps aux
    root     1397921  0.0  0.0  12144  1080 pts/0    S+   03:17   0:00 grep --color=auto ps
  • 后台进程:无法被 [Ctrl]+c杀掉的进程,命令行在这个终端也可以起作用,STAT列没有+号就表示后台进程的意思。

    [root@localhost ~]# ps aux
    USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
    root           1  0.0  0.0 238976 11476 ?        Ss   Jun08   0:27 /usr/lib/systemd/systemd --switched-root --system --deserialize 17
    root           2  0.0  0.0      0     0 ?        S    Jun08   0:02 [kthreadd]

1)进程状态查看方法

Linux 中查看进程的状态的指令是 ps ajx 或者 ps aux

ps aux:显示所有进程有效用户ID或名字
ps ajx:显示所有进程 PPID、PID、PGID、SID、STAT、COMMAND等

演示结果如下:

Linux干货 | 进程编程基础知识总结

图9 ps ajx显示结

Linux干货 | 进程编程基础知识总结

图10 ps aux显示结果

我们可以发现,Linux 中并没有所谓的就绪状态、挂起状态等等说法,这是因为 OS 其实主要是为了提供一个总体概念的说法,而具体到某个 OS 上面操作的时候,不同的 OS 的进程状态的设定是不一样的,但是都是基于总体概念的,Linux 有 Linux 独特的进程状态说法!

上述查看进程状态的指令显示了太多内容,很多内容并非我们想要的结果,日常中可以使用如下变种指令来显示想检索的进程。

ps aux|head -1 && ps aux|grep 进程PID
ps ajx|head -1 && ps ajx|grep 进程PID

演示结果如下:

[root@localhost ~]# ps aux|head -1 && ps aux|grep $(pidof ps)
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root     1398624  0.0  0.0  47636  3844 pts/0    R+   03:51   0:00 ps aux
root     1398625  0.0  0.0  12144  1204 pts/0    S+   03:51   0:00 grep --color=auto 1398624 

[root@localhost ~]# ps ajx|head -1 && ps ajx|grep $(pidof ps)
   PPID     PID    PGID     SID TTY        TPGID STAT   UID   TIME COMMAND
1340330 1340331 1340331 1340331 pts/0    1398748 Ss       0   0:00 -bash
1340331 1398748 1398748 1340331 pts/0    1398748 R+       0   0:00 ps ajx
1340331 1398749 1398748 1340331 pts/0    1398748 S+       0   0:00 grep --color=auto 1398748

2)Linux进程的状态

A. 运行状态R

运行状态并不意味着进程一定在运行中,它表明进程要么是在运行中,要么在运行队列里。

测试方法:使用命令top,查看进程状态。

Linux干货 | 进程编程基础知识总结

图11 top显示结果

我们可以看到某些进程状态是R,标识为R的进程就是正在运行或者进入运行队列的进程。

B. 睡眠状态S

测试代码:

#include<stdio.h>                                                                    
#include <unistd.h>                                                                  

int main()                                                                           
{                                                                                    
    printf("Process is running...n");                                               
    printf("Process PID:%dn",getpid());                                             
    sleep(5000);                                                                     
    return 0;                                                                        
}                    

演示结果如下:

Linux干货 | 进程编程基础知识总结

图12 进程睡眠状态

这里我们看到子进程1399924的运行状态就是S+状态,一个进程处于浅度睡眠状态(sleeping),意味着该进程正在等待某件事情的完成,处于浅度睡眠状态的进程随时可以被唤醒,也可以被杀掉(这里的睡眠有时候也可叫做可中断睡眠(interruptible sleep))。

C. 深度睡眠状态D

一个进程处于深度睡眠状态(disk sleep),表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动唤醒才可以恢复或者给机器断电。该状态有时候也叫不可中断睡眠状(uninterruptible sleep),处于这个状态的进程通常会等待IO的结束

假设场景:进程X需要向磁盘写入100万条用户数据,这些数据对用户很重要。进程X 访问磁盘,等待磁盘写入数据,进程X 等待磁盘返回一个结果,数据是否写入成功,此时进程X 处于休眠状态S;此时突然内存空间不足了挂起也无法解决内存空间不足的问题,操作系统会自主杀掉一些进程(特别是内存资源不用的,比如进程X),操作系统就把进程X 给杀掉了,造成了磁盘写入数据失败,磁盘给进程X 返回结果,发现进程X 没有应答,磁盘只能把这些数据丢弃,然后磁盘继续为其他进程提供服务。结果,这重要的 100万条数据皆丢失了。

为了防止这种情况的发生,Linux操作系统给进程设置了深度睡眠 (D) 状态,处于深度睡眠状态的进程既不能被用户杀掉,也不能被操作系统杀掉,只能通过断电,或者等待进程自己醒来

深度睡眠状态一般很难见到,一般在企业中做高并发或高IO的时候会遇到,这里就不演示了。

注:一旦机器大量进程处于D状态,说明机器已经处于崩溃的边缘了

D. 暂停状态T

Linux当中,我们可以通过发送SIGSTOP信号使进程进入暂停状态(stopped),发送SIGCONT信号可以让处于暂停状态的进程继续运行。

演示结果如下:

Linux干货 | 进程编程基础知识总结

图13 进程暂停状态

我们再对该进程发送SIGCONT信号,该进程就继续运行了:

Linux干货 | 进程编程基础知识总结

图14 进程暂停状态恢复

从上面结果可以看出,发送SIGCONT信号后,进程恢复运行,且从前台进程变成了后台进程。(test进程的状态由S+转变为S)。

E. 死亡状态X

死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。

F. 僵尸状态Z

僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。

测试代码:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>

int main()
{
    printf("I am running...n");
    pid_t id=fork();
    if (id < 0)
    {
        /* error occurred */
        fprintf(stderr,"Fork Failed!");
        exit(-1);
    }
    else if(id == 0)
    {
        //child process
        int count=5;
        while(count)
        {
            printf("I am chile,pid:%d,ppid:%d,count:%dn",getpid(),getppid(),--count);
            sleep(2);
        }
        printf("child quit...n");
        exit(1);
    }
    else
    {
        //father process
        while(1)
        {
            printf("I am father,pid:%d,ppid:%dn",getpid(),getppid());
            sleep(2);
        }
    }

   return 0;
}

演示结果如下:

Linux干货 | 进程编程基础知识总结

图15 进程僵尸状态

从上图我们就看到了子进程1402242的状态由S+变成了Z+,进入僵尸状态的进程我们称它为僵尸进程!

僵尸进程对于我们的操作系统来说是有极大危害的!

僵尸进程的危害:

  1. 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态;

  2. 维护退出状态本身要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护

  3. 那一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费,因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!

  4. 导致内存泄漏【常见问题:1.频繁GC(垃圾回收机制),发生GC的时候,所有进程都必须等待,GC频率越高,就感觉系统越卡顿。2.内存不足引发的程序运行崩溃】。

G. 孤儿状态

若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程;但若是父进程先退出,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为”孤儿进程”;孤儿进程被1init进程领养,当然要由init进程回收,1init就是操作系统。

测试代码:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
     pid_t id = fork();
     if(id < 0)
     {
         perror("fork");
         return 1;
     }
     else if(id == 0)
     {
         //child
         while(1)
         {
             printf("I am child, pid : %dn", getpid());
             sleep(10);
         }
     }
     else
     {
         //parent
         while(1)
         {
             printf("I am parent, pid: %dn", getpid());
             sleep(5);
             exit(0);
         }
     }
     return 0;
}

演示结果如下:

Linux干货 | 进程编程基础知识总结

图16 进程孤儿状态

根据上述结果可知,当父进程先退出后,子进程则被1号进程接管,这时操作系统就可以直接对1403409回收资源。 且进程状态会由前台转换为后台,后台进程可以使用 kill -9 来结束进程。

这种现象是一定存在的,如果不对子进程进行领养,对应的僵尸进程便没有人能回收了。如果是前台进程创建子进程,如果子进程变孤儿了,子进程会自动变成后台进程

面试题 :什么样的进程杀不死 ❓

D状态进程 和 Z状态进程。因为一个是在深度休眠,操作系统都无法杀死;一个是已经死了。

三、进程优先级

CPU资源分配的先后顺序,就是指进程的优先级(priority);优先级高的进程有优先其他进程执行的权利。配置进程优先级对多任务环境的Linux系统很有用,不仅可以改善系统性能,还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。

提问:进程为什么存在优先级?

排队的本质叫做确定优先级,资源总是不够的,需要优先分配给重要的进程

1、进程优先级查询及解析

1)静态查询ps

Linux系统下,一般用静态命令ps -l命令监控运行的进程的详细信息,其中包括进程优先级信息,ps提供系统进程过去信息的一次性快照,也就是说ps命令能够查看刚刚系统的进程信息,如下:

[root@localhost 0816]# ps -l
F S   UID     PID    PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S     0 1402577 1402574  0  80   0 -  4104 -      pts/3    00:00:00 bash
0 S     0 1407781 1402577  0  80   0 - 13755 -      pts/3    00:00:18 top
0 R     0 1405457 1402577  0  80   0 - 11378 -      pts/3    00:00:00 ps

关键指标解析

UID : 代表执行者的身份

PID : 代表这个进程的代号

PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号

PRI :代表这个进程可被执行的优先级,其值越小越早被执行

NI :代表这个进程的nice

与进程优先级关联较大的主要是PRINI两个指标,有关这两个指标整理理解如下:

  1. PRI代表进程的优先级(priority),通俗点说就是进程被CPU执行的先后顺序,该值越小进程的优先级别越高,默认是80

  2. NI代表的是nice值,其表示进程可被执行的优先级的修正数值,其值可以为负数。

  3. PRI值越小越快被执行,当加入nice值后,将会使得PRI变为:PRI(new) = PRI(old) + NI,注意旧的 PRI 永远都是80

  4. NI值为负值,那么该进程的PRI将变小,即其优先级会变高。

  5. 调整进程优先级,在Linux下,就是调整进程的nice值。

  6. NI的取值范围:-20~1940个级别。

  7. PRI的取值范围:60~99,每次修改该进程的优先级,都是参考80这个优先级进行调整的。即使我们修改了进程优先级为90,假如我再次修改该进程,它的参考是80这个值,不是90的值;也就是说,我修改优先级90这个进程,我修改nice5,它最终的优先级为 85,而不是95

  8. Linux不允许进程无节制的设置优先级。

注意:需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。可以理解nice值是进程优先级的修正数据。

2)动态查询top

ps 命令可以一次性给出当前系统中进程状态,top 命令可以动态地持续监听进程的运行状态,默认刷新间隔为3秒。第一部分是前五行,显示的是整个系统的资源使用状况,我们就是通过这些输出来判断服务器的资源使用状态的;第二部分从第六行开始,显示的是系统中进程的信息;如下:

[root@localhost 0816]# top
top - 08:57:17 up 68 days, 17:23,  4 users,  load average: 0.00, 0.00, 0.00
Tasks: 686 total,   1 running, 685 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  64063.9 total,  44141.1 free,   1467.0 used,  18455.9 buff/cache
MiB Swap:  32192.0 total,  32191.7 free,      0.3 used.  58753.5 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
1295620 root      20   0       0      0      0 I   0.3   0.0   0:06.59 kworker/24:1-events
1407781 root      20   0   55020   5120   3608 R   0.3   0.0   0:00.17 top
      1 root      20   0  238976  11476   8048 S   0.0   0.0   0:27.68 systemd
      2 root      20   0       0      0      0 S   0.0   0.0   0:02.92 kthreadd
      3 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_gp
      4 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 rcu_par_gp
      6 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 kworker/0:0H-events_highpri
      9 root      20   0       0      0      0 I   0.0   0.0   0:04.21 kworker/u128:0-edac-poller
     10 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 mm_percpu_wq
     11 root      20   0       0      0      0 S   0.0   0.0   0:00.00 rcu_tasks_rude_
     12 root      20   0       0      0      0 S   0.0   0.0   0:00.00 rcu_tasks_trace
     13 root      20   0       0      0      0 S   0.0   0.0   0:00.02 ksoftirqd/0
     14 root      20   0       0      0      0 I   0.0   0.0   1:06.20 rcu_sched
     15 root      rt   0       0      0      0 S   0.0   0.0   0:00.01 migration/0
     16 root      rt   0       0      0      0 S   0.0   0.0   0:00.00 watchdog/0
     17 root      20   0       0      0      0 S   0.0   0.0   0:00.00 cpuhp/0
     18 root      20   0       0      0      0 S   0.0   0.0   0:00.00 cpuhp/1
     19 root      rt   0       0      0      0 S   0.0   0.0   0:01.94 watchdog/1
     20 root      rt   0       0      0      0 S   0.0   0.0   0:00.00 migration/1
     21 root      20   0       0      0      0 S   0.0   0.0   0:00.01 ksoftirqd/1
     23 root       0 -20       0      0      0 I   0.0   0.0   0:00.00 kworker/1:0H-events_highpri
     ......

关键指标解析

PID:进程ID,进程的唯一标识符 USER:进程所有者的实际用户名 PR:进程的调度优先级。这个字段的一些值是’rt’。这意味这这些进程运行在实时态 NI:进程的nice值(优先级),数值越小、优先级越高;负值表示高优先级,正值表示低优先级 VIRT:进程使用的虚拟内存。进程使用的虚拟内存总量,单位kb。VIRT=SWAP+RES RES:驻留内存大小。驻留内存是任务使用的非交换物理内存大小。进程使用的、未被换出的物理内存大小,单位kb。RES=CODE+DATA SHR:SHR是进程使用的共享内存。共享内存大小,单位kb S:进程状态 %CPU:自从上一次更新时到现在任务所使用的CPU时间百分比 %MEM:进程使用的可用物理内存百分比 TIME+:任务启动后到现在所使用的全部CPU时间,精确到百分之一秒 COMMAND:运行进程所使用的命令。进程名称(命令名/命令行)

top命令输出中PR值和NI值有什么不同,NI是优先值,是用户层面的概念,开放给用户修改用的,PR是进程的实际优先级, 是CPU调度器真正使用的优先级。

一般情况下,PR=NI+20, 如果一个进程的优先级PR20, 那么它的NI(nice)值就是20-20=0

注意:在top中会看到 PR = rt 的进程,这个rt等同于-100

对于普通进程,不管用户怎么调整NI值,进程的PR都不会低于0,也就是保证所有实时进程的优先级,都要大于普通进程,像Linux中的一些内核进程就是实时进程(如:migration/0),必须保证他们被优先执行。实时进程的调度是抢占式的,只要其不结束,低优先级进程完全没有机会使用CPU,而对于普通进程而言,多少会留一点CPU时间给其它低优先级的普通进程使用的。

注意

  • nice 值可调整的范围为-20 ~ 19 ;
  • root 可随意调整自己或他人程序的Nice值,且范围为 -20 ~ 19 ;
  • 一般使用者仅可调整自己程序的 Nice 值,且范围仅为 0 ~ 19 (避免一般用户抢占系统资源);
  • 一般使用者仅可将nice值越调越高,例如本来nice5,则未来仅能调整到大与5

对比ps 命令的PRItop 命令的PR,可以发现,它们始终相差60。他们含义是一致的,只是显示的基准值不同而已,top中0以下代表实时进程,而ps -l60以下代表实时进程。

注意:系统允许root用户设置负数优先级,以及减小现有进程的优先级数值大小。对普通用户仅允许设置正数优先级,并且只能增大现有进程的优先级数值大小。

2、进程优先级修改

修改进程优先级,通常有两种方法,一种是通过top命令,另外一种是通过nice/renice命令。

1)top命令

top 命令修改进程优先级的步骤如下:

  1. top

  2. 进入top后按“r”–>输入进程PID–>输入nice值

  3. 修改成功:按q退出,再用ps -al 查看发现修改成功。

运行test进程,查看其初始PRI值和NI值。如下:

[root@localhost 0816]# ./test                                                                     │[root@nj-rack02-07 0816]# ps -al | grep test
Process is running...                                                                                │0 S     0 1407756 1399786  0  61 -19 -  1091 hrtime pts/1    00:00:00 test
Process PID:1408020                                                                                  │[root@nj-rack02-07 0816]#

[root@localhost ~]# ps -al | head -1 && ps -al | grep test
F S   UID     PID    PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S     0 1407756 1399786  0  80   0 -  1091 hrtime pts/1    00:00:00 test

执行top命令,输入r,跳出如下界面:

Linux干货 | 进程编程基础知识总结

图17 top命令输入r

输入欲修改的进程的Pid号,由上面可知test进程的Pid号为1447756,输入如下:

Linux干货 | 进程编程基础知识总结

图18 输入PID号

输入完成以后,按回车键,弹出如下界面:

Linux干货 | 进程编程基础知识总结

图19 待输入nice值

输入欲设置的nice值,次数nice设置为-10,输入完成以后,按回车键。

Linux干货 | 进程编程基础知识总结

图20 输入nice值

上述步骤做完以后,使用ps -l命令查看test进程的PRI值和NI值,如下:

[root@localhost ~]# ps -al | head -1 && ps -al | grep test
F S   UID     PID    PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S     0 1407756 1399786  0  70 -10 -  1091 hrtime pts/1    00:00:00 test

可见,该进程的优先级PRI值和NI值较初始值已发生变化,变化的数值正好是我们在top命令中修改的nice值。

2)nice/renice命令

进程的优先级可以通过改变nice值去修改,而nice值可以通过 nice/renice 命令修改,从而调整进程的运行顺序。

A. nice命令

nice 命令可以给要启动的进程赋予 NI 值,但是不能修改已运行进程的 NI 值。

nice 命令格式如下:

[root@localhost ~] # nice [-n NI值] 命令

-n NI值:给命令赋予NI值,该值的范围为-20~19

演示结果如下:

Linux干货 | 进程编程基础知识总结

图21 nice命令修改优先级

根据上述结果可知,使用nice命令运行test 进程后,表示其优先级的PRINI值变为70-10,而非一般情况下直接启动test进程的800。进程的优先级发生了改变。

B. renice命令

同 nice 命令恰恰相反,renice 命令可以在进程运行时修改其 NI 值,从而调整优先级。

renice 命令格式如下:

[root@localhost ~] # renice [优先级] PID

注意,此命令中使用的是进程的PID号,因此常与 ps 等命令配合使用。

演示结果如下:

Linux干货 | 进程编程基础知识总结

图22 renice命令修改优先级

根据上述结果可知,使用renice命令修改进程的nice值后,进程的优先级直接发生了变化。

四、进程控制块

进程控制块,英文名(Processing Control Block),简称 PCB

进程控制块是系统为了管理进程设置的一个专门的数据结构,主要表示进程状态。每一个进程都对应一个PCB来维护进程相关的信息,PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。在Linux中,PCB结构定义为内核的一种数据结构task_struct;它会被装载到RAM里并且包含进程的信息,每个进程都把它的信息放在task_struct这个数据结构里。

【提示】

  • OS管理的本质是先描述,再组织

  • OS并非直接管理进程 ,而是管理进程的PCB(task_struct)

  • PCB 中有着进程的各种信息,包括:PID、PPID、进程状态等

  • 我们可以通过函数 getpid() 获取当前进程的 PID

  • 进程 间存在父子关系,可以通过 fork() 主动创建子进程

  • 父子进程相互独立,共享一份代码时,具有写时拷贝机制

Linux内核里,无论是进程还是线程,统一使用 task_struct{} 结构体来表示,也就是统一抽象为任务(task)。task_struct{} 定义在 include/linux/sched.h 文件中,十分复杂,这里简单了解下。

struct task_struct
{
 
 #ifdef CONFIG_THREAD_INFO_IN_TASK
    struct thread_info    thread_info;  //进程基本信息
 #endif 
    volatile long state;              // 进程的运行状态,-1为不可运行, 0为可运行, >0为已中断
    void *stack;                      // 内核栈指针
    atomic_t usage;                   // 进程描述符的使用计数
    int      on_rq;                   //进程是否在运行队列上
    unsigned int flags;               // 进程的状态标志
    unsigned int ptrace;              // 实现断点调试,跟踪进程运行的系统调用

    int prio;                         // 进程的 CPU 调度优先级
    unsigned int policy;              // 进程的 CPU 调度策略,一般有FIFO,RR,CFS
    cpumask_t cpus_allowed;           // 进程可以在 CPU 的哪些核上执行

    const struct sched_class *sched_class; // 指向其所在的调度类
    struct sched_entity   se;         // 普通进程的调度实体
    struct sched_rt_entity rt;        // 实时进程的调度实体

    int exit_state;                   // 进程的退出码
    pid_t pid;                        // 进程的标识符
    pid_t tgid;                       // thread group ID,指的是进程的主线程id

    struct task_struct *parent;       // 一个指针,指向父进程
    struct task_struct *group_leader; // 指向的是进程的主线程
    struct list_head children;        // 一个链表,其中每个元素表示一个子进程

    cputime_t utime, stime;           // 进程占用的用户态、内核态 CPU 时长
    struct timespec start_time;       // 进程的启动时刻

    struct list_head tasks;              // 链表,将所有task_struct串起来    

    struct mm_struct *mm;             // 记录进程的虚拟内存空间,比如 code、data 区域的起始地址、结束地址
    struct mm_struct *active_mm;      // 指向进程地址空间

    struct fs_struct *fs;              // 文件系统相关信息   
    struct files_struct *files;          // 文件相关信息

    struct signal_struct    *signal;  // 接收的信号
    struct sighand_struct __rcu     *sighand;  // 信号处理信息
    ...
    struct thread_struct    thread;   // CPU相关的进程状态
}

进程运行在内核态时,需要相应的堆栈信息, 则linux kernel为每个进程都提供一个内核栈kernel stack

这里对task_struct结构体做了一个简单的归类,总结如下:

Linux干货 | 进程编程基础知识总结

图23 task_struct成员分类

在不同的操作系统中对进程的控制和管理机制不同,PCB中的信息多少不一样,通常PCB应包含如下一些信息:

  1. 进程标识符:每一个进程都一个唯一的进程ID,类型为 pid_t, 本质是一个整形数。

  2. 进程当前状态 status:说明进程当前所处的状态。为了管理的方便,系统设计时会将相同的状态的进程组成一个队列,如就绪进程队列,等待进程则要根据等待的事件组成多个等待队列,如等待打印机队列、等待磁盘I/O完成队列等等。

  3. 进程相应的程序和数据地址,以便把PCB与其程序和数据联系起来。

  4. 进程资源清单。列出所拥有的除CPU外的资源记录,如拥有的I/O设备,打开的文件列表等。

  5. 进程优先级 priority:进程的优先级反映进程的紧迫程度,通常由用户指定和系统设置。

  6. CPU 现场保护区 cpustatus:当进程因某种原因不能继续占用CPU时(如等待打印机),释放CPU,这时就要将CPU的各种状态信息保护起来,为将来再次得到处理器恢复CPU的各种状态,继续运行。

  7. 进程同步与通信机制 用于实现进程间互斥、同步和通信所需的信号量等。

  8. 进程所在队列PCB的链接字 根据进程所处的现行状态,进程的PCB参加到不同队列中。PCB链接字指出该进程所在队列中下一个进程PCB的首地址。

  9. 与进程有关的其他信息。如进程记账信息,进程占用CPU的时间等。

PCB通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列, 例如将处于就绪状态的进程链在一块,形成就绪队列,将所有因等待某事件而处于等待队列的进程链在一块形成阻塞队列。

Linux干货 | 进程编程基础知识总结

图24 PCB链表队列

除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。

Linux干货 | 进程编程基础知识总结

图25 PCB 索引链表

进程控制块(PCB)的理解:

  • 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合

  • 操作系统理论上称之为PCB(process control block)Linux操作系统下的PCB是: task_struct

  • 凡是提到进程,必须首先想到进程task_struct(PCB)

  • PCB是操作系统描述进程的一个统称。当可执行程序加载到内存,是运行了一个进程。实际的大小要比文件本身要大。操做系统管理进程要先对进程进行描述,会添加一些属性(所以比本身文件大),属性包括描述信息+内容代码数据等。操作系统允许多个进程同时允许,为了方便管理,操作系统还会将进程组织起来,一般是组织成一个双向链表的数据结构,如下图:

    Linux干货 | 进程编程基础知识总结图26 PCB双向链表

task_structPCB的区别:

  • PCB是操作系统描述进程的一个统称
  • task_structLinux下描述进程的结构体,是Linux内核的一种数据结构,它会被装载到内存里并且包含进程的信息

五、进程相关API

Linux下进程模型提供了很多API函数,下面的这些函数可以完成基本的工作。

API函数                     用途
fork 创建子进程
wait 将进程挂起,直到子进程退出
waitpid 将进程挂起,直到指定子进程退出
signal 设置收到信号时的处理函数
pause 将进程挂起,直到捕捉到信号
kill 向某个指定的进程发出信号
raise 向当前进程发出信号
alarm 给进程设置定时器
exec 将当前进程映像用一个新的进程映像来替换
exit 正常终止当前进程

日常编程中,我们常用到多进程的方式,可以让我们的程序同步执行多个任务。接下来,从函数原型依次介绍并用实例来分析。

1、进程的创建fork

功能:用于从一个已存在的进程中创建一个新进程,新进程称为子进程,原进程称为父进程。

函数原型:

pid_t fork(void);

头文件:

#include <sys/types.h>
#include <unistd.h>

注意:使用fork()函数需要包含头文件<unistd.h>pid_t类型需要包含头文件<sys/types.h>pid_t定义于/usr/include/sys/types.h 文件中,为整型int的别名。

返回值:

  • 成功:子进程中返回0,父进程中返回子进程PID

  • 失败:返回-1

    【拓展】失败主要原因是:

    1. 当前的进程数已经达到了系统规定的上限,这时errno的值被设置为 EAGAIN

    2. 系统内存不足,这时errno的值被设置为ENOMEM

当一个进程调用fork之后,父子进程共享同一份代码,也就是说整个代码父子进程都可以看到,但是此时父子进程的执行位置都是相同的,也就是说fork返回后子进程也是往fork之后的代码执行(并非再从头执行)。

使用 fork() 函数得到的子进程是父进程的一个复制品,会为其分配新的内存块和内核数据结构(PCB),将 父进程 中的数据结构内容拷贝给 子进程,同时还会继承 父进程 中的环境变量表。它从父进程处继承了整个进程的地址空间:包括进程上下文(进程执行活动全过程的静态描述)、进程堆栈、打开的文件描述符、信号控制设定、进程优先级、进程组号等。

子进程所独有的只有它的进程号,计时器等(只有小量信息)。因此,使用 fork() 函数的代价是很大的。

Linux干货 | 进程编程基础知识总结

图27 fork子进程

拓展

  • 进程具有独立性,即使是父子进程,也是两个完全不同的进程,拥有各自的 PCB
  • 假设 子进程 发生改写行为,会触发写时拷贝机制。

接下来让我们通过具体示例演示fork函数的原理和用法。

测试代码:

/* fork_test.c */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>     //进程等待相关函数头文件

int main()
{
  int val = 10;
  pid_t id = fork();
  if(id < 0)
  {
    //没有创建成功
    perror("fork fail!");
    return 1;
  }
  else if(id == 0)
  {
    val = 20;   //刻意改变共享值
    printf("我是子进程,pid:%d ppid:%d 共享值:%d 共享值地址:%pn", getpid(), getppid(), val, &val);
    exit(0);
  }
  else
  {
    waitpid(id, 00);  //等待子进程终止,如果子进程终止了,此函数会回收子进程的资源。
    printf("我是父进程,pid:%d ppid:%d 共享值:%d 共享值地址:%pn", getpid(), getppid(), val, &val);
  }
  return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost 0816]# gcc -o fork_test fork_test.c 
[root@localhost 0816]# ./fork_test                                                                  │
我是子进程,pid:1438925 ppid:1438924 共享值:20 共享值地址:0x7fff09487ce8                               │
我是父进程,pid:1438924 ppid:1399786 共享值:10 共享值地址:0x7fff09487ce8

根据程序执行结果可知,相同的变量内存地址,变量却出现了两个不同的值;看到这里想必很多人比较懵,一定有着这样的疑惑:“对于相同的内存地址,竟然读取到了不同的值,怎么可能出现这种情况”?

虽然父子进程的变量的地址值相同,但此时的变量其实已是位于不同的虚拟地址空间中的同名变量而已(子进程内的变量是从父进程中复制的,因此变量名字和虚拟地址值相同),二者已没有任何关联性,因为当子进程尝试修改共享值时,发生写时拷贝机制,OS会重新在真实物理内存空间中开辟一块空间,将数据拷贝至新空间上,此时子进程对变量值的改变则不会影响父进程。语言层面的程序地址空间不是真实物理地址,一般将此地址称为虚拟地址线性地址。那么虚拟地址和物理地址之间存在什么关系呢?我们继续往下看。

注意: 语言层面的地址都是虚拟地址,用户无法看到真实的物理地址,由 OS 统一管理。

一般用户的认知中,C/C++ 程序内存分布如下图所示:

Linux干货 | 进程编程基础知识总结

图28 虚拟内存分布

这可能只是编程入门级工程师看到的内存分布,然而实际上的内存空间分布是这样子的:

Linux干货 | 进程编程基础知识总结

图29 真实内存分布

如果有多个进程(真实地址空间只有一份),此时情况是这样的:

Linux干货 | 进程编程基础知识总结

图30 多进程真实内存分布

讲到虚拟地址空间,则不得不提到mm_struct,同task_struct一样,mm_struct中也包含了很多成员,如下仅展示不同区域的边界值:

mm_struct
{
    //代码区域划分
    unsigned long code_start;
    unsigned long code_end;

    //堆区域划分
    unsigned long heap_start;
    unsigned long heap_end;

    //栈区域划分
    unsigned long stack_start;
    unsigned long stack_end;

    //还有很多其他信息
    ……
}

每个进程都会有这样一个 mm_struct,其中的区域划分就是虚拟地址空间,通过对边界值的调整,可以做到不同区域的增长,如堆区、栈区扩大,mm_struct 中的信息配合 页表+MMU 在对应的真实空间中使内存(程序寻址)。

【问题解析】为什么会发生同一块空间能读取到不同值的现象:

  • 父子进程有着各自的 mm_struct,其成员起始值一致。
  • 对于同一个变量,如果未改写,则两者的虚拟地址通过 页表 + MMU 转换后指向同一块空间。
  • 发生改写行为,此时会在真实空间中再开辟一块空间,拷贝变量值,让其中一个进程的虚拟地址空间映射改变,这种行为称为 写时拷贝

对于上面测试例子,刚开始,父子进程共同使用同一块空间,示意图如下:

Linux干货 | 进程编程基础知识总结

图31 父子进程写时拷贝之前

当子进程修改共享值后,发生写时拷贝,示意图如下:

Linux干货 | 进程编程基础知识总结

图32 父子进程写时拷贝之后

由此导致相同的变量内存地址,不同进程的同名变量却出现了两个不同的值。

2、进程的等待wait

一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用waitwaitpid获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用waitwaitpid得到它的退出状态同时彻底清除掉这个进程。

简单来说,父进程执行 wait 函数之后,会被阻塞在此处。如果子进程状态发生变化,wait 函数会立即返回结果;否则 wait 函数会一直阻塞父进程,直到子进程状态发生变化。

注意

wait()函数以阻塞的方式等待子进程退出,防止僵尸进程的产生。

  • 当父进程忘了使用 wait() 函数等待已终止的子进程时,子进程就会进入一种无父进程的状态,此时子进程就是僵尸进程。
  • 如果父进程有多个子进程,只要有一个子进程终止,父进程就可以结束等待状态。

父进程调用wait函数可以回收子进程终止信息。该函数有三个功能:

  • 阻塞等待子进程退出
  • 回收子进程残留资源
  • 获取子进程结束状态(退出原因)

当进程终止时,操作系统的隐式回收机制会做如下操作:

1.关闭所有文件描述符

2.释放用户空间分配的内存。内核的PCB仍存在。其中保存该进程的退出状态。(正常终止→退出值;异常终止→终止信号) 。

函数原型:

pid_t wait(int *status);

参数说明:

status传出参数,用来获取子进程退出的状态。

可使用wait函数传出参数status来保存进程的退出状态(status只是一个整型变量,不能很精确的描述出状态),因此需要借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:

  • WIFEXITED(status) 为真 → 进程正常结束

使用宏WEXITSTATUS(status)获取进程退出状态 (exit的参数)。

  • WIFSIGNALED(status) 为真 → 进程异常终止

使用宏WTERMSIG(status)取得使进程终止的那个信号的编号。

  • WIFSTOPPED(status) 为真 → 进程处于暂停状态

使用宏WSTOPSIG(status)取得使进程暂停的那个信号的编号。

拓展:WIFCONTINUED(status) 为真 → 进程暂停后已经继续运行

头文件:

#include <sys/types.h>
#include <sys/wait.h>

返回值:

  • 成功:返回终止的子进程pid 。

  • 失败:如果调用进程没有子进程,调用就会失败,返回-1,设置errnoECHILD

接下来让我们通过具体示例演示wait函数的原理和用法。

如果我们对这个子进程是如何死掉毫不在意,只想把这个子进程消灭掉,(事实上绝大多数情况下,我们都会这样想),我们就可以设定wait函数的参数为NULL,就像下面这样:

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 1;
    }
    else if(id == 0)
    {
        int count = 5;
        while(count)
        {
            printf("child is running: %d, ppid: %d, pid: %dn", count--, getppid(), getpid());
            sleep(1);
        }                                                                                     
        printf("child quit...n");
        exit(0);
    }
    else
    {
        printf("father is waiting...n");
        pid_t ret = wait(NULL);
        printf("father is wait done, ret: %dn", ret);
    }
    return 0;

使用gcc编译并执行程序,如下:

[root@localhost wait]# gcc -o wait_test wait_test.c
[root@localhost wait]# ./wait_test
father is waiting...
child is running: 5, ppid: 3532637, pid: 3532638
child is running: 4, ppid: 3532637, pid: 3532638
child is running: 3, ppid: 3532637, pid: 3532638
child is running: 2, ppid: 3532637, pid: 3532638
child is running: 1, ppid: 3532637, pid: 3532638
child quit...
father is wait done, ret: 3532638

如果参数 status 的值不是NULLwait 就会把子进程退出时的状态取出并存入 status 中, 这是一个整数值(int),指出了子进程是正常退出还是被非正常结束的,以及正常结束时的返回值,或被哪一个信号结束的等信息。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid, wpid;
    pid = fork();
    int status;

    if (pid == 0) {
            printf("---child, my parent= %d, going to sleep 10sn", getppid());
            sleep(20);
            printf("-------------child die--------------n");
            exit(77);
    } else if (pid > 0) {
        while (1) {
            printf("I am parent, pid = %d, myson = %dn", getpid(), pid);

            wpid = wait(&status);
            if (wpid == -1) {
                perror("wait error");
                exit(1);
            }

            if (WIFEXITED(status)) {  //为真说明子进程正常结束
                printf("child exit with %dn", WEXITSTATUS(status));
            } else if (WIFSIGNALED(status)) { //为真说明子进程被信号终止(异常)
                printf("child is killed by %dn", WTERMSIG(status));
            }

            sleep(1);
        }
    } else {
        perror("fork");
        return 1;
    }

    return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost wait]# ./wait_status_test
I am parent, pid = 3534127, myson = 3534128
---child, my parent= 3534127, going to sleep 10s
-------------child die--------------
child exit with 77
I am parent, pid = 3534127, myson = 3534128
wait error: No child processes

对于上述执行结果,可能有些朋友会比较懵。其实,仔细研究会搞明白整个流程,因为fork之后,父进程是一个while循环,当第一次执行wait时,会阻塞父进程的运行。直到子进程运行完毕正常退出以后,此时的父进程wait函数会获取子进程的退出状态,首先会执行到child exit with %d这条语句,之后,因为父进程是while循环,故wait函数会第二次执行,而此时,父进程已无子进程,故wait函数会返回-1,输出错误语句,父进程退出,errno 设置为ECHILD,查看内核源码,可知该宏的含义为No child processes

3、进程的等待waitpid

函数原型:

pid_t waitpid(pid_t pid, int *status, int options);

参数说明:

1)pid:

  • pid > 0|只等待进程ID等于pid的子进程,不管其他子进程是否结束,只要指定子进程未结束,waitpid()就会一直等下去。

  • pid =-1|等待任何一个子进程退出,此时和wait()作用一样。

  • pid = 0|等待其组ID等于调用进程的组ID的任一子进程。

  • pid <-1|等待其组ID等于pid的绝对值的任一子程序

2)stasus: 同wait

3)options:

  • WNOHANG:若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待;若正常结束,则返回该子进程的ID

  • WUNTRACED:若pid指定进程已被暂停,且其状态自暂停以来还未报告过,则返回其状态。

  • 设置为0:表示默认的阻塞式等待子进程退出,即子进程没退出就不返回,一直等待到子进程退出回收子进程。

头文件:

#include <sys/types.h>
#include <sys/wait.h>

返回值:

  • 正常:返回已成功结束运行的子进程的进程号。

  • 失败:-1,失败的原因包括没有该子进程,参数不合法等。

  • 返回0:使用选项WNOHANG且子进程尚未退出。

阻塞式等待示例:

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        printf("I am child process: pid:%d ppid:%dn", getpid(), getppid());
        sleep(5);
        exit(111);
    }
    printf("father wait...n");
    int status=0;
    pid_t ret=waitpid(id, &status, 0);  //阻塞等待特定子进程
    if(ret>0 && WIFEXITED(status))   //等待成功并子进程退出正常
    {
        printf("wait success: wait for id:%d status code:%dn",ret, WEXITSTATUS(status));
    }else if(ret>0)  //等待成功但是子进程退出异常
    {
        printf("exit error! status coredump:%d sign:%dn", WIFSIGNALED(status), WTERMSIG(status));
    }
    return 0;

}

使用gcc编译并执行程序,如下:

[root@localhost waitpid]# ./waitpid_block
father wait...
I am child process: pid:3563187 ppid:3563186
wait success: wait for id:3563187 status code:111

非阻塞式等待示例:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
    pid_t pid;
    pid = fork();
    if(pid < 0){  //fork失败
        printf("%s fork errorn",__FUNCTION__);
        return 1;
    }else if( pid == 0 ){
        //child执行
        printf("child is run, pid is : %dn",getpid());
        sleep(5);
        exit(1);
    } else{
        //father执行
        int status = 0;
        pid_t ret = 0;
        do{
            ret = waitpid(-1, &status, WNOHANG);   //非阻塞式等待
            if( ret == 0 ){    //等待失败则继续等待
                printf("child is runningn");
                //TODO...等待执行其他任务,待会再等待
            }
            sleep(1);
        }while(ret == 0);
        //等待成功打印对应信息
        if( WIFEXITED(status) && ret == pid ){
            //退出正常输出退出码
            printf("wait child 5s success, child return code is :%d.n",WEXITSTATUS(status));
        }else{
            //退出异常
            printf("wait child failed, return.n");
            return 1;
        }
    }
    return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost waitpid]# ./waitpid_non_block
child is running
child is run, pid is : 3563353
child is running
child is running
child is running
child is running
wait child 5s success, child return code is :1.

4、进程的注册信号处理signal

Linux系统中,信号是事件发生时对进程的通知机制,有时也称之为软件中断,它通常是异步发生的,信号与硬件中断的相似之处在于打断了程序执行的正常流程,可以用来通知进程某个事件已经发生。每个信号都有一个唯一的编号,编号从1开始。进程可以通过注册信号处理函数来处理信号。

Linux系统中的信号有两类:标准信号和实时信号。

  • 标准信号是传统Unix系统中的信号,编号范围从131,可以延后处理。

  • 实时信号是Linux独有的信号,编号范围从3264,不可以延后处理。

注意:一般的, 信号通过软件/硬件产生, 本质上也是由OS构建信号, 构建好之后也就自动的发送给进程了

我们可以使用kill -l命令来查看所有信号:

[root@localhost ~]# kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX                                           

C语言中,可以使用signal函数来注册信号处理函数。signal函数原型如下:

void ( *signal(int signum, void (*handler)(int)) ) (int);

函数原型也可以这样写:

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

参数说明:

  • signum:表示要注册的信号编号。

  • handler:取值有 3 种情况:

    SIG_IGN:忽略该信号;

    SIG_DFL:执行系统默认动作;

    自定义信号处理函数名:自定义信号处理函数;如:handler 回调函数的定义如下:

    void handler(int signo) 

        // signo为触发的信号,为 signal()第一个参数的值,即signum的值。 
    }

头文件:

#include <signal.h>

返回值:

  • 成功:第一次返回 NULL,下一次返回此信号上一次注册的信号处理函数的地址。如果需要使用此返回值,必须在前面先声明此函数指针的类型。

  • 失败:返回 SIG_ERR

注意:该函数由ANSI定义,由于历史原因在不同版本的Unix和不同版本的Linux中可能有不同的行为。因此应该尽量避免使用它,取而代之使用sigaction函数。

演示程序:

#include<stdio.h>
#include<signal.h>
#include<unistd.h>

//注册自定义捕捉信号的处理方式
void handler(int signum)
{
  printf("进程[%d],已捕捉到%d信号n", getpid(), signum);
  // 将信号设置为默认处理方式
  signal(SIGINT,SIG_DFL);
}

int main()
{
  int count=0;
  //自定义捕捉SIGINT信号(2号)
  //注意:这里只是在注册捕捉到的递达信号的处理行为,并不是在这里调用handler,而是注册!
  //handler是在SIGINT信号递达时调用的
  if(signal(SIGINT, handler) == SIG_ERR)
  {
      perror("can’t set handler for SIGINT");
      return -1;
  }

  printf("I am process: %dn", getpid());

  //不让进程退出, 方便观察信号递达后的自定义捕捉行为
  while(1)
  {
     printf("%dn",count++);
     sleep(1);
  }
  return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost signal]# ./signal_test
I am process: 3631156
0
1
2
^C进程[3631156],已捕捉到2信号
3
4
5
6
7
^C

上述示例演示了自定义信号处理函数和默认信号处理函数的使用,当进程收到SIGINT信号时,会触发在main函数中通过signal 函数事先注册好的自定义信号处理函数handler,随后调用handler函数进行相应处理。另外,我们在handler函数内又通过signal 函数恢复了针对SIGINT 信号的默认信号处理函数,因此,当进程第一次接收到 Ctrl C 触发的SIGINT信号时,会执行handler函数,而当第二次接收到Ctrl C 触发的SIGINT信号时,则会执行默认信号处理函数,即直接退出进程。

Linux常规信号一览表

编号 信号 对应事件 默认动作
1 SIGHUP ①如果终端接口检测到连接断开,则会将SIGHUP信号发送给与该终端关联的控制进程(即会话首进程),接到该信号的会话首进程可能在后台。
②如果会话首进程终止,会将SIGHUP信号发送给前台进程组的所有进程。
终止进程
2 SIGINT 当用户按下了 <Ctrl+C> 组合键时,终端将SIGINT信号发送给该终端的前台进程组中的每个进程。 终止进程
3 SIGQUIT 用户按下<ctrl+>组合键时产生该信号,终端将SIGQUIT信号发送给该终端的前台进程组中的每个进程。 终止进程
4 SIGILL CPU检测到某进程执行了非法指令 终止进程并产生core文件
5 SIGTRAP 该信号由断点指令或其他 trap指令产生 终止进程并产生core文件
6 SIGABRT 调用abort函数时产生该信号 终止进程并产生core文件
7 SIGBUS 非法访问内存地址,包括内存对齐出错 终止进程并产生core文件
8 SIGFPE 在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误 终止进程并产生core文件
9 SIGKILL 无条件终止进程。本信号不能被忽略,处理和阻塞 终止进程,可以杀死任何进程
10 SIGUSE1 用户定义的信号。即程序员可以在程序中定义并使用该信号 终止进程
11 SIGSEGV 指示进程进行了无效内存访问(段错误) 终止进程并产生core文件
12 SIGUSR2 另外一个用户自定义信号,程序员可以在程序中定义并使用该信号 终止进程
13 SIGPIPE Broken pipe向一个没有读端的管道写数据 终止进程
14 SIGALRM 定时器超时,超时的时间 由系统调用alarm设置 终止进程
15 SIGTERM 程序结束信号,与SIGKILL不同的是,该信号可以被阻塞和终止。通常用来要示程序正常退出。执行shell命令Kill时,缺省产生这个信号 终止进程
16 SIGSTKFLT Linux早期版本出现的信号,现仍保留向后兼容 终止进程
17 SIGCHLD 子进程结束时,父进程会收到这个信号 忽略这个信号
18 SIGCONT 如果进程已停止,则使其继续运行 继续/忽略
19 SIGSTOP 停止进程的执行。信号不能被忽略,处理和阻塞 终止进程
20 SIGTSTP 交互停止信号。按下<ctrl+z>组合键时发出这个信号,该信号发送至前台进程组中的每个进程。 暂停进程
21 SIGTTIN 后台进程读终端控制台 暂停进程
22 SIGTTOU 该信号类似于SIGTTIN,在后台进程要向终端输出数据时发生 暂停进程
23 SIGURG 套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达 忽略该信号
24 SIGXCPU 进程执行时间超过了分配给该进程的CPU时间 ,系统产生该信号并发送给该进程 终止进程
25 SIGXFSZ 超过文件的最大长度设置 终止进程
26 SIGVTALRM 虚拟时钟超时时产生该信号。类似于SIGALRM,但是该信号只计算该进程占用CPU的使用时间 终止进程
27 SGIPROF 类似于SIGVTALRM,它不公包括该进程占用CPU时间还包括执行系统调用时间 终止进程
28 SIGWINCH 窗口变化大小时发出 忽略该信号
29 SIGIO 此信号向进程指示发出了一个异步IO事件 忽略该信号
30 SIGPWR 关机 终止进程
31 SIGSYS 无效的系统调用 终止进程并产生core文件
34~64 SIGRTMIN ~ SIGRTMAX LINUX的实时信号,它们没有固定的含义(可以由用户自定义) 终止进程

5、进程的挂起pause

调用该函数可以造成进程主动挂起,等待信号唤醒,调用该系统调用的进程处于阻塞状态(主动放弃CPU)直到有信号递达将其唤醒,直接执行pause()函数后面的语句。

函数原型:

int pause(void);

参数说明:

  • pause()函数不接受任何参数。

头文件:

#include<unistd.h>

返回值:

  • 只返回-1;

  • 错误代码:设置errnoEINTR

演示示例:

#include<signal.h>
#include<stdio.h>
#include <unistd.h>

//信号处理程序
static void sigHandler(int sig){
    static int count=0;
    count++;
    printf("Caught signal!(%d)n",count);
}


int main(){

    //给特定信号安装信号处理程序
    //键入Ctrl+C时会产生SIGINT信号
    if(signal(SIGINT,sigHandler)==SIG_ERR)
        printf("signal errorn");

    //让进程睡眠,直到接收到一个信号
    for(;;)
    {
        printf("进程处于就绪状态中...n");
        pause();
    }
}

使用gcc编译并执行程序,如下:

[root@localhost pause]# gcc -o pause_test pause_test.c
[root@localhost pause]# ./pause_test
进程处于就绪状态中...
^CCaught signal!(1)
进程处于就绪状态中...
^CCaught signal!(2)
进程处于就绪状态中...
^CCaught signal!(3)
进程处于就绪状态中...
^CCaught signal!(4)
进程处于就绪状态中...
^CCaught signal!(5)
进程处于就绪状态中...
^Quit

6、进程的信号发送kill

在Linux系统中,kill函数可以用于一个进程向另一个进程发送信号。这对于进程间通信、进程控制以及进程管理等任务非常有用。

kill 函数会向进程idpid的进程发送一个信号signumIDpid的进程会使用signal 函数来接收signum信号,并通过signal 函数做出相对应的响应。

函数原型:

int kill(pid_t pid, int sig);

参数说明:

  • pid_t pid:目标进程或进程组的 IDpid_t 是一个表示进程 ID 的数据类型。pid 参数可以是以下几种值:

    • pid>0:表示发送信号给具有该 PID 的单个进程。

    • pid=0:表示发送信号给与调用进程属于同一进程组的所有进程。也就是调用kill函数的这个进程组的进程都会接收到这个信号。

    • pid=-1:表示发送信号给除了调用进程和 init 进程(PID 为 1)以外的所有进程。通常情况下,这需要调用进程拥有特定权限,例如 root 用户权限。

    • pid<0:表示发送信号给进程组 ID 等于 pid 绝对值的所有进程。换句话说,如果 pid 是 -N(N > 1),则信号将发送给进程组 ID 为 N 的所有进程。

  • int sig:要发送的信号。Linux 支持多种信号,sig 参数可以是整数信号代码,也可以是预定义的信号常量。以下是一些常用的信号及其说明:

    • SIGHUP(1):挂起信号,通常用于通知进程重新读取其配置文件。
    • SIGINT(2):中断信号,通常用于用户通过键盘(如按下 Ctrl+C)发送的中断。
    • SIGQUIT(3):退出信号,通常用于用户通过键盘(如按下 Ctrl+)发送的退出。
    • SIGILL(4):非法指令信号,通常在进程尝试执行非法或未定义的指令时发送。
    • SIGABRT(6):异常中止信号,通常在进程遇到严重错误时发送。
    • SIGFPE(8):浮点异常信号,通常在进程遇到浮点错误时发送。
    • SIGKILL(9):杀死信号,强制结束进程,进程无法捕获或忽略此信号。
    • SIGSEGV(11):段错误信号,通常在进程试图访问非法内存区域时发送。
    • SIGPIPE(13):管道破裂信号,通常在进程向已关闭的管道写入数据时发送。
    • SIGALRM(14):报警信号,通常用于进程超时或定时器到期。
    • SIGTERM(15):终止信号,通知进程优雅地结束,进程可以捕获并执行清理操作。

头文件:

#include <sys/types.h>
#include <signal.h>

返回值:

  • 执行成功则返回 0,如果有错误则返回 -1

    错误代码:

    • EINVAL 参数 sig 不合法

    • ESRCH 参数 pid 所指定的进程或进程组不存在

    • EPERM 权限不够无法传送信号给指定进程

演示程序:

#include <sys/types.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

int main(){
    pid_t pid = fork();
    if(pid == 0){
        for(int i = 0; i < 5; ++i){
            printf("我是子进程n");
            sleep(1);
        }
    }else if(pid > 0){
        printf("我是父进程n");
        sleep(2);
        printf("kill child process nown");
        kill(pid,SIGINT);
    }else{
        perror("fork");
    }

    return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost kill]# gcc -o kill_test kill_test.c
[root@localhost kill]# ./kill_test
我是父进程
我是子进程
我是子进程
kill child process now

7、进程向本身发送信号raise

raise()函数是ANSI C而非POSIX标准定义的,作用是向本进程或线程发送信号。

单线程程序相当于kill(getpid(), sig);多线程程序则相当于pthread_kill(pthread_self(), sig)

函数原型:

int raiseint sig );

参数说明:

  • 参数sig代表要发送信号的编号。

头文件:

#include <signal>

返回值:

  • 成功:返回0

  • 失败:返回-1

演示程序:

#include<stdio.h>
#include<signal.h>

int main(void){
        printf("kill myselfn");

        raise(SIGKILL);//终结当前进程

        return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost raise]# gcc -o raise_test raise_test.c
[root@localhost raise]# ./raise_test
kill myself
Killed

8、进程的定时器alarm

在编程的过程中,很多时候我们需要为程序设置一个闹钟,然后到了闹钟设定的时刻然后再去采取相关的操作。比如进行socket编程时,如果客户端长时间没有与服务器进行交互,需要服务器在一定时间之后主动关闭socket连接。在这种场景下,就可以在服务器收到客户端的socket的连接时,设置一个定时信号,然后在定时信号到来时,关闭掉socket连接即可。

alarm函数可以用于设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送14SIGALRM信号。进程收到该信号,默认动作终止。每个进程都有且只有唯一的一个定时器。

函数原型:

unsigned int alarm(unsigned int seconds);

参数说明:

  • seconds:指定的时间,以秒为单位;alarm()用来设置信号SIGALRM 在经过参数seconds 指定的秒数后传送给目前的进程。如果参数seconds 为0, 则之前设置的闹钟会被取消, 并将剩下的时间返回。

头文件:

#include <unistd.h>

返回值:

  • 返回之前闹钟的剩余秒数,如果之前未设闹钟则返回 0。

说明

  • 常用操作:取消定时器alarm(0),返回旧闹钟余下秒数。

  • alarm使用的是自然定时法,与进程状态无关,就绪、运行、挂起(阻塞、暂停)、终止、僵尸…无论进程处于何种状态,alarm都计时。

演示程序:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

int main(int argc, char *argv[])
{
    int ret = alarm(5);
    printf("ret = %dn", ret);

    sleep(2);
    ret = alarm(6); //重新设定定时器,返回值是返回之前闹钟的剩余的时间
    printf("ret = %dn", ret);

    while (1) {
        printf("-------test-----n");
        sleep(1);
    }

    return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost alarm]# ./alarm_test
ret = 0
ret = 3
-------test-----
-------test-----
-------test-----
-------test-----
-------test-----
-------test-----
Alarm clock

这里第一个 ret = 0 ,是因为第一次调用,它的上一次的剩余时间为 0;然后 ret = 3 是因为 (5 - 2)s,然后定时器被充重置了新的 6s。故后面会打印 6 次输出语句,然后收到系统的终止信号,杀死本进程。因为是执行的 alarm() 的默认动作,为终止进程,且没有捕捉该函数发射的对应的信号,也没有定义自定义动作,所以本进程会被终止。

9、exec函数族

fork函数是用于创建一个子进程,该子进程几乎是父进程的副本。而当我们希望子进程去执行另外的程序时,exec函数族就提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件(这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件),并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新程序的内容替换了

拓展:一般而言,在Linux中使用exec函数族主要有以下两种情况:

  1. 进程认为自己不能再为系统和用户做出任何贡献时,就可以调用任何exec 函数族让自己重生。
  2. 一个进程想执行另一个程序,它可以调用fork函数新建一个进程,然后调用任何一个exec函数使子进程重生。

exec函数族中并没有exec函数,但有6个以exec开头的成员函数,原型如下:

int execl(const char *path, const char *arg, ...)
int execv(const char *path, char *const argv[])
int execle(const char *path, const char *arg, ..., char *const envp[])
int execve(const char *path, char *const argv[], char *const envp[])
int execlp(const char *file, const char *arg, ...)
int execvp(const char *file, char *const argv[])

函数命名解析

  • l(list) : 表示参数采用列表的形式传入如何使用程序或者命令

  • v(vector) : 参数用数组

  • p(path) : 有p自动搜索环境变量PATH

  • e(env) : 表示自己维护环境变量

    Linux干货 | 进程编程基础知识总结

图33-exec函数族说明

参数说明:

  • path:待替换程序的路径,如 /usr/bin/ls

  • file:待替换程序的名称,如 ls

  • arg:待替换程序的选项,如 -a -l等,最后一个参数为 NULL,表示选项传递结束。

  • argv[]:待替换程序名及其命名构成的 指针数组

  • envp[]:传递给待替换程序的环境变量表。

  • … :表示可变参数列表,可以传递多个参数。

头文件:

<unistd.h>

返回值:函数的返回值为int类型,一般而言有两种情况:

  1. 当文件执行成功时,函数不会返回任何东西。

  2. 当文件执行失败时,函数会返回-1,并且失败原因会记录在errno中(<errno.h>头文件中,一个表示错误类型的int)。

execl演示程序:

#include <stdio.h>
#include <unistd.h>

int main()
{
  //execl 函数
  printf("程序替换前,you can see men");
  int ret = execl("/usr/bin/ls""ls""-a""-l"NULL);

  //程序替换多发生于子进程,也可以通过子进程的退出码来判断是否替换成功
  if(ret == -1)
    printf("程序替换失败!n");

  printf("程序替换后,you can see me again?n");
  return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost exec]# gcc -o exec_test exec_test.c
[root@localhost exec]# ./exec_test
程序替换前,you can see me
total 28
drwxr-xr-x 2 root root 42 Oct 23 08:42 .
drwxrwxrwt. 12 root root 4096 Oct 23 08:42 ..
-rwxr-xr-x 1 root root 18152 Oct 23 08:42 exec_test
-rw-r--r-- 1 root root 406 Oct 23 08:42 exec_test.c

上述结果可以看出,程序都已经替换成功,且在execl后续的代码也都将被替换,不会再执行。因此打印语句:程序替换后,you can see me again?n无法再输出到控制台。

execvp演示程序:

#include <stdio.h>
#include <stdlib.h> //exit 函数头文件
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
  //execvp 函数
  pid_t id = fork();
  if(id == 0)
  {
    printf("子进程创建成功 PID:%d   PPID:%dn", getpid(), getppid());
    charconst argv[] =
    {
      "ls",
      "-a",
      "-l",
      NULL
    };

    execvp("ls", argv);

    printf("程序替换失败n");
    exit(-1); //如果子进程有此退出码,说明替换失败
  }

  int status = 0;
  waitpid(id, &status, 0); //父进程阻塞等待
  if(WEXITSTATUS(status) != 255)
  {
    printf("子进程替换成功,程序正常运行 exit_code:%dn", WEXITSTATUS(status));
  }
  else
  {
    printf("子进程替换失败,异常终止 exit_code:%dn", WEXITSTATUS(status));
  }

  return 0;
}

使用gcc编译并执行程序,如下:

[root@localhost exec]# gcc -o execvp_test execvp_test.c
[root@localhost exec]# ./execvp_test
子进程创建成功 PID:3719073 PPID:3719072
total 52
drwxr-xr-x 2 root root 82 Oct 23 08:56 .
drwxrwxrwt. 12 root root 4096 Oct 23 08:56 ..
-rwxr-xr-x 1 root root 18152 Oct 23 08:42 exec_test
-rw-r--r-- 1 root root 406 Oct 23 08:42 exec_test.c
-rwxr-xr-x 1 root root 18464 Oct 23 08:56 execvp_test
-rw-r--r-- 1 root root 818 Oct 23 08:56 execvp_test.c
子进程替换成功,程序正常运行 exit_code:0

10、进程的退出exit

exit() 函数是标准 C 库(也称为 C 标准库或 C89/C90/C99/C11 标准库)中的一个函数,用于正常终止程序的执行。

函数原型:

void exit(int status);

说明

  • exit() 函数的功能是使程序正常终止,并将状态值 status 返回给操作系统。
  • 在程序中使用 exit() 函数时,它会清理所有已经注册的终止函数(通过 atexit() 函数注册)、关闭所有打开的文件流、刷新所有输出缓冲区,并释放动态分配的内存。

参数说明:

  • status:一个整数值,用于表示程序的退出状态。
    • 通常,状态值为0 表示程序正常结束,非零值表示程序异常终止。exit(0) 表示程序正常退出,exit(1) 或者 exit(-1) 表示程序异常退出。

    • 不过,具体的退出状态值及其含义可能因操作系统而异。

      标准C里有EXIT_SUCCESS 和 EXIT_FAILURE 两个宏,定义于头文件 stdlib.h 中:

      / We define these the same for all machines.
         Changes from this to the outside world should be done in `_exit'.  */
      #define EXIT_FAILURE    1       /* Failing exit status.  */
      #define EXIT_SUCCESS    0       /* Successful exit status. */

      推荐在实际编程时候用 exit(EXIT_SUCCESS) 来表示正常退出, exit(EXIT_FAILURE) 来表示异常退出,更加方便代码阅读与理解。

头文件:

#include <stdlib.h> 

返回值:

演示程序:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    FILE *pFile;

    pFile = fopen("example.txt""r");
    if (pFile == NULL) {
        printf("Error: Unable to open the file.n");
        exit(EXIT_FAILURE); // 退出程序,返回非零状态值(通常为 1)
    }

    // 从文件中读取数据、处理数据等操作
    char buf[100] = { 0 };
    while (!feof(pFile)) //没有到文件末尾
    {
        memset(buf, 0sizeof(buf));
        size_t len = fread(buf, sizeof(char), sizeof(buf), pFile);
        printf("buf: %s", buf);
        printf("len: %dn", len);
    }

    fclose(pFile);
    printf("File processed successfully.n");
    exit(EXIT_SUCCESS); // 退出程序,返回零状态值
}

使用gcc编译并执行程序,如下:

[root@localhost exit]# ls
example.txt exit_test exit_test.c
[root@localhost exit]# gcc -o exit_test exit_test.c
[root@localhost exit]# ./exit_test
buf: Hello,world!
len: 13
File processed successfully.

在上述示例中,程序尝试打开名为 example.txt 的文件。如果无法打开该文件(例如,文件不存在或不可读),程序将使用 exit(EXIT_FAILURE) 终止执行并返回非零状态值。

如果文件处理成功,程序将使用 exit(EXIT_SUCCESS)终止执行并返回零状态值。

这里的 EXIT_FAILURE 和 EXIT_SUCCESS 是<stdlib.h> 头文件中预定义的宏,分别表示非零和零状态值。

六、进程总结

本次内容的分享就到这里了,主要叙述了Linux进程的基本概念、进程的状态模型及状态、进程的优先级及查看工具、进程在内核中的结构以及进程相关的API函数等进程相关的基础内容总结。后续可能会继续分享进程相关的内容,如进程间通信,进程的虚拟地址空间等。

参考文档

  1. 并发和并行的区别(图解)
    http://c.biancheng.net/view/9486.html
  2. [Linux进程概念](Linux进程概念 | 建波的学习妙妙屋)
    https://jianbo-study.netlify.app/2023/02/20/4.linux-jin-cheng-gai-nian/


原文始发于微信公众号(Linux二进制):Linux干货 | 进程编程基础知识总结

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/302133.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!