进程和信号
一、进程初识
1、程序的开始和结束
-
开始:编译链接时的引导代码。操作系统下的应用程序其实在main执行前也需要先执行一段引导代码才能去执行main。在程序链接时由链接器将编译器中事先准备好的引导代码给链接进去与我们的代码一起组成最终的可执行程序。当我们执行这个程序时,(例如./a.out,在代码中用exec族函数来运行)加载器负责将这个程序加载到内存去执行,加载器是属于操作系统中的程序。总体来说程序在编译链接过程中使用链接器,在程序运行过程中使用加载器。
-
结束:程序的结束分为正常终止和非正常终止两种方式,正常终止是使用使用 return、exet、_exit 等语句来终止程序。非正常终止是由自己或者他人发信号终止进程。另外可以使用
atexit
注册进程终止函数,在程序结束时执行,进行一些例如数据保存的工作,atexit
可以同时注册多个进程终止函数,先注册的进程终止函数后执行(先进后出)。在程序的正常终止方式中return和exit的效果一样,都会执行进程终止的处理函数,但是使用_exit
终止进程时并不执行atexit注册的进程终止函数。
2、进程环境
每个程序都会收到一张环境表。它是一个字符指针数组,其中每个指针包含一个以 null 结尾的字符串的地址。全局变量environ则包含了该指针数组的地址:extern char **environ;
使用export命令可以查看环境变量,在进程中可以无条件的直接直接使用系统中的这些环境变量。所以程序中一旦使用了环境变量那么程序就和操作系统的环境直接相关了。在程序中可以使用 getenv() 和 putenv() 来访问特定的环境变量。
- 进程的虚拟地址空间
在操作系统中的每个进程都是在独立的地址空间中运行的,在32位系统中每个进程的逻辑地址空间均为4GB(2^32)B,其中内核空间为1GB。01G为内核OS(用作进程管理、内存管理、设备管理和虚拟文件系统等),14G为应用地址空间。虚拟地址到物理地址空间的映射是彼此独立、互不干扰的,这是MMU地址变换必须要去保证的。虚拟地址空间的好处在于方便编译器和操作系统安排程序的地址;方便各进程空间之间的隔离,互不干扰;实现虚拟存储,从逻辑上扩大了内存。
3、进程的一些概念
- 什么是进程
UNIX标准把进程定义为“一个其中运行着一个或者多个线程的地址空间和这些线程做需要的系统资源”。进程是一个动态的过程而不是一个静态的实物,一个静态的可执行程序 a.out 的一次运行过程(./a.out 去运行到结束)这个过程就是一个进程。在linux系统中,进程的管理是通过由进程控制块PCB(process control block)来进行的,进程控制块是内核中专门用来管理进程的一个数据结构。
- 进程ID
进程都会分配一个唯一的数字编号,称之为进程标识符或者PID。常用到的关于进程 ID 的 API 有 getpid、getppid、getuid(用户ID)、geteuid(有效用户ID)、getgid、getegid,其中 gepid 用于获取本进程的 ID ,getppid 用于获取进程的父进程的 id。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t p1 = -1, p2 = -1;
printf("hello.\n");
p1 = getpid(); //获取进程的ID号
printf("pid = %d.\n", p1);
p2 = getppid(); //获取进程的父进程
printf("parent id = %d.\n", p2);
return 0;
}
- 进程的调度原理
操作系统可以同时运行多个进程的程序,这些进程在宏观上的运行是同时进行(并行)的,但是在微观上却是串行的(先后执行)。同一时间只有一个进程可以运行,其他进程处于等待运行的状态,每个进程轮流到的运行时间(时间片)是相当短暂的,所以才给人同时运行的假象。在linux系统中,进程并不是系统调度的最小单位,系统调度的最小单位是线程。
- 使用fork创建子进程
(1) 为什么要创建子进程
在每次运行程序的过程时,我们都会去创建一个进程,在程序开发过程中使用多进程程序可以实现程序宏观上的并行运行。子进程的最终目的就是为了要去运行另外的程序。
(2) fork 创建子进程的内部原理
进程的分裂生长模式,如果操作系统需要一个新的进程来运行一个程序,那么操作系统会用一个现有的进程来复制一个新进程,老进程叫父进程,复制生成的新进程叫子进程。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t p1 = -1;
p1 = fork(); // 返回2次
if (p1 == 0)
{
// 这里一定是子进程
// 先sleep一下让父进程先运行,先死
sleep(1);
printf("子进程, pid = %d.\n", getpid());
printf("hello world.\n");
printf("子进程, 父进程ID = %d.\n", getppid());
}
if (p1 > 0)
{
// 这里一定是父进程
printf("父进程, pid = %d.\n", getpid());
printf("父进程, p1 = %d.\n", p1);
}
if (p1 < 0)
{
// 这里一定是fork出错了
}
// 在这里所做的操作
//printf("hello world, pid = %d.\n", getpid());
return 0;
}
注意:fork函数调用一次会返回2次,返回值等于0的就是子进程,而返回值大于0的就是父进程。典型的使用fork的方法:使用fork后然后用if判断返回值,并且返回值大于0时就是父进程,等于0时就是子进程。sfork的返回值在子进程中等于0,在父进程中等于本次fork创建的子进程的进程ID。
- 子进程与父进程
子进程一般继承父进程:用户信息、权限、目录信息、信号信息、环境表、共享存储段和资源限制。例如:文件描述符表(包含偏移量)是可以共享的。同时子进程会拥有自己独立的PCB,子进程会被内核同等调度。
(1) 父子进程对文件的操作
前提条件:父进程父进程先open打开一个文件得到fd,然后在fork创建子进程。之后在父子进程中各自write向fd中写入内容。
测试结果:接续写。实际上本质原因是父子进程之间的fd对应的文件指针是彼此关联的(很像O_APPEND标志后的样子)
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
// 首先打开一个文件
int fd = -1;
pid_t pid = -1;
fd = open("1.txt", O_RDWR | O_TRUNC);
if (fd < 0)
{
perror("open");
return -1;
}
// fork创建子进程
pid = fork();
if (pid > 0)
{
// 父进程中
printf("parent.\n");
write(fd, "hello", 5);
sleep(1);
}
else if (pid == 0)
{
// 子进程
printf("child.\n");
write(fd, "world", 5);
sleep(1);
}
else
{
perror("fork");
exit(-1);
}
close(fd);
return 0;
}
总结:父子进程总会有一些联系,父进程在没有fork之前自己做的事情对子进程有很大影响,但是父进程fork之后在自己的if里做的事情就对子进程没有影响了。本质原因就是因为fork内部实际上已经复制父进程的PCB生成了一个新的子进程,并且fork返回时子进程已经完全和父进程脱离并且独立被OS调度执行。
二、进程的创建和消亡
1、进程的创建
通过以上我们已经知道了子进程可以由父进程创建,那么linux的第一个进程是什么的。这里就先介绍一下在linux系统中的几个特殊进程,进程0、进程1、进程2。
-
进程0、进程1、进程2
进程0: 进程0也叫做空闲进程,它不是一个用户进程,进程0是一个内核进程,它的主要作用是进入一个死循环,在内核中它表现为一个函数idle,因为不可能让cpu像人一样没事干就歇着,只要晶振起振,它就必须不停的工作,而进程0就是为了解决cpu空闲时刻的问题,所做的工作就是在cpu空闲时给cpu一个死循环从而使cpu工作。
它的特殊性在于它是系统创建的第一个进程,并且还是唯一一个没有通过kernel_thread以及它所创建的子类进程所创建的进程,在进程调度中起着重要作用。
进程1:我们的进程1也叫做init进程,它拥有两种不同的状态,是通过kernel_thread创建的进程,开始时它在内核态所做的工作就是挂载根文件系统,将根文件系统挂载上之后,就开始寻找我们用户态下的一个程序,通常这个程序叫做linuxrc,只要运行了这个程序,我们就算是进入了用户态了,并且在机器未关闭或复位时不能再返回内核态。运行这个程序之后我们会进入用户交互界面,可以进行输入登录密码以及shell命令行等操作。
进程1的最主要的作用就是对我们操作系统来说,其他所有的用户进程都是由进程1直接或间接创建的,也就是说所有的用户进程都是进程1的子孙进程。
进程2:在内核源码中,kthreadd函数就是所谓的进程2,它是我们内核的守护进程,可以管理和调度其他内核线程。
fork创造的子进程是父进程的完整副本,复制了父亲进程的资源,包括内存的内容task_struct内容,vfork创建的子进程与父进程共享数据段,而且由vfork()创建的子进程将先于父进程运行
2、进程的消亡
- 进程终止
进程的终止和程序一样分为正常终止和异常终止,进程和程序的区别在于进程是一个动态的过程,而程序是一个实实在在的实物,程序是存储在硬盘中的一段代码,是实体。进程在程序运行时需要消耗系统资源(内存、IO),进程终止时应该完全释放这些资源,如果进程消亡后仍没有释放这些资源则这些资源就会丢失了。
inux系统设计师规定,每一个进程在退出时,操作系统就会自动回收这个进程所涉及到的的所有的资源(譬如malloc申请的内容没有free时,当前进程结束时这个内存会被释放,譬如open打开的文件没有close的在程序终止时也会被关闭)。但是操作系统只是回收了这个进程工作时消耗的内存和IO,而并没有回收这个进程本身占用的内存(8KB,主要是task_struct和栈内存)
因为进程本身的8KB内存操作系统不能回收需要别人来辅助回收,因此我们每个进程都需要一个帮助它收尸的人,这个人就是这个进程的父进程。
- 僵尸进程
子进程先于父进程结束。子进程结束后父进程此时并不一定立即就能帮子进程“收尸”,在这一段子进程已经结束且父进程尚未帮其收尸的时间范围内子进程就被成为僵尸进程。此时子进程除task_struct和栈外其余内存空间皆已清理。子进程处于僵尸进程时,父进程可以使用wait或waitpid以显式回收子进程的剩余待回收内存资源并且获取子进程退出状态。或者父进程可以不使用wait或者waitpid回收子进程,此时父进程结束时一样会回收子进程的剩余待回收内存资源。(这样设计是为了防止父进程忘记显式调用wait/waitpid来回收子进程从而造成内存泄漏)
- 孤儿进程
父进程先于子进程结束,子进程成为一个孤儿进程。linux系统规定:所有的孤儿进程都自动成为一个特殊进程(进程1,也就是init进程)的子进程。
三、父进程wait回收子进程
作用:父进程用以回收子进程。
函数原型:pid_t wait (int * status);
wait函数属于系统调用,查阅需要用 man 2 wait
1、wait的工作原理
-
工作原理
在linux系统中,一个进程结束时,系统回向其父进程发送SIGCHILD信号。父进程会根据这个信号去判断子进程是否结束。父进程调用wait函数后会被阻塞,然后等待子进程结束后被SIGCHILD信号唤醒然后去回收僵尸子进程。SIGCHILD信号解决的是父子进程之间的异步问题,由于父子进程之间是异步的,所以他们互相都不知道对方的状态,这时候就需要发送SIGCHILD信号来通知父进程对结束的僵尸子进程进行回收。若父进程没有子进程,那么在调用wait函数时会直接报错。
-
wait函数
参数为输出型参数(未加const)代表的是进程的结束状态,父进程通过wait得到status后就可以知道子进程的一些结束状态信息。wait的返回值pid_t,这个返回值就是本次wait回收的子进程的PID。当前进程有可能有多个子进程,wait函数阻塞直到其中一个子进程结束wait就会返回,wait的返回值就可以用来判断到底是哪一个子进程本次被回收了。
总结:wait主要是用来回收子进程资源,回收同时还可以得知被回收子进程的pid和退出状态。
2、wait编程实战
-
几个重要的宏定义:WIFEXITED、WIFSIGNALED、WEXITSTATUS 用来获取子进程的退出状态。
- WIFEXITED宏用来判断子进程是否正常终止(return、exit、_exit退出)
- WIFSIGNALED宏用来判断子进程是否非正常终止(被信号所终止)
- WEXITSTATUS宏用来得到正常终止情况下的进程返回值的。
-
fork后wait回收实例
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t pid = -1;
pid_t ret = -1;
int status = -1;
pid = fork();
if(pid > 0)
{
printf("parint\n");
// 父进程
ret = wait(&status);
printf("子进程已经被回收 子进程pid = %d.\n",ret);
printf("子进程是否正常退出 %d. \n",WIFEXITED(status));
printf("子进程是否非正常退出 %d. \n",WIFSIGNALED(status));
printf("子进程正常终止的终止值是 %d. \n",WEXITSTATUS(status));
}
else if( pid == 0 )
{
sleep(1);
// 子进程
printf("child pid = %d.\n",getpid());
return 123;
}
else
{
perror("fork");
return -1;
}
return 0;
}
parint
child pid = 3138.
子进程已经被回收 子进程pid = 3138.
子进程是否正常退出 1.
子进程是否非正常退出 0.
子进程正常终止的终止值是 123.
3、waitpid介绍
作用:父进程用以回收子进程。
函数原型:pid_t waitpid(pid_t pid, int *status, int options);
-
使用waitpid实现wait的效果
ret = waitpid(-1, &status, 0); -1表示不等待某个特定PID的子进程而是回收任意一个子进程,0表示用默认的方式(阻塞式)来进行等待,返回值ret是本次回收的子进程的PID。
-
ret = waitpid(pid, &status, 0);
等待回收PID为pid的这个子进程,如果当前进程并没有一个ID号为pid的子进程,则返回值为-1;如果成功回收了pid这个子进程则返回值为回收的进程的PID。
-
ret = waitpid(pid, &status, WNOHANG);
这种表示父进程要非阻塞式的回收子进程。此时如果父进程执行waitpid时子进程已经先结束等待回收则waitpid直接回收成功,返回值是回收的子进程的PID;如果父进程waitpid时子进程尚未结束则父进程立刻返回(非阻塞),但是返回值为0(表示回收不成功)。
四、进程状态、关系
1、进程状态
进程一共有五种状态,具体分为就绪态、运行态、僵尸态、等待态、和暂停态。五种状态之间的转换图如下图。
- 就绪态:这个进程当前所有运行条件就绪,只要得到了CPU时间就能直接运行。
- 运行态:就绪态时得到了CPU就进入运行态开始运行。
- 僵尸态:进程已经结束但是父进程还没来得及回收。
- 等待态(浅度睡眠&深度睡眠):进程在等待某种条件,条件成熟后可进入就绪态。等待态下就算你给他CPU调度进程也无法执行。浅度睡眠等待时进程可以被(信号)唤醒,而深度睡眠等待时不能被唤醒只能等待的条件到了才能结束睡眠状态。
- 暂停态:暂停并不是进程的终止,只是被被人(信号)暂停了,还可以回复的。
2、进程间的关系
-
无关系
-
父子进程关系
-
进程组(group)
一个或多个进程的集合。通常,它们与一作业相关联,可以接收来自同一终端的信号。每个进程组有唯一的进程组ID。每个进程有一个组长进程,其进程ID就是进程组ID。
组长进程可以创建一个进程组,创建该组中的进程,然后终止。只要进程组中有一个进程存在,进程组就一直存在,与其组长是否存在无关。 -
会话(Session)
是一个或多个进程组的集合。一个会话可以有一个控制终端。这通常是登录到其上的终端设备(终端登录的情况下)或伪终端设备(网络登录的情况下)。建立与控制终端连接的会话首进程称为控制进程。一个会话中的几个进程组可被分为一个前台进程组和任意多个后台进程组。所以一个会话中应包含一个控制进程(会话首进程)、一个前台进程组合任意多个后台进程。
五、linux的进程间通信
1、进程间通信概述
同一个进程在一个地址空间中(0~4GB虚拟空间),所以同一进程之间的不同模块(函数、文件)之间都是很简单的,之间的数据交互都是利用全局变量、或者函数的形参等方式传递,进程间通信指(IPC)的是两个任意的进程之间的通讯,其难点在于两个进程位于不同的虚拟地址空间,彼此完全隔离,互相感觉不到对方的存在,所以要相互通信就很困难。
大部分程序是不需要进程间通信的,因为大部分程序都是属于单进程的(可以有多线程),只有一些大型的、比较复杂的程序才需要考虑使用进程间通信,程序设计为多个进程协同工作,例如一些服务器和GUI程序等。
2、进程通讯的几种方式
linux内核提供了多种进程间通讯的机制,这些机制是有些是自身独有的,有些从其它系统中借鉴而来的,linux是集大成者。这些机制分别有一下几种。
- 无名管道和有名管道
- SystemV IPC:信号量、消息队列、共享内存
- Socket域套接字
- 信号
注意:进程间通讯在日常的工作过程中使用的很少,因为其难度较高通讯也比较复杂,属于是linux应用编程中难度最大的部分。在平时的面试过程中也考察较少。可以在用到时在进行深入研究。
1、linux的IPC机制1-管道
一般我们说管道都指的是无名管道。
- 无名管道
-
管道通信的原理:内核维护的一块内存,有读端和写端(管道是单向通信的)
-
管道通信的方法:父进程创建管理后fork子进程,子进程继承父进程的管道fd
-
管道通信的限制:只能在父子进程间通信、半双工
-
管道通信的函数:pipe(系统调用API)、write、read、close
- 有名管道(fifo)
- 有名管道的原理:实质也是内核维护的一块内存,表现形式为一个有名字的文件
- 有名管道的使用方法:固定一个文件名,2个进程分别使用mkfifo创建fifo文件,然后分别open打开获取到fd,然后一个读一个写
- 管道通信限制:半双工(注意不限父子进程,任意2个进程都可)
- 管道通信的函数:mkfifo、open、write、read、close
2、SystemV IPC介绍
- SystemV IPC的基本特点
- 系统通过一些专用API来提供SystemV IPC功能
- 分为:信号量、消息队列、共享内存
- 其实质也是内核提供的公共内存
- 消息队列
- 本质上是一个队列,队列可以理解为(内核维护的一个)FIFO
- 工作时A和B2个进程进行通信,A向队列中放入消息,B从队列中读出消息。
- 信号量
- 实质就是个计数器(其实就是一个可以用来计数的变量,可以理解为int a)
- 通过计数值来提供互斥和同步
- 共享内存
- 大片内存直接映射
- 类似于LCD显示时的显存用法
3、其它的IPC方式
- 信号。
- Unix域套接字 socket (可以看做不同主机上的连个进程之间的通信)。
六、exec族函数
fork是为了创建子进程执行新的程序,fork在创建子进程后,子进程和父进程会同时被操作系统调度执行,这两个进程在宏观上是并行的。子进程和父进程位于同一个文件属于同一个可执行程序。而用exec可以运行新的可执行程序。
再有用exec族函数后,典型的父子进程之间的程序是,子进程需要的程序被单独编写、单独编译链接成一个可执行程序。主进程为父进程。fork创建了子进程后在子进程中exec来执行单独编写的程序,来达到父子进程分别做不同程序同时(宏观上)运行的效果。
1、exec族的6个函数
- execl和execv
这两个函数是最基本的exec,都可以用来执行一个程序,区别是传参的格式不同。execl是把参数列表(本质上是多个字符串,必须以NULL结尾)依次排列而成(l其实就是list的缩写),execv是把参数列表事先放入一个字符串数组中,再把这个字符串数组传给execv函数。
- execlp和execvp
这两个函数在上面2个基础上加了p,较上面2个来说,区别是:上面2个执行程序时必须指定可执行程序的全路径(如果exec没有找到path这个文件则直接报错),而加了p的传递的可以是file(也可以是path,只不过兼容了file。加了p的这两个函数会首先去找file,如果找到则执行执行,如果没找到则会去环境变量PATH所指定的目录下去找,如果找到则执行如果没找到则报错)
- execle和execvpe
这两个函数较基本exec来说加了e,函数的参数列表中也多了一个字符串数组envp形参,e就是environment环境变量的意思,和基本版本的exec的区别就是:执行可执行程序时会多传一个环境变量的字符串数组给待执行的程序。
如果用户在执行这个程序时没有传递第三个参数,则程序会自动从父进程继承一份环境变量(默认的,最早来源于OS中的环境变量);如果我们exec的时候使用execlp或者execvpe去给传一个envp数组,则程序中的实际环境变量是我们传递的这一份(取代了默认的从父进程继承来的那一份)
2、exec族函数实战
执行ls -a -l
命令和 自己的可执行程序 hello。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
extern char **environ;
int main()
{
pid_t pid = -1;
pid_t ret = -1;
int status = -1;
pid = fork();
if(pid > 0)
{
printf("子进程pid = %d.\n",pid);
sleep(4);
}
else if( pid == 0 )
{
char * const arg[] = {"ls","-l","-a","/",NULL};
char * const arg2[] = {"hello",NULL};
//execv("/bin/ls",arg);
//execl("/bin/ls","ls","-l","-a","/",NULL);
execl("hello","hello",NULL);
sleep(1);
execv("hello",arg2);
return 0;
}
else
{
perror("fork");
return -1;
}
return 0;
}
七、守护进程
八、使用syslog来记录调试信息
关键点在于openlog、syslog、closelog
这几个函数,以及函数的参数,可以通过man手册查看。一般log信息都在操作系统的/var/log/messages这个文件中存储着,但是ubuntu中是在/var/log/syslog文件中的。
- syslog的工作原理
- 操作系统中有一个守护进程syslogd(开机运行,关机时才结束),这个守护进程syslogd负责进行日志文件的写入和维护。
- syslogd是独立于我们任意一个进程而运行的。我们当前进程和syslogd进程本来是没有任何关系的,但是我们当前进程可以通过调用openlog打开一个和syslogd相连接的通道,然后通过syslog向syslogd发消息,然后由syslogd来将其写入到日志文件系统中。
- syslogd其实就是一个日志文件系统的服务器进程,提供日志服务。任何需要写日志的进程都可以通过openlog/syslog/closelog这三个函数来利用syslogd提供的日志服务。这就是操作系统的服务式的设计。
九、让程序不能被多次运行
1、问题
- 因为守护进程是长时间运行而不退出,因此./a.out执行一次就有一个进程,执行多次就有多个进程。
- 这样并不是我们想要的。我们守护进程一般都是服务器,服务器程序只要运行一个就够了,多次同时运行并没有意义甚至会带来错误。
- 因此我们希望我们的程序具有一个单例运行的功能。意思就是说当我们./a.out去运行程序时,如果当前还没有这个程序的进程运行则运行之,如果之前已经有一个这个程序的进程在运行则本次运行直接退出(提示程序已经在运行)。
2、实现方法:
- 最常用的一种方法就是:用一个文件的存在与否来做标志。具体做法是程序在执行之初去判断一个特定的文件是否存在,若存在则标明进程已经在运行,若不存在则标明进程没有在运行。然后运行程序时去创建这个文件。当程序结束的时候去删除这个文件即可。
- 这个特定文件要古怪一点,确保不会凑巧真的在电脑中存在的。