【Linux】进程控制

发布时间:2024年01月19日

讲解控制(操作)进程

1. 进程创建

创建进程可以采用:手动启动 / fork()

  1. 命令行中直接启动进程 – 手动启动
  2. 通过代码进行创建
    启动进程的本质就是创建进程,一般是通过父进程创建的

2. fork函数

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
    int ret = fork();
    if(ret < 0)
    {
        perror("fork error");
        return 1;
    }
    else if(ret == 0)
    {
        //child
        printf("I am child : %d!, ret: %d\n", getpid(), ret);
    }  
    else
    {
        //father
        printf("I am father : %d!, ret: %d\n", getpid(), ret);
    }
    sleep(1);
    return 0;
}

fork() 调用一次,但返回两次:一次在父进程中,一次在子进程中。

  • 在父进程中,fork() 返回新创建的子进程的进程 ID(一个大于 0 的整数);
  • 而在子进程中,fork() 返回 0。这个特性常常被用来“分流”父进程和子进程,即让它们执行不同的代码路径。

代码中:

  • if(ret < 0) 用于检查 fork() 是否成功。
  • else if(ret == 0) 是在子进程中执行的分支,因为在子进程中 fork() 返回 0。
  • else 是在父进程中执行的分支,因为在父进程中 fork() 返回子进程的 PID(一个大于 0 的整数)。

fork函数的返回值

由于这两个返回值不同,父进程和子进程会走不同的逻辑路径,即使它们实际上运行的是相同的代码。这就是所谓的“分流”,使得父子进程能够根据需要执行不同的操作。

[!attention] 总结fork函数做的事情

  1. 找到父进程的pcb对象
  2. malloc(task struct) ,即在内核空间为新的子进程分配一块pcb的空间
  3. 根据父进程pcb ,初始化子进程pcb
  4. 让子进程的pcb指向父进程的代码和数据
  5. 让子进程放入调度队列中,和父进程一样去排队

写时拷贝机制:父子进程的数据共享与独立执行流程

通常,父子代码共享,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
请添加图片描述

[!question] 为什么下面的代码中,同一个变量,同一个地址,内容却不同?

#include <stdio.h>
#include <unistd.h>
 
int main()
{
   int data = 42;
   pid_t pid;
 
   pid = fork();

   if (pid == -1)
   {
       // 错误处理
       perror("fork");
       return 1;
   }
   else if (pid == 0)
   {
       // 子进程
       printf("I am a Child process: PID=%d, PPID=%d\n", getpid(), getppid());
       // 修改子进程的数据
       data = 84;
       printf("Child process: Modified data=%d ,address:%p\n", data, &data);
   }
   else
   {
       // 父进程
       printf("I am a Parent process: PID=%d, Child PID=%d\n", getpid(), pid);
 
       // 父进程休眠等待子进程执行完毕
       sleep(2);
 
       // 父进程访问未修改的数据
       printf("Parent process: Original data=%d ,address:%p\n", data, &data);
   }
   return 0;
}

运行结果:
请添加图片描述


答:因为子进程继承了父进程的虚拟地址空间,而不是物理地址空间。C语言中所谓地址不是实际物理地址,而是进程的虚拟地址空间里的地址。虚拟地址通过页表映射到物理地址。写时拷贝机制在虚拟地址层面运作。

总结一下:

