进程 相关知识

进程

进程的概念:

进程就是正在运行的程序,

进程和程序是两个完全不同的概念:程序指的是可执行程序,也就是可执行文件,说明程序本质是一个文件。文件是一种静态的概念,文件存储在磁盘中,文件本身并不会对系统产生任何影响。

进程

指的是运行中的程序,进程是一种动态的概念,程序运行之后会对系统环境产生一定的影响。

同一个程序可以被运行多次,产生多个不同的进程,可以把进程理解为程序的实例化对象,程序被执行一次就代表实例化了一次,被执行多次,就表示实例化多次,产生多个实例化对象,也就是多次进程。

在linux 终端中,指令ps -aux 就可以展示出系统中所有运行的进程。

进程号:

每一个进程都有一个进程号(process ID,简称PID),进程号是一个整数,用于唯一标识系统中的某一个进程。

在某些系统调用中,进程号可以作为传入参数、有时也可作为返回值 。

在应用程序中,可通过系统调用 getpid()来获取本进程的进程号,其函数原型如下所示:

#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);

使用该函数需要包含头文件<sys/types.h>和<unistd.h>。
函数返回值为 pid_t 类型变量,便是对应的进程号。

进程的生命周期:

从程序启动到程序退出这段时间。

谁来调用main函数:

在运行main函数之前,会运行一段引导代码,最终由这段引导代码调用main函数,这段引导代码并不需要我们自己编写,而是在编译、连接我们的应用程序的时候由链接器将这段引导代码链接到我们的应用程序中,也就是最终的程序。

加载器会将可执行程序加载到内存当中。

进程如何被终止?

进程的终止可以分成两种,正常终止和异常终止

正常终止:

1、譬如在main函数中通过return返回,终止进程

2、调用库函数exit终止进程

3、调用系统调用_exit或者 _EXIT

异常终止:

1、调用abort函数终止进程。

2、被信号终止。进程接收到一个信号,譬如 SIGKILL 信号 。

终止进程:

exit() 和_exit() _EXIT大写的和小写的一样

void _exit(int status ); 终止进程的运行

​ status:这个参数用来表示进程终止时的状态,通常0表示正常终止,非零值表示非正常终止(比如open函数产生错误),比如 1

void exit (int status) ;

​ status:这个参数用来表示进程终止时的状态,通常0表示正常终止,非零值表示非正常终止(比如open函数产生错误),比如 1

exit()和_exit()的区别:

1、_exit()他是一个系统调用(man 2 查) ,exit()是库函数 (封装的更好)(man 3 查),他们所需要包含的头文件是不一样的

2、这两个函数的最终目的相同,都是终止进程,但是在终止进程之前需要做一些处理,这些处理工作这两个函数是不一样的。

在这里插入图片描述

_exit系统调用会直接进入内核

exit()调用进程终止处理函数 (atexit (库函数) 进程在终止的时候会执行的函数)

有一些终止进程的情况是不会刷新stdio缓冲的:

1、_exit()或 _EXIT()

2、被信号终止的情况

exit( )和return 之间有什么区别:

1、exit( )是一个库函数,return是一个C语言的语句

2、exit( )函数最后总会进入内核,把控制权交给内核,最终由内核去终止进程。

执行return并不会进入到内核,他只是从main函数返回,返回到他的上层调用,把控制权交给他的上层调用、最终由上层调用终止进程。

测试发现使用return终止进程也会调用终止处理函数,并且会刷新IO缓冲

exit( )函数和abort函数之间有什么区别:

1、exit用于正常终止进程,abort用于异常终止进程

​ 正常终止在终止进程之前会执行一些清理工作,

异常终止不会执行这些清理工作,它是直接终止进程,abort( )本质上是通过信号去终止进程,执行abort信号的系统默认操作,执行SIGABRT信号的系统默认处理操作,终止进程运行。不会调用终止处理函数 也不会刷新IO缓冲。

环境变量:

每一个进程都有一组与其相关的环境变量, 这些环境变量以字符串形式存储在一个字符串数组列表中,
把这个数组称为环境列表。 其中每个字符串都是以“名称=值(name=value)” 形式定义,所以环境变量是
“名称-值”的成对集合, 譬如在 shell 终端下可以使用 env 命令查看到 shell 进程的所有环境变量

1.一些常见的环境变量:

  • HOME:当前用户的主目录。
  • HOSTNAME:计算机的名称。
  • IFS:用于解析用户输入的内部字段分隔符。默认值为空格。
  • LS_COLORS:这定义了用于向 ls 的输出添加颜色的代码。
  • PATH:以冒号分隔的目录列表,当您在 shell 中键入命令时,这些目录按顺序搜索匹配的命令或应用程序。
  • PWD:当前工作目录。
  • SHELL:默认 shell 的名称。
  • TERM:运行 shell 时模拟的终端类型。
  • USER:当前用户。

2.添加删除环境变量

env 命令查看到 shell 进程的所有环境变量

export 【环境变量名字】= 【值】 //添加环境变量

echo $【环境变量名字】 //打印出来数值

unset 【环境变量名字】 //删除

export -n 【环境变量名字】= 【值】 //删除环境变量

3.环境变量的组织形式

在这里插入图片描述

这些环境变量都是以字符串的形式储存在一个字符串数组中,把这个字符串数组称为环境表。

环境表中的每一个字符都是按照“name = value”这种格式定义的, 字符串

数字以NULL结尾。

4.应用程序中获取环境变量

每一个应用程序中都有一组环境变量,进程在创建的时候,他的环境变量是从其父进程中继承过来的。

有三种方式可以获取这些环境变量:

**4.1 通过environ变量获取 :

这个变量是一个全局变量,我们可以在程序中直接使用,只需要声明即可。

environ变量其实是一个指针,它指向一个字符串数组,就是进程的环境表

extern char **environ

4.2 通过main函数的参数获取

int main(void)

int main(int argc ,char *argv[ ])

int main(int argc ,char *argv[ ], char *env[])

第三个参数env就是进程的环境表

