Linux操作系统之进程(六):进程的控制(上)

发布于:2025-06-19 ⋅ 阅读:(12) ⋅ 点赞:(0)

目录

前言:

一、进程创建

1.1 fork函数

1.2 fork函数返回值

1.3 写时拷贝

1.4 fork的常规用法

二、进程终止 

2.1、退出码

2.2、进程终止的方式

 三、进程等待

3.1、等待的必要性

3.2 wait与waitpid

wait

waitpid

status的提取问题 

 四、重谈进程退出

总结:


前言:

前面关于进程的五篇博客主要给大家讲解的是一些进程的相关概念,从本篇开始,将为大家带来有关我们进程控制的内容。

我们怎么在我们的代码中控制、管理我们的进程呢?进程有哪些基本的用图呢?

希望通过本篇博客,能够给大家解决这些疑惑。

一、进程创建

1.1 fork函数

在linux系统中,fork是一个十分重要的函数,它从一个已经存在的进程中创建一个新的进程。这个进度进程为原进程的子进程,原进程为父进程。

进程调用fork,当控制转移到内核中的fork代码后,内核会做四件事情:

1、分配新的内存块与内核数据结构(PCB)给子进程

2、将父进程部分数据结构内容拷贝到子进程

3、添加子进程到系统进程列表中

4、fork返回,开始调度器调度

当一个进程调用fork之后,就有两个二进制代码相同的进程。而且他们都运行到了相同的地方(复制了指令指针寄存器EIP/RIP,相当于程序执行的书签)。但每个进程都将可以开始他们自己的旅程。
譬如,我们有以下代码:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>

int main()
{
    printf("我是进程:%d\n",getpid());

    pid_t id = fork();
    if(id == 0)
    {
        printf("我是子进程:%d\n",getpid());
    }
    else
    {
        printf("我是父进程:%d\n",getpid());
    }
}

运行的结果就是:

可以看见,子进程从fork返回后开始执行,不会重复fork之前的代码。这是因为内核复制了父进程的执行上下文(包括EIP/RIP寄存器),让子进程直接从fork的下一条指令继续运行。父子进程的执行顺序由内核调度器决定,不同系统可能有不同表现。

1.2 fork函数返回值

fork函数的返回值主要有三种,当fork运行错误时,会返回-1。当运行成功时,对于子进程,会返回0;对于父进程,会返回子进程的pid


1.3 写时拷贝

通常,父子进程的代码是共享的。当父子进程都不再写入数据时,他们的数据也是共享的。当任意一方试图写入数据,就会触发写时拷贝,使其各自的数据分开来
我在上文讲到过isexist,借此也给大家介绍了写时拷贝的机制,它本质上是依靠页表来实现的。
在fork之后,父进程首先要做的事将代码与数据的权限全部改为只读,所以子进程的权限也是只读。子进程尝试修改,页表会识别对只读区域进行写入,触发系统错误,导致 缺页中断,让系统去做检测,如果修改的是代码区,那就直接杀进程,如果是数据区(因为数据区本身就是读写权限,可现在是只读),那就判定为发生写时拷贝。
为什么要在申请新的内存空间的基础上将原本的数据拷贝一份呢?因为新的数据的修改可能是在原本数据上的覆盖,比如:count++。
写时拷贝,是⼀种延时申请技术,可以提高整机内存的使用率。因为有写时拷贝技术的存在,所以父子进程得以彻底分离!完成了进程独立性的技术保证!

1.4 fork的常规用法

说到底,fiork有两种常规的用法:
 

1、一个父进程希望创建子进程复制自己,随后使得父子进程执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。

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

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        printf("我是子进程:%d,我的父进程是:%d\n",getpid(),getppid());
        printf("执行子进程专属代码");
        sleep(3);
    }
    else if(id > 0)
    {
        printf("我是父进程:%d,我的子进程是:%d\n",getpid(),id);
        printf("执行父进程专属代码");
        sleep(1);
    }
    else
    {
        printf("fork失败\n");
    }
    return 0;
}

2、一个进程要执行一个不同的程序。例如,子进程从fork返回后,调用exec等一系列的函数。

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


int main()
{
    pid_t pid = fork();
    if (pid == 0) 
    {
    // 子进程加载新程序
    execl("/bin/ls", "ls", "-l", NULL);
    // 若exec失败才会执行以下代码
    perror("exec failed");

    _exit(EXIT_FAILURE);  // 必须用_exit避免刷新父进程缓冲区
    
    } 
    else if (pid > 0) 
    {
        // 父进程继续执行原程序
        waitpid(pid, NULL, 0); 
        printf("子进程已退出\n");
    }
    return 0;
}

