当前位置: 首页 > article >正文

linux文件编程_进程

1. 进程相关概念

  • 面试中关于进程,应该会问的的几个问题:
1.1. 什么是程序,什么是进程,有什么区别?
  • 程序是静态的概念,比如:

  • 磁盘中生成的a.out文件,就叫做:程序
  • 进程是程序的一次运行活动,通俗点意思是程序跑起来了,系统中就多了一个进程
  • 程序是静态的概念,进程是动态的概念

简单的来说,没有跑起来的文件叫做程序(是静态概念),例如调用gcc test.c -o test 生成的这个test文件,它就是一个程序,当这个程序跑起来就是进程(动态概念)。进程是程序的一次执行,也就是说每执行一次程序,它就会生成一个新的进程。

1.2. 如何查看系统中有哪些进程?
a.使用ps指令查看

ps -aux

//查看当前运行的进程

  • ps指令显示的进程不够完整
  • ps -aux显示完整但是篇幅太长,不方便我们查看

由于当前的程序时非常多的,所以我们要使用grep指令进行筛选,例如:查看当前进程中带有 “init” 字样的进程

ps -aux|grep init(文件名)

b.使用top指令查看,类似windows任务管理器

1.3. 什么是进程的标识符

进程标识符(process identifier,又略称为进程ID,或者PID)是大多数操作系统的内核用于唯一标识进程的一个数值。这一数值可以作为许多函数调用的参数,以使调整进程优先级、杀死进程之类的进程控制行为成为可能。

在各 PID 中,较为特别的是 0 号 PID 和 1 号 PID。PID 为 0 者为交换进程(swapper),属于内核进程,负责分页任务;PID 为 1 者则常为 init 进程,主要负责启动与关闭系统。值得一提的是,1 号 PID 本来并非是特意为 init 进程预留的,而 init 进程之所以拥有这一 PID,则是因为 init 即是内核创建的第一个进程。不过,现今的许多 UNIX/类 UNIX 系统内核也有以进程形式存在的其他组成部分,而在这种情况下,1 号 PID 则仍为 init 进程保有,以与之前系统保持一致。

每个进程都有一个非负整数表示的唯一ID,叫做pid,类似身份证

  • Pid=0:
  • 称为交换进程(swapper)
  • 作用—进程调度(类似windows任务管理器,在某一个时刻,哪个程序CPU占用多少,内存占用多少,去管理)
  • Pid=1:init进程
  • 作用—系统初始化(程序初始化界面,比如wx打开是一个地球,QQ打开是一个企鹅)
1.3.1. 获取进程标识符函数gitpid函数原型和头文件:
/*
	Linux下 man 2 gitpid查看手册
*/
#include <sys/types.h>
#include <unistd.h>
 
pid_t getpid(void);				getpid函数作用:获取自身的进程标识符
pid_t getppid(void);			getppid函数作用: 获取父进程的进程标识符   
 
pid_t	获取到的进程标识符
 
1.3.2. 获取自身的进程标识符案例:
  • 代码设计
/*
	Linux下 man 2 gitpid查看手册
*/
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
 
int main()
{
     /*	pid_t pid;
    声明一个变量 pid,它的类型是 pid_t,这是用于表示进程ID的类型。
    
    pid = getpid();
    调用 getpid() 函数来获取当前进程的进程ID,并将其存储在 pid 变量中。
*/
	pid_t pid;

	pid = getpid();

	printf("my pid is %d\n",pid);

	while(1);
	return 0;
}
  • 实现结果

1.4. 什么是父进程,什么是子进程

进程A创建了进程B;

那么A叫做父进程,B叫做子进程,父子进程是相对的概念,理解为人类中的父子关系。

1.5. C程序的存储空间是如何分配的?

参考《UNIX环境高级编程》的第七章:进程环境 7.6节:C程序的存储空间布局:

正文段:又叫做:代码段,这是有CPU执行的机器指令部分。通常正文段是可以共享的,并且是只读的

初始化数据段:通常将此段作为数据段,它包含了程序中需要明确的赋初值的变量,比如函数外的声明:int cnt = 10;

非初始化数据段:通常此数据段称为bss段(block start symbol),在程序开始执行之前,内核将此段中的数据初始化为0或空指针。比如函数外声明:int arr[100];

堆:通常在堆中进行动态存储分配,由于历史上的惯例,堆位于非初始化数据段和栈之间

栈:自动变量以及每次函数调用所需保存的信息都存放在此段中。调用函数其返回地址也保存在栈中。递归函数每调用一次自身,就是用一个新的栈帧,这样一个函数调用中的变量集就不会影响另一个函数调用函数的变量

=================================================================

================================================================

2. 创建进程函数fork的使用

2.1. 进程创建函数fork函数原型和头文件:
/*
	Linux下 man 2 fork查看手册
*/
 #include <unistd.h>
 
pid_t fork(void);
 
无参数     
pid_t		是一个宏定义,其实质是int 被定义在<sys/types.h>中
    
fork函数调用成功,返回两次
返回值为0		代表当前进程是子进程
返回值非负数	   代表当前进程为父进程
    
调用失败,返回-1

代码实现:

/*
 	Linux下 man 2 fork查看手册
	fork 
*/
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
 
int main()
{
	pid_t pid;

	pid = getpid();
	
	fork();
	printf("my pid is %d,fork pid is %d\n",pid,getpid());

	return 0;
}
先从上到下,正常运行一遍程序,然后fork 创建一个子进程,在运行一编
这段代码会在 fork() 之后创建一个子进程。由于 fork() 后的代码会在父进程和子进程中各执行一次,
因此 printf 语句会被执行两次,一次是在父进程中,一次是在子进程中。
打印输出中,my pid is 的值在父子进程中是相同的,而 fork pid is 的值在父子进程中是不同的。