4.3 通过getenv获取

如果只想要获取某个指定的环境变量,可以使用库函数 getenv(),其函数原型如下所示:

#include <stdlib.h>
char *getenv(const char *name);

使用该函数需要包含头文件<stdlib.h>。
函数参数和返回值含义如下:
name: 指定获取的环境变量名称。
返回值: 如果存放该环境变量,则返回该环境变量的值对应字符串的指针;如果不存在该环境变量,则
返回 NULL
使用 getenv()需要注意,不应该去修改其返回的字符串,修改该字符串意味着修改了环境变量对应的值,
Linux 提供了相应的修改函数, 如果需要修改环境变量的值应该使用这些函数, 不应直接改动该字符串。

5.添加、修改、删除环境变量

5.1 putenv()函数

putenv()函数可向进程的环境变量数组中添加一个新的环境变量,或者修改一个已经存在的环境变量对应的值,其函数原型如下所示:

#include <stdlib.h>
int putenv(char *string);

使用该函数需要包含头文件<stdlib.h>。
函数参数和返回值含义如下:
string: 参数 string 是一个字符串指针,指向 name=value 形式的字符串。
返回值: 成功返回 0失败将返回非 0 值,并设置 errno。

**注意:**该函数调用成功之后,参数 string 所指向的字符串就成为了进程环境变量的一部分了,换言之, putenv()函数将设定 environ 变量(字符串数组)中的某个元素(字符串指针)指向该 string 字符串,而不是指向它的复制副本,这里需要注意!因此,不能随意修改参数 string 所指向的内容,这将影响进程的环境变量,出
于这种原因,参数 string 不应为自动变量(即在栈中分配的字符数组) 。 因为自动变量的生命周期是在函数内部,除了函数便不再有效了!

譬如说:可以使用malloc分配堆内存,或者直接使用全局变量!(保证不为自动变量)

5.2 setenv()函数

setenv()函数
setenv()函数可以替代 putenv()函数,用于向进程的环境变量列表中添加一个新的环境变量或修改现有环境变量对应的值,其函数原型如下所示:

#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);

使用该函数需要包含头文件<stdlib.h>。
函数参数和返回值含义如下:
name: 需要添加或修改的环境变量名称。
value: 环境变量的值。
overwrite: 若参数 name 标识的环境变量已经存在,在参数 overwrite 为 0 的情况下, setenv()函数将不改变现有环境变量的值,也就是说本次调用没有产生任何影响;如果参数 overwrite 的值为非 0,若参数 name标识的环境变量已经存在,则覆盖,不存在则表示添加新的环境变量。
返回值: 成功返回 0;失败将返回-1,并设置 errno。

setenv()函数为形如 name=value 的字符串分配一块内存缓冲区,并将参数 name 和参数 value 所指向的字符串复制到此缓冲区中,以此来创建一个新的环境变量,所以,由此可知, setenv()与 putenv()函数有两个
区别:

⚫ putenv()函数并不会为 name=value 字符串分配内存;
⚫ setenv()可通过参数overwrite控制是否需要修改现有变量的值而仅以添加变量为目的,显然putenv()并不能进行控制。
推荐大家使用 setenv()函数,这样使用自动变量作为 setenv()的参数也不会有问题。

5.3 执行程序时添加环境变量

运行的时候直接 在终端 NAME=value ./test

5.4通过unsetenv删除环境变量
#include <stdlib.h>
int unsetenv(const char *name);  

6.清空环境变量

6.1、直接将environ设置为NULL

6.2、通过clearenv清空环境变量

int clearenv(void);

子进程:

1.所有进程都是由其父进程所创建出来的:

Linux系统中的所有进程都是由其父进程创建出来的,譬如我们中断下执行某个应用程序:

./test

这个程序启动之后就是一个进程,这个进程就是他的父进程(也就是这个shell进程)所创建出来的

shell进程就是shell解析器(shell解析器有很多种,譬如bash\sh等),所谓解析器就是解析用户输入的各种命令,然后做出相应的相应,执行相应的程序。

那么既然所有的进程都是由其父进程所创建出来的,那么总有一个最原始的父进程,这个进程就是init进程。init进程的PID是1,他是所有子进程的父进程,所有进程的祖先,一切从Init开始。

2.进程空间

在Linux系统中,进程与进程之间,进程与内核之间都是相互隔离的,各自在自己的进程空间中运行(内核就是在自己的内核空间中运行);一个进程不能读取或者修改另一个进程或内核的内存数据,这样提高了系统的安全性和稳定性。

新进程被创建出来之后,便是一个独立的进程,拥有自己独立的内核空间,拥有自己唯一的新晋称号(PID),拥有自己独立的PCB(进程控制块),新进程会被内核同等调度执行,参与到系统调用中。

3.fork创建子进程

一个现有的进程可以调用 fork()函数创建一个新的进程, 调用 fork()函数的进程称为父进程,由 fork()函数创建出来的进程被称为子进程(child process)

4.如何理解fork系统调用?

4.1、一次fork调用会产生两次返回值:

也就是说调用一次fork函数,他会产生两次返回值。

原因:因为fork调用会创建一个新的进程,这个新的进程就是子进程,也就是说,在fork函数调用之后,会存在两个进程,一个是父进程,一个是子进程。

所以会有两个返回值,子进程会返回一次,父进程也会返回一次。

并且这两个返回值是不一样的,分别会返回一个大于0 的整数 和 0 ,这个0便是子进程的返回值,而大于0 的整数则是父进程的返回值,所以我们可以通过返回值来判断当前是子进程还是父进程返回。

其实这个大于0的整数就是子进程的PID。

4.2、fork创建了一个与原来进程几乎完全相同的进程:

其实子进程是父进程的一个副本,fork函数是以复制的方式创建子进程,子进程几乎完全复制了父进程,譬如子进程会拷贝父进程的数据段、堆、栈,并且拷贝父进程打开的所欲文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行fork之后,子进程和父进程各自在自己的进程空间中运行,每个进程均可修改各自的栈数据以及堆端中的变量,儿不会影响另一个进程。

