Linux/Uinx 系统编程:进程管理(1)
文章目录
- Linux/Uinx 系统编程:进程管理(1)
- 什么是进程
- 进程来源
- INIT 和 守护进程
- 登录进程
- sh进程
- 进程的执行模式
- 进程管理的系统调用
- 关于syscall中参数b,c,d的作用
- fork()
- 进程执行顺序
- nice()
- sched_yield
- 进程终止
- 正常终止
- wait() 等待子进程终止
- 异常终止
- 等待子进程终止
在学习编程内容之前先来学习一些基础知识:
- 什么是进程
- PROC结构体(PCB)是什么?
- 什么是挂载?
- 系统创建进程时都干了什么
- 登录进程
- sh进程
- 进程执行模式
等等…
如果你不想学习和了解或者已经了解并熟悉了以上内容,你可以直接通过目录跳转到 :进程管理的系统调用
什么是进程
在操作系统里面,任务也被称作进程。
进程的正式定义:进程是对映像的执行
- 程序:是静态的,就是存放在磁盘文件上的可执行文件如
tim.exe
- 进程:是动态的,是程序的一次执行过程,如:可以同时启动多次Tim程序
同一个程序可以执行多次进程
程序段、数据段、PCB三部分组成了进程实体(进程映像)引入进程实体的概念后,可把进程定义为:
进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
一个进程被“调度”,就是指操作系统决定让这个进程上CPU被运行
程序段
- 程序的代码(指令序列)
数据段
- 运行过程中产生的各种数据(如:程序中定义的变量)
PCB(PROC结构体)
PCB是进程存在的唯一标志
当进程被创建时,操作系统会为该进程分配一个唯一的、不重复的“身份证号”——PID (Process ID,进程ID)
操作系统要记录PID
、进程所属用户ID (UID)
还要记录给进程分配了哪些资源(如:分配了多少内存、正在使用哪些I/0
设备、正在使用哪些文件)
还要记录进程的运行情况(如:CPU
使用时间、磁盘使用情况、网络流量使用情况等)
PCB也被称作PROC结构体
PROC结构体的内容大概是这样的:
typedef struct proc {struct proc *next; // next proc pointerint *ksp; // saved stack pointerint pid; // pid = 0 to NPROC - 1int ppid; // parent pidint status; // PROC statusint priority; // scheduling priorityint kstack[SSIZE]; // process stack
} PROC;
以上为最简单的格式,有时会根据具体需求再进行增加其他字段。
简单来说PCB中有以下的一些信息:
- 进程描述信息
- 进程控制和管理信息
- 资源分配清单
- 处理机相关信息
挂载
简单来说就是操作系统将一片物理的存储空间识别并调用(准确来说是挂接在一个已经存在的目录下,建议以此为准)的过程。
例如windows识别新的U盘/硬盘并且分配一个盘符D、E、F…
在linux中,没有windows中盘符的概念,只有根目录/
,当插入新的硬盘时,我们无法从shell去访问这个块硬盘(新插入的硬盘在/dev/sdbx
但是不可访问,你可能觉得这是一个目录,但是并不可以访问,sdbx是一个类似于指针的东西,指向的是新硬盘中的原始数据块,在没有挂载之前,系统并不知道如何使用这片区域),这个时候需要执行挂载指令:
mount /dev/sdb1 ~/newDIR
这个指令的作用是将新硬盘空间识别并将其作为一个目录放在~/
下,目录名为newDIR
进程来源
当操作系统启动时,内核的启动代码通常会创建一个PID = 0
的初始进程, 通过分配PROC结构体(通常是PROC[0])进行创建。然后让指向运行进程的指针指向该结构体P0
。
在P0
中继续初始化系统,包括系统硬件和内核数据结构。
在此期间,他会挂载一个根文件系统,使得系统可以使用文件。在初始化系统之后,P0复刻出一个子进程P1
,并且把进程切换为以用户模式运行的状态去运行P1
INIT 和 守护进程
当进程P1
开始运行时,它将其执行映像更改为INIT程序。
因此P1
进程通常被称作INIT进程,目的是对当前系统环境进一步初始化。
具体做法是生成很多子进程,大部分子进程为系统服务,在后台运行,不与用户交互。
这种进程叫做守护进程
例如:
- syslogd:log daemon process
- inetd:Internet service daemon process
- httpd:HTTP server daemon process
- etc.
登录进程
除了守护进程之外,P1
还复刻了许多LOGIN进程,每个终端上一个,用于用户登录。
每个LOGIN进程打开三个与自己的终端相关联的文件流,分别是:
- stdin:用于标准输入
- stdout:用于标准输出
- stderr:用于错误信息输出
每个文件流都是指向进程堆区中FILE结构体的指针。每个FILE结构体记录一个文件描述符号(数字),stdin为0,stdout为1,stderr是2
然后每个LOGIN进程向stdout显示一个:
login:
等待用户登录。
用户账户保存在/etc/passwd
和/etc/shadow
中。每个用户账户在表单的/etc/passwd
文件中都有一行对应的记录:
name:x:gid:uid:description:home:program
其中:
- name:用户名
- x:密码
- gid:用户组ID
- uid:用户名ID
- home:用户主目录
- program:用户登录后执行的初始程序
用户的其他信息在:/etc/shadow
文件中。
其中包括了加密的用户密码,可选的过期限制信息(如过期时期和时间等)。
sh进程
用户登录成功之后,LOGIN进程会获取用户的gid
和uid
,从而成为用户的进程。
它将目录更改为用户的主目录并执行列出的程序,通常是命令解释程序sh。
这个sh进程就是我们通常所说的shell。
一些特殊命令(cd,退出,注销等)由sh自己执行,其他的大部分命令是存放在bin目录中的可执行文件(/bin, /sbin, /usr/bin, /usr/local/bin),对于这些命令,shell创建一个新的进程来执行这些命令,结束后返回到shell。
shell进程为父进程,执行的命令为子进程,子进程将执行映像更改为命令文件并执行命令程序。子进程在终止时会唤醒父进程sh,父进程会收集子进程的终止状态、释放子进程PROC结构体并提示执行另一个命令等
进程的执行模式
在 Linux/Uinx 中,进程以两种不同的模式执行:
- 内核模式(Kmode)也叫内核态
- 用户模式(Umode)也叫用户态
CPU中有一个状态寄存器,可以记录为K模式还是U模式。
在内核模式下,CPU通过修改状态寄存器就可以实现更改执行模式的状态,即从内核模式切换到用户模式。
但是在用户模式下是不能修改状态寄存器的,此时只能通过下面三种方式来进入内核态:
- 中断:外部设备发送给CPU请求CPU服务的信号
- 陷阱:错误条件,例如无效地址、非法指令、除以0等。在Linux/Uinx中,内核陷阱处理程序是将陷阱原因转换为信号编号,并将信号传递给进程。对于大多数信号,进程的默认操作是终止。
- 系统调用:简称syscall,是一种允许用户模式下进程进入内核模式执行内核函数的机制。当某个进程执行完内核函数之后,它将期望结果和一个返回值返回给用户模式下的程序,该值通常为0或者-1,表示成功或者失败。如果发生错误,外部全局变量errno(包含在errno.h中)会包含一个ERROR代码,用于标识错误。用户可以使用库函数
perror
打印错误信息。
当进程进入内核态的时候,它可能不会立即返回到用户态。某些情况下甚至永远不会返回用户态,例如
_exit()
和大多数陷阱会导致程序在内核态中终止,当某个进程即将退出内核态时,系统可能会切换进程来允许一个具有更高优先级的进程。
进程管理的系统调用
主要有以下四个:
- fork():创建子进程
- wait():等待子进程
- exec():更改进程执行映像
- exit():终止退出
这四个函数的本质实际上都是调用了下面这个函数,只不过a的值不同而已。
int syscall(int a, int b, int c, int d);
a表示系统调用号,b、c、d表示对核函数的参数。在Intel x86系统的Linux中,系统调用是由汇编指令INT 0x80实现的,使得CPU进入Linux内核来执行由系统调用号a标识的核函数。
关于syscall中参数b,c,d的作用
其实就是系统调用的参数,例如,当你调用 read() 系统调用时,你会提供一个文件描述符(文件的唯一标识)、一个存放数据的缓冲区地址和一个表示要读取的字节数。这些信息就是通过 b、c 和 d 这三个参数传递给 syscall 函数的。
fork()
函数描述:
fork()
函数是Linux系统中的一个系统调用,它用于创建一个新的进程。新的进程被称为子进程,而调用fork()
函数的进程被称为父进程。子进程是父进程的一个副本,它从父进程继承了大部分的环境,例如文件描述符、环境变量和程序计数器等。
每个用户在同一时间只能有数量有限的进程。用户资源限制可在/etc/security/limits.conf
设置。
用户可运行:
ulimit -a
来查看各种资源限制值。
函数原型:
#include <unistd.h>
pid_t fork(void);
各参数说明:
fork()
函数没有参数。
执行过程:
当调用fork()
函数时,操作系统会创建一个新的进程。新的进程(子进程)几乎是原进程(父进程)的完全复制品,它们的代码、数据和堆栈等都是一样的。
本质上就是复制进程映像
但是,子进程有自己的进程标识符,它的许多值(如某些资源使用量)也被设置为初始值。子进程会继承父进程的用户ID和组ID,继承父进程的文件模式创建屏蔽字,继承父进程的信号处理方式等。
底层实现:
fork()
函数的具体实现依赖于操作系统,一般来说,它会调用内核中的一些函数(主要为kfork()
)来分配资源(如内存),复制父进程的状态,并在进程表中创建一个新的条目。
执行示例:
#include <stdio.h>
#include <unistd.h>int main() {pid_t pid = fork();if (pid < 0) {printf("Fork failed!\n");} else if (pid == 0) {printf("This is the child process, with id %d\n", getpid());} else {printf("This is the parent process, with id %d\n", getpid());}return 0;
}
返回值:
fork()
函数的返回值是一个类型为pid_t
的值。如果fork()
调用成功,那么在父进程中,fork()
返回新创建的子进程的进程ID;在子进程中,fork()
返回0。如果fork()
调用失败,它将在父进程中返回-1。
执行结果:
在父进程中,fork()
函数返回新创建的子进程的进程ID。在子进程中,fork()
函数返回0。如果创建新进程失败,fork()
函数将返回-1,并设置errno
以指示错误。常见的错误包括EAGAIN
(系统限制了可以创建的进程总数)和ENOMEM
(没有足够的内存来创建新的进程数据结构)。
根据fork的执行属性,可以发现根据fork的返回值是判断当前进程(是子进程还是父进程)的唯一方法
因此可以得出判断父进程和子进程的程序框架:
int pid = fork();
if (pid) {// parent executes this part
} else {// child executes this part
}
进程执行顺序
fork()
执行结束之后,父进程和子进程与系统中的其他进程竞争CPU的运行时间。
哪一个任务率先完成并没有顺序,取决于它们的调度优先级,优先级呈现出动态化
下面举出例子:
/*************************************************************************> File Name: fork.c> Author:Royi > Mail:royi990001@gmail.com > Created Time: Thu 25 Jan 2024 04:21:54 PM CST> Describe:Describes the order in which the fork() function generates the parent-child process************************************************************************/#include <stdio.h>
#include <unistd.h>int main() {int pid = fork();if (pid) {printf("I'm PARENT %d, my CHILD = %d\n", getpid(), pid);//sleep(1);printf("PARENT %d EXIT\n", getpid());} else {printf("I'm CHILD %d ,my PARENT = %d\n", getpid(), getppid());//sleep(2);printf("child %d exit my parent = %d \n", getpid(), getppid());}return 0;
}
读者可以通过给上文代码的sleep()
取消和添加注释来查看父子进程的执行顺序,有以下几种情况:
-
全部注释:多次运行结果
第一种情况是先执行父进程,再执行子进程,第二种情况是先执行父进程,父进程结束后,shell进程弹出命令,此时子进程结束,归根结底还是父进程先执行,子进程再执行。值得注意的是,会发现情况中出现子进程的parent = 1的情况,这种情况在后文解释 -
取消父进程注释,子进程注释保留:多次运行发现只有一种情况
运行时会发现前三行先输出,1秒之后最后一行输出。下面是具体的执行细节:
首先,父进程调用fork()
函数,此时,进程一分为2,由于此时父进程没有退出CPU,因此继续执行父进程,父进程执行sleep
函数,调度器认为此时可以将父进程挂起,进而去执行子进程,此时子进程进入CPU,此时输出第二、第三行,子进程运行结束,1秒后父进程返回CPU继续执行输出最后一句话 -
取消第二行注释,但不取消第一行注释:多次运行后结果如下:
与上面的执行类似,CPU先执行父进程,创建子进程之后,继续运行父进程然后结束,此时切换至shell进程执行,然后再切换至子进程运行输出,等待一秒之后输出最后一行信息。这里也发现子进程的parent = 1的情况,这些情况放到后面来说。 -
取消所有注释:运行结果和第二种方式一样:
不同的细节在于,这种情况下,先输出前两句,1秒后输出第三句,1秒后输出第四句,读者可以猜想一下具体执行过程。
除了sleep函数可以让进程延迟几秒之外,Linux/Uinx还提供了以下几种函数会影响进程执行顺序:
- nice(int inc)
- sched_yield(void)
下面给出详细信息,读者不需要细看,等到深入学习之后可以再细究
nice()
函数描述
nice()
函数是用于调整进程运行的优先级。在Linux中,进程运行的优先级分为-20~19等40个级别,其中,数值越小运行优先级越高,数值越大运行优先级越低。函数nice()
是将当前进程运行的优先级增加指定值,即用当前进程运行的优先级加上指定值得到新的优先级,然后用新的优先级运行该进程。当计算出来的值小于-20,则进程将以优先级-20运行;当计算出来的值大于19,则进程将以优先级19运行。
函数原型
int nice(int inc);
各参数说明
inc
:指定优先级增加的值。若增加正值,则表示降低进程运行优先级;若增加负值,则表示升高进程运行优先级。但只有具有超级用户权限的用户才可以以负数作为函数的参数,否则该函数将返回错误。
执行过程
nice()
函数将当前进程运行的优先级增加指定值,即用当前进程运行的优先级加上指定值得到新的优先级,然后用新的优先级运行该进程。
底层实现
关于nice()
函数的底层实现,它是通过修改进程的优先级来实现的。在Linux中,进程的优先级是由一个介于-20到19的整数来表示的,这个整数越小,进程的优先级就越高。nice()
函数就是通过增加这个整数(即降低优先级)或减少这个整数(即提高优先级)来改变进程的优先级。
执行示例
#include <stdio.h> /* printf */
#include <stdlib.h> /* atoi, system, exit */
#include <errno.h> /* errno */
#include <string.h> /* strerror */
#include <unistd.h> /* nice */int main (int argc, char *argv [])
{int adjustment = 0;int ret;if (argc > 1) {adjustment = atoi(argv[1]);}ret = nice(adjustment);printf("nice(%d):%d\n", adjustment, ret);if (-1 == ret) {if (errno == EACCES) {printf("Cannot set priority:%s.\n", strerror(errno));exit(-1);}}system("nice");exit(0);
}
返回值
若操作成功,函数将返回调整后的进程运行的优先级;若操作失败,函数将返回-1。注意:当函数返回-1时,不一定就是函数操作失败。因为若函数成功调整进程运行优先级后的优先级为-1,函数也返回-1,所以在判断函数是否操作失败时,除了判断函数返回的值是否为-1外,还需要查看errno
的值是否为相关错误码。
执行结果
执行结果取决于nice()
函数是否成功调整了进程的优先级。如果成功,那么进程的优先级将被调整,如果失败,那么进程的优先级将保持不变。如果调用nice()
函数的用户没有超级用户权限,但是试图提高进程的优先级(即inc
参数为负数),那么nice()
函数将返回错误,并且errno
将被设置为EACCES
。
sched_yield
函数描述
sched_yield()
函数的作用是让出处理器,调用时会导致当前线程放弃CPU,进程管理系统会把该线程放到其对应优先级的CPU静态进程队列的尾端,然后一个新的线程会占用CPU。
函数原型
#include <sched.h>
int sched_yield(void);
此函数没有参数。
执行过程
sched_yield()
函数可以使另一个级别等于或高于当前线程的线程先运行。如果没有符合条件的线程,那么这个函数将会立刻返回然后继续执行当前线程的程序。
底层实现
在Linux 2.6以前的版本中,sched_yield()
所造成的影响非常小,如果存在另一个可以运行的进程,内核就切换到该进程,把进行调用的进程放在可运行进程列表的结尾处。短期内内核会对该进程进行重新调度。这样的话可能出现“乒乓球”现象,也就是两个程序来回运行,直到他们都运行结束。2.6版本中做了一些改变:如果进程是RR,把它放到可运行进程结尾,返回。否则,把它从可运行进程列表移除,放到到期进程列表,这样在其他可运行进程时间片用完之前不会再运行该进程。从可执行进程列表中找到另一个要执行的进程。
执行示例
#include <stdio.h>
#include <sched.h>int main() {int ret = sched_yield();if (ret == -1) {printf("调用sched_yield失败!\n");}return 0;
}
返回值
在成功完成之后返回零,否则返回-1。
执行结果
执行结果取决于sched_yield()
函数是否成功让出了CPU。如果成功,那么当前线程的CPU占有权将被让出,然后把线程放到静态优先队列的尾端,然后一个新的线程会占用CPU。如果失败,那么当前线程将继续执行。
进程终止
执行程序映像的进程可以有两种方式终止:
- 正常终止
- 异常终止
正常终止
每个C程序的main()
函数,都是由C启动代码crt0.o
调用的。如果main函数执行成功,那么函数将会返回到crt0.o
,然后调用库函数的exit(0)
来终止进程
exit()
函数的执行过程具体如下:
首先,执行清理工作,刷新stdout(此时其中没有被输出的信息将会被全部输出,具体内容请看你真的理解printf函数吗)、关闭I/O流等。然后发出一个系统调用_exit(value)
,使进入内核态的进程终止。退出值0通常表示正常终止。
如果需要,你可以在程序的任何位置调用
exit()
,不必返回到crt0.o
,再直接一点的话,进程可能会发出_exit(value)
系统调用立即执行终止,不必先进行清理工作。当内核中的某个进程终止时,它会将_exit(value)
系统调用中的值记录到进程PROC结构体中的状态字(status = EXIT),并通知它的父进程并使该进程成为僵尸进程。父进程可通过系统调用wait()
找到僵尸进程,获得其PID和退出状态。
pid = wait(int *status);
它还会清空僵尸子进程PROC结构体,使得该结构可被另一个进程重用。
wait() 等待子进程终止
在任何时候,一个进程都可以调用
int pid = wait(int *status);
函数描述
wait()
函数是用于让父进程等待子进程结束。当子进程结束后,wait()
函数会收集子进程的信息(exitCode),并将其彻底销毁。如果没有找到已经结束的子进程,wait()
函数会一直阻塞,直到有一个子进程结束。
函数原型
#include <sys/types.h> /* 提供类型pid_t的定义 */
#include <sys/wait.h>
pid_t wait(int *status);
各参数说明
status
:指向int类型的指针,用于保存被收集进程退出时的状态。如果我们对子进程是如何结束的不在乎,只想把这个僵尸进程消灭掉,我们就可以设定这个参数为NULL。
执行过程
进程一旦调用了wait()
,就立即阻塞自己,由wait()
自动分析是否当前进程的某个子进程已经退出。如果让它找到了这样一个已经变成僵尸的子进程,wait()
就会收集这个子进程的信息,并把它彻底销毁后返回。如果没有找到这样一个子进程,wait()
就会一直阻塞在这里,直到有一个出现为止。
底层实现
wait()
函数的底层实现主要是通过阻塞父进程,等待子进程的结束。具体来说调用了内核中的kwait()
函数,当子进程结束后,wait()
函数会收集子进程的信息,并将其彻底销毁。这样可以防止子进程变成僵尸进程。
执行示例
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>int main() {pid_t pc, pr;int status;pc = fork();if (pc < 0) {printf("fork error!\n");} else if (pc == 0) {printf("This is child process with pid of %d\n", getpid());sleep(10);} else {pr = wait(NULL);printf("I catched a child process with pid of %d\n", pr);}return 0;
}
返回值
如果成功,wait()
会返回被收集的子进程的进程ID。如果调用进程没有子进程,调用就会失败,此时wait()
返回-1,同时errno
被置为ECHILD
。
执行结果
执行结果取决于wait()
函数是否成功收集了子进程的信息并将其销毁。如果成功,那么子进程的信息将被收集并销毁,父进程将继续执行。如果失败,那么父进程将继续阻塞,直到有一个子进程结束。
异常终止
当进程遇到错误(如非法指令、越权、除零等)时,这些错误被CPU标记为异常。
当某个进程遇到异常时,会陷入操作系统内核。
内核的异常处理程序将陷阱错误类型转换为一个幻数,称为信号,将信号传递给进程,使得进程终止。在这种情况下,僵尸进程的退出状态是信号编号。
除了陷阱错误,信号也可能来自硬件或其他进程。例如:按下"Crtl + C"组合键会产生一个硬件中断信号,它会向该终端上的所有进程发送这个信号,使进程终止。
除此之外,也可以使用命令:
kill -s singnl_number pid
向通过pid识别的目标进程发送信号。
进程终止时,最终都会在操作系统内核中调用kexit()
函数,这里不再细讲,以后我会出详细文章再做介绍。
每个PROC都有一个2字节的退出代码(exitCode)字段,用于记录进程退出状态。如果进程正常终止,exitCode的高位字节是_exit(exitValue)
系统调用中的exitValue
,低位字节是导致异常终止的的信号数值。因为一个进程只能死亡一次,所以只有一个字节有意义。
等待子进程终止
接下来根据一个示例程序分析理解wait()
函数的作用:
/*************************************************************************> File Name: wait.c> Author:Royi > Mail:royi990001@gmail.com > Created Time: Thu 25 Jan 2024 07:09:56 PM CST> Describe: ************************************************************************/#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void main() {pid_t pid;int status;pid = fork();if (pid) {printf("I'm Parent %d, waitting for child %d to DIE\n", getpid(), pid);pid = wait(&status);printf("DEAD child = %d, status = 0x%04x\n", pid, status);} else {printf("child %d dies by exit(VALUE)\n", getpid());exit(100);}return ;
}
运行结果如下:
由于有wait()
函数的存在,程序不会出现因为进程优先级的动态顺序而出现不同进程先运行的情况。
可以看出status = 0x6400
,上文提到每个PROC都有两个字节的退出代码字段,在程序中,我们调用的exit(100)
中的100就是这个退出字段,表现在status中就是其高两位(0x64 = 100)
除此之外,某个进程还可以使用系统调用:
int pid = waitpid(int pid, int *status, int options);
等待由pid指定的具有多个选项的特定僵尸子进程。
wait(&status)
等于waitpid(-i, &status, 0)
,详细内容读者可以参考Linux手册页。