fork() 之后,程序会创建一个子进程。fork() 函数会返回两次:一次在父进程中返回子进程的 PID,一次在子进程中返回 0。具体到你的代码,在调用 fork() 之前,你已经使用 getpid() 获取并存储了当前进程的 PID 到 pid 变量中。这个 pid 变量在 fork() 之后会在父进程和子进程中分别存在,并保持相同的值,因为子进程是父进程的一个副本。

所以,在 printf("my pid is %d,fork pid is %d\n",pid,getpid()); 中,pid 变量始终保持父进程的 PID,而 getpid() 则返回当前进程的 PID。对于父进程,pidgetpid() 输出的是同一个值;对于子进程,pid 仍然是父进程的 PID,而 getpid() 则返回子进程的 PID。

综上所述,在 fork 之后 pid 变量不变,仍然是父进程的 PID,因为 pid 是在 fork 之前被设置的,并被 fork 复制给了子进程。

2.2 函数说明:

一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。 子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。注意,子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。 UNIX将复制父进程的地址空间内容给子进程,因此,子进程有了独立的地址空间。在不同的UNIX系统下,我们无法确定fork之后是子进程先运行还是父进程先运行,这依赖于系统的实现。所以在移植代码的时候我们不应该对此作出任何的假设。

由于在复制时复制了父进程的堆栈段,所以两个进程都停留在fork函数中,等待返回。因此fork函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。

在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。

调用fork之后,数据、堆、栈有两份,代码仍然为一份但是这个代码段成为两个进程的共享代码段都从fork函数中返回,箭头表示各自的执行处。当父子进程有一个想要修改数据或者堆栈时,两个进程真正分裂。

引用一位网友的话来解释Pid的值为什么在父子进程中不同。“其实就相当于链表,进程形成了链表,父进程的Pid指向子进程的进程id,因为子进程没有子进程,所以其Pid为0。”

2.3 编程实现创建子进程 并且分别获取子进程和父进程的PID号:

根据父进程和子进程的pid不同的特点,我们可以在创建进程之前获取一次进程pid,这是父进程的pid,创建进程之后再一次获取进程pid,并通过判断两次pid是否相同判断哪个是父进程pid,哪个是子进程pid

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
 
int main()
{
    pid_t pid;
    pid_t pid2;
 
    pid = getpid();                     //获取fork之前进程PID
    printf("fork之前PID = %d\n",pid);
 
    fork();                             //创建一个子进程
 
    pid2 = getpid();                    //获取fork之后进程PID
    printf("fork之后PID = %d\n",pid2);
 
    if(pid == pid2){                    //如果pid == pid2代表是父进程
        printf("父进程PID= %d\n",getpid());
    }else{                              //如果pid != pid2代表是子进程
        printf("子进程PID,子进程PID = %d\n",getpid());
    }
    
    return 0;
}
  • 代码实现:

2.4 根据fork函数的返回值也可以判断父子进程:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
 
int main()
{
	pid_t pid;
	pid_t pid2;
	pid_t retpid;

	pid = getpid();
	printf("before forl : pid = %d\n",pid);	
	
	retpid =fork();
	
	pid2 = getpid();
	printf("after fork : pid = %d\n",pid2);

	if(retpid > 0)
	{
 			printf("父进程PID,retpid = %d,父进程PID = %d\n",retpid,getpid());
	}
	else if(retpid == 0){
 			printf("子进程PID,retpid = %d,子进程PID = %d\n",retpid,getpid());
	}
	return 0;
}

代码实现:

  • fork之前只有一个父进程在运行,父进程的PID是17804,然后调用fork函数创建了一个子进程,

fork调用成功后返回两次,两次返回唯一的区别是:子进程返回0值,父进程返回子进程PID是17804.

2.5 程序的存储空间是如何分配的?

fork()父子进程的代码和数据的复制问题:

进程数据=代码+数据

父进程创建子进程时,代码共享(因为代码在内存中一般为只读),数据私有(写时拷贝),这也就解释了上面的fork()为什么会有两个不同的返回值。

之前Linux系统采用完全拷贝,将父进程的内存地址和内容都重新拷贝一份 现在Linux系统采用写实拷贝,只有再对某一变量运用时才执行拷贝

写实拷贝: 当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。 只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
 
int main()
{
    pid_t pid;
    int data = 10;
 
    printf("父进程PID = %d\n",getpid());    //获取父进程PID
 
    pid = fork();                           //创建一个子进程
    
    if(pid > 0){                            //返回值如果是非负数代表是父进程
        printf("PID > 0代表是父进程返回值 = %d,父进程PID = %d\n",pid,getpid()); //父进程的返回值是子进程的PID
    }else if(pid == 0){
        printf("PID = 0代表是子进程返回值 = %d,子进程PID = %d\n",pid,getpid()); //子进程的返回值是0
        data = data + 100;
    }
 
    printf("data = %d\n",data);
    return 0;
}

可以看到,data的值在子进程中改变时data = data +100;,是通过重新赋值了父进程的数据段修改的,父进程的data值没有改变

2.6 fork创建子进程的目的:

一个父进程希望复制自己,使父、子进程同时执行不同的代码段。这在网络服务进程中是常见的——父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求到达。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
 
int main()
{
    pid_t pid;
	
	int data ;
	
	while(1){
		printf("please input a data\n");
		scanf("%d",&data);
		if(data == 1)
		{
			pid  = fork();
			if(pid > 0)
			{
				 printf("父进程PID = %d \n",getpid());
			}
			else if(pid ==0)
			{
				while(1){
				
			//如果pid != pid2代表是子进程
					printf("子进程PID,子进程PID = %d\n",getpid());
					sleep(3);
				}
			}
		}
		else{
			printf("wait ,do no thie\n");
		}
    }
	
    return 0;
}

2.一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程从fork返回后立即调用exec。

3. vfork函数创建进程

3.1. 进程创建函数vfork函数原型和头文件:
#include <sys/types.h>
#include <unistd.h>
 
pid_t vfork(void);
 
无参数     
pid_t		是一个宏定义,其实质是int 被定义在<sys/types.h>中
    