4.3、子进程从fork调用返回后的代码开始运行:

子进程从fork调用返回后开始运行,虽然子进程和父进程运行在不同的进程空间中,但是他们执行的确实同一个程序,但是需要注意,子进程运行的是fork调用之后的代码,并不会执行fork调用之前的代码。

5.父、进程之间的文件共享

父、子进程对同一个文件进行读、写操作 ,子进程会拷贝父进程打开的所有文件描述符(fd),即 继承
在这里插入图片描述

比如父、子进程中的文件描述符fd0 ,都是指文件表1 ,i-node指针唯一,所以这两个fd0使用的是同一个文件读写指针 。 (接续写or 分别写?—接续写(显然)

分别写的话,(不公用同一个文件读写指针)会有竞争冒险的情况,比如:a、b两个进程同时写一段语句,导致内容会产生覆盖。(a写完了之后,b又从它自己的文件读写指针(比如从0 位置)开始写)

6.父、子进程间的竞争关系

fork之后 是父进程 先返回 还是 子进程先返回?

无法保证,但是绝大部分情况下是父进程先返回。

7.监视子进程

在很多应用程序的设计中,父进程需要知道子进程于何时被终止,并且需要知道子进程的终止状态信息,是正常终止、还是异常终止亦或者被信号终止等,意味着父进程会对子进程进行监视,

本小节我们就来学习下如何通过系统调用 wait()以及其它变体来监视子进程的状态改变

子进程的状态改变包括哪些?

1、子进程终止

2、子进程因为收到停止信号而停止运行,SIGSTOP、SIGTSTP

3、子进程在停止状态下因为收到恢复信号而恢复运行,SIGCONT

这个不就是SIGCHLD信号的三种触发信号吗

当子进程发生以上三种状态改变中任何一种时,内核就会向父进程发送这个SIGCHLD信号

7.1 wait( )函数

系统调用 wait()可以等待进程的任一子进程终止,同时获取子进程
的终止状态信息,其函数原型如下所示:

pid_t wait(int *status);

使用该函数需要包含头文件<sys/types.h>和<sys/wait.h>。
函数参数和返回值含义如下:
status: 参数 status 用于存放子进程终止时的状态信息,参数 status 可以为 NULL,表示不接收子进程终止时的状态信息。
返回值: 若成功则返回终止的子进程对应的进程号;失败则返回-1。
系统调用 wait()将执行如下动作:
⚫ 调用 wait()函数,如果其所有子进程都还在运行,则 wait()会一直阻塞等待,直到某一个子进程终止;

⚫如果进程调用 wait(),但是该进程并没有子进程, 也就意味着该进程并没有需要等待的子进程, 那 么 wait()将返回错误,也就是返回-1、并且会将 errno 设置为 ECHILD。

⚫ 如果进程调用 wait()之前, 它的子进程当中已经有一个或多个子进程已经终止了,那么调用 wait()也不会阻塞。 wait()函数的作用除了获取子进程的终止状态信息之外,更重要的一点,就是回收子 进程的一些资源,俗称为子进程“收尸” ,关于这个问题后面再给大家进行介绍。所以在调用 wait()函数之前,已经有子进程终止了, 意味着正等待着父进程为其“收尸”,所以调用 wait()将不会阻塞,而是会立即替该子进程“收尸” 、处理它的“后事” ,然后返回到正常的程序流程中, 一次 wait()调用只能处理一次。

参数 status 不为 NULL 的情况下,则 wait()会将子进程的终止时的状态信息存储在它指向的 int 变量中,
可以通过以下宏来检查 status 参数:
⚫ WIFEXITED(status): 如果子进程正常终止,则返回 true;
⚫ WEXITSTATUS(status): 返回子进程退出状态,是一个数值,其实就是子进程调用_exit()或 exit()时指定的退出状态,也就是这两个函数的参数,或者是return的返回值 ; wait()获取得到的 status 参数并不是调用_exit()或 exit()时指定的状态,可通过WEXITSTATUS 宏转换;
⚫ WIFSIGNALED(status): 如果子进程被信号终止,则返回 true;

⚫ WTERMSIG(status): 返回导致子进程终止的信号编号。如果子进程是被信号所终止,则可以通过
此宏获取终止子进程的信号;

wait函数有两个作用

1.监视子进程什么时候被终止,以及获取子进程终止时的状态信息。

2.回收子进程的一些资源,俗称 为子进程收尸

7.2 waitpid()函数

使用 wait()系统调用存在着一些限制,这些限制包括如下:
⚫ 如果父进程创建了多个子进程,使用 wait()将无法等待某个特定的子进程的完成,只能按照顺序等
待下一个子进程的终止,一个一个来、谁先终止就先处理谁;
⚫ 如果子进程没有终止,正在运行,那么 wait()总是保持阻塞,有时我们希望执行非阻塞等待,是否
有子进程终止,通过判断即可得知;
⚫ 使用 wait()只能发现那些被终止的子进程,对于子进程因某个信号(譬如 SIGSTOP 信号)而停止
(注意,这里停止指的暂停运行),或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就
无能为力了。
而设计 waitpid()则可以突破这些限制, waitpid()系统调用函数原型如下所示:

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

函数参数和返回值含义如下:
pid: 参数 pid 用于表示需要等待的某个具体子进程,关于参数 pid 的取值范围如下:
⚫ 如果 pid 大于 0,表示等待进程号为 pid 的子进程;
⚫ 如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组的所有子进程;
⚫ 如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程;
⚫ 如果 pid 等于-1,则等待任意子进程。 wait(&status)与 waitpid(-1, &status, 0)等价。
status: 与 wait()函数的 status 参数意义相同。
options: 稍后介绍。
返回值: 返回值与 wait()函数的返回值意义基本相同,在参数 options 包含了 WNOHANG 标志的情况下,返回值会出现 0,稍后介绍。
参数 options 是一个位掩码,可以包括 0 个或多个如下标志:

⚫ WNOHANG: 如果子进程没有发生状态改变(终止、暂停),则立即返回,也就是执行非阻塞等待,可以实现轮训 poll,通过返回值可以判断是否有子进程发生状态改变,若返回值等于 0 表示没有发生改变。

⚫ WUNTRACED: 除了返回终止的子进程的状态信息外,还返回因信号而停止(暂停运行)的子进程状态信息;

从以上的介绍可知, waitpid()在功能上要强于 wait()函数,它弥补了 wait()函数所带来的一些限制,具体在实际的编程使用当中,可根据自己的需求进行选择。

将 wait(&status)替换成了 waitpid(-1, &status, 0),通过上面的介绍可知, waitpid()函数的这种参数配置情况与 wait()函数是完全等价的 。

8. SIGCHLD 信号

我们可以为SIGCHLD信号绑定一个信号处理函数,然后在信号处理函数中调用wait、waitpid函数回收子进程。

为父进程绑定SIGCHLD信号处理函数、当子进程发生改变中任何一种时,内核就会向父进程发送这个SIGCHLD信号

使用SIGCHLD信号回收子进程需要注意一个问题:

当调用信号处理函数的时候,会暂时将当前正要处理的信号添加到进程的信号掩码中,这样一来,当SIGCHLD信号处理函数正在为某一个已经终止的子进程收尸时,如果此时相继有两个子进程终止了,也就是会产生两次SIGCHLD信号,但是会有一次SIGCHLD信号会被丢失,也就是说父进程最终也只能接受到一次SIGCHLD信号,那么就会漏掉一个,导致有一个子进程没有被回收。

解决方案就是:在 SIGCHLD 信号处理函数中循环以非阻塞方式来调用 waitpid(),直至再无其它终止的子进程需要处理为止,所以,通常 SIGCHLD 信号处理函数内部代码如下所示:

while (waitpid(-1, NULL, WNOHANG) > 0)
continue;

上述代码一直循环下去,直至 waitpid()返回 0,表明再无僵尸进程存在;或者返回-1,表明有错误发生。
应在创建任何子进程之前,为 SIGCHLD 信号绑定处理函数。

waitpid(-1, NULL, WNOHANG) 函数的第一个参数是 -1,表示等待任意一个子进程结束。第二个参数 NULL 表示不需要获取子进程的退出状态信息。第三个参数 WNOHANG 表示如果没有子进程结束,函数立即返回0,而不是阻塞等待。

因此,这段代码的作用是在一个循环中不断检查是否有子进程已经结束,如果有,就继续下一次循环;如果没有,就暂停一段时间(由操作系统决定),然后再次检查。这样可以确保主进程在子进程结束后能够继续执行其他任务。

9.僵尸进程和孤儿进程

父进程先于子进程终止,也就是子进程成了一个“孤儿”,我们把这种子进程成为孤儿进程。

进程结束之后,通常需要其父进程为其“收尸”,回收子进程占用的一些内存资源,父进程通过调用
wait()(或其变体 waitpid()、 waitid()等)函数回收子进程资源,归还给系统。
如果子进程先于父进程结束,此时父进程还未来得及给子进程“收尸”,那么此时子进程就变成了一个
僵尸进程。 子进程结束后其父进程并没有来得及立马给它“收尸”, 子进程处于“曝尸荒野”的状态,在这么一个状态下,我们就将子进程成为僵尸进程 。

当父进程调用 wait()(或其变体,下文不再强调)为子进程“收尸”后,僵尸进程就会被内核彻底删除。另外一种情况,如果父进程并没有调用 wait()函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(), 故而从系统中移除僵尸进程。

如果父进程创建了某一子进程,子进程已经结束,而父进程还在正常运行,但父进程并未调用 wait()回收子进程,此时子进程变成一个僵尸进程。 首先来说,这样的程序设计是有问题的,如果系统中存在大量的僵尸进程,它们势必会填满内核进程表,从而阻碍新进程的创建。需要注意的是,僵尸进程是无法通过信号将其杀死的,即使是“一击必杀”信号 SIGKILL 也无法将其杀死,那么这种情况下只能杀死僵尸进程的父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉!所以,在我们的一个程序设计中,一定要监视子进程的状态变化,如果子进程终止了,要调用 wait()将其回收,避免僵尸进程。

执行新程序

终端下执行某个程序,比如 ./test,程序启动之后就是一个进程了,这个进程就是由他的父进程所创建出来的,在这种情况下,shell父进程执行的是bash程序, 而子进程执行的是test程序。 子进程和父进程运行的不是同一个程序。

譬如 test进程通过fork函数创建了一个子进程,所以这个子进程也是运行test这个程序 。档子进程启动之后,它可以通过一些手段(调用库函数或者系统调用)用一个新程序去替换test程序,然后可以执行这个新的程序,从这个新程序的main函数开始运行。

execve()函数

系统调用 execve()可以将新程序加载到某一进程的内存空间,通过调用 execve()函数将一个外部的可执行文件加载到进程的内存空间运行,使用新的程序替换旧的程序,而进程的栈、数据、以及堆数据会被新程序的相应部件所替换,然后从新程序的 main()函数开始执行。
execve()函数原型如下所示:

#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);

使用该函数需要包含头文件<unistd.h>。
函数参数和返回值含义如下:
filename: 参数 filename 指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是
相对路径。
argv: 参数 argv 则指定了传递给新程序的命令行参数。是一个字符串数组, 该数组对应于 main(int argc,char *argv[])函数的第二个参数 argv,且格式也与之相同,是由字符串指针所组成的数组,以 NULL 结束。
argv[0]对应的便是新程序自身路径名。
envp: 参数 envp 也是一个字符串指针数组, 指定了新程序的环境变量列表, 参数 envp 其实对应于新程序的 environ 数组,同样也是以 NULL 结束,所指向的字符串格式为 name=value。
返回值: execve 调用成功将不会返回;失败将返回-1,并设置 errno。

system()函数

使用 system()函数可以很方便地在我们的程序当中执行任意 shell 命令, 本小节来学习下 system()函数的
用法,以及介绍 system()函数的实现方法。
首先来看看 system()函数原型,如下所示:

#include <stdlib.h>
int system(const char *command);

这是一个库函数, 使用该函数需要包含头文件<stdlib.h>。
函数参数和返回值含义如下

command: 参数 command 指向需要执行的 shell 命令,以字符串的形式提供,譬如"ls -al"、 "echo
HelloWorld"等。
返回值: 关于 system()函数的返回值有多种不同的情况,稍后给大家介绍。
system()函数其内部的是通过调用 fork()、execl()以及 waitpid()这三个函数来实现它的功能, 首先 system()
会调用 fork()创建一个子进程来运行 shell(可以把这个子进程成为 shell 进程) ,并通过 shell 执行参数
command 所指定的命令。譬如:

system("ls -la")
system("echo HelloWorld")

在函数内部,父进程会调用waitpid等待子进程结束,回收子进程,直到子进程终止退出。

system()的返回值如下:
⚫ 当参数 command 为 NULL, 如果 shell 可用则返回一个非 0 值,若不可用则返回 0;针对一些非
UNIX 系统,该系统上可能是没有 shell 的,这样就会导致 shell 不可能;如果 command 参数不为
NULL,则返回值从以下的各种情况所决定。
⚫ 如果无法创建子进程或无法获取子进程的终止状态,那么 system()返回-1;
⚫ 如果子进程不能执行 shell,则 system()的返回值就好像是子进程通过调用_exit(127)终止了;
⚫ 如果所有的系统调用都成功, system()函数会返回执行 command 的 shell 进程的终止状态。
system()的主要优点在于使用上方便简单,编程时无需自己处理对 fork()、 exec 函数、 waitpid()以及 exit()
等调用细节, system()内部会代为处理; 当然这些优点通常是以牺牲效率为代价的,使用 system()运行 shell
命令需要至少创建两个进程,一个进程用于运行 shell、另外一个或多个进程则用于运行参数 command 中解
析出来的命令,每一个命令都会调用一次 exec 函数来执行;所以从这里可以看出,使用 system()函数其效
率会大打折扣,如果我们的程序对效率或速度有所要求,那么建议大家不是直接使用 system()

vfork( )系统调用

1.fork()函数使用场景

fork()函数有以下两种用法:
⚫ 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常
见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork()创建一个子
进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
⚫ 一个进程要执行不同的程序。 譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要
去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2
程序的 main 函数开始运行。这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数
来实现。

2.fork函数的缺点:

在绝大部分情况下,创建的子进程中会调用exec( )函数去加载一个新的程序,然后从这个新的程序的main函数开始运行。

fork函数创建子进程之后,子进程会拷贝父进程的数据段、代码段、堆栈等,将其拷贝到自己的进程空间中,当子进程调用exec( )函数去加载一个新的程序时候,新的程序又会替换掉这些拷贝过来的代码段、数据段、堆栈等,导致之前的拷贝工作白忙一场,所以也导致在这种情况下,fork函数的效率比较低!

3.vfork的引入

最终目的是一样的,都是创建一个进程;并且返回值也是一样的,他们两个都是系统调用。这个函数主要是针对fork函数的缺点而引入的,所以它的使用场景自然也是在子进程中执行exec调用外部的一个新程序,从新程序的main函数开始运行。

4.fork 与vfork 函数之间的主要区别(两点):

1、是否共享地址空间

对于fork函数,fork会为子进程创建一个新的地址空间(也就是进程空间),子进程几乎完全拷贝了父进程,包括代码段、堆、栈等;而对于vfork函数,子进程在终止或者成功调用exec 函数之前,子进程与父进程共享地址空间,共享所有内存,包括数据段、堆栈等,所以在子进程在终止或者成功调用exec函数之前,不要去修改除了vfork的返回值的pid_t类型的变量之外的任何变量(父进程的变量)、也不要调用任何其他函数(除了_exit和exec函数之外的任何其他函数),否则会影响到父进程。(exec调用成功加载新程序之后,父、子进程就分家了,各自运行在自己的地址空间中)

注意:vfork创建的子进程如果要终止应该调用_exit,而不能调用exit或者return 返回,因为如果子进程调用exit或者return终止,则会调用父进程绑定的终止处理函数以及刷新父进程的stdio缓冲,影响到父进程。

2、

对于fork函数,fork 调用之后,父、子进程的执行次序不确定(虽然绝大部分情况下是父进程先运行);而对于vfork函数,vfork函数会保证子进程先运行,父进程此时处于阻塞,挂起状态,在子进程终止或者成功调用exec函数之后,父进程才会被调度运行。

注意:如果子进程在终止或成功调用exec函数之前,依赖于父进程的进一步动作,将会导致死锁!!

进程状态与进程关系

进程状态:

一、进程状态有那些?

进程状态有六种:

运行状态 和 可执行状态(就绪态)、可中断睡眠状态(浅度睡眠)、不可中断睡眠状态(深度睡眠)、停止状态、僵尸状态、死亡状态。

1、(R)运行状态可执行状态(就绪态)

正在运行的进程或者在进程队列中等待运行的进程都处于该状态,该状态实际包括了运行态就绪态两个基本状态。 就绪态的进程,表面他已经处于准备运行状态,一旦的到CPU使用权就会进入到运行态。

2、(S)可中断睡眠状态: 可中断睡眠也称为浅度睡眠,还可以被唤醒,一般来说可以通过信号来唤醒; 譬如等待IO时间、主动调用sleep函数等。一旦资源有效就进入就绪态,当然该状态下的进程也可被信号或者中断唤醒。

3、(D)不可中断睡眠状态: 不可中断睡眠称为深度睡眠,与浅度睡眠的区别深度睡眠无法被信号或者中断唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态) ,表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态;所以,处于等待态的进程是无法参与进程系统调度的 。 一般该状态下的进程正在跟硬件交互,交互过程不允许被其他进程中断。