[!abstract]

  1. fork函数做了什么事情?
    fork() 系统调用用于创建一个新的进程。新进程是调用进程(即父进程)的一个几乎完全相同的副本(采用写时拷贝技术),拥有相同的程序计数器、相同的 CPU 寄存器、相同的打开文件用于 I/O、相同的环境变量和相同的程序代码段等。

  1. 为什么 fork 会有两个返回值?
    fork() 实际上只调用一次,但它“返回”两次:一次在父进程中,一次在子进程中。这样设计是为了让父进程和子进程能够轻易地区分自己的角色,从而分别进行不同的操作。

  1. 为什么 fork 的两个返回值,会给父进程返回子进程的 pid,给子进程返回0?
    返回子进程的 PID 给父进程是为了让父进程能够控制或者与子进程进行通信。例如,父进程常常会等待子进程结束。返回 0 给子进程是为了让子进程知道自己是子进程,因为 PID 永远不会是0。

  1. fork之后,父子进程谁先运行?
    这取决于操作系统的调度算法。父进程和子进程是几乎同时创建的,但究竟谁先运行是不确定的。在多处理器系统上,它们甚至可能同时运行。

  1. 如何理解同一个变量,会有不同的值?
    虽然父进程和子进程运行相同的代码,但它们在各自独立的地址空间中执行。这意味着每个进程都有自己的变量副本。因此,相同的变量在父进程和子进程中可以有不同的值。例如,在 fork() 调用之后,ret 变量在父进程中将包含子进程的 PID,在子进程中则包含 0。

3. 进程查看

  • 进程ID(PID)
  • 父进程ID(PPID)

查询进程ID:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
	printf("pid: %d\n", getpid());
	printf("ppid: %d\n", getppid());
	return 0;
}
方法1:
ps axj | grep test | grep -v grep

这个命令用于在运行中的进程列表中查找名为 “test” 的进程。它包含了三个主要的部分,分别是 ps auxgrep testgrep -v grep,这些部分通过管道(|)连接在一起。

  1. ps aux:这个命令列出了系统上所有的运行中的进程的详细信息。其中:

    • a 表示列出所有进程。
    • u 表示以用户为主的格式列出进程。
    • x 表示列出没有控制终端的进程。
  2. grep test:这个命令会搜索传入的文本内容,查找包含 “test” 的行。在这个情景下,它用于从 ps aux 的输出中找到名为 “test” 的进程。

  3. grep -v grep:这个命令用于从传入的文本内容中过滤掉包含 “grep” 的行。这是因为在执行 ps aux | grep test 时,grep test 本身也是一个进程,也会出现在输出结果中。通过添加 grep -v grep,这个 grep 进程就被过滤掉了。

可以写一个循环:

while :; do ps ajx | head -1 && ps ajx grep ProcessName; sleep 1; echo "-------------------"; done
方法2:
ls /proc/29847 -l 

3. 进程终止与退出状态

进程的退出码

  • 为0表示进程运行结果正确;
  • 非0表示运行结果错误;

对于非0来说,不同的数字有又对应着不同的错误,我们可以自己设定不同退出码所对应的错误信息,也可以使用系统提供的退出码映射关系:

// cat proc.c
#include <stdio.h>
#include <string.h>

int main()
{
    for(int i=0; i<100; i++)
    {
        printf("%d:%s\n", i, strerror(i));
    }
    return 0;
}
[chen@Ali-CentOS-7 testprocess]$ ./proc
0:Success
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
...

查看进程退出状态的方法:$?

使用 echo $? 来查看最近一个进程的退出码:
请添加图片描述

进程正常与异常进程终止

  • 正常进程终止: 当进程运行结束后,正常退出系统时,称为正常进程终止。进程正常终止时,会向父进程发送一个SIGCHLD信号,父进程可以调用wait()waitpid()函数来获取子进程的退出状态。
  • 异常进程终止: 当进程在运行过程中由于某种错误或异常而被终止时,称为异常进程终止。异常进程终止时,会向父进程发送一个SIGKILL信号,父进程不能通过wait()waitpid()函数获取子进程的退出状态。

exit()、_exit()和return的区别

1. exit()是库函数

头文件:stdlib.h
函数原型:void exit(int status);
status:status 定义了进程的终止状态,父进程通过wait来获取该值
函数功能:终止进程

我们平时接触最多的就是通过 main 函数 return 返回来退出进程,但其实我们也可以通过库函数 exit 和系统调用 _exit 来直接终止进程;

请添加图片描述

2. _exit()是系统调用

头文件:unistd.h
函数原型:void _exit(int status);
status:status 定义了进程的终止状态,父进程通过wait来获取该值
函数功能:终止进程