fork函数调用成功,返回两次
返回值为0		代表当前进程是子进程
返回值非负数	   代表当前进程为父进程   
调用失败,返回-1
    
vfork - 创建子进程并阻塞父进程

既然vfork函数也可以创建进程,与fork的区别是什么?

  • vfork函数与fork函数的关键区别一:
3.2. vfork保证子进程先运行,当子进程调用exit退出后,父进程才执行

首先我们在fork的时候:

int main()
{
    pid_t pid;
    pid = fork();
    if(pid > 0)
    {
        while(1){
            printf("this is father print , pid = %d \n",getpid());
            sleep(1);
        }
    }

    else if(pid == 0)
    {
        while(1){
            printf("this is chilid print, pid = %d\n",getpid());
            sleep(1);
        }
    }       

    return 0;
} 
3.2.1. 代码解析
  1. 调用 fork() 创建子进程:
pid_t pid;
pid = fork();
    • fork() 会创建一个新的子进程,返回两次:一次在父进程中返回子进程的 PID(一个正整数),一次在子进程中返回 0。
    • 如果 fork() 返回负数,则表示创建子进程失败(代码中未处理此情况)。
  1. 区分父进程和子进程:
    • 父进程pid > 0):
if(pid > 0)
{
    while(1){
        printf("this is father print, pid = %d \n", getpid());
        sleep(1);
    }
}

在父进程中,fork() 返回子进程的 PID(正数)。进入这个 if 块后,父进程会进入一个无限循环,每秒打印一次自己的 PID,并输出 "this is father print, pid = ..."。

    • 子进程pid == 0):
else if(pid == 0)
{
    while(1){
        printf("this is child print, pid = %d\n", getpid());
        sleep(1);
    }
}

在子进程中,fork() 返回 0。进入这个 else if 块后,子进程会进入一个无限循环,每秒打印一次自己的 PID,并输出 "this is child print, pid = ...”。

3.2.2. 程序的执行效果
  • 并行运行: 父进程和子进程是两个独立的进程,它们的执行是并行的。由于进程的调度由操作系统管理,因此输出会交替出现。
  • 输出交替显示: 终端中会看到父进程和子进程的输出不断交替或混合显示,具体顺序取决于系统调度。
3.2.3. 示例输出

当你运行这个程序时,终端可能会显示如下内容:

3.2.4. 注意事项
  1. 无限循环: 由于两个进程都进入了无限循环(while(1)),程序将不会自动退出,必须手动终止(例如,通过 Ctrl + C)。
  2. 终端输出冲突: 因为父进程和子进程同时向同一个终端输出,输出可能会出现交错或混合的情况。这是正常的,因为两个进程是在并行执行。
  3. 系统资源消耗: 长时间运行该程序会占用系统资源(如 CPU 和内存),所以在测试或调试时注意及时停止程序。

首先我们在fork的时候:父进程和子进程同时运行!

然后我们在vfork的时候:

int main()
{
    pid_t pid;
	pid = vfork();
	if(pid > 0)
	{
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			sleep(1);
		}
	}

	else if(pid == 0)
	{	
		while(1){
			printf("PID = 0	this is chilid print, pid = %d\n",getpid());
			sleep(1);
		}
	}	

	return 0;
}
  • vfork函数调用之后,只运行子进程

我们在vfork的时候:当子进程不退出的时候,父进程无法运行

  • 当改成让子进程执行三次退出后:
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
 
int main()
{
    	pid_t pid;
	int cnt = 0;
	pid = vfork();
	if(pid > 0)
	{
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			sleep(1);
		}
	}

	else if(pid == 0)
	{	
		while(1){
			printf("PID = 0 this is chilid print, pid = %d\n",getpid());
			sleep(1);
			cnt++;
			if(cnt == 3){
                printf("子进程退出\n");
			break;
			}
		}
	}	

	return 0;
}

当使用vfork 函数之后 运行else if语句中的父进程,子进程运行3次之后,使用break函数结束循环,进入到if语句中运行父进程。

我们可以看见当子进程正常退出之后,父进程才执行。

3.3. vfork函数与fork函数的关键区别二:
  • vfork直接使用父进程存储空间,与父进程共享数据段,不拷贝。

int main()
{
        pid_t pid;
        int cnt = 0;
        pid = vfork();
        if(pid > 0)
        {
                while(1){
                        printf("this is father print , pid = %d \n",getpid());
                        printf(" printf在父进程cnt = %d\n",cnt);
                        sleep(1);

                }
        }

        else if(pid == 0)
        {
                while(1){
                        printf("PID = 0 this is chilid print, pid = %d\n",getpid());
                        sleep(1);
                        cnt++;
                        if(cnt == 3){
                         printf("子进程退出\n");
                                exit(0);
                        }
                }
        }

        return 0;
}

可以发现当子进程改变cnt的值之后,父进程的cnt也在改变,因为改变的同一个cnt。

4. 进程退出

4.1. 进程退出的三种情况:
  • 代码运行完毕,结果正确
    • exit() 、 _exit() 、_Exit()。
  • 代码运行完毕,结果不正确
  • 代码异常终止,进程崩溃
4.2. 进程退出码:

main函数是间接性被操作系统所调用的。当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行过程中出现错误,这就是为什么我们都在main函数的最后返回0的原因。 当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的进程退出码,可以使用echo $?命令查看最近一次进程退出的退出码信息。

比如:

  • 代码正常运行结束后可以用echo $?命令查看退出码是0

使用echo $? 命令查看之后是 0,属于正常退出

  • 当代码被强行结束(ctrl+c)echo $?命令查看退出码是130

使用echo $? 命令查看之后是 130,属于强制退出

4.3. 进程正常退出:
  • 从man函数返回,即调用return函数
  • 调用exit,标准C语言库
  • 调用_exit或者 _Exit,属于系统调用
  • 进程最后一个线程返回
  • 最后一个线程调用pthread_exit
  • 最后一个线程对取消(cancellation)请求做出响应