4、(T)暂停态: 暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP信号;处于暂停态的进程是可以恢复进入到就绪态的,譬如收到 SIGCONT 信号。

5、(Z)僵尸态: 僵尸态进程其实指的就是僵尸进程,指该进程已经结束、但其父进程还未给它“收尸”; 需要父进程回收它的一些资源,归还系统,然后该进程才会从系统中彻底删除

6、(X)死亡状态:该进程非常短暂,ps命令捕捉不到,处于此状态的进程即将被彻底销毁,可以认为就是僵尸进程被回收之后的一种状态。

ps命令查看到的进程状态信息中,除了第一个大写字母用于表示进程当前所处的状态之外,还有一些其他的字符,譬如s、l、N、+、<等

s:表示当前进程是一个会画的首领进程

l:表示当前进程是一个多线程进程

N:表示低优先级

<:表示高优先级

+:表示当前进程处于前台进程组中

二、不同进程状态之间的转换关系

一般情况下,一个新创建的进程处于就绪态,只要得到CPU使用权就会进入运行态。

不同进程状态之间的转换关系图:

在这里插入图片描述

进程之间的关系

两个进程之间的关系:无关系、父子关系、进程组、会话

1、 无关系
两个进程间没有任何关系,相互独立。

2、 父子进程关系

两个进程间构成父子进程关系,譬如一个进程 fork()创建出了另一个进程,那么这两个进程间就构成了父子进程关系,调用 fork()的进程称为父进程、而被 fork()创建出来的进程称为子进程;当然,如果“生父”先与子进程结束,那么 init 进程(“养父”)就会成为子进程的父进程,它们之间同样也是父子进程关系。