函数_exit()“立即”终止调用进程。任何属于该进程的打开的文件描述符都将关闭。进程的所有子进程都由1号进程 (init进程)继承,而进程的父进程会收到一个 SIGCHLD 信号。
status值作为进程的退出状态返回给父进程,并且可以使用wait(2)家族调用中的一个来收集。
函数_Exit()等价于_Exit()。

请添加图片描述

3. return

return语句用于从函数中返回。当函数调用结束时,return语句会将控制权返回给调用该函数的函数。return语句可以接受一个参数,该参数表示函数的返回值。执行 return n 等同于执行 exit(n) ,因为调用main的运行时函数会将main的返回值当做 exit的参数。

总结现象:

  • 首先,由于 exit 是C语言库函数,而 _exit 是系统调用,所以可以肯定的是 exit 的底层调用 _exit 函数,exit 是 _exit 的封装;

  • 其次,由于计算机体系结构的限制,CPU之和内存交互,所以数据会先被写入到缓冲区,待缓冲区刷新时才被打印到显示器上;而在上面的程序中,我们没用使用 ‘\n’ 进行行缓冲的刷新,可以看到,exit 最后打印了 “hello linux”,而 _exit 什么都没有打印;所以 exit 在终止程序后会刷新缓冲区,而 _exit 终止程序后不会刷新缓冲区

    请添加图片描述

  • 最后,由于 exit 的底层是 _exit,而 _exit 并不会刷新缓冲区,也可以反映出缓冲区不在操作系统内部,而是在用户空间

[!attention] 退出码和退出状态通常有着一一对应的关系,但并不总是如此。

在大多数情况下,退出码和退出状态是一一对应的,这意味着每个退出状态都有一个对应的退出码,反之亦然。例如,在 Linux 系统中,退出状态 0 通常对应退出码 0,表示进程成功终止;退出状态 1 通常对应退出码 1,表示进程遇到错误而终止。

然而,也存在一些例外情况,导致退出码和退出状态不一一对应。 例如:

  • 当进程收到某些信号(如 SIGKILLSIGSTOP) 时,它可能会被立即终止,而不会执行任何清理工作。在这种情况下,进程的退出状态可能为非零,但退出码可能为 0。
  • 当进程使用 _exit() 函数终止时,它不会向父进程发送 SIGCHLD 信号。在这种情况下,父进程无法获取子进程的退出状态,因此退出状态可能为未知值。
  • 当进程使用 exit(0) 函数终止时,无论进程实际的运行结果如何,其退出状态和退出码都为 0。这可能会导致父进程或 shell 误认为进程成功终止。

总的来说,退出码和退出状态通常有着一一对应的关系,但并不总是如此。在某些情况下,可能会出现退出码和退出状态不一一对应的情况。

为了避免退出码和退出状态不一一对应的潜在问题,建议进程使用 exit() 函数来终止,并使用非零的退出码来指示错误或异常情况。


4. 进程等待

进程等待的必要性

  • 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
  • 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
  • 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息

fork创建了子进程,子进程帮父进程完成某种任务后,父进程需要用 wait或者waitpid等待子进程的退出,下面介绍这两种方法

wait方法

请添加图片描述

头文件和函数原型:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);

返回值:
成功时返回等待进程的pid,失败返回-1。

参数:
输出型参数,获取子进程的退出状态,不关心则设置成为NULL

测试:
让子进程访问空指针报错,父进程通过status变量读取退出信息

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

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
         // child
        int cnt = 5;
        while(cnt)
        {
             printf("Child is running: cnt=%d, PID:%d, PPID=%d\n", cnt, getpid(), getppid());
            --cnt;
            printf("child process access nullptr!!!\n");
            int *p=NULL;
            *p=100;
            sleep(1);
        }
        exit(0);
    }
    sleep(10);
    printf("father wait begin\n");
    
	
    // father
    int status;
    pid_t ret = wait(&status);
    if(ret > 0)
    {
        printf("father wait: %d, success, status: %d\n", ret, status);
    }
    else
    {
        printf("father wait failed\n");
    }
	
    sleep(10);
    return 0;
}