5. 父进程等待子进程退出

5.1. 为什么父进程要等待子进程退出:
  • 父进程等待子进程退出并收集子进程退出状态,如果子进程退出状态不被收集,那么子进程会变成僵尸进程。
  • 僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程

的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。

  • 在之前我们使用vfork创建子进程的时候(子进程运行结束之后,在运行父进程),子进程退出时没有被父进程收集其退出的状态,因此子进程最终会

变成“僵尸进程”。

int main()
{
     pid_t pid;
	int cnt = 0;
	pid = vfork();
	if(pid > 0)
	{
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			printf(" printf在父进程cnt = %d\n",cnt);
			sleep(1);
			}
	}
	else if(pid == 0)
	{	
		while(1){
			printf("PID = 0 this is chilid print, pid = %d\n",getpid());
			sleep(1);
			cnt++;
			if(cnt == 3){
			 printf("子进程退出\n");
			exit(0);
			}
		}
	}	

	return 0;
}

  • 当我们使用fork创建子进程的时候(子父进程同时运行),子进程运行三次之后退出,父进程没有收集其退出的状态,其子进程也会成为”僵尸进程“。
int main()
{
    pid_t pid;
	int cnt = 0;
	pid = fork();
	if(pid > 0)
	{
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			printf(" printf在父进程cnt = %d\n",cnt);
			sleep(1);
			
			}
	}
	else if(pid == 0)
	{	
		while(1){
			printf("PID = 0 this is chilid print, pid = %d\n",getpid());
			sleep(1);
			cnt++;
            if(cnt == 3){
                         printf("进程退出\n");
                         exit(0);
                        }
			}
		}
	return 0;
}

5.2. 进程等待相关函数wait原型和头文件
/*
	Linux下man 2 wait查看手册
*/
#include <sys/types.h>
#include <sys/wait.h>
 
pid_t wait(int *wstatus);
 
pit_t			函数返回值,等待成功返回被等待进程的PID,等待失败则返回-1
int *wstatus	输出型参数,获取子进程的退出状态(传入的是整型指针),不关心可设置为NULL。
5.2.1. 父进程调用wait函数可以回收子进程终止信息。该函数有三个功能:
  • 阻塞等待子进程退出wait 会一直阻塞父进程,直到有一个子进程终止。这意味着父进程会暂停执行,直到至少有一个子进程结束。
  • 回收子进程残留资源:当子进程终止后,它的进程资源会暂时保留在系统中,成为“僵尸进程”。调用 wait 可以回收这些资源,避免系统资源浪费。
  • 获取子进程结束状态:通过 wait 函数的参数 status,父进程可以获取子进程的退出状态,这个状态能够提供子进程是如何终止的信息。
    • wait一旦被调用,就会一直阻塞在这里,直到有一个子进程退出出现为止。
    • 调用成功,则清理掉的子进程ID,失败则返回-1,表示没有子进程。
    • 使用wait函数传出参数status来保存进程的退出状态(正常终止→退出值;异常终止→终止信号)。

借助宏函数来进一步判断进程终止的具体原因。

5.3. 使用wait函数实现父进程等待子进程退出:
  • 使用wait(NULL)函数,等待子进程结束,子进程被清除,然后进入一个无限循环,不断打印自身的 PID 和变量 cnt 的值。
int main()
{
    	pid_t pid;
	int cnt = 0;
	pid = vfork();
	if(pid > 0)
	{
		wait(NULL);
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			printf(" printf在父进程cnt = %d\n",cnt);
			sleep(1);
                        }
		}

	else if(pid == 0)
	{	
		while(1){
			printf("PID = 0 this is chilid print, pid = %d\n",getpid());
			sleep(1);
			cnt++;
			if(cnt == 3){
			 printf("子进程退出\n");
				exit(0);
			}
		}
	}	

	return 0;
}

5.4. 检查wait和waitpid所返回的终止状态的宏:

正常结束

  • WIFEXITED(status): 如果返回值为非 0,表示子进程是正常结束的(例如通过调用 exit() 或从主函数返回)。
    • WEXITSTATUS(status): 当 WIFEXITED(status) 为真时,可以使用此宏获取子进程的退出状态。

这个退出状态是子进程调用 exit() 时传递的参数,或主函数返回的值。

异常终止

  • WIFSIGNALED(status): 如果返回值为非 0,表示子进程因信号而异常终止(例如未捕获的信号如 SIGKILLSIGSEGV 等)。
    • WTERMSIG(status): 当 WIFSIGNALED(status) 为真时,可以使用此宏获取导致子进程终止的信

号编号。

暂停状态

  • WIFSTOPPED(status): 如果返回值为非 0,表示子进程处于暂停状态(例如子进程收到 SIGSTOPSIGTSTPSIGTTINSIGTTOU 信号)。
    • WSTOPSIG(status): 当 WIFSTOPPED(status) 为真时,可以使用此宏获取导致子进程暂停的信号

编号。

继续运行

  • WIFCONTINUED(status): 如果返回值为真,表示子进程在暂停后已经继续运行(例如收到 SIGCONT 信号)。
5.4.1. 通过 wait(&status) 阻塞等待子进程退出,并通过 WEXITSTATUS(status) 获取子进程的退出状态码。

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    pid_t pid;
	int cnt = 0;
	int status = 10;
	pid = fork();
	if(pid > 0)        //父进程
	{
		wait(&status);
		printf("child quit ,child status = %d\n",WEXITSTATUS(status));
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			printf(" printf在父进程cnt = %d\n",cnt);
			sleep(1);
                        }
		}
	else if(pid == 0)    // 子进程
	{	
		while(1){
			printf("PID = 0 this is chilid print, pid = %d\n",getpid());
			sleep(1);
			cnt++;
			if(cnt == 3){         //当子进程执行三次后退出,退出之后执行父进程
			 printf("子进程退出\n");
				exit(3);
			}
		}
	}	
	return 0;
}