大家可能对exec系列的函数不太熟悉,包括我们所使用的配套的waitpid,没关系,我们会在后面的内容进行讲解,这里只是给大家示范一下第二种用法。

二、进程终止 

进程终止是操作系统管理进程生命周期的关键环节,其本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据与代码。

2.1、退出码

在C标准库中,存在着errnostrerror这对组合。error是一个定义在errno.h的全局变量,strerror通过传进来的出错误码errno,可以给出字符串。通过打印这个字符串就能够让我们知道错误的原因。

errno 负责捕获错误本质,strerror 负责翻译错误真相,二者协作构成了 Linux 系统编程中高效、精准、标准化的错误处理基石。

例如,在文件操作中,我们有时经常出现错误:

int main()
{
    //打开文件前的错误信息
    printf("errno: %d ,errstring: %s\n",errno,strerror(errno));

    FILE*fp=fopen("./log.txt","r");

    if(fp==nullptr)
    {
        //打开文件失败后的错误信息
        printf("errno: %d ,errstring: %s\n",errno,strerror(errno));
        return errno;
    }
    return 0;
}

在我系统下没有./log.txt时就会出错,此时我们就能获取原因:No such file or directory,不存在这样一个文件或者目录

那么系统中给我们提供了多少个错误码呢?

根据操作系统的不同,有着不同的错误码,我们看一眼试着打印一下linux系统的:

int main()
{
    for(int i=1;i<200;++i)
    {
        std::cout<<i<<": "<<strerror(i)<<std::endl;
    }
    return 0;
}

 结果如下:

1: Operation not permitted
2: No such file or directory
3: No such process
4: Interrupted system call
5: Input/output error
6: No such device or address
7: Argument list too long
8: Exec format error
9: Bad file descriptor
10: No child processes
11: Resource temporarily unavailable
12: Cannot allocate memory
13: Permission denied
14: Bad address
15: Block device required
16: Device or resource busy
17: File exists
18: Invalid cross-device link
19: No such device
20: Not a directory
21: Is a directory
22: Invalid argument
23: Too many open files in system
24: Too many open files
25: Inappropriate ioctl for device
26: Text file busy
27: File too large
28: No space left on device
29: Illegal seek
30: Read-only file system
31: Too many links
32: Broken pipe
33: Numerical argument out of domain
34: Numerical result out of range
35: Resource deadlock avoided
36: File name too long
37: No locks available
38: Function not implemented
39: Directory not empty
40: Too many levels of symbolic links
41: Unknown error 41
42: No message of desired type
43: Identifier removed
44: Channel number out of range
45: Level 2 not synchronized
46: Level 3 halted
47: Level 3 reset
48: Link number out of range
49: Protocol driver not attached
50: No CSI structure available
51: Level 2 halted
52: Invalid exchange
53: Invalid request descriptor
54: Exchange full
55: No anode
56: Invalid request code
57: Invalid slot
58: Unknown error 58
59: Bad font file format
60: Device not a stream
61: No data available
62: Timer expired
63: Out of streams resources
64: Machine is not on the network
65: Package not installed
66: Object is remote
67: Link has been severed
68: Advertise error
69: Srmount error
70: Communication error on send
71: Protocol error
72: Multihop attempted
73: RFS specific error
74: Bad message
75: Value too large for defined data type
76: Name not unique on network
77: File descriptor in bad state
78: Remote address changed
79: Can not access a needed shared library
80: Accessing a corrupted shared library
81: .lib section in a.out corrupted
82: Attempting to link in too many shared libraries
83: Cannot exec a shared library directly
84: Invalid or incomplete multibyte or wide character
85: Interrupted system call should be restarted
86: Streams pipe error
87: Too many users
88: Socket operation on non-socket
89: Destination address required
90: Message too long
91: Protocol wrong type for socket
92: Protocol not available
93: Protocol not supported
94: Socket type not supported
95: Operation not supported
96: Protocol family not supported
97: Address family not supported by protocol
98: Address already in use
99: Cannot assign requested address
100: Network is down
101: Network is unreachable
102: Network dropped connection on reset
103: Software caused connection abort
104: Connection reset by peer
105: No buffer space available
106: Transport endpoint is already connected
107: Transport endpoint is not connected
108: Cannot send after transport endpoint shutdown
109: Too many references: cannot splice
110: Connection timed out
111: Connection refused
112: Host is down
113: No route to host
114: Operation already in progress
115: Operation now in progress
116: Stale file handle
117: Structure needs cleaning
118: Not a XENIX named type file
119: No XENIX semaphores available
120: Is a named type file
121: Remote I/O error
122: Disk quota exceeded
123: No medium found
124: Wrong medium type
125: Operation canceled
126: Required key not available
127: Key has expired
128: Key has been revoked
129: Key was rejected by service
130: Owner died
131: State not recoverable
132: Operation not possible due to RF-kill
133: Memory page has hardware error
134: Unknown error 134
135: Unknown error 135
...省略
195: Unknown error 195
196: Unknown error 196
197: Unknown error 197
198: Unknown error 198
199: Unknown error 199