子进程的状态从睡眠状态,变成僵尸进程(因为子进程先退出,父进程过了一会才来检查退出信息),最后被父进程回收。

waitpid方法

函数原型:

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

返回值:

  1. 正常返回:waitpid返回收集到的子进程的进程pid
  2. 如果options设置成 WNOHANG 选项,表示非阻塞等待,调用时waitpid发现自己没有退出的子进程可以收集,返回0
  3. 如果调用出错,则返回-1,这时 errno 会被设置成相应的值,指示错误所在。

参数:

  1. pid:
    pid=-1时,表示任一子进程,等同于wait
    pid>0,等待id和pid相等的子进程。

  2. status:
    WIFEXITED(status):查看进程是否正常退出,正常退出-真
    WEXITSTATUS(status):查看进程的退出码,WIFEXITED非零,提取子进程的退出码。

  3. options
    WNOHANG:如果pid的子进程没有结束,那么waitpid返回值是0,不予以等待,如果正常结束,返回该子进程的id。
    0:表示非阻塞等待

用waitpid实现非阻塞等待:

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

int main()
{
	pid_t pid = fork();
	
	if(pid < 0)
	{
		printf("%s fork error\n", __FUNCTION__);
		return 1;
	}
	else if(pid == 0) 
	{ // child
		printf("child is run, pid is: %d\n", getpid());
		int cnt = 5;
		while(cnt)
		{
			printf("child[%d] is running, cnt = %d\n", getpid(), cnt);
			cnt--;
			sleep(1);
		}
		exit(1);
	} 
	else 
	{
		// father
		int status = 0;
		pid_t ret = 0;
	
		do 
		{
			ret = waitpid(-1, &status,WNOHANG); // 非阻塞式等待
			if(ret == 0) 
			{
				// 子进程还没结束
				printf("Do father things\n");
			}
			sleep(1);
		}while(ret == 0);
	
		if(WIFEXITED(status) &&ret == pid)
		{
			printf("wait child success, child return code is :%d\n", WEXITSTATUS(status));
		}
		else 
		{
			printf("wait child failed, return.\n");
			return 1;
		}
	}
	return 0;
}

用waitpid实现阻塞等待:

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

int main()
{
	pid_t pid = fork();
	
	if(pid < 0) 
	{
		printf("%s fork error\n", __FUNCTION__);
		return 1;
	}
	else if(pid == 0) 
	{ // child
		printf("child is run, pid is: %d\n", getpid());
		int cnt = 5;
		while(cnt)
		{
			printf("child[%d] is running, cnt = %d\n", getpid(), cnt);
			cnt--;
			sleep(1);
		}
		exit(1);
	} 
	else 
	{
		int status = 0;
		pid_t ret = waitpid(-1, &status, 0); // 阻塞式等待
		printf("wait test...\n");
		if(WIFEXITED(status) && ret == pid)
		{
			printf("wait child success, child return code is :%d\n", WEXITSTATUS(status));
		}
		else 
		{
			printf("wait child failed, return.\n");
			return 1;
		}
	}
	return 0;
}

5. 进程替换(后续新写一篇)

fork 函数一般有两种用途 – 创建子进程来执行父进程的部分代码以及创建子进程来执行不同的程序,创建子进程来执行不同的程序就是进程程序替换

进程程序替换是指父进程用 fork 创建子进程后,子进程通过调用 exec 系列函数来执行另一个程序;当进程调用某一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,然后从新程序的启动例程开始执行;

但是原进程的 task_struct 和 mm_struct 以及进程 id 都不会改变,页表可能会变;所以调用 exec 并不会创建新进程,而是让原进程去执行另外一个程序的代码和数据。

进程替换的原理

进程替换的函数 - exec函数家族


文章来源:https://blog.csdn.net/m0_73898917/article/details/135670646
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。