子进程(进程ID:13139)执行完后被清除,没有变成僵尸进程。

  • 如果需要子进程退出时的状态,可以在exit函数写入一个状态值,例如向父进程返回一个3,可以写成

exit(3),父进程则需要用到宏函数WEXITSTATUS(status)获取状态值。

  • 这里返回值为3,表示子进程是正常结束的,WIFEXITED(status) 为真时使用WEXITSTATUS(status) 获取子进程的退出状态码。
5.5. 进程等待函数waitpid函数原型和头文件:
#include <sys/types.h>
#include <sys/wait.h>
 
pid_t waitpid(pid_t pid, int *wstatus, int options);	//等待指定子进程或任意子进程
 
pit_t			函数返回值:
1.等待成功返回被等待进程的pid
2.如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
3.如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在
    
pid_t pid	
1.pid = -1		等待任一子进程,此种情况下:wait和waitpid等效
2.pid >  0		等待进程ID与pid相等的子进程
3.pid =  0		等待组ID等于调用进程组ID的任一子进程
4.pid < -1		等待组ID等于pid绝对值的任一子进程
    
int *wstatus	输出型参数,获取子进程的退出状态,不关心可设置为NULL
int options		提供了一些额外的选项来控制waitpid    
常量:		  说明:
WCONTINUED	若实现支持作业控制,那么由pid 指定的任一子进程在停止后已经继续,
但其状态尚未报告,则返回其状态(POSIX.1的XSI扩展)
WNOHANG		若由pid指定的子进程并不是立即可用的,则 waitpid不阻塞,此时其返回值为 0
WUNTRACED	若某实现支持作业控制,而由 pid 指定的任一子进程已处于停止状态,
            并且其状态自停止以来还未报告过,则返回其状态。				
WIESTOPPED宏确定返回值是否对应于一个停止的子进程

wait和waitpid的区别:

  1. wait 使调用者阻塞(子进程不结束,就一直不会运行父进程)
  2. waitpid 有一个选项可以使调用者不阻塞
5.5.1. 通过waitpid()函数来检查子进程的退出状态,与之前的版本相比,这次你使用了WNOHANG选项让waitpid() 以非阻塞模式运行
int main()
{
    	pid_t pid;
	int cnt = 0;
	int status = 10;
	pid = fork();
	if(pid > 0)
	{
		waitpid(pid,&status,WNOHANG);
		//	wait(&status);
		printf("child quit ,child status = %d\n",WEXITSTATUS(status));
		while(1){
			printf("this is father print , pid = %d \n",getpid());
			printf(" printf在父进程cnt = %d\n",cnt);
			sleep(1);
                        }
		}
	else if(pid == 0)
	{	
		while(1){
			printf("PID = 0 this is chilid print, pid = %d\n",getpid());
			sleep(1);
			cnt++;
			if(cnt == 3){
			 printf("子进程退出\n");
				exit(3);
			}
		}
	}	
	return 0;
}

waitpid(pid, &status, WNOHANG) 使其父进程与子进程同时就行,不存在阻塞模式,但当子进程结束之后,输入僵尸进程

5.6. 孤儿进程:
  • 父进程如果不等待子进程的退出,在子进程之前就“结束”了自己的生命,此时子进程叫孤儿进程。
  • Linux 避免系统存在过多的孤儿进程,init 进程收留孤儿进程,变成孤儿进程的父进程。
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main()
{
    pid_t retpid;
    int cnt = 0;

    retpid = fork();                                   //创建一个子进程

    if(retpid == 0){                                   //当PID=0代表是子进程
        while(1){
            printf("子进程PID = %d,父进程PID = %d\n",getpid(),getppid());
            cnt++;
            if(cnt == 3){                           //当子进程执行三次后退出,退出之后执行父进程
                printf("子进程退出\n");
                exit(0);
            }
            sleep(1);
        }
    }else if(retpid > 0){                              //当PID是一个非负整数代表是父进程    
        printf("父进程PID = %d\n",getpid());       
    }
    return 0;
}

当父进程运行结束之后,子进程就变成了孤儿进程,需要注意的是,不是所有的init都是1,我这里就是1087,大家也有可能是其他的,这是因为

  • 父进程退出后,子进程被系统接管。
  • 在你的环境中,子进程的父进程并没有成为 PID 1,而是 1087。这可能是由于系统中某些进程层次结构(例如容器化环境、不同的 init 系统或者进程管理策略)的结果。
  • 这并不是异常行为,而是符合系统进程管理的设计,只要子进程的父进程指向一个长期运行的进程(如 init 或类似进程),子进程就不会成为僵尸进程。

可以在系统上运行 ps -p 1087 -o comm= 来查看该进程的名称。

我运行这个命令之后,出现了systemd,在现代 Linux 系统中,systemd 是一个常见的初始化系统和服务管理器,它通常会接管孤儿进程。 这也就解释了,我的init不是1,而且1087了

6. 进程程序替换exec族函数

6.1. exec族函数作用和功能:

exec族函数作用:用fork函数创建新进程后,经常会在新进程中调用exec函数去执行另外一个程序。当进程调用exec函数时,该进程被完全替换为新程序。因为调用exec函数并不创建新进程,所以前后进程的ID并没有改变。

exec族函数功能:在调用进程内部执行一个可执行文件。可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件

6.2. exec族函数原型和头文件:
#include <unistd.h>
 
extern char **environ;
 