子进程ppid(getppid( )) == 父进程的pid (getpid( ))

3、进程组

进程组,也称为作业(job),进程组是一个或多个进程的集合

每个进程除了有一个进程 ID、父进程 ID 之外,还有一个进程组 ID(PGID),用于标识该进程属于哪一个进程组,如果两个进程的PGID相同,表示他们俩在同一个进程组中。 进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系。

进程组的作用:方便系统对进程的管理,对进程进行分类、方便管理、控制,建华多个进程的管理。

管理进程组的注意事项

1、每个进程必定属于某一个进程组、且只能属于一个进程组;
2、 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID;
3、 在组长进程的 ID 前面加上一个负号即是操作进程组;
4、只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关;
5、一个进程组可以包含一个或多个进程,进程组的生命周期从被创建开始,到其内所有进程终止或离开该进程组;
6、默认情况下,新创建的进程会继承父进程的进程组 ID (PGID),子进程与父进程在同一个进程组中。

查看当前所属进程组:

getpgrp()或 getpgid()可以获取进程对应的进程组 ID,其函数原型如下所示:

#include <unistd.h>
pid_t getpgid(pid_t pid);
pid_t getpgrp(void);

首先使用该函数需要包含头文件<unistd.h>。
这两个函数都用于获取进程组 ID, getpgrp()没有参数,返回值总是调用者进程对应的进程组 ID;而对于 getpgid()函数来说,可通过参数 pid 指定获取对应进程的进程组 ID,如果参数 pid 为 0 表示获取调用者进程的进程组 ID。
getpgid()函数成功将返回进程组 ID;失败将返回-1、并设置 errno。
所以由此可知, getpgrp()就等价于 getpgid(0)