可以看得出来,有133个是系统规定了的错误类型。 

同学们,那我们就必须要使用系统给的错误码吗?

不是的,我们写的代码跟系统强相关,自然可以使用系统给的错误码。写的毫无关系,自然可以自己规定一套错误码。

2.2、进程终止的方式

一般来说,程序正常终止有三种情况(我们在linux系统下可以通过echo $?指令来查看进程的退出码):

1、从main函数返回

2、任意地方调用exit

3、_exit终止

异常退出:
ctrl + c退出,信号终止

在退出的时候,程序可以给我们返回退出码,随后让我们的值他是否完成了预期的任务。其基本思想就是,我们规定程序返回代码0时才表示执行成功,返回其他任意代码都被视为不成功。

大家在学习C语言或者C++语言时,曾经天天都在写main函数。main函数也是一个函数,自然有自己的返回值。main函数的返回值类型主要有两种类型,最主要的是int main,另外一个是void main(void就是没有返回值)。那么大家有没有想过,为什么main函数会有返回值呢?这个返回值又能给谁呢?

答案是返回给父进程或者系统

我们通过子进程的main函数的返回值,就能确定子进程的执行的结果。这其实也是我们前面提到过一点的。return n等同于执⾏exit(n),因为调⽤main的运⾏时函数会将main的返回值当做 exit的参数。

main函数中的exit(0)可以平替return 0,但是在其他地方不能平替。

比如,非main函数的return,其实只代表这个函数的结束,但是非main函数调用exit,代表进程的结束。

而exit与_exit呢?他们的主要区别还是在于exit最后也会调用_exit, 但在调用_exit之前,还做了其他⼯作:

1、执⾏⽤⼾通过 atexit或on_exit定义的清理函数。

2、关闭所有打开的流,所有的缓存数据均被写入

3、 调⽤_exit

 我们给大家做个演示:

int main()
{
    printf("你好,这是一个打印");
    exit(0);
}

这个程序的运行结果是:

而我们使用_exit():

int main()
{
    printf("你好,这是一个打印");
    _exit(0);
}

什么也都不会打印,这是因为exit() 属于标准 C 库函数,它会:

1、刷新所有 stdio 缓冲区(包括 printf 未刷新的输出)

2、 调用通过  atexit()  注册的清理函数
3、 最终调用  _exit()  系统调用终止进程

_exit() 是直接的系统调用(定义在 <unistd.h>),它会:

1、立即终止进程,不刷新任何缓冲区

2跳过所有标准库的清理流程

3、由于 printf 的输出在行缓冲模式下(无换行符 \n)未被刷新,内容丢失

 三、进程等待

一般而言,父进程创建了子进程,父进程就要等待子进程,直到子进程结束。 

3.1、等待的必要性

为什么需要父进程来等待呢?

之前在进程状态时讲过,子进程退出,如果父进程不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏。

另外,进程一旦变成僵尸状态,那就刀枪不入,哪怕使用kill函数调用也无能为力,因为谁也没有办法杀死一个已经死去的进程。

最后,父进程派给子进程的任务完成的怎么样了?我们需要知道,如子进程运行完成,结果是对是错,或者是否正常退出。

总的来说,父进程通过进程等待的方式,回收子进程资源获取子进程退出的相关信息。

3.2 wait与waitpid

我们主要有两个函数来进行父进程的等待:wait与waitpid
 
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

status是一个指向整数类型的指针,用于存储子进程的退出信息,如果为空指针,就表示不关心子进程的退出状态,他是一个输出型的参数,我们可以用这个参数来检查子进程的退出状态。

函数运行成功后会返回终止的子进程的PID,如果失败了就返回-1,并且设置errno。

使用wait函数时,当我们的子进程一直不退出,父进程就会阻塞到wait函数内部。 

对于waitpid的参数,pid指定要等待的子进程 ,pid大于0时,等待进程ID等于pid的子进程;pid为-1时代表任意一个子进程,类似于wait;等于0时,等待与调用进程同进程组的任意子进程;小于0时,等待进程组ID等于pid绝对值的任意子进程。