int execl(const char *pathname, const char *arg, .../* (char  *) NULL */);
int execlp(const char *file, const char *arg, .../* (char  *) NULL */);
int execle(const char *pathname, const char *arg, .../*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);
  • 函数返回值:exec函数族的函数执行成功后不会返回,调用失败时,会设置errno并返回-1,然后从原程序的调用点接着往下执行
  • pathname:要执行的程序路径。可以是绝对路径或者是相对路径。在execv、execve、execl和execle这4个函数中,使用带路径名的文件名作为参数
  • file:要执行的程序名称。如果该参数中包含“/”字符,则视为路径名直接执行;否则视为单独的文件名,系统将根据PATH环境变量指定的路径顺序搜索指定的文件
  • arg:可执行程序所带的参数,第一个参数为可执行文件名字,没有带路径且arg必须以NULL结束
  • argv:命令行参数的矢量数组
  • envp:带有该参数的exec函数可以在调用时指定一个环境变量数组。其他不带该参数的exec函数则使用调用进程的环境变量
  • . . .:命令行参数列表。调用相应程序时有多少命令行参数,就需要有多少个输入参数项。注意:在使用此类函数时,在所有命令行参数的最后应该增加一个空的参数项(NULL),表明命令行参数结束

exec族函数参数极难记忆和分辨,函数名中的字符会给我们一些帮助:

  1. l : 使用参数列表
  2. p:使用文件名,并从PATH环境进行寻找可执行文件
  3. v:应先构造一个指向各参数的指针数组,然后将该数组的地址作为这些函数的参数。
  4. e:多了envp[]数组,使用新的环境变量代替调用进程的环境变量
6.3. exec族函数execl函数应用一:
#include <stdio.h>
#include <unistd.h>
 
int main()
{
    printf("execl之前:\n");  
        //    	文件路径   执行的参数
    if(execl("./echoarg","echoarg","abc",NULL) == -1){
        printf("execl失败\n");
        perror("why:");            //如果wxecl函数调用失败,调用perror函数输出:出错信息
    }
    printf("execl之后\n");
    return 0;
}
//文件echoarg.c
#include <stdio.h>

int main(int argc,char *argv[])
{
    int i = 0;
    for(i = 0; i < argc; i++)
    {
        printf("argv[%d]: %s\n",i,argv[i]); 
    }
    return 0;
}

在当前路径下,运行execl.c文件,通过execl函数,运行当前路径下的echoarg文件(cp文件),将参数echoarg","abc",传入到程序中。

代码总结

  • 主程序(main.c)打印一条消息后,尝试使用 execl 替换当前进程为另一个程序(echoarg)。
  • execl 的第一个参数是要执行的程序路径,后面的参数是传递给新程序的命令行参数。
  • 如果 execl 成功,当前进程被替换,后续代码不再执行;如果失败,会输出错误信息。
  • 被执行的程序(echoarg.c)打印出所有接收到的命令行参数,包括程序自身的名称和传递的参数。

注意:

我们先用gcc编译echoarg.c,生成可执行文件echoarg并放在当前路径目录下。文件echoarg的作用是打印命令行参数。然后再编译execl.c并执行execl可执行文件。用execl 找到并执行echoarg,将当前进程main替换掉,所以”execl之后” 没有在终端被打印出来。

6.3.1. exec族函数execl函数应用二:
  • 我们直接在Linux下输入date指令会显示出系统的时间,但是我们想使用代码来获取系统的时间,所以使用execl函数实现

获取系统时间—首先我们得知道date的路径:

代码编译:

int main()
{
    printf("execl之前:\n");
        //      文件路径   执行的参数
    if(execl("/bin/date","date",NULL,NULL) == -1){
        printf("execl失败\n");
        perror("why:");            //如果wxecl函数调用失败,调用perror函数输出:出错信息
    }
    printf("execl之后\n");
    return 0;
}

6.4. exec族函数execlp函数应用:

带p的一类exac函数,包括execlp、execvp、execvpe,如果参数file中包含/,则就将其视为路径名,否则就按 PATH环境变量,在它所指定的各目录中搜寻可执行文件。举个例子,PATH=/bin:/usr/bin

我们会发现我们每一次运行另一个程序都需要用whereis来查找路径,如果不写路径会怎样?

int main()
{
    printf("execl之前:\n");  
        //    	文件路径   执行的参数
    if(execl("ps","ps",NULL,NULL) == -1){
        printf("execl失败\n");
        perror("why");            //如果wxecl函数调用失败,调用perror函数输出:出错信息
    }
    printf("after execl\n");
    return 0;
}

execl会调用失败,因为在当前路径下招不到ps的路径。

  • 我们使用execlp函数就可以解决这个问题.
int main()
{
    printf("execl之前:\n");  
        //    	文件路径   执行的参数
    if(execlp("ps","ps",NULL,NULL) == -1){
        printf("execl失败\n");
        perror("why");            //如果wxecl函数调用失败,调用perror函数输出:出错信息
    }
    printf("after execl\n");
    return 0;
}

使用execlp函数来获取系统时间就可以不用加绝对路径了,因为execlp函数能通过环境变量PATH查找可执行文件ps

6.4.1. execlp函数应用2:
int main()
{
    printf("execl之前:\n");  
        //    	文件路径   执行的参数
    if(execlp("data","data",NULL,NULL) == -1){
        printf("execl失败\n");
        perror("why");            //如果wxecl函数调用失败,调用perror函数输出:出错信息
    }
    printf("after execl\n");
    return 0;
}
  • 使用execlp函数来获取系统时间就可以不用加绝对路径了,因为execlp函数能通过环境变量PATH查找可执行文件date

6.5. 配置PATH环境变量:
  • echo $PATH //输出当前环境变量指令

  • 如果需要修改环境变量可以在后面进行追加,比如想将:/etc/apache2/bin添加为环境变量,可以这样写:
  • export PATH=$PATH:/etc/apache2/bin //自己配置环境变量用export指令
  • 我们在运行程序的时候在程序名之前就不需要加./了,也可以运行其他路径下的可执行程序
6.6. exec族函数execv函数应用:

带v不带l的一类exac函数,包括execv、execvp、execve,应先构造一个指向各参数的指针数组,然后将该数组的地址作为这些函数的参数。

如char *arg[]这种形式,且arg最后一个元素必须是NULL,例如char *arg[] = {“ls”,”-l”,NULL};

int main()
{
	char *argv[] = {"date",NULL,NULL};	 //把所有的参数全部都放到数组中

    printf("execl之前:\n");  
        //    	文件路径   执行的参数
    if(execv("/bin/date",argv) == -1){//如果函数返回值为-1代表调用execv函数失败,反之执行date指令
        printf("execl失败\n");
        perror("why");           //调用perror函数输出:出错信息
    }
    printf("after execl\n");
    return 0;
}

6.7. exec族函数execvp函数应用:
int main()
{
	char *argv[] = {"date",NULL,NULL};
    printf("execl之前:\n");  
        //    	文件路径   执行的参数
    if(execvp("date",argv) == -1){   //如果函数返回值为-1代表调用execvp函数失败,反之执行date指令
        printf("execl失败\n");
        perror("why");           //调用perror函数输出:出错信息
    }
    printf("after execl\n");
    return 0;
}

6.8. exec族函数配合fork函数应用:

当用户输入1时创建子进程修改配置文件的字段值,当前配置文件LENG=9,通过 exec族函数配合fork函数修改成5

/*file_peizhi.c*/
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
 
int main(int argc, char **argv)
{
	int fdSrc;
 
	char *readBuf = NULL;
	
	if(argc != 2){											//判断C文件参数是不是有两个,如果不是程序退出
		printf("param error\n");
		exit(-1);
	}
 
	fdSrc = open(argv[1],O_RDWR);							//打开配置文件
	
	int size = lseek(fdSrc,0, SEEK_END);					//计算配置文件有多少个字节
	lseek(fdSrc, 0, SEEK_SET);								//让配置文件光标回到头
	
	readBuf = (char *)malloc(sizeof(char) * size + 8);		//动态开辟readBuf的内存空间
 
	int n_read = read(fdSrc,readBuf,size);					//把配置文件的size个字节的内容读取到readBuf里面
 
	//char *strstr(const char *haystack, const char *needle);
	char *p = strstr(readBuf,"LENG=");				//字符串查找函数,返回值为要查找的字符串的第一个字符的指														      针,第一个参数为待查找的原始字符串,第二个参数为要查找的内容
	p = p + strlen("LENG=");									//偏移LENG的长度,偏移到数据位置
	*p = '5';													//更改数据位置的值
 
	lseek(fdSrc, 0, SEEK_SET);									//让配置文件光标回到头
 
	int n_write = write(fdSrc,readBuf,strlen(readBuf));			//把读出的内容重新写入配置文件
 
	close(fdSrc);												//关闭配置文件
	return 0;
}

int main()
{
    pid_t pid;
    int data;
 
    printf("父进程PID = %d\n",getpid());            //获取父进程PID
 
    while(1){
        printf("请输入一个数据:\n");           //等待用户输入,当用户输入为1时父进程创建子进程,在子进程中处理请求。
        scanf("%d",&data);
        if(data == 1){
            pid = fork();                           //创建一个子进程
 
            if(pid > 0){                            //返回值如果是非负数代表是父进程
               wait(NULL);                          //父进程等待子进程退出
            }else if(pid == 0){
                while(1){
                    if(execl("./file_peizhi","file_peizhi","comfig.txt",NULL) == -1){	//执行已经写好的配置文件
                        printf("execl失败\n");
                        perror("why");
                    }
                }           
            }
        }else{
            printf("什么都不做!\n");
        }
    }
    return 0;
}

可以看到成功把mfig.txt中的LENG=9改成LENG=5。

7. system函数

system是一个C/C++的函数,Linux操作系统下system 函数主要是执行shell 命令

7.1. system函数原型和头文件:
#include <stdlib.h>

int system(const char *command);

int 	函数返回值
1. 成功则返回进程的状态值
2. 当 sh 不能执行时。返回127
3. 其他原因失败返回-1
  • 函数说明:system函数会调用fork函数产生子进程,由子进程来调用/bin/sh-c string来执行参数string字符

串所代表的命令,此命令执行完后随即返回原调用的进程。在调用system函数期间SIGCHLD 信号会被暂时搁置,SIGINT和SIGQUIT 信号则会被忽略。

  • 不同于exec族函数,system执行完之后还会执行原来的程序
7.2. system函数应用:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
 
int main()
{
    printf("execl之前:\n");
    if(system("date") == -1){      //如果函数返回值为-1代表调用system函数失败,反之获取系统时间
        printf("execl失败\n");
 
        perror("why");             //调用perror函数输出:出错信息
    }
    printf("execl之后\n");
    return 0;
}
int main()
{
    printf("execl之前:\n");
    if(system("./file_peizhi comfig.txt") == -1){      //如果函数返回值为-1代表调用system函数失败,反之获取系统时间
        printf("execl失败\n");
 
        perror("why");             //调用perror函数输出:出错信息
    }
    printf("execl之后\n");
    return 0;
}

代码解释:

  • system() 函数用于在程序中执行一个命令行命令。这行代码尝试执行 ./file_peizhi comfig.txt,即运行当前目录下的 file_peizhi 程序,并传递 "comfig.txt" 作为参数。
  • 如果 system() 调用成功,它会返回命令执行的状态码(通常为 0 表示成功,非零表示错误),但如果 system() 本身调用失败(例如,无法创建子进程或 system() 被禁用),它会返回 -1
  • 错误处理
  • 如果 system() 返回 -1,程序会打印 "execl失败\n" 并使用 perror("why") 输出系统调用失败的原因。这可以帮助调试为何 system() 没有成功执行命令。

8. popen函数

popen函数允许一个程序将另外一个程序作为新进程来启动,并可以传递数据或者通过它接受数据。其内部实现为调用 fork 产生一个子进程,执行一个 shell, 以运行命令来开启一个进程,这个进程必须由 pclose 函数关闭。

popen函数与system函数在应用中的好处是可以获取运行的输出结果

调用system函数后,指令的执行结果会在shell上打印出来,如果想要把执行的结果保存到一个数组里,system函数没有这个功能,所以引入popen函数。

声明:

  1. #include <stdio.h>
  2. FILE *popen(const char *command, const char *type);
  3. int pclose(FILE *stream); //关闭文件流

参数:

command 是一个指向以 NULL 结束的 shell 命令字符串的指针。这行命令将被传到 /bin/sh 并使用 -c 标志,shell 将执行这个命令。

type 指定了管道的类型,它可以是 “r”(读取模式)或 “w”(写入模式)。如果 type 是 “r”,则文件指针连接到 command 的标准输出;如果 type 是 “w”,则文件指针连接到 command 的标准输入。

返回值:

如果调用成功,popen 返回一个 FILE 指针,该指针可以用于读取或写入数据,取决于 type 参数的值。如果调用失败,popen 返回 NULL,具体错误可以通过检查 errno 来确定。

总的来说,popen 函数提供了一个比 system 更加灵活的方式来与子进程进行交互,因为它允许你直接读取或写入子进程的输入和输出。这使得 popen 在需要处理命令输出或向命令提供输入的情况下非常有用。

popen函数和open函数一样都会返回一个文件流,而且最后都需要把这个文件流关闭,防止文件损坏。

8.1. popen函数原型和头文件:
#include <stdio.h>
 
FILE *popen(const char *command, const char *type);
 
int pclose(FILE *stream);
 
FILE *					返回值是一个文件指针,函数执行成功返回文件指针,
                        否则返回NULL,可用来存储执行后的结果
                        
const char *command		是一个指向以NULL结束的shell命令字符串指针,shell将执行的命令   
const char *type		
1. "r":文件指针连接到 command 的标准输出
2. "w":文件指针连接到 command 的标准输入    
由于popen是以创建管道的方式创建进程连接到子进程的标准输出设备或标准输入设备,
因此其带有管道的一些特性,同一时刻只能定义为写或者读。
8.2. popen函数应用:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
int main()
{
    FILE* fp;
    int fd;
    char readBuf[1024] = {0};
    //FILE *popen(const char *command, const char *type);
 
    int size = sizeof(readBuf) / sizeof(readBuf[0]);            //计算数组的大小
    fp = popen("ps","r");                                       //运行ps指令
 
    //size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
    int n_read = fread(readBuf,1,size,fp);                      //从fp文件流里面每次读1bit读size次到readBuf里面
 
    printf("read ret = %d,readBuf = \n%s\n",n_read,readBuf);    //read ret = 读取的字节数,然后输出readBuf的内容
 
    fd = open("./file",O_RDWR|O_CREAT,0600);                    //可读可写方式打开file文件,如果没有则创建它
    //ssize_t write(int fd, const void *buf, size_t count);
    int n_write = write(fd,readBuf,size);                       //把readBud中的内容写size个字节到file文件中
    printf("通过write函数向file文件写入了%d个字节的数据\n",n_write);
 
    pclose(fp);                                                 //关闭文件流
    close(fd);                                                  //关闭file文件
    return 0;
}

8.2.1. popen函数应用二:n'hnh
#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE* fp;
    char readBuf[1024] = {0};
    int size = sizeof(readBuf) / sizeof(readBuf[0]); // 计算数组的大小

    // 先运行 file_peizhi 修改 comfig.txt
    system("./file_peizhi comfig.txt");

    // 使用 popen 打开管道来读取修改后的 comfig.txt 内容
    fp = popen("cat comfig.txt", "r"); // "r" 表示读取模式
    if (fp == NULL) {
        perror("popen 失败");
        return 1;
    }

    // 从 fp 中读取数据到 readBuf
    int n_read = fread(readBuf, 1, size, fp); // 每次读取 1 个字节,共读取 size 次

    // 检查 fread 返回值
    if (n_read == 0) {
        printf("没有读取到数据。请检查 comfig.txt 是否有内容。\n");
    } else {
        printf("读取到的内容 (字节数 = %d):\n%s\n", n_read, readBuf);
    }

    // 关闭 popen 打开的管道
    if (pclose(fp) == -1) {
        perror("pclose 失败");
    }

    return 0;
}