修改所属进程组:

调用系统调用 setpgid()或 setpgrp()可以加入一个现有的进程组或创建一个新的进程组

#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
int setpgrp(void); 

setpgid()函数将参数 pid 指定的进程的进程组 ID 设置为参数 gpid。
如果这两个参数相等(pid==gpid),则由 pid 指定的进程变成为进程组的组长进程,创建了一个新的进程;
如果参数 pid 等于 0,则使用调用者的进程 ID;另外,如果参数 gpid 等于 0,则创建一个新的进程组,由参数 pid 指定的进程作为进程组组长进程。

setpgrp()函数等价于 setpgid(0, 0)。 //比如子进程中,调用setpgrp()就是用子进程pid创建一个新的进程组并且作为组长进程。

注意:一个进程只能为它自己或它的子进程设置进程组 ID,在它的子进程调用 exec 函数后,它就不能更改该子进程的进程组 ID 了。

4、会话

会话是一个或多个进程组的集合,其与进程组、进程之间的关系如下图所示 :

在这里插入图片描述

●注意

1、每个进程组必定属于某一个会话,并且只能在一个会话中

2、一个会话包含一个或多个进程组,最多只能有一个前台进程组、其他的都是后台进程组

3、每个会话都有一个会话首领(leader、首领进程),即创建会话的进程(会话的首领进程就是创建该会话的进程)

4、同样每个会话也有ID标识,成为会话ID(简称:SID),每个会话的SID就是会话首领进程的ID(PID)。所以如果两个进程的SID相同,标识他们俩在同一个会话中。在应用程序中调用getsid函数获取进程的SID。

pid_t getsid(pid_t pid);  

如果参数pid设置为0,成功的话返回调用者进程的会话的ID;参数pid设置不为0,返回pid指定的进程对应的会话ID;失败返回-1;

5、会话的生命周期从会话创建开始,直到会话中所有进程组生命周期结束,与会话首领进程是否终止无关

6、一个会话可以有控制终端、也可没有控制终端,在有控制终端 的情况下最多只能连一个控制终端。控制终端与会话中的所有进程 关联、绑定,控制影响会话中所有进程的行为特性,譬如控制终端产生的信号,将会发送给会话中的进程,(譬如:crtl+c 、crtl+z 、crtl +\ 产生的终端信号,停止信号 、退出信号 将发送给前台进程组);

譬如前台进程可以通过终端与用户进行交互、从终端读取用户输入的数据,进程产生的打印信息会通过终端显示出来,譬如当控制终端关闭的时候,会话中的所有进程将被终止