status参数同上,是一个输出型参数。options参数用于控制函数行为(可组合使用):

  • WNOHANG:非阻塞模式,如果没有子进程退出立即返回0

  • WUNTRACED:也返回停止的子进程状态

  • WCONTINUED:也返回已继续的子进程状态

 我们写点代码来认识一下这几个函数:

wait


int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        int cnt=10;
        while(cnt--)
        {
            printf("子进程运行中:%d\n",getpid());
            sleep(1);
        }   
    }
    else if(id > 0)
    {
        pid_t rid=wait(nullptr);
        if(rid>0)
        {
            printf("等待成功,rid:%d\n",rid);
        }
        
        while(1)
        {
            printf("我是父进程:%d\n",getpid());
            sleep(1);
        }
    }
    else
    {
        printf("errno: %d ,errstring: %s\n",errno,strerror(errno));
        return errno;
    }
    return 0;
}

这个代码创建了一个子进程运行十秒钟,之后让父进程用wait来回收子进程 

运行以上代码,在另外一个bash中输入以下指令进行循环的进程状态查看,

while :; do ps ajx | head -1 && ps ajx | grep test; sleep 1; done

我们可以看到这个结果

可以看见,过程中并未出现僵尸状态,因为僵尸已经被我们父进程回收了。

那我们在使用一下waitpid吧,我先将以上的代码简单的更改一下:

waitpid

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        int cnt=3;
        while(cnt--)
        {
            printf("子进程运行中:%d\n",getpid());
            sleep(1);
        }   
        exit(123);//我们设定一下子进程的退出码为123
    }
    else if(id > 0)
    {
        //pid_t rid=wait(nullptr);
        int status;
        pid_t rid=waitpid(id,&status,0);
        if(rid>0)
        {
            printf("Child %d exited with status %d\n",rid, status);//试着让父进程打印一下status
        }
        
        while(1)
        {
            printf("我是父进程:%d\n",getpid());
            sleep(1);
        }
    }
    else
    {
        printf("errno: %d ,errstring: %s\n",errno,strerror(errno));
        return errno;
    }
    return 0;
}

我们故意让子进程退出码信息为123,然后再父进程回收时,打印status,却发现,打印的status的结果不是123:

这是怎么回事呢?

status的提取问题 

status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16
比特位)

 也就是说,我们想要查看进程的退出码,我们需要提取出低16位的高八位:(status >> 8) & 0xFF,我们也有一个标准宏来实现这个过程:WEXITSTATUS(status)。

 (阻塞options的问题我们之后在谈)

 四、重谈进程退出

了解了以上的知识,我们就知道了,一般来说,进程退出有三种情况:
1、代码跑完了,结果是对的,返回0。

2、代码跑完了,结果不对,返回非0。

3、代码没跑完,异常了。

 前两种是我们一直在说的通过退出码判定,最后一种这是最复杂的情况,通常是被信号(Signal)终止的 ,进程退出信息会记录退出的退出信号(低7位)。也就是说,status会同时记录退出码与退出信号,同样可以通过标准宏的手段,让我们知道退出信号是什么。

常见的退出信号原因包括但不限于:

除零错误 (SIGFPE)

段错误 (SIGSEGV)

用户中断 (SIGINT, 如Ctrl+C)

强制终止 (SIGKILL)

总线错误 (SIGBUS)

以野指针来说,我们之前提到过, 野指针实际上指向的是虚拟地址,在使用的时候,可能会出现指向的那个地址权限不对,或者根本不存在对应映射,所以可能会杀掉进程 ,导致程序崩溃。

杀掉进程的手段,就会触发段错误(Segmentation Fault),系统发送SIGSEGV信号(信号编号11)给进程。

总结:

我们本篇文章主要是讲了进程创建,进程的终止,以及进程等待的部分内容,了解了写时拷贝,以及退出码等信息。关于进程的等待,还有一点内容,options参数并未向大家详细介绍,这个我会放在下一篇文章讲解。

关于进程的控制这一章节,我打算划分两篇给大家讲解,下一篇的内容应该包含进程等待的阻塞选项问题,然后,会为大家讲一下进程的替换的内容,这一部分非常重要,并借此给大家简单展示一下每隔xx秒就自动存档记录的原理。讲完进程替换,就会为大家基于我们之前所学的所有内容做一个进程的总结并以此写一个我们shell的模拟实现,就是我们平时使用的SHell命令行解释器的模拟实现。

鼠鼠我最近要备战期末周,所以更新肯定会延迟到月底去了,希望大家多多包含,祝福我高数下别挂科(我高数上都没学过就直接让我学高数下吗,有意思)。

月底再跟大家见面啦!希望本篇文章的内容能够对您有所帮助!谢谢!!! 


网站公告

今日签到

点亮在社区的每一天
去签到