运行结果:

 点个赞吧!!!


http://www.kler.cn/news/327697.html

相关文章:

  • 2024新淘宝镜像地址下载【vue-cli】
  • 浅析人脸活体检测技术的实现过程及其应用领域
  • MongoDB 用户管理
  • docker 部署minio
  • Webpack 打包后文件过大,如何优化?
  • Maven超详细教程(三):Maven依赖查找顺序
  • PHP中的时间和日期详解
  • 无人机之数据提取篇
  • 性能优化-数据库分区技术深入解析
  • Java爬虫抓取数据的艺术
  • 56 门控循环单元(GRU)_by《李沐:动手学深度学习v2》pytorch版
  • 【JavaEE】——多线程常用类
  • spring boot集成日志
  • Hadoop集群的高可用(HA):NameNode和resourcemanager高可用的搭建
  • tauri中加载本地文件图片或者下载网络文件图片后存储到本地,然后通过前端页面展示
  • Trilium Notes笔记本地化部署与简单使用指南打造个人知识库
  • 数据结构和算法基础(一)
  • 探索Cherry键盘的FN+F9游戏模式与Ctrl+Fn功能
  • ffmpeg 结合 opencv 显示ps流文件
  • 深入计算机语言之C++:C到C++的过度
  • set和map结构的使用
  • Spring Boot技术在足球青训管理中的实践与挑战
  • STM32的DMA技术介绍
  • failed to load steamui.dll的多种处理方法,steamui.dll的作用
  • 论文阅读 | HiDDeN网络架构
  • 【规控+slam】探索建图方案及代码分享
  • 基于Springboot+Vue的农场投入品运营线上管理系统 (含源码数据库)
  • Python学习(3):画散点图和箱线图
  • 助农小程序|助农扶贫系统|基于java的助农扶贫系统小程序设计与实现(源码+数据库+文档)
  • 大数据-156 Apache Druid 案例实战 Scala Kafka 订单统计