7、当我们在Ubuntu中打开一个终端,那么就创建了一个新的会话(shell进程就是这个会话的首领进程,也就意味着该会话的SID等于shell进程的PID)

8、 默认情况下,新创建的进程会继承父进程的会话ID,子进程和父进程在同一个会话中(也可以说子进程继承了父进程的控制终端)。

●关于 前台和后台的一些操作:

执行程序时,后面添加&使其在后台运行,fg命令将后台进程调至前台继续运行

crtl+z讲一个前台运行的进程调至后台、并处于停止状态(暂停状态)

前台进程组中所有的进程都是前台进程,所以终端产生的信号,它们都会接收到,譬如crtl+c 、crtl+z 、crtl +\ 产生的中断信号(SIGINT 信号) ,停止信号(产生 SIGTSTP 信号) 、退出信号( 产生 SIGQUIT 信号)

●创建新会话:
pid_t setsid(void);  

调用setsid函数可以创建一个新的会话:

1、如果调用该函数的进程不是进程组的组长进程(前提),那么调用该函数会创建一个新的会话,调用该函数的进程会成为新会话的会话首领。

2、调用setsid函数除了创建新的会话之外,也会创建一个新的进程组(因为一个会话至少要存在一个进程组),而调用该函数的进程也是这个进程组的组长进程。

3、调用该函数创建的会话没有控制终端,脱离控制终端(ps命令可以查看进程的控制终端)Linux系统中的守护进程就是没有控制终端的进程

该函数的返回值,在成功情况下,将返回新对话的SID;如果失败将返回-1 并会设置全局变量errno

1.父进程信号处理对子进程影响

fork后子进程会继承父进程绑定的信号处理函数,若调用exec加载新程序后,就不会再继承这个信号处理函数了。

(17_test.c )子进程会不会继承父进程对SIGINT信号处理方式、也就是捕获SIGINT信号,执行信号处理函数(上下文说的继承信号处理函数均是此意)

2.父进程的信号掩码对子进程的影响:

fork后子进程会继承父进程的信号掩码,执行exec后仍会继承这个信号掩码。

守护进程:

定义和特点

守护进程(Daemon) 也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生, 主要表现为以下两个特点:
⚫ 长期运行。 守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行。 与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机。

与控制终端脱离。 在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,也就是会话的控制终端。当控制终端被关闭的时候, 该会话就会退出, 由控制终端运行的所有进程都会被终止, 这使得普通进程都是和运行该进程的终端相绑定的; 但守护进程能突破这种限制,它脱离终端并且在后台运行, 脱离终端的目的为了避免进程在运行的过程中的信息在终端显示并且进程也不会被任何终端所产生的信息所打断

Linux 中大多数服务器就是用守护进程实现的,譬如,系统日志服务进程syslogd、Internet 服务器inetd、 Web 服务器 httpd 等。同时,守护进程完成许多系统任务,譬如作业规划进程 crond 等。

守护进程 Daemon,通常简称为 d,一般进程名后面带有 d 就表示它是一个守护进程。

通过命令"ps -ajx"查看系统所有的进程

如何编写守护进程

守护进程的重点在于 脱离控制终端,但是除了这个关键点之外,还需要注意其他的一些问题,编写守护进程一般包括如下几个步骤:

1、创建自主进程,终止父进程(因为子进程可以调用setsid,父进程不可以)

2、 子进程调用 setsid 创建会话 (key:脱离控制终端

setsid()会使得子进程创建一个新的会话,子进程成为新会话的首领进程,同样也创建了新的进程组、子进程成为组长进程,此时创建的会话将没有控制终端。 所以这里调用 setsid 有三个作用:让子进程摆脱原会话的控制、让子进程摆脱原进程组的控制和让子进程摆脱原控制终端的控制。

3、将工作目录更改为根目录

子进程是继承了父进程的当前工作目录,由于在进程运行中,当前目录所在的文件系统是不能卸载的,这对以后使用会造成很多的麻烦。必须保证守护进程的当前目录在其运行过程中必须存在,因此通常的做法是让“/”作为守护进程的当前目录,当然也可以指定其它目录来作为守护进程的工作目录。

4、重设文件权限掩码 umask

文件权限掩码 umask 用于对新建文件的权限位进行屏蔽,子进程继承了父进程的文件权限掩码,这就给子进程使用文件带来了诸多的麻烦。因此, 把文件权限掩码设置为 0, 确保子进程有最大操作权限、这样可以大大增强该守护进程的灵活性。设置文件权限掩码的函数是 umask,通常的使用方法为 umask(0)

5、关闭不再需要的文件描述符

子进程继承了父进程的所有文件描述符, 这些被打开的文件可能永远不会被守护进程(此时守护进程指的就是子进程,父进程退出、子进程成为守护进程) 读或写,但它们一样消耗系统资源,可能导致所在的文件系统无法卸载,所以必须关闭这些文件, 这使得守护进程不再持有从其父进程继承过来的任何文件描述符。

重点是关闭0(标准输入)、1(标准输出)、2(标准错误)这三个文件描述符,使得守护进程的输出信息不能被终端显示出来。

6、忽略 SIGCHLD 信号

​ 处理 SIGCHLD 信号不是必须的, 但对于某些进程,特别是并发服务器进程往往是特别重要的,服务器进程在接收到客户端请求时会创建子进程去处理该请求,如果子进程结束之后,父进程没有去 wait 回收子进程,则子进程将成为僵尸进程;
​ 如果父进程 wait 等待子进程退出,将又会增加父进程的负担、也就是增加服务器的负担,影响服务器进程的并发性能。
​ 在 Linux 下,可以将 SIGCHLD 信号的处理方式设置为SIG_IGN,也就是忽略该信号,会让内核将僵尸进程转交给 init 进程去处理,这样既不会产生僵尸进程、又
省去了服务器进程回收子进程所占用的时间。

进程间通信

进程通信简介

1.什么是进程间通信?

进程间通信简称IPC(interprocess communication ),进程间通信就是在不同进程之间传递信息或交换信息。

2.进程间通信的目的?

数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源(譬如文件共享)
通知事件:一个进程需要向另一个或者一组进程发送消息,通知他或他们发生了某种事件,比如子进程终止时需要通知其父进程
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有异常,并且能够及时直到它的状态改变。

3.如何实现进程间通信?

​ 由于不同的进程都各自运行在自己的地址空间,这些地址空间相互隔离,一个进程不能读取或修改另一个进程的数据(也就是不能直接访问另一个进程的资源),因此不同的进程之间想要实现通信是比较困难的。

​ 如果想要实现进程间通信,我们需要借助第三方资源。这个第三方资源其实就是公共资源,这个资源不属于任何进程,而是需要进行通信的各个进程之间的公共资源,这些进程都可以访问这个公共资源,比如向公共资源写入或读取数据,从而实现进程之间通信的目的。
因为这个第三方资源是内核提供,所以进程间通信是需要内核参与的。
进程间通信的本质:由OS(操作系统内核)参与,提供一份所有进程都可以访问的公共资源。
公共资源包含:内存块、队列、文件等,所以就出现了多种不同的进程间通信的方法

4.进程间通信的手段有哪些 ?

在这里插入图片描述

4.1管道

管道 是linux系统中最古老的一种进程间通信方法,我们把一个进程连接到另一个进程的数据流称为管道

管道分为匿名管道命名管道

4.1.1匿名管道(也是普通管道)

匿名管道只能用于父子进程间或者具有血缘关系的进程之间通道

管道是一种单向通信的方法,一个进程向管道写入数据、另一个进程从管道读取数据,管道创建之后,就需要确认通信双方的角色,谁作为发送方,谁作为接收方!

在这里插入图片描述

访问管道跟访问文件一样,使用read 读取管道中的数据,使用write向管道中写入数据,需要注意的是,这个文件并不存在于磁盘中,管道中的数据是存在内存中,所以读写管道并不会访问磁盘设备,并且每一个管道会产生两个文件描述符,一个用于管道,一个用于管道。

pipe

int pipe(int pipefd[2])

数组中的第一个文件描述符用于读管道,第二个文件描述符用于写管道。

pipe函数通常和fork函数一起使用,首先调用pipe函数创建匿名管道。然后调用fork函数创建子进程,所以子进程就会继承pipe函数所得到的两个文件描述符,从而父子进程之间实现这个文件共享,也就实现了进程间通信。

注意:必须单向通信,比如父写子读(父:开fd1、关fd0 , 子:开fd0、关fd1),或者父读子写。

特点

1、管道内部有同步、互斥机制

2、管道的生命周期随着进程的终止而终止,因为管道本质上是通过文件的方式进行访问。

3、管道提供的是字节流服务,向管道写入数据或从管道中读取数据的字节大小是任意的,只要不超过管道容量,管道的大小通常是4K。并且管道中的数据是没有什么格式的。(字节流)

4、管道是单向传输的方式,如果要实现双向传输,我们可以创建两个管道。

5、匿名管道只能是在父子进程间或者是具有血缘关系的进程之间进行通信。

4.1.2命名管道

命名管道是有名字的,所以在进行进程间通信之前,需要创建这个管道文件,命名管道文件存在文件系统中,管道文件有自己的名字,只要对这管道文件有进行读写操作,就可以向管道中写入数据或者从管道中读取数据。所以,命名管道可以作为同一台主机上的任意进程之间进行通信

4.2 信号

信号用于通知接收信号的进程有某种事件发生,所以可用于进程间通信;除了用于进程间通信之外,进程还可以发送信号给进程本身

4.3 内存映射

内存映射就是将文件映射到进程的地址空间 ,然后直接通过读写地址的方式去访问这个文件的内容。适用于多个进程之间的文件共享

4.4 消息队列

4.5共享内存

所谓共享内存就是多个进程共享一块内存区域。这块内存区域会映射到各个进程的地址空间,这些进程都可以访问这块内存区域,实现进程间通信。需要程序中去处理同步、互斥的问题

他也是所有的进程间通信方法中最快的一种、效率最高

管道,第二个文件描述符用于写管道。

pipe函数通常和fork函数一起使用,首先调用pipe函数创建匿名管道。然后调用fork函数创建子进程,所以子进程就会继承pipe函数所得到的两个文件描述符,从而父子进程之间实现这个文件共享,也就实现了进程间通信。

注意:必须单向通信,比如父写子读(父:开fd1、关fd0 , 子:开fd0、关fd1),或者父读子写。

特点

1、管道内部有同步、互斥机制

2、管道的生命周期随着进程的终止而终止,因为管道本质上是通过文件的方式进行访问。

3、管道提供的是字节流服务,向管道写入数据或从管道中读取数据的字节大小是任意的,只要不超过管道容量,管道的大小通常是4K。并且管道中的数据是没有什么格式的。(字节流)

4、管道是单向传输的方式,如果要实现双向传输,我们可以创建两个管道。

5、匿名管道只能是在父子进程间或者是具有血缘关系的进程之间进行通信。

4.1.2命名管道

命名管道是有名字的,所以在进行进程间通信之前,需要创建这个管道文件,命名管道文件存在文件系统中,管道文件有自己的名字,只要对这管道文件有进行读写操作,就可以向管道中写入数据或者从管道中读取数据。所以,命名管道可以作为同一台主机上的任意进程之间进行通信

4.2 信号

信号用于通知接收信号的进程有某种事件发生,所以可用于进程间通信;除了用于进程间通信之外,进程还可以发送信号给进程本身

4.3 内存映射

内存映射就是将文件映射到进程的地址空间 ,然后直接通过读写地址的方式去访问这个文件的内容。适用于多个进程之间的文件共享

4.4 消息队列

4.5共享内存

所谓共享内存就是多个进程共享一块内存区域。这块内存区域会映射到各个进程的地址空间,这些进程都可以访问这块内存区域,实现进程间通信。需要程序中去处理同步、互斥的问题

他也是所有的进程间通信方法中最快的一种、效率最高

  • 26
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值